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

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

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

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

Счётчики: INCR и атомарный рост

Счётчик просмотров в Redis растёт одной командой: INCR увеличивает значение ключа на сервере и возвращает новый результат, поэтому ни чтения, ни записи, ни блокировок в Go-коде не нужно. Разберём INCR и INCRBY на go-redis v9: почему счётчик безопасен при гонках, как поставить TTL на первое увеличение и что сломает строка вместо числа.

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

Начнём с того, как счётчик просмотров делают без Redis — и где он ломается. Первый наивный вариант: таблица posts с колонкой views и запрос UPDATE posts SET views = views + 1 WHERE id = 17. Он корректен, но каждая строка ленты превращается в запись в базе: сто горячих постов умножаются на тысячи просмотров в секунду, и база занимается арифметикой вместо данных. Второй вариант ещё хуже: прочитать значение в Go, прибавить единицу, записать обратно. Между чтением и записью второй инстанс сервиса успевает сделать то же самое, и два просмотра засчитываются как один — классическая гонка read-modify-write, о которой мы говорили в уроке про хэши. Redis решает обе проблемы одной командой: INCR выполняется на сервере целиком, значение ключа растёт в памяти на миллионы в секунду, а Go-код лишь получает результат. В этом уроке соберём счётчик просмотров с суточным окном, разберём семейство команд и честно обсудим, когда инкремент в Redis — плохая идея.

INCR: атомарный рост на стороне сервера#

Семантика INCR умещается в три факта. Первый: команда увеличивает строковое значение ключа, понятое как целое число, на единицу и возвращает новое значение — вызывающий сразу видит результат и может использовать его как идентификатор, номер окна или условие. Второй: если ключа нет, он считается нулём, и первый INCR вернёт единицу — заводить ключ заранее не нужно, счётчик создаётся сам собой. Третий: атомарность гарантирована архитектурой Redis, который исполняет команды последовательно в одном потоке; две параллельные горутины, два инстанса сервиса и два разных клиента не могут получить один и тот же результат инкремента. В go-redis v9 команда называется Incr, принимает контекст и ключ и возвращает IntCmd — из него методом Result() достаётся int64. Близкие родственники закрывают остальные случаи: IncrBy прибавляет произвольное целое, DecrBy вычитает, IncrByFloat работает с дробями. Отдельно подчеркну тип: значение хранится строкой, но Redis сам парсит её как число — записи вроде 7 или 42 инкрементируются, а вот abc приведёт к ошибке, о ней ниже в врезке.

Счётчик просмотров: одна команда

GO
func countView(ctx context.Context, postID int) (int64, error) {
        key := fmt.Sprintf("views:post:%d", postID)

        n, err := rdb.Incr(ctx, key).Result()
        if err != nil {
                return 0, err
        }

        return n, nil
}

Вся функция — один INCR: ключ views:post:17 отсутствовал — создастся с единицы, существовал — вырастет на один и вернёт новое значение. Ни чтения, ни условных записей в коде нет, поэтому гонок здесь не существует по построению. Ошибка возможна только на уровне сети или если в ключе лежит нечисловая строка.

Почему INCR безопасен при гонках#

Атомарность — слово затёртое, поэтому расшифруем механику. Redis исполняет команды строго по одной: сетевой слой вычитывает запросы, ставит их в очередь, и каждый INCR от начала до конца — одна непрерываемая операция чтения-сложения-записи. Именно поэтому вариант «прочитал, прибавил, записал» на стороне Go проигрывает всегда: между тремя вашими шагами чужой INCR уже успел изменить значение, и вы записали поверх него устаревший результат. С INCR промежуточного состояния не существует ни для кого: десять инстансов сервиса, стреляющих по одному ключу, гарантированно получат итог ровно на десять больше. Для сравнения: в чистом Go ту же проблему пришлось бы решать sync.Mutex или атомарными типами — но они работают в пределах одного процесса, а счётчик просмотров живёт в памяти одного пода, когда сервис масштабируется. Распределённый счётчик из нескольких инстансов — это по определению общий внешний источник правды, и Redis здесь самая дешёвая реализация. Если тема синхронизации между процессами знакома по курсу go-advanced — горутины и гонки — здесь вы получаете тот же результат, не написав ни строчки синхронизации.

Счётчик просмотров с TTL: окно фиксированной длины#

Суточные счётчики — самый частый запрос после простых просмотров: сколько прочли пост сегодня, сколько запросов за минуту, сколько попыток входа за час. Схема одинакова для всех: ключ содержит окно в своём имени или живёт коротким TTL. Вариант с датой в ключе — views:post:17:2024-05-01 — самозакрывающийся: новый день делает новый ключ, старые умирают по TTL с запасом в сутки-двое, а чистка не требует кода. Вариант с обычным ключом требует TTL, и вот здесь прячется типовая ошибка: вызывать Expire после каждого инкремента нельзя — каждое чтение продлевает срок, и при постоянном трафике ключ живёт вечно, а окно растягивается до бесконечности. Правильный приём: ставить TTL только на первом инкременте, когда INCR вернул единицу — это ровно момент рождения ключа. Альтернатива без условия: SET key 0 EX ttl NX — создать ключ с сроком, если его нет, и потом инкрементировать; NX гарантирует, что TTL не перезапишется. Обе схемы — фундамент лимитеров: в уроке про rate limiter они превратятся в ограничение частоты запросов, а детали работы сроков — в уроке про TTL.

Суточное окно: TTL на первом инкременте

GO
func countDailyView(ctx context.Context, postID int) (int64, error) {
        key := fmt.Sprintf("views:post:%d:%s",
                postID, time.Now().Format("2006-01-02"))

        n, err := rdb.Incr(ctx, key).Result()
        if err != nil {
                return 0, err
        }

        if n == 1 { // ключ родился — теперь у него есть срок
                rdb.Expire(ctx, key, 48*time.Hour)
        }

        return n, nil
}

Дата в имени ключа закрывает сутки сама: 2024-05-02 создаст новый счётчик без всяких сбросов. Expire срабатывает один раз — на первом инкременте, когда n равен единице; повторные Expire не вызываются, поэтому TTL не растягивается под постоянным трафиком. Запас в сорок восемь часов нужен, чтобы ключ дожил до конца суток, даже если родился в последнюю минуту дня.

Семейство инкрементов#

КомандаЧто делаетТипичный случай
INCRПлюс единица, возвращает новое значениеПросмотры, клики, попытки входа
INCRBYПлюс заданное целоеНачисление бонусов пачкой
DECR и DECRBYМинус единица и минус заданное целоеОтмена действия, возврат товара
INCRBYFLOATПлюс дробное числоМетрики с копейками и долями
HIncrByИнкремент поля внутри хэшаГруппа счётчиков: лайки и дизлайки профиля

Где жить счётчику: строка или поле хэша#

Первый вопрос проектирования: самостоятельный ключ или поле внутри хэша. Одиночный счётчик с собственным жизненным циклом — строковый ключ: views:post:17 инкрементируется, истекает и удаляется независимо, имя само документирует содержимое. Группа счётчиков одного объекта — хэш с HIncrBy: у профиля с лайками, дизлайками и просмотрами общий TTL и общий ключ, и три поля дешевле трёх строк. Второй вопрос — переживание перезапуска: Redis хранит данные в памяти, а на диск сбрасывает снапшотами RDB или журналом AOF. При настройке по умолчанию последний интервал может не дожить до сброса, и счётчик откатится на десятки значений. Для просмотров это безразлично, для биллинга — недопустимо: там счётчик в Redis либо периодически сбрасывается в базу через периодический Get и UPDATE, либо дублируется транзакцией в PostgreSQL. Третий вопрос — история: счётчик с датой в ключе сам себе архив, и за неделю суточных окон уже можно строить графики без отдельного хранилища. Эти решения принимаются до кода — поменять форму ключа на живом трафике дороже, чем выбрать её заранее.

Чтение и отмена действия

GO
func currentViews(ctx context.Context, postID int) (int64, error) {
        key := fmt.Sprintf("views:post:%d", postID)

        n, err := rdb.Get(ctx, key).Int64()
        if errors.Is(err, redis.Nil) {
                return 0, nil // счётчик ещё не завёлся — это не ошибка
        }

        return n, err
}

func cancelView(ctx context.Context, postID int) error {
        key := fmt.Sprintf("views:post:%d", postID)

        return rdb.DecrBy(ctx, key, 1).Err()
}

Метод Int64 у StringCmd читает ключ и сразу парсит число — для строковых счётчиков это самый короткий путь. redis.Nil означает, что просмотров ещё не было: возвращаем ноль как норму, а не как сбой. DecrBy откатывает инкремент: счётчик может убывать, но уходить в минус стоит разрешать только там, где это имеет смысл.

Проверка: первый инкремент

Ключ views:post:17 не существует. Что вернёт rdb.Incr(ctx, "views:post:17").Result()?

Проверка: строка вместо числа

В ключе status:17 лежит строка abc. Что произойдёт при rdb.Incr(ctx, "status:17")?

Проверка: что возвращает Incr

Ключ views равен 7. Что вернёт rdb.Incr(ctx, "views").Result()?

Что напечатает счётчик?

Прочитайте код и выберите, что он выведет:

rdb.Set(ctx, "views:post:1", "7", 0)

rdb.Incr(ctx, "views:post:1")

n, _ := rdb.IncrBy(ctx, "views:post:1", 4)

fmt.Println(n)

Соберите оконный счётчик

Допишите инкремент, проверку первого прироста и установку срока жизни.

n, err := rdb.(ctx, key).Result() if err != nil { return 0, err } if n == { rdb.(ctx, key, 24*time.Hour) }

Окно на первом инкременте

Расставьте строки оконного счётчика по порядку: от ключа до установки TTL.

Строки

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

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

Карточки: счётчики

Термин

INCR

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

Атомарный плюс единица на сервере Redis: возвращает новое значение, отсутствующий ключ считает нулём. Инициализация не нужна.

1 / 5

Счётчики закрыли третий тип задач курса, и впереди самое интересное: атомарный рост — это редкость, а вот команды, которые должны выполниться только у одного из десяти инстансов, — обычное дело в микросервисах. Следующий урок — про распределённые блокировки на SETNX: захват работы на одного исполнителя, токен владения и честные границы приёма. Из этого урока две двери: оконные счётчики превратятся в лимитер запросов в уроке про rate limiter, а групповые счётчики в полях хэша уже ждали вас в уроке про HSet и профили. Проверьте себя на живом Redis: весь код урока запускается локальным Go, Docker с redis:7 поднимает сервер одной строкой.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • INCR растит ключ на сервере атомарно и возвращает новое значение; отсутствующий ключ считается нулём.
  • Семейство: IncrBy и DecrBy для целых шагов, IncrByFloat для дробей, HIncrBy для полей внутри хэша.
  • Get плюс Set на стороне Go — потерянные события под нагрузкой; счётчик растёт только инкрементами.
  • Окно фиксированной длины: дата в имени ключа или Expire на первом инкременте, когда n равен единице.
  • INCR по нечисловой строке даёт ошибку not an integer — счётчики не смешивают с текстом в одном пространстве имён.
  • Для биллинга помните про персистентность Redis: последние инкременты могут не дожить до сброса на диск.

Домашнее задание

Соберите countDailyView из урока и прогоните его локальным Go с Redis в Docker: десять вызовов в цикле, затем проверьте ключ в redis-cli через GET и TTL — срок должен стоять, потому что первый инкремент его задал. Затем сломайте код намеренно: замените Incr на пару Get и Set, запустите два процесса параллельно и посчитайте, сколько событий пропало. Верните Incr и убедитесь, что итог совпал с числом вызовов.

Личные заметкиПусто
Хранятся только в вашем браузере (Redis · Счётчики: INCR и атомарный рост)

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

Чем счётчик в Redis лучше колонки в PostgreSQL?

Скоростью и ценой: INCR — операция в памяти без блокировок строк и записи на диск на каждом шаге, миллионы инкрементов в секунду доступны одному ключу с шардированием. База даёт durability и аналитику — поэтому типовая схема: Redis для горячего счёта, периодический сброс итогов в базу.

INCR безопасен при нескольких инстансах сервиса?

Да: атомарность обеспечивается сервером Redis, который исполняет команды последовательно, и количество инстансов Go-приложения роли не играет. Гонка возникает только если вы читаете значение и записываете его обратно на стороне приложения — этого делать не нужно.

Как считать просмотры с точностью до копейки — там дроби?

INCRBYFLOAT прибавляет дробное число, но помните про особенности представления: для денег и точных метрик лучше целые копейки через INCRBY. Дробные счётчики уместны для коэффициентов и процентов, где цена ошибки округления терпима.

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

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

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

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