Профилирование приложений Node.js
Существует множество сторонних инструментов для профилирования приложений Node.js, но во многих случаях проще всего использовать встроенный профилировщик Node.js. Встроенный профилировщик использует внутренний профилировщик V8, который с определенной периодичностью во время выполнения программы выборочно анализирует стек. Он записывает результаты этих выборок вместе с важными событиями оптимизации, такими как JIT-компиляции, в виде серии тиков:
code-creation,LazyCompile,0,0x2d5000a337a0,396,"bp native array.js:1153:16",0x289f644df68,~
code-creation,LazyCompile,0,0x2d5000a33940,716,"hasOwnProperty native v8natives.js:198:30",0x289f64438d0,~
code-creation,LazyCompile,0,0x2d5000a33c20,284,"ToName native runtime.js:549:16",0x289f643bb28,~
code-creation,Stub,2,0x2d5000a33d40,182,"DoubleToIStub"
code-creation,Stub,2,0x2d5000a33e00,507,"NumberToStringStub"
Раньше для интерпретации тиков требовался исходный код V8. К счастью, начиная с Node.js 4.4.0 появились инструменты, которые облегчают обработку этой информации без отдельной сборки V8 из исходного кода. Давайте посмотрим, как встроенный профилировщик может помочь получить представление о производительности приложения.
Для демонстрации использования тикового профилировщика мы будем работать с простым приложением Express. Наше приложение будет иметь два обработчика, один для добавления новых пользователей в нашу систему:
app.get('/newUser', (req, res) => {
let username = req.query.username || ''
const password = req.query.password || ''
username = username.replace(/[!@#$%^&*]/g, '')
if (!username || !password || users[username]) {
return res.sendStatus(400)
}
const salt = crypto.randomBytes(128).toString('base64')
const hash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512')
users[username] = { salt, hash }
res.sendStatus(200)
})
и другой для проверки попыток аутентификации пользователя:
app.get('/auth', (req, res) => {
let username = req.query.username || ''
const password = req.query.password || ''
username = username.replace(/[!@#$%^&*]/g, '')
if (!username || !password || !users[username]) {
return res.sendStatus(400)
}
const { salt, hash } = users[username]
const encryptHash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512')
if (crypto.timingSafeEqual(hash, encryptHash)) {
res.sendStatus(200)
} else {
res.sendStatus(401)
}
})
Обратите внимание, что это НЕ рекомендуемые обработчики для аутентификации пользователей в ваших приложениях Node.js и используются исключительно в демонстрационных целях. В целом, вы не должны пытаться создавать собственные криптографические механизмы аутентификации. Гораздо лучше использовать существующие, проверенные решения для аутентификации.
Теперь предположим, что мы развернули наше приложение, и пользователи жалуются на высокую задержку запросов. Мы можем легко запустить приложение со встроенным профилировщиком:
NODE_ENV=production node --prof app.js
и нагрузить сервер с помощью ab
(ApacheBench):
curl -X GET "http://localhost:8080/newUser?username=matt&password=password"
ab -k -c 20 -n 250 "http://localhost:8080/auth?username=matt&password=password"
и получить вывод ab:
Concurrency Level: 20
Time taken for tests: 46.932 seconds
Complete requests: 250
Failed requests: 0
Keep-Alive requests: 250
Total transferred: 50250 bytes
HTML transferred: 500 bytes
Requests per second: 5.33 [#/sec] (mean)
Time per request: 3754.556 [ms] (mean)
Time per request: 187.728 [ms] (mean, across all concurrent requests)
Transfer rate: 1.05 [Kbytes/sec] received
...
Percentage of the requests served within a certain time (ms)
50% 3755
66% 3804
75% 3818
80% 3825
90% 3845
95% 3858
98% 3874
99% 3875
100% 4225 (longest request)
Из этого вывода мы видим, что мы обрабатываем только около 5 запросов в секунду и что средний запрос занимает чуть менее 4 секунд в обе стороны. В реальном примере мы могли бы выполнять много работы во многих функциях от имени пользовательского запроса, но даже в нашем простом примере время может теряться на компиляцию регулярных выражений, генерацию случайных солей, генерацию уникальных хэшей из паролей пользователей или внутри самой структуры Express.
Поскольку мы запустили наше приложение с помощью опции --prof
, файл тиков был сгенерирован в том же каталоге, что и локальный запуск приложения. Он должен иметь формат isolate-0xnnnnnnnnnnnn-v8.log
(где n — цифра).
Чтобы понять этот файл, нам нужно использовать процессор тиков, включенный в бинарный файл Node.js. Чтобы запустить процессор, используйте флаг --prof-process
:
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt
Открытие processed.txt в вашем любимом текстовом редакторе предоставит вам несколько различных типов информации. Файл разделен на секции, которые в свою очередь разделены по языкам. Сначала мы посмотрим на секцию summary и увидим:
[Summary]:
ticks total nonlib name
79 0.2% 0.2% JavaScript
36703 97.2% 99.2% C++
7 0.0% 0.0% GC
767 2.0% Shared libraries
215 0.6% Unaccounted
Это говорит нам о том, что 97% всех собранных выборок произошли в коде C++ и что при просмотре других разделов обработанного вывода мы должны уделять больше внимания работе, выполняемой в C++ (в отличие от JavaScript). Имея это в виду, мы далее находим раздел [C++], который содержит информацию о том, какие функции C++ занимают больше всего времени ЦП, и видим:
[C++]:
ticks total nonlib name
19557 51.8% 52.9% node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
4510 11.9% 12.2% _sha1_block_data_order
3165 8.4% 8.6% _malloc_zone_malloc
Мы видим, что три верхние записи составляют 72,1% времени ЦП, затраченного программой. Из этого вывода мы сразу видим, что как минимум 51,8% времени ЦП занимает функция под названием PBKDF2, которая соответствует нашей генерации хэша из пароля пользователя. Однако может быть не сразу очевидно, как два нижних элемента учитываются в нашем приложении (или если это так, мы будем притворяться, что это не так, ради примера). Чтобы лучше понять взаимосвязь между этими функциями, мы далее посмотрим на раздел [Bottom up (heavy) profile], который предоставляет информацию о основных вызывающих объектах каждой функции. Изучив этот раздел, мы обнаруживаем:
ticks parent name
19557 51.8% node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
19557 100.0% v8::internal::Builtins::~Builtins()
19557 100.0% LazyCompile: ~pbkdf2 crypto.js:557:16
4510 11.9% _sha1_block_data_order
4510 100.0% LazyCompile: *pbkdf2 crypto.js:557:16
4510 100.0% LazyCompile: *exports.pbkdf2Sync crypto.js:552:30
3165 8.4% _malloc_zone_malloc
3161 99.9% LazyCompile: *pbkdf2 crypto.js:557:16
3161 100.0% LazyCompile: *exports.pbkdf2Sync crypto.js:552:30
Разбор этого раздела требует немного больше работы, чем приведенные выше необработанные значения тиков. Внутри каждого из приведенных выше «стеков вызовов» процент в столбце parent указывает процент выборок, для которых функция в строке выше вызывалась функцией в текущей строке. Например, в среднем «стеке вызовов» выше для _sha1_block_data_order
мы видим, что _sha1_block_data_order
встречалась в 11,9% выборок, что мы знали из необработанных подсчетов выше. Однако здесь мы также можем сказать, что она всегда вызывалась функцией pbkdf2 внутри модуля Node.js crypto. Мы видим, что аналогичным образом _malloc_zone_malloc
вызывалась почти исключительно той же функцией pbkdf2. Таким образом, используя информацию в этом представлении, мы можем сказать, что наше вычисление хэша из пароля пользователя учитывает не только 51,8% из приведенных выше данных, но и все время ЦП в трех наиболее часто выбираемых функциях, поскольку вызовы _sha1_block_data_order
и _malloc_zone_malloc
были сделаны от имени функции pbkdf2.
На данном этапе совершенно ясно, что генерация хэша на основе пароля должна стать целью нашей оптимизации. К счастью, вы полностью усвоили преимущества асинхронного программирования и понимаете, что работа по генерации хэша из пароля пользователя выполняется синхронно и, таким образом, связывает цикл событий. Это мешает нам работать над другими входящими запросами во время вычисления хэша.
Чтобы исправить эту проблему, вы вносите небольшое изменение в приведенные выше обработчики, чтобы использовать асинхронную версию функции pbkdf2:
app.get('/auth', (req, res) => {
let username = req.query.username || ''
const password = req.query.password || ''
username = username.replace(/[!@#$%^&*]/g, '')
if (!username || !password || !users[username]) {
return res.sendStatus(400)
}
crypto.pbkdf2(password, users[username].salt, 10000, 512, 'sha512', (err, hash) => {
if (users[username].hash.toString() === hash.toString()) {
res.sendStatus(200)
} else {
res.sendStatus(401)
}
})
})
Новый запуск эталонного теста ab выше с асинхронной версией вашего приложения дает:
Concurrency Level: 20
Time taken for tests: 12.846 seconds
Complete requests: 250
Failed requests: 0
Keep-Alive requests: 250
Total transferred: 50250 bytes
HTML transferred: 500 bytes
Requests per second: 19.46 [#/sec] (mean)
Time per request: 1027.689 [ms] (mean)
Time per request: 51.384 [ms] (mean, across all concurrent requests)
Transfer rate: 3.82 [Kbytes/sec] received
...
Percentage of the requests served within a certain time (ms)
50% 1018
66% 1035
75% 1041
80% 1043
90% 1049
95% 1063
98% 1070
99% 1071
100% 1079 (longest request)
Ура! Ваше приложение теперь обрабатывает около 20 запросов в секунду, примерно в 4 раза больше, чем с синхронной генерацией хэша. Кроме того, средняя задержка снизилась с 4 секунд до чуть более 1 секунды.
Надеюсь, благодаря исследованию производительности этого (признано искусственного) примера вы увидели, как процессор тиков V8 может помочь вам лучше понять производительность ваших приложений Node.js.
Вы также можете найти информацию о создании flame graph полезной.