Привет, Хабр! В настоящее время используются не только SQL решения для работы с данными, тем не менее, на долю SQL приходится значительная часть систем. Также нередко бывает, что приложение генерирует SQL в зависимости от действий пользователя, например, при выборе полей или применении фильтров в отчетах, иными словами, есть динамический SQL, а не статический. Также часто для приложения есть тесты, например, соответствующие типичным активностям пользователей, и каждой активности соответствует один или несколько SQL, причем в тестах проверяется именно правильность результатов выполнения SQL.
Далее, для относительно сложных вариантов генерации SQL при изменениях бизнес-логики (или других изменениях) может измениться и сам динамически генерируемый SQL, причем результаты выполнения SQL могут остаться неизменными, однако производительность поменяется. В таких случаях изменения в производительности можно уловить при помощи бенчмарков, однако прогон бенчмарков может занимать значительное время, в связи с этим актуальна задача быстрого анализа изменений в производительности динамически генерируемого SQL. Интересны особенности анализа производительности приложения на основе SQL — добро пожаловать под кат :)
Видно, при изменениях в бизнес-логике, которые потенциально могут привести к изменению динамически сгенерированного SQL, возникают задачи:
1) детектирование изменений в производительности (без прогона бенчмарков);
2) при наличии изменений — отображение обобщенной картины (позитивные или негативные изменения);
3) при наличии изменений — возможность получить детальную информацию, что изменилось (в каком тесте).
Предполагается, что нет изменений в окружении и зависимостях, тогда считаем, что изменения в производительности возможны только из-за SQL.
Рассмотрим для примера пять тестов и допустим, мы получили пять SQL, по одному SQL для каждого теста. Будем считать, что условно имеем дело с PostgreSQL. Рассмотрим таблицы sales, customer, product. Предположим, что поле sales.customerid является обязательным и есть проверка в sales на существование ключа в customer.id.
Условной характеристикой качества SQL запроса будем считать количество INNER JOIN в нем (хотя, конечно, можно учитывать любые типы JOIN и любые другие характеристики, например, другие виды JOIN, длину запроса, количество параметров и т.д., также можно рассмотреть и не SQL запросы вовсе, а API запросы, подход остается один и тот же).
Возьмем пять тестов для проверки результатов выполнения SQL и пять соответствующих SQL, ниже приведены названия тестов и текст SQL:
Detailed Sales Report: SELECT sales.ordernumber, sales.amount, customer.name, product.name FROM sales INNER JOIN customer ON sales.customerid = customer.id INNER JOIN product ON sales.productid = product.id
Total Sales: SELECT SUM(sales.amount) FROM sales
Total Products: SELECT COUNT() FROM product
Total Customers: SELECT COUNT() FROM customer
Sales by Products: SELECT sales.ordernumber, product.name FROM sales INNER JOIN product ON sales.productid = product.id GROUP BY product.name
Считаю, что есть смысл сразу привести результат, к которому можно стремиться. Смоделируем изменения в бизнес-логике, которые привели к изменениям в генерации SQL — добавим лишний INNER JOIN customer в пятый тест:
SELECT sales.ordernumber, product.name
FROM sales
INNER JOIN customer ON sales.customerid = customer.id
INNER JOIN product ON sales.productid = product.id GROUP BY product.name
Для исходных пяти тестов и после изменений в бизнес-логике построены два Markdown файла, видны изменения Total суммарно по всем тестам — количество INNER JOIN увеличилось с 3 до 4, что может говорить о возможной деградации производительности.
Также видно и имя теста Sales by Products, в который мы добавили лишний INNER JOIN, а также видно, что в SQL для Sales by Products количество INNER JOIN увеличилось с 1 до 2.
Таким образом, видны ответы на все три вопроса, перечисленные выше. Рассмотрим, как был получен этот результат.
Будем рассчитывать для каждого теста хеш каждого SQL и количественные характеристики производительности SQL запроса (в данном случае просто количество INNER JOIN в нем), также для всех тестов расчитаем общий инкрементальный хеш и общее количество INNER JOIN. Такую таблицу условно можно назвать performance fingerprint, т.к. за счет рассчитанного хеша по всем запросам в Total мы "фиксируем" SQL и анализируем его общие тенденции при помощи суммарного количества INNER JOIN, а за счет хешей по тестам можно найти все тесты с изменениями производительности для детального изучения, а также изменения количественных характеристик по тестам (т.е. количество INNER JOIN в каждом тесте).
К слову говоря, такой подход применим не только к SQL, а к анализу любых других метаданных или запросов, влияющих на производительность.
Реализация возможна на любом языке, можно рассмотреть на C# и .NET 9.
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
const string FullJoinString = "FROM sales INNER JOIN customer ON sales.customerid = customer.id INNER JOIN product ON sales.productid = product.id";
const string SalesProductJoinString = "FROM sales INNER JOIN customer ON sales.customerid = customer.id";
List<(string testName, string sql)> queries =
[
("Detailed Sales Report", "SELECT sales.ordernumber, sales.amount, customer.name, product.name " + FullJoinString),
("Total Sales", "SELECT SUM(sales.amount) FROM sales"),
("Total Products", "SELECT COUNT() FROM product"),
("Total Customers", "SELECT COUNT() FROM customer"),
("Sales by Products", "SELECT sales.ordernumber, product.name " + SalesProductJoinString)
];
var innerJoinTotalCount = 0;
using SHA256 sha256 = SHA256.Create();
using IncrementalHash incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
Console.WriteLine($"| Test Name | INNER JOIN Count | Hash |{Environment.NewLine}|---------:|-----------------:|------|");
for (int i = 0; i < queries.Count; i++)
{
var bytes = Encoding.UTF8.GetBytes(queries[i].sql);
incrementalHash.AppendData(bytes);
int innerJoinCount = Regex.Matches(queries[i].sql, @"\bINNER JOIN\b", RegexOptions.IgnoreCase).Count;
innerJoinTotalCount += innerJoinCount;
var hash= sha256.ComputeHash(bytes);
Console.WriteLine($"| {queries[i].testName} | {innerJoinCount} | {Convert.ToHexString(hash).Replace("-", "").ToLower()} |");
}
var hashValue = incrementalHash.GetHashAndReset();
Console.WriteLine($"| Total | {innerJoinTotalCount} | {Convert.ToHexString(hashValue).Replace("-", "").ToLower()} |");
Используем SHA256 для хеша каждого SQL, и IncrementalHash для хеша всех SQL запросов. В результате выполнения получаем необходимую таблицу со значениями хешей.
Видно, что можно проверить начилие изменений в производительности можно через хеш Total. По количественным характеристикам Total (количество INNER JOIN в Total) можно понять, какое было изменение (хуже/лучше).
Далее по изменению SQL из теста можно понять, что изменилось, что ответственно за изменение. Наконец, по характеристикам для изменившегося теста можно понять, что изменилось в конкретном тесте (стало хуже/лучше).
Можно также сказать пару слов о выборе хеш функции. Нужно, чтобы она не зависела от данных времени выполнения приложения. В итоге, требования к хеш-функции — детерминированность и кросс-платформенность, чтобы можно было спокойно сравнивать файл обычным diff и там были одинаковые хеши для одинаковых SQL для любой платформы.
Теперь изменим SalesProductJoinString на FullJoinString для SQL в последнем тесте — моделируем изменения в бизнес‑логике, которые привели к изменениям в генерации SQL, добавляем лишний INNER JOIN customer:
("Sales by Products", "SELECT sales.ordernumber, product.name " + FullJoinString)
После изменений в бизнес-логике генерации SQL получили для пяти тестов следующие SQL:
Detailed Sales Report: SELECT sales.ordernumber, sales.amount, customer.name, product.name FROM sales INNER JOIN customer ON sales.customerid = customer.id INNER JOIN product ON sales.productid = product.id
Total Sales: SELECT SUM(sales.amount) FROM sales
Total Products: SELECT COUNT() FROM product
Total Customers: SELECT COUNT() FROM customer
Sales by Products: SELECT sales.ordernumber, product.name FROM sales INNER JOIN product ON sales.productid = product.id GROUP BY product.name
Запустим расчет хеша и получим результат, который мы видели раньше.
Результаты выполнения SQL не изменились, однако в тесте Sales by Products SQL стал менее оптимальным, что видно по увеличению количества INNER JOIN с 1 до 2 (лишний INNER JOIN с таблицей customer). Сумеет ли СУБД это исправить оптимизаторами — другой вопрос, предполагаем, это изменение действительно снижает произодительность.
При ручной проверке всех SQL кейсов даже для пяти тестов случаев это занимает неоторое время, чтобы найти лишний sales INNER JOIN customer в тесте Sales by Products. Поэтому, например, в реальных условиях для 5000 тестов и для 5000 SQL (причем, намного более сложных SQL), и изменений в нескольких тестах возможность обработки вручную всех SQL стоит под вопросом. Однако предложенный подход позволяет быстро решить подобную задачу (в рамках описанных возможностей и ограничений), может помочь в планировании задач и принятии решений (решения вида "нужно посмотреть возможную деградацию из теста Sales by Products", или решение вида "все стало только лучше, уменьшился SQL", если всё проверить вручную слишком трудозатратно).
Таким образом, предлагается добавить такого рода построенную таблицу в репозиторий, т.е. коммитить общий хеш, общие характеристики по всем хешам, и таблицу хешей и характеристик для каждого теста.
С точки зрения workflow, для очередного коммита достаточно просто проверить общий хеш. Если он изменился, то сразу по общим характеристикам понять, стало лучше или хуже. По детальным характеристикам за счет хеша теста понять, в каком тесте изменилось, и в каком тесте стало лучше или хуже по характеристикам. Дальше уже другими средствами получить детальный SQL для теста, дебажить тест при необходимости. При многочисленных изменениях и невозможности/трудоемкости проверки каждого кейса — принять решение на основе общей таблицы.
Надеюсь, описанный подход будет интересен или может вдохновить на новые решения :)
Комментарии (4)
sshikov
02.12.2024 04:55Условной характеристикой качества SQL запроса будем считать количество INNER JOIN в нем
Берем простой запрос с подзапросом (WITH сгодится). Внутри WITH пишем оконную функцию. Снаружи - WHERE, для отбора например 1 записи. Имеем фуллскан, потому что постгрес не умеет проталкивать предикаты в подзапрос, если там есть оконная функция (не знаю, для каких версий это верно). То есть, в запросе вообще нет JOIN, но он ужасно неэффективен.
Я ваши оговорки чуть ниже этой фразы видел, но это все равно фигня. Методика не останется той же самой, если заменить число джойнов на другую метрику. Если бы план запроса было так просто построить - каждый дурак и писал бы оптимизаторы этого плана. Единственный работающий способ - это попросить у СУБД план запроса при помощи EXPLAIN, опять же с оговорками, что в некоторых случаях время выполнения будет таким же, как для выполнения запроса, а потом этот план научиться анализировать (это не является чем-то невозможным). Вот уже из плана (постгрес умеет отдавать его во вполне разбираемом виде типа JSON или ямл) можно понять, эффективен ли запрос - хотя бы взять кост, который тоже те еще попугаи - но это попугаи на базе данных СУБД, а не выдумок.
koanse Автор
02.12.2024 04:55То есть, в запросе вообще нет JOIN, но он ужасно неэффективен
В статье нет цели заменить анализ плана выполнения запроса на один показатель. И даже если нет INNER JOIN — то такой кейс не является контрпримером, т.к. ноль INNER JOIN соответствует не оптимальности запроса, а только тому, что нет информации об ухудшении производительности запроса в разрезе INNER JOIN, а по другим критериям — по плану выполения запроса, или, в частности, по подзапросам и WHERE с оконными функциями — ухудшение производительности может быть.
В качестве дополнительной метрики можно использовать количество подзапросов (считать WITH), или при помощи регулярных выражений (при возможности и необходимости) искать как раз позапросы с WHERE и оконными функциями.
Вот уже из плана (постгрес умеет отдавать его во вполне разбираемом виде типа JSON или ямл) можно понять, эффективен ли запрос
В статье описывается подход применительно к тестам, не ставится задача глубокого анализа каждого SQL запроса, как может делать не только СУБД, но и, частично, например, какой-нибудь плагин Rider для разных SQL диалектов.
В контексте тестов всё фокусируется на оценке регресса, деградации, сравнении до и после, а не на детальном анализе запроса: если все SQL совпадают (определяем по совпадению двух Total хешей до и после), то деградации нет, тесты проходят и заканчиваем анализ. Делать выводы на основе нуля INNER JOIN при совпадении Total хешей всех запросов "до и после" никто не собирается.
попугаи на базе данных СУБД, а не выдумок
Всё же это не выдумки, а так как всё относится к тестам, то это больше идеи на основе эспертизы разработчика, что он делает, что меняет в генерации SQL, на что это может повлиять. Так вообще можно назвать любые тесты "выдумками" и их запретить.
Если разработчик видит, что он меняет логику генерации SQL, в которой присутствуют INNER JOIN, и он может случайно увеличить их количество, и это будет считаться багом и проблемой производительности, то в таком случае есть смысл считать количество INNER JOIN, записывать в таблицу, это не более чем метаданные для простейших тестов, упрощающие работу разработчика и позволяющие сразу найти и исправить такого рода баги: был 1 INNER JOIN, стало 2 или 10 — это выглядит, как явный баг, при изменениях в логике с INNER JOIN, и нуждается в проверке и исправлении, как раз с помощью ручного анализа плана выполнения запроса.
Но в подходе из статьи анализировать вручную требуется условно не все подряд 5000 тестов в проекте, а с учетом того, что изменения в SQL тестов фиксируются, вручную необходимо проверять только несколько тестов с именениями в SQL, плюс разработчик может и без анализа плана выполнения запроса понять, что +10 INNER JOIN — это баг, и сразу перейти к исправлению, тогда анализ плана заведомо неоптимального запроса и потерю времени на это можно исключить. Причем интеграционные тесты такие баги могут не выявить, т.к. результаты запросов могут совпасть, но SQL будет разный. То же самое и с другой метрикой, например, количеством подзапросов WITH, с несколькими метриками одновременно, и т.д.
weirded
Концептуально - идея прикольная. Нет, серьёзно. Мне нравится.
Но я не совсем понимаю, как вы в реальной системе планируете привязывать SQL-запрос к какому-то идентификатору. Это надо структуру приложения менять - складировать куда-то SQL-запросы, пусть и генерируемые через ORM, чтобы потом это место как-то парсить. А если там query-builder какой-то, у которого итоговый SQL зависит от входных параметров? Из логов выхватывать? Но как их тогда с предыдущими версиями сопоставлять?
Число INNER JOIN тоже, конечно те ещё попугаи. Лучше, чем ничего, но и вредить может. К примеру было 3 отдельных последовательных SQL-запроса не под транзакцией. Объединили их под одну транзакцию? Стало хуже? Лучше? Объединили их с помощью JOIN - стало лучше или хуже? Как себя стал пул соединений чувствовать под нагрузкой от этого? А если таких запросов -> JOIN'ов 10? 20? Как себя ведёт планировщик постгри, переключившись на всякие генетические алгоритмы? Но вы вроде понимаете это и обозначили что JOIN - это просто пример.
Этот подход хорошо сработает для вторичных систем, по большей части состоящих из набора SQL-запросов. Или вы таки применяете его где-то ещё?:)
koanse Автор
Собирать SQL можно, например, со стороны C# при помощи директивы #if DEBUG или из логов СУБД. Вообще подход актуален для улучшения существующих интеграционных тестов (которые проверяют только правильность значений), собирать такую статистику на основе ручной активности в приложении тоже можно, но сложнее и имеет смысл отдельно рассматривать.
Предполагается, что для интеграционных тестов всегда фиксировано количество тестов (и количество SQL) и входные параметры. Если будет где-то расхождение, и оно будет видно в таблице, то в этом и смысл подхода. Могут быть и "ложные срабатывания" для ожидаемых изменений (например, новые тесты или изменения в параметрах тестов и т.д.). Также от версии к версии придется поддерживать сбор статистики (при необходимости), в "первой версии" сравнение с предыдущей будет невозможно.
Это хороший вопрос, больше подход расчитан на существующие интеграционные тесты. Получается, нужно, чтобы количество SQL запросов было условно постоянным и параметры были неслучайные. В таком случае можно обойтись без ID, а оставлять какие-либо условно подсказки/комментарии и т.д. для упрощения поиска генерации этого SQL в C# коде. Достаточно отсортировать все SQL запросы и в качестве ключа взять, например, часть SQL запроса (условно первые 30 символов и последние 30), это лучше, чем ничего, и в некоторых случах может помочь найти исходный C# код. Даже если не поможет — в любом случае, из таблицы с хешами будет виден масштаб изменений. Наконец, тесты можно выполнять по частям, и соответственно, использовать это для описания, т.е., например, брать в качестве ключа первые 30 и последние 30 символов из SQL и название текущей группы запускаемых тестов, например, условный ключ
Sales Details Tests: SELECT sales.ordernumber, sale...N sales.productid = product.id
Рассмотрена PostgreSQL как одна из наиболее популярных, и там действительно эффективные оптимизации. Если расмотреть, например, ClickHouse, который ещё активно развивается, то в некоторых версиях перенос INNER JOIN в WHERE работает быстрее, несмотря на абсурдность такого SQL, в последних вериях это исправили. Т.е. эти количественные характеристики зависят от SQL диалекта, и иногда имеет смысл вылавливать даже INNER JOIN.
Также подход может быть полезен при рефакторинге — можно проверить, что нет изменений и ничего не сломалось именно с точки зрения производительности, и не нужно гонять бечмарки после каждого изменения.