Оптимизация вложенных подзапросов

2020.03.04
Эксперимент по оптимизации вложенных запросов. Были опробованы 4 способа оптимизации - 4 способа получить одни и те же данные из БД с максимальной скоростью

Делая подзапрос в запросе стало интересно: можно ли как-то оптимизировать по скорости исполнения данные операции?

Пример запроса:

SELECT * 
FROM `t1`
WHERE `id` IN(
  SELECT `t1` 
  FROM `t2` 
  WHERE `status`=0
) 
LIMIT 1

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

Далее читая форумы наткнулся на пост СУБД сама оптимизирует запросы, но подтверждающей информации сходу не удалось найти, однако казалось очевидно что такую мелочь СУБД просто обязана делать.

В итоге я задался вопросом: а оптимизирует ли MySQL запросы если для сравнения id каждой строки из таблицы основного запроса делается подзапрос?

Так и появился репозиторий с экспериментом, отвечающим на данный вопрос.

Терминология

Википедия дает следующее определение понятию корреляция:

Корреляция (от лат. correlatio «соотношение, взаимосвязь») - статистическая взаимосвязь двух или более случайных величин.

Иными словами:

Изменение одной величины ведет к изменению другой.

Коррелированный подзапрос это оператор SELECT внутри другого оператора, который синхронизируется с основным запросом (то есть подзапрос выполняется для каждой строки внешнего запроса).

Переделаем предыдущий запрос под коррелированный:

SELECT * 
FROM `t1`
WHERE `id` IN(
  SELECT `t1` 
  FROM `t2` 
  WHERE `status`=0 AND t1.count > 10
)
LIMIT 1

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

Данные эксперимента

Имеется всего 2 таблицы (с индексами):

Данные для таблиц генерируются скриптом gen-data.php.

Используется 4 способа получения данных из БД:

SELECT * 
FROM `t1` 
WHERE `id` IN(
  SELECT `t1` 
  FROM `t2` 
  WHERE `status`=0
)
LIMIT 1
$inner = $db->query("SELECT  `t1` FROM `t2` WHERE status=0", null, true);
$inner2 = [];
foreach($inner as $value) {
    $inner2[] = $value["t1"];
}
$innerImplode = implode(",", $inner2);
$res = $db->query("SELECT  * FROM t1 WHERE id IN($innerImplode) LIMIT 1");
$inner = $db->query("SELECT GROUP_CONCAT(`t1`) AS `t1` FROM `t2` WHERE status=0", null, true);
$innerList = $inner[0]["t1"];
$res = $db->query("SELECT * FROM t1 WHERE id IN($innerList) LIMIT 1");
$db->query(
	"SET group_concat_max_len = CAST('-1' AS UNSIGNED);
	SET @s1 = CONCAT(
		\"SELECT  * FROM t1 WHERE id IN('\",
			REPLACE(
				(SELECT  GROUP_CONCAT(`t1`) AS t1 FROM `t2` WHERE status=0),
				\",\",
				\"','\"
			),
		\"') LIMIT 1\"
	);
	PREPARE stmt1 FROM @s1;
",
null, false
);
$res = $db->query("EXECUTE stmt1;", null, true);

Нюансы реализация

Типы данных

Первым делом надо четко понимать что в MySQL есть типы данных (en, ru), пожалуй, отсутствие этого понимания с моей стороны стало веской причиной, по которой этот эксперимент дошел до конца.

GROUP_CONCAT возвращает строковое значение, и использование результатов выполнения этого оператора в операции сравнения с числовым значением, без перекомпиляции запроса, приведет к преобразованию строкового типа к числовому.

Например:

SELECT @listId:=GROUP_CONCAT(DISTINCT t1) 
FROM `t2` 
WHERE `status`=0;

SELECT * 
FROM `t1` 
WHERE `id` IN(@listId);

Здесь listId это '1,2,3,4', а теперь подставим в запрос:

SELECT * 
FROM `t1` 
WHERE id IN('1,2,3,4');

Как видно, данные в listId это строка, а не массив как хотелось бы предполагать :)

Конкатенация

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

Кстати, чтобы данные полностью вмещались в строку, надо использовать:

SET group_concat_max_len = CAST('-1' AS UNSIGNED)

Генерация запроса

Можно люто извратиться и сделать предыдущий вариант запроса на стороне СУБД (4 способ), при этом результат будет правильным. Это было сделано в четвертом способе. Но это трэш, just for skill :)

Кэш?

В ходе эксперимента меня заклинило на том, что надо делать честные замеры скорости и перед каждым запросом очищать кэш. Но как выяснилось это бессмысленная затея, потому что я использовал движок InnoDB, а в этом случае рекомендуется хранить такие таблицы в ОЗУ для более быстрого доступа. Для этого имеется пул буфферов, на который можно воздействовать в любую сторону, но … надо ли специально пытаться уменьшить скорость запросов, гоняя данные из ПЗУ и ОЗУ?

Вывод

Результаты синтетических тестов (t1-количество записей, t2-количество записей):

t1 - 1000, t2 - 1000:

  1. 0.00059890747070312
  2. 0.0016210079193115
  3. 0.00142502784729
  4. 0.0018970966339111

t1 - 101000, t2 - 101000:

  1. 0.058974027633667
  2. 0.16162991523743
  3. 0.14342308044434
  4. 0.19108605384827

t1 - 1101000, t2 - 1101000:

  1. 1.1628739833832
  2. 1.7279899120331
  3. 1.4494400024414
  4. 1.9515790939331

Оказалось СУБД MySQL и MariaDB оптимизируют запросы, и если подзапрос не зависит от внешнего запроса, то он выполняется единожды. И сделать более быстрый запрос, чем может это сделать сама СУБД, мне не удалось, все 3 попытки оказались медленнее.

В телеграм канале DevOps от первого лица можно оставить комментарий или почитать интересные истории из практики DevOps