
Наша панель для управления серверами хранит метрики в таблице server_metrics. В октябре в ней было 2,26 млн строк, и на проде страницы со списком серверов открывались по несколько секунд. Виноватым оказался один запрос. С подставленными значениями он выполнялся за 4–8 мс, а с биндингами, как его отправляет приложение, за 2,3–3,3 секунды.
Локально и в тестах этого видно не было. Тесты гоняются на SQLite, а в локальной базе мало данных.

Откуда запрос
Последняя метрика каждого сервера доставалась через latestOfMany:
public function latestMetric() { return $this->hasOne(ServerMetric::class)->latestOfMany(); }
На странице со списком серверы грузятся вместе с ней через with('latestMetric'). Для такой жадной загрузки (eager loading) Laravel строит подзапрос с MAX и GROUP BY server_id, а id всех серверов со страницы уходят в where server_id in (...). На проде latestOfMany мерили и по id, и по created_at. Оба варианта стоили от 0,5 до 2,3 секунды на каждую загрузку.
Если убрать обвязку Laravel, остается вот что:
select max(created_at), server_id from server_metrics where server_id in (?, ?, ?) group by server_id
На таблице есть индекс (server_id, created_at). Для такого запроса MySQL умеет делать loose index scan. Для каждого server_id он берет из индекса одну крайнюю запись и остальные метрики сервера не читает. В EXPLAIN это видно по Using index for group-by
Где теряется loose index scan
С подставленными значениями все так и работает. Приложение отправляет запрос по‑другому. В Laravel эмуляция prepared statements по умолчанию выключена, это видно в Illuminate/Database/Connectors/Connector.php:
protected $options = [ PDO::ATTR_CASE => PDO::CASE_NATURAL, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, PDO::ATTR_STRINGIFY_FETCHES => false, PDO::ATTR_EMULATE_PREPARES => false, ];
PDO отправляет на сервер текст запроса с вопросительными знаками, а значения передает отдельно. С вопросительными знаками внутри IN оптимизатор loose index scan уже не выбирает.
Это видно прямо в консоли MySQL. Для проверки поднят стенд, MySQL 8.0.46 в Docker и таблица на 2,25 млн строк, 50 серверов по 45 000 метрик. Сначала значения через пользовательские переменные, потом тот же запрос через PREPARE с плейсхолдерами:
SELECT id INTO @a FROM servers ORDER BY id LIMIT 0, 1; SELECT id INTO @b FROM servers ORDER BY id LIMIT 1, 1; EXPLAIN SELECT MAX(created_at), server_id FROM server_metrics WHERE server_id IN (@a, @b) GROUP BY server_id; -- type: range, rows: 399, Extra: Using where; Using index for group-by PREPARE s FROM 'EXPLAIN SELECT MAX(created_at), server_id FROM server_metrics WHERE server_id IN (?, ?) GROUP BY server_id'; EXECUTE s USING @a, @b; -- type: range, rows: 181170, Extra: Using where; Using index
Во втором плане Using index for group-by пропал, и MySQL проходит все метрики обоих серверов подряд.
Сам PREPARE тут ни при чем. Если подготовить через него запрос, где значения вписаны в текст, loose index scan остается. Не мешает и один плейсхолдер, IN (?) с одним значением и server_id = ? тоже получают Using index for group-by. План ломается, когда в IN два значения и больше. При 40 и 50 значениях MySQL уже проходит индекс целиком, в EXPLAIN это type: index и 2,24 млн строк.
По времени на стенде выходит 2 мс со значениями в тексте и 1,6 секунды с плейсхолдерами. Разница в 800 раз. С включенной эмуляцией PDO сам вписывает значения в текст, и запрос снова идет за 2 мс. На MySQL 9.2 картина такая же.
Похожий случай в баг‑трекере MySQL есть, это баг #115227. Там запрос через prepared statement вместо range scan шел по ref, читал 123 тысячи строк и выполнялся 270 мс вместо 0,26. Его исправили в MySQL 9.1, а план с плейсхолдерами в IN на 9.2 все тот же. Отчета именно про этот случай я не нашел.
Почему с целыми id этого не видно
Если ключи у моделей целые, Laravel обходит проблему сам. Как подставлять id в жадную загрузку, решает метод в Illuminate/Database/Eloquent/Relations/Relation.php:
protected function whereInMethod(Model $model, $key) { return $model->getKeyName() === last(explode('.', $key)) && in_array($model->getKeyType(), ['int', 'integer']) ? 'whereIntegerInRaw' : 'whereIn'; }
Целые ключи вписываются прямо в текст запроса через whereIntegerInRaw, строковые уходят биндингами. У серверов в панели ключи UUID, поэтому запрос и попал под плейсхолдеры.

На том же стенде два одинаковых отношения hasOne()->latestOfMany('created_at')У одного родитель с UUID, у другого с целым id. Жадная загрузка 50 серверов заняла 1 643 мс с 50 биндингами против 7 мс без единого биндинга
Под удар попадают приложения на MySQL, где у моделей UUID или ULID, а на большой таблице стоит latestOfMany, oldestOfMany или ofMany. То же касается любого своего запроса с whereIn на несколько значений, groupBy и max или min
Как проверить у себя
Проще всего взять свой запрос с GROUP BY и сравнить два EXPLAIN, как выше. Значения сначала через SET @a, потом тот же текст через PREPARE и EXECUTE ... USING. Если Using index for group-by есть только в первом, приложение работает по второму плану.
Мерить на проде тоже надо с биндингами. EXPLAIN с подставленными значениями покажет хороший план, которого у приложения нет. Стенд из статьи собирается так:
Скрипт стенда
CREATE TABLE servers (id CHAR(36) PRIMARY KEY); CREATE TABLE server_metrics ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, server_id CHAR(36) NOT NULL, cpu DECIMAL(5,2) NOT NULL, created_at TIMESTAMP NOT NULL, KEY server_metrics_server_id_created_at_index (server_id, created_at) ); INSERT INTO servers SELECT UUID() FROM (WITH RECURSIVE n AS (SELECT 1 i UNION ALL SELECT i + 1 FROM n WHERE i < 50) SELECT i FROM n) t; SET SESSION cte_max_recursion_depth = 100000; INSERT INTO server_metrics (server_id, cpu, created_at) SELECT s.id, RAND() * 100, NOW() - INTERVAL n.i MINUTE FROM servers s CROSS JOIN (WITH RECURSIVE n AS (SELECT 1 i UNION ALL SELECT i + 1 FROM n WHERE i < 45000) SELECT i FROM n) n; ANALYZE TABLE server_metrics;
Что сделали
Последний id метрики теперь лежит у самого сервера, в servers.latest_metric_id. Его обновляет хук created у метрики, а latestMetric() стал обычным belongsTo:
// ServerMetric protected static function booted(): void { static::created(function (ServerMetric $metric): void { Server::whereKey($metric->server_id)->toBase()->update(['latest_metric_id' => $metric->id]); }); } // Server public function latestMetric(): BelongsTo { return $this->belongsTo(ServerMetric::class, 'latest_metric_id'); }
toBase() здесь нужен, чтобы на каждую метрику не срабатывали журнал изменений, обсерверы и updated_at у сервера. Выборка по первичному ключу с биндингами на проде идет за 1–3 мс. Список серверов после выкатки открывается за 0,23 секунды вместо 1,7

Можно было включить ATTR_EMULATE_PREPARES, с ней запрос снова идет за 2 мс. Тогда до эмуляции не додумались. Это выяснилось уже на стенде для статьи, а указатель к тому времени работал на проде.
Как замер уронил прод на 13 минут
Чтобы увидеть разницу на проде, запрос гоняли через PDO с биндингами. Один такой пробный запрос по server_metrics отменили, но отменилась только задача, которая его отправила. В MySQL он проработал еще 93 минуты. В processlist он висел с command = Execute, потому что пришел как prepared statement. Искали его среди command = 'Query'
В ту же ночь выкатывалась миграция с latest_metric_id, то есть ALTER TABLE servers. Пробный запрос читал другую таблицу, но у server_metrics внешний ключ на servers. В документации MySQL про metadata locks так и написано, при изменении родительской таблицы блокировка берется и на дочернюю. ALTER встал ждать, за ним в очередь встали все запросы к servers, и с 00:36 до 00:49 панель не отвечала, пока поток с пробным запросом не сняли через KILL

После этого пробные SELECT на проде идут только с хинтом
/*+ MAX_EXECUTION_TIME(5000) */
через 5 секунд MySQL обрывает их сам. А после отмены processlist проверяется по времени, без фильтра по типу команды:
SELECT id, command, time, LEFT(info, 80) AS query FROM information_schema.processlist WHERE command NOT IN ('Sleep', 'Daemon') AND time > 20;
И перед деплоем с ALTER по таблице, на которую ссылаются внешние ключи, лучше посмотреть, нет ли долгих запросов к дочерним таблицам.

А у вас прод ронял какой‑нибудь чудо‑запрос? Расскажите в комментариях, как это было и чем кончилось.

