Что такое потерянное обновление (Lost Update)
Аномалия возникает не из-за сбоев СУБД, а из-за разрыва во времени между операциями в приложении:
🟢 Junior Level
Потерянное обновление (Lost Update) — это критическая аномалия параллельного доступа, при которой две параллельные транзакции одновременно считывают одну и ту же запись, производят независимые вычисления в памяти приложения и сохраняют результат обратно в базу данных. В итоге транзакция, выполнившая запись последней, безвозвратно перезаписывает (стирает) изменения первой транзакции.
Простой пример: потеря пополнения счета
Начальный баланс: 1 000 руб.
Транзакция 1 (Зачисление зарплаты +500 руб.) Транзакция 2 (Списание за подписку -200 руб.)
-------------------------------------------- ---------------------------------------------
1. SELECT balance FROM acc WHERE id = 1;
-- Прочитал: 1 000 руб.
2. SELECT balance FROM acc WHERE id = 1;
-- Тоже прочитал: 1 000 руб.!
3. Вычисляет в RAM: 1 000 + 500 = 1 500
4. UPDATE acc SET balance = 1500 WHERE id = 1;
5. COMMIT; -- Баланс в БД = 1 500 руб.
6. Вычисляет в RAM: 1 000 - 200 = 800
7. UPDATE acc SET balance = 800 WHERE id = 1;
8. COMMIT; -- Баланс в БД = 800 руб.!
ИТОГ В БАЗЕ: 800 руб. Зачисление 500 рублей от Транзакции 1 полностью потеряно!
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountService {
// ОПАСНЫЙ КОД: @Transactional НЕ защищает от Lost Update на уровне Read Committed!
@Transactional
public void depositUnsafe(Long accountId, BigDecimal amount) {
Account account = accountRepository.findById(accountId).orElseThrow();
account.setBalance(account.getBalance().add(amount)); // Read-Modify-Write в памяти JVM
accountRepository.save(account); // При параллельных вызовах перезапишет чужие апдейты
}
}
Аналогия: Два сотрудника скачали один и тот же файл Excel с остатками склада. Первый добавил 10 коробок и сохранил файл на сетевой диск. Второй убавил 5 коробок и через минуту нажал «Сохранить». В итоговом файле осталась только правка второго сотрудника — поступление 10 коробок исчезло.
🟡 Middle Level
Почему возникает Lost Update (Паттерн Read-Modify-Write)
Аномалия возникает не из-за сбоев СУБД, а из-за разрыва во времени между операциями в приложении:
- Read (Чтение): Загрузка строки из БД в память приложения.
- Modify (Модификация): Изменение полей объекта в оперативной памяти JVM.
- Write (Запись): Отправка
UPDATEобратно в базу данных.
На стандартном уровне Read Committed СУБД защищает каждую отдельную строчку SQL-запроса, но не связывает SELECT и последующий UPDATE, разделенные миллисекундами работы бизнес-логики.
4 способа гарантированного предотвращения Lost Update
МЕТОДЫ БОРЬБЫ С LOST UPDATE
│
┌───────────────────────┼───────────────────────┬───────────────────────┐
▼ ▼ ▼ ▼
1. ATOMIC IN-PLACE SQL 2. PESSIMISTIC LOCK 3. OPTIMISTIC LOCK 4. HIGH ISOLATION
UPDATE ... SET SELECT ... @Version колонка Repeatable Read /
balance = balance + ? FOR UPDATE в сущности Serializable
1. Атомарный SQL-запрос (In-Place Update)
Исключает стадию переноса данных в память приложения:
@Modifying
@Query("UPDATE Account a SET a.balance = a.balance + :amount WHERE a.id = :id")
int atomicDeposit(@Param("id") Long id, @Param("amount") BigDecimal amount);
СУБД захватывает эксклюзивную блокировку (X-lock) на строку и вычисляет новое значение прямо в ядре базы данных.
2. Оптимистическая блокировка (Optimistic Locking через @Version)
В таблицу добавляется числовое поле версии. При записи проверяется, что версия в БД совпадает с исходной:
@Entity
public class Account {
@Id private Long id;
private BigDecimal balance;
@Version // Управляется Hibernate автоматически
private Long version;
}
Сгенерированный SQL:
UPDATE accounts SET balance = 1500, version = 2 WHERE id = 1 AND version = 1;
Если параллельная транзакция успела обновить строку первой (version уже стал 2), этот запрос затронет 0 строк, и Hibernate немедленно выбросит OptimisticLockException.
3. Пессимистическая блокировка (Pessimistic Locking FOR UPDATE)
Блокирует строку на чтении до конца транзакции:
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findByIdForUpdate(@Param("id") Long id);
}
🔴 Senior Level
Поведение Repeatable Read в PostgreSQL против Lost Update
В стандарте ANSI SQL-92 аномалия Lost Update не была выделена в отдельную категорию (ее формализовали Беренсон и Грей в 1995 г. как $P_4$).
Интересно ведет себя PostgreSQL:
- На Read Committed: Допускает Lost Update при использовании прикладного паттерна Read-Modify-Write. Механизм EvalPlanQual (EPQ) перечитывает строку, но перезаписывает ее вычисленным в Java старым значением.
- На Repeatable Read: Автоматически предотвращает Lost Update!
Если транзакция $T_2$ пытается выполнить
UPDATEстроки, которая уже была изменена и закоммичена транзакцией $T_1$ после старта снимка $T_2$, PostgreSQL немедленно прерывает $T_2$:ERROR: could not serialize access due to concurrent update (SQLSTATE 40001).
Архитектурный паттерн: Event Sourcing (Append-Only Ledger)
В финтех-системах и биржах высокого уровня (Tier-1 банки, криптобиржи) операции с балансами вообще не используют UPDATE:
- Таблица счетов (
Account) является представлением (Read Model). - Баланс не перезаписывается: любое движение средств сохраняется как неизменяемая проводка (Ledger Transaction) через
INSERT:INSERT INTO transactions (account_id, amount, operation_type, created_at) VALUES (1, +500, 'SALARY', now()); - Текущий баланс — это сумма всех записей в журнале (
SUM(amount)). - Результат: Lost Update невозможен физически, так как операции
UPDATEотсутствуют в системе.
4 Tricky Questions
1. Почему аннотация @Transactional в Spring по умолчанию абсолютно не защищает от аномалии Lost Update?
Ответ:
Аннотация @Transactional лишь обрамляет метод командами connection.setAutoCommit(false) и connection.commit().
По умолчанию транзакция выполняется на дефолтном уровне изоляции базы данных (Read Committed).
Внутри метода код на Java:
- Вызывает
findById(), получая состояние строки в момент $T_1$. - Выполняет расчеты в локальной памяти JVM (пока поток может быть приостановлен GC или планировщиком).
- Выполняет
save(), который генерируетUPDATE ... SET col = :newVal WHERE id = :id. На уровнеRead CommittedСУБД успешно применит этотUPDATE, перезаписав любые другие коммиты, произошедшие между шагами 1 и 3. Транзакция гарантирует только атомарность группы запросов, но не защищает от гонок на уровне сущностей без явного версионирования или блокировок.
2. В чем разница между LockModeType.PESSIMISTIC_READ и LockModeType.PESSIMISTIC_WRITE в JPA? Какой из них предотвращает Lost Update?
Ответ:
PESSIMISTIC_READ: Транслируется вSELECT ... FOR SHARE(илиLOCK IN SHARE MODE). Запрашивает разделяемую блокировку (S-lock). Другие транзакции могут параллельно читать строку, но не могут ее модифицировать. Опасность: Если две транзакции одновременно возьмутPESSIMISTIC_READна одну и ту же строку, а затем обе попытаются выполнитьUPDATE, возникнет гарантированный Deadlock, так как ни одна из них не сможет повысить S-lock до X-lock!PESSIMISTIC_WRITE: Транслируется вSELECT ... FOR UPDATE. Запрашивает монопольную блокировку (X-lock). ИменноPESSIMISTIC_WRITEгарантированно защищает от Lost Update, выстраивая параллельные потоки в строгую очередь еще на этапе чтения.
3. Что произойдет, если в сущности с аннотацией @Version выполнить обновление через нативный SQL-запрос (@Modifying @Query)?
Ответ:
Механизм оптимистической блокировки будет полностью сломан!
Аннотация @Version поддерживается исключительно движком Hibernate при вызовах entityManager.merge(), persist() или автоматическом dirty checking управляемых сущностей.
Если разработчик выполняет прямой запрос:
@Modifying
@Query("UPDATE Account a SET a.balance = :b WHERE a.id = :id")
void updateRaw(@Param("id") Long id, @Param("b") BigDecimal b);
Hibernate отправляет этот SQL в базу напрямую. Колонка version в БД не будет инкрементирована, условие WHERE version = ? не будет добавлено, и проверка версий не сработает. Защита от Lost Update отключится.
4. В каких ситуациях Optimistic Locking (@Version) работает хуже, чем Pessimistic Locking (SELECT FOR UPDATE)?
Ответ: В сценариях с высокой конкуренцией на запись в одну точку (High Contention Hot-Spots):
- Классический пример: бронирование последних 10 билетов на концерт суперзвезды или счетчик просмотров вирусного видео (1 000 RPS на одну строку).
- При оптимистической блокировке из 100 одновременных потоков только 1 успешно выполнит коммит, а 99 потоков упадут с
OptimisticLockExceptionи пойдут на повтор (Retry). Это вызовет шторм повторных запросов (Thundering Herd / Retry Storm), сожжет ресурсы CPU и забьет пул соединений к БД. - В условиях высокой конкуренции
Pessimistic Lockingработает значительно быстрее и предсказуемее, так как потоки не падают и не перезапускаются, а быстро проходят по очереди блокировки за несколько миллисекунд.
🎯 Шпаргалка для интервью
- Суть Lost Update: Последняя запись перезаписывает и безвозвратно уничтожает изменения параллельной транзакции (проблема паттерна Read-Modify-Write).
- Почему
@Transactionalне спасает: На уровнеRead Committedтранзакция защищает отдельные SQL-выражения, но не сохраняет память приложения между чтением и записью. - 4 метода решения:
- Atomic SQL:
UPDATE acc SET val = val + 1 WHERE id = 1(самый быстрый, $O(1)$). - Optimistic Locking:
@Versionв Entity (нет блокировок, идеален для низкой/умеренной конкуренции). - Pessimistic Locking:
SELECT FOR UPDATE(@Lock(PESSIMISTIC_WRITE)) — выстраивает потоки в очередь (идеален для hot-spots). - Event Sourcing: Использование append-only журнала (
INSERTвместоUPDATE).
- Atomic SQL:
- Поведение в PostgreSQL: На уровне
Repeatable ReadСУБД сама прерывает конфликтующий апдейт ошибкой40001 (serialization failure).