open atlas
↑ К треку
Разборы System Design SDC · 04 · 04

Проектируем лидерборд в реальном времени

Проектируем лидерборд: backend на отсортированном множестве Redis (ZSET) для O(log n) обновлений и top-k, ответ на «мой ранг среди миллионов» через ZRANK, детерминированные ничьи и шардинг по диапазону очков.

SDC Senior ◷ 32 min
Уровень
ОсновыJuniorMiddleSenior

Лидерборд выглядит как SELECT ... ORDER BY score DESC LIMIT 10 и не более. Затем продукт задаёт вопрос, ломающий наивную схему: «покажи каждому игроку его собственный ранг — он на позиции 4 172 338 из двенадцати миллионов, и обновляй вживую, пока он играет». Вычислить ранг одного игрока на SQL — значит сосчитать каждого с очками выше, скан O(n), для каждого из миллионов игроков, на каждой смене очков. Доска также горяча на запись: каждый конец матча меняет очки, и каждое изменение может перетасовать ранги. Вся задача — найти структуру данных, где вставка, top-k и «каков мой ранг» дёшевы одновременно — а реляционная таблица ею не является.

После этого урока ты точно поймёшь, почему SQL ORDER BY ломается на масштабе, как skip list (список с пропусками) отвечает «каков мой ранг среди миллионов» за O(log n) и когда пора переходить от одного узла к шардингу.

Требования

  • Функциональные: обновить очки игрока; читать top-k (топ 10/100); читать текущий ранг и очки любого игрока; часто читать малое окно вокруг игрока («ты и 5 выше и ниже»).
  • Нефункциональные: горяча на запись (очки постоянно меняются в игре) и горяча на чтение (все смотрят таблицу). Низкая задержка и обновлений, и запросов ранга. Достаточно сильная свежесть — ранг должен отражать недавние очки за секунды. Доска может быть большой: десятки миллионов записей.

Определяющее требование — комбинация. Top-k сам по себе прост (держи кучу). Ранг-одного сам по себе несложен (счётчик). Нужда в обоих, плюс быстрые обновления, плюс «окно вокруг меня», на доске из миллионов — вот что исключает очевидную таблицу и указывает на упорядоченную, осознающую ранг структуру.

Оценки

Пусть игра с 50 млн игроков в месяц, 5 млн одновременно на пике и смена очков примерно каждые 10 секунд активной игры — назовём 5×10^6 / 10 = 5×10^5 обновлений/с на пике, плюс тяжёлый трафик чтения, пока игроки смотрят таблицу. Сама доска мала в байтах: 50 млн записей (player_id, score) — порядка пары ГБ — влезает в память на одном мощном узле, поэтому in-memory отсортированное множество — естественный дом. Давление не в хранилище; оно в выполнении O(log n) работы полмиллиона раз в секунду и ответе на запросы ранга без сканирования.

Высокоуровневая схема

Событие очков идёт к сервису лидерборда, обновляющему отсортированное множество в Redis; чтения (top-k, мой-ранг, окно) бьют в ту же структуру. Долговечное хранилище-источник истины (база данных) держит авторитетную историю очков; ZSET — быстрый индекс, перестраиваемый из неё при потере.

Глубокие погружения — почему отсортированное множество выигрывает, как оно отвечает «мой ранг среди миллионов» и что делать, когда доска перерастает один узел.

Глубокое погружение

Отсортированное множество (ZSET) и почему оно выигрывает

Отсортированное множество Redis хранит члены, каждый с числовым score, упорядоченные по score, реализованы как skip list рядом с хеш-таблицей. Skip list даёт упорядоченный обход и ранг за O(log n); хеш даёт O(1) поиск score по члену. Эта комбинация — ровно список желаний лидерборда:

  • Обновление: ZADD board <score> <player> вставляет или перемещает за O(log n).
  • Top-k: ZREVRANGE board 0 9 WITHSCORES возвращает топ-10 за O(log n + k).
  • Мой ранг: ZREVRANK board <player> возвращает 0-базовую позицию игрока за O(log n)без сканирования других.
  • Окно вокруг меня: взять ранг, затем ZREVRANGE board r-5 r+5 для соседей.

Реляционная альтернатива отвечает на top-k нормально с индексом, но «мой ранг» становится SELECT COUNT(*) WHERE score > mine — счёт O(n) на запрос, разрушительный, когда миллионы игроков опрашивают свой ранг. ZSET держит информацию о ранге в самой структуре, так что ему никогда не нужно считать.

Почему это работает

Почему skip list даёт ранг за O(log n), когда простому отсортированному списку нужен O(n) для счёта позиций? Потому что каждый узел skip list хранит span — число элементов нижнего уровня, через которые перепрыгивает каждый указатель вперёд. Чтобы найти ранг члена, вы спускаетесь по экспресс-полосам к нему и суммируете span’ы указателей, по которым прошли; сумма и есть его позиция. Никакого счёта отдельных элементов, лишь сложение горстки предвычисленных ширин-прыжков вдоль пути O(log n). Эта бухгалтерия span’ов обновляется инкрементально на каждой вставке/удалении, так что вы платите чуть больше на записях, чтобы чтения ранга были дёшевы — та же сделка предвычисления-для-чтения, что в уроке про Maps, в миниатюре. Поэтому ZREVRANK честно O(log n), а не замаскированный скан.

Top-k и «мой ранг среди миллионов»

У двух чтений очень разные профили стоимости, и их смешение — классическая ошибка. Top-k разделяем: есть один топ-10 на всю доску, так что вы считаете его один раз и кэшируете для всех, обновляя на коротком интервале — миллионы зрителей, одно вычисление. Мой-ранг персонален: у каждого игрока свой, так что его нельзя кэшировать глобально; его надо считать на запрос. Спасение в том, что ZREVRANKO(log n), так что даже на запрос он дёшев — а чтение окна-вокруг-меня переиспользует тот же ранг. Так что архитектура агрессивно кэширует глобальный top-k и обслуживает персональные ранги вживую из ZSET, вместо попытки предвычислить ранг каждого игрока (что было бы O(n) на поддержку и бессмысленно, ведь большинство никогда не смотрят).

Граничные случаи

Ничьи — не деталь; они решают, кто видит себя выше кого. Если у двух игроков одинаковые очки, ZSET упорядочивает их лексикографически по имени члена, что с точки зрения игры произвольно и может сделать ранг нестабильным. Стандартный фикс — сделать сам score тайбрейкером, закодировав вторичный ключ в него: соединить очки с обратной временной меткой (раньше достигший — выше рангом) в один составной числовой score — например, очки в старших битах и (maxTime − achievedAt) в младших. Теперь игроки с равными очками получают детерминированный, осмысленный порядок, и никакие две записи не ничьи по-настоящему. Решите правило тайбрейка заранее; ретрофит означает пересчёт каждого score.

Шардинг, когда одна доска слишком велика

У одиночного ZSET на одном узле есть пределы: память и однопоточная пропускная способность одного инстанса Redis против 5×10^5 записей/с. Когда вы его перерастаете, вы шардите по диапазону очков — узел A держит очки 0–1000, узел B 1001–2000 и так далее. Top-k тогда читает лишь шард высшего диапазона (или сливает топы нескольких). Обновления маршрутизируются на шард, владеющий новым счётом (и удаляются из старого шарда при смене, пересекающей границу). Трудная часть — глобальный ранг: истинный ранг игрока — его ранг внутри своего шарда плюс полный счёт всех в более высоких по очкам шардах. Вы держите по-шардовые кардинальности (дешёвые счётчики), так что глобальный ранг — «мой внутришардовый ранг + сумма размеров более высоких шардов» — сложение O(шарды), не скан. Это тот же инстинкт грубо-потом-точно и обработки границ, что во всём юните, теперь как партиционирование диапазона на оси очков.

Узкие места и компромиссы

Первый компромисс — память против долговечности: ZSET живёт в RAM ради скорости, так что должен подкрепляться долговечным источником истины и перестраиваться при рестарте — относитесь к отсортированному множеству как к быстрому индексу, никогда как к системе записи. Второй — конкуренция записи: на сотнях тысяч обновлений/с один инстанс — узкое место, поэтому шардинг по диапазону очков (или разбиение на временны́е доски — дневная/недельная/всё-время, каждая свой меньший ZSET) — путь масштабирования. Третий — свежесть против стоимости персонального ранга при волатильности: когда очки постоянно меняются, точный ранг игрока — движущаяся цель, и пересчёт его на каждое обновление для каждого зрителя расточителен — так что вы считаете ранг на чтение (дёшево через ZREVRANK) и принимаете, что это снимок почти-реального-времени, а не непрерывно толкаемое значение, если продукт реально не требует стрима своего ранга.

Викторина

Лидерборд хранит очки в SQL-таблице с индексом по score. Top-10 быстр, но показ каждому из 12 млн игроков его живого ранга убивает базу. Корень и фикс?

Викторина

Ваш одноузловой ZSET не успевает за 500K обновлений/с, и доска больше не влезает комфортно в один инстанс. Вы шардите по диапазону очков. Как всё ещё отвечать на ГЛОБАЛЬНЫЙ ранг игрока?

Закончи аналогию

Отсортированное множество Redis подкреплено _______, чьи узлы хранят span (сколько элементов перепрыгивает каждый указатель вперёд); суммирование span'ов вдоль пути поиска O(log n) даёт ранг члена без счёта элементов по одному — поэтому ZREVRANK дёшев.

Вспомните перед уходом
  1. 01
    Почему отсортированное множество бьёт реляционную таблицу для лидерборда, и какие операции даёт?
  2. 02
    Как работать с top-k против мой-ранг, и как ломать ничьи?
  3. 03
    Как шардировать лидерборд, вычислять глобальный ранг по шардам, и каковы ключевые компромиссы?
Итог

Жёсткое требование лидерборда реального времени — комбинация: дешёвые обновление, top-k и ранг-одного одновременно, на доске из миллионов. Реляционная таблица делает «мой ранг» счётом O(n) (COUNT(*) WHERE score > mine) на игрока — фатально при опросе. Отсортированное множество (Redis ZSET: skip list со span’ами узлов плюс хеш-таблица) даёт ZADD, ZREVRANGE и ZREVRANK все за O(log n), вычисляя ранг суммированием предвычисленных ширин-прыжков вместо счёта. Top-k разделяем (считай один раз, кэшируй для всех); мой-ранг персонален (считается вживую на запрос, дёшево через ZREVRANK); не предвычисляй ранг каждого. Ничьи решаются кодированием тайбрейкера (очки плюс обратная временная метка) в составной score, так что порядок детерминирован. Когда один узел мал или медлен, шардь по диапазону очков: top-k читает высший диапазон, а глобальный ранг — внутришардовый ранг плюс суммированные размеры более высоких шардов из дешёвых счётчиков — O(шарды), не скан. Стоящие компромиссы — память против долговечности (ZSET — быстрый индекс поверх долговечного источника истины), конкуренция записи (шардинг по диапазону или временны́е доски) и вычисление персонального ранга на чтение как снимка почти-реального-времени. Теперь, встретив лидерборд на SELECT COUNT(*) для ранга, ты знаешь, чем это заменить, что кэшировать глобально и на что реально хватит одного Redis-узла.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 8 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.