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

При какой проблеме с двух учетных записей одновременно обновляются данные

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 происходят когда:

  1. Два потока одновременно читают одни данные
  2. Оба изменяют эти данные в памяти
  3. Оба пишут обновления в БД
  4. Последнее обновление перезаписывает предыдущие
  5. Обновления теряются

Решения:

  1. Pessimistic Locking (FOR UPDATE) — гарантированная безопасность
  2. Optimistic Locking (@Version) — высокая производительность при низких конфликтах
  3. Правильный Isolation Level — конфигурация БД
  4. Атомарные операции в БД — где возможно (UPDATE ... WHERE ...)

Для критических операций (платежи, инвентарь) используй SELECT FOR UPDATE или @Version. Это не опционально — это обязательно для корректности!

При какой проблеме с двух учетных записей одновременно обновляются данные | PrepBro