Cet article est actuellement en russe — la traduction en anglais est en cours.
Счётчик кажется самой простой записью в базе: одно число, которое надо увеличить. Но именно на счётчиках чаще всего ломается корректность при конкуренции — потому что наивный код читает значение, прибавляет единицу и пишет обратно тремя отдельными шагами. База умеет сделать это атомарно одним запросом.
Баг потерянного обновления
Представим таблицу счётчиков просмотров для пользователей:
CREATE TABLE counters (
id bigint PRIMARY KEY,
n bigint NOT NULL DEFAULT 0
);
Типичный код в приложении выглядит как read-modify-write:
SELECT n FROM counters WHERE id = 1;
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.n — EXCLUDED это отвергнутая вставка (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 — тогда счётчик останется верным при любой конкуренции.
Счётчик кажется самой простой записью в базе: одно число, которое надо увеличить. Но именно на счётчиках чаще всего ломается корректность при конкуренции — потому что наивный код читает значение, прибавляет единицу и пишет обратно тремя отдельными шагами. База умеет сделать это атомарно одним запросом.
Баг потерянного обновления
Представим таблицу счётчиков просмотров для пользователей:
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.n—EXCLUDEDэто отвергнутая вставка (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. Инкрементируйте как можно позже и коммитьте быстро.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— тогда счётчик останется верным при любой конкуренции.