1. Введение
Если вы когда‑нибудь думали «конкурентность — это для банков, бирж и NASA», то у меня для вас новости: обычный интернет‑магазин (даже учебный) тоже живёт в мире параллельных запросов. Пока вы читаете эту строку, где‑то рядом два потока уже готовы одновременно изменить один и тот же InventoryItem. И вот тут появляется проблема: транзакции умеют делать вашу операцию атомарной, но не умеют сами по себе сравнивать две независимые попытки обновления. В результате «последний записавший» может тихо затереть изменения «предыдущего», и вы даже не узнаете об этом по исключениям.
Представьте, что у вас в системе есть данные, которые являются «текущим состоянием»: остатки, резервы, сумма, статус заказа. Эти поля особенно чувствительны к ситуации read → modify → write. В одиночном потоке всё выглядит логично, а под нагрузкой начинает вести себя как чат в пятницу вечером: кто последний написал — тот и прав (даже если прав он только в своём воображении).
И вот тут важно связать эту боль с тем, что мы уже знаем про транзакции. Даже если transaction boundary выбрана правильно и весь read → modify → write живёт внутри одного @Transactional метода, это защищает только одну попытку целиком. Как только таких попыток две, нужен уже не просто commit, а способ понять, не устарело ли состояние между чтением и записью.
2. Наивный read–modify–write
Большинство CRUD‑сервисов выглядит максимально невинно. Мы открываем транзакцию, читаем сущность, меняем поле, выходим из метода — Hibernate на flush/commit отправляет UPDATE. Поскольку мы уже знаем про dirty checking, такой код нас даже радует: «Супер, никакого save() не надо». И в одиночном потоке всё действительно корректно.
Мини‑пример из каталога, где «конкурентность» кажется вообще не проблемой:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameProduct(Long productId, String newName) {
// 1) Читаем текущее состояние из БД
Product product = entityManager.find(Product.class, productId);
// 2) Меняем поле в памяти (Hibernate пометит сущность как dirty)
product.setName(newName);
// 3) На commit/flush Hibernate сам выполнит UPDATE (явного save() нет)
}
В одном запросе это работает идеально: читаем Product, меняем name, коммитим. Но если два администратора (или два фоновых процесса) одновременно меняют имя одного и того же товара, мы получаем классическое «кто последний — тот победил», причём победил молча, без драки и без предупреждений.
Теперь пример из подсистемы остатков, где цена ошибки заметнее (потому что деньги и склад — вещи обидчивые):
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void reserve(Long itemId, int qty) {
// 1) Загружаем строку остатков (снимок текущего состояния на момент SELECT)
InventoryItem item = entityManager.find(InventoryItem.class, itemId);
// 2) Делаем read–modify–write в памяти: считаем новые итоговые значения
item.setAvailableQty(item.getAvailableQty() - qty);
item.setReservedQty(item.getReservedQty() + qty);
// 3) На commit/flush Hibernate отправит UPDATE с итоговыми значениями полей
// Важно: тут нет проверки, что между SELECT и UPDATE строку не меняли
}
Пока это исполняется один раз — всё хорошо. Но как только появятся два параллельных вызова reserve() для одной и той же строки, мы внезапно обнаружим, что «корректная арифметика» может дать некорректный бизнес‑результат. И самое неприятное: SQL‑обновления будут выглядеть нормальными, без синтаксических ошибок, без constraint violations, без единого «ой».
3. Таймлайн lost update
Lost update проще всего понять как историю про два независимых человека, которые редактируют один и тот же документ, но система не умеет говорить «эй, документ уже изменился». Каждый честно прочитал старую версию, каждый честно внёс правку, и оба честно нажали «сохранить». В итоге сохранится последняя правка, а первая исчезнет так тихо, что можно даже не успеть обидеться — вы просто не узнаете.
В базе данных это часто выглядит так: две транзакции стартуют почти одновременно и обе успевают прочитать одну и ту же строку до того, как первая транзакция закоммитит изменения. Дальше обе транзакции вычисляют новое состояние из одного и того же старого снимка. И вот это ключевой момент: ошибка происходит не в SQL‑синтаксисе и не в Java‑коде, а в логике “мы считали из устаревшего состояния”.
Удобно смотреть на это как на таймлайн:
| Момент времени | Транзакция 1 (Tx1) | Транзакция 2 (Tx2) | Состояние строки в БД |
|---|---|---|---|
| t0 | SELECT item (available=10, reserved=0) | available=10, reserved=0 | |
| t1 | SELECT item (available=10, reserved=0) | available=10, reserved=0 | |
| t2 | Tx1 вычислила новое состояние: available=8, reserved=2 | Tx2 вычислила новое состояние: available=7, reserved=3 | available=10, reserved=0 |
| t3 | UPDATE ... set available=8, reserved=2 where id=... + commit | available=8, reserved=2 | |
| t4 | UPDATE ... set available=7, reserved=3 where id=... + commit | available=7, reserved=3 |
Обратите внимание на финальную строку: итог — available=7, reserved=3. С точки зрения Tx2 она «всё сделала правильно». Но с точки зрения бизнеса, если у нас были две резервации на 2 и на 3, мы ожидаем available=5 и reserved=5. Потерялись изменения Tx1, просто потому что Tx2 записала результат, рассчитанный из старых данных.
И вот это и есть lost update: более поздняя запись тихо затирает результат более ранней, и система не сигнализирует о конфликте.
4. Lost update на InventoryItem
С остатками и резервами lost update особенно нагляден, потому что там почти всегда есть инварианты и ожидания: нельзя «случайно потерять» резерв, нельзя «забыть» уменьшить доступный остаток. Но код при этом выглядит абсолютно бытовым: пара геттеров, пара сеттеров, всё чинно и благородно.
Давайте ещё раз увидим «математику», которая кажется правильной:
// Оба потока (Tx1 и Tx2) прочитали одно и то же значение из БД
int loadedByTx1 = 10;
int loadedByTx2 = 10;
// Каждый поток честно посчитал новый итог на основе прочитанного снимка
int newQtyByTx1 = loadedByTx1 - 2; // 8 (резерв на 2)
int newQtyByTx2 = loadedByTx2 - 3; // 7 (резерв на 3)
// Проблема не в арифметике: проблема в том, что оба расчёта стартовали из "10"
Оба расчёта верны. Проблема в том, что они оба стартовали из одного и того же числа 10. Если эти расчёты потом оба попадут в базу как «установить значение = 8» и «установить значение = 7», то один из результатов неизбежно будет потерян. Это не «ошибка вычисления», это ошибка предположения: «между чтением и записью никто другой не тронул строку».
Как это выглядит в generated SQL
В Hibernate‑стиле (без специальных механизмов конкурентного контроля) обновление часто выглядит примерно так:
update inventory_item
set available_qty = ?, -- итоговое значение, которое получилось после расчёта в Java
reserved_qty = ?, -- итоговое значение, которое получилось после расчёта в Java
updated_at = ? -- служебное поле: "когда обновили"
where id = ?; -- важно: тут нет проверки "строка не изменилась с момента чтения"
Это важный момент: Hibernate не знает, что вы хотите «уменьшить на 2». Он знает только, что вы в объекте выставили итоговые значения. Dirty checking увидел изменение полей и сгенерировал SQL вида «установить новые значения». Если другая транзакция между вашим SELECT и вашим UPDATE уже внесла изменения, ваш UPDATE всё равно сработает — потому что в WHERE нет никакого условия, которое говорит: «обновляй, только если строка всё ещё та, которую я читал».
Опасность для «текущих» полей
В Commerce Persistence Lab у нас есть несколько типов данных, где «последний writer wins» особенно токсичен.
В InventoryItem поля availableQty и reservedQty описывают текущее состояние склада. Если один резерв перетрёт другой, у вас либо начнутся продажи того, чего нет, либо наоборот — вы «потеряете» товар и начнёте отказывать клиентам, хотя товар физически лежит на складе и скучает.
В PurchaseOrder статус — это поток состояний. Если два процесса параллельно переводят заказ в разные статусы, вы можете получить «скачок назад» или «потерю шага». Формально запись успешна, а бизнес‑процесс сломан.
В Product можно спорить, насколько критично потерять переименование, но даже там бывают важные вещи вроде status (показывать/не показывать) или цена, и цена в некоторых системах тоже должна быть устойчивой к перезаписи, особенно если её обновляют разные сценарии.
5. @Transactional не спасает
Очень хочется верить, что если мы написали @Transactional, то система автоматически стала «безопасной» для параллельных изменений. Это понятное желание: аннотация выглядит серьёзно, а серьёзные аннотации должны спасать мир. Но на практике @Transactional решает другую задачу: он гарантирует, что ваша операция выполнится целиком или откатится целиком. Он обеспечивает атомарность unit of work, но не делает магию уровня «сравнить две независимые попытки обновления и выбрать правильную».
Если сформулировать совсем простыми словами: транзакция отвечает за то, чтобы внутри одной попытки не было «полуобновлённого» состояния. Но если у вас две попытки одновременно, транзакция не обязана (и обычно не может) догадаться, что вы делаете «один и тот же смысловой апдейт».
С технической точки зрения проблема в том, что классический read–modify–write выглядит так:
1) прочитали строку;
2) на основе прочитанного посчитали новое значение;
3) записали новое значение.
Если две транзакции одновременно делают этот цикл, то обе могут начать с одного и того же «старого» шага 1. База данных при этом вполне может разрешить обе записи, потому что каждая запись сама по себе корректна. Чтобы база «заподозрила неладное», ей нужно дополнительное условие: либо вы должны захватить ресурс (но это уже другой класс решений), либо вы должны предоставить критерий актуальности прочитанного состояния.
Важно также не путать lost update с «грязным чтением» или прочими страшилками из учебников по изоляции. Lost update может происходить в очень обычных настройках и в очень обычной жизни, потому что это не про чтение «не‑закоммиченных данных». Это про то, что вы обновляете строку на основании состояния, которое уже успело устареть к моменту записи.
6. Что считать lost update
Хорошая инженерная привычка: прежде чем хвататься за инструмент, нужно чётко назвать, что именно сломалось. Lost update — это не «Hibernate ошибся» и не «PostgreSQL ведёт себя странно». Это ситуация, когда система допускает следующий сценарий: два независимых изменения одной и той же строки выполняются последовательно, и второй апдейт не проверяет, что он всё ещё основан на актуальном состоянии.
Поэтому правильная формулировка требования звучит примерно так: нам нужен механизм, который делает возможным вопрос «строка не изменилась с момента моего чтения?». Если ответ «изменилась», операция должна не молча перетирать чужие изменения, а превратиться в отдельный исход: конфликт, повторное чтение, пересчёт, другое решение.
И обратите внимание на тонкость: речь не про то, чтобы «запретить параллельность». Параллельность — нормальная вещь; в backend‑мире она происходит даже тогда, когда вы о ней не просили. Речь про то, чтобы не терять смысл при параллельности. Для остатков смысл часто такой: «резерв — это операция, которая должна учитываться вместе с другими резервами». Для статуса заказа смысл такой: «переход должен быть последовательным и проверяемым». Для цены товара смысл может быть такой: «мы не хотим случайно откатить цену назад, потому что два фоновых процесса спорят, кто сегодня главный».
Отсюда и вырастает следующий инженерный вопрос: как заставить UPDATE проверить, что строка не успела измениться между чтением и записью. Для этого и появляется техническая версия строки. Сейчас важно, чтобы у вас в голове закрепилась причина: обычный CRUD‑код без дополнительного механизма конкурентного контроля допускает тихую потерю обновления.
7. Типичные ошибки при lost update
В теме lost update главная сложность даже не в том, чтобы понять «две транзакции перетёрли друг друга». Главная сложность — в том, что эта проблема не кричит о себе: нет красного stacktrace по центру экрана, нет явной ошибки в SQL. Поэтому типичные ошибки здесь почти всегда про неверные ожидания и про неправильную диагностику.
Ошибка №1: считать, что @Transactional автоматически защищает от параллельной перезаписи.
Транзакция делает вашу операцию атомарной и корректной «внутри себя», но она не обязана сравнивать вашу попытку обновления с другой параллельной попыткой. Если вы не добавили отдельный механизм контроля актуальности, «last write wins» остаётся вполне законным исходом.
Ошибка №2: искать проблему только в SQL‑синтаксисе и “правильности UPDATE”.
В lost update SQL как раз обычно идеальный: UPDATE корректный, constraints не нарушены, база довольна. Проблема не в том, что SQL «плохой», а в том, что он не содержит условия, которое связывает запись с прочитанным ранее состоянием.
Ошибка №3: путать lost update с бизнес‑валидацией и доменными ошибками.
Конфликт параллельных изменений — это не то же самое, что «нельзя резервировать больше, чем есть». Валидация отвечает на вопрос «разрешено ли так менять данные вообще», а lost update отвечает на вопрос «не устарели ли данные, на которых мы основывали расчёт». Эти причины неуспеха должны различаться, иначе вы будете лечить не ту болезнь.
Ошибка №4: анализировать только финальное состояние строки и игнорировать момент чтения.
Когда вы видите в БД “available=7”, очень легко сказать «значит, кто-то зарезервировал 3». Но в lost update важно не только что записано, а какой путь к записи был: из какого исходного состояния считали, сколько раз считали, кто писал позже. Без понимания момента чтения вы не увидите, что данные потерялись.
Ошибка №5: думать, что проблема “редкая” и “в учебном проекте не бывает”.
В production это проявляется чаще, чем кажется, потому что параллельность создаёт не только пользователь, но и фоновые задачи, и интеграции, и повторные попытки запросов, и даже ваши же тесты, если они запускаются параллельно. Если модель данных содержит «текущее состояние», потерянные обновления — не экзотика, а риск по умолчанию.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ