Базы данных и PDO
PHP разговаривает с базой в модели shared-nothing: воркер php-fpm поднимает соединение на время одного запроса и отпускает его, когда запрос закончился. Между запросами не переносится ничего — ни пула, ни кэша соединений, ни открытой транзакции. Это делает PHP простым и одновременно расставляет три ловушки, каждую из которых спрашивают: соединений столько же, сколько воркеров, а не сколько нужно базе; всё состояние транзакции живёт внутри одного запроса; а прочитать строку и записать её — это два похода в базу, между которыми успевает вклиниться соседний воркер.
Второй сквозной сюжет темы — что именно делает подготовленное выражение. Правильный ответ не «оно экранирует ввод», а «сервер разбирает и планирует запрос до того, как получит хоть одно значение, поэтому параметр физически не может стать синтаксисом». Отсюда же вырастает главная ловушка уровня senior: PDO::ATTR_EMULATE_PREPARES в драйвере MySQL включён по умолчанию, и при нём никакого серверного разбора до связывания нет — PDO склеивает финальный SQL на стороне клиента. Отсюда же следует и граница: связать можно только значение, но не имя столбца в ORDER BY — единственное место, где подготовленное выражение вас не спасает и нужен allowlist. Разбор по слоям — ниже.
Карта темы
- PDO и подготовленные выражения — единый API поверх драйверов, двухфазный протокол prepare/execute, режим эмуляции и почему
PDO::quote()не заменяет связывание. - Транзакции — атомарность «всё или ничего»,
beginTransaction()/commit()/rollBack()в связке с исключениями и уровни изоляции с их аномалиями. - Блокировки — гонка «прочитать, потом записать», условный
UPDATEсrowCount(), пессимистичныйSELECT ... FOR UPDATEи оптимистичная проверкаversion. - Индексы — B-tree как отсортированная структура поиска, правило левого префикса составного индекса, чтение
EXPLAINи цена индекса при записи. - Управление соединениями — жизненный цикл соединения внутри запроса, арифметика
max_connections, чемATTR_PERSISTENTотличается от пула и что делаетpgbouncer.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Говорить, что подготовленное выражение «экранирует ввод» | Механизм другой: запрос разбирается до связывания, поэтому параметр не может стать синтаксисом |
Оставить PDO::ATTR_EMULATE_PREPARES включённым (это умолчание MySQL-драйвера) | Серверного разбора до связывания нет вовсе — PDO склеивает SQL на клиенте, гарантия становится «экранирование было верным» |
Считать PDO::quote() равноценной заменой связыванию | Это экранирование, а не отдельная передача данных; забыть один вызов — значит открыть инъекцию |
Пытаться связать имя столбца или ASC/DESC в ORDER BY | Плейсхолдер — слот для значения: сортировка пойдёт по константной строке. Здесь нужен allowlist |
Не включить PDO::ERRMODE_EXCEPTION | Упавший запрос молча вернёт false, и код продолжит работать на пустом результате |
| Считать, что уже выполненные запросы остаются зафиксированными, если следующий упал | Без commit() не зафиксировано ничего: rollBack() откатывает всю транзакцию целиком |
| Поймать исключение внутри транзакции и не пробросить дальше | Вызывающий сочтёт операцию успешной, хотя она откатилась |
| Считать транзакцию защитой от гонки «прочитать, потом записать» | Изоляция не мешает соседу прочитать ту же строку — нужна блокировка или условный UPDATE |
| Писать в базу абсолютное значение, посчитанное в PHP | stock = 0 затирает чужую запись; относительный UPDATE ... SET stock = stock - 1 WHERE stock >= 1 — нет |
Не проверять rowCount() после условного или оптимистичного UPDATE | Проигранная гонка молча считается успехом |
| Ставить по индексу на каждый столбец вместо одного составного | Движок возьмёт один индекс: составной под конкретный запрос работает, набор одиночных — почти нет |
Ставить столбец ORDER BY перед столбцами WHERE в составном индексе | Левый префикс не совпадёт с условием — индекс не применится, останется filesort |
Оборачивать столбец функцией в WHERE (YEAR(created_at) = 2024) | Индекс по created_at не используется: движок обязан вычислить функцию на каждой строке |
Называть PDO::ATTR_PERSISTENT пулом соединений | Это один сокет, привязанный к одному воркеру; общее число соединений не уменьшается |
Поднимать max_connections, не сверив его с произведением серверов на pm.max_children | Потолок PHP остаётся выше потолка базы — Too many connections вернётся под нагрузкой |
Значение для собеседований
Базы — тема, на которой проверяют, отличаете ли вы механизм от заклинания. Кандидат, который на вопрос про подготовленные выражения отвечает «PDO экранирует данные», формально «знает про безопасность», но провалит любой следующий вопрос: почему нельзя связать ORDER BY, что меняет эмуляция, зачем вообще нужен bindValue с типом. Кандидат, который говорит «сервер разбирает запрос до того, как получит значения», отвечает на все три сразу.
Что обычно проверяют:
- Что делает
prepare()на сервере и почему параметр не может стать синтаксисом. - Что меняет
PDO::ATTR_EMULATE_PREPARESи почему его выключают. - Что можно связать, а что обязано идти через allowlist.
- Что гарантирует транзакция при падении запроса в середине и какие аномалии снимает каждый уровень изоляции.
- Чем оптимистичная блокировка отличается от
SELECT ... FOR UPDATEи когда какая дешевле. - Как доказать по
EXPLAIN, что причина медленного эндпоинта — недостающий индекс. - Почему в php-fpm нет пула соединений и что с этим делают.
Типичный неверный ответ: «Мы используем PDO, значит SQL-инъекций у нас нет». PDO — это API, а не гарантия: интерполяция строки внутри query() через PDO так же уязвима, как через mysql_*. Второй классический провал — «транзакция защитит от двойной продажи»: транзакция даёт атомарность, а не взаимное исключение, и без блокировки строки или условия внутри UPDATE два воркера спокойно прочитают один и тот же остаток.