На своей предыдущей работе я занимался поддержкой Java-сервиса, обеспечивавшего удалённую функциональность UI подобно RDP или Citrix. Этот сервис был устроен на основе сессий, состоявших из взаимосвязанных объектов Java, которые должны были очищаться или после выхода пользователя, или после истечения заданного таймаута.
На этапе планирования нагрузок мы обнаружили существенные траты памяти, о причинах которых я бы хотел рассказать в этой статье.
Планирование нагрузок
Часть моей повседневной работы с командой заключалась в планировании нагрузок на следующий год. Анализируя метрики использования, паттерны роста и исследования популяции, наши дата-саентисты могли прогнозировать, сколько пользователей у нас будет в следующем году.
Для определения инфраструктуры, необходимой для поддержки ожидаемой пользовательской базы, мы использовали чрезвычайно сложную формулу:
Так мы рассчитывали количество серверов, которое нам понадобится в предстоящем году.
На одном из совещаний по планированию нагрузок выяснилось, что из-за огромной популярности сервиса нас ждёт существенный рост количества пользователей. Наши расчёты показали, что для удовлетворения возросшего спроса нам потребуется больше серверов, чем у нас есть. Поэтому перед нами встала задача: разобраться, как уместить больше пользователей на каждый сервер, чтобы обеспечить поддержку предполагаемой пользовательской базы.
Чем мы ограничены?
Благодаря измерению нагрузок мы смогли выявить узкое место нашей системы, которым в данном случае оказалась память. При добавлении на сервер новых пользователей система начинала давать сбои под увеличившейся нагрузкой, и в конечном итоге у неё заканчивалась память. Понимание того, что мы ограничены памятью, было критически важным, потому что это направило наши усилия в сторону снижения потребления памяти.
Изучаем использование памяти
Приблизительную оценку потребления памяти на каждого пользователя мы вычисляли по формуле:
Взяв для примера числа из головы, мы можем получить следующее:
То есть для каждого пользователя требуется приблизительно 300 МБ памяти. Чтобы понять, как снизить это число, мы провели серьёзные измерения потребления памяти.
Чтобы выявить потенциальные возможности улучшений, мы начали с анализа дампа памяти Java. Поначалу мы исследовали дампы вручную, однако из-за большого количества серверов нам пришлось разработать скрипт для оптимизации процесса. При помощи этого скрипта мы могли выявлять тратящие память объекты, связанные с конкретными сессиями. Обнаруживая такие проблемы, мы могли избавиться от излишних трат и оптимизировать использование памяти в нашей системе.
Возможно, в другом посте я расскажу о скрипте и анализе, но пока мне бы хотелось подробнее рассмотреть одну лёгкую победу, которую дал нам анализ памяти.
Очень большая строка
Мы начали с изучения тысяч дампов памяти в поисках очень больших объектов. Самым крупным «китом» оказалась строка на 1,5 ГБ. Она выглядела примерно так:
Как видно из изображения, строка содержала множество символов обратной косой черты. Мы нашли много похожих строк меньшего размера, но эта была самой большой.
Изучая предназначение этой строки, я увидел, что у нас были классы, которые устроены вот так:
class Screen {
//...
private Screen previous;
public String toJson() {
JSONObject jo = new JSONObject();
//...
if (previous != null) {
jo.put("previous", previous.toJson());
}
//...
return jo.toString();
}
}
class Session {
//...
String currentScreen;
public void setUrl(Screen s) {
currentScreen = s.toJson();
}
}
Итак, у каждого экрана есть предыдущий экран, посещённый пользователем; это позволяет пользователю вернуться назад точно к тому же экрану, на котором он был ранее (с сохранением состояния, позиции скроллинга, уведомлений валидации и так далее). Также сессия пользователя имеет текущий экран, на котором находится пользователь, поэтому если пользователь повторно подключается к существующей сессии, он может вернуться к тому экрану, где находился.
Здесь возникает две архитектурные проблемы:
- Стек предыдущих экранов неограничен, то есть мы сохраняем всё больше и больше данных, пока сервер не взорвётся
- Выполняя
jo.put("previous", previous.toJson());
, мы преобразуем словарь JSON в строку. Так как поля JSON содержат кавычки, а эти кавычки при сохранении в строку необходимо сочетать со знаком перехода, они сохраняются как\"
. Эту обратную косую черту необходимо сочетать со знаком перехода, когда эта строка сохраняется внутри другой строки, что даёт нам\\\"
. Ещё пара таких повторений, и мы получаем\\\\\\\\\\\\\\\\"
Оказывается, что пользователь с сессией, состоящей из множества экранов, создавал String
currentScreen
огромных пропорций.Решение проблемы и продолжение
Мы разделили проблему на быстрое и долговременное решения:
Быстрое решение заключалось в усечении строки предыдущих экранов в случае превышения определённого количества символов (например, 100 МБ). Хотя такое решение было неполным и могло ухудшить UX, оно быстрое в реализации и простое для тестирования, к тому же позволило повысить надёжность (предотвратив ситуацию, в которой сессия займёт слишком большой объём и приведёт к выходу из строя сервера).
Долговременное решение заключалось в полном переписывании решения стека предыдущих экранов: мы создали отдельный реальный стек, имевший внутренние ограничения на размеры и собственную отчётность. Писать и тестировать его потребовалось дольше, а выпуск занял больше времени, но он предотвратил пустую трату памяти, а не просто скрыл строки-«киты» в виде ещё одного типа памяти (то есть очень глубоких объектов JSON).
Эпилог
Мы продолжили пользоваться инструментом анализа дампов памяти и обнаружили другие проблемы, но никакая из них не решалась так просто, как эта.
Основной вывод из этой истории для меня заключается в том, что иногда проверка подробностей использования программой ресурсов (например, изучение дампа памяти вместо простого измерения потребляемой памяти) критически важна для успеха и позволяет добиться немедленной выгоды.