Задача, ради которой обычно и вспоминают про ZSET: геймификация с топом игроков, витрина «самые популярные товары недели», рейтинг коротких ссылок по переходам. На SQL это ORDER BY по колонке с индексом — работает, но под постоянными запросами топа база греется, а при миллионе записей страница лидерборда становится дорогой. На множествах из прошлого урока рейтинг не собрать: уникальность есть, порядка нет. ZSET занимает ровно эту нишу: каждый элемент несёт балл, сервер поддерживает элементы отсортированными по баллу, и чтение топа стоит доли миллисекунды независимо от размера. В этом уроке строим лидерборд целиком: запись баллов, атомарный прирост, чтение топа и позиции конкретного игрока, плюс два неочевидных сценария — отложенные задачи и тренды по дням. Обзор всех пяти типов лежит в уроке про типы данных Redis, а различие с обычными множествами мы проговорим по ходу. Всё, что здесь отработаете, вернётся в итоговом проекте курса, где рейтинг коротких ссылок собирается теми же командами — такой двойной проход экономит время в конце.
ZSET: элементы с баллом, отсортированные на сервере#
ZSET — это множество, где у каждого элемента есть балл: число с плавающей точкой, по которому сервер поддерживает порядок. Элемент уникален, балл — нет: два игрока могут иметь равные очки, и тогда их порядок определяется лексикографически, по алфавиту. Запись — ZAdd, при повторной записи элемента его балл перезаписывается, а не прибавляется: об этой ловушке отдельный блок ниже. Атомарный прирост делает ZIncrBy, чтение диапазонов идёт и по месту — ZRange и ZRevRange с индексами, и по баллам — ZRangeByScore. Всё это стоит O(log N): миллион игроков означает пару десятков сравнений на операцию, а не пересчёт рейтинга. Внутри ZSET — два индекса: хэш-таблица для быстрого поиска балла по элементу и список с пропусками для порядка; именно поэтому и точка, и позиция, и диапазон доступны одинаково дёшево. Обычное множество из урока про SAdd помнит только факт присутствия; ZSET помнит ещё и то, насколько элемент важнее соседа. Отсюда и правило выбора между ними: если единственный вопрос — «встречался ли элемент», хватит множества; как только появляется «кто из них выше», добро пожаловать в ZSET.
ZAdd и ZIncrBy: установка и прирост балла
GOfunc setScore(ctx context.Context, player string, points float64) error {
// v9 принимает указатели на redis.Z
return rdb.ZAdd(ctx, "leaderboard:week", &redis.Z{
Score: points,
Member: player,
}).Err()
}
func bumpScore(ctx context.Context, player string, points float64) (float64, error) {
// прибавить и вернуть новый балл атомарно
return rdb.ZIncrBy(ctx, "leaderboard:week", points, player).Result()
}ZAdd устанавливает балл: повторный вызов по тому же игроку перезапишет прежнее значение — это идеальный путь для импорта, ручной правки и уровней, где балл назначается целиком. ZIncrBy прибавляет к текущему баллу и возвращает новый; отсутствующий элемент считается нулём и создаётся со значением прироста. Смешивать их без причины не стоит: вся разница между «назначить» и «заработать» в лидерборде живёт на этой развилке.
Типичная ошибка: обработчик победы читает балл через ZScore, прибавляет очки в Go и пишет результат через ZAdd. Два одновременных запроса прочитали одинаковый балл — оба записали «свою» сумму, и часть очков исчезла, потому что чтение и запись разделены сетью. Правило: балл, который зарабатывается событиями, растёт только ZIncrBy на сервере; ZAdd — для случаев, когда значение приходит извне целиком.
Лидерборд: ZRevRangeWithScores и позиция игрока#
Чтение топа — одна команда. ZRevRangeWithScores с диапазоном 0..9 отдаёт десять элементов от наибольшего балла к наименьшему вместе с их очками: именно обратный порядок нужен рейтингу, где первый — лучший. Ответ приходит срезом структур redis.Z с полями Score и Member; Member в Go имеет тип any, поэтому перед печатью его приводят к строке или форматируют. Для карточки игрока нужны две команды: ZRevRank отвечает позицией с нуля — сколько игроков стоят выше — а ZScore отдаёт его текущий балл. Игрок за пределами первой страницы получает свою позицию так же дёшево, как лидер, и это принципиальное отличие от ORDER BY в базе, где место в середине списка стоит сканирования. Отсутствующего игрока обе команды отвечают redis.Nil: карточка ещё не игравшего пользователя должна показать ноль, а не ошибку — проверяйте errors.Is, как и со строками. По объёму это самая дешёвая часть лидерборда: три команды закрывают экран топа, страницу профиля и половину спортивных геймификаций, которые обычно заказывают вместе с фразой «сделайте как в Duolingo».
Топ-10 и карточка игрока
GOfunc topPlayers(ctx context.Context, n int64) ([]redis.Z, error) {
// десять лучших: от большего балла к меньшему
return rdb.ZRevRangeWithScores(ctx, "leaderboard:week", 0, n-1).Result()
}
func playerCard(ctx context.Context, player string) {
rank, err := rdb.ZRevRank(ctx, "leaderboard:week", player).Result()
if errors.Is(err, redis.Nil) {
fmt.Println(player, "пока без очков")
return
}
score, _ := rdb.ZScore(ctx, "leaderboard:week", player).Result()
fmt.Printf("%s: %d место, %v баллов", player, rank+1, score)
}ZRevRangeWithScores принимает индексы, как в срезах Go: 0..n-1 даёт первые n позиций. ZRevRank возвращает позицию с нуля, для человека прибавляем единицу. Обе команды по отсутствующему игроку отвечают redis.Nil, поэтому карточка проверяет ошибку перед печатью — иначе новый пользователь увидел бы чужую панику вместо своего нуля. Запускается всё это только локальным Go с Redis в Docker: песочница сетевых соединений не открывает.
Диапазоны по баллу и тренды по дням#
Возможности ZSET не заканчиваются топом. ZCount с границами отвечает, сколько игроков набрали сто очков и больше — порог награды проверяется одной командой. ZRangeByScore выбирает элементы по баллам: уровни сложности, матчмейкинг по рейтингу, выдача призов всем, кто перешагнул порог. Чистка — ZRem для отдельных элементов и ZRemRangeByRank для обрезки старых позиций. Отдельно стоит приём с ключами-периодами: leaderboard:2024-06-01 хранит рейтинг дня, а Expire чистит историю через неделю — вечный недельный лидерборд создаёт споры, дневной создаёт активность. Тот же приём даёт тренды: популярность товаров за сегодня, за неделю, за всё время — три ключа, три независимых рейтинга, без единой строки аналитики в коде. Единственная аккуратность — балл это float64: для денег копейки держите целыми числами в другом типе, а ZSET оставьте вещам, где округление не трагедия. И не забывайте про срок жизни периодических ключей: без Expire дни копятся, и через год поиск по сотням рейтингов становится отдельной задачей — срок, заданный при создании, решает проблему до её появления.
Отложенные задачи: ZSET вместо таймера
GOfunc schedule(ctx context.Context, task string, runAt time.Time) error {
// балл — unix-время запуска
return rdb.ZAdd(ctx, "tasks:delayed", &redis.Z{
Score: float64(runAt.Unix()),
Member: task,
}).Err()
}
func dueTasks(ctx context.Context) ([]string, error) {
max := strconv.FormatInt(time.Now().Unix(), 10)
// задачи, срок которых уже наступил
return rdb.ZRangeByScore(ctx, "tasks:delayed", &redis.ZRangeBy{
Min: "-inf",
Max: max,
Count: 10,
}).Result()
}Тот же тип решает неожиданную задачу: балл — время запуска, и ZRangeByScore отдаёт всё, что просрочено к текущей секунде. Воркер раз в секунду читает порцию, удаляет взятые задачи ZRem-ом и исполняет. Взятие и удаление здесь хочется атомарности — полный разбор транзакций и Lua ждёт в уроке про MULTI и WATCH. Границы диапазона передаются строками, поэтому время форматируется strconv-ом.
Команды ZSET под задачи рейтинга#
| Команда Redis | Метод go-redis v9 | Зачем нужна |
|---|---|---|
| ZADD | ZAdd | Установить балл элемента; повторная запись перезаписывает |
| ZINCRBY | ZIncrBy | Атомарный прирост балла; отсутствующий элемент считается нулём |
| ZREVRANGE WITHSCORES | ZRevRangeWithScores | Топ от большего к меньшему вместе с очками |
| ZREVRANK / ZSCORE | ZRevRank, ZScore | Позиция игрока и его текущий балл |
| ZCOUNT | ZCount | Сколько элементов в границах баллов, например выше порога |
| ZRANGEBYSCORE | ZRangeByScore | Выборка по баллам: уровни, матчи, просроченные задачи |
| ZREM / ZREMRANGEBYRANK | ZRem, ZRemRangeByRank | Удаление отдельных элементов и обрезка старых позиций |
Тот же приём с баллом-временем лежит в основе скользящего окна rate limiter: отметки запросов живут в ZSET, а старые чистятся ZRemRangeByScore — полный разбор с кодом ждёт в уроке про ограничение запросов. Итоговый проект курса считает топ коротких ссылок именно на ZSET — с теми же командами, что вы отработали здесь, только вместо игроков — ссылки. Когда ZSET не нужен: без сортировки остаётся обычное множество из прошлого урока, с повторами — список, с полями — хэш; плата за порядок — память и логарифм на операцию, и там, где топ не показывается никому, ZSET — избыточная роскошь. Для чтения топа в HTTP-эндпоинте — маршрутизация и JSON-ответы разбирались в курсе по Gin — пара ZRevRangeWithScores обычно дешевле любого запроса к базе. Отдельный вопрос — деградация при недоступности Redis: если топ — витрина, а не деньги, кэшируйте последний ответ в памяти сервиса и показывайте его с пометкой устаревания, вместо того чтобы класть страницу из-за рейтинга.
Проверка: ZIncrBy на новом игроке
Игрока anya нет в рейтинге. Что сделает rdb.ZIncrBy(ctx, key, 15, "anya").Result()?
Проверка: порядок в ZRevRangeWithScores
Что вернёт rdb.ZRevRangeWithScores(ctx, key, 0, 4).Result() при тысяче игроков?
Какой балл вернёт ZIncrBy?
Прочитайте код и выберите, что он выведет:
rdb.ZAdd(ctx, "lb", &redis.Z{Score: 10, Member: "anya"})
rdb.ZAdd(ctx, "lb", &redis.Z{Score: 30, Member: "anya"})
s, _ := rdb.ZIncrBy(ctx, "lb", 5, "anya").Result()
fmt.Println(s)Соберите работу с рейтингом
Допишите установку балла, атомарный прирост и чтение топ-10 с очками.
Сценарий недельного рейтинга
Расставьте строки по порядку: прирост очков, чтение топа, позиция и балл игрока, печать результатов.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ZSET и лидерборды
Лидерборд собран полностью: прирост на сервере, топ и позиция одной командой каждый, плюс два неочевидных применения — планировщик и тренды по ключам-периодам. Отличие от множеств из прошлого урока сформулируйте для себя одним предложением: там хранился факт присутствия, здесь — ещё и величина. Дальше по курсу данные начинают двигаться между сервисами: урок про Pub/Sub рассылает события всем подписчикам в реальном времени, а после него потоки добавят к рассылке хранение и подтверждения; между ними — пайплайны из урока про один рейс сети, если рейтинги станет считать тысячами. Полный план из двадцати пяти уроков — на странице курса по Redis. Код урока выполняется локальным Go с Redis в Docker; домашнее задание попросит сравнить ZAdd и ZIncrBy на гонке двух процессов — разница между назначением и приростом станет осязаемой.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- ZSET — множество с баллом: элементы уникальны, сервер держит их отсортированными, чтение топа стоит O(log N).
- ZAdd перезаписывает балл целиком, ZIncrBy прибавляет атомарно на сервере; смешивание приводит к потере очков на гонках.
- ZRevRangeWithScores 0..9 — топ-10 с очками; ZRevRank и ZScore дают позицию и балл любого игрока за постоянное время.
- Отсутствующий игрок считается нулём: ZIncrBy создаёт его со значением прироста, а чтения отвечают redis.Nil.
- Балл-время превращает ZSET в планировщик отложенных задач и скользящее окно rate limiter.
- Ключи-периоды с Expire дают дневные рейтинги и тренды без строк аналитики; деньги в float64 не хранят.
Домашнее задание
Соберите лидерборд: setScore, bumpScore, topPlayers и playerCard из урока. Запустите локальным Go с Redis в Docker, поднимите трём игрокам очки через bumpScore и выведите топ-10. Затем проверьте карточку игрока, которого нет в рейтинге, — программа должна напечатать заглушку, а не упасть. В конце имитируйте гонку: два процесса go run одновременно вызывают пару ZScore и ZAdd по одному игроку — посчитайте, сколько очков пропало, и замените пару на один ZIncrBy, чтобы сумма сошлась.
Личные заметкиПусто
Вопросы и ответы по уроку
Как разыграть равные баллы?
При одинаковом балле ZSET упорядочивает элементы лексикографически, поэтому порядок детерминирован, но не по времени достижения. Если нужно, кто раньше набрал, добавляйте в балл долю секунды или храните отдельный ключ с отметкой времени. Для большинства продуктов алфавитный порядок равных призёров — приемлемая простота.
ZSET или ORDER BY в PostgreSQL?
Для чтения топа под постоянным трафиком ZSET дешевле: ответ из памяти за доли миллисекунды без планов запросов. База остаётся источником истины и для аналитики; типовая схема — счёт в Redis, периодический сброс итогов в таблицу. Если топ запрашивается раз в час, возможно, ZSET вам не нужен вовсе.
Как хранить в балле деньги?
Храните копейки целым числом и не используйте ZIncrBy с дробями: float64 накапливает ошибку округления, а споры о рублях на лидерборде — худший из возможных инцидентов. Для денежных рейтингов умножайте на сто и оперируйте целыми, а дробные баллы оставьте очкам опыта и коэффициентам.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin