ESPN DeportesRafael Márquez: No tenemos la materia prima para jugar como BarcelonaCNN Türkİstanbul’da Kültür Yolu heyecanı: 400’den fazla etkinlikle sanat şöleni başladıESPNGoals galore! Why Barça's start to season is best in Europe's top leagues for almost 100 yearsThe Jerusalem PostSukkot 2026: The empty chair is gone, but the threat of Hamas and Iran remains - opinionPunchFULL LIST: Ronaldo extends record as Europe’s most-capped international, 14 others한겨레[속보] 한국 여자농구 일본 꺾고 12년 만에 ‘아시아 정상’ColliderPrime Video’s Billion-Dollar Fantasy Epic Officially Returns This YearХабрЧем заменить ChatGPT Plus в 2026 году: 9 альтернатив, цены и возможностиCapital FMPolice raid alleged phone-hacking syndicate in Kisumu, arrest twoVarietyFinnish Hit ‘100 Litres of Gold’ Gets Another Round With Sequel ‘200 Litres of Gold’ That’s a ‘Little Bit Darker, a Little Bit Funnier and a Little Bit Bigger’ (EXCLUSIVE)الشرقتوقيف رجلين بعد خروجهما من فتحة صرف قرب فندق نتنياهو في نيويوركGuardian SportKohli announces next World Cup will be his last; County Championship title race, third day – live
The Daily Newsstand · Free, Always
Saturday, September 26, 2026

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

Translate

В прошлый раз я вытащил 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, together => false

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.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.