С самого начала я знал, что API Bencher Perf будет одним из самых требовательных к производительности конечных точек. Я считаю, что основная причина, по которой многим приходилось изобретать колесо отслеживания бенчмарков заново, заключается в том, что существующие инструменты “из коробки” не справляются с необходимой высокой размерностью. Под “высокой размерностью” я понимаю возможность отслеживания производительности во времени и по нескольким измерениям: Ветвлениям, Тестовым стендам, Бенчмаркам и Метрикам. Эта возможность анализа данных по пяти различным измерениям приводит к очень сложной модели.
Именно из-за этой врожденной сложности и специфики данных я рассматривал возможность использования временной базы данных для Bencher. Однако в итоге я остановил свой выбор на SQLite. Я решил, что лучше делать вещи, которые не масштабируются, чем тратить дополнительное время на изучение совершенно новой архитектуры базы данных, которая может и не помочь.
С течением времени требования к API Bencher Perf также возросли. Изначально вы должны были вручную выбрать все измерения, которые хотели визуализировать. Это создавало много трудностей для пользователей при попытке получить полезный график. Чтобы решить эту проблему, я добавил список самых последних отчетов на страницы Perf, и по умолчанию выбирался и отображался самый последний отчет. Это означало, что если в самом последнем отчете было 112 бенчмарков, то они все были бы визуализированы. Модель стала еще более сложной с возможностью отслеживания и визуализации границ порогов.
Имея это в виду, я внес несколько улучшений, связанных с производительностью. Поскольку для начала визуализации графика Perf требовался самый последний отчет, я рефакторил API отчетов, чтобы получить данные результатов отчета одним вызовом к базе данных, вместо итерации. Временное окно для запроса отчета по умолчанию было установлено в четыре недели, вместо неограниченного. Я также значительно ограничил область всех дескрипторов базы данных, уменьшив конфликт блокировок. Чтобы помочь в общении с пользователями, я добавил индикатор состояния для графика Perf и вкладок измерений.
У меня также была неудачная попытка осенью использовать комбинированный запрос для получения всех результатов Perf одним запросом, вместо использования четырехкратного вложенного цикла. Это привело к тому, что я достиг лимита рекурсии типовой системы Rust, постоянно переполнял стек, страдал из-за безумно долгих (намного более 38 секунд) времен компиляции и, наконец, зашел в тупик из-за максимального количества терминов в составном выражении выборки SQLite.
Со всем этим на своем счету, я знал, что мне действительно нужно здесь копнуть глубже и надеть штаны инженера по производительности. Я никогда раньше не профилировал базу данных SQLite, и, честно говоря, я никогда не профилировал никакую базу данных до этого. Теперь подождите-ка, вы можете подумать. Мой профиль в LinkedIn говорит, что я был “Администратором баз данных” почти два года. И я никогда не профилировал базу данных‽ Да, это история на другой раз, предполагаю.
От ORM к SQL запросу
Первое препятствие, с которым я столкнулся, это извлечение SQL запроса из моего кода на Rust.
Я использую Diesel в качестве объектно-реляционного маппера (ORM) для Bencher.
Diesel создает параметризованные запросы.
Он отправляет SQL запрос и его параметры привязки отдельно в базу данных.
То есть, подстановка выполняется базой данных.
Поэтому Diesel не может предоставить пользователю полный запрос.
Лучший метод, который я нашел, - использование функции diesel::debug_query для вывода параметризованного запроса:
А затем вручную очищать и параметризовать запрос для преобразования его в валидный SQL:
Если вы знаете лучший способ, пожалуйста, сообщите мне!
Но это тот способ, который предложил разработчик проекта,
так что я просто последовал его совету.
Теперь, когда у меня был SQL запрос, я наконец был готов… прочитать очень много документации.
Планировщик запросов SQLite
На сайте SQLite есть отличная документация по планировщику запросов.
Она точно объясняет, как SQLite выполняет ваш SQL запрос,
и учит, какие индексы полезны и на что стоит обратить внимание, например, на полные сканирования таблиц.
Чтобы увидеть, как планировщик запросов выполнит мой запрос Perf,
мне нужно было добавить новый инструмент в мой арсенал: EXPLAIN QUERY PLAN
Вы можете либо добавить к вашему SQL запросу префикс EXPLAIN QUERY PLAN
или выполнить команду .eqp on перед вашим запросом.
В любом случае, я получил результат, который выглядит так:
О, боже!
Здесь много информации.
Но три главные вещи, которые бросились мне в глаза:
SQLite создает материализованное представление на лету, которое сканирует всю таблицу boundary
После этого SQLite сканирует всю таблицу metric
SQLite создает два индекса на лету
И каковы размеры таблиц metric и boundary?
Оказывается, это две самые большие таблицы,
так как именно в них хранятся все Метрики и Границы.
Поскольку это был мой первый опыт настройки производительности SQLite,
я хотел проконсультироваться с экспертом, прежде чем вносить какие-либо изменения.
Эксперт по SQLite
В SQLite есть экспериментальный режим “эксперта”, который можно активировать с помощью команды .expert.
Он предлагает индексы для запросов, поэтому я решил его испытать.
Вот что он предложил:
Это определенно улучшение!
Теперь не происходит сканирование таблицы metric и оба индекса “на лету” исчезли.
Честно говоря, первые два индекса я бы сам не придумал.
Спасибо, Эксперт по SQLite!
Теперь осталось только избавиться от этого проклятого материализованного представления “на лету”.
Материализованное представление
Когда я добавил возможность отслеживать и визуализировать Пороговые Границы в прошлом году,
мне нужно было принять решение по модели базы данных.
Между метрикой и соответствующей ей границей существует отношение 1 к 0/1.
То есть метрика может быть связана с нулем или одной границей, и граница может быть связана только с одной метрикой.
Я мог бы просто расширить таблицу metric, включив в нее все данные boundary с возможностью установки каждого поля связанного с boundary в NULL.
Или я мог создать отдельную таблицу boundary с UNIQUE внешним ключом к таблице metric.
Для меня последний вариант показался намного чище, и я подумал, что смогу всегда разобраться с любыми последствиями для производительности позже.
Это были актуальные запросы, использованные для создания таблиц metric и boundary:
И оказалось, что “позже” наступило.
Я попытался просто добавить индекс для boundary(metric_id), но это не помогло.
Я считаю, что причина заключается в том, что запрос Perf исходит из таблицы metric,
и поскольку эта связь 0/1, или другими словами, nullable, ее необходимо сканировать (O(n)),
а не искать (O(log(n))).
У меня остался один ясный вариант.
Мне нужно было создать материализованное представление, которое “разгладило” бы отношение между metric и boundary,
чтобы SQLite не приходилось создавать его на лету.
Это запрос, который я использовал для создания нового материализованного представления metric_boundary:
С этим решением я обмениваю пространство на производительность выполнения.
Сколько пространства?
Удивительно, но только около 4% увеличения, хотя это представление для двух самых больших таблиц в базе данных.
И самое главное, это позволяет мне иметь все и сразу в моем исходном коде.
Создание материализованного представления с Diesel оказалось удивительно простым.
Мне просто нужно было использовать точно такие же макросы, которые Diesel использует при генерации моей обычной схемы.
Сказав это, я стал гораздо больше ценить Diesel на протяжении всего этого опыта.
Смотрите Bonus Bug для всех интересных подробностей.
Заключение
С добавлением трех новых индексов и материализованного представления вот что теперь показывает Планировщик Запросов:
Посмотрите на все эти прекрасные SEARCH с существующими индексами! 🥲
И после развертывания моих изменений в продакшен:
Теперь настало время для финального теста.
Как быстро теперь загружается страница с производительностью Rustls?
Здесь я даже дам вам якорную метку. Кликните по ней, а затем обновите страницу.
Bencher - это набор инструментов для непрерывного тестирования производительности. Когда-нибудь регрессия производительности влияла на ваших пользователей? Bencher мог бы предотвратить это. Bencher позволяет вам обнаруживать и предотвращать регрессии производительности до того, как они попадут в продакшн.
Запустить: Запустите свои тесты производительности локально или в CI, используя ваши любимые инструменты для этого. CLI bencher просто оборачивает ваш существующий аппарат тестирования и сохраняет его результаты.
Отслеживать: Отслеживайте результаты ваших тестов производительности со временем. Мониторите, запрашивайте и строите графики результатов с помощью веб-консоли Bencher на основе ветки исходного кода, испытательного стенда и меры.
Поймать: Отлавливайте регрессии производительности в CI. Bencher использует инструменты аналитики, работающие по последнему слову техники, чтобы обнаружить регрессии производительности, прежде чем они попадут в продакшн.
По тем же причинам, по которым модульные тесты запускаются в CI, чтобы предотвратить регрессии функций, тесты производительности должны быть запущены в CI с Bencher, чтобы предотвратить регрессии производительности. Ошибки производительности – это тоже ошибки!
Дополнение к использованию собственных продуктов в качестве теста
Я уже использую Bencher для тестирования Bencher,
но все существующие адаптеры для инструментов бенчмаркинга предназначены для микро-бенчмаркинга.
Большинство инструментов для тестирования HTTP представляют собой скорее инструменты для тестирования нагрузки,
и тестирование нагрузки отличается от бенчмаркинга.
Кроме того, я не планирую в ближайшее время расширять Bencher функционалом тестирования нагрузки.
Это совсем другой случай использования, который потребует совершенно иных архитектурных решений,
например, вот база данных временных рядов.
Даже если бы у меня было тестирование нагрузки,
мне действительно нужно было бы проводить его на свежих данных из продуктивной базы, чтобы это было замечено.
Различия в производительности для этих изменений были незначительны с моей тестовой базой данных.
Нажмите, чтобы просмотреть результаты бенчмарков тестовой базы данных
До:
После добавления индексов и материализованных представлений:
Все это заставляет меня думать, что мне следует создать микро-бенчмарк,
который будет работать с API для измерения производительности и использовать результаты в Bencher.
Это потребует значительной тестовой базы данных,
чтобы убедиться, что такие регрессии производительности будут выявлены в CI.
Я создал задачу для отслеживания этой работы, если вы хотите следить за её прогрессом.
Это заставило меня задуматься:
А что если бы можно было делать тестирование снимками (snapshot testing) планов SQL-запросов вашей базы данных?
То есть, вы могли бы сравнивать текущие и потенциальные планы SQL-запросов вашей базы данных.
Тестирование плана SQL-запросов могло бы быть своего рода бенчмаркингом на основе подсчета инструкций для баз данных.
План запроса помогает указать на возможную проблему с производительностью во время выполнения,
не требуя фактического бенчмаркинга SQL-запроса.
Я также создал задачу для отслеживания и для этого.
Пожалуйста, не стесняйтесь добавлять комментарий с вашими мыслями или существующими решениями, которые вам известны!
Я пытался быть слишком изобретательным,
и в схеме материализованного представления Diesel я разрешил это соединение:
Я предполагал, что эта макроинструкция каким-то образом достаточно умна,
чтобы связать alert.boundary_id с metric_boundary.boundary_id.
Но увы, это не так.
Похоже, что было просто выбрано первое поле metric_boundary (metric_id) для связи с alert.
Как только я обнаружил ошибку, исправить её было легко.
Мне просто нужно было использовать явное соединение в запросе Perf:
🐰 Вот и всё, ребята!
🤖 Этот документ был автоматически создан OpenAI GPT-4. Оно может быть неточным и содержать ошибки. Если вы обнаружите какие-либо ошибки, откройте проблему на GitHub.