Выбирайте PDO, если у вас нет конкретной причины поступить иначе. Оба расширения поддерживаются, оба работают с MySQL через одну и ту же библиотеку mysqlnd, оба безопасны при использовании подготовленных запросов, а разницу в скорости между ними в реальном приложении невозможно измерить. PDO выигрывает в том, с чем вы сталкиваетесь каждый день: именованные плейсхолдеры, единый API для MySQL, PostgreSQL и SQLite, режимы выборки, которые складывают строки в массивы или объекты без цикла, и исключения по умолчанию начиная с PHP 8.0. mysqli выигрывает только там, где нужно что-то специфичное для MySQL, чего PDO не предоставляет - асинхронные запросы, multi_query() с отдельными наборами результатов, либо готовая кодовая база, которая уже написана на нём и работает. Остальная часть статьи - подробности за этим выводом: чем на самом деле отличаются два API, подготовленные запросы бок о бок, смена режима ошибок в PHP 8.1, из-за которой сломалось много старого кода на mysqli, что здесь значит "производительность" и как перенести устаревший код на mysql_* на любой из двух вариантов.
Коротко, в таблице#
| Вопрос | mysqli | PDO |
|---|---|---|
| Базы данных | Только 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_mysql | MySQL, MariaDB |
pdo_pgsql | PostgreSQL |
pdo_sqlite | SQLite |
pdo_sqlsrv | SQL 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, передаваемый пакетами.
$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 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, с именованными плейсхолдерами:
$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 обычно выглядит так:
$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, чтобы показать "это имя занято", теперь просто падает.
Вперёд ведут два пути. Правильный - оставить исключения и перехватывать ожидаемые ошибки:
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. Подготовленные запросы используют бинарный протокол и возвращают нативные целые и дробные числа. Чтобы обычные запросы тоже возвращали нативные типы, можно сделать так:
$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+) | исключение |
Ловушки при простой автозамене:
- Порядок аргументов.
mysql_query($sql, $link)принимала соединение вторым и необязательным.mysqli_query($link, $sql)принимает его первым и всегда. Регулярное выражение, которое переименовывает функцию, но не меняет аргументы местами, даёт код, падающий при каждом вызове. - Неявное соединение. Функции
mysql_*использовали последнее открытое соединение, если вы не передавали своё, поэтому унаследованный код вызываетmysql_query()из глубины функций, которые никогда не видели переменной соединения. mysqli и PDO требуют передавать его явно. Быстрое решение - функция, возвращающая общее соединение (db()->query(...)); глобальная переменная тоже работает и хуже. - Функции без аналога. У
mysql_result()(получить одну ячейку по строке и полю) нет двойника в mysqli; замените её выборкой и обращением по индексу массива илиfetchColumn()в PDO.mysql_list_dbs()и подобные превращаются в запросыSHOW DATABASES. - Экранирование без кодировки. Если старый код вызывает
mysql_real_escape_string(), а вы заменяете её наmysqli_real_escape_string(), сначала задайте кодировку черезmysqli_set_charset($link, 'utf8mb4'), а не запросомSET NAMES- экранирование знает только ту кодировку, которую ему сообщили через API. Ещё лучше - убрать экранирование и использовать плейсхолдеры. - Обработка ошибок меняется. Устаревшие конструкции
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 на новый хостинг.
Подключение к размещённой базе из любого из них#
Правила подключения в обоих расширениях одинаковы, потому что клиентская библиотека под ними одна и та же.
// 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 и большинство других фреймворков строят на нём свой слой работы с базой, и это ещё одна причина, по которой новый код, который может оказаться внутри фреймворка, стоит начинать с него.


