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

Как достигается очередь при Serializable

2.0 Middle🔥 151 комментариев
#Основы Java

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

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

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

# Как достигается очередь (Serialization) при SERIALIZABLE уровне изоляции

Что означает SERIALIZABLE

SERIALIZABLE - это самый строгий уровень изоляции транзакций. Он гарантирует, что транзакции выполняются как будто они идут одна за другой (serially), а не параллельно.

При SERIALIZABLE:

Трансакция 1 ┌───────────────┐
             │ (выполняется) │
             └───────────────┘
                       ↓
             Транзакция 2 ┌───────────────┐
                          │ (выполняется) │
                          └───────────────┘
                                    ↓
                             Транзакция 3 ┌───────────────┐
                                          │ (выполняется) │
                                          └───────────────┘

Все транзакции как в очереди (serialization)

Проблема без SERIALIZABLE

Phantom Read

Трансакция 1:
  SELECT COUNT(*) FROM accounts;
  Результат: 100 аккаунтов
  
Трансакция 2 (параллельно):
  INSERT INTO accounts VALUES (...);
  COMMIT;
  
Трансакция 1:
  SELECT COUNT(*) FROM accounts;
  Результат: 101 аккаунт ← Phantom!

Это проблема Phantom Read - данные изменились между SELECT запросами в одной транзакции.

Как достигается очередь

1. Pessimistic Locking (Пессимистическое блокирование)

Идея: Блокировать все возможные конфликты ДО того, как они произойдут.

// Транзакция 1
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // SELECT ... FOR UPDATE (экстензивная блокировка)
    Account from = accountRepository.findByIdForUpdateSerializable(fromId);
    Account to = accountRepository.findByIdForUpdateSerializable(toId);
    
    // БД блокирует ОБЕ строки и весь диапазон
    // Другие транзакции ждут
    
    from.setBalance(from.getBalance().subtract(amount));
    to.setBalance(to.getBalance().add(amount));
    
    accountRepository.save(from);
    accountRepository.save(to);
    
    // COMMIT - блокировки освобождаются
    // Другие транзакции могут выполняться
}
Трансакция 1 начало:
┌──────────────────────────────┐
│ LOCK row 1 (EXCLUSIVE)       │ ← блокировка
│ LOCK row 2 (EXCLUSIVE)       │ ← блокировка
└──────────────────────────────┘

Трансакция 2 (параллельно):
┌──────────────────────────────┐
│ Ждёт... LOCK row 1 (WAITING) │ ← ждёт освобождения
│ Ждёт... LOCK row 2 (WAITING) │ ← ждёт освобождения
└──────────────────────────────┘

Трансакция 1 конец:
┌──────────────────────────────┐
│ UNLOCK all                   │ ← освобождаю блокировки
│ COMMIT                       │
└──────────────────────────────┘
         ↓
Трансакция 2 (продолжает):
┌──────────────────────────────┐
│ LOCK row 1 (ACQUIRED)        │ ← получила блокировку
│ LOCK row 2 (ACQUIRED)        │ ← получила блокировку
│ (выполнять)                  │
└──────────────────────────────┘

2. Predicate Locking (Блокирование предиката)

Для защиты от Phantom Read, БД блокирует не только строки, но и диапазоны.

// Транзакция 1
@Transactional(isolation = Isolation.SERIALIZABLE)
public void calculateAverageBalance() {
    // SELECT ... WHERE balance > 1000
    List<Account> richAccounts = 
        accountRepository.findAccountsWithBalanceGreaterThan(1000);
    
    // БД блокирует НЕ ТОЛЬКО найденные строки
    // НО И весь диапазон WHERE balance > 1000
    // Это предотвращает INSERT новых строк в этот диапазон
    
    double avgBalance = richAccounts.stream()
        .mapToDouble(Account::getBalance)
        .average()
        .orElse(0);
}

// Транзакция 2 (параллельно)
public void insertNewRichAccount() {
    Account newAccount = new Account();
    newAccount.setBalance(BigDecimal.valueOf(5000));
    
    // БЛОКИРОВАНА! Потому что:
    // 1. Диапазон (balance > 1000) заблокирован транзакцией 1
    // 2. New account имеет balance 5000 > 1000
    // 3. Нужно ждать освобождения диапазона
    accountRepository.save(newAccount); // WAITING
}
Трансакция 1:
┌────────────────────────────────────────┐
│ SELECT * WHERE balance > 1000          │
│ LOCK range [1000, ∞) EXCLUSIVE         │ ← Блокирую весь диапазон!
│ (читаю данные)                         │
└────────────────────────────────────────┘

Трансакция 2 (параллельно):
┌────────────────────────────────────────┐
│ INSERT (balance=5000)                  │
│ Пытается вставить в range [1000, ∞)   │
│ WAITING на освобождение range lock    │ ← Блокирована!
└────────────────────────────────────────┘

Механизм очереди: версионирование (в PostgreSQL)

MVCC (Multi-Version Concurrency Control)

Постгрес не просто блокирует - он хранит несколько версий данных!

Тип данных Account (версионирование):
xmin=100 (версия начинается с транзакции 100)
xmax=200 (версия заканчивается в транзакции 200)


Трансакция 100:
INSERT Account(balance=1000) xmin=100

Трансакция 150:
SELECT... ← видит версию где xmin=100, xmax=NULL

Трансакция 200:
UPDATE Account SET balance=2000
    ← старая версия: xmin=100, xmax=200
    ← новая версия: xmin=200, xmax=NULL

Трансакция 150:
SELECT... ← ВСЕГДА видит ту же версию (снимок на 150)

Трансакция 300:
SELECT... ← видит новую версию (xmin=200)

Пример: Очередь в действии

// Сценарий: двое пытаются вывести деньги с одного счёта

Аккаунт Account(id=1, balance=1000)

Трансакция 1 (Пользователь А):
@Transactional(isolation = Isolation.SERIALIZABLE)
public void withdraw() {
    Account account = repo.findByIdForUpdate(1); // LOCK ACQUIRED
    // balance = 1000
    
    if (account.getBalance() >= 500) {
        account.setBalance(500);
        repo.save(account);
        Thread.sleep(5000); // Имитирую долгую операцию
    }
    // COMMIT - блокировка освобождена
}

Трансакция 2 (Пользователь В, через 1 сек):
@Transactional(isolation = Isolation.SERIALIZABLE)
public void withdraw() {
    Account account = repo.findByIdForUpdate(1); // WAITING на lock!
    // Ждёт 4 сек пока транзакция 1 не завершится
    
    // Затем получает блокировку
    // account.balance = 500 (изменение из транзакции 1)
    
    if (account.getBalance() >= 500) {
        account.setBalance(0);
        repo.save(account);
    }
    // COMMIT
}

// Результат:
// Оба вывода выполнены последовательно (как очередь)
// Не было двойного вывода денег
// SERIALIZABLE гарантирует консистентность

Уровни блокирования

┌──────────────────────┬──────────────────────┐
│ Тип Блокировки       │ Что блокируется      │
├──────────────────────┼──────────────────────┤
│ Record Lock          │ Отдельная строка     │
│ Range Lock           │ Диапазон строк       │
│ Table Lock           │ Вся таблица          │
│ Gap Lock             │ "Промежуток" между   │
│                      │ строками (phantom)   │
└──────────────────────┴──────────────────────┘

Конфигурация в Spring

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
    
    // READ COMMITTED (по умолчанию)
    @Query("SELECT a FROM Account a WHERE a.id = ?1")
    Account findById(Long id);
    
    // REPEATABLE READ
    @Query("SELECT a FROM Account a WHERE a.id = ?1 FOR UPDATE")
    Account findByIdForUpdate(Long id);
    
    // SERIALIZABLE
    @Query(value = 
        "SELECT * FROM accounts WHERE id = ?1 FOR UPDATE",
        nativeQuery = true
    )
    Account findByIdForUpdateSerializable(Long id);
}

@Service
public class TransferService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    // Уровень транзакции: SERIALIZABLE
    @Transactional(
        isolation = Isolation.SERIALIZABLE,
        propagation = Propagation.REQUIRED,
        rollbackFor = Exception.class
    )
    public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
        // Блокирую оба счёта
        Account from = accountRepository.findByIdForUpdateSerializable(fromId);
        Account to = accountRepository.findByIdForUpdateSerializable(toId);
        
        // Во время этого блока другие транзакции ЖДУТ
        // → эффект очереди
        
        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException();
        }
        
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        
        accountRepository.save(from);
        accountRepository.save(to);
        
        // COMMIT - блокировки освобождаются
        // Ждущие транзакции могут продолжить
    }
}

Цена очереди: Deadlocks

Осторожно с порядком блокировок!

// ❌ DEADLOCK ВОЗМОЖЕН!

Трансакция 1:
LOCK account 1
WAIT account 2 ← ждёт на 2

Трансакция 2:
LOCK account 2
WAIT account 1 ← ждёт на 1

→ DEADLOCK!
// ✓ ПРАВИЛЬНО - одинаковый порядок

Трансакция 1:
LOCK account 1 (min id)
LOCK account 2 (max id)

Трансакция 2:
LOCK account 1 (min id) ← ждёт на 1
LOCK account 2 (max id)

// Нет deadlock, потому что порядок одинаковый

Производительность SERIALIZABLE

┌─────────────────┬──────────────┬──────────────┐
│ Уровень         │ Скорость     │ Безопасность │
├─────────────────┼──────────────┼──────────────┤
│ READ UNCOMMITTED│ Очень быстро │ Низкая       │
│ READ COMMITTED  │ Быстро       │ Средняя      │
│ REPEATABLE READ │ Медленнее     │ Высокая      │
│ SERIALIZABLE    │ Очень медленно│ Максимальная │
└─────────────────┴──────────────┴──────────────┘

SERIALIZABLE медленен, потому что:

  1. Много блокировок
  2. Другие транзакции ждут
  3. Нет параллелизма

Когда использовать SERIALIZABLE

✓ Используй для критичных операций

  • Финансовые транзакции
  • Резервирование мест (авиа, кино)
  • Инвентаризация
  • Любое где нельзя ошибиться

❌ НЕ используй для

  • Просмотра статей/новостей
  • Поиска по каталогу
  • Чтения некритичных данных
  • High-frequency операций

Заключение

Очередь при SERIALIZABLE достигается через:

  1. Pessimistic Locking - блокируем ВСЕ конфликты заранее
  2. Predicate Locking - блокируем диапазоны (для phantom reads)
  3. MVCC (в некоторых БД) - версионирование данных
  4. Sequence of locks - гарантируем эффект очереди

Результат: Транзакции выполняются как одна за другой, гарантируя максимальную консистентность.