Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~18 мин +20 XP

Множества: SAdd, SInter и уникальность

Множества Redis хранят набор уникальных строк и молча отбрасывают дубликаты — задача, ради которой в обычном коде заводят карту с проверками. В уроке разбираем SAdd, SCard, SMembers и SInter на go-redis v9: посчитаем уникальных посетителей за день, соберём теги и найдём общие интересы двух пользователей.

Содержание урока · 7 разделов

Задача из жизни: продукт просит «сколько уникальных посетителей было вчера». Наивное решение в 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.

Первый визит и счётчик уникальных

GO
func 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 хранят миллиарды элементов в килобайтах с погрешностью около процента — для дашбордов этого хватает, для розыгрыша призов нет. Выбор между точностью и памятью делается по цене ошибки, а не по вкусу. И ещё одно преимущество, которое проявляется при росте: подсчёт не привязан к процессу. Перезапуск сервиса, деплой, второй инстанс за балансировщиком — множество продолжает копиться под тем же ключом, а карта в памяти каждого процесса начинала бы день заново.

SIsMember и SMembers: проверка и выгрузка

GO
func 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-ключа.

Общие интересы двух пользователей

GO
func 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 при отписке.

Команды множеств и их цена#

Команда RedisМетод go-redis v9СложностьЗачем нужна
SADDSAddO(1) на элементДобавить элементы; дубликаты отбрасываются молча
SISMEMBERSIsMemberO(1)Проверить принадлежность, не выгружая данные
SCARDSCardO(1)Сколько уникальных элементов во множестве
SMEMBERSSMembersO(N)Выгрузить всё множество; не для горячих проверок
SINTER / SUNIONSInter, SUnionO(N) от меньшегоПересечение и объединение нескольких множеств
SDIFFSDiffO(N)Разность: есть в первом, нет в остальных
SREM / SPOPSRem, SPopO(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)

Соберите подсчёт уникальных посетителей

Допишите запоминание посетителя, проверку его прошлого визита и подсчёт уникальных.

rdb.(ctx, key, ip) // запомнить посетителя seen, _ := rdb.(ctx, key, ip).Result() // приходил ли он раньше total, _ := rdb.(ctx, key).Result() // сколько уникальных

Сценарий первого визита

Расставьте строки по порядку: построение ключа, запоминание IP, ветка для нового посетителя, подсчёт уникальных.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: множества

Термин

SAdd

нажмите, чтобы перевернуть
Определение

Добавляет элементы во множество и возвращает число реально добавленных: дубликаты отбрасываются молча. added == 1 — значит, элемент встречен впервые.

1 / 6

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

Личные заметкиПусто
Хранятся только в вашем браузере (Redis · Множества: SAdd, SInter и уникальность)

Вопросы и ответы по уроку

Чем множество отличается от списка с проверкой дубликатов в Go?

Уникальность, проверка принадлежности и подсчёт живут на сервере Redis и атомарны: несколько инстансов сервиса не собьют статистику гонками. В Go-коде map со своим mutex работает только внутри одного процесса и умирает при рестарте. Множество ещё и дешевле по сети: вместо выгрузки набора возвращаются ноль или единица.

Как посчитать уникальных посетителей за месяц?

Дневные множества visitors:2024-06-01 плюс SUnion или SInterStore по диапазону дат — точный, но дорогой путь. Если нужна только оценка, копите всё в один HyperLogLog за месяц: PFCount отдаст число с погрешностью около процента при постоянной памяти. Для биллинга и призов точность обязательна — там остаётся множество.

Что вернёт SInter, если одного из ключей не существует?

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

JWT25 уроков~12 чпрактика25 уроков уже доступны

JWT-аутентификация в Go: golang-jwt и bcrypt

Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.

  • Понимать анатомию JWT и почему payload не зашифрован
  • Выпускать и проверять токены через golang-jwt v5
  • Хешировать пароли bcrypt и сравнивать безопасно
  • Писать middleware аутентификации для chi и Gin