В прошлый раз я вытащил 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 tLEFT JOIN order_items items ON items.order_id = t.idLEFT JOIN products product ON items.product_id = product.idLEFT JOIN product_category pc ON product.id = pc.product_idLEFT JOIN categories c ON c.id = pc.category_idWHERE 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.
ссылка на оригинал статьи https://habr.com/ru/articles/1086910/