Теория: уровни изоляции 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 accounts SET balance = 0 WHERE id = main; UPDATE accounts SET balance = 470 WHERE id = reserve.

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

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

Комментарии (16)


  1. OlegIct
    05.06.2026 17:46

    Lost update проходит молча, и чинить его приходится самостоятельно

    Почему в стандарте SQL, который реализует PostgreSQL, пишут "The four isolation levels guarantee that each SQL-transaction will be executed completely or not at all, and that no updates will be lost."?


    1. nikolayschaback Автор
      05.06.2026 17:46

      Путаница с термином «lost update» — в стандарте и в статье это разные вещи.

      «No updates will be lost» в стандарте — про dirty write: незакоммиченную запись транзакции другая не перезатрёт. Эту гарантию дают все 4 уровня через write-локи до конца транзакции.

      «Lost update» в статье — read-modify-write аномалия: две транзакции читают один баланс, обе пишут, обе коммитятся — формально ничего не «потеряно», но логически одно списание затёрто.

      На практике Postgres так и ведёт себя: READ COMMITTED теряет такой апдейт, REPEATABLE READ ловит конфликт ошибкой 40001.


  1. Akina
    05.06.2026 17:46

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

    Что за бред у вас там? Читаем из таблицы accounts, а main и reserve - это значения-параметры, передаваемые в запрос. А вот пишем в таблицы main и reserve.

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

    Да, PostgreSQL не умеет многотабличный UPDATE. Зато умеет UPDATE в CTE. Так что всё прекрасно выражается одним запросом. Включая и обеспечение того, что итоговый balance не вылетел в минус в обеих записях - для этого есть функция GREATEST(). И не нужно ничего считать в коде, не требуются ни транзакции, ни FOR UPDATE.


    1. nikolayschaback Автор
      05.06.2026 17:46

      да, согласен, пример синтетический. главная мысль в том, что не все можно выразить внутри запроса. и еще есть системы, которые построены на базе ORM и не всегда рационально делать raw queries, только чтобы привести запрос к атомарному виду. и да, вычисления в рантайме приложения, существуют, и поэтому области применения FOR UPDATE всегда найдутся.

      main/reserve это id-параметры, таблица одна; в шаге 4 опечатка, должно быть UPDATE accounts SET ... WHERE id = main/reserve. Поправил.

      по CTE — тут не соглашусь в главном. да, multi-table UPDATE через data-modifying CTE выражается одним запросом, и раздачу 70/30 с защитой от минуса можно собрать на LEAST/GREATEST — считать в коде необязательно.

      но «не нужны ни транзакции, ни FOR UPDATE» — неверно. Все под-запросы CTE работают на одном снапшоте, а в READ COMMITTED при конфликте Postgres перечитывает только саму обновляемую строку и её WHERE — не значения, подтянутые из другой строки. цитата из доки: [Because of the above rules, it is possible for an updating command to see an inconsistent snapshot: it can see the effects of concurrent updating commands on the same rows it is trying to update, but it does not see effects of those commands on other rows in the database](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED) .

      сумма с reserve зависит от баланса main — другой строки. конкурентное изменение main между снапшотом и апдейтом приводит к неверному распределению: суммы посчитаны по старому балансу, а он уже изменился — спишется не та сумма. дока про Read Committed называет такой режим «unsuitable for commands that involve complex search conditions» (13.2.1 из доки выше), хендлится блокировкой строк — SELECT … FOR UPDATE — либо переходом на REPEATABLE READ + повтор.

      если есть рабочий вариант без локов, который держит конкурентный тест на двух счетах — с интересом изучу, обновлю статью.


      1. Akina
        05.06.2026 17:46

        если есть рабочий вариант без локов, который держит конкурентный тест на двух счетах — с интересом изучу

        Ну вот, быстренько накидал: fiddle. Оптимальностью не пахнет, даже два CTE избыточны, но принцип демонстрируется. Изменяя cte1.amount, можно убедиться, что все три варианта (достаточно main счёта, достаточно суммарно, недостаточно) обрабатываются правильно.

        Текст запроса (на всякий случай)
        WITH 
        cte1 (main_balance, reserve_balance, amount) AS 
            ( -- получить балансы счетов в одну запись
            SELECT main.balance, 
                   reserve.balance,
                   100 
            FROM accounts reserve
            CROSS JOIN accounts main
            WHERE reserve.id = 'reserve'
              AND main.id = 'main'
            ),
        cte2 (main_balance, reserve_balance) AS 
            ( -- посчитать итоговые суммы
            SELECT GREATEST(main_balance - amount, 0),
                   GREATEST(main_balance + reserve_balance - amount - GREATEST(main_balance - amount, 0))
            FROM cte1
            )
        UPDATE accounts -- записать итоговые суммы
        SET balance = CASE id WHEN 'main'    THEN main_balance
                              WHEN 'reserve' THEN reserve_balance
                              END
        FROM cte2
        WHERE reserve_balance >= 0
          AND id IN ('main', 'reserve');

        Для того, чтобы избежать описанной вами проблемы, достаточно протащить за собой через CTE старые значения и сравнить во внешнем запросе с текущими. Если хотя бы одна запись изменена - спровоцировать не-выполнение запроса или ошибку (последнее - проще, что-нить вроде 1/ifnullif(nullif())).


        1. nikolayschaback Автор
          05.06.2026 17:46

          спасибо за фиддл. согласен что считать в приложении в нашем примере необязательно. многие вещи можно посчитать и обновить на уровне SQL

          но какой ценой?

          • протащить через CTE старые значения балансов

          • во внешнем запросе сравнить их с текущими

          • если хоть одна строка изменилась — уронить весь запрос ошибкой (через sql-хак)

          • приложение потом повторяет

          (надеюсь что правильно понял идею)

          ваш пример доказывает что расчет и перевод можно выразить в SQL, но по сути это теже оптимистичные блокировки, которые доступны через штатные механизмы БД, с ценой повторов.

          у вас сложность не исчезла, а переехала в запрос. Просто сравните:

          BEGIN;
          
          -- 1. Залочили обе строки и прочитали свежие балансы
          SELECT id, balance FROM accounts
          WHERE id IN ('main', 'reserve')
          FOR UPDATE;
          --  main=70, reserve=500
          
          -- 2. Посчитали в коде: с main снять 70, остаток 30 — с reserve
          
          -- 3. Записали
          UPDATE accounts SET balance = 0   WHERE id = 'main';
          UPDATE accounts SET balance = 470 WHERE id = 'reserve';
          
          COMMIT;

          3 банальных стейтмента против двойного CTE с намеренной ошибкой-абортом


          1. Akina
            05.06.2026 17:46

            но какой ценой?

            Что? Посчитать тупо арифметику на сервере - это, по вашему мнению, цена??? А на самом деле это то, что вы при всём своём желании не сможете зарегистрировать, потому что эта ваша цена будет гораздо ниже точности измерения. Тьфу, и растереть.

            3 банальных стейтмента против двойного CTE с намеренной ошибкой-абортом

            Ну да... а о 6 сессиях обмена трафиком с сервером вместо двух, о трёх против одного сеансов работы планировщика (и вообще всё в тройном размере), и о дополнительном выполняемом в приложении коде (да с учётом того, что клиент по сравнению с сервером если и не улитка, то уж точно не метеор) вы, конечно, стыдливо так забыли... зря, зря.

            А что, если резервных счетов - два? или три? под каждый вариант напишете свой код, да? А вот на стороне SQL это всё делается опять же одним запросом, даже если этих счетов десяток.


            1. nikolayschaback Автор
              05.06.2026 17:46

              говоря о цене, мы говорим о цене саппорта такого решения, а не о нагрузке на железо.

              А что, если резервных счетов - два? или три? под каждый вариант напишете свой код, да? А вот на стороне SQL это всё делается опять же одним запросом, даже если этих счетов десяток.

              я выберу 10 простых примитивных запросов, а не один сложный, и вот почему:

              • в простом запросе при рефакторинге LLM допустит ошибку с меньшей вероятностью, простор для "творчества" меньше ;)

              • у простого запроса меньше сайд-эффектов, я точно знаю что он делает (и мой Claude тоже)

              • делая сложный запрос я вынужден имплементировать механизм абстракции для того, чтобы работали все N (10) кейсов. эту логику надо знать и мейнтейнить. а ее могло бы не быть


              1. Akina
                05.06.2026 17:46

                говоря о цене, мы говорим о цене саппорта такого решения, а не о нагрузке на железо.

                Сколько вам стОит саппорт любой стандартной функции из любой стандартной библиотеки? думаю, бесплатно. А если она из стандартной для проекта, а то и для всей фирмы, пользовательской библиотеки - что, что-то меняется? стоимость создания - это не саппорт, не учитываем, исправление ошибок, неправильно учтённых краевых, тестирование, отладка, и т.д., и т.п. - это составные части стоимости создания, тоже не саппорт, не учитываем. Так откуда ненулевая цена саппорта за функцию, которая вылизана, отлажена, протестирована, включена в пользовательскую библиотеку, и просто используется, как любая другая стандартная или пользовательская функция?

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


                1. michael_v89
                  05.06.2026 17:46

                  Вы не привели корректный контрпример. Ваш запрос “без FOR UPDATE” создает ошибки в данных при параллельном доступе.


        1. michael_v89
          05.06.2026 17:46

          Тут при параллельных запросах будет race condition. Если добавить pg_sleep() в какой-нибудь SELECT и запустить параллельно 2 запроса, финальный результат будет как после одного запроса.


          1. nikolayschaback Автор
            05.06.2026 17:46

            проверил ровно ваш сценарий на pg 16: pg_sleep(2) внутри транзакции, два перевода по 100 параллельно, старт main=70, reserve=500.

            с FOR UPDATE: вторая транзакция встаёт на лок, ждёт первую, потом перечитывает свежие балансы. итог main=0, reserve=370 — оба списания на месте. по времени 4 секунды, то есть сериализовались, а не параллельно.

            без FOR UPDATE — да, ровно как вы говорите: 2 секунды, итог 470, одно списание потерялось

            pg_sleep ничего не ломает: лок не даёт второй прочитать устаревший баланс, сколько бы первая ни спала. об этом и статья


            1. michael_v89
              05.06.2026 17:46

              Так я не про статью, а про комментарий вашего собеседника “не требуются ни транзакции, ни FOR UPDATE”.


              1. nikolayschaback Автор
                05.06.2026 17:46

                а, ясно, неправильно иерархию комментов понял)

                возможно будут рейсы, решение @Akina не тестировал. но интересно, не знал, что в CTE возможны рейсы, думал что все блокируется на время такого запроса. надо будет изучить тему, спасибо


  1. favgen
    05.06.2026 17:46

    Редкость по нынешним временам, разбор внутренностей постгреса. Хотя надо ли это сейчас знать самому, если честно, большой вопрос.

    ИИ агент и сам это вывозит. Паттерн заезженный, данных по нему в обучении более чем достаточно, на запрос "перевод денег, конкурентный доступ" агент сам достаёт for update. Он и сам может прогнать все гонки, увидеть просадку баланса и переписать запрос, ему не обязательно понимать, достаточно проверить.


    1. nikolayschaback Автор
      05.06.2026 17:46

      никто не предлагает в 2026 писать код руками. Но я бы никогда не гарантировал что ИИ всегда поставит for update там, где это необходимо. а цена ошибки слишком высокая - через такую дыру можно опустошить легко опустошить hot wallet проекта. и такое происходит на CEXах

      ранее случались громкие истории типа Flexcoin и Poloniex

      сейчас все такие уязвимости у крупных проектов лечатся через bug bounty programs

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

      и как всегда бывает в CS - атаки усложняются вместе с эволюцией средств защиты:

      The single-packet attack: making remote race-conditions 'local'