Питання 10 · Розділ 11

Що таке втрачене оновлення (Lost Update)?

Lost Update — це не проблема рівня ізоляції безпосередньо, а проблема паттерну read-modify-write. Вона виникає коли:

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Втрачене оновлення — це аномалія, при якій зміни однієї транзакції перезаписуються іншою транзакцією, і перше оновлення безповоротно втрачається.

Ключове: обидві транзакції не лише читають, а й ЗАПИСУЮТЬ. Останній запис стирає результат першої.

Проста аналогія: Два редактори одночасно правлять один документ. Редактор А виправив 5 помилок, натиснув “Зберегти”. Редактор Б виправив 3 інші помилки, натиснув “Зберегти”. Файл зберіг лише правки Б — правки А втрачено.

SQL-приклад:

-- Початкове значення: balance = 1000

-- Транзакція 1: зарахування +500
BEGIN;
SELECT balance FROM accounts WHERE id = 1;    -- читає 1000
-- (обчислює: 1000 + 500 = 1500)

-- Транзакція 2: списання -200
BEGIN;
SELECT balance FROM accounts WHERE id = 1;    -- теж читає 1000
-- (обчислює: 1000 - 200 = 800)

-- Транзакція 1:
UPDATE accounts SET balance = 1500 WHERE id = 1;  -- записує 1500
COMMIT;

-- Транзакція 2:
UPDATE accounts SET balance = 800 WHERE id = 1;   -- перезаписує на 800!
COMMIT;

-- Підсумок: balance = 800. Зарахування +500 втрачено!

Ключова проблема: Обидві транзакції прочитали одне й те саме значення і записали результат своїх обчислень, перезаписавши одна одну.

Які рівні ізоляції захищають: | Рівень | Lost Update можливий? | | —————- | ——————— | | Read Uncommitted | Так | | Read Committed | Так | | Repeatable Read | Залежить від СУБД | | Serializable | Ні |

Коли важливо: Баланси рахунків, лічильники, інвентар, будь-які операції “прочитати-змінити-записати”.


🟡 Middle Level

Як це працює всередині

Lost Update — це не проблема рівня ізоляції безпосередньо, а проблема паттерну read-modify-write. Вона виникає коли:

  1. Транзакція А читає значення X
  2. Транзакція Б читає значення X (той самий snapshot)
  3. Транзакція А обчислює нове значення і записує
  4. Транзакція Б обчислює своє значення і перезаписує результат А

На рівні Read Committed це відбувається природно, тому що кожен SELECT бачить останні закомічені дані. На Repeatable Read у PostgreSQL це менш ймовірне (MVCC snapshot), але все ще можливе за певних сценаріїв.

Реальні сценарії

Сценарій 1: Оновлення рахунку через API

// Без захисту — Lost Update гарантований при конкурентних запитах
@Transactional
public void deposit(Long accountId, BigDecimal amount) {
    Account account = accountRepo.findById(accountId);   -- читає balance = 1000
    account.setBalance(account.getBalance().add(amount)); // 1000 + 500 = 1500
    accountRepo.save(account);                            -- записує 1500
}
// Два паралельних виклики deposit(500) можуть дати 1500 замість 2000

Сценарій 2: Лічильник переглядів

@Transactional
public void incrementViews(Long articleId) {
    Article article = articleRepo.findById(articleId);     // views = 99
    article.setViews(article.getViews() + 1);              // 99 + 1 = 100
    articleRepo.save(article);                              // записує 100
}
// 100 паралельних запитів можуть дати 101 замість 199

Типові помилки

Помилка Наслідок Рішення
Read-modify-write без блокувань Втрачені оновлення при конкурентному доступі SELECT ... FOR UPDATE або optimistic locking
Припущення, що @Transactional захищає від lost update Транзакція != захист від конкурентних записів Використати @Version або pessimistic lock
Optimistic lock retry без ліміту Нескінченний цикл retries при високій конкуренції Додати maxRetries та fallback
UPDATE без WHERE version = ? Обхід optimistic locking Завжди використовувати version в умові

Порівняння підходів захисту

Підхід Механізм Плюси Мінуси
SELECT ... FOR UPDATE Pessimistic lock Простота, гарантія захисту Блокування, deadlocks, зниження throughput
@Version (optimistic) Перевірка версії при записі Немає блокувань, висока швидкість Retries при конфлікті, не підходить для hot-spots
Atomic UPDATE (SET x = x + 1) Один запит без читання Максимальна продуктивність Тільки для простих операцій, немає validation
Serializable isolation Повна серіалізація Захист від усіх аномалій Serialization failures, низький throughput

Коли НЕ варто використовувати pessimistic locking

  • Висока конкуренція на один запис (лічильники, лідери) — deadlock rate зростатиме експоненціально
  • Read-heavy workload — 95%+ запитів на читання, блокування надмірні
  • Мікросервіси з eventual consistency — блокування не масштабуються горизонтально

Коли НЕ використовувати optimistic locking (@Version)

Не використовуйте optimistic locking (@Version):

  • Для hot-spot записів (лічильники, черги) — contention >20%, нескінченні retries
  • Для фінансових транзакцій з жорсткими інваріантами — retry logic ускладнює код

🔴 Senior Level

Internal Implementation: MVCC, Lock Types, та Write Skew

Терміни:

  • Write Skew — дві транзакції читають перехресні дані, приймають рішення на основі прочитаного, і записують результат, порушуючи інваріант.
  • HOT update (Heap Only Tuple) — оновлення без зміни індексних колонок, тому PostgreSQL оновлює рядок без оновлення індексу.
  • EvalPlanQual — механізм PG: при конкурентному UPDATE перечитує рядок і перевіряє, чи задовольняє він WHERE-умову в новій версії.

Як PostgreSQL MVCC запобігає (і не запобігає) Lost Update

PostgreSQL Read Committed (за замовчуванням):
  Кожен SELECT бачить останній committed snapshot
  T1: SELECT balance → 1000 (snapshot at time T1)
  T2: SELECT balance → 1000 (snapshot at time T2, вже бачить committed T1, якщо T1 закомітив)

  Якщо T1 і T2 працюють паралельно (обидві ще не закомітили):
  T1: SELECT balance → 1000
  T2: SELECT balance → 1000 (MVCC: xmax = 0, row не видалена)
  T1: UPDATE balance = 1500 → створює нову версію рядка (new tuple, xmin = T1)
  T2: UPDATE balance = 800 → ЧЕКАЄ (T1 ще не закомітив, row locked)
  T1: COMMIT → T2 продовжує, бачить нову версію рядка
  T2: UPDATE balance = 800 → перезаписує! Lost Update!

Ключовий інсайт: На Read Committed PostgreSQL НЕ запобігає Lost Update для read-modify-write паттерну, тому що T2 після очікування блокування перечитує рядок (але не обов’язково бачить зміну T1, якщо логіка на рівні додатку).

Pessimistic Locking: Row-Level Lock Internals

SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;

Що відбувається всередині:

  1. PostgreSQL встановлює ExclusiveLock на tuple (рядок)
  2. Інші транзакції, що намагаються SELECT ... FOR UPDATE або UPDATE, блокуються
  3. Lock зберігається в LockManager shared memory (~200 bytes per lock)
  4. При deadlock: PostgreSQL обирає жертву за вартістю транзакції (менше work → abort)

Deadlock detection:

PostgreSQL запускає deadlock detector кожні deadlock_timeout (за замовчуванням 1 сек)
Алгоритм: побудова wait-for graph, пошук циклів
Складність: O(V + E) де V = число транзакцій, E = число очікувань

Optimistic Locking: @Version Implementation

@Entity
public class Account {
    @Id
    private Long id;

    private BigDecimal balance;

    @Version
    private Long version;  // Hibernate додає WHERE version = ?
}

Generated SQL:

-- Hibernate при save():
UPDATE accounts
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?;

-- Якщо rows_affected == 0 → OptimisticLockException

Internal mechanics в Hibernate:

  1. При завантаженні entity: читає поточний version
  2. При flush: генерує UPDATE з WHERE version = old_version
  3. Якщо executeUpdate() == 0: значить інша транзакція вже змінила рядок
  4. Hibernate кидає OptimisticLockException (або StaleObjectStateException)

Архітектурні Trade-offs

Підхід A: Atomic SQL (SET balance = balance + ?)

  • ✅ Плюси: Zero lost update, максимальний throughput (50K+ TPS), немає блокувань
  • ❌ Мінуси: Тільки для простих операцій, не можна додати validation, немає audit trail
  • Підходить для: лічильників, простих інкрементів/декрементів

Підхід B: Optimistic Locking (@Version)

  • ✅ Плюси: Немає блокувань, детермінована поведінка, easy to implement
  • ❌ Мінуси: Retries при contention (5-30% abort rate при high load), не працює для hot-spots
  • Підходить для: CRUD з помірною конкуренцією (profile updates, config changes)

Підхід C: Pessimistic Locking (SELECT … FOR UPDATE)

  • ✅ Плюси: Гарантія захисту, простота, працює для complex business logic
  • ❌ Мінуси: Deadlocks, throughput зниження 3-10x, connection pool exhaustion risk
  • Підходить для: фінансових транзакцій з низькою конкуренцією

Підхід D: Event Sourcing / CQRS

  • ✅ Плюси: Немає lost updates (append-only), повний audit trail, горизонтальне масштабування
  • ❌ Мінуси: Складність, eventual consistency, потрібен event store
  • Підходить для: високонавантажених систем з вимогою аудиту

Edge Cases та Corner Cases

1. Lost Update зі складним ключем:

// UPDATE може "втратитися" якщо WHERE condition не унікальний
UPDATE accounts SET balance = ? WHERE user_id = ? AND currency = 'USD';
// Якщо два запити одночасно, обидва знайдуть один рядок, але UPDATE атомарний
// Однак read-modify-write ДО UPDATE вже зламаний

2. Hibernate First-Level Cache та Lost Update:

@Transactional
public void updateAccount(Long id, BigDecimal amount) {
    Account a1 = repo.findById(id);      // завантажений в L1 cache
    Account a2 = repo.findById(id);      // поверне той самий об'єкт з L1 cache!

    // Якщо інша транзакція змінила БД між двома findById,
    // a1 і a2 обидва покажуть старе значення
    a1.setBalance(a1.getBalance().add(amount));
    repo.save(a1);  // Може перезаписати чужі зміни
}

3. Write Skew (витончений Lost Update):

-- doctors on call constraint: at least 1 doctor must be on call
-- T1: SELECT COUNT(*) FROM doctors WHERE on_call = true;  → 2
-- T2: SELECT COUNT(*) FROM doctors WHERE on_call = true;  → 2
-- T1: UPDATE doctors SET on_call = false WHERE name = 'Alice';  → тепер 1
-- T2: UPDATE doctors SET on_call = false WHERE name = 'Bob';    → тепер 0! Constraint порушено

На Repeatable Read обидва SELECT повернуть 2. Обидва UPDATE пройдуть. Constraint порушено. Це Write Skew — варіація Lost Update.

4. Lost Update з batching:

// Пакетне оновлення: оновлюємо 1000 записів
@Transactional
public void batchUpdate() {
    List<Account> accounts = repo.findAll();  // snapshot 1
    for (Account a : accounts) {
        a.setBalance(calculate(a));           // обчислення
    }
    repo.saveAll(accounts);                   // flush — може перезаписати чужі зміни
}
// Поки batch працює, інша транзакція може змінити 500 з 1000 записів

5. Lost Update через secondary index:

-- UPDATE по не-унікальному індексу може викликати row lock escalation
UPDATE accounts SET balance = ? WHERE status = 'active';
-- Якщо 10,000 рядків, PostgreSQL може escalate до page-level або table-level lock

Performance Implications

Підхід Latency (p99) Throughput Вплив contention
Atomic SQL 1-3ms 50,000+ TPS Мінімальний
Optimistic Locking 2-5ms (happy path), 50-200ms (retry) 30,000 TPS (low contention), 5,000 TPS (high) Експоненційне зростання retries
Pessimistic Locking 5-20ms 5,000-10,000 TPS Лінійне зниження
Serializable 20-100ms 2,000-8,000 TPS 10-30% abort rate

Конкретні цифри (PostgreSQL 15, 8 cores, NVMe, 1M rows):

  • Atomic SET balance = balance + 1: ~55,000 TPS
  • Optimistic Lock з 10% contention: ~25,000 TPS, avg 3 retries
  • Pessimistic Lock: ~8,000 TPS, deadlock rate 0.1% при 100 concurrent
  • Serializable: ~5,000 TPS, serialization failure rate 15%

Memory Implications

  • Row locks (FOR UPDATE): ~200 bytes per lock у shared memory. При 10,000 concurrent locks = ~2MB.
  • Hibernate L1 cache: Одне entity ~500-2000 bytes. При завантаженні 10,000 entities = 5-20MB у heap.
  • MVCC dead tuples: Кожне UPDATE створює нову версію рядка. Стара версія — dead tuple. AutoVacuum видаляє, але при високій частоті оновлень → table bloat (2-5x росту).
  • Retry buffers: При optimistic locking, кожна retry алокує новий transaction context. При 1000 retries/sec = ~50MB/s allocation rate.

Concurrency Aspects

Write Skew detection:

PostgreSQL Serializable: відстежує RW-dependencies
  T1: reads {doctors WHERE on_call = true}
  T2: reads {doctors WHERE on_call = true}
  T1: writes (update Alice)
  T2: writes (update Bob)

  Dependency graph:
    T1 → reads → predicate P → T2 writes to P
    T2 → reads → predicate P → T1 writes to P

  Cycle detected → serialization failure → one aborts

Real Production Scenario

Ситуація: Криптовалютна біржа (2023), обробка ордерів.

Проблема: При високій волатильності (5000 ордерів/сек) баланси користувачів розсинхронізувалися. Сума балансів не збігалася з сумою депозитів — “зниклі” $2M за 30 хвилин.

Root cause:

@Transactional  // Read Committed за замовчуванням
public void executeOrder(Long userId, BigDecimal amount) {
    User user = userRepo.findById(userId);       // balance = 10,000
    // Order processing...
    user.setBalance(user.getBalance().subtract(amount));  // 10,000 - 500 = 9,500
    userRepo.save(user);
}

При 5000 ордерів/сек, множинні транзакції читали один баланс, віднімали свою суму, і записували результат. Остання транзакція перезаписувала всі попередні.

Рішення (швидке): Atomic SQL:

@Modifying
@Query("UPDATE User u SET u.balance = u.balance - :amount WHERE u.id = :userId AND u.balance >= :amount")
int subtractBalance(@Param("userId") Long userId, @Param("amount") BigDecimal amount);

// Returns rows affected — якщо 0, значить insufficient funds

Чому atomic SQL працює? SET balance = balance - amount виконується за один крок: СУБД читає поточне значення і одразу записує нове. Немає вікна між read та write, де інша транзакція може втрутитися.

Рішення (довгострокове): Event Sourcing з append-only ledger:

// Замість UPDATE балансу — INSERT транзакції
@Transactional
public void executeOrder(Long userId, BigDecimal amount) {
    // Перевірка через суму всіх транзакцій (повільніше, але точніше)
    BigDecimal currentBalance = ledgerRepo.sumByUser(userId);
    if (currentBalance.compareTo(amount) < 0) {
        throw new InsufficientFundsException();
    }
    ledgerRepo.save(new LedgerEntry(userId, amount.negated(), "ORDER"));
    // Баланс = сума всіх записів, немає lost update
}

Impact:

  • До: $2M lost updates за 30 хвилин
  • Після (atomic SQL): 0 lost updates, throughput 8,000 TPS
  • Після (event sourcing): 0 lost updates, throughput 5,000 TPS, full audit trail

Monitoring та Діагностика

PostgreSQL — виявлення lost update паттернів:

-- Check for update conflicts
SELECT
    schemaname,
    relname,
    n_tup_upd,
    n_tup_hot_upd,  -- HOT updates = good (no index update)
    n_dead_tup,     -- Dead tuples від lost updates
    last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'accounts';

-- Long-running transactions (risk window for lost updates)
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY duration DESC;

Hibernate — Optimistic Lock failures:

@EntityListener
public class OptimisticLockMonitor {
    @PostUpdate
    public void onUpdate(Object entity) {
        // Log version conflicts
    }
}

// Micrometer counter
Counter.builder("hibernate.optimistic_lock.failures")
    .tag("entity", "Account")
    .register(meterRegistry);

Deadlock monitoring:

-- PostgreSQL deadlock stats (PG 14+)
SELECT
    datname,
    deadlocks
FROM pg_stat_database;

-- Application: track deadlock rate
// Alert when deadlocks > 10/min

Best Practices для Highload

  1. Використовуйте atomic SQL для лічильників та балансів: UPDATE table SET counter = counter + 1 замість read-modify-write.
  2. Optimistic locking для помірної конкуренції: Додавайте exponential backoff з jitter для retries.
  3. Pessimistic locking лише для критичних шляхів: Мінімізуйте час утримання FOR UPDATE.
  4. Partitioning для hot-spot reduction: Розбивайте таблиці з частими оновленнями (наприклад, по user_id hash).
  5. Event Sourcing для фінансових систем: Append-only ledger виключає lost updates на архітектурному рівні.
  6. Уникайте long-running транзакцій: Чим довша транзакція, тим більше window для lost update.
  7. Monitor dead tuples: Регулярний autoVacuum, налаштуйте autovacuum_vacuum_threshold для high-update таблиць.

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • Lost Update — дві транзакції читають одне значення, обидві змінюють і записують, результат першої перезаписується другою
  • Це проблема паттерну read-modify-write, а не конкретного рівня ізоляції
  • Рішення: atomic SQL (SET balance = balance + 1), SELECT FOR UPDATE, optimistic locking (@Version), Serializable
  • PostgreSQL на Read Committed НЕ запобігає lost update для read-modify-write на рівні додатку
  • Event Sourcing / append-only ledger повністю виключає lost updates на архітектурному рівні
  • Write Skew — варіація lost update: дві транзакції читають перехресні дані і порушують інваріант

Часті уточнюючі питання:

  • Як atomic SQL запобігає lost update? — Один крок: СУБД читає і записує атомарно, немає вікна для втручання
  • Чим pessimistic locking відрізняється від optimistic? — Pessimistic блокує заздалегідь, optimistic перевіряє версію при записі
  • Коли використовувати Event Sourcing? — Фінансові системи з вимогою аудиту, високонавантажені системи
  • Що таке Write Skew? — Дві транзакції читають одне, обидві змінюють — інваріант порушено (приклад: doctors on call)

Червоні прапорці (НЕ говорити):

  • “@Transactional захищає від lost update” — транзакція != захист від конкурентних записів
  • “Optimistic locking завжди кращий за pessimistic” — для hot-spots optimistic = нескінченні retries
  • “Lost Update = проблема рівня ізоляції” — це проблема паттерну read-modify-write

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

  • [[2. Які рівні ізоляції транзакцій існують]]
  • [[5. Що таке Repeatable Read]]
  • [[13. Що таке Propagation в Spring]]
  • [[16. Що таке анотація @Transactional]]