Sorted Sets: рейтинги и лидерборды

Sorted set — самая мощная структура Redis. Уникальные элементы, каждый со своим числовым счётом, всегда отсортированы. Это готовый лидерборд.

Хотите топ-10 игроков по очкам, обновляемый в реальном времени миллионами запросов? Sorted set делает это одной командой и держит порядок автоматически.

Отсортированное множество (Sorted Set, ZSet) объединяет свойства множества (уникальность) и сортировки: каждый элемент имеет score (числовой счёт), и элементы всегда хранятся в порядке этого счёта. Это делает мгновенными запросы вроде «топ-N» и «ранг элемента».

Основные команды

127.0.0.1:6379> ZADD leaderboard 100 "alice" 250 "bob" 175 "carol"
(integer) 3
127.0.0.1:6379> ZADD leaderboard 300 "alice"
(integer) 0
127.0.0.1:6379> ZRANGE leaderboard 0 -1 WITHSCORES
1) "carol"   2) "175"
3) "bob"     4) "250"
5) "alice"   6) "300"
127.0.0.1:6379> ZREVRANGE leaderboard 0 2 WITHSCORES
1) "alice"   2) "300"   (топ-3 по убыванию)
127.0.0.1:6379> ZRANK leaderboard "bob"
(integer) 1
127.0.0.1:6379> ZSCORE leaderboard "alice"
"300"
127.0.0.1:6379> ZINCRBY leaderboard 50 "carol"
"225"

ZADD добавляет элемент со счётом (повторное добавление обновляет счёт), ZRANGE — по возрастанию, ZREVRANGE — по убыванию (для топа), ZRANK — позиция элемента, ZSCORE — его счёт, ZINCRBY атомарно меняет счёт.

   Лидерборд всегда отсортирован по score:

   ZADD 100 alice, 250 bob, 175 carol

   позиция:  0        1        2
   элемент:  carol    bob      alice
   score:    175      250      300

   ZREVRANGE 0 2 = топ-3 (alice, bob, carol)
   ZRANK bob = 1   (он на 2-м месте с нуля)

Диапазонные запросы по счёту

ZRANGEBYSCORE leaderboard 200 300   # все со счётом 200..300
ZCOUNT leaderboard 200 300          # сколько таких

Это удобно для «игроки уровня X», «события за период» (если score — timestamp) и очередей с приоритетом.

Демонстрация: sorted set на Python — лидерборд с рангами

# Моделируем sorted set: словарь "элемент -> score"
scores = {}

def zadd(member, score):
    scores[member] = score   # повторное добавление обновляет score

def zrevrange(top):
    # отсортировать по score по убыванию (как ZREVRANGE)
    return sorted(scores.items(), key=lambda x: -x[1])[:top]

def zrank(member):
    # позиция в порядке возрастания score (как ZRANK)
    order = sorted(scores.items(), key=lambda x: x[1])
    for i, (m, _) in enumerate(order):
        if m == member:
            return i
    return None

zadd("alice", 100); zadd("bob", 250); zadd("carol", 175)
zadd("alice", 300)   # alice улучшила результат

print("Топ-3 (ZREVRANGE):")
for rank, (name, sc) in enumerate(zrevrange(3), 1):
    print(f"  {rank}. {name} — {sc} очков")

print("\nРанг bob (ZRANK):", zrank("bob"))
print("Счёт alice (ZSCORE):", scores["alice"])
print("\nЭлементы уникальны, всегда отсортированы по score.")

Каждое обновление счёта мгновенно меняет позицию в рейтинге. В Redis это работает за O(log n) благодаря умной внутренней структуре.

Как работает под капотом

Большой sorted set хранится как пара структур: skip list (список с пропусками) для упорядоченного обхода и диапазонных запросов, и хеш-таблица для мгновенного поиска счёта по элементу (ZSCORE за O(1)). Skip list даёт операции добавления, удаления и поиска ранга за O(log n) — это и есть секрет масштабируемости лидербордов. Маленькие ZSet (≤128 элементов, короткие строки) Redis хранит компактно как listpack.

Частые ошибки

  • Хранить нечисловые «счета». Score — это число с плавающей точкой; нельзя сортировать по произвольной строке.
  • ZRANGE без WITHSCORES, когда нужны очки. Без флага вернутся только элементы.
  • Путать ZRANGE и ZREVRANGE. Для топа (от большего к меньшему) нужен ZREVRANGE или ZRANGE ... REV.

Best practices

  • Используйте sorted set для лидербордов, рейтингов, очередей с приоритетом, временных рядов (score = timestamp).
  • Для топ-N используйте ZREVRANGE ... 0 N-1 — это O(log n + N), очень быстро.
  • Меняйте счёт через ZINCRBY атомарно, не пересоздавая элемент.

Итог: Sorted set — уникальные элементы со счётом, всегда отсортированные. Это готовый лидерборд с операциями за O(log n). Внутри — связка skip list + хеш-таблицы, дающая и быструю сортировку, и мгновенный поиск счёта.

Sorted set как очередь с приоритетом и временная шкала

Лидерборды — самое наглядное, но далеко не единственное применение sorted set. Поскольку score — это просто число, в него можно положить что угодно сортируемое, и структура раскрывается с новых сторон.

# Очередь с приоритетом: score = приоритет
ZADD jobs 1 "low-task" 10 "urgent-task"
ZPOPMIN jobs        # забрать задачу с наименьшим score (или ZPOPMAX)

# Временная шкала: score = timestamp
ZADD timeline 1718900000 "event-A"
ZRANGEBYSCORE timeline 1718900000 1718903600   # события за час

Если положить в score метку времени, sorted set превращается в индекс по времени: «последние события», «всё за период», «удалить старше N» через ZREMRANGEBYSCORE. Это популярный приём для лент, расписаний и хранения недавней активности.

Команды ZPOPMIN/ZPOPMAX (и их блокирующие версии BZPOPMIN/BZPOPMAX) делают из sorted set полноценную очередь с приоритетом: всегда забирается элемент с наименьшим или наибольшим score. А ZRANGEBYLEX позволяет делать лексикографические диапазонные запросы, когда у всех элементов одинаковый score, — основа для автодополнения и словарей.

Проверьте себя
1. Какая команда вернёт топ-3 игроков лидерборда (с наибольшими очками)?
AZRANGE leaderboard 0 2
BZREVRANGE leaderboard 0 2
CZSCORE leaderboard 0 2
DSMEMBERS leaderboard
2. Благодаря какой внутренней структуре sorted set обеспечивает операции за O(log n)?
AСвязный список
BSkip list (список с пропусками) в паре с хеш-таблицей
CБинарное дерево поиска
DПростой отсортированный массив