حالت تاریک
فصل ۱۰ — جدول امتیاز بازی real-time (Real-time Gaming Leaderboard)
خلاصهٔ تحلیلی — این متن چکیدهٔ مفاهیم فصل به زبان فارسی است، نه ترجمهٔ واژهبهواژه.
گام ۱ — نیازمندیها و برآورد
سیستم امتیازدهی ساده: هر برد یک امتیاز. مسابقات ماهانه است (هر ماه leaderboard جدید). نیازمندیها: نمایش ۱۰ نفر برتر، رتبهٔ یک کاربر مشخص، و (امتیازی) چهار بازیکن بالاتر و پایینتر از او. امتیاز کاربران همامتیاز یکسان است و بهروزرسانی باید real-time باشد نه دستهای.
مقیاس: ۵ میلیون DAU و ۲۵ میلیون MAU؛ هر بازیکن ~۱۰ مسابقه در روز. برآورد: میانگین ~۵۰ کاربر بر ثانیه، پیک ۵ برابر؛ QPS ثبت امتیاز ~۵۰۰ (پیک ۲۵۰۰)؛ QPS خواندن top-10 ~۵۰.
گام ۲ — طراحی سطح بالا
API ها
POST /v1/scores— ثبت امتیاز پس از برد؛ API داخلی که فقط game server صدا میزند (کلاینت نباید مستقیم امتیاز بفرستد — در برابر man-in-the-middle امن نیست).GET /v1/scores— ده نفر برتر.GET /v1/scores/{user_id}— رتبهٔ یک کاربر.
معماری
دو سرویس: game service (اعتبارسنجی برد) و leaderboard service (بهروزرسانی/خواندن leaderboard store). صف پیام (Kafka) بین آنها تنها وقتی لازم است که امتیازها مصرفکنندههای متعددی داشته باشند (تحلیل، اعلان، بازی چندنفره)؛ اینجا نیازی نیست.
چرا دیتابیس رابطهای شکست میخورد
جدول leaderboard(user_id, score) با INSERT/UPDATE کار میکند، اما یافتن رتبه مستلزم مرتبسازی کل جدول (یا کوئری تو در توی COUNT) روی میلیونها ردیفِ دائم در حال تغییر است — دهها ثانیه طول میکشد، کش هم بهخاطر تغییر مداوم بیفایده است. ایندکس + LIMIT فقط top-10 را نجات میدهد، نه رتبهٔ کاربر دلخواه را.
راهحل Redis: sorted set
Sorted set دقیقاً برای این مسئله ساخته شده: اعضای یکتا با score، پیادهسازی داخلی با hash table (کاربر←امتیاز) + skip list (امتیاز←کاربر مرتبشده). skip list یک لیست پیوندی مرتب با ایندکسهای چندسطحی است که مثل جستوجوی دودویی، درج/حذف/جستوجو را به O(log n) میرساند.
عملیات کلیدی:
| عملیات | دستور | پیچیدگی |
|---|---|---|
| ثبت/افزایش امتیاز | ZINCRBY leaderboard_feb 1 user | O(log n) |
| ده نفر برتر | ZREVRANGE ... 0 9 WITHSCORES | O(log n + m) |
| رتبهٔ کاربر | ZREVRANK ... user | O(log n) |
| ۴ نفر بالا/پایین | ZREVRANGE حول رتبهٔ کاربر | O(log n + m) |
هر ماه sorted set جدیدی ساخته و قبلی به آرشیو منتقل میشود.
ظرفیت: بدترین حالت ۲۵M عضو × ۲۶ بایت ≈ ۶۵۰MB (حتی با دوبرابر سربار، یک نود Redis کافی است)؛ پیک ۲۵۰۰ QPS هم مشکلی نیست. برای پایداری: read replica و promote هنگام خرابی. کنار Redis دو جدول MySQL (user و point با timestamp هر برد) نگه میداریم — هم برای تاریخچهٔ بازی و هم بازسازی leaderboard پس از خرابی.
گام ۳ — بررسی عمیق
استقرار: خودمیزبان یا ابری
- خودمیزبان: sorted set ماهانه + MySQL برای پروفایل کاربر + کش پروفایل top-10.
- Serverless (AWS): API Gateway ← Lambda (سه تابع متناظر با سه API) ← Redis/MySQL. مزیت: auto-scaling بدون مدیریت سرور؛ برای بازیهای از-صفر توصیه میشود.
مقیاسدهی Redis (سناریوی ۵۰۰M DAU)
با صد برابر شدن: ~۶۵GB داده و ۲۵۰هزار QPS ← نیاز به شاردینگ:
- پارتیشن ثابت (بر اساس بازهٔ امتیاز): مثلاً ۱۰ شارد هر یک ۱۰۰ امتیاز (1–100، 101–200، …). top-10 مستقیم از شارد بالایی میآید؛ رتبهٔ کاربر = رتبهٔ محلی + مجموع اعضای شاردهای بالاتر (
info keyspaceبا O(1)). چالشها: توزیع یکنواخت امتیازها، نگهداری نگاشت کاربر←امتیاز (کش ثانویه) و جابهجایی کاربر بین شاردها هنگام عبور از مرز بازه. - پارتیشن هش (Redis Cluster): هر کلید به یکی از ۱۶۳۸۴ hash slot میرود (
CRC16(key) % 16384). بهروزرسانی ساده است اما top-10 نیازمند scatter-gather از همهٔ شاردهاست: تأخیر با k بزرگ و تعداد پارتیشن زیاد بالا میرود (منتظر کندترین شارد) و راه سرراستی برای رتبهٔ یک کاربر ندارد.
جمعبندی کتاب: پارتیشن ثابت ترجیح دارد. برای سایز نود، به برنامههای write-heavy دو برابر حافظه بدهید (snapshot) و با redis-benchmark بسنجید.
راهحل جایگزین: NoSQL (DynamoDB)
معیار انتخاب: بهینه برای نوشتن + مرتبسازی کارآمد درون پارتیشن. جدول denormalize (کاربر + امتیاز + leaderboard). ایندکس ثانویهٔ سراسری (GSI) با partition key = game#{year-month} و sort key = score، اما ماه جاری hot partition میشود ← الگوی write sharding: افزودن شمارهٔ پارتیشن به کلید (chess#2020-02#p0). خواندن top-10 دوباره scatter-gather بین پارتیشنها میشود؛ تعداد پارتیشن مبادلهٔ بار-پیچیدگی است و باید benchmark شود.
رتبهٔ دقیق در این طرح سخت است؛ جایگزین عملگرایانه: صدک (percentile). یک cron job توزیع امتیاز شاردها را تحلیل و کش میکند تا بگوییم کاربر مثلاً در ۱۰٪ برتر است — در مقیاس واقعی برای کاربر معنادارتر از «رتبهٔ ۱٬۲۰۰٬۰۰۱» است.
نکات تکمیلی
- Redis Hash برای دو کار: کش شیء کاربر (واکشی سریعتر از DB) و شکستن تساوی — نگهداری timestamp آخرین برد؛ در تساوی، قدیمیتر بالاتر میایستد.
- بازیابی از خرابی: چون MySQL هر برد را با timestamp دارد، اسکریپتی میتواند با پیمایش رکوردها و
ZINCRBYبه ازای هر برد، leaderboard را آفلاین بازسازی کند.
نکات کلیدی فصل
- «رتبه» عملیاتی است که RDBMS در دادهٔ پرتغییر از پس آن برنمیآید؛ sorted set (hash + skip list) آن را O(log n) میکند.
- امتیاز باید سمت سرور ثبت شود؛ کلاینت هرگز منبع حقیقت امتیاز نیست.
- Redis منبع حقیقت نیست — دیتابیس رابطهای پشتیبان، مسیر بازسازی پس از فاجعه است.
- شاردینگ رتبهبندی سراسری را سخت میکند: پارتیشن ثابت رتبه را حفظ میکند، پارتیشن هش/GSI به scatter-gather و در نهایت به «صدک بهجای رتبهٔ دقیق» میرسد.
یادداشتهای مترجم
- sorted set، skip list، scatter-gather، write sharding و hot partition لاتین حفظ شدهاند.