Работая над различными интересными задачами, мне только и приходилось слышать о существовании программ, которые работают с большими данными (в области действия одного сервера). И вот настал тот день, когда к нам обратился клиент, у которого сайт грузился очень долго. Задание для решения этой проблемы выдали моего коллеги. Немного прошло времени, до того как он подозвал меня к себе, с целью показать, столь диковинное для меня зрелище, связанное с объёмом таблиц, в которых находились данные после импорта категорий, характеристик, брендов товаров.

22 миллиона варианта категорий для нас показалось более чем приличным объёмом, тем более — вес таблицы с категориями получился чуть больше 1,6 Гиб. С такими размерами страницы начали грузиться дольше, чем хотелось бы. Ввиду того что, сроки на решение проблемы маленькие (чем быстрей тем лучше), руководство решило выделить на эту задачу, две единицы программистов, меня и моего коллегу. Разделив модуль на двоих, мне достались по объёму не самые сливки, но не менее ответственный участок, поскольку в нём — на загрузку данных, уходило значительное время.

▍ Задача


Задача состоит в том, чтобы оптимизировать запросы таким образом, чтобы время их выполнения было не более одной секунды.

Имеются следующие таблицы:

1. Таблица связи

  • id варианта характеристики (variant_id)
  • ссылки на картинку (image_link)

Количество строк 995371:

CREATE TABLE `features_variants_links` (
    `variant_id` bigint(64) UNSIGNED NOT NULL,
    `image_link` varchar(255)  default '',
    PRIMARY KEY (`variant_id`)
)ENGINE=MyISAM DEFAULT CHARSET=UTF8


2. Таблица связи:

  • id варианта характеристики (variant_id)
  • наименование характеристики (name)
  • описание характеристики (description)
  • код языка (lang_code)

Количество строк 2285984:

CREATE TABLE feature_variants_descriptions (
  variant_id bigint(64) UNSIGNED NOT NULL,
  name varchar(255)  default '',
  description text,
  lang_code char(2) NOT NULL default 'ru',
PRIMARY KEY (variant_id,lang_code) )ENGINE=MyISAM DEFAULT CHARSET=UTF8


3. Таблица связи:

  • id характеристики (feature_id)
  • id варианта характеристики (variant_id)

Количество строк 1142994:

CREATE TABLE feature_variants (
  variant_id bigint(64) UNSIGNED NOT NULL,
  feature_id bigint(64) UNSIGNED NOT NULL,
PRIMARY KEY (variant_id,feature_id) ,
  KEY feature_id (feature_id) ,
  KEY variant_id (variant_id) )ENGINE=MyISAM DEFAULT CHARSET=UTF8


Запрос наиболее продолжительного времени выполнения процесса:

SELECT SQL_CALC_FOUND_ROWS *
FROM feature_variants AS variants
JOIN features_variants_links AS variants_links ON variants.variant_id = variants_links.variant_id
JOIN feature_variants_descriptions AS variants_descriptions ON variants.variant_id = variants_descriptions.variant_id
WHERE variants_links.feature_id IN (127)
  AND  variants_descriptions.lang_code = 'ru'
ORDER BY variants_descriptions.variant ASC
LIMIT 0, 10

Время выполнения запроса:
Query_ID 1
Duration 28.66710200
Query SELECT SQL_CALC_FOUND_ROWS *
FROM feature_variants AS variants… .
Query_ID 2
Duration 0.00030500
Query SELECT FOUND_ROWS()
Это самый долгий запрос, из моей половины модуля. Решение проблемы я начну с изменения структуры таблиц. Вторым шагом оптимизация самого запроса.

▍ Решение


Первое, что сразу бросается в глаза, и что я не могу пропустить, это наиболее часто встречающийся способ подсчёта строк, с помощью модификатора «SQL_CALC_FOUND_ROWS» и сопутствующая функция «FOUND_ROWS()». В подсистеме хранения данных «MyISAM» этот вариант подсчёта общего количества строк в выборке, использовать не всегда благоразумно, поскольку точное количество строк кэшируется системой хранения. Чего не скажешь о «InnoDB», в которой такие запросы чаще показывают большую скорость.

Если убрать модификатор «SQL_CALC_FOUND_ROWS», и выполнить подсчёт строк во втором запросе через функцию COUNT(*), то получится следующий результат:
Query_ID 1
Duration 11.10706900
Query SELECT * FROM feature_variants AS variants….
Query_ID 2
Duration 12.77787800
Query SELECT COUNT(*) FROM feature_variants AS variants
После внесённых изменений время выполнения запроса сократилось на 4 секунды. По сравнению с тем что было, разница кажется не существенной, но 4 секунды, на фоне предшествующего времени загрузки, это уже много.

Пробежавшись взглядом по таблице, я обнаружил, что в таблице "feature_variants_descriptions" данные периодически дублируются. Для того чтобы этого избежать, надо поделить таблицу на три части. В первой и второй будут храниться описания и наименования характеристик. А в третьей — связь между вариантом характеристик, его именем и описанием. Получились такие таблицы:

1. Таблица будет содержать в себе:

  • ключ описания характеристики (feature_descriptions_id)
  • код языка (lang_code)
  • описание варианта характеристики (description)

Количество строк 224248:

CREATE TABLE test_feature_descriptions (
  feature_descriptions_id BIGINT(64) UNSIGNED NOT NULL AUTO_INCREMENT,
  lang_code CHAR(2) NOT NULL default 'ru',
  description text ,
PRIMARY KEY (feature_descriptions_id) ,
 KEY (lang_code))ENGINE=MyISAM DEFAULT CHARSET=UTF8


2. Таблица будет содержать в себе:

  • ключ наименование характеристики (feature_name_id)
  • код языка (lang_code)
  • наименование характеристики (name)

Количество строк 10294:

CREATE TABLE test_feature_name (
  feature_name_id BIGINT(64) UNSIGNED NOT NULL AUTO_INCREMENT,
  lang_code CHAR(2) NOT NULL default 'ru',
  name varchar(255)  default '' ,
PRIMARY KEY (feature_name_id) ,
KEY (lang_code))ENGINE=MyISAM DEFAULT CHARSET=UTF8


3. Таблица будет содержать в себе:

  • ключ варианта имени характеристики и описания характеристики (feature_name_descriptions_id)
  • вариант характеристики (variant_id)
  • связь с таблицей «test_feature_name» (feature_name_id)
  • связь с таблицей «test_feature_descriptions» (feature_name_descriptions_id)

Количество строк 224998:

CREATE TABLE test_feature_name_descriptions (
  feature_name_descriptions_id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
  variant_id BIGINT(64) UNSIGNED NOT NULL,
  feature_name_id BIGINT(64) UNSIGNED NOT NULL,
  feature_descriptions_id BIGINT(64) UNSIGNED NOT NULL ,
PRIMARY KEY (feature_name_descriptions_id) ,
  KEY (variant_id, feature_descriptions_id, feature_name_id))ENGINE=MyISAM DEFAULT CHARSET=UTF8


Как можно видеть, объём данных уменьшился примерно в 4 раза. Запрос теперь выглядит так:

SELECT * FROM feature_variants AS variants
JOIN features_variants_links AS variants_links ON variants.variant_id = variants_links.variant_id
LEFT JOIN test_feature_name_descriptions AS variants_descriptions ON variants.variant_id = variants_descriptions.variant_id
LEFT JOIN test_feature_name ON test_feature_name.feature_name_id = variants_descriptions.feature_name_id
LEFT JOIN test_feature_descriptions ON test_feature_descriptions.feature_descriptions_id = variants_descriptions.feature_descriptions_id
WHERE variants_links.feature_id IN (127)
  AND test_feature_name.lang_code = 'ru'
  AND test_feature_descriptions.lang_code = 'ru'
ORDER BY test_feature_name.name ASC
LIMIT 0, 10

В таблице «feature_name_descriptions», если нет описания или наименование характеристики, поля name или/и description просто пустые, и необходимы для дальнейшей логики, которая выходит за пределы этой статьи. В первом случае когда используем «JOIN feature_name_descriptions», мы теряем «variants_links.feature_id» у которых ещё нет записи в «feature_name_descriptions». По этому я решил использовать «LEFT JOIN test_feature_name_description».

Query_ID 1
Duration 2.83528475
Query SELECT * FROM feature_variants AS variants….
Query_ID 2
Duration 2.36108550
Query SELECT COUNT(*) FROM feature_variants AS variants….
За счёт уменьшения объёма данных удалось улучшить показатель скорости. Но всё равно долго. Для такой структуры таблиц напрашивается тип «InnoDB». Так как внешние ключи «MyISAM» не поддерживает. Ради эксперимента я решил попробовать сначала без связи внешних ключей, и замерить разницу. Вот результат:
Query_ID 1
Duration 1.55744800
Query SELECT * FROM feature_variants AS variants….
Query_ID 2
Duration 0.76852400
Query SELECT COUNT(*) FROM feature_variants AS variants….
Даже без внешних ключей, у «InnoDB» видно преимущество. Вариант с использованием функции FOUND_ROWS():
Query_ID 1
Duration 1.50749225
Query SELECT SQL_CALC_FOUND_ROWS * FROM feature_variants AS variants ….
Duration 0.00019200
Query SELECT FOUND_ROWS()
Выиграли примерно полсекунды. Но здесь есть свои нюансы. Данный вариант уже можно считать устаревшем с версии MySQL 8.0.17, и разработчики в дальнейшем обещают удалить модификатор SQL_CALC_FOUND_ROWS и сопутствующую функцию. Поэтому (по возможности) лучше избегать этот метод и использовать COUNT(*), как рекомендует автор документации. Ссылка на источник.

Итак, для таких запросов, более эффективно себя показывает система хранения «InnoDB». Если добавить таблице «test_feature_name_descriptions» «FOREIGN KEY», то можно добиться ещё чуть большей производительности. Я добавил два внешних ключа на поле «feature_name_id» и «feature_name_descriptions_id»
Query_ID 1
Duration 1.55744800
Query SELECT * FROM feature_variants AS variants….
Query_ID 2
Duration 0.76852400
Query SELECT COUNT(*) FROM feature_variants AS variants….
Даже без внешних ключей, у «InnoDB» видно преимущество. Вариант с использованием функции FOUND_ROWS()
Query_ID 1
Duration 0.53544925
Query SELECT * FROM feature_variants AS variants….
Query_ID 2
Duration 0.66671650
Query SELECT COUNT(*) FROM feature_variants AS variants….
На этом этапе у меня получилась максимальная производительность. Можно добиться скорости выполнение за доли секунд, за счёт добавления ещё одной таблицы, которая уменьшит количество элементов в выборке до сотен. Но это метод уже выходит за рамки показанных таблиц моей половины модуля, и данная статья была бы уже не актуальна, так как в том варианте я не обнаружил бы просадку производительности.

Второй вариант предполагает пожертвовать сортировкой «ORDER BY», например, предоставить выбор сортировки пользователю, по нажатию на соответствующую кнопку, подгружая данные ajax-ом. Без неё скорость первого запроса составила 0.00096500 секунды. Поскольку оптимизировать таблицы уже некуда, то можно заняться оптимизацией самого запроса. Первый запрос можно не трогать, так как получаемые данные из запроса нам нужны. Запрос на получение количества данных будет иметь следующий вид:

SELECT COUNT(*) FROM feature_variants AS variants
JOIN features_variants_links AS variants_links ON variants.variant_id = variants_links.variant_id
WHERE variants_links.feature_id IN (127)

Я убрал LEFT JOIN, так как он, в моём случае, не влияет на количество элементов в выборке. ORDER BY и LIMIT, были исключены ещё в предыдущих запросах. Результат получился таким:
Query_ID 1
Duration 0.05242752
Query SELECT COUNT(*) FROM feature_variants AS variants … .

▍ Итог


У моего коллеги получилась примерно та же история. На начальных этапах разработки, наши предшественники не рассчитывали, что при увеличении объёмов данных, изменится скорость выполнения запросов, и произойдёт дублирование данных. В итоге данные оказались не такими уж и большими.

Также в коде, был доработан метод, для того чтобы он, из запроса убирал не только ORDER BY и LIMIT, но и LEFT JOIN. Для небольших данных, это решение задачи вполне подходит, но если объём разрастётся до действительно больших размеров данных, то решение будет выглядеть по-другому. А как — предлагаю обсудить в комментариях.
Telegram-канал с полезностями и уютный чат

Комментарии (7)


  1. Akina
    04.10.2022 12:47
    +11

    Очень странно в статье, посвящённой оптимизации, не увидеть ни планов выполнения запросов, ни попыток оптимальной индексации данных... По сути, данные просто были нормализованы. Но и тут - вместо того, чтобы обосновать применённую нормализацию на основании анализа предметной области, всё сделано чуть ли не по наитию (слава богу, структуры - проще некуда, потому и прокатило).

    А ещё за кадром как-то остался факт необходимости полной переделки кода создания/обновления записей с учётом изменения структуры.

    Я убрал LEFT JOIN, так как он не влияет на количество элементов в выборке.

    Серьёзно? А то, что это изменяет логику запроса - это ничего? Нет, к тому, что безграмотные "запросописатели" лепят LEFT JOIN где надо и где не надо вне зависимости от требуемой логики, я как-то привык, но вносить изменения без оглядки на логику - это уже край.

    Запрос на получение количества данных будет иметь следующий вид

    SELECT * ну никак не может вернуть количество.


    1. Tolsedum Автор
      04.10.2022 15:48
      +1

      SELECT * ну никак не может вернуть количество.

      Извиняюсь, за опечатку. Исправил.

      Серьёзно? А то, что это изменяет логику запроса - это ничего?

      Согласен. Не хватает пояснения.

      В таблице "feature_name_descriptions", если нет описания или наименование характеристики, поля name или/и description просто пустые, и необходимы для дальнейшей логики, которая выходит за пределы этой статьи. В первом случае когда используем "JOIN feature_name_descriptions", мы теряем "variants_links.feature_id" у которых ещё нет записи в "feature_name_descriptions". По этому я решил использовать "LEFT JOIN test_feature_name_description".

      А ещё за кадром как-то остался факт необходимости полной переделки кода

      С этим тоже согласен. Не хватает логики после выборки данных. Постарался уложится в SQL код, иначе статья получилась бы чересчур большая.


  1. webdevium
    04.10.2022 14:44
    +5

    А где хоть один EXPLAIN?


  1. Nnnnoooo
    05.10.2022 02:03
    +2

    Статья полный фейспалм. О чем уже и написали комментаторы сверху.
    При оптимизации Mysql существует куча способов оптимизации (в зависимости от схем таблиц, размера строк, количества данных), но статья совсем не об этом, а из разряда делаю что-то не знаю что и получаю что-то еще, но не знаю почему.


  1. kornel
    05.10.2022 13:24

    features_variants_links
    image_link - char, иначе зачем MyISAM?

    feature_variants
    KEY variant_id не нужен. Перекрывается PRIMARY KEY.


  1. odiemius
    06.10.2022 00:24

    Как уже отметили выше, в статье никак не упомянут EXPLAIN. Но даже без explain, длительность выполенния поиска еще сильно зависит от проиводительности работы дисковой подсистемы, есть ли кэш, прогрет ли он, запускает ли кто-то паралельно другие запросы по базе... Так что Вы, может, и прооптимизируете запрос до 1 секунды выполнения, а потом на реальном сервере кто-то запустит паралельный бакап базы и время выполнения запроса улетит в космос.

    И вообще MyISAM использовать в XXI веке.. как-то нехорошо. InnoDB тоже не фонтан, конечно, но если не хранить всё в одном огромной файле, а разбить на file-per-table, то терпимо.


  1. indocoder
    06.10.2022 20:13

    Ужас а не статья. Начиная от подзаголовков "Количество строк 224998:" и заканичвая тем что нет ни одного EXPLAIN, ни одного примера результата запроса (чтобы примерно понять котекст), постоянные отсылки к InnoDB - так перейдите на InnoDB, черт вас за ногу.

    Абсолютно вырвиглазные глаза таблиц и переменных, ни одного слова про железо, на котором это все крутится.

    В общем кто-то где-то оптимизировал какие то циферки сферического коня в вакууме и решил поделиться опытом. Статья уровня "я взял базу, выкинул из нее старые ненужные данные и все заработало быстрее".

    Ну и псевдо-большие данные в заголовке это откровенное вранье. Это вообще смешной объем данных - чуть больше миллиона строк. Я регулярно в плане хобби перемалываю базы размером 20+ ГБ (нац. база стоимостей процедур в госпиталях, базы продажи недвижимости, итд), правда мне там скорость запроса вообще не важна, но с обычным индексом скорость выполнения примерно такая же как и у вашего клиента, в пределах 1-2 секунд.