Вопрос 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 Impact
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]]