Числа, счётчики и атомарный инкремент

Считать просмотры страницы — задача, которая в обычной БД полна гонок данных. В Redis она решается одной командой.

«Прочитал, прибавил один, записал обратно» — классический рецепт состояния гонки. Redis делает это атомарно, и проблема исчезает.

Допустим, вы считаете просмотры статьи. В реляционной БД наивный подход — прочитать текущее значение, прибавить единицу, записать обратно. Если два запроса делают это одновременно, один инкремент потеряется. В Redis есть атомарные команды, которые делают всё за один шаг.

Команды для чисел

127.0.0.1:6379> SET views 0
OK
127.0.0.1:6379> INCR views
(integer) 1
127.0.0.1:6379> INCR views
(integer) 2
127.0.0.1:6379> INCRBY views 10
(integer) 12
127.0.0.1:6379> DECR views
(integer) 11
127.0.0.1:6379> INCRBYFLOAT price 1.5
"1.5"

INCR увеличивает на 1, DECR уменьшает, INCRBY/DECRBY — на заданное число, INCRBYFLOAT — для дробных. Если ключа не было, Redis считает его нулём и создаёт.

Почему это атомарно

Redis обрабатывает команды последовательно, по одной. Когда выполняется INCR views, никакая другая команда не может вклиниться между чтением и записью. Поэтому даже под нагрузкой в тысячи параллельных запросов каждый инкремент учитывается. Это и делает Redis идеальным для счётчиков.

   Гонка данных в обычной БД vs атомарный INCR

   Без атомарности:           С INCR:
   Поток A: read 5            Поток A: INCR -> 6
   Поток B: read 5            Поток B: INCR -> 7
   A: write 6                 (последовательно,
   B: write 6                  без потерь)
   Итог: 6 (потеряли +1!)     Итог: 7

Демонстрация: атомарный счётчик на чистом Python

Чтобы прочувствовать, как Redis защищает счётчик от гонок, смоделируем оба сценария на stdlib. Запустите — увидите разницу.

import threading

# Небезопасный счётчик: read-modify-write без блокировки
unsafe = {"v": 0}
def bad():
    for _ in range(10000):
        tmp = unsafe["v"]   # read
        tmp = tmp + 1       # modify
        unsafe["v"] = tmp   # write (тут может вклиниться другой поток)

# Атомарный счётчик: как INCR в Redis (операция неделима)
lock = threading.Lock()
safe = {"v": 0}
def good():
    for _ in range(10000):
        with lock:          # имитируем атомарность Redis
            safe["v"] += 1

for fn, label in [(bad, "Небезопасный"), (good, "Атомарный (как INCR)")]:
    if label.startswith("Не"):
        ts = [threading.Thread(target=fn) for _ in range(4)]
    else:
        ts = [threading.Thread(target=fn) for _ in range(4)]
    for t in ts: t.start()
    for t in ts: t.join()

print("Небезопасный счётчик:", unsafe["v"], "(ожидалось 40000)")
print("Атомарный счётчик:   ", safe["v"], "(ожидалось 40000)")
print("Вывод: без атомарности часть инкрементов теряется.")
print("Redis INCR гарантирует точный результат всегда.")

Небезопасный счётчик почти наверняка покажет число меньше 40000 — это потерянные инкременты. Redis INCR такого не допускает по определению.

Как работает под капотом

Когда значение «выглядит как число», Redis хранит его не как текст, а как нативное целое (encoding int). INCR тогда — это прямое арифметическое сложение в памяти, без парсинга строки. Если значение не парсится как число, INCR вернёт ошибку. Команды счётчиков ограничены 64-битными знаковыми целыми.

Частые ошибки

  • Делать INCR над нечисловой строкой — получите ошибку value is not an integer.
  • Использовать INCRBYFLOAT для денег без понимания, что float неточен. Для финансов храните копейки как целое и используйте INCR.
  • Забывать TTL на счётчиках лимитов — счётчик без срока жизни будет расти вечно.

Best practices

  • Для любых счётчиков (просмотры, лайки, лимиты) используйте INCR/INCRBY — это атомарно и быстро.
  • Деньги храните в минимальных единицах (копейки) как целые числа.
  • Счётчикам с временным окном задавайте TTL через SET ... EX или EXPIRE.

Итог: Атомарные команды INCR/DECR/INCRBY решают проблему гонок при подсчёте. Однопоточная природа Redis гарантирует точность даже под высокой нагрузкой. Числа хранятся эффективно как нативные целые.

Счётчики с временным окном

Самое частое применение атомарных счётчиков на практике — счётчики с TTL, которые сами сбрасываются. На них строятся ограничители частоты и метрики «за период»:

# Счётчик запросов на минуту для пользователя
INCR ratelimit:user:42:minute
EXPIRE ratelimit:user:42:minute 60   # окно живёт 60 секунд

Идея: ключ создаётся при первом запросе и автоматически исчезает через минуту. На следующей минуте отсчёт начнётся с нуля — потому что старого ключа уже нет. Так получается «скользящее окно» почти бесплатно. Подробный разбор такого rate-limiter будет в разделе про блокировки и Lua.

Важная тонкость: INCR и EXPIRE — две команды, и между ними возможен сбой, оставляющий ключ без TTL навсегда. Поэтому в продакшене эту пару часто оборачивают в Lua-скрипт, чтобы выполнить их атомарно. Запомните этот паттерн: «инкремент + условный EXPIRE на первом обращении» — он встречается повсюду, от лимитов API до подсчёта голосований.

Проверьте себя
1. Почему INCR в Redis безопасен при тысячах параллельных запросов?
ARedis использует блокировки на уровне строк, как PostgreSQL
BRedis обрабатывает команды последовательно, поэтому INCR атомарен — ничто не вклинивается между чтением и записью
CINCR откатывает транзакцию при конфликте
DINCR работает только с одним клиентом за раз по принципу очереди подключений
2. Что вернёт INCR, если ключ содержит значение hello?
A1, проигнорировав текст
B0
CОшибку: значение не является целым числом
Dnil