طراحی لیدربورد و سیستم‌های رتبه‌بندی با Redis Sorted Set

برای این‌که Sorted Set را درست بفهمیم، لازم است خیلی خلاصه بدانیم Set و Hash در Redis چه کاری می‌کنند؛ چون ZSET عملاً از دل همین دو مفهوم بیرون آمده.

Redis Set چیست؟

Set در Redis یک مجموعه از رشته‌های یکتاست.

  • هر عضو فقط یک‌بار می‌تواند وجود داشته باشد

  • ترتیب ندارد

  • فقط می‌توانی بپرسی «هست یا نیست؟»

Set برای سناریوهایی خوب است که:

  • تکراری بودن مهم نیست

  • فقط عضویت مهم است، نه مقدار یا رتبه

مثلاً:

  • لیست کاربران آنلاین

  • لیست آی‌دی‌هایی که قبلاً کاری را انجام داده‌اند

اما همین‌جا محدودیتش معلوم می‌شود:
نه ترتیبی دارد، نه عددی، نه مفهومی از رتبه.

مثال Set: فقط «هست یا نیست»

فرض کن می‌خوای بفهمی کدوم کاربرها الان آنلاین‌اند.

ذهنی که پشت داده است

ما فقط اینو می‌خوای:

  • user_42 آنلاین هست یا نه؟

  • user_99 آنلاین هست یا نه؟

هیچ امتیاز، ترتیب یا اطلاعات اضافه‌ای مهم نیست.

داده در Redis چطور ذخیره می‌شود

SADD online_users user_42
SADD online_users user_99
SADD online_users user_7

تو چطور می‌بینیش

SMEMBERS online_users

خروجی:

user_7
user_42
user_99

نکته مهم:
ترتیب هیچ معنایی ندارد. Redis عمداً ترتیب را به تو تضمین نمی‌دهد.

اگر بپرسی:

SISMEMBER online_users user_42

خیلی سریع جواب می‌گیری: بله یا خیر.

Redis Hash چیست؟

Hash شبیه یک دیکشنری یا آبجکت است:

  • یک کلید اصلی داری

  • داخلش مجموعه‌ای از field → value نگه می‌داری

Hash برای این عالی است که:

  • اطلاعات مرتبط یک موجودیت را کنار هم نگه داری

  • به هر فیلد جداگانه دسترسی داشته باشی

مثلاً:

  • پروفایل کاربر (name، level، xp، status)

  • تنظیمات یک بازی یا سرویس

اما Hash هم یک ضعف جدی دارد:

هیچ مفهومی از مرتب‌سازی بین چند عضو مختلف ندارد.

مثال Hash: یک موجودیت با چند ویژگی

حالا فرض کن می‌خوای پروفایل یک کاربر را ذخیره کنی.

ذهنی که پشت داده است

ما یک کاربر داریم با چند ویژگی:

  • نام

  • لول

  • امتیاز کلی

همه این‌ها به یک موجودیت تعلق دارند.

داده در Redis چطور ذخیره می‌شود

HSET user:42 name "Ali" level 7 totalScore 1850

تو چطور می‌بینیش

HGETALL user:42

خروجی:

name
Ali
level
7
totalScore
1850

یا اگر فقط یکی از فیلدها مهم باشد:

HGET user:42 level

اینجا Hash عالی است،
اما هیچ راهی نداری بگویی:

«کدام کاربر امتیازش بالاتر است؟»

حالا چرا Sorted Set به‌وجود آمده؟

اینجا Redis می‌گوید:

  • از Set، یکتایی اعضا را بگیر

  • از Hash، نگاشت عضو به مقدار را بگیر

  • و یک چیز اضافه کن که هیچ‌کدام ندارند: ترتیب دائمی

نتیجه می‌شود Sorted Set:

  • هر عضو یکتا است (مثل Set)

  • هر عضو یک عدد دارد (مثل Hash)

  • و کل مجموعه همیشه مرتب است

نکته‌ای که معمولاً نادیده گرفته می‌شود

اگر جایی داری:

  • Set + سورت در کد

  • یا Hash + مرتب‌سازی دستی

در واقع داری کاری می‌کنی که Redis از قبل برایش ساخته شده، ولی استفاده‌اش نمی‌کنی.

این نه بهینه است، نه مقیاس‌پذیر، و نه تمیز.

مثال Sorted Set: رتبه‌بندی واقعی

حالا می‌رسیم به لیدربورد.

ذهنی که پشت داده است

ما می‌خواهیم:

  • هر کاربر یک امتیاز داشته باشد

  • بتوانیم بفهمیم چه کسی بالاتر است

  • سریع ۱۰ نفر اول را ببینیم

داده در Redis چطور ذخیره می‌شود

ZADD leaderboard 1850 user_42
ZADD leaderboard 2100 user_7
ZADD leaderboard 1600 user_99

اینجا:

  • score = امتیاز

  • member = شناسه‌ی کاربر

تو چطور می‌بینیش

۱۰ نفر اول:

ZREVRANGE leaderboard 0 9 WITHSCORES

خروجی:

user_7
2100
user_42
1850
user_99
1600

Redis بدون این‌که ازش بخواهی سورت کند،
از اول داده را مرتب نگه داشته.

مثال امتیاز مساوی (جایی که خیلی‌ها اشتباه می‌کنند)

فرض کن دو نفر امتیاز برابر دارند:

ZADD leaderboard 2000 user_20
ZADD leaderboard 2000 user_3

Redis چه کار می‌کند؟

چون score برابر است، می‌رود سراغ اسم‌ها.

از نظر لغوی:

user_20 > user_3

پس ترتیب همیشه ثابت است و لیدربورد «به هم نمیریزد».

تعریف ساده

یک Redis Sorted Set مجموعه‌ای از رشته‌های یکتا (members) است که هر کدام یک امتیاز عددی (score) دارند و همیشه به‌صورت مرتب‌شده نگه‌داری می‌شوند.

نکته‌ی کلیدی اینجاست:

مرتب‌بودن، ویژگی ذاتی داده است، نه نتیجه‌ی کوئری.

یعنی Redis از لحظه‌ی ذخیره‌سازی، ترتیب را حفظ می‌کند؛ نه این‌که هر بار موقع خواندن، سورت کند.

Sorted Set دقیقاً چه مشکلی را حل می‌کند؟

1. لیدربورد (Leaderboard)

واضح‌ترین و مهم‌ترین کاربرد.

  • امتیاز هر کاربر = score

  • شناسه‌ی کاربر = member

  • گرفتن ۱۰ نفر اول؟ → O(log N)

  • آپدیت امتیاز؟ → O(log N)

برای یک بازی آنلاین با میلیون‌ها کاربر، این یعنی نجات پروژه.

2. Rate Limiting (محدودسازی درخواست)

با ZSET می‌توانی Sliding Window Rate Limiter بسازی:

  • هر درخواست = یک timestamp به‌عنوان score

  • حذف درخواست‌های قدیمی

  • شمردن درخواست‌های بازه‌ی زمانی

بدون نیاز به دیتابیس سنگین یا لاک پیچیده.

Sorted Set ترکیب کدام ساختارهاست؟

می‌تونی Sorted Set رو این‌طوری تصور کنی:

  • از Set:

    • اعضا یکتا هستند

    • هیچ عضوی تکراری نیست

  • از Hash:

    • هر عضو به یک مقدار (score) مپ شده

اما چیزی که هیچ‌کدام ندارند:

مرتب‌سازی دائمی و ذاتی

قانون مرتب‌سازی در Sorted Set

Redis برای مرتب‌سازی دو قانون خیلی شفاف دارد:

قانون اول: امتیاز (Score)

اگر دو عضو score متفاوت داشته باشند:

A > B  اگر  A.score > B.score

یعنی امتیاز بالاتر = رتبه بالاتر.

قانون دوم: ترتیب لغوی (Lexicographical)

اگر دو عضو امتیاز یکسان داشته باشند:

A > B  اگر  A.member  از نظر لغوی بزرگ‌تر از  B.member باشد

مثلاً:

score = 100
"user_20" > "user_3"

نکته مهم:

  • چون اعضا یکتا هستند، دو رشته‌ی کاملاً یکسان وجود ندارد

  • این قانون باعث می‌شود ترتیب همیشه deterministic باشد

چرا این جزئیات مهم‌اند؟

چون در سیستم واقعی:

  • امتیازها مساوی می‌شوند

  • هزاران کاربر score برابر دارند

  • اگر ترتیب مشخص نباشد، UI شما «به هم میریزه»

  • کاربر حس بی‌عدالتی می‌گیرد

Redis این مشکل را در سطح ساختار دیتا حل کرده، نه در کد شما.

یک مثال ساده: امتیازدهی به راننده‌ها با Redis Sorted Set

بیایم همه‌چیز رو با یک مثال خیلی ساده شروع کنیم:
فرض کن چند راننده داریم و هرکدوم توی اولین مسابقه یک امتیاز گرفته‌اند.
هدف ما اینه که این امتیازها رو ذخیره کنیم و هر لحظه بتونیم رتبه‌بندی‌شون رو ببینیم.

اضافه‌کردن داده‌ها به Sorted Set

برای این کار از دستور ZADD استفاده می‌کنیم.

ZADD racer_scores 10 "Norem"
ZADD racer_scores 12 "Castilla"
ZADD racer_scores 8 "Sam-Bodden" 10 "Royce" 6 "Ford" 14 "Prickett"

چند نکته‌ی خیلی مهم همین‌جا وجود دارد که معمولاً نادیده گرفته می‌شود:

  • ZADD شبیه SADD است، اما قبل از هر عضو یک score می‌گیرد

  • این دستور variadic است؛ یعنی می‌توانی چند score–member را یک‌جا اضافه کنی

  • مقدار برگشتی نشان می‌دهد چند عضو جدید اضافه شده‌اند (نه این‌که چند تا دستور اجرا شده)

از همین لحظه، Redis داده‌ها را مرتب‌شده نگه می‌دارد.

دیدن لیست مرتب‌شده‌ی راننده‌ها

حالا بدون هیچ سورت اضافه‌ای، می‌توانیم لیست را بگیریم.

ZRANGE racer_scores 0 -1

خروجی:

Ford
Sam-Bodden
Norem
Royce
Castilla
Prickett

این خروجی از کمترین امتیاز به بیشترین است.
چرا؟ چون ZRANGE همیشه از پایین به بالا می‌آید.

اگر برعکسش را بخواهی:

ZREVRANGE racer_scores 0 -1

خروجی:

Prickett
Castilla
Royce
Norem
Sam-Bodden
Ford

اینجا بالاترین امتیاز اول می‌آید، دقیقاً چیزی که برای لیدربورد لازم داریم.

نکته‌ی ظریف:
0 یعنی اولین عنصر
-1 یعنی آخرین عنصر
دقیقاً مثل LRANGE در لیست‌ها.

دیدن امتیازها همراه با اسم‌ها

تا این‌جا فقط اسم‌ها را دیدیم. اگر امتیاز هم بخواهیم:

ZRANGE racer_scores 0 -1 WITHSCORES

خروجی به‌صورت جفت‌های پشت‌سرهم برمی‌گردد:

Ford        6
Sam-Bodden  8
Norem       10
Royce       10
Castilla    12
Prickett    14

این شکل خروجی شاید اولش عجیب باشد،
ولی برای پردازش ماشین‌محور عالی است و سریع.

کار روی بازه‌ها (اینجا قدرت ZSET معلوم می‌شود)

فرض کن فقط راننده‌هایی را می‌خواهیم که ۱۰ امتیاز یا کمتر گرفته‌اند.

ZRANGEBYSCORE racer_scores -inf 10

خروجی:

Ford
Sam-Bodden
Norem
Royce

اینجا داریم به Redis می‌گوییم:

  • از منفی بی‌نهایت

  • تا ۱۰

  • هر چی توی این بازه است را بده

این یعنی فیلتر + مرتب‌سازی با هم، آن هم با پیچیدگی مناسب.

حذف داده‌ها (تکی و گروهی)

حذف یک راننده‌ی خاص خیلی ساده است:

ZREM racer_scores "Castilla"

اما قدرت واقعی اینجاست که بتوانی یک بازه را پاک کنی.

مثلاً:

همه‌ی کسانی که امتیازشان کمتر از ۱۰ است را حذف کن

ZREMRANGEBYSCORE racer_scores -inf 9

Redis تعداد آیتم‌های حذف‌شده را برمی‌گرداند،
که برای مانیتورینگ خیلی مهم است.

بعد از حذف، اگر دوباره لیست را ببینیم:

ZRANGE racer_scores 0 -1

خروجی:

Norem
Royce
Prickett

گرفتن رتبه‌ی یک عضو

یکی از مهم‌ترین قابلیت‌های Sorted Set این است که بپرسی:

«این شخص چندمه؟»

ZRANK racer_scores "Norem"

خروجی:

0

یعنی از پایین، اولین نفر.

اگر رتبه از بالا را بخواهی (حالت لیدربورد واقعی):

ZREVRANK racer_scores "Norem"

خروجی:

2

یعنی نفر سوم از بالا.

این دستور برای نمایش رتبه‌ی کاربر بدون گرفتن کل لیدربورد حیاتی است.

مرتب‌سازی لغوی (Lexicographical) — وقتی همه امتیاز برابرند

از Redis 2.8 به بعد، اگر همه‌ی اعضا score یکسان داشته باشند،
می‌توانی از Sorted Set به‌عنوان ایندکس لغوی استفاده کنی.

بیایم همه راننده‌ها را با score صفر اضافه کنیم:

ZADD racer_scores 0 "Norem" 0 "Sam-Bodden" 0 "Royce" 0 "Castilla" 0 "Prickett" 0 "Ford"

حالا اگر لیست را بگیریم:

ZRANGE racer_scores 0 -1

خروجی:

Castilla
Ford
Norem
Prickett
Royce
Sam-Bodden

کاملاً لغوی مرتب شده‌اند.

گرفتن بازه‌ی لغوی

مثلاً همه‌ی اسم‌هایی که بین A و L هستند:

ZRANGEBYLEX racer_scores [A [L

خروجی:

Castilla
Ford

براکت [ یعنی شامل (inclusive)
و می‌توانی بازه‌های باز یا بی‌نهایت هم تعریف کنی.

چرا این قابلیت خیلی مهم است؟

چون Sorted Set فقط برای لیدربورد نیست.

با این تکنیک می‌توانی:

  • ایندکس بسازی

  • روی بازه‌های عددی بزرگ (مثلاً 128 بیت) جستجو کنی

  • بدون دیتابیس سنگین، range query واقعی داشته باشی

Redis این کار را با یک ساختار دوگانه انجام می‌دهد:

  • Skip List برای مرتب‌سازی

  • Hash Table برای دسترسی سریع

به همین دلیل:

  • اضافه‌کردن عضو: O(log N)

  • خواندن داده‌ی مرتب: تقریباً بدون هزینه

آپدیت امتیازها: چرا Sorted Set برای لیدربورد ایده‌آل است؟

قبل از رفتن به مبحث بعدی، یک نکته‌ی بسیار مهم درباره‌ی Sorted Set وجود دارد که اگر درک نشود، کل ارزش آن از دست می‌رود:

امتیاز (score) اعضای Sorted Set هر لحظه قابل تغییر است.

و مهم‌تر از آن:

این تغییر با پیچیدگی O(log N) انجام می‌شود.

یعنی حتی اگر میلیون‌ها عضو داشته باشی،
آپدیت امتیاز هنوز هم قابل‌اعتماد و سریع است.

به همین دلیل است که Sorted Set انتخاب پیش‌فرض برای leaderboard محسوب می‌شود.

لیدربورد در دنیای واقعی یعنی چه؟

تصور کن یک بازی آنلاین داری (مثلاً بازی‌های اجتماعی شبیه بازی‌های فیسبوکی):

  • می‌خواهی همیشه بتوانی Top-N را نشان بدهی

  • می‌خواهی به هر کاربر بگویی:
    «رتبه‌ی تو الان 4932 است»

  • امتیازها مدام تغییر می‌کنند

  • تعداد آپدیت‌ها خیلی زیاد است

اگر این را با دیتابیس رابطه‌ای یا سورت در کد انجام بدهی،
سیستم خیلی زود تحت فشار قرار میگیره.

Sorted Set دقیقاً برای همین سناریو طراحی شده.

دو مدل آپدیت امتیاز در لیدربورد

در عمل، دو حالت داریم:

حالت اول: امتیاز جدید را دقیق می‌دانیم

مثلاً بازی تمام شده و امتیاز نهایی مشخص است.

در این حالت، ZADD کافی است.

ZADD racer_scores 100 "Wood"
ZADD racer_scores 100 "Henshaw"

اگر همان عضو دوباره اضافه شود:

ZADD racer_scores 150 "Henshaw"

اینجا Redis:

  • عضو جدید اضافه نمی‌کند

  • فقط امتیاز را جایگزین می‌کند

  • و جایگاه عضو در لیدربورد را به‌روزرسانی می‌کند

نکته‌ی ظریف:

  • مقدار برگشتی 0 است

  • یعنی عضو از قبل وجود داشته

حالت دوم: می‌خواهیم امتیاز را افزایش بدهیم

در خیلی از بازی‌ها، امتیاز تجمعی است:

  • هر برد → +50

  • هر مأموریت → +20

اینجا استفاده از ZINCRBY منطقی‌تر است.

ZINCRBY racer_scores 50 "Wood"

خروجی:

"150"

Redis امتیاز جدید را برمی‌گرداند.

حالا اگر همین کار را برای Henshaw انجام بدهیم:

ZINCRBY racer_scores 50 "Henshaw"

خروجی:

"200"

اینجا اتفاقات مهمی افتاده:

  • امتیاز قبلی مهم نیست

  • Redis خودش جمع می‌زند

  • رتبه بلافاصله اصلاح می‌شود

بدون race condition، بدون lock، بدون دردسر.

تفاوت رفتاری ZADD و ZINCRBY (خیلی مهم)

  • ZADD

    • امتیاز را جایگزین می‌کند

    • وقتی امتیاز نهایی را می‌دانی

  • ZINCRBY

    • امتیاز را افزایش یا کاهش می‌دهد

    • وقتی امتیاز مرحله‌ای یا تجمعی است

اگر این دو را اشتباه استفاده کنی،
لیدربوردت به‌مرور غلط و غیرقابل اعتماد می‌شود.

دستورات پایه‌ای که لیدربورد روی آن‌ها می‌چرخد

بدون وارد شدن به لیست بلندبالا، لیدربورد واقعی معمولاً فقط به این‌ها نیاز دارد:

  • ZADD
    اضافه‌کردن یا آپدیت امتیاز

  • ZRANGE
    گرفتن لیست مرتب‌شده (از پایین به بالا)

  • ZRANK
    گرفتن رتبه‌ی کاربر از پایین

  • ZREVRANK
    گرفتن رتبه‌ی کاربر از بالا (حالت لیدربورد)

با همین چهار دستور می‌توان یک لیدربورد کامل ساخت.

نکته‌ی عملکردی که نباید نادیده بگیری

بیشتر عملیات‌های Sorted Set:

  • O(log N) هستند

  • و برای آپدیت‌های پرتعداد عالی‌اند

اما یک هشدار جدی وجود دارد:

اگر ZRANGE را با خروجی خیلی بزرگ صدا بزنی
(مثلاً ده‌ها هزار یا صدها هزار عضو)،
هزینه‌اش دیگر فقط لگاریتمی نیست.

در این حالت:

  • هزینه می‌شود O(log N + M)

  • که M تعداد آیتم‌های برگشتی است

نتیجه‌ی عملی:

لیدربورد را صفحه‌بندی‌شده بگیر، نه یک‌جا.

اگر Sorted Set تنها کافی نبود چه؟

گاهی Sorted Set فقط نقش ایندکس را دارد،
و داده‌ی اصلی جای دیگری است.

در این سناریوها:

  • می‌توانی ZSET را برای رتبه‌بندی نگه داری

  • و دیتای واقعی را در ساختارهای دیگر ذخیره کنی

  • یا سراغ امکانات Query و JSON در Redis بروی

اما این تصمیم، بعد از درک درست Sorted Set گرفته می‌شود، نه قبلش.

سخن پایانی

با رشد سیستم‌های بلادرنگ، حجم بالای داده و نیاز به پاسخ‌دهی سریع در محصولات دیجیتال، انتخاب درست ساختار داده و الگوی معماری نقش مستقیمی در مقیاس‌پذیری و پایداری سامانه‌ها دارد. Redis و به‌ویژه Sorted Set نمونه‌ای از ابزارهایی هستند که اگر درست درک و استفاده شوند، می‌توانند بخش مهمی از پیچیدگی سیستم‌هایی مثل لیدربورد، رتبه‌بندی و محدودسازی درخواست‌ها را به‌صورت ذاتی حل کنند.

اگر در حال طراحی یا بازطراحی سامانه‌هایی با بار بالا، نیاز به رتبه‌بندی، یا منطق‌های real-time هستید، تیم معماری و توسعهٔ نرم‌افزار شرکت راهکار نگار هوشمند (آرکان) می‌تواند از تحلیل مسئله تا طراحی معماری و پیاده‌سازی عملیاتی، همراه شما باشد.

این مقاله با هدف انتقال تجربه و آگاهی‌بخشی فنی، توسط تیم توسعهٔ نرم‌افزار شرکت راهکار نگار هوشمند (آرکان) تهیه شده است.


#Redis
#SortedSet
#Leaderboard
#Scalable_Systems
#Backend_Architecture
#System_Design
#High_Performance
#RealTime_Systems
#Data_Structures
#Software_Engineering
#DevTools
#شرکت_راهکار_نگار_هوشمند
#arcanco