Как безопасно вставить запись в БД через PDO и избежать race condition

    При разработке веб-приложений часто возникает задача вставить новую запись в базу данных, исключив дублирование при одновременных запросах (race condition). В этой статье разберём, как корректно использовать PDO и транзакции с блокировкой SELECT ... FOR UPDATE, чтобы гарантировать уникальность данных.

    Проблема race condition при вставке

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

    Решение: транзакции и блокировка строк

    Для безопасной вставки используйте комбинацию транзакции и блокировки строк через SELECT ... FOR UPDATE. Это заставит другие транзакции ждать, пока текущая не завершится.

    Пример кода на PDO

    Предположим, у вас есть таблица rating с полями sign_ts и user_id. Чтобы вставить запись только если её ещё нет, выполните:

    $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $dbh->beginTransaction();
    
    try {
        $sql = "INSERT INTO `rating` (`ID`, `count`, `post_id`, `user_id`, `sign_ts`, `date`)
                SELECT NULL, '4', '1', '1', :sign_ts, CURRENT_TIMESTAMP
                FROM DUAL
                WHERE NOT EXISTS (
                    SELECT * FROM `rating` WHERE `sign_ts` = :sign_ts FOR UPDATE
                )";
        $stmt = $dbh->prepare($sql);
        $stmt->execute([':sign_ts' => $id]);
        
        if ($stmt->rowCount() > 0) {
            echo "Запись вставлена";
            $dbh->commit();
        } else {
            echo "Запись уже существует";
            $dbh->rollBack();
        }
    } catch (Exception $e) {
        $dbh->rollBack();
        echo "Ошибка: " . $e->getMessage();
    }

    Почему это работает?

    • Транзакция - изолирует операцию от других соединений.
    • FOR UPDATE - блокирует строки, которые проверяются в подзапросе, не давая другим транзакциям вставить дубликат.
    • Подготовленные выражения - защищают от SQL-инъекций (в исходном коде была прямая подстановка $id).

    Альтернативные подходы

    В некоторых СУБД можно использовать уникальные индексы с конструкцией INSERT IGNORE или ON DUPLICATE KEY UPDATE. Однако для строгих требований к уникальности и при высокой нагрузке транзакции с блокировкой надёжнее.

    Использование уникального индекса

    Создайте уникальный составной индекс на поля, которые не должны дублироваться (например, UNIQUE INDEX idx_unique (sign_ts, user_id)). Тогда вставка дубликата вызовет исключение, которое можно обработать.

    Рекомендации по производительности

    • Минимизируйте время удержания блокировки - выполняйте только необходимые операции внутри транзакции.
    • Используйте уровень изоляции REPEATABLE READ (по умолчанию в InnoDB) - он совместим с FOR UPDATE.
    • Тестируйте код под нагрузкой, чтобы убедиться в отсутствии взаимоблокировок (deadlock).

    Частые ошибки новичков

    • Проверка существования записи без блокировки - приводит к race condition.
    • Неправильное экранирование данных - используйте только подготовленные выражения.
    • Забывают вызвать rollBack() при ошибке - это оставляет транзакцию открытой.

    Следуя этим рекомендациям, вы сможете безопасно вставлять записи в базу данных через PDO, избегая дублирования даже при параллельных запросах.

    Часто задаваемые вопросы