Счётчики в PostgreSQL: как избежать гонок при инкременте
Если два запроса одновременно прочитали счётчик со значением 10, а затем записали 11, один инкремент потеряется. В PostgreSQL это решается атомарным обновлением — но для горячих строк и повторных попыток есть нюансы.

В PostgreSQL 16 два параллельных запроса могут прочитать значение счётчика 10 и оба записать 11: один инкремент пропадёт. Чтобы этого не случилось, изменение нужно выполнять внутри самого SQL-запроса, а не собирать из отдельных «прочитать — посчитать — записать». Ниже — рабочие варианты для счётчика, который уже хранится в таблице, и случаи, когда атомарный инкремент всё равно становится узким местом.
Откуда берётся потерянный инкремент
Представим таблицу с одной строкой: value = 10. Приложение сначала выполняет SELECT value, увеличивает результат на единицу у себя и отправляет UPDATE ... SET value = 11. Если два запроса успевают прочитать 10 до записи первого, оба отправят одно и то же значение. В базе останется 11, хотя запросов было два.
Проблема не в том, что PostgreSQL плохо блокирует таблицу. Проблема в том, что приложение передаёт в запрос уже рассчитанное значение и тем самым затирает чужое обновление.
Для обычного счётчика не читайте число в приложение, чтобы тут же записать его обратно. Попросите PostgreSQL прибавить единицу к текущему значению строки.
Атомарное обновление: вариант по умолчанию
Если строка счётчика уже есть, используйте выражение в UPDATE:
UPDATE counters
SET value = value + 1
WHERE counter_key = $1
RETURNING value;
PostgreSQL блокирует изменяемую строку на время обновления. Если другой запрос одновременно меняет её, он дождётся освобождения блокировки и применит выражение к актуальной версии строки, а не к числу, которое приложение прочитало раньше. В режиме изоляции по умолчанию, READ COMMITTED, конкурентные инкременты такой записью не теряются.
RETURNING вернёт новое значение, не требуя отдельного запроса на чтение. Если подходящей строки нет, запрос не обновит ничего и не вернёт строку. Это не ошибка PostgreSQL — приложение должно понимать, ожидается ли уже созданный счётчик.
Для простой таблицы ключ счётчика должен быть уникальным. Например, counter_key можно сделать первичным ключом, а для неотрицательных значений добавить ограничение CHECK (value >= 0). Тогда база защитит от дублей и случайного перехода в минус.
Если строку нужно создать при первом обращении
Когда счётчик может отсутствовать, используйте вставку с обработкой конфликта по уникальному ключу:
INSERT INTO counters (counter_key, value)
VALUES ($1, 1)
ON CONFLICT (counter_key) DO UPDATE
SET value = counters.value + EXCLUDED.value
RETURNING value;
Первый запрос создаст строку со значением 1. Следующие обнаружат конфликт по counter_key и увеличат существующее значение. Уникальное ограничение обязательно: без него PostgreSQL не сможет определить, какой конфликт нужно обработать.
EXCLUDED — специальная ссылка на строку, которую запрос пытался вставить. Здесь её значение равно единице. Если нужно прибавлять не единицу, передавайте при вставке величину изменения вместо 1.
Какой способ выбрать
| Подход | Когда подходит | Цена и риск |
|---|---|---|
UPDATE value = value + 1 | Строка уже существует | Короткий атомарный запрос; при высокой конкуренции запросы ждут одну и ту же строку |
INSERT ... ON CONFLICT DO UPDATE | Строку нужно создать при первом событии | Удобно для счётчика с ленивым созданием; нужен уникальный ключ |
Чтение с FOR UPDATE, затем изменение в транзакции | Новое значение зависит от нескольких проверок и действий | Блокировка удерживается дольше; легко увеличить число запросов и время ожидания |
Блокировка через FOR UPDATE нужна, когда нельзя выразить правило одним обновлением: например, перед изменением нужно проверить несколько полей той же строки. Чтение и запись должны быть в одной транзакции. Для простого прибавления единицы этот вариант избыточен: достаточно атомарного UPDATE.
Уменьшение счётчика и бизнес-ограничения
Для количества товара или доступных мест недостаточно избежать потерянного обновления: значение не должно стать отрицательным. Проверку можно включить в условие того же запроса:
UPDATE counters
SET value = value - 1
WHERE counter_key = $1
AND value > 0
RETURNING value;
Если запрос вернул строку, значение уменьшилось. Если не вернул — ключа нет или значение уже равно нулю. Различайте эти случаи, если бизнес-логике важно сообщить покупателю именно «товар закончился», а не «счётчик не найден».
Не проверяйте остаток отдельным SELECT, а затем не выполняйте безусловное уменьшение. Между двумя запросами другой клиент может забрать последнее доступное место. Условие value > 0 и само уменьшение должны оставаться частью одного изменения.
Что ломается под нагрузкой
Атомарность не означает, что одна строка сможет принимать неограниченный поток обновлений без очереди. Все запросы к одному ключу по очереди ждут блокировку этой строки. На горячем счётчике растёт время ожидания, даже если само обновление очень короткое.
- Счётчик обновляют слишком часто. Если точное значение не нужно после каждого события, копите изменения и записывайте их пакетами либо храните события отдельно, а итог пересчитывайте по расписанию. Цена — задержка между событием и отображаемым итогом.
- Счётчик делят на несколько строк. Это уменьшает конкуренцию за одну строку, но для точного общего значения придётся складывать части. Чтение становится дороже, а выбор части должен распределять нагрузку.
- Одна транзакция меняет несколько счётчиков в разном порядке. Параллельные транзакции могут ждать друг друга и попасть в взаимную блокировку. Обновляйте ключи в одинаковом порядке, а приложение готовьте к повтору всей транзакции при ошибке сериализации или deadlock.
- Запрос повторяют после тайм-аута. Сервер мог успеть выполнить инкремент, даже если приложение не получило ответ. Повтор запроса тогда добавит ещё единицу. Для событий, которые нельзя применить дважды, нужен отдельный идентификатор события и защита от повторной обработки.
Взаимная блокировка (deadlock) — ситуация, когда транзакции удерживают блокировки, нужные друг другу. PostgreSQL прерывает одну из них; приложение должно обработать ошибку, а при безопасной операции — повторить транзакцию целиком.
Как проверить, что счётчик выдерживает конкуренцию
- Запустите параллельно несколько тысяч одинаковых атомарных инкрементов на одном ключе в тестовой базе.
- Дождитесь завершения всех запросов и сравните итог с числом успешно выполненных операций. Записывайте также ошибки и повторы, иначе тест покажет только часть картины.
- Повторите тест с разными ключами. Если время резко улучшается, узкое место, вероятно, в конкуренции за одну строку, а не в общей пропускной способности базы.
- В рабочей среде наблюдайте за задержкой запросов, ожиданиями блокировок и количеством повторов транзакций. Снимайте показатели именно для характерной нагрузки вашего приложения: универсальной цифры, после которой счётчик обязательно станет проблемой, нет.
Для небольшого счётчика начните с UPDATE ... SET value = value + 1 или upsert, если строка создаётся по требованию. Переходить к пакетным записям или разбиению счётчика стоит только после измерений: они снижают конкуренцию, но добавляют задержку, сложность чтения и требования к точности.
Что делать дальше
Проверьте все места, где приложение читает значение, прибавляет число вне базы и записывает результат обратно. Замените эту последовательность атомарным SQL-обновлением, добавьте уникальное ограничение на ключ и прогоните тест с параллельными запросами. Если счётчик остаётся горячей строкой, сначала измерьте ожидания блокировок и частоту повторов — затем выбирайте между пакетной записью и разделением значения.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: PostgreSQL 16: INSERT, PostgreSQL 16: уровни изоляции транзакций
Частые вопросы
Достаточно ли написать value = value + 1?
Что вернёт запрос, если счётчика ещё нет?
Можно ли таким способом хранить остаток товара?
Что делать, если приложение повторило запрос после тайм-аута?
Работает ли это в другой версии PostgreSQL?
Почему точный счётчик тормозит при большом числе запросов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.