Що таке втрачене оновлення (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)
Аномалія виникає не через збої СУБД, а через розрив у часі між операціями в застосунку:
- 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).
Червоні прапорці (Red Flags)
- Стверджувати, що звичайна анотація
@Transactionalзахищає від Lost Update. - Використовувати
PESSIMISTIC_READзамістьPESSIMISTIC_WRITEперед наступним оновленням рядка (гарантує взаємне блокування / Deadlock). - Виконувати прямий
@Modifying @Queryбез ручного інкременту версії на сутностях з@Version. - Рекомендувати Optimistic Locking для екстремально гарячих рядків із високим Contention.