Используйте EXISTS вместо COUNT при проверке существования записей

Источник: «Use EXISTS instead of COUNT > 0 when checking if records exist»
TL;DR Используйте EXISTS при проверке наличия записей в SQL вместо COUNT. EXISTS гораздо эффективнее и выходит из цикла, когда найдена первая запись.

Использование COUNT

До недавнего времени, когда требовалось проверить, существует ли в БД запись, удовлетворяющая некоторым условиям, я использовал COUNT, а затем проверял, больше ли полученный результат чем 0.

Обычный SQL-запрос:

SELECT COUNT(*)
FROM `post_likes`
WHERE `member_id` = 1
AND `post_id` = 1

В MySQL COUNT(*) оптимизирован, и он быстрее и эффективнее, чем, например, COUNT(id).

Запрос в Laravel Eloquent (с использованием отношения postLikes):

// Понравилось ли пользователю сообщение
$member->postLikes()->count() > 0;

Обратите внимание на скобки после имени отношения postLikes(). Они означают, что мы используем это отношение для создания запроса к связанной таблице и установки внешнего ключа для нас. Если бы мы использовали $member->postLikes->count() без скобок, мы бы получили все связанные записи, а затем произвели подсчёт. Это привело бы к более дорогостоящему запросу к БД, а также к увеличению объёма используемой памяти, поскольку все эти записи должны быть сохранены в памяти.

Это "заставит" БД перебрать все записи, удовлетворяющие этим условиям. И если ваша таблица достаточно велика, это может занять некоторое время. Конечно, возможно, в миллисекундах, но всё равно это будет лишняя работа, так как она не знает, что вас интересует только "существование" записи, а не точный подсчёт.

Конечно, если таблицу "правильно" проиндексировать и использовать составной индекс на столбцах member_id и post_id, результат будет довольно быстрым в этом случае, но в других случаях он всё равно может быть оптимизирован.

Использование подзапроса EXISTS

Лучшим решением будет использование подзапроса EXISTS. Он доступен в MySQL начиная с версии 5.7, поэтому нет причин не использовать его.

С документацией MySQL по EXISTS можно ознакомиться здесь. Существует также подзапрос NOT EXISTS.

EXISTS работает, заключая запрос в подзапрос SELECT:

SELECT EXISTS(
SELECT *
FROM `post_likes`
WHERE `member_id` = 1
AND `post_id` = 1
)

Неважно, извлекает ли ваш SELECT все столбцы (*) или только 1, SELECT будет отброшен в запросе EXISTS.

Этот запрос вернёт true, если в подзапросе есть хотя бы одна запись, или false, если нет ни одной записи, удовлетворяющей вашим условиям. MySQL выйдет из "цикла", когда найдёт первую запись, и именно это делает его более производительным, чем COUNT.

В Laravel вы можете использовать метод exists в конструкторе запросов:

// Понравилось ли пользователю сообщение
$member->postLikes()->exists();

Eloquent поместит запрос в подзапрос EXISTS.

Eloquent также предоставляет whereExists, whereNotExists, doesntExist, withExists и другие возможности, позволяющие построить нужный запрос.

Правильный пример использования EXISTS

Я использовал его для проверки существования всевозможных записей и отношений. Например, разрешения, лайки и даже в качестве вложенного подзапроса.

Например, при получении списка постов для отображения на странице я хочу знать, понравился ли пост пользователю, чтобы показать соответствующую иконку UI. Это может привести к ситуации N+1, когда для каждого поста придётся выполнять отдельный SQL-запрос для проверки наличия записи.

Или можно использовать подзапрос EXISTS:

SELECT `id`, `title`, `content`, exists(
SELECT *
FROM `post_likes`
WHERE `posts`.`id` = `post_likes`.`post_id`
AND `member_id` = 1
) AND `is_liked`
FROM `posts`

Это будет выполнено в одном оптимизированном SQL-запросе и предоставит информацию о том, понравилось ли пользователю сообщение, в сгенерированном столбце is_liked. Если быть точным, MySQL будет делать N+1 подзапросов для проверки существования, но это будет оптимизировано и выполнено внутри.

В Laravel для этого можно использовать withExists:

$posts = Post::query()
->select(['id', 'title', 'content'])
->withExists([
'postLikes as is_liked' => function ($query) {
$query->where('member_id', $member->id);
}
]);

is_liked будет добавлен как атрибут для каждой модели $post.


Эта оптимизация может показаться не такой уж важной и похожей на "микро" улучшение. Но если ваши таблицы содержат миллионы записей, то вы уже знаете, что каждая миллисекунда на счету.

Дополнительные материалы

Предыдущая Статья

Поиск оптимальных настроек PHP-FPM

Следующая Статья

Упрощение интеграции API с фасадом Http в Laravel