На своей предыдущей работе я занимался поддержкой Java-сервиса, обеспечивавшего удалённую функциональность UI подобно RDP или Citrix. Этот сервис был устроен на основе сессий, состоявших из взаимосвязанных объектов Java, которые должны были очищаться или после выхода пользователя, или после истечения заданного таймаута.

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

Планирование нагрузок


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

Для определения инфраструктуры, необходимой для поддержки ожидаемой пользовательской базы, мы использовали чрезвычайно сложную формулу:

$\text{Количество серверов} = { \text{Количество пользователей} \over \text{Пользователей на сервер} } * \text{Запас прочности}$


Так мы рассчитывали количество серверов, которое нам понадобится в предстоящем году.

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

Чем мы ограничены?


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

Изучаем использование памяти


Приблизительную оценку потребления памяти на каждого пользователя мы вычисляли по формуле:

$\text{Память на пользователя} = { \text{Память сервера} \over \text{Количество пользователей} }$



Взяв для примера числа из головы, мы можем получить следующее:

$\text{Память на пользователя} = \text{300 МБ} = { \text{90 ГБ} \over \text{300} }$


То есть для каждого пользователя требуется приблизительно 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();
  }
}

Итак, у каждого экрана есть предыдущий экран, посещённый пользователем; это позволяет пользователю вернуться назад точно к тому же экрану, на котором он был ранее (с сохранением состояния, позиции скроллинга, уведомлений валидации и так далее). Также сессия пользователя имеет текущий экран, на котором находится пользователь, поэтому если пользователь повторно подключается к существующей сессии, он может вернуться к тому экрану, где находился.

Здесь возникает две архитектурные проблемы:

  1. Стек предыдущих экранов неограничен, то есть мы сохраняем всё больше и больше данных, пока сервер не взорвётся
  2. Выполняя jo.put("previous", previous.toJson());, мы преобразуем словарь JSON в строку. Так как поля JSON содержат кавычки, а эти кавычки при сохранении в строку необходимо сочетать со знаком перехода, они сохраняются как \". Эту обратную косую черту необходимо сочетать со знаком перехода, когда эта строка сохраняется внутри другой строки, что даёт нам \\\". Ещё пара таких повторений, и мы получаем \\\\\\\\\\\\\\\\"

Оказывается, что пользователь с сессией, состоящей из множества экранов, создавал String currentScreen огромных пропорций.

Решение проблемы и продолжение


Мы разделили проблему на быстрое и долговременное решения:

Быстрое решение заключалось в усечении строки предыдущих экранов в случае превышения определённого количества символов (например, 100 МБ). Хотя такое решение было неполным и могло ухудшить UX, оно быстрое в реализации и простое для тестирования, к тому же позволило повысить надёжность (предотвратив ситуацию, в которой сессия займёт слишком большой объём и приведёт к выходу из строя сервера).

Долговременное решение заключалось в полном переписывании решения стека предыдущих экранов: мы создали отдельный реальный стек, имевший внутренние ограничения на размеры и собственную отчётность. Писать и тестировать его потребовалось дольше, а выпуск занял больше времени, но он предотвратил пустую трату памяти, а не просто скрыл строки-«киты» в виде ещё одного типа памяти (то есть очень глубоких объектов JSON).

Эпилог


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

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

Комментарии (63)


  1. headliner1985
    09.04.2023 10:38

    Не смотрели в сторону использования редиса для хранения сессии? В томкате есть готовый коннектор.

    Понятно что вам нужно переписывать решение с хранением предыдущих view но тем не менее.


    1. Metotron0
      09.04.2023 10:38
      +5

      Это же перевод. Не "вам", а "им".


  1. aanovik42
    09.04.2023 10:38
    +76

    TL;DR: "Мы создали бесконечную историю действий для пользователей и не подумали, что она занимает бесконечно много памяти." — 6+ years Facebook SRE.

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


    1. grobitto
      09.04.2023 10:38
      +14

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

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


      1. aanovik42
        09.04.2023 10:38
        +3

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


      1. MyraJKee
        09.04.2023 10:38
        +1

        Строкой, может быть чтобы не десериализовывать )) лишний раз.

        Хотя ситуация - дичь


        1. grobitto
          09.04.2023 10:38
          +2

          Ага, иначе статья называлась бы "Как мы оптимизировали загрузку процессора")))


    1. Wesha
      09.04.2023 10:38
      +1

      Вот да. Плохо спроектированное решение (не подумали об ограничении глубины истории) привело к трагичным (для памяти) результатам — какая внезапная неожиданность!


  1. LedIndicator
    09.04.2023 10:38
    +3

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


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


    "name": "\"Вася\""

    Был бы повод насторожиться, как минимум. Но тесты, я так понимаю, написаны на отцепись, если вообще есть.


    1. grobitto
      09.04.2023 10:38
      +4

      Не было неправильного конвертирования, в том и дело

      У них вложенный json хранится строкой и каждый раз при сериализации в строку json удваивал эскейп слеши


      1. LedIndicator
        09.04.2023 10:38

        Да, я понимаю.
        Я поэтому и написал "условно неправильное". Правильное, с точки зрения спецификации, но не то, что ожидалось разработчиком.


  1. Ainyru
    09.04.2023 10:38
    +16

    Одно ужасное решение заменено другим не менее ужасным.


    1. Vsevo10d
      09.04.2023 10:38
      +10

      Хорошо, что автор перевода на Хабре не сделал третье ужасное решение и не сгенерил КДПВ в Кандинском 2.1 (с гроба бедного Василия Васильевича уже можно электричество добывать, наверное).


      1. ris58h
        09.04.2023 10:38

        Просто поблочил превью статей с помощью uBlock. Полет нормальный.


      1. dragonnur
        09.04.2023 10:38

        Там уже от трения об воздух разаряды разделяются, молнии, вот это вот всё...


    1. MiraclePtr
      09.04.2023 10:38
      +6

      Скажите спасибо что они не решили ничего не трогать и просто прикрутить какой-нибудь gzip или zstd для хранения строк с повторами :)


  1. Rive
    09.04.2023 10:38
    +2

    Я видел экзотичное решение похожей проблемы экономии памяти: массивные объекты в памяти или Redis архивировались gzip и распаковывались при чтении.


    1. grobitto
      09.04.2023 10:38
      +3

      В целом отличное решение, особенно если чанки небольшие, как тут


      1. KReal
        09.04.2023 10:38

        Согласен. Решение не экзотичное, а вполне приличное)


    1. saboteur_kiev
      09.04.2023 10:38
      +8

      Если у вас пишет, что недостаточно места при выполнении команды
      cat /dev/zero > file.log

      то попробуйте
      cat /dev/zero | gzip >file.log


      1. Wesha
        09.04.2023 10:38
        +1

        Сюрпрайз человеку, решившему этот лог почитать, готовите?


        1. vassabi
          09.04.2023 10:38

          мда .... а ведь это еще не ИИ!
          (размышляет - как спросить ЧатЖПТ какие еще могут быть короткие варианты для "я олбанский вирус, запустите меня пожалуйст")


    1. sved
      09.04.2023 10:38
      +1

      Хранение серилизованных объектов в памяти (иногда с упаковкой) для экономии памяти - типичное решение, которое используется сплошь и рядом


  1. WhiteApfel
    09.04.2023 10:38
    +1

    Кодировать в base64 и не экранировать сотню раз подряд? ????

    Вообще не понял, зачем и почему возникает вложенное экранирование


    1. KuzCode
      09.04.2023 10:38
      +2

      Вложенное экранирование возникает изза того, что они ложили json как строку внутрь другого json


      1. WhiteApfel
        09.04.2023 10:38
        +2

        Какой чел вообще додумался делать матрёшку... Это же не возникла мысль "А не делаю-ка я фигню". Чё со свободным доступом к состояниям?


        1. nikolayv81
          09.04.2023 10:38

          Возможно первоначально было ограничение на 1 предыдущий экран, а потом с годами кто-то доработал по заказу пользователей, кто знает...


    1. mayorovp
      09.04.2023 10:38

      Вложенное кодирование в base64 ещё хуже.


  1. LaRN
    09.04.2023 10:38
    -1

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


  1. mayorovp
    09.04.2023 10:38
    +5

    Я всегда знал что укладывать json внутрь другого как строку — некрасиво, а теперь буду знать что именно в нём некрасивого :-)


    1. TimsTims
      09.04.2023 10:38

      Я в одном крупном банке видел как была с виду нормальная REST API, в ответ выдававшая json, внутри которого был svg-файл. Со всеми экранированиями, почти как в статье. Размер увеличивался примерно раза в полтора-два, не говоря уже про то, что потом этот svg просто отображался как картинка в браузере.


  1. stitrace
    09.04.2023 10:38
    +9

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


  1. klvov
    09.04.2023 10:38
    +1

    как-будто получилось O(n^2) по памяти, как в задаче про шахматную доску и зерна пшеницы.


    1. mayorovp
      09.04.2023 10:38
      +5

      Только O(2^N)


  1. me21
    09.04.2023 10:38
    +1

    И там и здесь O(2^n), что хуже.


  1. panzerfaust
    09.04.2023 10:38
    +5

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


    1. vadimr
      09.04.2023 10:38
      +5

      Да нет, это классический индусский кодинг. Причём слово “индусский” здесь не указывает на национальность или вероисповедания.


    1. BugM
      09.04.2023 10:38
      +4

      Классические тесты такое не отловят. На тестовых данных все ок будет.


      1. panzerfaust
        09.04.2023 10:38
        -4

        Не понимаю, о каких "классических" тестах вы говорите. Превращение \" в \\\" это тривиальный кейс для любого юнит-теста над строками.


        1. mayorovp
          09.04.2023 10:38
          +7

          В данном случае внутренний формат хранения состояния был никому не интересен (был бы интересен — такую фигню бы не нагородили), а значит и тестов на формат не было бы.


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


          1. GrigorGri
            09.04.2023 10:38

            Разве тест вида: .Put, Put, assert(json, "{...}") где {...} - текст с двойными кавычками не самый что ни на есть классический unit test на этот код?


            1. Aldrog
              09.04.2023 10:38
              +2

              А в качестве текста для сравнения json, скопированный из отладочного вывода?

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


              1. GrigorGri
                09.04.2023 10:38
                -1

                Как видно из 300MB, формат влияет на внешнее поведение. Ну и да, смысл в том чтобы задуматься, что там должно быть. Так то можно любой assert поломать просто копируя actual в excepted.


                1. Aldrog
                  09.04.2023 10:38
                  +1

                  Ну и да, смысл в том чтобы задуматься, что там должно быть.

                  Вот в комментарии, на который вы изначально отвечали, человек и пишет, что если бы этот формат считался важным и о нём задумывались, то такой ерунды бы и не получилось. Тут (не)написание тестов точно не является первопричиной проблемы.


                  Как видно из 300MB, формат влияет на внешнее поведение.

                  И я бы предпочёл видеть в проекте тесты, валидирующие нужные свойства (в данном случае, в первую очередь performance тесты на уровне всего приложения), а не просто проверки, что внутреннее представление получилось именно таким, как задумывалось.


                  1. Wesha
                    09.04.2023 10:38

                    если бы этот формат считался важным и о нём задумывались

                    — Шеф, но я не думаю...
                    — Вот этим я от вас и отличаюсь! (c) Bonkers


                  1. GrigorGri
                    09.04.2023 10:38

                    Ну мое мнение - важны разные типы тестов. Performance тест поймал бы это только если бы кто-то запустил проверку с большим количеством предыдущих экранов (сотни) и не факт что кто-то стал бы добавлять этот сценарий.

                    А проверить unit тестом этот метод ничего не стоит, тут ведь и запись состояния идет. Выглядит на мой взгляд логично проверить, что все состояние там записано корректно.

                    По TDD так и вовсе сложно представить, что этот баг возникнет.


                    1. BugM
                      09.04.2023 10:38
                      +2

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

                      Перформанс тест вероятно тоже ничего не найдёт. Тут завит от того догадались сделать огромную историю или нет. Угадайка чистая. Хотя вероятно если бы догадались, то кто-нибудь такой кейс прогнал бы локально и сразу бы починил.


                      1. GrigorGri
                        09.04.2023 10:38
                        -1

                        Меня побуждает написать unit test на state то, что метод public.

                          public String toJson() {...jo.put("previous", previous.toJson());
                        }

                        Будь это что то вроде

                        public class State
                        {
                          public void addScreen(Screen screen) {}
                          public Screen returnPreviousScreen() {}
                        }

                        то и тестировать можно было бы только как вы описали.

                        Но ок, согласен, тут сложно предугадать нужен ли тест или нет.


  1. stalinets
    09.04.2023 10:38
    +21

    Бесит, как в современном интернете работают кнопки "назад-вперёд" в браузере. Нажал на ссылку, и пока страница грузится - заметил краем глаза что-то интересное на странице, с которой уходишь. Нажал "назад" - а там всё берётся не из кеша, а опять тянется с интернета, и динамически меняется, того блока, что заинтересовал, больше нет и неясно как на него выйти, там теперь что-то другое. Потом, после возвращения на предыдущую страницу я хочу просто мгновенно оказаться там же, где был, а вместо этого (при условии небыстрого интернета) происходит следующее: сначала я попадаю на свежеперезагрузившуюся страницу в самый верх, начинаю быстро скроллить до места, где остановился, тут начинают подгружаться картинки, фреймы с рекламой и текст, который я читаю, дёргается и прыгает вверх-вниз, и наконец, когда всё загрузилось, срабатывает скрипт, который по идее должен поставить скроллинг страницы в старое положение, но не учитывает, что пользователь уже давно сам скроллит и читает, и текст в очередной раз выдёргивают и помещают скроллинг хрен пойми на какую позицию, заставляя снова скроллить и искать где я остановился. А если пропал интернет, я тупо не могу сделать "назад", хотя казалось бы, что проще - возьми данные страницы из кеша! И сохранение страниц на диск - та же боль: вместо того, чтобы молча оффлайн сохранить страницу в файл, браузер начинает её заново выкачивать, часто при этом происходит какая-нибудь ошибка и сохранение не удаётся (где такое видано?), а в мобильных браузерах и вовсе по непонятным причинам поубирали экспорт страницы в .html или хотя бы в .pdf. Зачем всё это?


    1. toxicdream
      09.04.2023 10:38
      +1

      Вот да, полностью поддерживаю.

      Даже не понятно какой процент контента грузится из кэша при переходе на предыдущую страницу. Нам бы хотелось чтобы было 100%.

      Но увы, SEO, маркетинг и эффективные менеджеры победили.

      Там в KPI есть метрики которые крутятся если есть загрузка страницы. Из-за этих метрик появилось такое явление как "протухание" страницы. Не сессии, а страницы!

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


      1. Vest
        09.04.2023 10:38

        Насчёт процента не скажу, но у Хрома, вроде бы, появился хороший анализатор bf-cache, вы сможете сами посмотреть почему страница не кешируется. Там кое-как расписано (просто иногда гуглить надо, чтобы расшифровать):

        https://developer.chrome.com/docs/devtools/application/back-forward-cache/


    1. mayorovp
      09.04.2023 10:38
      +2

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


    1. binque
      09.04.2023 10:38
      +1

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

      В Opera есть сохранение для просмотра в оффлайне и экспорт в PDF.


      1. mayorovp
        09.04.2023 10:38
        +1

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

        Вот это как раз древняя проблема, из времён до history api


      1. Alex_Hi
        09.04.2023 10:38

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


    1. kaka888
      09.04.2023 10:38

      Ничего не поубирали. У себя в хроме только что проверил - кнопка скачивания страницы имеется.


    1. Tom910
      09.04.2023 10:38
      +1

      В браузере тоже так подумали и не так давно внедрили новую фичу - Back/forward cache, которая сохраняет снапшоты с предыдущими страницами и позволяет быстро восстанавливать. Но, нужно чтобы сайт не использовал некоторые фичи JS - https://web.dev/bfcache/


      1. sumanai
        09.04.2023 10:38

        В браузере

        В хроме.


  1. comdivuz
    09.04.2023 10:38

    Как джавист со стажем скажу, что когда видишь вопрос "почему на клиента 300mb?" и при этом рядом есть слово Java, то ответ сам собой напрашивается )))) Загадка не сложная )))


  1. Kostik_s_v
    09.04.2023 10:38

    Приманил заголовок, а всё так банально…


  1. IGR2014
    09.04.2023 10:38

    Очень прошу прощения, но:

    1960-е годы: Бортовой компьютер Аполлона с 72 Кб памяти доставляет человека на луну и обратно...
    2023-й год: *строчка на 1.5 Гб из обратных слешей*

    Где мы свернули не туда ?!)))


    1. K0styan
      09.04.2023 10:38
      -2

      В той точке, в которой фразу про "640 кБ хватит каждому" стали воспринимать как повод для стёба, а не как руководство к действию)


    1. vassabi
      09.04.2023 10:38
      +1

      1) зато код можно писать как текст

      2) зато код можно писать не на той машине, "которая полетит" (которая с уникальной и неповторимой архитектурой команд, способом храниения, выполнения, задержек и других ограничений, о которых нужно помнить), а которая у тебя дома (вот скажите - как часто джаваскрипт программисты сидя за десктопами размышляют о том, как их объекты выглядят в памяти мобилок?)