← Назад к вопросам

Что будет происходить в базе данных, если две транзакции хотят обновить одну и ту же строку?

3.0 Senior🔥 151 комментариев
#Базы данных и SQL#Многопоточность

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Конфликты при одновременном обновлении строки: Уровни изоляции и锁

Краткий ответ: Всё зависит от уровня изоляции транзакции и механизма блокировки в БД. Без явной блокировки одна из транзакций может перезаписать изменения другой.

Проблема: Lost Updates (Потерянные обновления)

Шаг 1: Transaction A читает счёт: balance = 1000
Шаг 2: Transaction B читает счёт: balance = 1000
Шаг 3: Transaction A пополняет +500 → balance = 1500
Шаг 4: Transaction B снимает -200 → balance = 800
Шаг 5: Transaction A коммитит: balance = 1500
Шаг 6: Transaction B коммитит: balance = 800 (перезаписал A!)

Результат: потеря 500 (обновление A потеряно)

Решение 1: SELECT FOR UPDATE (Pessimistic Locking)

Идея: Заблокировать строку сразу при чтении.

@Service
public class AccountService {
    @Autowired
    private AccountRepository accountRepository;
    
    @Transactional
    public void transferMoney(String fromId, String toId, BigDecimal amount) {
        // Пессимистическая блокировка
        Account fromAccount = accountRepository.findByIdForUpdate(fromId);
        Account toAccount = accountRepository.findByIdForUpdate(toId);
        
        // Обновляем
        fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
        toAccount.setBalance(toAccount.getBalance().add(amount));
        
        // Коммитим — блокировка снимается
    }
}

// Repository
@Repository
public interface AccountRepository extends JpaRepository<Account, String> {
    @Query("SELECT a FROM Account a WHERE a.id = :id FOR UPDATE")
    Account findByIdForUpdate(@Param("id") String id);
}

SQL:

SELECT * FROM accounts WHERE id = '123' FOR UPDATE;
-- Строка заблокирована, другие транзакции ждут

Хронология:

Transaction A: SELECT FOR UPDATE account_1  → LOCKED
Transaction B: SELECT FOR UPDATE account_1  → ЖДЁТ
Transaction A: UPDATE account_1 ... COMMIT   → UNLOCK
Transaction B: SELECT FOR UPDATE account_1  → NOW LOCKED
Transaction B: UPDATE account_1 ... COMMIT   → UNLOCK

Преимущества:

  • ✅ Гарантирует: одновременно только одна транзакция
  • ✅ Просто в понимании

Недостатки:

  • ❌ Deadlock риск (если A ждёт B, а B ждёт A)
  • ❌ Может привести к снижению производительности
  • ❌ Может привести к timeout

Решение 2: Optimistic Locking (Version Column)

Идея: Не блокировать, но проверить версию при обновлении.

@Entity
public class Account {
    @Id
    private String id;
    
    private BigDecimal balance;
    
    @Version  // JPA будет управлять версией
    private Long version;
}

@Service
public class AccountService {
    @Autowired
    private AccountRepository accountRepository;
    
    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();
        
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        
        // Сохраняем — версия увеличится
        accountRepository.save(from);  // SQL: UPDATE ... WHERE id = ? AND version = ?
        accountRepository.save(to);
    }
}

SQL:

UPDATE accounts SET balance = 1500, version = 2 
WHERE id = '123' AND version = 1;
-- Если version != 1 → 0 rows updated → OptimisticLockException

Хронология:

Transaction A: SELECT account (version=1, balance=1000)
Transaction B: SELECT account (version=1, balance=1000)
Transaction A: UPDATE ... SET balance=1500, version=2 WHERE version=1 → SUCCESS
Transaction B: UPDATE ... SET balance=800, version=2 WHERE version=1 → FAIL!
               OptimisticLockException!
               → Retry

Преимущества:

  • ✅ Нет deadlock
  • ✅ Лучше производительность (нет блокировок)
  • ✅ Масштабируется лучше

Недостатки:

  • ❌ Нужно обработать OptimisticLockException
  • ❌ При высокой конкуренции много retries

Решение 3: Уровни изоляции PostgreSQL

Isolation Level определяет, какие аномалии допускаются:

READ UNCOMMITTED
├─ Грязное чтение: читаешь незакоммиченные данные
├─ Аномалия: T1 пишет, T2 читает до коммита T1
└─ Редко используется

READ COMMITTED (default PostgreSQL)
├─ Избегает грязного чтения
├─ Фантомное чтение: SELECT возвращает разные наборы
├─ Lost update: T1 обновила, T2 перезаписала
└─ Нужен SELECT FOR UPDATE для safety

REPEATABLE READ
├─ Избегает грязного чтения и non-repeatable reads
├─ Фантомное чтение всё ещё возможно
├─ T1 читает X дважды, между ними T2 добавила нужное T1 значение
└─ В PostgreSQL это Snapshot Isolation (сильнее стандарта)

SERIALIZABLE
├─ Максимальная безопасность
├─ Все транзакции выполняются как будто последовательно
├─ Медленно, много conflicts
└─ Используется редко (только для критичных операций)

Практический пример: Какой использовать?

// Сценарий 1: Финансовый трансфер (HIGH RISK)
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferMoney(String from, String to, BigDecimal amount) {
    // Максимальная защита
}

// Сценарий 2: Обновление профиля (MEDIUM RISK)
@Transactional(isolation = Isolation.REPEATABLE_READ)
@Version
public void updateProfile(User user) {
    // Optimistic locking (version column)
}

// Сценарий 3: Чтение отчёта (LOW RISK)
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public List<Transaction> getHistory(String userId) {
    // Достаточно READ_COMMITTED
}

Таблица сравнения

МеханизмБлокировкаDeadlockПроизвод.Сложность
SELECT FOR UPDATEДаВозможен⭐⭐⭐⭐⭐
Optimistic LockingНетНет⭐⭐⭐⭐⭐⭐⭐
SERIALIZABLEДаВозможен
REPEATABLE_READНетНет⭐⭐⭐⭐

Практический код: Обе стратегии

// ============ PESSIMISTIC (SELECT FOR UPDATE) ============
@Repository
public interface AccountRepository extends JpaRepository<Account, String> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = ?1")
    Optional<Account> findByIdForUpdate(String id);
}

@Service
public class PessimisticTransferService {
    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        Account from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        Account to = accountRepository.findByIdForUpdate(toId).orElseThrow();
        
        from.withdraw(amount);
        to.deposit(amount);
    }
}

// ============ OPTIMISTIC (Version) ============
@Entity
public class Account {
    @Id
    private String id;
    private BigDecimal balance;
    @Version
    private Long version;  // Hibernated управляет
}

@Service
public class OptimisticTransferService {
    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        try {
            Account from = accountRepository.findById(fromId).orElseThrow();
            Account to = accountRepository.findById(toId).orElseThrow();
            
            from.withdraw(amount);
            to.deposit(amount);
            
            accountRepository.saveAll(List.of(from, to));
        } catch (OptimisticLockException e) {
            // Retry логика
            throw new RetryableException("Transfer conflict, retry", e);
        }
    }
}

На собеседовании правильный ответ

"Без явного управления — вторая транзакция перезапишет
изменения первой (Lost Update).

Решения:

1. SELECT FOR UPDATE (пессимистичная блокировка)
   - Блокируем строку при SELECT
   - SQL: SELECT * FROM accounts WHERE id = ? FOR UPDATE
   - Гарантирует: только одна транзакция за раз
   - Минус: может быть deadlock, медленнее

2. Optimistic Locking (@Version column)
   - Добавляем version к сущности
   - При обновлении проверяем версию
   - SQL: UPDATE ... SET balance = ?, version = version+1
          WHERE id = ? AND version = ?
   - Если версия изменилась — OptimisticLockException
   - Минус: нужно обработать exception и retry

3. Правильный Isolation Level
   - SERIALIZABLE для критичных операций
   - REPEATABLE_READ для обычных
   - READ_COMMITTED для read-only

Выбирать зависит от требований к consistency и производительности."

Best Practices

  1. Финансовые операции → SERIALIZABLE или SELECT FOR UPDATE
  2. Обновления профилей → Optimistic Locking (@Version)
  3. Высокая конкуренция → Optimistic Locking
  4. Простота важнее → SELECT FOR UPDATE
  5. Всегда проверь что happens в твоей БД (PostgreSQL ≠ MySQL ≠ Oracle)
Что будет происходить в базе данных, если две транзакции хотят обновить одну и ту же строку? | PrepBro