2 + 2 = 6 и как мы это фиксим: lost updates в Postgres

от автора

Теория: уровни изоляции Postgres

Уровень изоляции транзакций на изменение значений в БД — это регулятор строгости той самой «I» из ACID. SERIALIZABLE — изоляция в полном смысле: результат эквивалентен последовательному выполнению. Более низкие уровни (REPEATABLE READ, READ COMMITTED) — ослабленная изоляция: часть аномалий разрешена ради скорости. То есть «I» — не бинарная гарантия «есть / нет», а шкала, и выбор уровня — это компромисс между строгостью и производительностью. Остальные три буквы — A, C, D — уровни изоляции не трогают.

Что каждый уровень гарантирует и как ведёт себя при конкурентной записи в одну и ту же строку:

  • READ COMMITTED (дефолт). База не мешает гонке. Каждый SELECT видит свежий снимок на момент самого запроса; две транзакции спокойно читают одно и то же значение, считают новое и записывают — второе затирает первое. Lost update проходит молча, и чинить его приходится самостоятельно.

  • REPEATABLE READ. Снимок фиксируется на старт транзакции. Если транзакция меняет строку, которую после этого снимка успел изменить и закоммитить кто-то другой, Postgres не ждёт и не сливает изменения, а отклоняет транзакцию ошибкой 40001 (could not serialize access).

  • SERIALIZABLE. То же, но строже: Postgres отслеживает зависимости между транзакциями и отклоняет (40001) даже там, где строки разные, но итог не соответствовал бы последовательному выполнению (так ловится write skew). Отказов, естественно, больше.

Главная мысль: повышение уровня изоляции не «чинит» гонку волшебно. Postgres делает ровно ту работу, что входит в его зону ответственности — честно замечает конфликт и сообщает о нём ошибкой 40001. А вот повторять отклонённую транзакцию он за нас не станет: это уже забота приложения. Не предусмотрел повтор — и запрошенная пользователем операция просто не будет осуществлена (транзакция откатывается целиком, данные при этом не теряются — операции просто не происходит).

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

Подопытный: перевод денег

Возьмём самый заезженный пример в мире конкурентного доступа — перевод денег между двумя счетами. Два счёта, на каждом по 10 000, и сотни переводов между ними одновременно. Именно это и практикуют злоумышленники, когда тестируют недавно вышедшие в прод финансовые сервисы: кастодиальные криптокошельки, бэкенды необанковских приложений, маркетплейсы — любые приложения, где есть понятие счёта пользователя и перевода средств между этими счетами.

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

Эти знания особенно важны в эпоху вайб-кодинга, когда значительную часть кода генерируют языковые модели. Такие тривиальные уязвимости LLM обрабатывает лишь с некоторой долей вероятности — это следует из самого принципа работы LLMs. Поэтому разработчик, который делает ревью кода финансового приложения, написанного LLM, просто обязан знать особенности поведения этого кода. Кода, который на первый взгляд выглядит простым и однозначным. Проблема становится особенно острой, когда модель имеет дело со сценариями сложнее нашего синтетического примера. В реальных финтек приложениях сам перевод может осуществляться из нескольких источников (см. пример списание с овердрафтом ниже). В дополнение к этому в бизнес-требованиях может быть фиксация этой транзакции в независимой параллельной системе учёта по принципу двойной записи. Еще могут быть начисления бонусов и кэшбека. Если это первая транзакция пользователя в системе — скорее всего будет отложенное создание фиатных или крипто кошельков. И так далее.

Сразу обозначу границы. Цель этой статьи — продемонстрировать аномалию конкурентного доступа под названием lost update в самом простом и доступном виде. Задача — показать, объяснить и сравнить: базу плюс цену каждого способа решения этой проблемы. Потому что, по опыту автора, даже этих базовых вещей значительная часть разработчиков, проходящих собеседование на позицию Senior Backend Developer, либо не знает, либо знает очень поверхностно. Все остальные виды гонок, а также потери данных при конкурентном доступе, тоже остаются за рамками этой статьи.

Весь код лежит в репозитории автора на GitHub — любой замер можно повторить. Стек минимальный: локально установленный Postgres и Node + TypeScript-приложение. Очевидно, цифры могут отличаться в зависимости от конфигурации машины; все приведённые в статье получены на MacBook Pro M3, 36 ГБ RAM.

Способы убрать lost update

Наивная реализация

Прямой перевод формулировки из тикета в код. Задача «уменьшить баланс на сумму» превращается в три последовательных шага:

1. SELECT balance FROM accounts WHERE id = $from           -- прочитали текущий баланс2. new_balance = balance - amount                          -- посчитали новый в коде приложения3. UPDATE accounts SET balance = $new_balance WHERE id = $from   -- записали обратно

Логически всё верно, но между шагом 1 и шагом 3 другая транзакция успевает прочитать то же значение — и обе пишут одинаковый результат, затирая друг друга. Это и есть lost update: часть списаний бесследно исчезает. Сам по себе это не «способ», а точка отсчёта — он демонстрирует проблему, которую все остальные варианты и чинят.

Атомарный UPDATE

Чтение и запись в одной команде:

UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1

База сама держит строку на время апдейта, поэтому read-modify-write неделим: две параллельные команды не могут прочитать одно значение и затереть друг друга.

Чем этот способ хорош: гонки нет в принципе, при этом ни повторов, ни ручных блокировок в коде — самый простой и быстрый способ. Условие AND balance >= $1 заодно не даёт уйти в минус. Идеален для счётчиков, балансов, остатков склада.

Но у этого способа есть одно существенное ограничение: он годится, только когда новое значение выводится из старого прямо в SQL. Как только «что записать» зависит от прочитанного (решение принимается в коде) — этот способ не подходит. В большинстве реальных финансовых приложений осуществлять атомарные апдейты невозможно потому что реальные сценарии движения средств сложны.

Пример: списание с овердрафтом — снять 100 с основного счёта, а чего не хватило — добрать с резервного. Сколько с какого — зависит от прочитанных балансов обоих счетов, одной арифметикой не выразить. Сначала читаем оба, считаем распределение в коде, потом пишем в БД. Тут более подробный разбор этого примера


Пессимистичные блокировки

Пессимистичный подход исходит из того, что драка за строку почти неизбежна — поэтому текущий процесс занимает её заранее, а остальные ждут в очереди. Повторных операций записи здесь не происходит, но есть ожидание — и именно оно составляет «цену» этой группы подходов.

SELECT … FOR UPDATE

При чтении строки сразу ставит на неё блокировку на запись. Другая транзакция, которая попытается SELECT … FOR UPDATE ту же строку, ждёт, пока первая закоммитит или откатит.

Как лечит lost update:

  1. Транзакция A: SELECT balance … FOR UPDATE — заблокировала строку счёта и читает текущий баланс.

  2. Транзакция B доходит до своего FOR UPDATE по той же строке — встаёт в очередь.

  3. A списывает сумму, COMMIT → отпускает замок.

  4. B просыпается и перечитывает уже обновлённый баланс (а не устаревший, что был до A) — и списывает уже с него.

Ключевой нюанс: дождавшись очереди, B перечитывает строку заново и видит свежее значение — то, что записала A, а не то, что было до неё. Поэтому ничего не затирается. Повторов нет, только ожидание в очереди.

Advisory lock

Блокировка по произвольному числовому ключу, которую ставит само приложение, а не база по строке/таблице.

  • Мы говорим: SELECT pg_advisory_xact_lock(1) — и берём «замок №1». Вторая транзакция с тем же номером ждёт, пока первая его отпустит.

  • База не знает, что значит этот номер — смысл ключа задаём мы (например, номер счёта). Поэтому «advisory» (рекомендательный): работает, только если все договорились брать замок по этому ключу.

  • Бывает транзакционный (pg_advisory_xact_lock(1) — сам отпускается на COMMIT/ROLLBACK) и сессионный (pg_advisory_lock(1) — держится до pg_advisory_unlock(1) или конца сессии). Транзакционный удобнее — не забудем отпустить.

В нашем переводе: перед работой со счётом №1 берём pg_advisory_xact_lock(1) → остальные переводы по счёту 1 встают в очередь. Эффект как у FOR UPDATE, но замок логический (по ключу), а не привязан к конкретной строке.

Зачем тогда он, если есть FOR UPDATE? Когда блокировать нужно не строку, а абстрактную сущность (которой может ещё не быть в таблице) или операцию, охватывающую несколько строк/таблиц по одному смыслу.


Оптимистичные блокировки

Оптимистичный подход рассчитывает, что чаще всего конкуренции не будет и никто не помешает обновить данные, — поэтому мы ничего не запираем заранее. Мы делаем работу, а при попытке записать данные мы проверяем, не влез ли кто-то в ту же строку; если влез — пробуем заново.

Версионирование

В таблицу добавляем колонку version — обычный счётчик. Перевод идёт так:

  1. Читаем строку вместе с версией: SELECT balance, version (допустим, версия = 7).

  2. Считаем новый баланс в коде.

  3. Пишем с проверкой версии:

UPDATE accounts SET balance = $new, version = 8 WHERE id = $id AND version = 7

Если за это время строку никто не трогал — версия всё ещё 7, UPDATE проходит. Но если кто-то успел вклиниться, он уже поднял версию, условие version = 7 не находит строку, и UPDATE меняет 0 строк — нас опередили.

Цена этого способа — повторы на стороне приложения: их нужно имплементировать в коде самостоятельно, со всей вытекающей ответственностью на плечах программиста. А в нашей текущей ситуации 2026 года — на плечах того, кто ревьюит код, написанный LLM :).

REPEATABLE READ + повтор

В этом способе мы строку сами не версионируем — за нас это делает СУБД. Мы просто просим транзакцию работать на уровне REPEATABLE READ и пишем всё как обычно: открыли транзакцию, прочитали баланс, посчитали, записали, закоммитили. Ключевая функциональность в том, что снимок данных Postgres фиксирует на момент старта транзакции. И если кто-то успел поменять ту же строку и закоммититься раньше нас — наш COMMIT не пройдёт, база честно скажет 40001 (could not serialize access). Мы ловим эту ошибку и просто запускаем транзакцию заново, уже со свежими данными.

SERIALIZABLE + повтор

Похоже на предыдущее, та же ошибка 40001, — только к операции записи предъявляются еще более строгие условия. На SERIALIZABLE Postgres следит уже не за одной строкой, а за всей паутиной зависимостей между транзакциями. Этот подход избыточен для защиты переводов в нашем тривиальном примере, но зато ловит сложные, нетривиальные кейсы — такие как write skew и фантомы. О них, возможно, расскажу в будущих статьях.

Чем они отличаются. REPEATABLE READ смотрит на конкретную строку, а SERIALIZABLE — на связи между транзакциями вообще.

Архитектурный способ

Принцип этого подхода в том, что мы убираем гонку не на уровне запросов к БД, а в архитектуре приложения. Идея простая: делаем так, чтобы все операции по одному счёту выполнял строго один исполнитель и строго по очереди. Если к счёту №1 в каждый момент обращается только один воркер — двум транзакциям просто негде столкнуться. Очередь выстраивается ещё до базы, а не внутри неё.

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

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

Чем мы за это платим: это уже не модификатор оператора SELECT в SQL, а часть инфраструктуры — очередь, роутинг, шардинг. «Горячий» счёт превращается в бутылочное горлышко: все его операции идут через одного воркера, и внутри одного счёта параллелизма у нас уже нет.

Что выбрать для прода?

Возможность гонки присутствует везде, где есть конкурентный доступ к операциям изменения данных. Ответственность за решение, какие данные стоит защищать, а какие нет, лежит на нас.

Если мы понимаем, что на операции по изменению данных ожидается race condition, то чаще всего без оглядки используем SELECT FOR UPDATE. Такой подход работает, но может привести к неоптимальным расходам на инфраструктуру.

Давайте изучим влияние двух параметров на общий Throughput: число счетов (2 — все переводы осуществляются между одной парой счетов, 1000 — пересекаются редко) и длину транзакции (RACE_DELAY_MS — сколько работы делается внутри). В ячейках — медиана throughput по 10 прогонам, первые 3 отброшены на прогрев; 100 одновременных переводов, пул 50 соединений. Вот что показали замеры производительности на нашем тестовом стенде race-sim:

##Throughput (переводов в секунду) Столбцы — счета · задержка(мс):

способ

2·0

100·0

2·10

100·10

2·100

100·100

naive

3175

13393

2857

3175

385

418

atomic

4083

14286

4545

15476

4545

14286

forUpdate

3510

14286

73

931

9

117

advisory

2353

10000

68

707

9

103

version

795

7418

80

986

9

124

repeatableRead

766

6667

72

953

9

115

serializable

772

4257

72

745

9

87

(naive приведён для контекста, как способ выбора он не рассматривается — он теряет деньги.)

##Повторы Медиана на 100 успешных переводов. Они возникают только у оптимистичных способов (version, REPEATABLE READ, SERIALIZABLE): при конфликте те перезапускают транзакцию. У atomic, FOR UPDATE и advisory повторов нет по определению — они либо обходятся одной командой, либо просто ждут в очереди, а не перезапускаются, поэтому в таблице их нет.

способ

2·0

100·0

2·10

100·10

2·100

100·100

version

1092

92

3484

116

3644

107

repeatableRead

2302

93

3461

108

3671

99

serializable

2283

230

3546

204

3670

240

Что из этого следует

Главное узкое место — не выбор механизма, а время удержания горячей строки. На двух счетах, как только в транзакции появляется работа, все блокировочные и оптимистичные способы сходятся к одному дну: ~70 переводов в секунду при задержке 10мс и ~9 при задержке 100мс. Какой именно механизм — там уже почти не важно. Поэтому первое, во что стоит инвестировать время при оптимизации использования БД под большими нагрузками — работать над общим перформансом исполняемых запросов. Совет старый как мир СУБД 🙂

atomic от длины транзакции не зависит (4000–4500 на горячей паре при любой задержке): у него нет окна между чтением и записью. Это следствие ограничения — atomic применим только когда никакой логики между чтением и записью нет.

SERIALIZABLE — самый медленный почти везде. Даже без реальных конфликтов (1000 счетов, задержка 0) он даёт 4257 против ~14000 у блокировочных — из-за ложных срабатываний SSI (200+ повторов на ровном месте). Его берут не за скорость, а за то, что он единственный ловит write skew.

Заодно критически оценим некоторые тезисы, которые приходилось часто слышать

  • «atomic всегда быстрее». Оказалось, что нет, по крайней мере на нашем тестовом стенде. При коротких транзакциях он сравним с блокирующими (100·0: atomic 14286, forUpdate тоже 14286). Отрыв появляется только когда в транзакции есть работа — а её atomic просто не несёт.

  • «SELECT FOR UPDATE — безопасный выбор по умолчанию». На горячей строке с работой внутри он обваливается ровно как все: 73 перевода в секунду при задержке 10мс.

  • «Оптимистичная блокировка легче пессимистичной». Под нагрузкой наоборот: на строках, к которым происходит много конкурентных обращений, version 795 против forUpdate 3510, и это ценой 1000–2300 повторов. Оптимистичные блокировки выигрывают только когда конфликты редки.

  • «SERIALIZABLE — это самый безопасный уровень, включил и забыл». Он платит throughput’ом всегда (даже когда конфликтов нет!).

  • «Повторы — это дёшево». На горячей строке оптимистичные способы делают 2000–3500 повторов на 100 успешных операций: в 20–35 раз больше выброшенной работы, чем полезной. Этот пункт в долгосрочной перспективе может повлиять на общий перформанс системы. А когда такое происходит обычно, — времени на размышления мало, и чаще всего принимается решение о “горизонтальном масштабировании”. Хотя этих расходов бизнес мог бы и не нести.

Заключение

Громкой сенсации не вышло — и это даже к лучшему. Хайп по своей природе — это спайк, а мы, инженеры, спайки не любим. Мы любим плавные графики и спать по ночам. 🙂

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

СУБД не делает никакой магии. Самое важное, что нужно понимать про блокировки, — это где проходит граница ответственности: где заканчивается зона ответственности СУБД и начинается зона ответственности разработчика. И хотя в наших экспериментах явного влияния выбора способа блокировки на throughput не выявлено, мы увидели другое: если под ростом нагрузки выбран необоснованно дорогой способ, может настать момент, когда решить проблему технологически уже не успеть — и единственное, что останется предложить бизнесу, это горизонтальное масштабирование. То есть залить проблему деньгами.

Зона ответственности разработчика — выбрать осознанно:

  • atomic — если логика влезает в одну SQL-команду;

  • пессимистичная блокировка — на точках конкуренции;

  • оптимистичная — когда конфликты редки;

  • SERIALIZABLE — осознанно, ради write skew.

За корректность несет ответственность выбирающий. За цену — тоже.


Подробный разбор: списание с овердрафтом

Логика (нужно снять 100, на основном main лежит 70, на резервном reserve — 500):

  1. BEGIN.

  2. Блокируем обе строки одним запросом:

    SELECT id, balance FROM accounts WHERE id IN (main, reserve) FOR UPDATE

    Теперь обе строки заняты, никто в них не влезет.

  3. Прочитал: main=70, reserve=500. Считаю в коде: с main снять 70 (всё, что есть), недостающие 30 — с reserve.

  4. UPDATE main SET balance = 0; UPDATE reserve SET balance = 470.

  5. COMMIT → отпускает обе блокировки.

Почему не атомарный: сколько снять с каждого (70 и 30) вычисляется из балансов обоих счетов — это решение в коде, одной командой balance = balance - X не выразить.

Важный нюанс про «второй FOR UPDATE»: блокировать две строки лучше одним SELECT … WHERE id IN (…) FOR UPDATE, а не двумя отдельными. Если делать двумя запросами в разном порядке (одна транзакция main→reserve, другая reserve→main) — получим deadlock: каждая держит одну строку и ждёт чужую. Защита: либо один запрос, либо всегда блокировать в фиксированном порядке (например, по возрастанию id).

ссылка на оригинал статьи https://habr.com/ru/articles/1044190/