Exile
Administrator
Привет!
В этой статье я хочу подробно рассказать о том, почему мы решили внедрить Eloquent ORM в TorrentPier, какие преимущества это дает, и - самое главное - честно показать результаты бенчмарков производительности. Без маркетинговой чуши, с реальными цифрами на базе в 1.5 миллиона пользователей.
1. Legacy Raw SQL - прямая конкатенация строк:
Проблемы очевидны:
Nette Database - отличная библиотека, пускай мы ее и не успели внедрить до конца. Но у нее нет одной важной вещи - моделей.
Четвертый вариант -
Вывод: На тяжелых COUNT-запросах разницы нет. Более того, Eloquent оказался даже чуть быстрее Legacy! Это потому что 99.9% времени тратится на MySQL, а не на PHP.
Вывод: Разница в пределах погрешности. На запросах, где MySQL делает реальную работу, ORM overhead не заметен.
Вот оно! Eloquent ORM на 73% медленнее Legacy на SELECT-запросах. Но - и это важно -
Вывод: На тяжелых запросах с большим offset разницы практически нет. MySQL тратит ~44ms на сканирование 10000 строк, а гидратация 50 объектов - это доли миллисекунды.
В реальном проекте с десятками таблиц и сотнями полей это экономит часы на отладке.
Eloquent автоматически предотвращает N+1 через eager loading. А в dev-режиме может даже выбрасывать исключение при lazy loading:
Observers срабатывают при любом изменении модели - через админку, через API, через консольные команды. Невозможно "забыть" синхронизировать.
Вместо copy-paste одинаковых WHERE-условий по всему коду - один scope в модели.
Главный вывод: Eloquent медленнее на ~0.16ms при выборке 50 записей. Это цена за типобезопасность, автоматическую синхронизацию с поисковым индексом, предотвращение N+1 проблем и подготовку к миграции на Laravel.
Для проекта типа TorrentPier, где страница генерируется за 100-300ms, эти 0.16ms - это шум. А преимущества - реальны и измеримы в часах разработки и количестве багов.
В этой статье я хочу подробно рассказать о том, почему мы решили внедрить Eloquent ORM в TorrentPier, какие преимущества это дает, и - самое главное - честно показать результаты бенчмарков производительности. Без маркетинговой чуши, с реальными цифрами на базе в 1.5 миллиона пользователей.
Что было раньше
TorrentPier ранее использовал два способа работы с базой данных:1. Legacy Raw SQL - прямая конкатенация строк:
PHP:
$sql = "SELECT * FROM bb_users WHERE user_id = $user_id";
$result = DB()->fetch_row($sql);
- SQL-инъекции при недостаточной валидации
- Нет автодополнения в IDE
- Опечатка в названии поля - узнаем только в рантайме
- Copy-paste одинаковых запросов по всему коду
PHP:
// Raw запрос с параметрами
$row = DB()->connection->query('SELECT * FROM bb_users WHERE user_id = ?', $userId)->fetch();
// Fluent builder (Explorer)
$users = DB()->table('users')->where('user_level', 0)->limit(50)->fetchAll();
Что такое Eloquent и зачем он нужен
Eloquent - это ORM (Object-Relational Mapping) из Laravel. Главная идея - каждая таблица в базе представлена PHP-классом (моделью), а каждая строка - объектом этого класса.
PHP:
// Модель
class User extends Model
{
protected $table = 'users'; // bb_users с префиксом
protected $primaryKey = 'user_id';
// Отношение: у пользователя много постов
public function posts()
{
return $this->hasMany(Post::class, 'poster_id', 'user_id');
}
}
// Использование
$user = User::find(2); // SELECT * FROM bb_users WHERE user_id = 2
$user->username; // IDE знает что это string
$posts = $user->posts()->limit(10)->get(); // Автоматический JOIN
Почему Taylor Otwell (создатель Laravel) сделал Eloquent
Eloquent построен на паттерне Active Record - каждая модель "знает" как себя сохранить, загрузить, удалить. Это не единственный способ работы с БД (есть Data Mapper, Repository pattern и другие), но для большинства задач - самый продуктивный."Я хотел, чтобы разработка была творческим процессом, а не рутиной. Когда разработчики получают удовольствие от работы, качество кода значительно улучшается."
Три уровня абстракции
В Laravel (и теперь в TorrentPier) есть три способа работы с базой:| Уровень | Что возвращает | Скорость | Возможности |
|---|---|---|---|
| Raw SQL | массив/stdClass | Максимум | Минимум |
| Query Builder | Collection<stdClass> | Высокая | Fluent API |
| Eloquent ORM | Collection<Model> | Ниже | Максимум |
PHP:
// 1. Raw SQL - максимальная скорость, минимум удобств
$users = DB::select('SELECT * FROM bb_users WHERE user_level = ?', [0]);
// 2. Query Builder - fluent API без моделей
$users = Capsule::table('users')->where('user_level', 0)->get();
// 3. Eloquent ORM - полные возможности
$users = User::where('user_level', 0)->get();
// 4. Eloquent без гидратации - скорость QB + синтаксис Eloquent
$users = User::where('user_level', 0)->toBase()->get();
Четвертый вариант -
toBase() - это компромисс. Ты пишешь код через модель (используя scopes, relations для построения запроса), но получаешь stdClass вместо объектов модели. Это убирает overhead гидратации.
Бенчмарки: честные цифры
Хватит теории - давайте смотреть цифры. Я написал бенчмарк, который сравнивает 6 способов выполнения типичных запросов для memberlist (список пользователей):- Legacy Raw SQL - старый способ через конкатенацию
- Nette Raw - prepared statements через Nette Database
- Nette Explorer - fluent builder от Nette
- Laravel QB - Query Builder через
Capsule::table() - Eloquent ORM - полноценные модели с гидратацией
- Eloquent toBase - Eloquent query builder без гидратации
Тестовая среда
Code:
PHP: 8.4.14
MySQL: 9.4.0
Записей в bb_users: 1,500,002
Итераций: 30 (+ 5 warmup)
Тест 1: COUNT всех пользователей
Простой запрос:
SQL:
SELECT COUNT(*) FROM bb_users WHERE user_id NOT IN (1)
| Метод | Время (мс) | vs Legacy |
|---|---|---|
| Legacy Raw SQL | 377.4 | baseline |
| Nette Raw | 380.8 | +0.9% |
| Nette Explorer | 372.4 | -1.3% |
| Laravel QB | 364.5 | -3.4% |
| Eloquent ORM | 364.0 | -3.6% |
Тест 2: COUNT с фильтром по роли
SQL:
SELECT COUNT(*) FROM bb_users WHERE user_id NOT IN (1) AND user_level = 0
| Метод | Время (мс) | vs Legacy |
|---|---|---|
| Legacy Raw SQL | 186.7 | baseline |
| Nette Raw | 189.6 | +1.6% |
| Nette Explorer | 190.2 | +1.9% |
| Laravel QB | 190.8 | +2.2% |
| Eloquent ORM | 190.9 | +2.2% |
Тест 3: SELECT первой страницы (50 записей)
Вот тут начинается интересное:
SQL:
SELECT username, user_id, ... FROM bb_users WHERE user_id NOT IN (1)
ORDER BY user_regdate DESC LIMIT 50
| Метод | Время (мс) | vs Legacy |
|---|---|---|
| Legacy Raw SQL | 0.220 | baseline |
| Nette Raw | 0.208 | -5.7% |
| Nette Explorer | 0.233 | +5.7% |
| Laravel QB | 0.234 | +6.5% |
| Eloquent ORM | 0.380 | +72.8% |
| Eloquent toBase | 0.231 | +4.8% |
Вот оно! Eloquent ORM на 73% медленнее Legacy на SELECT-запросах. Но - и это важно -
toBase() убирает этот overhead до 5%.Тест 4: SELECT с большим offset (глубокая пагинация)
SQL:
SELECT ... FROM bb_users WHERE user_id NOT IN (1)
ORDER BY user_regdate DESC LIMIT 50 OFFSET 10000
| Метод | Время (мс) | vs Legacy |
|---|---|---|
| Legacy Raw SQL | 44.0 | baseline |
| Nette Raw | 43.7 | -0.8% |
| Nette Explorer | 45.7 | +3.8% |
| Laravel QB | 45.4 | +3.2% |
| Eloquent ORM | 45.2 | +2.6% |
| Eloquent toBase | 44.3 | +0.6% |
Вывод: На тяжелых запросах с большим offset разницы практически нет. MySQL тратит ~44ms на сканирование 10000 строк, а гидратация 50 объектов - это доли миллисекунды.
Почему Eloquent медленнее (и почему это нормально)
Eloquent при вызове->get() делает следующее:- Выполняет SQL-запрос (как и все остальные)
- Создает объект модели для каждой строки (гидратация)
- Заполняет атрибуты с учетом casts, mutators, appends
- Применяет
$hidden/$visibleдля сериализации - Возвращает Collection
Когда это критично
- High-frequency API endpoints (тысячи запросов в секунду)
- Выборки тысяч записей за раз
- Real-time системы с жесткими требованиями к latency
Когда это не критично (99% случаев)
- Веб-страницы с временем генерации 50-500ms
- Админки и CMS
- API с разумным rate limiting
- Фоновые задачи
Что мы получаем взамен
1. Типизация и IDE support
PHP:
// Legacy - IDE ничего не знает
$user = DB()->fetch_row("SELECT * FROM bb_users WHERE user_id = 2");
$user['usrename']; // Опечатка, узнаем в рантайме
// Eloquent - полный autocomplete
$user = User::find(2);
$user->usrename; // IDE сразу подсветит ошибку
$user->user_id; // IDE знает тип: int
$user->username; // IDE знает тип: string
2. Отношения (Relations)
Классическая проблема N+1:
PHP:
// Legacy - N+1 запросов
$topics = DB()->fetch_rowset("SELECT * FROM bb_topics LIMIT 25");
foreach ($topics as $topic) {
// +1 запрос на каждую тему!
$poster = DB()->fetch_row("SELECT * FROM bb_users WHERE user_id = {$topic['topic_poster']}");
echo $poster['username'];
}
// Итого: 1 + 25 = 26 запросов
PHP:
// Eloquent - eager loading
$topics = Topic::with('poster')->limit(25)->get();
foreach ($topics as $topic) {
echo $topic->poster->username; // Уже загружено!
}
// Итого: 2 запроса (темы + пользователи WHERE user_id IN (...))
PHP:
Model::preventLazyLoading(true); // В development
3. Observers для автоматической синхронизации
Это одна из причин внедрения Eloquent в TorrentPier. Например, уже сейчас у нас есть ManticoreSearch для полнотекстового поиска, и его нужно синхронизировать с MySQL.
PHP:
// Без Eloquent - ручная синхронизация везде
DB()->query("UPDATE bb_topics SET topic_title = '...' WHERE topic_id = 123");
$manticore->updateTopic(123, $newTitle); // Легко забыть!
PHP:
// С Eloquent - автоматически через Observer
class TopicObserver
{
public function updated(Topic $topic)
{
if ($topic->wasChanged('topic_title')) {
$this->manticore->updateTopic($topic->topic_id, $topic->topic_title);
}
}
public function deleted(Topic $topic)
{
$this->manticore->deleteTopic($topic->topic_id);
}
}
// Теперь синхронизация происходит автоматически
$topic->update(['topic_title' => 'New Title']);
// Observer сам вызовет ManticoreSearch!
4. Scopes - переиспользуемые фильтры
PHP:
class User extends Model
{
// Scope для активных пользователей
public function scopeActive($query)
{
return $query->where('user_active', 1)
->where('user_level', '>=', 0);
}
// Scope для пользователей с постами
public function scopeWithMinPosts($query, int $min = 10)
{
return $query->where('user_posts', '>=', $min);
}
// Scope для недавно активных
public function scopeRecentlyActive($query, int $days = 30)
{
return $query->where('user_lastvisit', '>=', time() - $days * 86400);
}
}
// Использование - чистый, читаемый код
$activePosters = User::active()
->withMinPosts(100)
->recentlyActive(7)
->get();
5. Casts - автоматическое приведение типов
PHP:
class User extends Model
{
protected $casts = [
'user_regdate' => 'datetime',
'user_opt' => 'integer',
'user_active' => 'boolean',
'user_permissions' => 'array', // JSON в базе -> array в PHP
];
}
// Автоматические преобразования
$user->user_regdate; // Carbon instance, не timestamp
$user->user_regdate->diffForHumans(); // "2 дня назад"
$user->user_active; // true/false, не 1/0
$user->user_permissions; // array, не JSON string
6. Подготовка к Laravel (версия 4.0)
TorrentPier 4.0 планируется как полноценное Laravel-приложение. Внедряя Eloquent-модели сейчас, мы:- Создаем структуру, совместимую с Laravel
- Пишем код, который перенесется без изменений
- Обучаем команду работе с Eloquent
- Находим и исправляем проблемы архитектуры заранее
Практические рекомендации
Когда использовать полный Eloquent (с гидратацией)
- Нужны отношения (relations)
- Нужны observers для событий
- Нужны mutators/accessors
- Данные будут модифицироваться
- Выборка до ~100-200 записей
PHP:
// Получить пользователя с его постами и темами
$user = User::with(['posts', 'topics'])->find($userId);
// Создать тему (Observer синхронизирует ManticoreSearch)
$topic = Topic::create([
'forum_id' => $forumId,
'topic_title' => $title,
'topic_poster' => $userId,
]);
Когда использовать toBase() (без гидратации)
- Только чтение данных
- Не нужны relations
- Большие выборки (100+ записей)
- Данные идут напрямую в шаблон/JSON
PHP:
// Список пользователей для memberlist
$users = User::whereNotIn('user_id', $excludedIds)
->select(['username', 'user_id', 'user_regdate', 'user_posts'])
->orderBy('user_regdate', 'DESC')
->limit(50)
->toBase() // Возвращает stdClass вместо User
->get();
Когда использовать Query Builder напрямую
- Сложные запросы с подзапросами
- Запросы к таблицам без моделей
- Bulk-операции на тысячах записей
PHP:
// Массовое обновление без событий
Capsule::table('users')
->where('user_lastvisit', '<', time() - 86400 * 365)
->update(['user_active' => 0]);
Когда использовать Raw SQL
- Специфичные для MySQL фичи (MATCH AGAINST, etc.)
- Экстремальная оптимизация
- Legacy-код в процессе миграции
PHP:
// Полнотекстовый поиск MySQL
$results = DB()->fetch_rowset("
SELECT *, MATCH(post_text) AGAINST(? IN BOOLEAN MODE) as score
FROM bb_posts_text
WHERE MATCH(post_text) AGAINST(? IN BOOLEAN MODE)
ORDER BY score DESC
LIMIT 100
", [$searchQuery, $searchQuery]);
Итоги
| Критерий | Legacy SQL | Eloquent ORM |
|---|---|---|
| Скорость COUNT | baseline | -4% (быстрее!) |
| Скорость SELECT | baseline | +73% (медленнее) |
| С toBase() | baseline | +5% |
| Типизация | Нет | Полная |
| IDE support | Нет | Autocomplete |
| Relations | Ручные JOIN | Eager loading |
| Синхронизация | Вручную | Observers |
| SQL injection | Риск | Защита |
| Готовность к Laravel | Нет | Да |
Главный вывод: Eloquent медленнее на ~0.16ms при выборке 50 записей. Это цена за типобезопасность, автоматическую синхронизацию с поисковым индексом, предотвращение N+1 проблем и подготовку к миграции на Laravel.
Для проекта типа TorrentPier, где страница генерируется за 100-300ms, эти 0.16ms - это шум. А преимущества - реальны и измеримы в часах разработки и количестве багов.