---
title: "Счётчики: INCR и атомарный рост"
description: "Счётчик просмотров на Redis в Go: INCR и INCRBY, атомарность при гонках, TTL на первое увеличение и ошибка not an integer — разбор на go-redis v9."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/09-schetchiki-i-incr/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

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

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

Начнём с того, как счётчик просмотров делают без Redis — и где он ломается. Первый наивный вариант: таблица posts с колонкой views и запрос UPDATE posts SET views = views + 1 WHERE id = 17. Он корректен, но каждая строка ленты превращается в запись в базе: сто горячих постов умножаются на тысячи просмотров в секунду, и база занимается арифметикой вместо данных. Второй вариант ещё хуже: прочитать значение в Go, прибавить единицу, записать обратно. Между чтением и записью второй инстанс сервиса успевает сделать то же самое, и два просмотра засчитываются как один — классическая гонка read-modify-write, о которой мы говорили в уроке про [хэши](/courses/go-redis/lessons/08-hashi/). 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](/courses/go-advanced/) — горутины и гонки — здесь вы получаете тот же результат, не написав ни строчки синхронизации.

> **Get плюс Set теряет просмотры — всегда.** Реальный сценарий из код-ревью: n, _ := rdb.Get(ctx, key).Int64(); rdb.Set(ctx, key, n+1, 0). На двух инстансах оба прочитают 41 и оба запишут 42 — один просмотр исчезает при каждом наложении. Ловушка ещё и в том, что на одном инстансе в девелопменте баг не воспроизводится: выглядит рабочим, ломается только под нагрузкой. Счётчик растёт только командами INCR-семейства — читайте его, но не пересчитывайте.

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

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

### Суточное окно: 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 откатывает инкремент: счётчик может убывать, но уходить в минус стоит разрешать только там, где это имеет смысл.

> **Один горячий ключ — это потолок.** INCR по одному ключу исполняется последовательно, и на экстремальном трафике — сотни тысяч инкрементов в секунду на один пост — ключ становится узким местом. Рабочий приём — шардирование счётчика: ключи views:post:17:0, :1, :2, инкремент по случайному шарду и сумма трёх значений при чтении. До этого потолка большинство сервисов не доживает — не усложняйте заранее.

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

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

- ✅ 1: отсутствующий ключ считается нулём
- redis.Nil: ключ нужно создать заранее
- 0: команда не сработает по пустому ключу
- Ошибку wrong type

INCR по несуществующему ключу создаёт его со значением единицы — отдельная инициализация не нужна. redis.Nil вернул бы Get по тому же ключу, но инкремент пропускает эту стадию и сразу растёт от нуля.

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

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

- ✅ Ошибку: value is not an integer
- Ключ перезапишется единицей
- Вернёт 0 без ошибки
- Ключ удалится

INCR требует, чтобы текущее значение ключа парсилось как целое число; abc не подходит, и команда вернёт ошибку, не меняя ключ. Отсюда гигиена имён: не смешивайте в одном префиксе счётчики и текстовые значения.

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

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

- 7 — старое значение
- ✅ 8 — новое значение
- nil
- Ошибку, потому что 7 — строка

INCR возвращает значение после инкремента — это удобно и для логов, и для приёма с TTL: n равен единице означает, что ключ только что создан. Хранение чисел строкой — внутреннее дело Redis, парсинг происходит на сервере.

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

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

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

- 7
- 8
- ✅ 12
- ошибку not an integer

Set записал строку 7, первый Incr поднял её до 8 и вернул 8, IncrBy с четвёркой дошёл до 12 — это значение и напечатается. Строка 7 не помешала: Redis парсит её как число, и семейство INCR работает с ней как со счётчиком.

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

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

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

if n == ___ {
        rdb.___(ctx, key, 24*time.Hour)
}
```

Подсказка: Инкремент делает Incr, единица означает рождение ключа, а срок живому ключу задаёт Expire.

Правильные ответы в пропусках: `Incr`, `1`, `Expire`

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

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

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

1. `key := "views:post:17:2024-05-01"`
2. `n, err := rdb.Incr(ctx, key).Result()`
3. `if err != nil {`
4. `    return 0, err`
5. `}`
6. `if n == 1 { rdb.Expire(ctx, key, 24*time.Hour) }`

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

- **INCR** — Атомарный плюс единица на сервере Redis: возвращает новое значение, отсутствующий ключ считает нулём. Инициализация не нужна.
- **INCRBY и DECRBY** — Прирост и убыль на заданное целое; INCRBYFLOAT работает с дробями. Всё то же атомарно и без чтения значения в Go.
- **Атомарность INCR** — Redis исполняет команды последовательно в одном потоке: параллельные инкременты не теряются. Get плюс Set в Go — гонка и потеря событий.
- **TTL на первом инкременте** — Expire вызывают, когда INCR вернул единицу: ключ только что родился. Expire на каждый инкремент растягивает окно до бесконечности.
- **not an integer** — Ошибка INCR, если в ключе лежит нечисловая строка. Счётчики и текст не смешивают в одном пространстве имён ключей.

Счётчики закрыли третий тип задач курса, и впереди самое интересное: атомарный рост — это редкость, а вот команды, которые должны выполниться только у одного из десяти инстансов, — обычное дело в микросервисах. Следующий урок — про [распределённые блокировки на SETNX](/courses/go-redis/lessons/10-raspredelennye-blokirovki/): захват работы на одного исполнителя, токен владения и честные границы приёма. Из этого урока две двери: оконные счётчики превратятся в лимитер запросов в уроке про [rate limiter](/courses/go-redis/lessons/22-rate-limiter/), а групповые счётчики в полях хэша уже ждали вас в уроке про [HSet и профили](/courses/go-redis/lessons/08-hashi/). Проверьте себя на живом Redis: весь код урока запускается локальным Go, Docker с redis:7 поднимает сервер одной строкой.

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

- 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 лучше колонки в PostgreSQL?

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

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

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

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

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