Как регулировать параллельные запросы, изменяющие одни и те же данные в базе?

«Как регулировать параллельные запросы, изменяющие одни и те же данные в базе?» — вопрос из категории Базы данных, который задают на 24% собеседований PHP Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

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

1. Пессимистичная блокировка (Pessimistic Locking) Используется, когда конфликты часты. Блокируем данные на время транзакции.

// MySQL/PostgreSQL с FOR UPDATE
$pdo->beginTransaction();

try {
    // Блокируем строку для обновления
    $stmt = $pdo->prepare(
        "SELECT balance FROM accounts WHERE id = ? FOR UPDATE"
    );
    $stmt->execute([$accountId]);
    $balance = $stmt->fetchColumn();

    // Проверка бизнес-логики
    if ($balance < $amount) {
        throw new Exception('Недостаточно средств');
    }

    // Обновление
    $updateStmt = $pdo->prepare(
        "UPDATE accounts SET balance = balance - ? WHERE id = ?"
    );
    $updateStmt->execute([$amount, $accountId]);

    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
    throw $e;
}

2. Оптимистичная блокировка (Optimistic Locking) Подходит для редких конфликтов. Проверяем версию при обновлении.

// Таблица с версией
CREATE TABLE products (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    quantity INT,
    version INT DEFAULT 0
);

// PHP код
$pdo->beginTransaction();

// Читаем текущее состояние
$stmt = $pdo->prepare(
    "SELECT id, quantity, version FROM products WHERE id = ?"
);
$stmt->execute([$productId]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);

// Бизнес-логика
$newQuantity = $product['quantity'] - $ordered;

// Обновляем только если версия не изменилась
$updateStmt = $pdo->prepare(
    "UPDATE products 
     SET quantity = ?, version = version + 1 
     WHERE id = ? AND version = ?"
);

$affected = $updateStmt->execute([
    $newQuantity, 
    $productId, 
    $product['version']
]);

if ($affected === 0) {
    // Кто-то уже обновил данные - повторяем операцию
    $pdo->rollBack();
    // Повторная попытка или уведомление пользователя
    throw new OptimisticLockException('Данные были изменены');
}

$pdo->commit();

3. Уровни изоляции транзакций

// MySQL: SET TRANSACTION ISOLATION LEVEL
$pdo->exec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
$pdo->beginTransaction();

// Или в PostgreSQL
$pdo->beginTransaction();
$pdo->exec("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");

4. Очереди для сериализации запросов Для высоконагруженных систем:

// Использование Redis для очереди
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// Блокировка по ключу
$lockKey = "lock:account:$accountId";
$lockTimeout = 10; // секунд

if ($redis->set($lockKey, 1, ['nx', 'ex' => $lockTimeout])) {
    try {
        // Выполняем операцию
        $pdo->beginTransaction();
        // ... логика обновления ...
        $pdo->commit();
    } finally {
        $redis->del($lockKey);
    }
} else {
    throw new Exception('Операция уже выполняется');
}

Выбор стратегии:

  • Пессимистичная блокировка: банковские операции, бронирование
  • Оптимистичная блокировка: корзины покупок, редактирование контента
  • Очереди: массовые операции, фоновые задачи
  • Уровни изоляции: зависит от требований ACID и производительности