💳 Раздел 11 · Вопрос #10

Что такое потерянное обновление (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)

Аномалия возникает не из-за сбоев СУБД, а из-за разрыва во времени между операциями в приложении:

  1. Read (Чтение): Загрузка строки из БД в память приложения.
  2. Modify (Модификация): Изменение полей объекта в оперативной памяти JVM.
  3. 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:

  1. Вызывает findById(), получая состояние строки в момент $T_1$.
  2. Выполняет расчеты в локальной памяти JVM (пока поток может быть приостановлен GC или планировщиком).
  3. Выполняет 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 метода решения:
    1. Atomic SQL: UPDATE acc SET val = val + 1 WHERE id = 1 (самый быстрый, $O(1)$).
    2. Optimistic Locking: @Version в Entity (нет блокировок, идеален для низкой/умеренной конкуренции).
    3. Pessimistic Locking: SELECT FOR UPDATE (@Lock(PESSIMISTIC_WRITE)) — выстраивает потоки в очередь (идеален для hot-spots).
    4. Event Sourcing: Использование append-only журнала (INSERT вместо UPDATE).
  • Поведение в PostgreSQL: На уровне Repeatable Read СУБД сама прерывает конфликтующий апдейт ошибкой 40001 (serialization failure).

Связанные темы