Задача из жизни: продукт просит «сколько уникальных посетителей было вчера». Наивное решение в Go — держать срез IP и проверять каждый новый запрос перебором, либо заводить map[string]struct{} и бояться рестартов. На языке SQL это таблица с уникальным индексом и INSERT ON CONFLICT, а значит — нагрузка на базу от каждого просмотра страницы. Redis закрывает вопрос одной структурой: множество принимает элементы командой SAdd, само отбрасывает повторы и отвечает на вопрос «сколько всего» командой SCard за постоянное время. В этом уроке мы живём в множествах: первый визит пользователя, статистика за день, пересечения интересов и честные границы типа. Место множеств среди остальных структур разбиралось в уроке про типы данных Redis — здесь практикуемся на одном типе до уверенности.
Множество Redis: уникальность на стороне сервера#
Ментальная модель — математическое множество: набор уникальных строк без порядка и без повторов. SAdd добавляет один или несколько элементов и возвращает число реально добавленных: повтор не изменит множество и вернёт ноль — из этого бесплатно получается флаг «встретили впервые». Проверка принадлежности SIsMember работает за O(1) независимо от того, десять элементов в множестве или десять миллионов, подсчёт SCard — той же ценой, чтение всего набора — SMembers. Важное отличие от строк и хэшей: внутри множества нет полей и нет баллов — только элементы. Порядка тоже нет: SMembers вернёт элементы в произвольном порядке, и полагаться на него нельзя — когда нужен отсортированный список, это работа для ZSET из следующего урока. Именно отсутствие порядка делает тип таким дешёвым и быстрым: сервер не тратит время на сортировку того, что вам в большинстве случаев просто не нужно. Внутреннее устройство подстраивается под размер: маленькое множество Redis держит компактным списком listpack, а с ростом примерно до сотни элементов переключается на хэш-таблицу — вам об этом думать не нужно, но на графике памяти заметно, что сотня мелких множеств стоит дешевле, чем одна большая карта в Go.
Первый визит и счётчик уникальных
GOfunc markVisit(ctx context.Context, day, ip string) (bool, error) {
key := "visitors:" + day
// SAdd возвращает число добавленных элементов
added, err := rdb.SAdd(ctx, key, ip).Result()
if err != nil {
return false, err
}
return added == 1, nil // единица — посетитель новый
}
func visitorsPerDay(ctx context.Context, day string) (int64, error) {
return rdb.SCard(ctx, "visitors:" + day).Result()
}added равен единице, только если элемента раньше не было: дубликат вернёт ноль, и множество останется прежним. SCard по несуществующему ключу отвечает нулём без ошибки — пустое и отсутствующее множество в Redis неотличимы, и отдельная ветка «ключа ещё нет» не нужна. Срок жизни статистике задаётся Expire на ключ visitors:2024-06-01, как это работает — отдельная тема урока про TTL в начале курса.
Уникальные посетители за день: полный сценарий#
Соберём сценарий от начала до конца. На каждый запрос к странице сервис вызывает markVisit с IP или идентификатором пользователя — это одна сетевая команда, которая выполняется за доли миллисекунды даже под большим трафиком. Когда маркетинг просит цифру, SCard отдаёт её мгновенно — независимо от того, был ли день тихим или пришёл миллион человек. Сравните с INCR из урока про счётчики: счётчик дешевле по памяти, но считает визиты, а не посетителей, и различить их не умеет. Множество честнее, но и прожорливее: строка IP занимает порядка десятков байт, миллион уникальных посетителей съедает десятки мегабайт. Когда точное множество не нужно, а нужна оценка, есть HyperLogLog: PFAdd и PFCount хранят миллиарды элементов в килобайтах с погрешностью около процента — для дашбордов этого хватает, для розыгрыша призов нет. Выбор между точностью и памятью делается по цене ошибки, а не по вкусу. И ещё одно преимущество, которое проявляется при росте: подсчёт не привязан к процессу. Перезапуск сервиса, деплой, второй инстанс за балансировщиком — множество продолжает копиться под тем же ключом, а карта в памяти каждого процесса начинала бы день заново.
Типичная ошибка: прочитать всё множество через SMembers, сложить в map[string]struct{} и проверить элемент в Go. На множестве в сотни тысяч элементов это мегабайты трафика и секунды на сериализацию — на каждый запрос. Проверку делает SIsMember на стороне сервера за O(1), гоняя по сети десяток байт. SMembers оставьте для экспорта и отладки небольших наборов.
SIsMember и SMembers: проверка и выгрузка
GOfunc isFirstVisit(ctx context.Context, day, ip string) (bool, error) {
// проверка на сервере: ничего не гоняем по сети
return rdb.SIsMember(ctx, "visitors:"+day, ip).Result()
}
func listVisitors(ctx context.Context, day string) error {
members, err := rdb.SMembers(ctx, "visitors:" + day).Result()
if err != nil {
return err
}
for ip := range members { // v9: map[string]struct{}
fmt.Println(ip)
}
return nil
}Обратите внимание на тип SMembers в v9: функция возвращает map[string]struct{}, а не срез строк, как в v8, — после апгрейда клиентский код с индексами ломается на компиляции, и это к лучшему. Итерация идёт по ключам карты. SIsMember возвращает булево значение и стоит постоянное время; честная выгрузка SMembers оправдана для экспорта данных и просмотра в отладке, не для горячих проверок на каждый запрос.
SInter и соседи: пересечения, объединения, разности#
Сила множеств раскрывается, когда их становится несколько. SInter возвращает элементы, которые есть во всех перечисленных множествах: общие интересы двух пользователей, аудитория, которой подошли оба фильтра, друзья, знакомые обоим. SUnion собирает всё, что встречается хотя бы в одном множестве, а SDiff отдаёт разность — то, что есть в первом и отсутствует в остальных: «подписки Ани без подписок Миши» решается одной командой. Тяжёлый результат можно материализовать: SInterStore пишет пересечение в новый ключ, которому назначается TTL, и витрина «что у вас общего» перестаёт считать пересечение тысяч элементов на каждый просмотр. Осторожность нужна с размером аргументов: ядро Redis однопоточное, и SInter по двум миллионным множествам задержит всех остальных клиентов на время вычисления. Большие пересечения считайте в фоне, по расписанию, и отдавайте пользователям готовый результат из store-ключа.
Общие интересы двух пользователей
GOfunc commonInterests(ctx context.Context, userA, userB int64) (map[string]struct{}, error) {
keyA := fmt.Sprintf("user:%d:tags", userA)
keyB := fmt.Sprintf("user:%d:tags", userB)
// элементы, которые есть в обоих множествах
return rdb.SInter(ctx, keyA, keyB).Result()
}
func readTags(ctx context.Context, user int64) (map[string]struct{}, error) {
key := fmt.Sprintf("user:%d:tags", user)
return rdb.SMembers(ctx, key).Result()
}SInter принимает любое число ключей; отсутствующее множество считается пустым, поэтому пересечение с несуществующим пользователем честно опустеет вместо ошибки. Результат приходит той же картой, что и SMembers. Схема user:42:tags из примера — стандартное пространство имён для тегов; заполняется она SAdd при подписке, удаляется SRem при отписке.
Если выгрузка всё-таки обязательна, SMembers на миллионе элементов занимает память и соединение на секунды. Идите итератором: iter := rdb.SScan(ctx, key, 0, "", 500).Iterator() с циклом для iter.Next(ctx) отдаёт элементы порциями, сеть и память нагружаются равномерно, а другие клиенты не замечают обхода. Полный проход дольше одной команды, зато предсказуем и безопасен.
Команды множеств и их цена#
| Команда Redis | Метод go-redis v9 | Сложность | Зачем нужна |
|---|---|---|---|
| SADD | SAdd | O(1) на элемент | Добавить элементы; дубликаты отбрасываются молча |
| SISMEMBER | SIsMember | O(1) | Проверить принадлежность, не выгружая данные |
| SCARD | SCard | O(1) | Сколько уникальных элементов во множестве |
| SMEMBERS | SMembers | O(N) | Выгрузить всё множество; не для горячих проверок |
| SINTER / SUNION | SInter, SUnion | O(N) от меньшего | Пересечение и объединение нескольких множеств |
| SDIFF | SDiff | O(N) | Разность: есть в первом, нет в остальных |
| SREM / SPOP | SRem, SPop | O(1) / O(1) | Удалить конкретные элементы или вынуть случайный |
Вариаций вокруг базовых команд хватает на целую подсистему. Теги статьи живут в множестве, и фильтр «все статьи с тегами go и redis» — это SInter по ключам тегов. Розыгрыш призов среди участников — SPop, который вынимает случайный элемент без возврата, или SRandMemberN, который случайных элементов не удаляет. Перенос элемента между множествами — SMove за одну атомарную операцию: переквалификация пользователя из новичков в активы без гонок. Границы типа тоже ясны: нужен порядок — берите ZSET, нужны повторы со счётчиком — INCR или список, нужны поля — хэш. И помните про издержки: SMembers по множеству в миллион элементов — это миллион строк в ответе, поэтому интерфейс больших множеств строится на SCard и SIsMember, а выгрузка — на порционных обходах из совета выше. Ещё одна вариация — белые и чёрные списки: множество заблокированных адресов проверяется SIsMember на каждом входе, а добавление и снятие банов — SAdd и SRem без деплоя кода. Внутренняя цена такой проверки — постоянное время, и она дешевле любой таблицы в базе там, где нужна скорость, а не история изменений.
Проверка: дубликаты в SAdd
IP уже есть во множестве visitors:2024-06-01. Что вернёт повторный rdb.SAdd(ctx, key, ip).Result()?
Проверка: SCard по пустоте
Ключа visitors:2024-06-01 не существует. Что вернёт rdb.SCard(ctx, key).Result()?
Сколько элементов во множестве?
Прочитайте код и выберите, что он выведет:
rdb.SAdd(ctx, "tags:go", "redis")
rdb.SAdd(ctx, "tags:go", "redis")
rdb.SAdd(ctx, "tags:go", "go")
n, _ := rdb.SCard(ctx, "tags:go").Result()
fmt.Println(n)Соберите подсчёт уникальных посетителей
Допишите запоминание посетителя, проверку его прошлого визита и подсчёт уникальных.
Сценарий первого визита
Расставьте строки по порядку: построение ключа, запоминание IP, ветка для нового посетителя, подсчёт уникальных.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: множества
Множества закрыли вопрос уникальности, но у них нет главного — порядка. Как только цифры нужно показывать отсортированными: топ игроков, самые популярные товары, рейтинг коротких ссылок — включается следующий тип, и урок про лидерборды на ZSET начинается ровно с этой границы. Если теги живут в базе данных, рядом будет полезен курс по GORM — там связь many-to-many с таблицей тегов; множества Redis часто становятся кэшем поверх неё. Полный план с остальными уроками — на странице курса по Redis. Код урока, как и раньше, выполняется локальным Go с Redis в Docker: песочница сетевых соединений не открывает, а двух окон терминала с redis-cli достаточно, чтобы увидеть пересечение своими глазами. Домашнее задание ниже намеренно доводит сценарий до конца — от первого визита до пересечения тегов и TTL на ключе статистики.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Множество — набор уникальных строк без порядка: SAdd отбрасывает дубликаты и возвращает число реально добавленных.
- SIsMember проверяет принадлежность за O(1) на сервере; SMembers ради одной проверки — мегабайты по сети и антипаттерн.
- SCard по несуществующему ключу отвечает нулём: пустое и отсутствующее множество в Redis неотличимы.
- Уникальные посетители за день: SAdd на каждый визит, SCard для цифры; HyperLogLog оценивает миллионы в килобайтах с погрешностью около процента.
- SInter, SUnion и SDiff находят общие элементы, всё вместе и разность; тяжёлые пересечения материализуют SInterStore-ом в ключ с TTL.
- Нужен порядок или баллы — это ZSET; множества хранят уникальность без сортировки и не отвечают на вопрос «кто выше».
Домашнее задание
Соберите подсчёт уникальных посетителей: markVisit с флагом первого визита и visitorsPerDay через SCard. Запустите локальным Go с Redis в Docker, отправьте десять запросов с трёх разных IP и проверьте, что счётчик равен трём, а флаг первого визита сработал только трижды. Затем добавьте второму пользователю набор тегов через SAdd и найдите commonInterests — проверьте результат в redis-cli командой SINTER. В конце поставьте на ключ статистики Expire на сутки и убедитесь, что данные исчезнут без вашего участия.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем множество отличается от списка с проверкой дубликатов в Go?
Уникальность, проверка принадлежности и подсчёт живут на сервере Redis и атомарны: несколько инстансов сервиса не собьют статистику гонками. В Go-коде map со своим mutex работает только внутри одного процесса и умирает при рестарте. Множество ещё и дешевле по сети: вместо выгрузки набора возвращаются ноль или единица.
Как посчитать уникальных посетителей за месяц?
Дневные множества visitors:2024-06-01 плюс SUnion или SInterStore по диапазону дат — точный, но дорогой путь. Если нужна только оценка, копите всё в один HyperLogLog за месяц: PFCount отдаст число с погрешностью около процента при постоянной памяти. Для биллинга и призов точность обязательна — там остаётся множество.
Что вернёт SInter, если одного из ключей не существует?
Пустую карту без ошибки: Redis считает отсутствующее множество пустым, а пересечение с пустым всегда пусто. Это удобно — код не требует проверок существования, — но опасно при опечатке в имени ключа: вместо ошибки вы тихо получите ноль общих элементов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Отсортированные множества: лидерборды
- Pub/Sub: рассылка событий в реальном времени
- Streams: журнал событий вместо очереди
- Пайплайны: много команд за один рейс
- Списки: LPush, RPop и очереди задач
- Распределённые блокировки: SETNX
- Счётчики: INCR и атомарный рост
- Хэши: HSet, HGet и профили пользователей
- Все курсы по Go
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin