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

Что может пойти не так при работе нескольких запросов с одними и теми же данными если есть 2 транзакции, которые их обновляют?

3.0 Senior🔥 112 комментариев
#Базы данных и SQL

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

🐱
deepseek-v3.2PrepBro AI5 апр. 2026 г.(ред.)

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

Проблемы параллельных транзакций при обновлении одних данных

При одновременной работе нескольких транзакций, обновляющих одни и те же данные в базе данных, возникают классические проблемы параллелизма. В контексте PHP Backend разработки это особенно критично, поскольку веб-приложения по своей природе многопользовательские и многопоточные.

Основные проблемы

1. Потерянные обновления (Lost Update)

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

-- Транзакция 1: Увеличиваем баланс на 100
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1;
-- Транзакция 2: Увеличиваем баланс на 50
UPDATE accounts SET balance = balance + 50 WHERE user_id = 1;
-- Если обе транзакции читают старое значение баланса (например, 1000),
-- то результат будет 1100 или 1050, вместо ожидаемых 1150

2. Грязное чтение (Dirty Read)

Транзакция читает данные, которые были изменены другой транзакцией, но еще не зафиксированы.

// Транзакция 1
$db->beginTransaction();
$db->query("UPDATE products SET stock = stock - 1 WHERE id = 5");
// Здесь stock = 4, но транзакция еще не завершена

// Транзакция 2 (с уровнем изоляции READ UNCOMMITTED)
$stock = $db->query("SELECT stock FROM products WHERE id = 5")->fetchColumn();
// $stock = 4, хотя транзакция 1 может откатиться!

3. Неповторяющееся чтение (Non-repeatable Read)

Одна и та же выборка в рамках одной транзакции возвращает разные результаты из-за изменений другой транзакции.

// Транзакция 1
$db->beginTransaction();
$stock1 = $db->query("SELECT stock FROM products WHERE id = 5")->fetchColumn();
// Допустим, stock1 = 10

// Транзакция 2 уменьшает количество
$db->query("UPDATE products SET stock = 9 WHERE id = 5");
$db->commit();

// Транзакция 1 снова читает те же данные
$stock2 = $db->query("SELECT stock FROM products WHERE id = 5")->fetchColumn();
// stock2 = 9, хотя мы в той же транзакции!

4. Фантомное чтение (Phantom Read)

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

-- Транзакция 1: Подсчет заказов за сегодня
SELECT COUNT(*) FROM orders WHERE created_at >= '2024-01-15';
-- Возвращает 50

-- Транзакция 2: Добавляет новый заказ
INSERT INTO orders (user_id, amount, created_at) VALUES (123, 100, NOW());

-- Транзакция 1 повторяет запрос
SELECT COUNT(*) FROM orders WHERE created_at >= '2024-01-15';
-- Теперь возвращает 51 - появилась "фантомная" запись

Решения и подходы в PHP

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

В MySQL/PostgreSQL можно настраивать уровни изоляции:

// Установка уровня изоляции в PDO
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");

// Или для конкретной транзакции
$pdo->beginTransaction();
$pdo->exec("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");

Пессимистические блокировки

Явная блокировка строк для эксклюзивного доступа:

-- Блокировка строки для обновления
SELECT * FROM accounts WHERE user_id = 1 FOR UPDATE;

-- Или разделяемая блокировка
SELECT * FROM products WHERE id = 5 LOCK IN SHARE MODE;

В PHP с использованием PDO:

$db->beginTransaction();
// Явная блокировка
$stmt = $db->query("SELECT * FROM orders WHERE id = 100 FOR UPDATE");
// Только текущая транзакция может изменять эту запись
$db->query("UPDATE orders SET status = 'processed' WHERE id = 100");
$db->commit();

Оптимистические блокировки

Использование версионирования или временных меток:

ALTER TABLE products ADD COLUMN version INT DEFAULT 0;

-- При обновлении проверяем версию
UPDATE products 
SET stock = stock - 1, version = version + 1 
WHERE id = 5 AND version = 2;
-- Если версия изменилась, обновление не произойдет

PHP реализация:

function updateProductStock($productId, $quantity, $expectedVersion) {
    $db->beginTransaction();
    
    $stmt = $db->prepare(
        "UPDATE products 
         SET stock = stock - ?, version = version + 1 
         WHERE id = ? AND version = ?"
    );
    
    $stmt->execute([$quantity, $productId, $expectedVersion]);
    
    if ($stmt->rowCount() === 0) {
        $db->rollBack();
        throw new OptimisticLockException("Данные были изменены другим пользователем");
    }
    
    $db->commit();
}

Практические рекомендации

  1. Анализируйте сценарии использования - для финансовых операций используйте пессимистические блокировки, для каталогов товаров - оптимистические.

  2. Минимизируйте время транзакций - чем короче транзакция, тем меньше вероятность конфликтов.

  3. Используйте очереди для высоконагруженных операций:

// Вместо прямого обновления в HTTP-запросе
$queue->push(new UpdateStockJob($productId, -1));
  1. Реализуйте retry-логику для обработки конфликтов:
$retries = 3;
while ($retries > 0) {
    try {
        updateProductStock($productId, 1, $version);
        break;
    } catch (OptimisticLockException $e) {
        $retries--;
        if ($retries === 0) throw $e;
        usleep(100000); // 100ms задержка
        $version = getCurrentVersion($productId);
    }
}

Заключение

Проблемы параллельных транзакций требуют внимательного подхода при проектировании Backend систем на PHP. Ключевое - правильно выбрать стратегию блокировок в зависимости от бизнес-логики, использовать соответствующие уровни изоляции СУБД и реализовывать механизмы обработки конфликтов. Современные фреймворки (Laravel, Symfony) предоставляют инструменты для работы с транзакциями, но понимание низкоуровневых механизмов остается критически важным для разработки надежных приложений.