sqlpostgresqlconcurrencyupsert

Atomic Counter: UPDATE SET n = n + 1 Without Lost Updates

Why read-modify-write loses increments under load, and how a single UPDATE SET n = n + 1 keeps a counter correct under concurrency.

3 λεπτά ανάγνωσηςReferencesql · postgresql · concurrency · upsert · returning
Αυτό το άρθρο είναι προς το παρόν στα ρωσικά — η αγγλική μετάφραση βρίσκεται σε εξέλιξη.

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

Баг потерянного обновления

Представим таблицу счётчиков просмотров для пользователей:

CREATE TABLE counters (
    id      bigint PRIMARY KEY,
    n       bigint NOT NULL DEFAULT 0
);

Типичный код в приложении выглядит как read-modify-write:

-- step 1: read
SELECT n FROM counters WHERE id = 1;   -- got 41
-- step 2: app adds 1 in memory -> 42
-- step 3: write back
UPDATE counters SET n = 42 WHERE id = 1;

Пусть два процесса одновременно прочитали 41. Оба прибавили единицу и оба записали 42. Два инкремента превратились в один — это и есть lost update. Под нагрузкой счётчик систематически отстаёт, и никакой try/catch это не ловит: ошибки нет, есть молча потерянные единицы.

Один запрос вместо трёх

Решение — переложить и чтение, и арифметику, и запись на сам UPDATE:

UPDATE counters SET n = n + 1 WHERE id = 1;

Здесь n + 1 вычисляется по текущему значению строки в момент записи, а не по тому, что приложение прочитало секунду назад. PostgreSQL берёт блокировку на строку: второй параллельный UPDATE ждёт фиксации первого и затем прибавляет к уже обновлённому значению. Два запроса дают +2, как и положено. Атомарность здесь — свойство одного оператора, а не вашей дисциплины.

Тот же приём работает для любых накоплений, например для суммы заказов клиента:

UPDATE users
SET orders_total = orders_total + (
    SELECT amount FROM orders WHERE id = 5001
)
WHERE id = 42;

Upsert: создать или увеличить

Часто строки счётчика ещё нет — первое событие должно её создать, последующие увеличивать. INSERT ... ON CONFLICT делает оба случая в одном запросе:

INSERT INTO counters (id, n)
VALUES (1, 1)
ON CONFLICT (id)
DO UPDATE SET n = counters.n + 1;
  • Если строки нет — она вставляется со значением 1.
  • Если есть — срабатывает ветка DO UPDATE, где counters.n ссылается на текущее значение в таблице.
  • Грабли: не пишите SET n = EXCLUDED.nEXCLUDED это отвергнутая вставка (1), и счётчик навсегда застрянет на единице. Нужно именно counters.n + 1.

Этот upsert тоже атомарен и безопасен в гонке: конкурентные вставки одного id сериализуются на уникальном индексе.

Вернуть новое значение через RETURNING

Часто после инкремента нужно узнать результат — например, новый номер в очереди. Отдельный SELECT снова открыл бы окно гонки. RETURNING отдаёт значение из того же атомарного запроса:

UPDATE counters
SET n = n + 1
WHERE id = 1
RETURNING n;

Вы получаете значение, которое присвоила именно ваша транзакция, без повторного чтения. Работает и с upsert:

INSERT INTO counters (id, n)
VALUES (1, 1)
ON CONFLICT (id)
DO UPDATE SET n = counters.n + 1
RETURNING n;

Горячие точки и пределы

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

  • Грабли: длинная транзакция, которая увеличила счётчик и потом делает что-то ещё, держит блокировку строки до COMMIT. Инкрементируйте как можно позже и коммитьте быстро.
  • Для очень горячих счётчиков применяют шардирование: N строк-частей (id, shard, n), запись в случайный шард, чтение через SUM(n). Это размазывает конкуренцию по нескольким строкам.
  • Если нужен только монотонно растущий уникальный номер, а не точный подсчёт, SEQUENCE (или GENERATED AS IDENTITY) дешевле: он не блокирует и не откатывается, но допускает пропуски в нумерации.

Различия СУБД: в MySQL/InnoDB UPDATE ... SET n = n + 1 так же атомарен, а upsert пишется как INSERT ... ON DUPLICATE KEY UPDATE n = n + 1; аналога RETURNING нет — берут LAST_INSERT_ID() с трюком или повторный SELECT в той же транзакции. ClickHouse — аналитическая СУБД: точечные UPDATE там дороги, счётчики обычно делают через SummingMergeTree или AggregatingMergeTree, где значения складываются при слиянии частей, а не по строке.

Вывод прост: никогда не считайте инкремент в приложении. Пусть база сделает чтение, сложение и запись одним выражением n = n + 1 — тогда счётчик останется верным при любой конкуренции.

Εξασκηθείτε σε πραγματικές ασκήσεις

Λύστε ασκήσεις στην εξάσκηση SQL με άμεση βαθμολόγηση και υποδείξεις.

Άνοιγμα εξάσκησης