Проектируем лидерборд в реальном времени
Проектируем лидерборд: backend на отсортированном множестве Redis (ZSET) для O(log n) обновлений и top-k, ответ на «мой ранг среди миллионов» через ZRANK, детерминированные ничьи и шардинг по диапазону очков.
Лидерборд выглядит как 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 на всю доску, так что вы считаете его один раз и кэшируете для всех, обновляя на коротком интервале — миллионы зрителей, одно вычисление. Мой-ранг персонален: у каждого игрока свой, так что его нельзя кэшировать глобально; его надо считать на запрос. Спасение в том, что ZREVRANK — O(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 дёшев.
- 01Почему отсортированное множество бьёт реляционную таблицу для лидерборда, и какие операции даёт?
- 02Как работать с top-k против мой-ранг, и как ломать ничьи?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.