💳 Розділ 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;
import java.math.BigDecimal;

@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).

Червоні прапорці (Red Flags)

  • Стверджувати, що звичайна анотація @Transactional захищає від Lost Update.
  • Використовувати PESSIMISTIC_READ замість PESSIMISTIC_WRITE перед наступним оновленням рядка (гарантує взаємне блокування / Deadlock).
  • Виконувати прямий @Modifying @Query без ручного інкременту версії на сутностях з @Version.
  • Рекомендувати Optimistic Locking для екстремально гарячих рядків із високим Contention.

Пов’язані теми