RE:NODE

mysqli vs PDO v PHP: rozdíly, chyby a co zvolit

Text: Tým RE:NODE
15 min čtení


Vol PDO, pokud nemáš konkrétní důvod to neudělat. Obě rozšíření jsou udržovaná, obě mluví s MySQL přes stejnou knihovnu mysqlnd, obě jsou s prepared statements bezpečná a rozdíl v rychlosti je v reálné aplikaci příliš malý na to, aby se dal změřit. PDO vyhrává v tom, na co sáhneš každý den: pojmenované placeholdery, jedno API pro MySQL, PostgreSQL i SQLite, režimy fetch, které naplní pole nebo objekty bez smyčky, a výjimky zapnuté ve výchozím stavu od PHP 8.0. mysqli vyhrává jen tam, kde potřebuješ něco specifického pro MySQL, co PDO nenabízí - asynchronní dotazy, multi_query() s oddělenými sadami výsledků, nebo kde už máš kódovou základnu napsanou v mysqli a funguje. Zbytek článku je detail za tímto verdiktem: v čem se obě API doopravdy liší, prepared statements vedle sebe, změna hlášení chyb v PHP 8.1, která rozbila spoustu starého mysqli kódu, co se tady myslí „výkonem“ a jak přesunout starý kód s mysql_* na jedno či druhé.

Stručně v tabulce#

OtázkamysqliPDO
DatabázePouze MySQL a MariaDBTucet ovladačů, jedno API
PlaceholderyPouze ?? i :named
StylObjektový nebo procedurálníPouze objektový
PrepareVždy na straně serveruPro MySQL ve výchozím stavu emulovaný
Chyby ve výchozím stavuVýjimky od PHP 8.1Výjimky od PHP 8.0
Asynchronní dotazyAno, MYSQLI_ASYNCNe
Fetch do třídyfetch_object()FETCH_CLASS, fetchObject()

Žádný z těch řádků není důvod přepisovat fungující kód. Jsou to důvody, proč si vybrat jedno z nich pro nový kód, a pro nový kód je volba téměř vždy PDO. Pokud už PDO používáš a chceš kompletní výklad DSN, voleb, transakcí a chybových kódů, najdeš ho v článku PHP PDO a MySQL; tenhle článek zůstává u srovnání.

Co ta dvě rozšíření jsou#

V PHP existovala tři rozšíření pro MySQL a historie vysvětluje pojmenování.

Původní rozšíření mysql - mysql_connect(), mysql_query(), mysql_fetch_assoc() - bylo v PHP 5.5 označeno jako zastaralé a v PHP 7.0 úplně odstraněno. Nemělo prepared statements, drželo v globálním stavu implicitní „poslední připojení“ a celou generaci vývojářů vedlo k sestavování SQL slepováním řetězců. Pokud nějaký kód pořád volá mysql_query(), běží na PHP 5.6 nebo starším, nebo neběží vůbec.

mysqli („i“ znamená „improved“, tedy vylepšené) přišlo v PHP 5 jako náhrada. Je to poměrně tenký obal nad klientským API samotného MySQL: zpřístupňuje koncepty MySQL přímo, podporuje prepared statements, transakce, více příkazů najednou a asynchronní dotazy a nabízí objektové rozhraní ($mysqli->query()) i procedurální (mysqli_query($link, ...)). Procedurální rozhraní existuje hlavně proto, aby se snadněji portoval starý kód s mysql_*.

PDO (PHP Data Objects) přišlo zhruba ve stejné době s jiným cílem: jedno API pro přístup k databázím, pod ním zapojené ovladače pro jednotlivé databáze. pdo_mysql je ovladač pro MySQL. PDO se nesnaží zpřístupnit všechno, co MySQL umí. Zpřístupňuje to, co potřebuje většina aplikací, a to konzistentně napříč databázemi.

Obě rozšíření používají mysqlnd, nativní ovladač MySQL napsaný pro PHP, v každém běžném sestavení. To je důležité pro otázku výkonu později: síťový protokol, buffering i zpracování výsledků je v obou případech stejný kód.

Ovladače: jedna databáze, nebo mnoho#

Seznam ovladačů PDO je nejzřetelnější funkční rozdíl. Ty běžné:

OvladačDatabáze
pdo_mysqlMySQL, MariaDB
pdo_pgsqlPostgreSQL
pdo_sqliteSQLite
pdo_sqlsrvSQL Server (rozšíření od Microsoftu, instaluje se zvlášť)
pdo_odbcCokoli, co má ovladač ODBC

Co má daná instalace PHP, zjistíš pomocí php -m nebo php -r 'print_r(PDO::getAvailableDrivers());'.

Upřímná verze argumentu o přenositelnosti: PDO nedělá SQL přenositelné. LIMIT versus TOP, AUTO_INCREMENT versus identity sloupce, uvozování zpětnými apostrofy versus dvojitými uvozovkami - to všechno musíš pořád řešit sám. PDO zpřenositelňuje kód kolem SQL: připojení, binding, fetch, transakce a zpracování chyb. V praxi je to většina databázového kódu v aplikaci a je to důvod, proč je každý významný PHP framework a ORM - Eloquent v Laravelu, Doctrine DBAL, ORM v CakePHP - postavený na PDO. Je to také důvod, proč je spouštění testů proti SQLite při produkci na MySQL s PDO reálná možnost a s mysqli nepřipadá v úvahu.

Pokud aplikace bude mluvit vždy jen s MySQL, přenositelnost sama o sobě moc neznamená. Argument pro PDO pak stojí na tom, že API je příjemnější, což je pravda, hlavně kvůli placeholderům a režimům fetch.

Prepared statements v obou#

Stejný dotaz - najdi aktivní uživatele vytvořené po určitém datu - napsaný třemi způsoby.

mysqli, klasická podoba. Parametry se vážou referencí s řetězcem typů: i celé číslo, d double, s řetězec, b blob posílaný v paketech.

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 na PHP 8.1 a novějším může bind_param() vynechat a předat do execute() pole; každá hodnota se pak naváže jako řetězec, který MySQL podle potřeby převede. PHP 8.2 přidalo execute_query(), které provede prepare, binding i execute jedním voláním a vrátí výsledek:

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 s pojmenovanými placeholdery:

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);

Rozdíly, které v praxi bolí:

  • Pojmenované placeholdery. PDO je má, mysqli ne. U dotazu s osmi parametry je :email čitelné a řetězec typů ssisdssi je chyba čekající na to, až někdo přidá sloupec doprostřed.
  • Binding referencí. bind_param() váže proměnné, ne hodnoty, a čte je až při execute(). To je užitečné ve smyčce (jednou navázat, měnit proměnné, opakovaně spouštět) a všude jinde matoucí, protože nemůžeš předat přímo literál ani výsledek funkce. PDO má bindParam() (referencí) i bindValue() (hodnotou) a execute($array) pokryje většinu případů.
  • `get_result()` versus `bind_result()`. get_result() vrací běžný objekt výsledku, ze kterého lze číst pole. Vyžaduje mysqlnd, proto staré návody psané pro sestavení bez něj používají bind_result(), který naváže každý výstupní sloupec na proměnnou. Na jakémkoli současném PHP můžeš použít get_result() a na bind_result() zapomenout.
  • Na straně serveru, nebo emulované. Prepare v mysqli jsou vždy skutečné prepared statements na straně serveru. PDO s MySQL je ve výchozím stavu emuluje - escapuje hodnoty a pošle jeden hotový dotaz - dokud nenastavíš PDO::ATTR_EMULATE_PREPARES na false. Obojí je bezpečné, pokud je správně nastavená znaková sada připojení. Kdy na emulaci záleží, vysvětluje článek o PDO.

Žádné z API nedovolí, aby placeholder zastoupil název tabulky, název sloupce nebo ASC/DESC. Ty musí pocházet z pevného seznamu ve tvém kódu, ať používáš kterékoli rozšíření.

Hlášení chyb: co se změnilo v PHP 8.0 a 8.1#

Tohle je rozdíl, který nejspíš rozbije upgrade, a u obou rozšíření se změnil během jednoho roku.

PDO mělo do PHP 8.0 výchozí PDO::ERRMODE_SILENT. Neúspěšný dotaz vrátil false, nastavil chybový kód, o který sis musel říct, a skript pokračoval dál. PHP 8.0 změnilo výchozí hodnotu na PDO::ERRMODE_EXCEPTION, takže neúspěšný dotaz vyhodí PDOException.

mysqli následovalo v PHP 8.1. Před 8.1 byl výchozí režim hlášení MYSQLI_REPORT_OFF: query() vrátilo false a $mysqli->error nebo $mysqli->errno sis kontroloval sám - nebo, jak dělala většina kódu, vůbec. Od PHP 8.1 je výchozí MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, což vyhodí mysqli_sql_exception při jakékoli chybě, včetně neúspěšného připojení.

Starý kód s mysqli typicky vypadá takhle:

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}

Na PHP 8.1 se bloky if nikdy nespustí: výjimka se vyhodí dřív, a pokud ji nic nezachytí, požadavek skončí neodchycenou mysqli_sql_exception. Se zapnutým display_errors - což v produkci být nemá - může zpráva obsahovat název hostitele a uživatele. Kód, který spoléhal na to, že neúspěšný INSERT vrátí false a on zobrazí „toto jméno je obsazené“, teď místo toho spadne.

Máš dvě cesty dál. Ta správná je ponechat výjimky a zachytávat chyby, které očekáváš:

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;}

Provizorním řešením je vrátit staré chování pomocí mysqli_report(MYSQLI_REPORT_OFF); hned po startu. Získáš tím čas na velké starší kódové základně, ale vrátí se i tiché selhávání, takže to ber jako dočasné nastavení s přiloženým ticketem.

Jeden drobný rozdíl v tom, podle čeho větvíš: mysqli_sql_exception::getCode() vrací číslo chyby MySQL (1062, 1045, 2002) a getSqlState() vrací SQLSTATE. PDOException::getCode() vrací řetězec SQLSTATE (23000) a číslo MySQL je v $e->errorInfo[1]. Ve větvení použij v obou případech číslo MySQL; jeden SQLSTATE pokrývá mnoho nesouvisejících chyb. Uživatelé a oprávnění v MySQL vysvětlují běžné případy 1045 Access denied.

Typy, fetch a funkce, které má jen jedno z nich#

Celá čísla, která se vrátí jako řetězce

V mysqli používá běžné query() textový protokol MySQL a vrací každý sloupec jako řetězec, i sloupce INT. Prepared statements používají binární protokol a vrací nativní celá čísla a čísla s plovoucí řádovou čárkou. Aby i běžné dotazy vracely nativní typy, můžeš udělat takhle:

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

PDO s nativními prepare vrací celá čísla a floaty; s emulovanými prepare vracelo řetězce do PHP 8.1, které to změnilo na nativní typy. V obou rozšířeních se DECIMAL vrací záměrně jako řetězec, aby peníze nenabíraly zaokrouhlovací chyby čísel s plovoucí řádovou čárkou. Když po upgradu najednou přestane fungovat striktní porovnání jako $row['id'] === '5', bývá důvod právě tohle.

Fetch

Režimy fetch v PDO šetří smyčky: FETCH_COLUMN pro plochý seznam, FETCH_KEY_PAIR pro mapu id na jméno, FETCH_GROUP pro řádky seskupené podle prvního sloupce, FETCH_CLASS pro naplnění objektů. mysqli má fetch_assoc(), fetch_row(), fetch_object(), fetch_all() a od PHP 8.1 fetch_column(). Cokoli dalšího je smyčka, kterou napíšeš ty.

Objekt mysqli_result je iterovatelný, takže foreach ($result as $row) vrátí asociativní pole bez volání fetch_assoc() ve smyčce while. Statement v PDO je iterovatelný stejně, v jakémkoli nastaveném režimu fetch.

Co umí jen mysqli

  • Asynchronní dotazy. $mysqli->query($sql, MYSQLI_ASYNC) se vrátí okamžitě; mysqli_poll() čeká na několik připojení a reap_async_query() vyzvedne výsledek. Díky tomu může jeden proces PHP spustit několik pomalých dotazů paralelně na oddělených připojeních. Používá se zřídka, a když ano, tak obvykle pro reporty, které se rozbíhají přes několik databází.
  • `multi_query()`. Spustí několik příkazů oddělených středníkem jedním voláním a výsledky projde přes next_result(). Hodí se na spuštění migračního souboru. Je to také přesně to, po čem SQL injection touží, takže by nikdy nemělo vidět vstup od uživatele. PDO s emulovanými prepare dokáže spustit více příkazů jedním exec(), ale zpracování jejich výsledků je těžkopádné.
  • Volání specifická pro MySQL jako $mysqli->info (počet nalezených a změněných řádků po UPDATE), stat(), thread_id a kill().

Co umí jen PDO

Pojmenované placeholdery, výše zmíněné režimy fetch, FETCH_CLASS s argumenty konstruktoru a stejný kód nad několika databázemi. Od PHP 8.4 existují také podtřídy ovladačů: PDO::connect() vrací pro DSN MySQL objekt Pdo\Mysql s konstantami specifickými pro MySQL přímo na něm.

Výkon: v čem se opravdu liší#

Benchmarky mysqli proti PDO obvykle ukazují, že mysqli je o pár procent rychlejší na těsných smyčkách triviálních dotazů. To číslo je skutečné a nepodstatné. Obě rozšíření předávají práci mysqlnd; rozdíl je tenká vrstva obalu na straně PHP při každém volání, měřená v mikrosekundách. Jediný dotaz, který čte z disku místo z buffer poolu, stojí víc než ten rozdíl přes tisíce volání.

Věci, které rychlost databázového kódu opravdu mění:

  • Round tripy. Prepared statement na straně serveru jsou požadavek prepare a pak požadavek execute: dvě cesty na server, než se vrátí výsledky. mysqli to dělá vždy u prepare() a execute_query(). PDO s emulovanými prepare pošle jeden dotaz. Přes lokální socket si toho nikdo nevšimne; přes síťovou cestu s latencí pár milisekund ztratí stránka, která spouští 200 jednorázových prepared dotazů, skutečný čas čekáním. Buď uprav stránku tak, aby těch 200 dotazů nespouštěla, nebo udělej prepare jednou a spouštěj ve smyčce, což obě rozšíření zvládají dobře.
  • Buffered versus unbuffered výsledky. Obě ve výchozím stavu ukládají celý výsledek do paměti PHP. U velkého exportu řádky streamují MYSQLI_USE_RESULT v mysqli (nebo real_query() plus use_result()) a MYSQL_ATTR_USE_BUFFERED_QUERY => false v PDO. To mění spotřebu paměti o řády; volba rozšíření ne.
  • Připojení. Otevírání připojení v každé pomocné funkci místo jednou na požadavek znásobuje handshaky. To stojí stejně v obou. Limity připojení a pooling v MySQL vysvětluje, proč se musí workery PHP-FPM a max_connections shodnout.
  • Indexy. Téměř každé hlášení „PHP je s MySQL pomalé“ je chybějící index. Indexy a EXPLAIN v MySQL jsou dobré místo, kde začít, a slow query log ti řekne, kterým dotazem.

Pokud mezi oběma rozšířeními vybíráš kvůli rychlosti, optimalizuješ špatnou vrstvu.

Migrace starého kódu s mysql_*#

Kód, který je pořád postavený na funkcích mysql_*, se musí přesunout, než poběží na jakémkoli podporovaném PHP. Mechanická cesta vede přes procedurální API mysqli, protože názvy funkcí si odpovídají; lepší cesta, když stejně sahaš na každý dotaz, je PDO s prepared statements. V obou případech je práce stejná ve třech krocích: nahradit volání, předávat připojení explicitně a nahradit SQL sestavované z řetězců placeholdery.

Mapování běžných volání:

mysql_*mysqli (procedurální)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)prepared statementprepared statement
mysql_error()výjimka (8.1+)výjimka

Pasti u prostého najdi-a-nahraď:

  1. Pořadí argumentů. mysql_query($sql, $link) brala připojení jako druhý, nepovinný argument. mysqli_query($link, $sql) ho bere jako první a vždy. Regulár, který přejmenuje funkci, ale nevymění argumenty, vyrobí kód, který selže při každém volání.
  2. Implicitní připojení. Funkce mysql_* použily poslední otevřené připojení, pokud jsi žádné nepředal, takže starý kód volá mysql_query() hluboko uvnitř funkcí, které proměnnou s připojením nikdy neviděly. mysqli i PDO ho potřebují dostat předané. Rychlé řešení je funkce, která vrací sdílené připojení (db()->query(...)); globální proměnná funguje taky a je horší.
  3. Funkce bez ekvivalentu. mysql_result() (načtení jedné buňky podle řádku a pole) nemá v mysqli protějšek; nahraď ho fetchem a indexem pole, nebo fetchColumn() v PDO. mysql_list_dbs() a podobné se změní na dotazy SHOW DATABASES.
  4. Escapování bez znakové sady. Pokud starý kód volá mysql_real_escape_string() a ty ho vyměníš za mysqli_real_escape_string(), nastav nejdřív znakovou sadu přes mysqli_set_charset($link, 'utf8mb4'), nikdy ne dotazem SET NAMES - escapování zná jen znakovou sadu, která mu byla sdělena přes API. Ještě lepší je escapování smazat a použít placeholdery.
  5. Zpracování chyb se obrací. Staré vzory or die(mysql_error()) se pod výjimkami PHP 8.1 stanou mrtvým kódem. Rozhodni se pro jednu strategii chyb na začátku portu, ne abys na ni přicházel dotaz po dotazu.

Existují knihovny-shimy, které reimplementují funkce mysql_* nad mysqli, takže starý kód běží beze změny na PHP 7 a 8. Jsou to způsob, jak dostat opuštěnou aplikaci na podporovanou verzi PHP, zatímco plánuješ skutečnou migraci, ne cíl: kód pořád sestavuje SQL z řetězců.

Starý kód obvykle přichází se starou databází. MySQL 8.4 už ve výchozím stavu nezapíná plugin mysql_native_password a velmi staré sestavení PHP se neumí přihlásit pomocí jeho náhrady caching_sha2_password, takže stará aplikace přesunutá na novou databázi může při připojení selhat na chybě autentizace. MySQL 8.4 LTS: co se změnilo popisuje opravu a migrace MySQL na nového hostitele popisuje přesun dat.

Připojení k hostované databázi z obou#

Pravidla připojení jsou v obou rozšířeních stejná, protože klientská knihovna pod nimi je stejná.

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]);

Tři věci platí pro obě. Nastav utf8mb4 přes API - set_charset() nebo DSN - aby fungovalo escapování i emoji (utf8mb4 a collation v MySQL pokrývá tabulky). Nepiš localhost pro databázi na jiném stroji, protože klient bere localhost jako požadavek na lokální Unix socket a port ignoruje. A přihlašovací údaje drž v proměnných prostředí, ne v souboru PHP uvnitř webrootu; proměnné prostředí a secrets vysvětlují, kam patří.

Perzistentní připojení existují v obou - předpona p: u hostitele v mysqli, PDO::ATTR_PERSISTENT v PDO - a platí stejná opatrnost: přenášejí stav session z jednoho požadavku do dalšího na stejném workeru a málokdy ušetří tolik, aby se to vyplatilo.

Na RE:NODE se databáze MySQL 8.4 LTS vytvoří s heslem uživatele root, aplikační databází a aplikačním uživatelem, které jsou už vygenerované, a je dostupná na hostiteli a portu daného plánu - takže obě výše uvedená připojení jsou jen těchto čtyřech hodnot vložených dovnitř. Řada aplikací Laravel / PHP běží na PHP 8.3, 8.4 nebo 8.5, což znamená, že každé výchozí chování popsané v tomto článku (výjimky v obou rozšířeních, execute_query(), nativní typy z emulovaných prepare v PDO) je to, co tam uvidíš.

FAQ#

Je mysqli zastaralé?

Ne. mysqli je udržované a dostává nové funkce - execute_query() přišlo v PHP 8.2. Zastaralé a odstraněné bylo staré rozšíření mysql bez „i“. Volba PDO pro nový kód je preference, ne migrace, ke které bys byl nucen.

Je mysqli méně bezpečné než PDO?

Ne. Obě jsou stejně bezpečná, když každá vnější hodnota projde placeholderem, a stejně nebezpečná, když se SQL sestavuje slepováním. Prepare v mysqli jsou vždy na straně serveru, takže není žádná vrstva emulace, kterou by bylo třeba nastavovat. Bezpečnost obou závisí na návycích kolem nich, ne na rozšíření.

Můžu používat mysqli a PDO ve stejné aplikaci?

Ano. Jsou to oddělená připojení, takže nesdílejí transakce, lastInsertId() ani proměnné session, ale obě mohou číst a zapisovat do stejné databáze. Je to obvyklý způsob, jak postupně migrovat z jednoho na druhé.

Funguje PDO s MariaDB?

Ano. pdo_mysql i mysqli mluví protokolem MySQL, který MariaDB implementuje. Rozdíly, na které narazíš, jsou ve funkcích SQL a výchozích nastaveních serveru, ne v rozšíření PHP.

Proč můj kód s mysqli po upgradu PHP vyhazuje výjimky?

PHP 8.1 změnilo výchozí režim hlášení mysqli z vypnutého na MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT. Dotazy, které dřív tiše vracely false, teď vyhazují mysqli_sql_exception. Zachytávej chyby, které očekáváš, nebo jako krátkodobé provizorium zavolej mysqli_report(MYSQLI_REPORT_OFF).

Které z nich používají frameworky?

PDO. Laravel, Symfony přes Doctrine DBAL, CakePHP a většina dalších frameworků na něm staví svou databázovou vrstvu, což je další důvod, proč by měl nový kód, který může skončit uvnitř frameworku, začít tady.

Pomohlo vám to?