RE:NODE

mysqli или PDO в PHP: различия, ошибки и что выбрать

Автор Команда RE:NODE
15 мин чтения


Выбирайте PDO, если у вас нет конкретной причины поступить иначе. Оба расширения поддерживаются, оба работают с MySQL через одну и ту же библиотеку mysqlnd, оба безопасны при использовании подготовленных запросов, а разницу в скорости между ними в реальном приложении невозможно измерить. PDO выигрывает в том, с чем вы сталкиваетесь каждый день: именованные плейсхолдеры, единый API для MySQL, PostgreSQL и SQLite, режимы выборки, которые складывают строки в массивы или объекты без цикла, и исключения по умолчанию начиная с PHP 8.0. mysqli выигрывает только там, где нужно что-то специфичное для MySQL, чего PDO не предоставляет - асинхронные запросы, multi_query() с отдельными наборами результатов, либо готовая кодовая база, которая уже написана на нём и работает. Остальная часть статьи - подробности за этим выводом: чем на самом деле отличаются два API, подготовленные запросы бок о бок, смена режима ошибок в PHP 8.1, из-за которой сломалось много старого кода на mysqli, что здесь значит "производительность" и как перенести устаревший код на mysql_* на любой из двух вариантов.

Коротко, в таблице#

ВопросmysqliPDO
Базы данныхТолько MySQL и MariaDBДесяток драйверов, один API
ПлейсхолдерыТолько ?? и :named
СтильОбъектный или процедурныйТолько объектный
Подготовка запросовВсегда на стороне сервераДля MySQL по умолчанию эмулируется
Ошибки по умолчаниюИсключения с PHP 8.1Исключения с PHP 8.0
Асинхронные запросыДа, MYSQLI_ASYNCНет
Выборка в классfetch_object()FETCH_CLASS, fetchObject()

Ни одна из этих строк не повод переписывать рабочий код. Это причины выбрать один из вариантов для нового кода, и для нового кода почти всегда выбор - PDO. Если вы уже используете PDO и хотите полный разбор DSN, опций, транзакций и кодов ошибок, всё это есть в статье PHP PDO и MySQL; здесь мы остаёмся в рамках сравнения.

Что это за расширения#

В PHP существовало три расширения для MySQL, и история объясняет названия.

Исходное расширение mysql - mysql_connect(), mysql_query(), mysql_fetch_assoc() - объявили устаревшим в PHP 5.5 и полностью удалили в PHP 7.0. В нём не было подготовленных запросов, оно хранило неявное "последнее соединение" в глобальном состоянии и толкнуло целое поколение разработчиков собирать SQL конкатенацией строк. Если код всё ещё вызывает mysql_query(), он работает на PHP 5.6 или старше - или не работает вовсе.

mysqli (буква "i" означает "improved", улучшенный) появилось в PHP 5 на замену. Это довольно тонкая обёртка над собственным клиентским API MySQL: оно напрямую выставляет понятия MySQL, поддерживает подготовленные запросы, транзакции, несколько запросов за вызов и асинхронные запросы, а также предлагает и объектный интерфейс ($mysqli->query()), и процедурный (mysqli_query($link, ...)). Процедурный интерфейс нужен в основном для того, чтобы упростить перенос старого кода на mysql_*.

PDO (PHP Data Objects) появилось примерно тогда же с другой целью: единый API доступа к базам данных, под которым подключаются драйверы для конкретных СУБД. pdo_mysql - драйвер для MySQL. PDO не пытается выставить всё, что умеет MySQL. Оно предоставляет то, что нужно большинству приложений, и делает это одинаково для разных баз.

Оба расширения в любой распространённой сборке используют mysqlnd - нативный драйвер MySQL, написанный для PHP. Это важно для вопроса о производительности ниже: сетевой протокол, буферизация и обработка результатов - один и тот же код в обоих случаях.

Драйверы: одна база или много#

Список драйверов PDO - самое явное функциональное отличие. Самые распространённые:

ДрайверБаза данных
pdo_mysqlMySQL, MariaDB
pdo_pgsqlPostgreSQL
pdo_sqliteSQLite
pdo_sqlsrvSQL Server (расширение Microsoft, устанавливается отдельно)
pdo_odbcВсё, для чего есть драйвер ODBC

Проверить, что есть в установленном PHP, можно командой php -m или php -r 'print_r(PDO::getAvailableDrivers());'.

Честная версия аргумента о переносимости: PDO не делает SQL переносимым. LIMIT против TOP, AUTO_INCREMENT против identity-столбцов, кавычки-обратные апострофы против двойных кавычек - со всем этим вам по-прежнему разбираться самим. Переносимым PDO делает код вокруг SQL: подключение, привязку параметров, выборку, транзакции и обработку ошибок. На практике это большая часть кода работы с базой в приложении, и именно поэтому каждый крупный PHP-фреймворк и ORM - Eloquent в Laravel, Doctrine DBAL, ORM в CakePHP - построен на PDO. Поэтому же прогон тестов на SQLite при боевой MySQL - реальный вариант с PDO и невозможный с mysqli.

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

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

Один и тот же запрос - найти активных пользователей, созданных после определённой даты, - написанный тремя способами.

mysqli, классический вариант. Параметры привязываются по ссылке со строкой типов: i - целое, d - double, s - строка, b - blob, передаваемый пакетами.

php
$stmt = $mysqli->prepare(    'SELECT id, email FROM users WHERE status = ? AND created_at > ?');$stmt->bind_param('ss', $status, $since);$stmt->execute();$result = $stmt->get_result();$users = $result->fetch_all(MYSQLI_ASSOC);

В mysqli на PHP 8.1 и новее можно обойтись без bind_param() и передать массив в execute(); тогда каждое значение привязывается как строка, а MySQL преобразует его по необходимости. В PHP 8.2 добавили execute_query(), который готовит запрос, привязывает параметры и выполняет его одним вызовом и возвращает результат:

php
// PHP 8.1+$stmt = $mysqli->prepare('SELECT id, email FROM users WHERE status = ? AND created_at > ?');$stmt->execute([$status, $since]);// PHP 8.2+$users = $mysqli->execute_query(    'SELECT id, email FROM users WHERE status = ? AND created_at > ?',    [$status, $since])->fetch_all(MYSQLI_ASSOC);

PDO, с именованными плейсхолдерами:

php
$stmt = $pdo->prepare(    'SELECT id, email FROM users WHERE status = :status AND created_at > :since');$stmt->execute(['status' => $status, 'since' => $since]);$users = $stmt->fetchAll(PDO::FETCH_ASSOC);

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

  • Именованные плейсхолдеры. В PDO они есть, в mysqli нет. В запросе с восемью параметрами :email читается легко, а строка типов ssisdssi - это баг, который ждёт, пока кто-нибудь добавит столбец в середину.
  • Привязка по ссылке. bind_param() привязывает переменные, а не значения, и читает их в момент execute(). В цикле это удобно (привязали один раз, меняете переменные, выполняете многократно), а во всех остальных случаях сбивает с толку, потому что нельзя передать литерал или результат функции напрямую. В PDO есть и bindParam() (по ссылке), и bindValue() (по значению), а execute($array) покрывает большинство случаев.
  • `get_result()` и `bind_result()`. get_result() возвращает обычный объект результата, из которого можно получать массивы. Ему нужен mysqlnd, поэтому старые руководства, написанные для сборок без него, используют bind_result(), чтобы привязать каждый столбец результата к переменной. В любом актуальном PHP можно пользоваться get_result() и забыть, что bind_result() существует.
  • На стороне сервера или эмуляция. Подготовка запросов в mysqli - всегда настоящие подготовленные запросы на стороне сервера. PDO с MySQL по умолчанию эмулирует их: экранирует значения и отправляет один готовый запрос, пока вы не установите PDO::ATTR_EMULATE_PREPARES в false. Оба варианта безопасны, если кодировка соединения задана правильно. Когда эмуляция имеет значение, объясняется в статье про PDO.

Ни один из API не позволяет подставить плейсхолдер вместо имени таблицы, имени столбца или ASC/DESC. Они должны браться из фиксированного списка в вашем коде, каким бы расширением вы ни пользовались.

Сообщения об ошибках: что изменилось в PHP 8.0 и 8.1#

Это различие чаще всего ломает обновление, и в обоих расширениях оно изменилось в течение года.

PDO до PHP 8.0 по умолчанию использовало PDO::ERRMODE_SILENT. Неудавшийся запрос возвращал false, выставлял код ошибки, который нужно было запрашивать отдельно, и скрипт шёл дальше. В PHP 8.0 значение по умолчанию сменили на PDO::ERRMODE_EXCEPTION, так что неудавшийся запрос выбрасывает PDOException.

mysqli последовало за ним в PHP 8.1. До 8.1 режим отчётов по умолчанию был MYSQLI_REPORT_OFF: query() возвращал false, а $mysqli->error или $mysqli->errno приходилось проверять самому - или, как делала большая часть кода, не проверять вовсе. Начиная с PHP 8.1 по умолчанию действует MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, который выбрасывает mysqli_sql_exception при любой ошибке, включая неудачное подключение.

Старый код на mysqli обычно выглядит так:

php
$link = mysqli_connect($host, $user, $pass, $db);if (!$link) {    die('Connect failed: ' . mysqli_connect_error());}$result = mysqli_query($link, $sql);if ($result === false) {    error_log(mysqli_error($link));    // fall back to something}

В PHP 8.1 блоки if никогда не выполняются: исключение выбрасывается раньше, а если его никто не перехватит, запрос завершается с необработанным mysqli_sql_exception. При включённом display_errors - а в продакшене он должен быть выключен - сообщение может содержать имя хоста и пользователя. Код, который рассчитывал, что неудавшийся INSERT вернёт false, чтобы показать "это имя занято", теперь просто падает.

Вперёд ведут два пути. Правильный - оставить исключения и перехватывать ожидаемые ошибки:

php
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); // explicit, same as 8.1 defaulttry {    $mysqli->execute_query('INSERT INTO users (email) VALUES (?)', [$email]);} catch (mysqli_sql_exception $e) {    if ($e->getCode() === 1062) {   // duplicate entry        return 'That email is already registered.';    }    throw $e;}

Временная мера - вернуть старое поведение с помощью mysqli_report(MYSQLI_REPORT_OFF); сразу после запуска. Она выигрывает время на большой устаревшей кодовой базе, но заодно возвращает и тихие сбои, так что считайте её временной настройкой, под которую заведена задача.

Одно небольшое различие в том, по чему вы ветвитесь: mysqli_sql_exception::getCode() возвращает номер ошибки MySQL (1062, 1045, 2002), а getSqlState() - SQLSTATE. PDOException::getCode() возвращает строку SQLSTATE (23000), а номер MySQL лежит в $e->errorInfo[1]. В обоих случаях ветвитесь по номеру MySQL: один SQLSTATE покрывает множество не связанных между собой ошибок. Типичные случаи 1045 Access denied разобраны в статье Пользователи и привилегии MySQL.

Типы, выборка и возможности, которые есть только у одного#

Целые числа, которые приходят строками

В mysqli обычный query() использует текстовый протокол MySQL и возвращает каждый столбец строкой, даже столбцы INT. Подготовленные запросы используют бинарный протокол и возвращают нативные целые и дробные числа. Чтобы обычные запросы тоже возвращали нативные типы, можно сделать так:

php
$mysqli = mysqli_init();$mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, true);$mysqli->real_connect($host, $user, $pass, $db, $port);

PDO с нативной подготовкой возвращает целые и дробные числа; с эмулируемой до PHP 8.1 возвращало строки, а затем это поменяли на нативные типы. В обоих расширениях DECIMAL намеренно приходит строкой, чтобы деньги не набирали погрешность округления чисел с плавающей запятой. Если после обновления внезапно ломается строгое сравнение вроде $row['id'] === '5', причина обычно в этом.

Выборка

Режимы выборки PDO экономят циклы: FETCH_COLUMN - плоский список, FETCH_KEY_PAIR - соответствие id и имени, FETCH_GROUP - строки, сгруппированные по первому столбцу, FETCH_CLASS - заполнение объектов. В mysqli есть fetch_assoc(), fetch_row(), fetch_object(), fetch_all() и, начиная с PHP 8.1, fetch_column(). Всё, что сверх этого, - цикл, который вы пишете сами.

mysqli_result итерируем, поэтому foreach ($result as $row) даёт ассоциативные массивы без вызова fetch_assoc() в цикле while. Выражение PDO итерируется так же, в том режиме выборки, который задан.

Что умеет только mysqli

  • Асинхронные запросы. $mysqli->query($sql, MYSQLI_ASYNC) возвращается сразу; mysqli_poll() ждёт сразу несколько соединений, а reap_async_query() забирает результат. Так один процесс PHP может параллельно выполнять несколько медленных запросов на отдельных соединениях. Используется редко, а когда используется, то обычно для отчётов, собирающих данные из нескольких баз.
  • `multi_query()`. Выполняет несколько запросов, разделённых точкой с запятой, одним вызовом и проходит по результатам через next_result(). Полезно для запуска файла миграции. Это же ровно то, чего хочет SQL-инъекция, поэтому пользовательский ввод сюда попадать не должен. PDO с эмулируемой подготовкой может выполнить несколько запросов в одном exec(), но работать с их результатами неудобно.
  • Вызовы, специфичные для MySQL, такие как $mysqli->info (сколько строк найдено и изменено после UPDATE), stat(), thread_id и kill().

Что умеет только PDO

Именованные плейсхолдеры, перечисленные выше режимы выборки, FETCH_CLASS с аргументами конструктора и один и тот же код для нескольких баз данных. Начиная с PHP 8.4 есть ещё и подклассы драйверов: PDO::connect() возвращает объект Pdo\Mysql для DSN MySQL, а константы, специфичные для MySQL, лежат на нём.

Производительность: в чём разница на самом деле#

Бенчмарки mysqli против PDO обычно показывают, что mysqli на несколько процентов быстрее на плотных циклах из тривиальных запросов. Эта цифра настоящая и не имеет значения. Оба расширения передают работу mysqlnd; разница - тонкий слой обёртки на стороне PHP на каждый вызов, измеряемый микросекундами. Один запрос, который читает с диска, а не из буферного пула, стоит дороже, чем эта разница на тысячах вызовов.

Что действительно меняет скорость вашего кода работы с базой:

  • Обращения к серверу. Подготовленный запрос на стороне сервера - это запрос на подготовку, а затем запрос на выполнение: два обращения к серверу, прежде чем вернутся результаты. mysqli всегда делает так для prepare() и execute_query(). PDO с эмулируемой подготовкой отправляет один запрос. По локальному сокету этого никто не заметит; по сетевому пути в пару миллисекунд страница, которая выполняет 200 разовых подготовленных запросов, тратит заметное время на ожидание. Либо исправьте страницу, чтобы она не делала 200 запросов, либо подготовьте запрос один раз и выполняйте в цикле - оба расширения делают это хорошо.
  • Буферизованные и небуферизованные результаты. Оба по умолчанию целиком буферизуют результат в памяти PHP. Для большой выгрузки MYSQLI_USE_RESULT в mysqli (или real_query() плюс use_result()) и MYSQL_ATTR_USE_BUFFERED_QUERY => false в PDO передают строки потоком. Это меняет расход памяти на порядки; выбор расширения - нет.
  • Соединения. Открытие соединения в каждой вспомогательной функции вместо одного на запрос умножает число рукопожатий. В обоих расширениях это стоит одинаково. Почему воркеры PHP-FPM и max_connections нужно согласовывать, описано в статье Лимиты соединений MySQL и пулинг.
  • Индексы. Почти каждая жалоба "PHP медленно работает с MySQL" - это отсутствующий индекс. Начать стоит со статьи Индексы MySQL и EXPLAIN, а журнал медленных запросов подскажет, с какого запроса начинать.

Если вы выбираете между двумя расширениями ради скорости, вы оптимизируете не тот слой.

Переход с устаревшего кода на mysql_*#

Код, всё ещё построенный на функциях mysql_*, нужно перенести, прежде чем он заработает на любой поддерживаемой версии PHP. Механический путь - процедурный API mysqli, потому что имена функций совпадают; лучший путь, если вы всё равно трогаете каждый запрос, - PDO с подготовленными запросами. В любом случае работа сводится к трём шагам: заменить вызовы, явно передавать соединение и заменить SQL, собранный из строк, плейсхолдерами.

Соответствие для распространённых вызовов:

mysql_*mysqli (процедурный)PDO
mysql_connect() + mysql_select_db()mysqli_connect($h, $u, $p, $db)new PDO($dsn, $u, $p)
mysql_query($sql)mysqli_query($link, $sql)$pdo->query($sql)
mysql_fetch_assoc($r)mysqli_fetch_assoc($r)$stmt->fetch()
mysql_num_rows($r)mysqli_num_rows($r)count($stmt->fetchAll())
mysql_insert_id()mysqli_insert_id($link)$pdo->lastInsertId()
mysql_real_escape_string($s)подготовленный запросподготовленный запрос
mysql_error()исключение (8.1+)исключение

Ловушки при простой автозамене:

  1. Порядок аргументов. mysql_query($sql, $link) принимала соединение вторым и необязательным. mysqli_query($link, $sql) принимает его первым и всегда. Регулярное выражение, которое переименовывает функцию, но не меняет аргументы местами, даёт код, падающий при каждом вызове.
  2. Неявное соединение. Функции mysql_* использовали последнее открытое соединение, если вы не передавали своё, поэтому унаследованный код вызывает mysql_query() из глубины функций, которые никогда не видели переменной соединения. mysqli и PDO требуют передавать его явно. Быстрое решение - функция, возвращающая общее соединение (db()->query(...)); глобальная переменная тоже работает и хуже.
  3. Функции без аналога. У mysql_result() (получить одну ячейку по строке и полю) нет двойника в mysqli; замените её выборкой и обращением по индексу массива или fetchColumn() в PDO. mysql_list_dbs() и подобные превращаются в запросы SHOW DATABASES.
  4. Экранирование без кодировки. Если старый код вызывает mysql_real_escape_string(), а вы заменяете её на mysqli_real_escape_string(), сначала задайте кодировку через mysqli_set_charset($link, 'utf8mb4'), а не запросом SET NAMES - экранирование знает только ту кодировку, которую ему сообщили через API. Ещё лучше - убрать экранирование и использовать плейсхолдеры.
  5. Обработка ошибок меняется. Устаревшие конструкции or die(mysql_error()) в PHP 8.1 с исключениями становятся мёртвым кодом. Определите единую стратегию обработки ошибок в самом начале переноса, а не открывайте её для себя запрос за запросом.

Существуют библиотеки-прослойки, которые заново реализуют функции mysql_* поверх mysqli, чтобы старый код без изменений работал на PHP 7 и 8. Это способ перевести заброшенное приложение на поддерживаемую версию PHP, пока вы планируете настоящую миграцию, а не конечная цель: код по-прежнему собирает SQL из строк.

К старому коду обычно прилагается старая база данных. В MySQL 8.4 плагин mysql_native_password больше не включён по умолчанию, а очень старые сборки PHP не умеют аутентифицироваться через его замену, caching_sha2_password, поэтому устаревшее приложение, перенесённое на новую базу, может упасть при подключении с ошибкой аутентификации. Как это исправить, описано в статье MySQL 8.4 LTS: что изменилось, а перенос самих данных - в статье Перенос MySQL на новый хостинг.

Подключение к размещённой базе из любого из них#

Правила подключения в обоих расширениях одинаковы, потому что клиентская библиотека под ними одна и та же.

php
// mysqli$mysqli = new mysqli($host, $user, $pass, $db, (int) $port);$mysqli->set_charset('utf8mb4');// PDO$pdo = new PDO(    "mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4",    $user,    $pass,    [PDO::ATTR_EMULATE_PREPARES => false]);

Три вещи верны для обоих. Задавайте utf8mb4 через API - set_charset() или DSN, - чтобы работали и экранирование, и эмодзи (таблицы разобраны в статье MySQL utf8mb4 и сортировки). Не пишите localhost для базы на другой машине: клиент воспринимает localhost как запрос локального Unix-сокета и игнорирует порт. И храните учётные данные в переменных окружения, а не в PHP-файле внутри корня сайта; где им место, объясняет статья Переменные окружения и секреты.

Постоянные соединения есть в обоих - префикс p: у хоста в mysqli и PDO::ATTR_PERSISTENT в PDO - и действует та же осторожность: они переносят состояние сессии из одного запроса в следующий на том же воркере, и экономят редко настолько, чтобы это стоило делать.

На RE:NODE база данных MySQL 8.4 LTS создаётся с паролем root, уже сгенерированной базой приложения и пользователем приложения, а подключаться к ней нужно по хосту и порту тарифа - так что любое из подключений выше сводится к вставке этих четырёх значений. Линейка приложений Laravel / PHP работает на PHP 8.3, 8.4 или 8.5, а значит, все описанные в статье значения по умолчанию (исключения в обоих расширениях, execute_query(), нативные типы при эмулируемой подготовке в PDO) - это именно то поведение, которое вы там увидите.

FAQ#

mysqli устарело?

Нет. mysqli поддерживается и получает новые возможности - execute_query() появился в PHP 8.2. Устарело и было удалено старое расширение mysql без "i". Выбор PDO для нового кода - вопрос предпочтения, а не миграция, к которой вас принуждают.

mysqli менее безопасно, чем PDO?

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

Можно ли использовать mysqli и PDO в одном приложении?

Да. Это отдельные соединения, поэтому у них нет общих транзакций, lastInsertId() и переменных сессии, но оба могут читать и писать в одну и ту же базу. Так обычно и мигрируют постепенно с одного на другое.

PDO работает с MariaDB?

Да. pdo_mysql и mysqli используют протокол MySQL, который реализует MariaDB. Различия, с которыми вы столкнётесь, касаются возможностей SQL и настроек сервера по умолчанию, а не расширения PHP.

Почему мой код на mysqli выбрасывает исключения после обновления PHP?

В PHP 8.1 режим отчётов mysqli по умолчанию сменился с выключенного на MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT. Запросы, которые раньше тихо возвращали false, теперь выбрасывают mysqli_sql_exception. Перехватывайте ожидаемые ошибки или вызовите mysqli_report(MYSQLI_REPORT_OFF) как краткосрочную временную меру.

Что используют фреймворки?

PDO. Laravel, Symfony через Doctrine DBAL, CakePHP и большинство других фреймворков строят на нём свой слой работы с базой, и это ещё одна причина, по которой новый код, который может оказаться внутри фреймворка, стоит начинать с него.

Это было полезно?