Дыягназаванне медленных API на Node.js: зьмераюце запазджэнне пры выконанні перш чым працаваць над оптымаўкамі
Выучыце, як разбіваць повольны запит Node.js на затрымку цыклу адбывання запускаў, чаканне ў пуле, час выконання запыту і вызовы на наступных этапах, каб вы моглі выправіць той этап, які насправды забірае у вас час.
Адаптаванне канцэнтры выконвання займае майже секунду, пры тым як запас можаў CPU станавіць адно трэця часткі, память не выказвае значных проблем, а панелі керування базайнама даных маюць зелены колер. Налагоджэнне таго, што вы падозрываеце, рэдкасцю дапамагае. Шырокі практычны кераванні паказвае кожы этап запиту, ўсёлякія способы ведчыцтва, якія дапамагаюць швядка і надзеяна выконваць расследаванне.
Змеры часу запиту пры тым, як не караваць час выконвання
Выберыце просты маршрут, які завантажае замовленне па його ідэнтыфікаторы і вяртае яго у формате JSON.
app.get("/orders/:id", async (req, res) => {
const order = await getOrder(req.params.id);
res.json(order);
});
Падазроўваючы, што час адпаведзення становіць або 900 мс, замест таго, каб караваць Node.js, абгорніце запускаемы калл з performance.now(), ўбачыце, якую частку з абоўсуму часу ён займае.
const start = performance.now();
const order = await getOrder(req.params.id);
console.log(
`getOrder: ${performance.now() - start}ms`
);
res.json(order);
Часам цыфры швядка даюць адказ на пытанне:
Request: 910ms
getOrder(): 870ms
Node.js: 40ms
Паўна большасць часу на падготовку выканання знаходзіцца ў функцыі getOrder(); праця супакоўванага JavaScript-кода займае або каля 40 мс. Час выкорыстоўваецца на чаканне чагосьці з іншай часткі системы.
Низкая актывацыя CPU не значыць, што сервіс працюе належна
Панель статыстык, падобная да наступнай, выглядае абяцальна:
CPU: 32%
Memory: 48%
CPU паказвае колькісць выконванай роботы, а запит, який чакае на обробку, практычна не спалюе ресурсаў. Тыповыя прычыны, чаму процес Node.js чакае:
- з’яўленне з’язку з базай дадаў
- самы запит
- іншы HTTP-сервіс
- загальныя сетавыя операцыі I/O
- чарга паведамленняў
- блаканне
Слабы сервіс з незайнятым CPU зазвычай не працуе акіўна; ён заблокаваны.
Следзіце за затрымкай цыклу запуску, а не толькі за процэнтам актывацыі CPU
Кожны апвоўт у процэсе Node.js выкарыстовае адну жыцёвую цярпь, таму разлік межа часам, калі трэба было запусціць таймер, і часам яго фактычнага запуску, ёсць прымітным сигналам стану системы. Функцыя monitorEventLoopDelay з пакета node:perf_hooks аналізуе гэты разлік і стварае з яго гістаграму; у даным прыкладзе вона выкарыстоўвае разрэшэнне 20 мс і выдруквае значэння p95 і p99 кожныя пяць секунд.
import { monitorEventLoopDelay } from "node:perf_hooks";
const histogram = monitorEventLoopDelay({
resolution: 20
});
histogram.enable();
setInterval(() => {
console.log({
p95: histogram.percentile(95),
p99: histogram.percentile(99)
});
}, 5000);
Якщо выхід выглядае так, значыць жыцёвая цярпь працюе нормальна і не можа поясніць затрымку запыту у 900 мс:
p95 event-loop delay: 8ms
p99 event-loop delay: 14ms
Значэння у сотнях мілісекунд рассказваюць зовсім іншую історыю:
p95: 180ms
p99: 650ms
Зараз якась прычына монаполізуе жыцёвую цярпь. Тыповым вінаватцам є сінхронная, ресурсоўна-навантажлівая функцыя, якая вызываецца безпосередньа ўнутры обробніка запыту:
app.get("/report", (req, res) => {
const result = expensiveCalculation();
res.json(result);
});
Колі звільняецца expensiveCalculation(), жадныя іншы запиты на гэтым процесе не працююць. На апаратазе з калькольнікамі таксис можа застацца умерзенным, таму што адна перавантажаная ядро сумовычасовуецца з непрацюючымі, таму затрымка циклу часта ўсё-такі є болей адрадным показнікам, чым процэнт викорыстоўвання CPU. Звычны спосаб рашэння — пераказаць такую работу у пот працавальніка, у чергу заведамоў калі чы і ў аднальны сервіс. Аднаковы спосаб неяўнасці, калі планаванне мікрозадач узамест вычыслаў блокуе цикл, раскрываецца ў як process.nextTick можа блокаваць цыкл задач Node.js.
Калі вінаватыкам выступае база дадзенаў
Далей неабходна аднойчы таксама інструментаваць запит.
const start = performance.now();
const result = await db.query(
"SELECT * FROM orders WHERE customer_id = $1",
[customerId]
);
console.log(
`DB query: ${performance.now() - start}ms`
);
Тепер прычына ясна адносіцца да базы дадзенаў:
Total request: 850ms
Node.js: 15ms
DB query: 810ms
Serialization: 25ms
Перад перапісвам SQL, пераканацеся, чы не была запит слабой або чы запчатка чакала на з’яўленне з’язку.
Выкарыстанне всіх ресурсаў пула, яке выглядае як слабы запит
Разглядзім такі состав флоту:
API instances: 10
Connections/instance: 20
Total connections: 200
Потым прыходзіць сплеск трафіку:
Requests: 500
Active DB queries: 20
Waiting requests: 480
За адну інстанцыю можна запускаць толькі 20 запитоў, таму рэшта зачакваюць у пуле. Запит можа зайняць 30 мс, але запчатка ў канцы лісты чакае сотні мілісекунд, прычаму ўрабляецца з’язак з базай дадзенаў. Таймер прыкладнення паказвае:
DB call = 500ms
Сама база дадзенаў паказвае:
Query = 30ms
Слабы запит выклікае патрэбу ў індексах або кращым плане; дзяўны чаканне выклікае патрэбу ў збільшэнні розмеру пула, скорыях транзакцыях або меншых колькісах перавозкаў дадзенаў. Меркуйце кожны з этапаў окрема, а не як адну цифру „затрымкі базы дадзенаў“:
Connection wait
+
Query execution
+
Result processing
Большасць бібліятак пулаў адкрываюць данні пра колькасць вільных, всых і чакаючых запыткаў; ўывоз гэтых дадзенняў — гэта шырэйшы спосаб разлічваць гэтыя варыянты.
Службы, якія працуюць пасля, і последовныя запыткі await
Багато канцэнтраў складаюць адпаведзь з калькі завыскаў, адну за іншай:
const customer = await customerService.get(id);
const payment = await paymentApi.get(
customer.paymentId
);
const orders = await orderService.get(id);
Адзельны звяртанні часу паказвае адхыленняя:
Customer API: 40ms
Payment API: 700ms ← 🚨
Orders DB: 35ms
Node.js: 15ms
Служба платежа домінуе, і таму што кожны await чакае на пылейшы, затрымкі накаплююцца:
40ms + 700ms + 35ms + 15ms
≈ 790ms
Незалежныя запыткі можаць пачацца адночасна. Для пошуку кліента і замовлення патрэбны толькі id, таму Promise.all адбывае іх одночасна:
const [customer, orders] = await Promise.all([
customerService.get(id),
orderService.get(id)
]);
Запытка платежа все ўсё чакае, адколькі яй патрэбны customer.paymentId. Не паралелізаваць запыткі без раздумоў: дадатковая канкурэнцыя паўтарае навантажэння на пулы, API і памяць, а пад высокім навантажэнням гэта можа пагоршыць ситуацыю.
Перцентыляры паказваюць тое, што хавае сярэднія
Панель керування, якая адналежыць да гэтага, здаецца прыемной:
Average latency: 120ms
Відображэнне перцентыляраў для таго ж трафіку менш заспакойваюча:
p50: 80ms
p95: 180ms
p99: 2.8s
Сярэдній паказвае звычайную запитоў; а ўзлёты паказваюць корыстнікаў, якія сталкнуліся з перапоўненым ресурсам або повольным залежным элементам. Следзіце за распадам:
p50
p95
p99
Інструменты, якія дапамагаюць, а не шкодзяць
Зважэнне коштуе CPU, прыемлівасці і уваги. Калькі прывычкі дапамагаюць яго застаўляць корыстным.
Зважайце на межах, а не ў кожным хелперы
Зважэнне кожной функцыі такім спосабам дае больш шуму, чым корисных даных:
const start = performance.now();
// ...
console.log(performance.now() - start);
Зважайце там, дзе запит пераходзіць у іншую систему, таму што самэ там адбываецца чаканне:
HTTP request
↓
Database
↓
External API
↓
Queue
↓
Response
Контролюйце логаванне за кожны запит
Лінія лога за кожны запит становіцца дорогай пры вялікай праўоце:
console.log({
path: req.path,
duration,
userId: req.user.id
});
Ўзайце структуравана логгаванне з рэвэлямі і выбірам, і абсалютна не павінна быць ў логах:
passwords
tokens
authorization headers
payment information
Час выканання — гэта не дыягназ
Прамеркі гэтага типу не падтверджуюць, што запит SQL зайняў столькі часу:
DB: 700ms
Этот перыяд можа включаць калькі разных элементаў:
Connection acquisition
+
Query execution
+
Network
+
Result transfer
+
Client-side processing
Калі цяжкае число вас здзівляе, разбейце яго на часткі пры выконанні дзеяння з ям.
Ўтримваюцца ад метрык з вялікай кардынальнасцю
Пазначэнне метрыкі затрымкі ідэнтыфікаторам пользователя выглядае безнебяжна:
http_request_duration{user_id="123456"}
У разе мільйонаў корыстнікаў гэта значыць мільйоны тайм-серый. Узяце заместа гэтага межаваныя выміры:
route
method
status_code
service
Якша пазначаная тайм-серыя выглядае так:
http_request_duration{
route="/orders/:id",
method="GET",
status="200"
}
Дзялейні даны за кожны запит павінны быць у логах і трэйсах.
Свядома выбірайце прыклады
Звычная стратэгія аналізу працюе з часткай звычнага трафіку і всім, што ўважаецца значным:
Normal traffic → sample a percentage
Errors → capture aggressively
Slow requests → capture aggressively
Спявазанне залежыць ад трафіку, інструментаў і бюджэту; мета — наявнасць достатніх доказоў для адказа на прычыны несправнасцей, а не ўсяго спектру падазроў.
Пораўняйце стан да і пасля змян, на адной часовай шкале
Фраза «Здаецца, што быстрэй» не ўважаецца доказам. Запісуйце пэрцентыльны показнік да змян:
Before:
p95 = 820ms
І знову пасля яе:
After:
p95 = 410ms
Потым пераканайцеся, што нічога іншаго не зместілася ў неправым напрамку:
error rate
CPU
memory
DB load
connection pool usage
Больш шырокі запит, які удвое збільшае викорыстоўвання ресурсаў чы пярасунак у базе дадзеных, проста пераносіць проблему.
Следзіце за адным запитам праз трэйсінг
Показнік на рывень маршруту паведамляе толькі аб загальнай кантэйнцыі:
GET /orders = 980ms
Распраўны трэйс разбівае адны і той жа запит на часткі, так што вартасць кожнага етапу можна побачыць за мгновенне:
GET /orders
│
├── Node.js processing 15ms
├── DB connection wait 120ms
├── PostgreSQL query 40ms
├── Payment API 700ms
├── JSON serialization 20ms
└── Network 5ms
───
900ms
Такыя разпады працы варта памятаць: метрыкі паведамляюць вас, калі ўсё не так, а трэсы паказваюць, дзе самэ гэта выклікае проблемы.
Спіс пераконтальных пунктав для аналізу шляху запита
Калі зростае латэнцыя, не трэба спачатку дадзіваць серверы чы сістэмныя процесоры. Аналізуйце шлях, які праходзіць запит, і вимеравайце кожны етап:
Request
↓
Queue / Load Balancer
↓
Node.js
↓
Event Loop
↓
Connection Pool
↓
Database
↓
External APIs
↓
Network
↓
Response
На кожнам этапе задавайце конкрэтныя запитанні:
Is the event loop blocked?
Are requests waiting for connections?
Are database queries slow?
Are downstream APIs slow?
Are we waiting on network I/O?
Is the queue growing?
Is garbage collection causing pauses?
Is the response itself expensive to serialize?
Кожна з гэтых пытанняў можа быць адпаведзена на аднойчыны дадзенымі, таму няма прычыны здагадваліцца.
Што зазвычай паказваюць доказы
Верніцеся да пачатковага сцэнарыю:
API latency: 900ms
CPU: 35%
Memory: 48%
Пасля вимеравання кожнага этапа картіна стае абсалютна іншай:
Event loop: 12ms
DB query: 35ms
Connection wait: 120ms
Payment API: 700ms
Serialization: 20ms
Network: 5ms
Няма можлівасці перапісваў пад час выконання, таму дапаможа большы экземпляр або дадатковая памяць. Галоўныя прычыны затрымака — 700 мс, якія трэба для адпраўкі платежа (кашуванне, таймаут з альтэрнатываю або перанесенне ціяў за межы шляху запита), і 120 мс, якія трэба на чаканне з’ўязку (размер пула або колькасць запытанняў).
Ключовыя выводы
Інстынктыўны цикл выглядае так:
Slow API
↓
Add more servers
Эфектыўны цикл выглядае так:
Slow API
↓
Measure
↓
Break down latency
↓
Find the bottleneck
↓
Fix the bottleneck
↓
Measure again
- Спачатку змерыце час выконання цэлага запита, а потым раздзеліце яго на часткі ў кожны момент, калі він пакідае процес.
- Спрыяйце затрымкам у цыклі запаўтакоў як главным сигналам стану Node.js; пацэнтаж CPU можа маскаваць адзін заблокаваны ядро.
- Раздзеліце час чакання на з’ўязку ад выконання запытання, прычаму не трэба ўжо займатыся SQL.
- Оцэніце затрымку па показначыках p95 і p99, а не па сярэднях.
- Зберагачыце метрыкі з низкай кардинальнасцю, логі — у выбраных прыкладах без секрэтных даноў, а деталі кожнага запита — у трэйсах.
Слабы система ўможліва кераваць, калі яе можна бачыць; да таго часу кожная змена ўжо є прыпускам. Чыбаць, якія паследкі трэба прыоритэтаваць, калі вядомы «вузкі месца», можна ў фрэймворку з прыорітетам для паспекты работы Node.js API.