Галоўная / Артыкулы / Дыягназаванне медленных API на Node.js: зьмераюце запазджэнне пры выконанні перш чым працаваць над оптымаўкамі

Дыягназаванне медленных API на Node.js: зьмераюце запазджэнне пры выконанні перш чым працаваць над оптымаўкамі

Выучыце, як разбіваць повольны запит Node.js на затрымку цыклу адбывання запускаў, чаканне ў пуле, час выконання запыту і вызовы на наступных этапах, каб вы моглі выправіць той этап, які насправды забірае у вас час.

1841 слоў

Адаптаванне канцэнтры выконвання займае майже секунду, пры тым як запас можаў 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.