← Назад к вопросам
Что будет происходить в базе данных, если две транзакции хотят обновить одну и ту же строку?
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
- Финансовые операции → SERIALIZABLE или SELECT FOR UPDATE
- Обновления профилей → Optimistic Locking (@Version)
- Высокая конкуренция → Optimistic Locking
- Простота важнее → SELECT FOR UPDATE
- Всегда проверь что happens в твоей БД (PostgreSQL ≠ MySQL ≠ Oracle)