Я работаю техническим руководителем в команде Системы, отвечающей за производительность и стабильность услуг. С марта по ноябрь 2020 года Miro вырос в семь раз, достигнув 600+ тысяч уникальных пользователей в день. В настоящее время наш монолит работает на 350 серверах, и мы храним пользовательские данные примерно в 150 экземплярах.

Чем больше пользователей взаимодействуют с сервисом, тем сложнее найти и устранить узкие места на его серверах. Вот как мы решили эту проблему.

Часть первая: введение и постановка задачи

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

Не все согласны с такой постановкой проблемы: некоторые скажут, что на серверах RESTful нет очередей, у них есть только обработчики - методы для обработки запросов.

Я вижу по-другому: не все запросы обрабатываются одновременно. Некоторые ждут своей очереди в памяти веб-движка, в сокетах или где-то еще. Все равно где-то очередь есть.

В Miro мы используем соединение WebSocket для поддержки совместной работы на доске, а сам сервер состоит из множества очередей задач: есть отдельная очередь для приема данных, их обработки, записи их обратно в сокеты и для записи в постоянного хранения. Соответственно, есть очереди, а есть обработчики.

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

Что искали и что нашли

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

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

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

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

В нашем случае больше подходило значение процентиля. Это помогло нам разбить все процессы на два типа: аномалии (1%) и задачи, укладывающиеся в общую картину (99%).

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

Поясню: отказ от аномалий (1%) помог нам сосредоточиться на оптимизации процессов, затрагивающих абсолютное большинство наших пользователей. В результате мы улучшили UX и повысили продуктовые, а не технические показатели. Это очень важный момент.

Часть вторая: решение проблемы

В предыдущем разделе мы определили метод обнаружения узких мест. Теперь давайте определим самый медленный компонент проблемы.

Основываясь на нашем опыте, мы выяснили, что мы можем разбить общее время, необходимое для выполнения задачи, на две части: когда мы что-то считаем и когда процессор ожидает завершения операции ввода / вывода (IO).

Ошибки могут возникать и в первой части процесса, но это выходит за рамки данной статьи. Давайте сосредоточимся на второй части: времени ждать ответа от базы данных. Это включает, например, отправку данных по сети и ожидание выполнения SQL-запросов в базе данных.

На этом этапе мы должны иметь возможность встроить в уровень абстракции (уровень доступа к данным, DAL) и написать фрагмент кода, который можно будет вызывать до и после операции. Другими словами, функция должна быть наблюдаемой.

Рассмотрим следующий пример: в Miro мы используем jOOQ для работы с SQL. В этой библиотеке есть слушатели: они позволяют писать код, который выполняется до и после каждого SQL-запроса. Redis использует стороннюю библиотеку, которая не позволяет добавлять слушателей. В этом случае вы можете написать свой собственный DAL для доступа. Это означает, что вместо того, чтобы напрямую использовать библиотеку в коде, вы можете скрыть ее под своим собственным интерфейсом, реализация которого обеспечивает вызовы любых обработчиков, которые могут нам понадобиться.

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

Давайте проиллюстрируем этот процесс на примере нашей панели инструментов. Мы профилируем конкретную задачу и получаем как общее время ее выполнения, так и время, потраченное на запросы в SQL и Redis. Также можем поставить разные счетчики времени; например, в контексте команд, отправленных в Redis.

Информация о выполнении каждого запроса дублируется в Prometheus и Jaeger. Зачем нужны две системы? Графики более наглядны, а журналы подробны. Системы дополняют друг друга.

Давайте посмотрим на пример: есть команда открыть доску Miro; Время его выполнения напрямую зависит от размера доски. Технически мы не можем показать на графике, что маленькие доски открываются быстро, а большие - медленно. Но Прометей показывает в реальном времени аномалии, на которые можно оперативно реагировать.

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

Трассировка стека для особых случаев

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

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

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

В Miro мы отправляем трассировки стека в Grafana. Мы исключаем из дампа данных все данные сторонней библиотеки, а значение метрики формируем в результате конкатенации части дампа. Это выглядит так: после манипуляций с данными журнал projects.pt.server.RepositoryImpl.findUser (RepositoryImpl.java:171) становится RepositoryImpl.findUser: 171.

WatchDog для непрерывного мониторинга

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

Более универсальный метод отслеживания аномалий - WatchDog. Это наша библиотека, она отслеживает медленно выполняющиеся или зависшие задачи. Инструмент регистрирует задачу перед ее выполнением и отменяет регистрацию задачи после ее завершения.

Иногда задачи зависают: они занимают пять секунд вместо 100 миллисекунд. В таких случаях WatchDog периодически проверяет состояние задачи своим потоком и делает снимки трассировки стека.

На практике это выглядит так: если задача все еще зависает через пять секунд, мы видим это в журнале трассировки стека. Кроме того, система отправляет предупреждение, если задача зависает слишком долго - например, из-за тупика на сервере.

Заключение

В марте 2020 года, когда началась самоизоляция, количество пользователей Miro росло на 20% в день. Мы написали все функции, описанные в этой статье, за несколько дней; это не заняло много времени.

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

Присоединиться к нашей команде!

Хотели бы вы стать инженером в Miro? Ознакомьтесь с возможностями присоединиться к Инженерной команде.