Що таке втрачене оновлення (Lost Update)?
Lost Update — це не проблема рівня ізоляції безпосередньо, а проблема паттерну read-modify-write. Вона виникає коли:
🟢 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. Вона виникає коли:
- Транзакція А читає значення X
- Транзакція Б читає значення X (той самий snapshot)
- Транзакція А обчислює нове значення і записує
- Транзакція Б обчислює своє значення і перезаписує результат А
На рівні 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;
Що відбувається всередині:
- PostgreSQL встановлює
ExclusiveLockна tuple (рядок) - Інші транзакції, що намагаються
SELECT ... FOR UPDATEабоUPDATE, блокуються - Lock зберігається в
LockManagershared memory (~200 bytes per lock) - При 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:
- При завантаженні entity: читає поточний version
- При flush: генерує UPDATE з
WHERE version = old_version - Якщо
executeUpdate() == 0: значить інша транзакція вже змінила рядок - 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
- Використовуйте atomic SQL для лічильників та балансів:
UPDATE table SET counter = counter + 1замість read-modify-write. - Optimistic locking для помірної конкуренції: Додавайте exponential backoff з jitter для retries.
- Pessimistic locking лише для критичних шляхів: Мінімізуйте час утримання FOR UPDATE.
- Partitioning для hot-spot reduction: Розбивайте таблиці з частими оновленнями (наприклад, по user_id hash).
- Event Sourcing для фінансових систем: Append-only ledger виключає lost updates на архітектурному рівні.
- Уникайте long-running транзакцій: Чим довша транзакція, тим більше window для lost update.
- 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]]