Новое в журнале. Маленькие pull request: почему их быстрее ревьюят и как резать задачу
Разборы и проверки

Распределённый растущий счётчик: как спроектировать без гонок и потерь

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

3 марта 20269 мин чтенияПродвинутым
Распределённый растущий счётчик: как спроектировать без гонок и потерь
INCRBYатомарная операция без блокировок
N шардовмасштабирование записи счётчика

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

  1. Атомарность операции подтверждена нагрузочным тестом
  2. Настроена персистентность и репликация хранилища счётчика
  3. Есть журнал событий с ключом идемпотентности для защиты от дублей
  4. Настроена фоновая сверка журнала с текущим значением пула
  5. Продуман план восстановления после потери данных: пересчёт из журнала, а не ручная правка числа

Частые вопросы

Чем INCRBY в Redis отличается от обычного UPDATE total = total + 1 в SQL?

INCRBY выполняется атомарно на уровне сервера без блокировки строки и без отдельной транзакции, поэтому не создаёт точку сериализации при высокой конкуренции. SQL-UPDATE остаётся безопасным вариантом для низкой и средней нагрузки, где важнее транзакционная согласованность с остальными данными заказа.

Нужно ли шардировать счётчик, если нагрузка пока небольшая?

Нет, шардирование добавляет сложность в чтении, нужно суммировать несколько ключей, и оправдано только при реальном упоре в пропускную способность одного ключа. Для большинства проектов обычный атомарный инкремент с персистентностью выдержит десятки тысяч операций в секунду без проблем.

Как проверить, что счётчик не теряет данные при падении сервиса?

Нужен хаос-тест: во время нагрузочного прогона принудительно убивать процесс хранилища или воркер, который пишет инкременты, и затем сверять итоговую сумму с журналом событий. Если числа расходятся, персистентность или идемпотентность настроены неверно.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор