RE:NODE

Базы данных13 мин чтения

PHP PDO и MySQL: соединения, подготовленные запросы, ошибки

Как правильно подключить PHP к MySQL через PDO: DSN и кодировка, нужные опции, подготовленные запросы, транзакции и ошибки, которые вам встретятся.

0 прочтений

Правильное подключение PDO к MySQL - это пять строк: DSN с хостом, портом, базой данных и charset=utf8mb4, имя пользователя и пароль из окружения и три опции - исключения при ошибках, ассоциативные массивы по умолчанию и нативные подготовленные запросы. Всё остальное - это подготовленные запросы для каждого значения, пришедшего извне кода, транзакции для изменений из нескольких операторов и знание того, какой код ошибки что означает. В этой статье разобрана каждая часть, включая умолчания, изменившиеся в PHP 8, и имена констант, переехавшие в PHP 8.4 и 8.5, чтобы код, который вы пишете сегодня, не сыпал предупреждениями об устаревании в следующем году.

DSN и подключение, правильное с первого раза#

PDO подключается по строке имени источника данных (DSN), имени пользователя и паролю. Для MySQL префикс DSN - mysql:, а параметры разделяются точкой с запятой.

db.php
<?php$dsn = sprintf(    'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',    getenv('DB_HOST'),    (int) getenv('DB_PORT'),    getenv('DB_NAME'));$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASSWORD'), [    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,    PDO::ATTR_EMULATE_PREPARES   => false,]);

Параметры DSN для MySQL:

ПараметрПримерПримечания
hostdb.example.comlocalhost означает Unix-сокет, а не TCP - см. ниже
port3306Игнорируется при подключении через сокет
dbnameappНеобязателен; без него придётся выполнить USE для базы данных
charsetutf8mb4Кодировка соединения. Задавайте всегда
unix_socket/run/mysqld/mysqld.sockТолько когда база данных на той же машине

В ловушку с localhost однажды попадает каждый. Клиентская библиотека MySQL воспринимает имя хоста localhost как просьбу использовать локальный Unix-сокет, поэтому host=localhost;port=3307 игнорирует порт и падает с SQLSTATE[HY000] [2002] No such file or directory, если локального сервера нет. Если база данных на другой машине - а хостинговая база всегда на другой, - используйте её настоящее имя хоста или адрес. Если она на той же машине, но слушает TCP, используйте 127.0.0.1.

Не держите учётные данные в файле. Читайте их из переменных окружения или из конфигурационного файла вне корня сайта; db.php с паролем внутри отделяет от отдачи в виде текста одна ошибка в настройке веб-сервера. Где им место, разобрано в статье переменные окружения и секреты.

Одно соединение на запрос и исключение для долгоживущих процессов

В обычном PHP за PHP-FPM создавайте объект PDO один раз на запрос - в небольшой фабричной функции или в контейнере вашего фреймворка - и передавайте его всему, что в нём нуждается. Открытие нового соединения в каждой функции, выполняющей запрос, умножает рукопожатия и соединения без всякой пользы; страница, вызывающая двадцать хелперов, не должна открывать двадцать соединений. Когда запрос заканчивается, PHP уничтожает объект и соединение закрывается. Управлять пулом не нужно, а число одновременных соединений просто равно числу занятых воркеров FPM.

С долгоживущим PHP всё иначе. Воркер очереди, WebSocket-сервер, скрипт по расписанию, который спит между пакетами, или сервер приложений вроде RoadRunner, Swoole или FrankenPHP в режиме воркера держит один объект PDO живым часами. MySQL закрывает простаивающие соединения через wait_timeout секунд - по умолчанию 28 800, на общих серверах часто меньше, - и следующий запрос на устаревшем объекте падает с 2006 MySQL server has gone away. Сам PDO не переподключается. Решение - перехватывать эту ошибку на границе каждой задачи, выбрасывать объект PDO, создавать новый и один раз повторять задачу:

php
function db(bool $fresh = false): PDO{    static $pdo = null;    if ($fresh || $pdo === null) {        $pdo = new PDO($GLOBALS['dsn'], getenv('DB_USER'), getenv('DB_PASSWORD'), $GLOBALS['opts']);    }    return $pdo;}try {    handleJob(db(), $job);} catch (PDOException $e) {    if (!in_array($e->errorInfo[1] ?? 0, [2006, 2013], true)) {        throw $e;    }    handleJob(db(true), $job);  // reconnect once and retry}

Повторяйте только ту работу, которую безопасно выполнить дважды, или ту, что была внутри транзакции, откатившейся вместе с потерянным соединением. Воркер очередей Laravel делает свою версию этого за вас, и это одна из причин, по которым воркеры фреймворков периодически перезапускают с --max-jobs или --max-time.

Опции, которые стоит задать, и их умолчания#

Умолчания PDO стали лучше, но не все они подходят для MySQL.

АтрибутПо умолчаниюРекомендуетсяПочему
ATTR_ERRMODEERRMODE_EXCEPTION (PHP 8.0+)То же, задать явноДо 8.0 по умолчанию ошибки проходили молча
ATTR_DEFAULT_FETCH_MODEFETCH_BOTHFETCH_ASSOCFETCH_BOTH возвращает каждую колонку дважды
ATTR_EMULATE_PREPAREStrue для MySQLfalseНастоящая подготовка на сервере, типизированные результаты
ATTR_PERSISTENTfalsefalseПостоянные соединения переносят состояние между запросами
ATTR_STRINGIFY_FETCHESfalsefalsetrue превращает каждое число в строку
MYSQL_ATTR_FOUND_ROWSfalseПо ситуацииtrue заставляет rowCount() считать найденные строки, а не изменённые
MYSQL_ATTR_USE_BUFFERED_QUERYtruetrueСтавьте false только для потоковой выдачи огромных результатов

Задавайте режим ошибок явно даже на PHP 8, потому что код копируют в проекты со старыми настройками и потому что так видно намерение. Тихий режим - когда execute() возвращает false и от вас ожидается проверка - это то, как баги, которые должны были быть громкими, превращаются в пропавшие строки.

Константы переехали в PHP 8.4 и 8.5

PHP 8.4 добавил подклассы для конкретных драйверов: Pdo\Mysql, Pdo\Pgsql, Pdo\Sqlite. PDO::connect($dsn, $user, $password, $options) возвращает подкласс, соответствующий DSN, а атрибуты, специфичные для MySQL, теперь живут в нём: Pdo\Mysql::ATTR_SSL_CA, Pdo\Mysql::ATTR_INIT_COMMAND, Pdo\Mysql::ATTR_FOUND_ROWS и так далее. PHP 8.5 объявляет устаревшими старые написания PDO::MYSQL_ATTR_*. Они всё ещё работают, но выдают уведомление об устаревании, а это ломает наборы тестов, которые считают уведомления провалом, - несколько фреймворков наткнулись ровно на это с PDO::MYSQL_ATTR_SSL_CA в своей конфигурации по умолчанию. Если вы поддерживаете PHP ниже 8.4, оставьте старые имена; если ваш минимум - 8.4, переходите сейчас.

Подготовленные запросы#

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

php
// Positional placeholders$stmt = $pdo->prepare('SELECT id, email FROM users WHERE status = ? AND created_at > ?');$stmt->execute(['active', '2026-01-01']);$users = $stmt->fetchAll();// Named placeholders$stmt = $pdo->prepare('UPDATE users SET email = :email WHERE id = :id');$stmt->execute(['email' => $email, 'id' => $id]);echo $stmt->rowCount();  // rows actually changed

Значения, переданные в execute(), все привязываются как строки, а MySQL преобразует их по необходимости. Когда тип важен, привязывайте явно:

php
$stmt = $pdo->prepare('SELECT id, title FROM posts ORDER BY id DESC LIMIT :limit OFFSET :offset');$stmt->bindValue('limit', $perPage, PDO::PARAM_INT);$stmt->bindValue('offset', ($page - 1) * $perPage, PDO::PARAM_INT);$stmt->execute();

LIMIT - классический случай. При эмулированной подготовке привязка лимита через execute() даёт LIMIT '10', а это синтаксическая ошибка. С нативной подготовкой или PARAM_INT всё работает.

Чего не умеют плейсхолдеры

Плейсхолдеры заменяют значения, но никогда не идентификаторы и не ключевые слова. Имя таблицы, имя колонки в ORDER BY или ASC/DESC привязать нельзя. Для них сопоставляйте пользовательский ввод с фиксированным списком:

php
$sortable = ['created_at' => 'created_at', 'title' => 'title'];$column = $sortable[$_GET['sort'] ?? ''] ?? 'created_at';$direction = ($_GET['dir'] ?? '') === 'asc' ? 'ASC' : 'DESC';$stmt = $pdo->query("SELECT id, title FROM posts ORDER BY $column $direction");

Списку IN (...) нужен один плейсхолдер на значение, собранный из массива:

php
$ids = array_map('intval', $ids);$marks = implode(',', array_fill(0, count($ids), '?'));$stmt = $pdo->prepare("SELECT * FROM products WHERE id IN ($marks)");$stmt->execute($ids);

Перед этим защититесь от пустого массива: IN () - синтаксическая ошибка.

Эмулированная и нативная подготовка#

По умолчанию драйвер MySQL вообще не использует подготовленные запросы на стороне сервера. При включённом ATTR_EMULATE_PREPARES PDO сам экранирует каждое значение и отправляет одну готовую строку запроса. Это безопасно, пока клиент знает кодировку соединения, - и именно поэтому charset=utf8mb4 должен стоять в DSN, а не в запросе SET NAMES после подключения: PDO знает только о той кодировке, которую ему сообщили в DSN, а экранирование с неверным представлением о кодировке - корень старых трюков с многобайтовыми инъекциями.

Различия, которые проявляются на практике:

  • Типы. Нативная подготовка использует бинарный протокол MySQL, поэтому целые числа возвращаются как целые числа PHP, а числа с плавающей точкой - как float. Эмулированная подготовка возвращала всё строками до PHP 8.1, где её изменили, чтобы она тоже возвращала нативные целые и float. В старом коде об этом изменении стоит знать, когда строгое сравнение вроде $row['id'] === '5' внезапно перестаёт срабатывать после обновления.
  • Повторяющиеся именованные плейсхолдеры. Эмуляция позволяет использовать :term в одном запросе дважды. Нативная подготовка - нет, вы получите SQLSTATE[HY093]: Invalid parameter number. Используйте :term1 и :term2.
  • Сетевые обмены. Нативная подготовка - это отдельный запрос к серверу перед выполнением. Для оператора, выполняемого один раз, это один лишний обмен туда и обратно; для оператора в цикле однократная подготовка и многократное выполнение быстрее.
  • Ошибки. При нативной подготовке синтаксическая ошибка проявляется в prepare(). При эмуляции - в execute().

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

Кодировки: utf8mb4 от начала до конца#

charset=utf8mb4 задаёт кодировку соединения - то, что PHP отправляет и ожидает получить обратно. Таблицы тоже должны быть в utf8mb4, а в MySQL 8.4 это кодировка сервера по умолчанию с collation utf8mb4_0900_ai_ci. Старый utf8 (псевдоним utf8mb3) хранит не больше трёх байт на символ и отклоняет эмодзи и некоторые символы CJK с ошибкой 1366, Incorrect string value: '\xF0\x9F\x98\x80'. Если вы её видите, какая-то колонка или таблица всё ещё в utf8mb3; как её перевести, описано в статье utf8mb4 и collation в MySQL.

Если для соединения нужен определённый collation, задайте его через init-команду, которая выполняется один раз на соединение:

php
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",// PHP 8.4+: Pdo\Mysql::ATTR_INIT_COMMAND

Та же опция - место, чтобы задать часовой пояс сессии (SET time_zone = '+00:00') или sql_mode, если приложение от них зависит.

Получение результатов и потоковая выдача больших#

Методы выборки стоит знать не только в виде fetchAll():

ВызовВозвращает
fetch()Одну строку или false, когда строк больше нет
fetchAll()Все строки в виде массива
fetchColumn()Первую колонку следующей строки - идеально для COUNT(*)
fetchAll(PDO::FETCH_COLUMN)Плоский список одной колонки
fetchAll(PDO::FETCH_KEY_PAIR)[col1 => col2] из запроса с двумя колонками
fetchAll(PDO::FETCH_GROUP)Строки, сгруппированные по первой колонке
fetchObject(Product::class)Одну строку, заполненную в объект класса

По умолчанию PDO буферизует весь набор результатов в памяти PHP, прежде чем ваш цикл увидит первую строку. Для обычных страниц это именно то, что нужно, и именно из-за этого экспорт на 2 миллиона строк упирается в memory_limit. Для экспорта отключите буферизацию для этого одного запроса и итерируйте:

php
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);$stmt = $pdo->query('SELECT id, email, created_at FROM users');$out = fopen('php://output', 'w');foreach ($stmt as $row) {    fputcsv($out, $row);}$stmt->closeCursor();$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);

Пока небуферизованный результат открыт, соединение не может выполнить другой запрос - вы получите SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active. Сначала закончите цикл и вызовите closeCursor(). Про memory_limit и max_execution_time для задач, которым они нужны, - в статье настройки php.ini, которые имеют значение.

Транзакции, ID вставки и число строк#

Всё, что меняет больше одной строки как единое целое, должно быть в транзакции:

php
try {    $pdo->beginTransaction();    $stmt = $pdo->prepare('INSERT INTO orders (user_id, total) VALUES (?, ?)');    $stmt->execute([$userId, $total]);    $orderId = (int) $pdo->lastInsertId();    $line = $pdo->prepare('INSERT INTO order_lines (order_id, sku, qty) VALUES (?, ?, ?)');    foreach ($items as $item) {        $line->execute([$orderId, $item['sku'], $item['qty']]);    }    $pdo->commit();} catch (Throwable $e) {    if ($pdo->inTransaction()) {        $pdo->rollBack();    }    throw $e;}

Три детали в этом примере легко сделать неправильно:

  • lastInsertId() возвращает значение AUTO_INCREMENT последней вставки в этом соединении в виде строки. Оно привязано к соединению, поэтому параллельные запросы не видят ID друг друга.
  • rowCount() после UPDATE возвращает число реально изменённых строк. Обновление строки значениями, которые в ней уже есть, считается как ноль, и это удивляет код, который по этому числу решает, существует ли строка. MYSQL_ATTR_FOUND_ROWS => true меняет его на число найденных строк.
  • DDL-операторы вроде CREATE TABLE или ALTER TABLE в MySQL неявно фиксируют транзакцию. Миграцию, которая смешивает DDL с транзакцией, нельзя откатить, а на PHP 8 последующий commit() выбрасывает There is no active transaction.

Deadlock (ошибка 1213) откатывает всю транзакцию, и её нужно повторить; таймаут ожидания блокировки (1205) по умолчанию откатывает только оператор. Обёртка для повторов и причины каждой из ошибок - в статье транзакции, блокировки и deadlock в MySQL.

Ошибки и что они означают#

PDOException содержит SQLSTATE в getCode() и номер ошибки MySQL в $e->errorInfo[1]. Ветвитесь по номеру MySQL: SQLSTATE HY000 покрывает десятки не связанных между собой ошибок.

php
try {    $stmt->execute([$email]);} catch (PDOException $e) {    if (($e->errorInfo[1] ?? null) === 1062) {        return 'That email is already registered.';    }    throw $e;}
Фрагмент сообщенияКод MySQLОбычная причина
[2002] Connection refused2002Неверный хост или порт либо файрвол
[2002] No such file or directory2002host=localhost без локального сокета
[1045] Access denied for user1045Неверный пароль или пользователю нельзя подключаться с этого хоста
[1049] Unknown database1049Неверный dbname или пользователь её не видит
2006 MySQL server has gone away2006Простаивающее соединение закрыто по wait_timeout или слишком большой пакет
Duplicate entry ... for key1062Уникальное ограничение - часто законная ошибка пользователя
Cannot add or update a child row1452Цель внешнего ключа не существует
Incorrect string value1366Колонка не в utf8mb4
Too many connections1040Достигнут лимит соединений

Для 1045 помните, что аккаунт MySQL - это пользователь и шаблон хоста вместе: 'app'@'localhost' и 'app'@'%' - разные аккаунты с разными паролями. Правила сопоставления объясняет статья пользователи и привилегии в MySQL, а про 1040 и 2006 - статья лимиты соединений и пулы в MySQL.

Никогда не выводите $e->getMessage() посетителям в продакшене. Там может оказаться имя хоста, имя пользователя и части запроса. Записывайте его в лог, а показывайте общую ошибку.

Шифрованные соединения#

MySQL 8.4 по умолчанию включает TLS на сервере, но PDO не запрашивает его, пока вы не зададите SSL-опцию. Через интернет запрашивайте:

php
$pdo = new PDO($dsn, $user, $pass, [    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,    PDO::MYSQL_ATTR_SSL_CA => '/etc/ssl/certs/db-ca.pem',    PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => true,]);

Если сервер использует сертификат, который сам себе сгенерировал, а файла его CA у вас нет, шифровать всё равно можно: оставьте SSL-опцию в массиве и установите MYSQL_ATTR_SSL_VERIFY_SERVER_CERT в false - трафик будет зашифрован, но это ничего не докажет о том, кто вам ответил. Это лучше открытого текста и хуже проверки. Выполните SHOW SESSION STATUS LIKE 'Ssl_cipher' со стороны PHP: пустое значение означает, что соединение не зашифровано.

FAQ#

PDO или mysqli?

PDO, для большинства кода. В нём есть именованные плейсхолдеры, тот же API для других баз данных и более аккуратные исключения. mysqli открывает несколько специфичных для MySQL возможностей, которых нет в PDO, например асинхронные запросы и обработку результатов нескольких операторов. Оба поддерживаются, и оба безопасны с подготовленными запросами.

Экранирование через quote() так же хорошо, как подготовленный запрос?

Оно экранирует правильно, когда кодировка соединения задана в DSN, но про него легко один раз забыть, а забыть один раз - это и есть уязвимость. Подготовленные запросы делают безопасный путь путём по умолчанию. Используйте quote() только там, где плейсхолдеры действительно не работают.

Стоит ли использовать постоянные соединения?

Обычно нет. Они держат по одному открытому соединению на воркер PHP-FPM, что экономит несколько миллисекунд рукопожатия на запрос, но оставляет временные таблицы, переменные сессии и иногда недоделанные транзакции следующему запросу на этом воркере. Сначала измерьте рукопожатие: по короткому сетевому пути оно невелико.

Почему мои целые числа возвращаются строками?

Либо у вас PHP старше 8.1 с эмулированной подготовкой, либо включён ATTR_STRINGIFY_FETCHES. Установите ATTR_EMULATE_PREPARES в false, и бинарный протокол будет возвращать нативные целые и float. Колонки DECIMAL по-прежнему возвращаются строками - так задумано, чтобы избежать округления с плавающей точкой.

Как включить TLS для MySQL в Laravel?

config/database.php в Laravel передаёт в PDO массив options для соединения mysql, и файл по умолчанию уже читает путь к CA из переменной окружения MYSQL_ATTR_SSL_CA в атрибут SSL CA. Задайте в .env этой переменной путь к файлу CA. Свежие версии Laravel на PHP 8.5 выбирают константу Pdo\Mysql, чтобы избежать уведомления об устаревании; в старом конфигурационном файле обновите эту строку при переходе на 8.5.

Как увидеть запрос, который PDO выполнил на самом деле?

$stmt->debugDumpParams() выводит SQL и привязанные параметры, а начиная с PHP 7.2 включает развёрнутый запрос, если подготовка эмулируется. На стороне сервера общий журнал запросов или журнал медленных запросов показывает в точности то, что пришло.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000