---
title: "Отсортированные множества: лидерборды"
description: "Лидерборд на Redis в Go: ZAdd, ZIncrBy и ZRevRangeWithScores на go-redis v9. Топ игроков, позиция любого участника, отложенные задачи и тренды по дням на ZSET."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/13-zset-reitingi/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Отсортированные множества: лидерборды

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 13 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/13-zset-reitingi/*

Отсортированное множество, или ZSET, — тип Redis, с которым рейтинг и лидерборд собираются без единой строки сортировки в Go: балл живёт рядом с элементом, а порядок держит сервер. Разберём ZAdd, ZIncrBy и ZRevRangeWithScores на go-redis v9 и соберём топ игроков с позицией любого участника.

Задача, ради которой обычно и вспоминают про ZSET: геймификация с топом игроков, витрина «самые популярные товары недели», рейтинг коротких ссылок по переходам. На SQL это ORDER BY по колонке с индексом — работает, но под постоянными запросами топа база греется, а при миллионе записей страница лидерборда становится дорогой. На множествах из прошлого урока рейтинг не собрать: уникальность есть, порядка нет. ZSET занимает ровно эту нишу: каждый элемент несёт балл, сервер поддерживает элементы отсортированными по баллу, и чтение топа стоит доли миллисекунды независимо от размера. В этом уроке строим лидерборд целиком: запись баллов, атомарный прирост, чтение топа и позиции конкретного игрока, плюс два неочевидных сценария — отложенные задачи и тренды по дням. Обзор всех пяти типов лежит в уроке про [типы данных Redis](/courses/go-redis/lessons/03-tipy-dannyh-redis/), а различие с обычными множествами мы проговорим по ходу. Всё, что здесь отработаете, вернётся в итоговом проекте курса, где рейтинг коротких ссылок собирается теми же командами — такой двойной проход экономит время в конце.

### ZSET: элементы с баллом, отсортированные на сервере

ZSET — это множество, где у каждого элемента есть балл: число с плавающей точкой, по которому сервер поддерживает порядок. Элемент уникален, балл — нет: два игрока могут иметь равные очки, и тогда их порядок определяется лексикографически, по алфавиту. Запись — ZAdd, при повторной записи элемента его балл перезаписывается, а не прибавляется: об этой ловушке отдельный блок ниже. Атомарный прирост делает ZIncrBy, чтение диапазонов идёт и по месту — ZRange и ZRevRange с индексами, и по баллам — ZRangeByScore. Всё это стоит O(log N): миллион игроков означает пару десятков сравнений на операцию, а не пересчёт рейтинга. Внутри ZSET — два индекса: хэш-таблица для быстрого поиска балла по элементу и список с пропусками для порядка; именно поэтому и точка, и позиция, и диапазон доступны одинаково дёшево. Обычное множество из урока про [SAdd](/courses/go-redis/lessons/12-mnozhestva/) помнит только факт присутствия; ZSET помнит ещё и то, насколько элемент важнее соседа. Отсюда и правило выбора между ними: если единственный вопрос — «встречался ли элемент», хватит множества; как только появляется «кто из них выше», добро пожаловать в ZSET.

### ZAdd и ZIncrBy: установка и прирост балла

```go
func 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 прибавляет к текущему баллу и возвращает новый; отсутствующий элемент считается нулём и создаётся со значением прироста. Смешивать их без причины не стоит: вся разница между «назначить» и «заработать» в лидерборде живёт на этой развилке.

> **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 и карточка игрока

```go
func 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 вместо таймера

```go
func 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](/courses/go-redis/lessons/17-transakcii/). Границы диапазона передаются строками, поэтому время форматируется 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 — полный разбор с кодом ждёт в уроке про [ограничение запросов](/courses/go-redis/lessons/22-rate-limiter/). Итоговый проект курса считает топ коротких ссылок именно на ZSET — с теми же командами, что вы отработали здесь, только вместо игроков — [ссылки](/courses/go-redis/lessons/25-itogovyj-proekt/). Когда ZSET не нужен: без сортировки остаётся обычное множество из прошлого урока, с повторами — список, с полями — хэш; плата за порядок — память и логарифм на операцию, и там, где топ не показывается никому, ZSET — избыточная роскошь. Для чтения топа в HTTP-эндпоинте — маршрутизация и JSON-ответы разбирались в курсе по [Gin](/courses/gin/) — пара ZRevRangeWithScores обычно дешевле любого запроса к базе. Отдельный вопрос — деградация при недоступности Redis: если топ — витрина, а не деньги, кэшируйте последний ответ в памяти сервиса и показывайте его с пометкой устаревания, вместо того чтобы класть страницу из-за рейтинга.

### Проверка: ZIncrBy на новом игроке

**Вопрос:** Игрока anya нет в рейтинге. Что сделает rdb.ZIncrBy(ctx, key, 15, "anya").Result()?

- ✅ Создаст игрока с баллом 15: отсутствующий элемент считается нулём
- Вернёт redis.Nil: игрок должен существовать заранее
- Вернёт ошибку not an integer
- Добавит игрока с баллом 0 и не изменит его

ZIncrBy трактует отсутствующего игрока как ноль и после прироста создаёт его с баллом 15, возвращая это значение. То же поведение у счётчиков и полей хэшей — семейство инкрементов Redis ведёт себя единообразно, и проверки существования перед приростом не нужны.

### Проверка: порядок в ZRevRangeWithScores

**Вопрос:** Что вернёт rdb.ZRevRangeWithScores(ctx, key, 0, 4).Result() при тысяче игроков?

- ✅ Пять игроков от наибольшего балла к наименьшему
- Пять игроков от наименьшего балла к наибольшему
- Всю тысячу игроков в обратном порядке
- Первых четырёх игроков без баллов

Индексы 0..4 означают пять позиций, а префикс Rev — от большего балла к меньшему: это и есть топ-5. Без Rev команда ZRangeWithScores шла бы от наименьшего — для лидерборда она почти не нужна, лучшие всегда в начале обратного порядка.

### Какой балл вернёт ZIncrBy?

```go
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)
```

**Что выведет программа?**

- 15
- 30
- ✅ 35
- 45

Второй ZAdd перезаписал балл anya с 10 на 30 — не прибавил, а заменил. ZIncrBy прибавил пять к текущим тридцати и вернул 35. Каждая команда видит результат предыдущей, а перезапись против прироста — главная развилка при записи баллов.

### Соберите работу с рейтингом

Допишите установку балла, атомарный прирост и чтение топ-10 с очками.

```go
rdb.___(ctx, key, &redis.Z{Score: 100, Member: "anya"})

rdb.___(ctx, key, 15, "anya")

top, _ := rdb.___(ctx, key, 0, 9).Result()
```

Подсказка: Балл назначает ZAdd, приращивает ZIncrBy, а топ в обратном порядке читает команда с Rev и WithScores в имени.

Правильные ответы в пропусках: `ZAdd`, `ZIncrBy`, `ZRevRangeWithScores`

### Сценарий недельного рейтинга

Расставьте строки по порядку: прирост очков, чтение топа, позиция и балл игрока, печать результатов.

Правильный порядок строк:

1. `rdb.ZIncrBy(ctx, "leaderboard:week", 10, "anya")`
2. `top, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard:week", 0, 2).Result()`
3. `rank, _ := rdb.ZRevRank(ctx, "leaderboard:week", "anya").Result()`
4. `score, _ := rdb.ZScore(ctx, "leaderboard:week", "anya").Result()`
5. `fmt.Println(len(top))`
6. `fmt.Println(rank+1, score)`

### Карточки: ZSET и лидерборды

- **ZSET** — Отсортированное множество: уникальные элементы с баллом float64, порядок держит сервер. Внутри — хэш-таблица для балла и список с пропусками для порядка.
- **ZAdd против ZIncrBy** — ZAdd перезаписывает балл целиком — для импорта и ручных правок. ZIncrBy прибавляет атомарно на сервере — для всего, что зарабатывается событиями.
- **ZRevRangeWithScores** — Топ-диапазон от большего балла к меньшему вместе с очками: ZRevRangeWithScores(ctx, key, 0, 9) — лидерборд топ-10 одной командой.
- **ZRevRank и ZScore** — Позиция игрока с нуля и его текущий балл. По отсутствующему игроку обе команды отвечают redis.Nil — проверяйте через errors.Is.
- **ZCount и ZRangeByScore** — Подсчёт и выборка по границам баллов: пороги наград, уровни, матчмейкинг. Границы передаются строками, например 100 и +inf.
- **Балл как время** — Score в виде unix-секунд превращает ZSET в планировщик: ZRangeByScore с Max равным сейчас отдаёт просроченные задачи. Тот же приём — в скользящем окне rate limiter.

Лидерборд собран полностью: прирост на сервере, топ и позиция одной командой каждый, плюс два неочевидных применения — планировщик и тренды по ключам-периодам. Отличие от множеств из прошлого урока сформулируйте для себя одним предложением: там хранился факт присутствия, здесь — ещё и величина. Дальше по курсу данные начинают двигаться между сервисами: урок про [Pub/Sub](/courses/go-redis/lessons/14-pub-sub/) рассылает события всем подписчикам в реальном времени, а после него потоки добавят к рассылке хранение и подтверждения; между ними — пайплайны из урока про [один рейс сети](/courses/go-redis/lessons/16-pipy-lineinye/), если рейтинги станет считать тысячами. Полный план из двадцати пяти уроков — на странице [курса по Redis](/courses/go-redis/). Код урока выполняется локальным Go с Redis в Docker; домашнее задание попросит сравнить ZAdd и ZIncrBy на гонке двух процессов — разница между назначением и приростом станет осязаемой.

## Коротко: что запомнить

- 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 накапливает ошибку округления, а споры о рублях на лидерборде — худший из возможных инцидентов. Для денежных рейтингов умножайте на сто и оперируйте целыми, а дробные баллы оставьте очкам опыта и коэффициентам.
