Дано: старое http node.js приложение и возросшая нагрузка на него.

Стандартные решения проблемы: докинуть серверов, все переписать с 0, оптимизировать уже написанное.

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

Всех заинтересованных добро пожаловать под кат!

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

Запускать будем приложение в режиме 1 воркера (1 процесса), замеряя производительность старого кода и кода с оптимизациями — абсолютная производительность не важна, важна сравнительная производительность.

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

С другой стороны, можно взять реальный список запросов и пулять их все (например с помощью yandex-tank-а) — получим достоверный профиль нагрузки.

Но делая множество итераций оптимизации кода, куда удобнее использовать более простой и быстрый инструмент и один конкретный тип запросов (а после оптимизации одного запроса изучать следующий, и т.д.). Мой выбор — wrk. Тем более что в моем случае количество роутов не велико — проверить все по одному не сложно.

Сразу надо оговорится, что в плане блокирующих запросов, ожидания БД и т.п. приложение уже оптимизировано, все упирается в cpu: при тестах воркер потребляет 100% cpu.

На продашен серверах используется node.js версии 6 — с неё и начнем:

Requests/sec: 1210

Пробуем на 8й ноде:
Requests/sec: 2308
10я нода:
Requests/sec: 2590

Разница очевидна. Ключевую роль тут играет обновление версии v8 — множество плохо оптимизирующегося v8 кода осталось в прошлом. И чтобы не бороться с ветряными мельницами исчезнувшими в node.js v8 — лучше сразу обновиться, а потом уже заниматься оптимизацией кода.

Переходим собственно к поиску узких мест: на мой взгляд, лучший инструмент для этого — flamegraph. И с появлением проекта 0x получить flamegraph стало очень просто — запускам 0x вместо node: 0x -o ваш_скрип.js, делаем тест, останавливаем скрипт, смотрим результат в браузере.

Примерно так выглядит flamegraph тестируемого кода до оптимизаций:


Внизу фильтры, оставляем app, deps — только код приложения и сторонних модулей.

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

Разбираться будем с центральной, самой большой частью.

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

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

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

Удобное следствие такого подхода: можно видеть rps в идеальных условиях (при работающей только одной стартовой функцией rps близок к максимальному rps-у hellow world node.js приложения), и при дальнейшем перемещении заглушки-ответа вглубь приложения наблюдать вклад исследуемой функции в падение производительности в rps-ах.

Итак, оставляем только стартовую функцию, получаем:

Requests/sec: 16176



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

Переходим к следующей функции:

Requests/sec: 16111
Ничего не изменилось — погружаемся дальше:
Requests/sec: 13330


Наш клиент! Видно что задействованная функция getByUrl занимает значимую часть стартовой функции — что хорошо коррелирует с проседанием rps.

Смотрим внимательно что в ней происходит (включаем core, v8):

Много чего происходит… курим код, оптимизируем:

for (var i in this.data) {
  if (this[i]._options.regexp_obj.test(url)) return this[i];
}
return null;

превращаем в

let result = null;
for (let i=0; i<this.length && !result; i++) {
  if (this[i]._options.regexp_obj.test(url)) result = this[i];
}

В данном случае простой for значительно быстрее for..in

Получаем Requests/sec: 16015



Визуально функция «сдулась» и занимает значительно меньшую долю от стартовой функции.
В детальной информации по функции так же все значительно упростилось:

Идем дальше, к следующей функции

Requests/sec: 13316



В этой функции много array функций и, несмотря на существенное ускорение в последних версиях node.js, они все еще медленней простых циклов: меняем [].map и filter. на обычный for и получаем

Requests/sec: 15067



И так раз за разом, для каждой следующей функции.

Еще несколько пригодившихся оптимизаций: для хешей с динамически изменяемым набором ключей new Map() может быть на 40% быстрее обычного {};

Math.round(el*100)/100 в 2 раза быстрее чем toFixed(2).

В flamegraph-е для core и v8 функций можно увидеть как и малопонятные записи, так и вполне говорящие StringPrototypeSplit или v8::internal::Runtime_StringToNumber, и, если это значимая часть выполнения кода, попытаться оптимизировать, например просто переписать код, не выполняющий эти операции.

Например, замена split на несколько вызовов indexOf и substring может давать двойной выигрыш в производительности.

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

Тут может помочь вдумчивое изучение вывода node --trace_file_names --trace_opt_verbose --trace-deopt --trace_opt

Например, строчки вида

deoptimizing (DEOPT soft): begin 0x2bcf38b2d079 <JSFunction getTime… Insufficient type feedback for binary operation привели к строчке

return val >= 10? val: '0'+val;

Замена на

return (val >= 10? '': '0')+val;

исправила ситуацию.

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

github.com/P0lip/v8-deoptimize-reasons — список,
www.netguru.co/blog/tracing-patterns-hinder-performance — разбор типовых причин,
www.html5rocks.com/en/tutorials/speed/v8 — про оптимизации для v8, думаю справедливо и для текущего движка v8.

Но многие из проблем уже не актуальны для нового v8.

Так или иначе, после всех оптимизаций удалось получить Requests/sec: 9971, т.е. ускорится примерно в 2 раза за счет перехода на свежую версию node.js, и еще в 4 раза за счет оптимизации кода.

Надеюсь, этот опыт будет полезен кому-нибудь еще.

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


  1. ReklatsMasters
    19.04.2019 20:46

    Могу ещё порекомендовать использовать стандартную линуксовую утилиту perf. Несколько раз хорошо помогала в разы ускорять приложение.


    для хешей с динамически изменяемым набором ключей new Map() может быть на 40% быстрее обычного {};

    Это происходит из-за того, что резолв в объекте происходит медленнее, чем просто доступ по ключу. У меня была похожая ситуация, только динамические ключи объекта были именами функциями. И ускорить такие вызовы смог с помощью генерируемых функций, generate-function.


    Ещё могу добавить, что операции с созданием / пересозданием буферов очень медленные и стоит их избегать, если нужна высокая производительность.


    1. Odrin
      22.04.2019 13:52

      Это происходит из-за того, что резолв в объекте происходит медленнее, чем просто доступ по ключу.

      Не только из-за этого. Автор пишет именно про «для хешей с динамически изменяемым набором ключей», а добавление/удаление ключей в объекте приводит к изменению object shape, что в свою очередь является деоптемизацией.


  1. wtpltd
    20.04.2019 03:06

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


    1. sh84 Автор
      20.04.2019 09:27

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


  1. 1c80
    20.04.2019 10:46
    +1

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


  1. justboris
    20.04.2019 14:25

    На продашен серверах используется node.js версии 6

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


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


    1. sh84 Автор
      20.04.2019 14:36

      Да, все в 10й.


  1. justboris
    20.04.2019 14:26

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


    1. sh84 Автор
      20.04.2019 14:43

      i<a.length && !result — эквивалент раннего return-а.
      С точки зрения производительности принципиальной разницы с return-ом внутри быть не должно, и насколько помню, такой вариант и появился в процессе проверки этой теории.
      В любом случае главное тут замена цикла for..in на обычный for.


      1. justboris
        20.04.2019 22:01
        +1

        а есть ли прирост от того, что вы заменили return на такую проверку?

        если нет, то я бы оставил return, потому что оптимизации оптимизациями, но и понятность кода важна тоже.


        1. sh84 Автор
          22.04.2019 10:36

          Прироста нет.
          Пусть остается как есть, что бы было понятно очем мы говорили.
          Комментарий про то, что смысл правки в другом виде цикла я добавил.