Оптимизация запросов MySQL
от aNNiMON
Вчера я решил заняться оптимизацией запросов на сайте. После перехода с устаревшего модуля mysql на PDO скорость работы снизилась, а после обновления MySQL с версии 5.5 до 5.7 производительности не прибавилось. Это явно говорило о том, что с нашими таблицами и с запросами что-то не так.
Прощай, MyISAM
Первым делом я решил посмотреть на саму структуру базы данных. Оказалось, половина таблиц была на движке MyISAM, а половина на InnoDB.
MyISAM хорош для тех таблиц, в которых данные в основном читаются, а не пишутся. Например, наш раздел Категории или правила сайта. Если в таблицу часто делается запись, это снижает производительность, потому что MyISAM на время добавления блокирует всю таблицу полностью.
После перехода на PDO все операции с базой данных проходят в специальном классе. Добавив туда простой логгер запросов с выводом [время - запрос], я мог видеть, какие таблицы обновлялись при открытии той или иной страницы.
Такой таблицей оказалась таблица пользователей. При каждом открытии страницы обновляется счётчик переходов и местоположение (главная, форум, статьи и т.д.). MyISAM здесь не место.
Сразу отмечу, что напрямую менять движок и вообще какие-то критические данные, тем более такой таблицы как users, никак нельзя. На локалке можно. А в продакшене лучше сделать копию:
Если что-то пойдёт не так, всегда можно будет быстро вернуть прошлую таблицу. Но далее в статье я буду просто писать сам запрос.
Следующими кандидатами были таблицы учёта прочтений в модулях. При каждом открытии модуля производится очистка старых данных, а это достаточно часто.
MyISAM и FULLTEXT INDEX
В MySQL 5.5 полнотекстовый индекс был доступен только для MyISAM. По этой причине мы и держали форум на этом движке. Изредка случались ситуации, когда после написания сообщения в теме просто не показывались какие-то свежие посты. Это вина MyISAM.
В MySQL 5.6 FULLTEXT появился и для InnoDB, так что:
Типы столбцов
Следующее, на что я обратил внимание — типы столбцов. В статьях, альбомах, уголке писателя и кодах раздел, запись и комментарий классифицируются по значению столбца type. al - раздел, cm - комментарий, ph - запись (фотография). Больше никаких значений нет, только эти три.
Столбец type имел тип VARCHAR(2). Конечно, лучшим решением было бы перепроектировать БД, чтобы у разделов, комментариев и записей были свои таблицы, но у нас legacy проект, поэтому можно просто сменить VARCHAR на ENUM:
Производительности это особо не добавит, зато можно быть уверенным, что другое значение кроме перечисленных в таблицу не попадёт.
Самое интересное было с дневниками — прародителем всех остальных модулей. Там у столбца был тип TEXT. TEXT, КАРЛ!!! Для трёх различных трёхбуквенных значений (com, txt, dir)!
Индексы
У некоторых таблиц индексы либо отсутствовали, либо не включали в себя нужные столбцы.
Например, не было индексов для столбцов type, которые я на прошлом этапе переделывал. А ведь очень много запросов именно на тип и смотрят.
Пример
Возьмём запрос на получение комментариев некоторого пользователя в уголке писателя.
При отсутствующих индексах результат будет таков:
Добавляем индекс для типа:
Теперь вместо всей таблицы из 864 строк, было просмотрено 686 строк по индексу type.
Добавляем индекс для пользователя:
Теперь было просмотрено 38 записей по индексу userid.
Индекс для двух столбцов
Однако самым эффективным будет индекс для двух столбцов: type + userid. Удаляем предыдущие индексы и добавляем новый.
Уникальный индекс
Столбец с логином в JohnCMS уникален. Не может быть двух пользователей с одинаковыми логинами, поэтому для этого столбца лучше подойдёт индекс UNIQUE. Это поможет и быстрее найти пользователя, и не даст записать в таблицу повторяющееся значение. Что было до:
А вот, что стало после смены индекса на уникальный:
Теперь запись нашлась без дополнительных проверок.
В итоге, добавив к остальным таблицам пару нужных индексов, удалось заметно ускорить открытие страниц. Однако на главной дневников страница по-прежнему открывалась более полутора-двух секунд, а на локалке вообще за 3.
Переписываем запросы
Дневники
Счётчик запросов показал, что на главной дневников было около 30 запросов. И это всего лишь для вывода 5 дневников на страницу с количеством комментариев и самой последней записью.
Сперва выбирались пять дневников, то есть записей с type = 'dir'. Затем для каждого такого дневника, то есть в цикле, выполнялись ещё запросы:
1. Получение информации о пользователе.
2. Количество записей в дневнике.
3. Последняя запись.
4. Количество комментариев у последней записи.
5. Количество непрочитанных комментариев.
Итого, один запрос на получение списка дневников, 25 запросов в цикле для каждого дневника и несколько для проверок и очистки старых непрочитанных.
Список дневников:
Последняя запись в дневнике:
Количество записей в дневнике пользователя:
В дневнике (type = 'dir') хранится только id пользователя и время создания, которое всегда совпадает с временем первой размещённой записи. Нам эта информация не только не нужна, но ещё и мешает. Если пользователь создаст дневник, а потом удалит все записи, то дневник всё ещё будет отображаться в списке. Поэтому уберём этот тип и будем идентифицировать дневники по наличию записей.
Сначала идёт запрос на получение количества дневников, чтобы правильно отобразить постраничную навигацию.
Было:
Стало:
Теперь это количество уникальных пользователей, у которых есть записи, что, в принципе, одно и то же.
Благодаря группировке мы можем в одном запросе получить список последних записей пользователя и их количество.
Во внутреннем подзапросе получаем количество и время последней записи, чтобы по нему выбрать саму последнюю запись. Это сэкономило 10 запросов на страницу.
Сюда же можно добавить и информацию о пользователе.
А это ещё минус пять запросов. В итоге мы снизили количество запросов вдвое и получили весомый прирост скорости.
Форум
Следующим на очереди оптимизации был форум. Вывод темы занимал от одной секунды на обычных темах, до трёх-четырёх секунд на большой теме в 80000 постов.
Это запрос на получение списка сообщений в теме с информацией о пользователе и банах. Остаются ещё вложения, но они получаются для каждого сообщения в цикле.
Здесь уже было уменьшено количество запросов, но это лишь снизило скорость. Дело в том, что сообщений в теме может быть очень много и ко всем им придётся делать LEFT JOIN. В теме с 80000 сообщений это большая нагрузка, нужен другой способ оптимизации.
Я решил максимально упростить первый запрос, чтобы даже проход по большому количеству сообщений выполнялся быстро. Для этого нужно убрать все объединения.
Теперь у нас есть только 20 сообщений (количество постов на странице). Уж на этом промежутке выбирать остальные данные будет заметно проще.
Средствами php я получил массив с id сообщений или user_id полученной выборки и теперь могу делать остальные запросы.
Баны:
Информация о пользователях:
И даже вложения удалось вынести из цикла:
Таким образом, разбив один запрос на несколько маленьких, удалось ускорить отображение темы. Теперь даже самая большая тема выводится менее, чем за секунду, а порой даже за 0.11 секунд.
Удаление записей в связанных таблицах
В движке во многих местах удаление происходит таким образом: выбираются сначала данные, зачастую id, а потом в цикле они берутся и выполняется запрос на удаление.
Например, удаление дневника.
Можно сделать по-другому: получить сначала все id, а потом сразу одним запросом удалить при помощи id IN (...):
А можно и одним запросом всё удалить, выполнив JOIN:
Но лучше, конечно же, настроить каскадное удаление.
Итоги
В итоге сайт стал открываться очень быстро. Прирост скорости на всех страницах приблизительно в 6 раз. На форуме и дневниках заметно больше. Да и PHP 7.1 стал намного шустрее, по сравнению с версией 5.4, которая была раньше.
Прощай, MyISAM
Первым делом я решил посмотреть на саму структуру базы данных. Оказалось, половина таблиц была на движке MyISAM, а половина на InnoDB.
MyISAM хорош для тех таблиц, в которых данные в основном читаются, а не пишутся. Например, наш раздел Категории или правила сайта. Если в таблицу часто делается запись, это снижает производительность, потому что MyISAM на время добавления блокирует всю таблицу полностью.
После перехода на PDO все операции с базой данных проходят в специальном классе. Добавив туда простой логгер запросов с выводом [время - запрос], я мог видеть, какие таблицы обновлялись при открытии той или иной страницы.
Такой таблицей оказалась таблица пользователей. При каждом открытии страницы обновляется счётчик переходов и местоположение (главная, форум, статьи и т.д.). MyISAM здесь не место.
Сразу отмечу, что напрямую менять движок и вообще какие-то критические данные, тем более такой таблицы как users, никак нельзя. На локалке можно. А в продакшене лучше сделать копию:
- CREATE TABLE `users_innodb` LIKE `users`;
- INSERT `users_innodb` SELECT * FROM `users`;
- ALTER TABLE `users_innodb` ENGINE=INNODB;
- RENAME TABLE `users` TO `users_myisam`, `users_innodb` TO `users`;
Если что-то пойдёт не так, всегда можно будет быстро вернуть прошлую таблицу. Но далее в статье я буду просто писать сам запрос.
- ALTER TABLE `users` ENGINE=INNODB;
Следующими кандидатами были таблицы учёта прочтений в модулях. При каждом открытии модуля производится очистка старых данных, а это достаточно часто.
- ALTER TABLE `ablogs_rdm` ENGINE=INNODB;
- ALTER TABLE `ablogs_com_rdm` ENGINE=INNODB;
- -- ...
- ALTER TABLE `writers_rdm` ENGINE=INNODB;
- ALTER TABLE `writers_com_rdm` ENGINE=INNODB;
MyISAM и FULLTEXT INDEX
В MySQL 5.5 полнотекстовый индекс был доступен только для MyISAM. По этой причине мы и держали форум на этом движке. Изредка случались ситуации, когда после написания сообщения в теме просто не показывались какие-то свежие посты. Это вина MyISAM.
В MySQL 5.6 FULLTEXT появился и для InnoDB, так что:
- ALTER TABLE `forum` ENGINE=INNODB;
Типы столбцов
Следующее, на что я обратил внимание — типы столбцов. В статьях, альбомах, уголке писателя и кодах раздел, запись и комментарий классифицируются по значению столбца type. al - раздел, cm - комментарий, ph - запись (фотография). Больше никаких значений нет, только эти три.
Столбец type имел тип VARCHAR(2). Конечно, лучшим решением было бы перепроектировать БД, чтобы у разделов, комментариев и записей были свои таблицы, но у нас legacy проект, поэтому можно просто сменить VARCHAR на ENUM:
- ALTER TABLE `albums` CHANGE `type` `type` ENUM('al','cm','ph') NOT NULL;
Производительности это особо не добавит, зато можно быть уверенным, что другое значение кроме перечисленных в таблицу не попадёт.
Самое интересное было с дневниками — прародителем всех остальных модулей. Там у столбца был тип TEXT. TEXT, КАРЛ!!! Для трёх различных трёхбуквенных значений (com, txt, dir)!
Индексы
У некоторых таблиц индексы либо отсутствовали, либо не включали в себя нужные столбцы.
Например, не было индексов для столбцов type, которые я на прошлом этапе переделывал. А ведь очень много запросов именно на тип и смотрят.
Пример
Возьмём запрос на получение комментариев некоторого пользователя в уголке писателя.
- EXPLAIN SELECT * FROM `writers`
- WHERE `type` = 'cm' AND `userid` = 1720
- ORDER BY `time` DESC;
При отсутствующих индексах результат будет таков:
Добавляем индекс для типа:
- ALTER TABLE `writers` ADD INDEX `type` (`type`);
Теперь вместо всей таблицы из 864 строк, было просмотрено 686 строк по индексу type.
Добавляем индекс для пользователя:
- ALTER TABLE `writers` ADD INDEX `userid` (`userid`);
Теперь было просмотрено 38 записей по индексу userid.
Индекс для двух столбцов
Однако самым эффективным будет индекс для двух столбцов: type + userid. Удаляем предыдущие индексы и добавляем новый.
- ALTER TABLE `writers`
- DROP INDEX `type`,
- DROP INDEX `userid`,
- ADD INDEX `type_user` (`type`, `userid`);
Уникальный индекс
Столбец с логином в JohnCMS уникален. Не может быть двух пользователей с одинаковыми логинами, поэтому для этого столбца лучше подойдёт индекс UNIQUE. Это поможет и быстрее найти пользователя, и не даст записать в таблицу повторяющееся значение. Что было до:
А вот, что стало после смены индекса на уникальный:
- ALTER TABLE `users`
- DROP INDEX `name_lat`,
- ADD UNIQUE INDEX `name_lat` (`name_lat`);
Теперь запись нашлась без дополнительных проверок.
В итоге, добавив к остальным таблицам пару нужных индексов, удалось заметно ускорить открытие страниц. Однако на главной дневников страница по-прежнему открывалась более полутора-двух секунд, а на локалке вообще за 3.
Переписываем запросы
Дневники
Счётчик запросов показал, что на главной дневников было около 30 запросов. И это всего лишь для вывода 5 дневников на страницу с количеством комментариев и самой последней записью.
Сперва выбирались пять дневников, то есть записей с type = 'dir'. Затем для каждого такого дневника, то есть в цикле, выполнялись ещё запросы:
1. Получение информации о пользователе.
2. Количество записей в дневнике.
3. Последняя запись.
4. Количество комментариев у последней записи.
5. Количество непрочитанных комментариев.
Итого, один запрос на получение списка дневников, 25 запросов в цикле для каждого дневника и несколько для проверок и очистки старых непрочитанных.
Список дневников:
- SELECT * FROM `diaries`
- WHERE `type` = 'dir'
- ORDER BY `time` DESC
- LIMIT :start, 5
Последняя запись в дневнике:
- SELECT * FROM `diaries`
- WHERE `type` = 'txt'
- AND `userid` = :userid
- ORDER BY `time` DESC
- LIMIT 1
Количество записей в дневнике пользователя:
- SELECT COUNT(*) FROM `diaries`
- WHERE `type` = 'txt'
- AND `userid` = :userid
В дневнике (type = 'dir') хранится только id пользователя и время создания, которое всегда совпадает с временем первой размещённой записи. Нам эта информация не только не нужна, но ещё и мешает. Если пользователь создаст дневник, а потом удалит все записи, то дневник всё ещё будет отображаться в списке. Поэтому уберём этот тип и будем идентифицировать дневники по наличию записей.
Сначала идёт запрос на получение количества дневников, чтобы правильно отобразить постраничную навигацию.
Было:
- SELECT COUNT(*) FROM `diaries`
- WHERE `type` = 'dir'
Стало:
- SELECT COUNT(DISTINCT `userid`) FROM `diaries`
- WHERE `type` = 'txt'
Теперь это количество уникальных пользователей, у которых есть записи, что, в принципе, одно и то же.
Благодаря группировке мы можем в одном запросе получить список последних записей пользователя и их количество.
- SELECT * FROM `diaries` d
- INNER JOIN (
- SELECT
- MAX(`time`) as `record_date`,
- COUNT(*) as `records_count`
- FROM `diaries`
- WHERE `type` = 'txt'
- GROUP BY `userid`
- ) r
- WHERE d.type = 'txt' AND d.`time` = r.record_date
- ORDER BY d.`time` DESC
- LIMIT :start, 5
Во внутреннем подзапросе получаем количество и время последней записи, чтобы по нему выбрать саму последнюю запись. Это сэкономило 10 запросов на страницу.
Сюда же можно добавить и информацию о пользователе.
- SELECT * FROM `diaries` d
- INNER JOIN (
- SELECT
- MAX(`time`) as `record_date`,
- COUNT(*) as `records_count`
- FROM `diaries`
- WHERE `type` = 'txt'
- GROUP BY `userid`
- ) r
- LEFT JOIN `users` u ON u.id = d.userid
- WHERE d.type = 'txt' AND d.`time` = r.record_date
- ORDER BY d.`time` DESC
- LIMIT :start, 5
А это ещё минус пять запросов. В итоге мы снизили количество запросов вдвое и получили весомый прирост скорости.
Форум
Следующим на очереди оптимизации был форум. Вывод темы занимал от одной секунды на обычных темах, до трёх-четырёх секунд на большой теме в 80000 постов.
- SELECT
- `forum`.*,
- `users`.*,
- `cms_ban_users`.*
- FROM `forum`
- LEFT JOIN `users`
- ON `forum`.`user_id` = `users`.`id`'
- LEFT JOIN `cms_ban_users`
- ON `module` = "forum"
- AND `cms_ban_users`.`mid` = `forum`.`id`'
- WHERE
- `forum`.`type` = "m"
- AND `forum`.`refid` = :refid
- LIMIT
- :start, :total
Это запрос на получение списка сообщений в теме с информацией о пользователе и банах. Остаются ещё вложения, но они получаются для каждого сообщения в цикле.
Здесь уже было уменьшено количество запросов, но это лишь снизило скорость. Дело в том, что сообщений в теме может быть очень много и ко всем им придётся делать LEFT JOIN. В теме с 80000 сообщений это большая нагрузка, нужен другой способ оптимизации.
Я решил максимально упростить первый запрос, чтобы даже проход по большому количеству сообщений выполнялся быстро. Для этого нужно убрать все объединения.
- SELECT * FROM `forum`
- WHERE `type` = "m" AND `refid` = :refid
- LIMIT :start, :total
Теперь у нас есть только 20 сообщений (количество постов на странице). Уж на этом промежутке выбирать остальные данные будет заметно проще.
Средствами php я получил массив с id сообщений или user_id полученной выборки и теперь могу делать остальные запросы.
Баны:
- SELECT * FROM `cms_ban_users`
- WHERE `module` = "forum" AND `mid` IN :ids
Информация о пользователях:
- SELECT * FROM `users`
- WHERE `id` IN :user_ids
И даже вложения удалось вынести из цикла:
- SELECT * FROM `forum_files`
- WHERE `post` IN :ids
Таким образом, разбив один запрос на несколько маленьких, удалось ускорить отображение темы. Теперь даже самая большая тема выводится менее, чем за секунду, а порой даже за 0.11 секунд.
Удаление записей в связанных таблицах
В движке во многих местах удаление происходит таким образом: выбираются сначала данные, зачастую id, а потом в цикле они берутся и выполняется запрос на удаление.
Например, удаление дневника.
- // Очистка комментариев
- $stmt = DB::query(
- "SELECT * FROM `diaries`
- WHERE `type` = 'txt' AND `userid` = ?",
- [$id]);
- while ($diary = $stmt->fetch()) {
- DB::query("DELETE FROM `diaries`
- WHERE `type` = 'com' AND `com_id` = ?",
- [$diary['id']);
- }
- // Удаление записей
- DB::exec("DELETE FROM `diaries`
- WHERE `type` = 'txt' AND `userid` = ?");
Можно сделать по-другому: получить сначала все id, а потом сразу одним запросом удалить при помощи id IN (...):
- // Очистка комментариев
- $diaryIds = DB::prepareAndExec(
- "SELECT `id` FROM `diaries`
- WHERE `type` = 'txt' AND `userid` = ?",
- [$id])->fetchAll(PDO::FETCH_COLUMN);
- $sql = implode(',', $diaryIds);
- DB::exec("DELETE FROM `diaries`
- WHERE `type` = 'com' AND `com_id` IN ($sql)");
- // Удаление записей
- DB::exec("DELETE FROM `diaries`
- WHERE `type` = 'txt' AND `userid` = ?",
- [$id]);
А можно и одним запросом всё удалить, выполнив JOIN:
- DB::exec("DELETE d, com FROM `diaries` d
- LEFT JOIN `diaries` com
- ON com.`com_id` = d.id
- AND com.`type` = 'com'
- WHERE d.`type` = 'txt'
- AND d.userid = ?",
- [$id]);
Но лучше, конечно же, настроить каскадное удаление.
Итоги
В итоге сайт стал открываться очень быстро. Прирост скорости на всех страницах приблизительно в 6 раз. На форуме и дневниках заметно больше. Да и PHP 7.1 стал намного шустрее, по сравнению с версией 5.4, которая была раньше.