Распределённый растущий счётчик: как спроектировать без гонок и потерь
Проектируем общий накопительный пул для нагруженной системы: атомарные инкременты, Redis INCRBY, шардирование счётчиков, консистентность и аудит без потерь данных.

Многие продуктовые механики строятся вокруг одного простого числа, которое постоянно растёт: общий призовой пул программы лояльности, общий счётчик обработанных заказов, суммарный остаток бонусных баллов у миллионов пользователей. Похожая механика встречается и в играх с общим накопительным призом, но суть задачи одна и та же независимо от предметной области: нужно безопасно инкрементировать общее число под высокой конкурентной нагрузкой, не теряя события и не задваивая их. Разберём, как спроектировать такой счётчик на уровне архитектуры, а не просто добавить «+1» в базе.
Почему наивный UPDATE ломается под нагрузкой
Первая реализация, которая приходит в голову: таблица с одной строкой и столбцом total, и запрос вида UPDATE pool SET total = total + :amount WHERE id = 1. На малой нагрузке это работает годами. Проблема начинается на масштабе: каждая транзакция блокирует строку, и при тысяче параллельных инкрементов в секунду очередь блокировок превращается в узкое горлышко. Добавляется и более тонкая проблема: если инкремент идёт внутри более крупной транзакции, например, вместе с записью заказа, любая долгая транзакция держит лок на счётчике, и все остальные ждут. На практике это выглядит как внезапный рост задержки именно на операциях с общим пулом, при том что остальная часть системы работает нормально.
BEGIN;
UPDATE pool SET total = total + 10 WHERE id = 1;
COMMIT;
-- при высокой конкуренции строка id=1 становится точкой сериализацииАтомарные инкременты: Redis INCRBY и его границы
Самый простой выход, перенести счётчик в структуру данных, которая атомарна по природе. Команда INCRBY в Redis выполняется на одном потоке сервера и гарантирует отсутствие гонок без явных блокировок: SET pool:total 0, затем INCRBY pool:total 10 из любого числа клиентов одновременно даёт корректную сумму. Это снимает проблему блокировки строки, но не снимает два других вопроса. Первый: Redis по умолчанию хранит данные в памяти, и при падении процесса без настроенной персистентности можно потерять последние операции. Второй: инкремент в Redis и запись бизнес-события в основной базе, это две разные системы без общей транзакции между ними. Если сервис упал между INCRBY и записью в основную базу, пул и журнал операций расходятся.
- Включайте журнал AOF как минимум с fsync раз в секунду, иначе падение сервера стирает секунды последних инкрементов
- Используйте кластер с репликацией, если счётчик критичен для бизнеса
- Для согласованности с основной базой пишите событие в основную базу в одной транзакции с бизнес-операцией, а воркер асинхронно применяет инкремент в Redis
Шардирование счётчика: когда один ключ не выдерживает
Даже атомарный инкремент на одном ключе Redis имеет потолок: все операции на конкретный ключ обслуживает один шард, то есть фактически одно ядро процессора. При очень высокой частоте записи узким местом становится сама пропускная способность одного ядра на запись в один ключ. Решение, знакомое из распределённых систем счётчиков: разбить общий пул на N частичных счётчиков, например pool:total:0 и далее до pool:total:15, писать в случайный или хешированный по клиенту шард, а итоговое значение получать суммированием. Это убирает конкуренцию за один ключ и масштабируется горизонтально, но отображение пула в реальном времени становится чуть менее точным: читающий клиент видит сумму на момент чтения, а не строго последовательный поток изменений.
| Подход | Пропускная способность записи | Сложность |
|---|---|---|
| Один UPDATE в SQL с блокировкой строки | Низкая под нагрузкой | Минимальная |
| Один ключ Redis с INCRBY | Средняя или высокая | Низкая |
| N шардированных ключей и суммирование | Высокая, горизонтально масштабируется | Средняя |
| Счётчик типа G-Counter с мультирегиональной репликацией | Высокая, мультирегион | Высокая |
Консистентность и аудит: как не потерять и не задвоить
Для любого денежного или призового пула одного числа в быстром хранилище недостаточно: должен существовать журнал событий, из которого это число можно пересчитать с нуля. Практическая схема: каждая операция изменения пула получает уникальный ключ идемпотентности, например, ID заказа или события, который пишется в отдельную таблицу перед применением инкремента. Повторная доставка того же события, например из-за ретрая очереди сообщений или падения воркера, проверяется по этому ключу и игнорируется, если уже обработана. Раз в сутки или чаще стоит гонять фоновую задачу, которая суммирует журнал событий и сверяет результат с текущим значением пула: расхождение сигнализирует либо о потерянном инкременте, либо о дублировании.
Чек-лист перед продакшеном
Мониторинг и алертинг для пула
Отдельный счётчик, который растёт месяцами, требует собственного набора метрик, а не только логов приложения. Полезно выводить в систему мониторинга текущее значение пула, скорость его роста за последний интервал и расхождение между суммой по журналу событий и значением в быстром хранилище. Резкий скачок скорости роста часто сигнализирует не о всплеске легитимной активности, а о баге в клиенте, который отправляет повторные инкременты без защиты от дублей. Алерт стоит настраивать не на абсолютное значение, а на отношение: например, если расхождение между журналом и текущим значением превышает заданный процент от общей суммы, это повод остановить обработку новых событий и разобраться, прежде чем пул уйдёт в заметный минус или плюс относительно реальных операций.
Полезная практика, добавить в систему отдельный read-only эндпоинт для сверки, который пересчитывает сумму по журналу за последние N часов и сравнивает с текущим значением без побочных эффектов. Такой эндпоинт можно дёргать как из автоматической задачи, так и руками во время расследования инцидента, и он не заменяет полную пересверку с нуля, но позволяет быстро понять масштаб проблемы: разошлись данные за последний час или рассинхронизация тянется неделями и была замечена только сейчас.
Отдельного внимания заслуживает выбор интервала для фоновой сверки. Слишком частая сверка (каждую секунду) создаёт дополнительную нагрузку на ту же базу, где лежит журнал событий, и может сама стать источником деградации производительности. Слишком редкая сверка (раз в неделю) увеличивает время, за которое накопленное расхождение остаётся незамеченным, и усложняет расследование, потому что к моменту обнаружения проблемы могли накопиться сотни затронутых операций. Практический ориентир: интервал сверки должен быть заметно короче, чем время, за которое бизнес готов терпеть незамеченную рассинхронизацию, обычно это от нескольких минут до часа для денежных пулов и может быть реже для менее критичных счётчиков.
Выбор между собственным Redis-кластером и управляемым облачным сервисом стоит делать с учётом того, кто будет отвечать за операционную сторону вопроса: резервное копирование, обновление версий, мониторинг репликации. Для небольшой команды управляемый сервис обычно выгоднее по суммарным трудозатратам, даже с учётом более высокой прямой стоимости, потому что снимает с команды задачи, которые напрямую не относятся к продукту.
- Атомарность операции подтверждена нагрузочным тестом
- Настроена персистентность и репликация хранилища счётчика
- Есть журнал событий с ключом идемпотентности для защиты от дублей
- Настроена фоновая сверка журнала с текущим значением пула
- Продуман план восстановления после потери данных: пересчёт из журнала, а не ручная правка числа
Частые вопросы
Чем INCRBY в Redis отличается от обычного UPDATE total = total + 1 в SQL?
INCRBY выполняется атомарно на уровне сервера без блокировки строки и без отдельной транзакции, поэтому не создаёт точку сериализации при высокой конкуренции. SQL-UPDATE остаётся безопасным вариантом для низкой и средней нагрузки, где важнее транзакционная согласованность с остальными данными заказа.
Нужно ли шардировать счётчик, если нагрузка пока небольшая?
Нет, шардирование добавляет сложность в чтении, нужно суммировать несколько ключей, и оправдано только при реальном упоре в пропускную способность одного ключа. Для большинства проектов обычный атомарный инкремент с персистентностью выдержит десятки тысяч операций в секунду без проблем.
Как проверить, что счётчик не теряет данные при падении сервиса?
Нужен хаос-тест: во время нагрузочного прогона принудительно убивать процесс хранилища или воркер, который пишет инкременты, и затем сверять итоговую сумму с журналом событий. Если числа расходятся, персистентность или идемпотентность настроены неверно.



