TorrentPier 3.0: переход на Eloquent ORM

Exile

Administrator
Привет!

В этой статье я хочу подробно рассказать о том, почему мы решили внедрить 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 одинаковых запросов по всему коду
2. Nette Database - prepared statements и fluent builder:
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();
Nette Database - отличная библиотека, пускай мы ее и не успели внедрить до конца. Но у нее нет одной важной вещи - моделей.

Что такое 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 BuilderCollection<stdClass>ВысокаяFluent API
Eloquent ORMCollection<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 SQL377.4baseline
Nette Raw380.8+0.9%
Nette Explorer372.4-1.3%
Laravel QB364.5-3.4%
Eloquent ORM364.0-3.6%
Вывод: На тяжелых COUNT-запросах разницы нет. Более того, Eloquent оказался даже чуть быстрее Legacy! Это потому что 99.9% времени тратится на MySQL, а не на PHP.

Тест 2: COUNT с фильтром по роли​

SQL:
SELECT COUNT(*) FROM bb_users WHERE user_id NOT IN (1) AND user_level = 0
МетодВремя (мс)vs Legacy
Legacy Raw SQL186.7baseline
Nette Raw189.6+1.6%
Nette Explorer190.2+1.9%
Laravel QB190.8+2.2%
Eloquent ORM190.9+2.2%
Вывод: Разница в пределах погрешности. На запросах, где MySQL делает реальную работу, ORM overhead не заметен.

Тест 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 SQL0.220baseline
Nette Raw0.208-5.7%
Nette Explorer0.233+5.7%
Laravel QB0.234+6.5%
Eloquent ORM0.380+72.8%
Eloquent toBase0.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 SQL44.0baseline
Nette Raw43.7-0.8%
Nette Explorer45.7+3.8%
Laravel QB45.4+3.2%
Eloquent ORM45.2+2.6%
Eloquent toBase44.3+0.6%

Вывод: На тяжелых запросах с большим offset разницы практически нет. MySQL тратит ~44ms на сканирование 10000 строк, а гидратация 50 объектов - это доли миллисекунды.

Почему Eloquent медленнее (и почему это нормально)​

Eloquent при вызове ->get() делает следующее:
  1. Выполняет SQL-запрос (как и все остальные)
  2. Создает объект модели для каждой строки (гидратация)
  3. Заполняет атрибуты с учетом casts, mutators, appends
  4. Применяет $hidden/$visible для сериализации
  5. Возвращает Collection
Шаги 2-4 - это и есть overhead. Для 50 записей это ~0.16ms. Для 1000 записей - пропорционально больше.

Когда это критично​

  • High-frequency API endpoints (тысячи запросов в секунду)
  • Выборки тысяч записей за раз
  • Real-time системы с жесткими требованиями к latency

Когда это не критично (99% случаев)​

  • Веб-страницы с временем генерации 50-500ms
  • Админки и CMS
  • API с разумным rate limiting
  • Фоновые задачи
Для TorrentPier типичная страница генерируется за 100-300ms. Добавить 0.16ms на гидратацию 50 пользователей - это +0.05% к общему времени. Незаметно.

Что мы получаем взамен​

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 (...))
Eloquent автоматически предотвращает N+1 через eager loading. А в dev-режиме может даже выбрасывать исключение при lazy loading:
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!
Observers срабатывают при любом изменении модели - через админку, через API, через консольные команды. Невозможно "забыть" синхронизировать.

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();
Вместо copy-paste одинаковых WHERE-условий по всему коду - один scope в модели.

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 SQLEloquent ORM
Скорость COUNTbaseline-4% (быстрее!)
Скорость SELECTbaseline+73% (медленнее)
С toBase()baseline+5%
ТипизацияНетПолная
IDE supportНетAutocomplete
RelationsРучные JOINEager loading
СинхронизацияВручнуюObservers
SQL injectionРискЗащита
Готовность к LaravelНетДа

Главный вывод: Eloquent медленнее на ~0.16ms при выборке 50 записей. Это цена за типобезопасность, автоматическую синхронизацию с поисковым индексом, предотвращение N+1 проблем и подготовку к миграции на Laravel.

Для проекта типа TorrentPier, где страница генерируется за 100-300ms, эти 0.16ms - это шум. А преимущества - реальны и измеримы в часах разработки и количестве багов.
 
Back
Top