PHP: четыре Active Record в одной клетке — кто быстрее достаёт ваши данные

В прошлый раз я вытащил Active Record из Yii1 в отдельный composer-пакет и показал, что код Yii1 умеет жить без фреймворка. Но интереснее другое — насколько хорошо он это делает рядом с остальными. Поэтому я поднял четыре ORM на одной схеме, одних данных и одном железе и прогнал их через phpbench — код и данные лежат на GitHub. В клетке: Eloquent (Laravel), Yii2, Yiisoft Active Record (Yii3) и мой форк Active Record из Yii1 — yii1x/active-record, далее в статье — просто Yii1x.
Можно подумать: раз в предыдущем абзаце прозвучало «мой форк», то и замеры предвзяты. Признаюсь, изначально такой план и был — а потом я всё-таки постарался сделать всё максимально честно. Насколько получилось — судите по цифрам.
Правила честной игры
Дабы поставить всех в более-менее равные условия, тестировать будем в одном проекте:
одна схема — интернет-магазин: заказы, позиции, товары, категории, покупатели;
один драйвер — SQLite, чтобы убрать сеть и оставить чистый overhead самой библиотеки;
одни данные — 25 000 заказов, ~249 000 позиций, 1 000 товаров, 2 500 покупателей;
одно окружение — PHP 8.4 в Docker, opcache включён, JIT выключен;
изолированный замер — phpbench гоняет каждый сценарий в отдельном процессе, поэтому бутстрап фреймворка в цифры не попадает.
Важная деталь: я замеряю не «запрос к базе», а работу ORM поверх запроса — гидратацию объектов, построение SQL, всю обвязку. База одна и та же, так что разница целиком на совести библиотеки. Кеш схемы отключён у всех — чтобы сравнение было честным.
Ещё один нюанс, который всплывёт по ходу: Eloquent единственный отдаёт результат не массивом, а Illuminate\Database\Eloquent\Collection — с десятками методов вроде map, filter, pluck. Остальные возвращают обычные массивы, так что API у них заметно разный.
CRUD — скучно, но показательно
Начнём с примитива, на котором «все быстрые». А может — и не быстрые. А может — и не все. Ладно, шутка. Замерили всех, как и задумывалось.
операция | Eloquent | Yiisoft | Yii2 | Yii1x |
|---|---|---|---|---|
findByPk | 93 μs | 59 μs | 66 μs | 39 μs |
count | 59 μs | 33 μs | 36 μs | 22 μs |
insert | 1.82 ms | 1.74 ms | 1.79 ms | 1.69 ms |
Два наблюдения. Первое — Yii1x на чтении быстрее всех и с большим отрывом: findByPk в 2.4 раза быстрее Eloquent, count — в 2.7. Напоминаю, это код 2008 года, просто в отдельном пакете.
Второе — insert у всех почти одинаковый (разброс в пределах ~8 %, фактически шум). Вставка упирается в базу, а не в ORM: стройте запросы как хотите — одна строка в SQLite стоит одинаково.
Eloquent на чтении заметно тяжелее. Это не случайность, а аванс за его навороты: глобальные скоупы, события, коллекции, кастинг атрибутов. Всё это крутится на каждой операции, даже когда вы этого не просили.
Гидратация: неожиданный лидер
Самый массовый сценарий в реальном приложении — «выбрать список и разложить в объекты». Гоняем findAll с лимитом 100…1000 заказов:
лимит (заказов) | Eloquent | Yiisoft | Yii2 | Yii1x |
|---|---|---|---|---|
100 | 654 µs | 612 µs | 626 µs | 368 µs |
250 | 1.48 ms | 1.45 ms | 1.47 ms | 859 µs |
500 | 2.93 ms | 2.84 ms | 2.93 ms | 1.68 ms |
1000 | 5.83 ms | 5.74 ms | 5.71 ms | 3.46 ms |
Во всём диапазоне Yii1x делает это в 1.7–1.8 раза быстрее остальных, которые идут ноздря в ноздрю. Магии get/set у него тоже хватает — наследуются из CComponent и переопределены в ActiveRecord. Разница в накладных: значения лежат в одном _attributes-массиве, без отдельной копии для отслеживания изменений и без кастинга типов. Плюс без отношений Yii1x вообще не трогает реляционный движок — findAll у него тонкая обёртка над PDO плюс цикл гидратации, а у Eloquent, Yii2 и Yiisoft билдер всегда идёт полным конвейером.
Отношения: картина начинает переворачиваться
Добавляем связи. И тут начинается самое интересное.
Простая many-many (товар → категории, 50 записей):
Eloquent | Yiisoft | Yii2 | Yii1x |
|---|---|---|---|
2.35 ms | 0.95 ms | 0.98 ms | 0.65 ms |
Yii1x снова первый, а Eloquent на many-many в 3.6 раза медленнее — его belongsToMany тянет за собой ощутимую обвязку.
Но перейдём к глубокой вложенности (заказ → покупатель → адреса, заказ → позиции → товар → категории → родитель, плюс hasOne payment/shipment и hasMany events). Ниже — жадная загрузка связей для выборки из 1000 заказов (limit => 1000); в ячейках — время / память:
сценарий (выборка 1000 заказов) | Eloquent | Yiisoft | Yii2 | Yii1x |
|---|---|---|---|---|
2 связи (customer + items) | 98.1 ms / 17.4 MB | 61.4 ms / 16.8 MB | 74.7 ms / 16.6 MB | 70.7 ms / 17.5 MB |
глубокая вложенность | 310.4 ms / 42.7 MB | 163.8 ms / 27.4 MB | 207.5 ms / 28.3 MB | 353.3 ms / 94.4 MB |
Вот он, перелом. На глубокой вложенности Yii1x из чемпиона превращается в аутсайдера: 353.3 ms против 163.8 ms у Yiisoft — в 2.2 раза. И это не случайность — про причину ниже.
Почему Yii1x взрывается на вложенности и что при этом с памятью
Мало того, что Yii1x тут последний по времени — он ещё и самый прожорливый по памяти, причём с большим отрывом. И память растёт почти линейно с размером выборки:
limit (заказов) | Yii1x, время | Yii1x, память |
|---|---|---|
100 | 32 ms | 12 MB |
250 | 91 ms | 26 MB |
500 | 172 ms | 49 MB |
1000 | 360 ms | 94 MB |
Причина — стратегия по умолчанию. Пока остальные грузят каждый уровень отношений отдельным узким запросом с WHERE IN, Yii1x вложенные hasMany/manyMany склеивает в один широкий JOIN. Для items.product.categories.parent это выглядит так (упрощённо):
FROM orders t
LEFT JOIN order_items items ON items.order_id = t.id
LEFT JOIN products product ON items.product_id = product.id
LEFT JOIN product_category pc ON product.id = pc.product_id
LEFT JOIN categories c ON c.id = pc.category_id
WHERE t.id IN (1, 2, 3, …, 1000)
Соединение даёт кросс-продукт. Один только этот запрос вернул 19 709 строк по 40 колонок — это ~38 MB сырых данных, а вместе с гидратацией объектов процесс доходит до 94 MB. Это не «утечка»: память честно занята материализацией результата, а queryAll() тянет все строки в массив PHP целиком, ещё до дедупликации в объекты. Причём колонки родительских таблиц дублируются в каждой строке — эти байты не только занимают память, но и летят по сети от базы к приложению. В моём замере SQLite живёт в том же процессе, так что сети нет; но на настоящем MySQL/PostgreSQL по TCP этот кросс-продукт ударит дважды — по памяти на стороне PHP и по трафику. Вдобавок даже раздельный запрос Yii1x всё равно начинается с orders JOIN …, то есть снова выбирает базовые строки.
И тут вторая цена той же архитектуры — уже не про скорость, а про функциональность. Раздельный запрос Yii1x выполняется на соединении родителя ($this->_parent->runQuery), поэтому таблица связи обязана лежать в БД родительской модели. Настоящий узкий SELECT … FROM order_items WHERE order_id IN (…) строится на соединении связанной модели — именно так грузят Eloquent, Yii2 и Yiisoft. Это (в отличие от JOIN и EXISTS) и позволяет держать связанные таблицы в разных БД, если у связанной модели задано отдельное соединение. У Yii1x межбазовых связей нет, исключение только lazy loading. Старость не в радость, время не обманешь.
Как это лечится. Параметром together => false — но не на верхнем уровне, а на каждом вложенном. Верхний уровень при наличии LIMIT и так грузится отдельным запросом (baseLimited), а вот вложенная цепочка по умолчанию джойнится:
Order::model()->with([
'items' => ['together' => false, 'with' => [
'product' => ['together' => false, 'with' => [
'categories' => ['together' => false, 'with' => [
'parent' => ['together' => false],
]],
]],
]],
])->findAll(['limit' => 1000]);
сценарий (выборка 1000 заказов) | время | память |
|---|---|---|
Eloquent | 310.4 ms | 42.7 MB |
Yiisoft | 163.8 ms | 27.4 MB |
Yii2 | 207.5 ms | 28.3 MB |
Yii1x, по умолчанию (JOIN) | 353.3 ms | 94.4 MB |
Yii1x, | 214.4 ms | 42.0 MB |
То есть Yii1x перестал быть абсолютным пылесосом по памяти: 94.4 → 42.0 MB (в 2.2 раза меньше), а по времени 353 → 214 ms (−39 %). До Yiisoft и Yii2 всё ещё далеко (27–28 MB, 164–207 ms), но из «дна» Yii1x выбрался на уровень Eloquent.
Скоупы: у кого есть, а у кого нет
Тут всплывает архитектурное различие. Нативные (named) скоупы есть только у Eloquent и Yii1. У Yii2 и Yiisoft их нет, поэтому в бенче условие вынесено в метод active() собственного query-класса — идиоматичный для них приём, а не костыль.
Стоят ли скоупы чего-то? Я сравнил один и тот же граф связей с одинаковым условием, записанным двумя способами — напрямую через where и через скоуп active(). По памяти результат совпал байт в байт (у Eloquent 22.4 MB, у Yiisoft 23.1 MB, у Yii2 22.6 MB, у Yii1x ~37 MB в обоих случаях), а по времени разница меньше разброса между прогонами. То есть механизм скоупов не стоит ничего — он навешивает то же самое условие.
Скоупы — это про удобство, а не про производительность.
Сложный запрос: сборка против выполнения
Последний раунд — сложный запрос с вложенными условиями по отношениям. Тут я разделил сборку SQL (без обращения к базе) и выполнение:
на одну операцию | Eloquent | Yiisoft | Yii2 | Yii1x |
|---|---|---|---|---|
сборка SQL | 193 μs | 56 μs | 49 μs | 86 μs |
сборка + выполнение | 1.08 ms | 1.07 ms | 1.07 ms | 0.74 ms |
Тут нужна важная оговорка. Запрос я строил идиоматично для каждого ORM, а идиомы разные: Eloquent (whereHas) и Yii1x (whereRelation) собирают вложенные EXISTS-подзапросы, а Yii2 и Yiisoft (joinWith) — INNER JOIN. Так что здесь сравниваются не только ORM, но и две разные стратегии.
Поэтому честное сравнение — «EXISTS против EXISTS», то есть Eloquent против Yii1x. И тут Eloquent всё равно собирает запрос в 2.2 раза дольше (193 против 86 μs): его whereHas с вложенными замыканиями дороже. А на выполнении разница стирается — у всех ~1 ms, потому что почти всё время съедает база. Сборка — это 4–18 % от общего времени: меньше всего у Yii2, больше всего у Eloquent.
Вывод для тех, кто гоняет десятки тысяч коротких запросов: у Eloquent накладные на сборку могут быть ощутимы. Для всех остальных — неважно.
JOIN против WHERE IN: фича, которую все выпилили
Под конец — маленькое открытие. Единственный, кто умеет по-настоящему жадно загружать hasMany из JOIN-результата (одним запросом, с заполнением связей) — это Yii1 через together(). Yii2 и Yiisoft при joinWith(eager=true) всё равно догружают связи отдельным запросом (в докблоке Yii2 так прямо и написано), а Eloquent JOIN-eager не умеет вовсе.
Но вот что интересно: эта фича не даёт выигрыша — наоборот. У Yii1x JOIN-eager (96.4 ms, 30.1 MB) заметно тяжелее, чем честная загрузка отдельным запросом WHERE IN (58.9 ms, 16.5 MB): почти в 1.6 раза медленнее и почти вдвое прожорливее по памяти. Современные ORM выпилили JOIN-eager не из вредности — из-за того самого кросс-продукта и вспышек памяти, о которых я писал выше. А у остальных узкий WHERE IN — не оптимизация ради оптимизации, а ещё и условие для межбазовых связей.
Итоговая таблица
Сценарий | 1-е место | 2-е место | 3-е место | последнее |
|---|---|---|---|---|
CRUD-чтение | Yii1x | Yiisoft | Yii2 | Eloquent |
Гидрация | Yii1x | Yiisoft / Yii2 / Eloquent (вровень) | — | — |
Простые связи | Yii1x | Yii2 | Yiisoft | Eloquent |
Глубокая вложенность | Yiisoft | Yii2 | Eloquent | Yii1x |
Сборка SQL | Yii2 | Yiisoft | Yii1x | Eloquent |
Выполнение сложного SQL | Yii1x | Yiisoft | Yii2 | Eloquent |
Память (база) | Yii1x | Yiisoft | Yii2 | Eloquent |
Память (вложенность) | Yiisoft | Yii2 | Eloquent | Yii1x |
Что из этого следует
Универсально быстрого ORM нет — но начнём с Eloquent, на нём пишет большинство. В этом забеге он самый тяжёлый, и это не баг, а осознанная цена: Eloquent тащит больше механики на каждом шаге. Зато экосистема у него самая большая. Если у вас read-heavy места — сужайте select(), вешайте условия прямо в with(), а где объекты не нужны, берите toBase() или DB::table(). 2.8 MB базовой памяти и ×2.4 на findByPk — цена, которую большинство команд платит осознанно.
Yii2 на этом фоне — середняк, который неожиданно берёт один раунд: сложный SQL он собирает быстрее всех (49 µs). Плюс with() с замыканиями-условиями, joinWith, via для связей через промежуточную таблицу и asArray(), когда объекты не нужны.
Yiisoft — наоборот: на простом он второй-третий (CRUD, простые связи), зато ровно там, где всем тяжело, идёт впереди — глубокая вложенность, скоупы, joinWith — и по памяти стабильно в топе. Типизированные свойства, явный relationQuery(), аккуратный ActiveQuery — чувствуется, что писали под PHP 8.4, а не переносили из 2008-го.
Ну а Yii1x берёт «дешёвое и частое» — CRUD, гидратацию, простые связи — и спотыкается на глубоком: один широкий JOIN, кросс-продукт, до 94 MB памяти, и всё это в пределах одной БД. together => false возвращает его в игру (94 → 42 MB). Межбазовых связей нет, возможности ограничены — без переосмысления архитектуры это не более чем инструмент миграции с Yii1.
А что выберете вы?
Мы замерили производительность — только в самых распространённых сценариях. Но выбор ORM этим не ограничивается: есть ещё экосистема, документация, миграции, инструменты, порог входа и поддержка сообщества. И тут расклад совсем другой — например, по экосистеме и удобству Eloquent явный фаворит и бесспорный лидер, хотя по сырым цифрам в этой статье он чаще в хвосте.
И ещё, чего в цифрах не видно. Насколько мне не изменяет память, Laravel не меняет правила игры на ходу: обновляться между мажорами спокойно — на каждый релиз есть официальный upgrade guide и внятная политика deprecations. Однажды, ещё неопытным джуном, я обновил проект с Laravel 7 до 10 минут за двадцать. Про Yii такого не скажу: Yii2 → Yii3 — это не апгрейд, а полное переписывание, официального пути миграции нет. Так что да, это камень в огород Yii — и в первую очередь Yiisoft.
Идей для новых замеров у меня ещё вагон, но чужие всегда интереснее. Пишите, что прогнать в следующий раз. Только чур сценарий, который тянут все четыре ORM, — иначе сравнивать не с чем.
Все замеры — SQLite, PHP 8.4, 25 000 заказов / ~249 000 позиций / 1 000 товаров. Полный код бенчмарка и данные — в репозитории. Числа в таблицах — мода (mode) по phpbench.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.