Любому программисту приходилось что-то менять в программном продукте. Ладно если просто новый типовой модуль добавить по образу и подобию. А иногда делаешь что-то принципиально новое и совершенно не понимаешь как оно будет работать. Вот об этом и поговорим. Если менее загадочно - про эксперименты, бенчмарки и с более-менее общими выводами, чтобы их можно было использовать применимо к маркетингу и гипотезам.

Нужен пример

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

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

Наш сервис при пиковых нагрузках создаёт немаленькую нагрузку на СУБД. СУБД нарастить можно, но сперва стоит немного подумать. Больше всего запросов к сервису происходит от одного и того же метода. В этом методе выполняется несколько SQL-запросов. В целом приложение сервиса асинхронное, запросы в БД асинхронные. Бутылочное горлышко не в процессоре. Проблема метода в том, что два SQL-запроса зависят друг от друга и поэтому в рамках запроса они таки выполняются синхронно, один дожидается выполнения другого.

Здесь напрашиваются два выхода:

  1. Добавить ещё один JOIN и объедини таким образом два запроса в один. Но в таком случае терялась возможность отделить отсутствие данных в одной таблице от отсутствия данных в другой при отправке ответов с ошибками. А эта семантика очень важна и сильно упрощает поддержку сервиса.
  2. Табличка маленькая, влезает в память целиком, меняется редко. Ответ напрашивается - кэшировать.

На второй ответ в ответ возникает череда вопросов: что, где, когда, чем и как кэшировать. Согласно какому-то классическому афоризму, в разработке есть две проблемы:

  1. Именование
  2. Инвалидация кэша

Вариантов на самом деле много было. По причинам не связанным с производительностью, был выбран собственный велосипед (не, ну серьёзно, что нам стоит кэш построить на голом питоне) и гнуть его под себя так. как нам самим удобно? Вышло всего-то около ста строчек.

Benchmark-first

Если задача не стоит как “сделать для галочки и пофиг что будет хуже” или “делать откровенное вредительство”, значит качество имеет значение. Так-как вопрос касается производительности, нужно сперва решить вопрос с метриками производительности и только потом приступать к её изменению.

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

Среда Плюсы Минусы
Машина разработчика можно делать всё что угодно, быстро получать обратную связь крайне далека от реальности, всю инфраструктуру облака не воспроизвести
Сервер на тестовом слое Близок к реальному окружению Тем не менее профиль нагрузки сильно отличается от прода
Сервер на продакшне Является реальным окружением Ломать жалко, даже временно

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

Изоляция эксперимента

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

На локальной машине мы можем, используя моки и что-то вроде автотестов вызывать в больших количествах только нужную нам функцию. А это замечательно, потому что любая оптимизация начинается с профилирования. А профилировать очень удобно на локальной машине. По-другому тоже можно, но обычно это схоже с гинекологической гимнастикой, а в некоторых окружениях и вовсе не получится.

Если эксперимент потенциально разрушителен и его можно ограничить виртуалкой или контейнером - волшебно. Дешевизна проб и ошибок - это всегда хорошо.

Большое количество вызовов сгладит оверхед на запуск. Регулирование числа запусков или объёмов обрабатываемых данных

Метрики

Что собирать? Что приложение делает, то и собирать. А приложение состоит из:

  • Сетевое I/O. Оно долгое. В вопросе пропускной способности и %wa в загрузке процессора асинхронные подходы спасают ситуацию, но это мало влияет на задержку между получением запроса и отдачей ответа. Обычно объёмы сетевого I/O +/- корреллируют с числом сетевых запросов, можно не заморачиваться и измерять только объём. Можно измерять число соединений. Можно число и размеры пакетов (вдруг у вас не влезало буквально 2-3 байта в jumbo-frame и теперь приложение стало обходиться меньшим числом пакетов для своей работы?). Всяко можно. Можно всё сразу, главное не запутаться потом.
  • Дисковое I/O. Не такое долгое, но всё же. Если ваш сервис работает с СУБД, измерять надо дисковое I/O СУБД. Можно залезть во внутренние метрики СУБД и собирать и их в том числе.
  • Потраченные такты CPU.
  • Максимальный размер резидентной памяти. Здесь есть нюанс - смотреть надо снаружи, например с помощью утилиты /usr/bin/time. Внутри самого приложения это сделать сложнее, через всякие там resource.getrusage() можно получить потребление в моменте выполнения самого resource.getrusage(). То есть либо городить явно отдельный поток, в котором эта функция по кругу будет крутиться, либо получать неточные данные. Почему неточные? Конструкция start = getrusage(); func(); end = getrusage(); diff = end - start на самом деле не покажет сколько памяти отъедает выполнение func(). Эта конструкция покажет сколько памяти потребляет ваша программа до запуска func и после запуска func с учётом работы garbage collector в процессе. Можно отключить garbage collector, но это отдалит нас от окружения продакшна ещё дальше.
  • Объём I/O в оперативной памяти. Число malloc’ов
  • Context switching можно оставить совсем уж системным программистам.

    Статистика

Окей мы насобирали сырых данных. На что смотреть?

В целом смотреть надо на распределение. Но сравнивать распределения сложно. Числа-то сравнивать сложно, люди постоянно графики всякие строят. Поэтому можно обойтись набором:

  • min - max
  • avg - mean
  • 95 percentile