← Назад к вопросам
При какой проблеме с двух учетных записей одновременно обновляются данные
1.0 Junior🔥 151 комментариев
#JVM и управление памятью
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
При какой проблеме с двух учетных записей одновременно обновляются данные?
Это вопрос о Race Condition или Lost Update Problem — классической проблеме многопоточности и конкурентного доступа в базах данных. Давайте разберемся в деталях.
Основная проблема: Lost Update
// Сценарий: Два пользователя одновременно обновляют свой баланс счёта
// Счёт: balance = 1000
// Поток 1 (User A): Поток 2 (User B):
T1: READ balance = 1000 →
T2: → T2: READ balance = 1000
T3: balance = 1000 - 500 →
T4: WRITE balance = 500 → T4: balance = 1000 + 200
T5: → T5: WRITE balance = 1200
// Результат: balance = 1200
// ОШИБКА! Должно быть (1000 - 500 + 200 = 700)
// Обновление User A потеряно (Lost Update)!
// Что произошло:
// 1. User A прочитал balance = 1000
// 2. User B прочитал balance = 1000
// 3. User A обновил balance на 500 (вычел)
// 4. User B перезаписал balance на 1200 (не видя изменения A)
// 5. Обновление A потеряно!
Код, демонстрирующий проблему
// ❌ НЕБЕЗОПАСНО — Lost Update
@Service
public class AccountService {
@Autowired
private AccountRepository repository;
public void transfer(Long accountId, BigDecimal amount) {
// Шаг 1: READ
Account account = repository.findById(accountId).orElse(null);
BigDecimal balance = account.getBalance(); // balance = 1000
// Шаг 2: MODIFY (в памяти приложения)
balance = balance.subtract(amount); // balance = 1000 - 500 = 500
account.setBalance(balance);
// Шаг 3: WRITE
repository.save(account); // UPDATE account SET balance = 500
}
}
// Проблема визуально:
/*
Время Thread 1 БД Thread 2
────────────────────────────────────────────────────────────
T0: SELECT balance → 1000
T1: SELECT balance → 1000
T2: balance = 1000 - 500 = 500
T3: balance = 1000 + 200 = 1200
T4: UPDATE balance = 500
T5: UPDATE balance = 1200
T6: COMMIT COMMIT
Результат: balance = 1200 (ПОТЕРЯНО -500 от Thread 1!)
*/
Типы Race Conditions
// 1. LOST UPDATE
// Описано выше — последнее обновление перезаписывает предыдущие
// 2. DIRTY READ
@Service
public class ReportService {
public void generateReport() {
// Thread 1 начал транзакцию и изменил balance
Account account = accountRepository.findById(1L).orElse(null);
account.setBalance(account.getBalance() - 1000);
// Транзакция ещё не COMMIT'ена
// Thread 2 (другой запрос) читает грязные данные
// Он видит balance - 1000, но это может быть откачено!
}
}
// 3. NON-REPEATABLE READ
// Одна транзакция читает данные 2 раза, они отличаются
AccountRepository.findById(1); // balance = 1000 (читай 1)
AccountRepository.findById(1); // balance = 900 (читай 2, изменилось!)
// 4. PHANTOM READ
// Между двумя запросами появляются/исчезают строки
Select * From accounts Where balance > 500; // 10 строк
// Другой поток добавил строку
Select * From accounts Where balance > 500; // 11 строк (фантом)
Решение 1: Pessimistic Locking (SELECT FOR UPDATE)
// ✅ БЕЗОПАСНО — Пессимистичная блокировка
@Service
@Transactional
public class AccountService {
@Autowired
private AccountRepository repository;
public void transfer(Long accountId, BigDecimal amount) {
// Шаг 1: READ WITH LOCK
Account account = repository.findByIdForUpdate(accountId); // SELECT FOR UPDATE
// Блокируем эту строку → другие потоки ждут!
// Шаг 2: MODIFY
account.setBalance(account.getBalance().subtract(amount));
// Шаг 3: WRITE (и UNLOCK при COMMIT)
repository.save(account);
}
}
// Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
@Query("SELECT a FROM Account a WHERE a.id = ?1 FOR UPDATE")
Account findByIdForUpdate(Long id);
}
// Как это работает:
/*
Время Thread 1 БД Thread 2
────────────────────────────────────────────────────────────────────
T0: SELECT FOR UPDATE LOCK row
(ждёт блокировку)
T1: Получил блокировку
balance = 1000 - 500 = 500
T2: SELECT FOR UPDATE
(ждёт, блокировка занята)
T3: UPDATE balance = 500
T4: COMMIT (UNLOCK)
T5: Получил блокировку
balance = 500 - 300 = 200
T6: UPDATE balance = 200
T7: COMMIT
Результат: balance = 200 (корректно! -500 и -300)
*/
Решение 2: Optimistic Locking (Version)
// ✅ БЕЗОПАСНО — Оптимистичная блокировка
@Entity
@Table(name = "accounts")
public class Account {
@Id
private Long id;
private BigDecimal balance;
@Version // ← Ключевое поле для оптимистичной блокировки
private Long version; // 0, 1, 2, ...
}
@Service
@Transactional
public class AccountService {
@Autowired
private AccountRepository repository;
public void transfer(Long accountId, BigDecimal amount) {
Account account = repository.findById(accountId).orElse(null);
// version = 5 (текущая версия)
// Изменяем баланс
account.setBalance(account.getBalance().subtract(amount));
// При SAVE, Hibernate автоматически проверит версию:
// UPDATE account SET balance = ?, version = 6
// WHERE id = ? AND version = 5
// Если version != 5 → OptimisticLockingException
// Означает: кто-то другой уже изменил эту строку!
repository.save(account);
}
}
// Как это работает:
/*
Время Thread 1 БД Thread 2
────────────────────────────────────────────────────────────────────
T0: SELECT balance (версия = 5)
T1: SELECT balance
(версия = 5)
T2: balance = 1000 - 500
T3: balance = 1000 + 200
T4: UPDATE WHERE version = 5
✅ Успех! version = 6
T5: UPDATE WHERE version = 5
❌ FAIL! (version уже 6)
OptimisticLockingException
T6: COMMIT
T7: Нужно RETRY
Результат: Thread 2 получит исключение и может повторить попытку
*/
Решение 3: Isolation Levels
// ✅ Правильная конфигурация Isolation Level
// На уровне БД (PostgreSQL):
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Самый строгий уровень, но самый медленный
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- Oracle и PostgreSQL по умолчанию
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- MySQL по умолчанию
BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- Самый быстрый, но опасный (Dirty Read)
// На уровне Spring/Hibernate:
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalOperation() {
// Самый безопасный уровень
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public void normalOperation() {
// Стандартный уровень
}
Матрица проблем vs Isolation Level:
Level Lost Update Dirty Read Non-Rep. Read Phantom
─────────────────────────────────────────────────────────────────────
READ UNCOMMITTED ✓ ✓ ✓ ✓
READ COMMITTED ✓ ✗ ✓ ✓
REPEATABLE READ ✓ ✗ ✗ ✓*
SERIALIZABLE ✗ ✗ ✗ ✗
✓ = проблема может быть
✗ = проблемы исключены
* зависит от БД
Практический пример: Правильная реализация
// ❌ НЕПРАВИЛЬНО
@Service
public class BankingService {
@Autowired
private AccountRepository repository;
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = repository.findById(fromId).orElse(null);
Account to = repository.findById(toId).orElse(null);
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
repository.save(from);
repository.save(to);
// Race condition!
}
}
// ✅ ПРАВИЛЬНО с Pessimistic Locking
@Service
public class BankingService {
@Autowired
private AccountRepository repository;
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount)
throws InsufficientFundsException {
// Блокируем обе строки в правильном порядке (избегаем deadlock)
Account from = repository.findByIdForUpdate(fromId);
Account to = repository.findByIdForUpdate(toId);
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
}
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
repository.save(from);
repository.save(to);
// ATOMICALLY: либо оба обновления, либо ничего
}
}
// Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
@Query("SELECT a FROM Account a WHERE a.id = ?1 FOR UPDATE")
Account findByIdForUpdate(Long id);
}
// Тест для проверки
@SpringBootTest
public class BankingServiceTest {
@Autowired
private BankingService service;
@Autowired
private AccountRepository repository;
@Test
public void testConcurrentTransfers() throws Exception {
Account from = new Account();
from.setBalance(new BigDecimal("1000"));
Account to = new Account();
to.setBalance(new BigDecimal("0"));
repository.saveAll(Arrays.asList(from, to));
ExecutorService executor = Executors.newFixedThreadPool(2);
// 100 переводов по 5 одновременно
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
try {
service.transfer(from.getId(), to.getId(),
new BigDecimal("5"));
} catch (Exception e) {}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
Account finalFrom = repository.findById(from.getId()).orElse(null);
Account finalTo = repository.findById(to.getId()).orElse(null);
// Проверяем что сумма правильная
BigDecimal total = finalFrom.getBalance().add(finalTo.getBalance());
assertEquals(new BigDecimal("1000"), total);
// Без лocking: может быть 950, 930, etc
// С locking: всегда 1000
}
}
Когда какое решение использовать?
// 1. PESSIMISTIC LOCKING (SELECT FOR UPDATE)
// Используй когда:
// - Много конфликтов ожидается (высокая вероятность race condition)
// - Критические операции (транзакции, платежи)
// - Нужна 100% гарантия consistency
@Transactional
public void criticalOperation(Long id) {
Item item = repository.findByIdForUpdate(id); // ← FOR UPDATE
// Гарантированно эксклюзивный доступ
}
// Минусы:
// - Может быть медленнее (блокировки)
// - Риск deadlock если много потоков
// 2. OPTIMISTIC LOCKING (Version)
// Используй когда:
// - Мало конфликтов ожидается (низкая вероятность race condition)
// - Нечастые обновления
// - Много конкурентных читателей
@Version
private Long version;
repository.save(item); // Hibernate проверит version
// Минусы:
// - Нужно обработать OptimisticLockingException
// - Может потребоваться retry
// 3. DATABASE ISOLATION LEVEL
// Используй когда:
// - Нужен глобальный уровень безопасности
// - Настройка на уровне БД
@Transactional(isolation = Isolation.SERIALIZABLE)
public void veryImportantOperation() {}
// Минусы:
// - Влияет на все запросы
// - Может сильно замедлить БД
Заключение
Lost Update и Race Conditions происходят когда:
- Два потока одновременно читают одни данные
- Оба изменяют эти данные в памяти
- Оба пишут обновления в БД
- Последнее обновление перезаписывает предыдущие
- Обновления теряются
Решения:
- ✅ Pessimistic Locking (FOR UPDATE) — гарантированная безопасность
- ✅ Optimistic Locking (@Version) — высокая производительность при низких конфликтах
- ✅ Правильный Isolation Level — конфигурация БД
- ✅ Атомарные операции в БД — где возможно (UPDATE ... WHERE ...)
Для критических операций (платежи, инвентарь) используй SELECT FOR UPDATE или @Version. Это не опционально — это обязательно для корректности!