Пошук уроків, статей та іншого контенту
Навчитеся профілювати запити, аналізувати hit ratio та latency й послідовно усувати вузькі місця.
Оптимізація продуктивності починається не з вибору кешу чи збільшення кількості серверів, а з відповіді на три запитання:
Яка частина запитів повільна?
На якому етапі обробки виникає затримка?
Чи підтверджує вимірювання, що внесена зміна справді допомогла?
Типова система обробляє запит через кілька етапів:
Клієнт
↓
Балансувальник
↓
API-сервіс
├─ перевірка автентифікації
├─ читання кешу
├─ запит до бази даних
├─ виклик зовнішнього сервісу
└─ серіалізація відповідіЗагальна затримка запиту складається не лише з часу виконання коду:
[ L_{total} = L_{network} + L_{queue} + L_{application} + L_{database} + L_{external} ]
Тому збільшення продуктивності окремої функції не завжди зменшує час відповіді системи.
Latency — час від початку обробки запиту до формування відповіді.
Для одного endpoint варто вимірювати:
кількість запитів;
кількість успішних відповідей;
кількість помилок;
p50 — медіанну затримку;
p95 — затримку, яку не перевищують 95% запитів;
p99 — затримку, яку не перевищують 99% запитів;
максимальну затримку;
розподіл за HTTP-статусами та типами операцій.
Середнє значення часто приховує проблему. Наприклад, 99 запитів можуть тривати 50 мс, а один — 5 секунд:
Середня latency ≈ 99,5 мс
p99 ≈ 5 секундДля користувача, який потрапив у цей один повільний запит, система не є швидкою. Саме тому для SLO зазвичай важливі перцентилі, а не лише середнє.
Високий p50 означає, що повільною є більша частина запитів.
Нормальний p50, але високий p95 або p99 означає нестабільність або окремий клас повільних запитів.
Високий p99 часто пов’язаний із чергами, блокуваннями, GC, повільними залежностями або повторними спробами.
Якщо зростають усі перцентилі, проблема, ймовірно, системна.
Якщо зростає лише p99, потрібно досліджувати рідкісні сценарії.
Для кешу:
[ HitRatio = \frac{CacheHits}{CacheHits + CacheMisses} ]
Наприклад, 900 влучань і 100 промахів дають:
[ HitRatio = \frac{900}{1000} = 90% ]
Важливо чітко визначити знаменник. Не слід змішувати:
запити, які взагалі могли використовувати кеш;
запити, навмисно виконані в обхід кешу;
помилки кеша;
запити, для яких кеш не підтримується.
Корисно вимірювати окремо:
cache_hits;
cache_misses;
cache_errors;
cache_bypasses;
кількість байтів, прочитаних із кешу;
кількість запитів до повільного джерела після промаху.
Високий hit ratio не гарантує низької latency. Кеш може мати високий показник влучань, але сам бути перевантаженим або розташованим через повільний мережевий канал.
Так само низький hit ratio не завжди є помилкою. Якщо дані майже не повторюються, кеш може не підходити для цього endpoint.
Throughput — кількість запитів, яку система обробляє за одиницю часу:
requests per second (RPS)Latency і throughput потрібно аналізувати разом. Збільшення RPS може призвести до:
зростання черг;
вичерпання пулу з’єднань;
збільшення часу очікування бази даних;
зростання p95 і p99;
появи тайм-аутів.
Система може мати низьку latency під малим навантаженням і різко деградувати під реальним.
Спочатку потрібно зафіксувати, що саме вважається проблемою:
99% запитів GET /orders повинні завершуватися швидше за 300 мсДалі потрібно уточнити:
який endpoint повільний;
для якого регіону або клієнта;
за яких параметрів запиту;
у який час;
чи проблема постійна;
чи збігається вона зі зростанням навантаження.
Без цього легко оптимізувати не той сценарій.
Перед зміною системи зафіксуйте:
RPS;
p50, p95, p99;
частку помилок;
hit ratio;
час кожної залежності;
використання CPU та пам’яті;
кількість з’єднань;
довжину черг;
кількість тайм-аутів.
Базова лінія потрібна для порівняння. Формулювання «стало швидше» без чисел не є результатом профілювання.
Для кожного запиту потрібно вимірювати не тільки загальну latency, а й окремі етапи:
Загальна latency: 820 мс
├─ автентифікація: 5 мс
├─ читання кешу: 4 мс
├─ SQL-запит: 190 мс
├─ зовнішній API: 600 мс
└─ серіалізація: 21 мсУ такому випадку оптимізація серіалізації майже не вплине на результат. Основне вузьке місце — зовнішній API.
Важливо також враховувати паралельні операції. Якщо два етапи виконуються одночасно, їхня сума не дорівнює latency:
Загальна latency =
max(час бази даних, час зовнішнього API)Приклад правильної гіпотези:
p95endpoint зростає через очікування з’єднання з базою даних, оскільки під навантаженням пул з’єднань повністю зайнятий.
Приклад надто нечіткої гіпотези:
База даних працює повільно.
Одна гіпотеза допомагає провести контрольований експеримент і зрозуміти, яка зміна дала результат.
Не слід одночасно:
збільшувати кількість серверів;
змінювати TTL кешу;
переписувати SQL-запит;
змінювати розмір пулу;
додавати повторні спроби.
Інакше буде неможливо визначити причину покращення або погіршення.
Після зміни порівняйте:
ті самі перцентилі;
той самий тип трафіку;
ту саму частку кешованих і некешованих запитів;
ту саму кількість помилок;
поведінку під навантаженням.
Оптимізація вважається підтвердженою лише тоді, коли зміна покращує потрібну метрику і не створює регресії в інших.
Вимірювання слід виконувати монотонним годинником. Для Node.js підходить performance.now(), оскільки він призначений для вимірювання інтервалів часу.
Не варто покладатися на різницю між значеннями календарного часу: системний годинник може бути скоригований.
Кожна операція, що може бути вузьким місцем, повинна мати власне вимірювання:
читання кешу;
звернення до бази даних;
виклик зовнішнього сервісу;
серіалізація;
очікування черги.
Приклад нижче моделює API-запит, який спочатку читає кеш, а після промаху звертається до бази даних і зовнішнього сервісу. Програма обчислює p50, p95, p99 для загальної latency та окремих етапів.
const { performance } = require('node:perf_hooks');
const sleep = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
class Metrics {
constructor() {
this.requestLatencies = [];
this.stageLatencies = new Map();
this.cacheHits = 0;
this.cacheMisses = 0;
}
recordStage(name, latency) {
if (!this.stageLatencies.has(name)) {
this.stageLatencies.set(name, []);
}
this.stageLatencies.get(name).push(latency);
}
percentile(values, percentile) {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
summary(values) {
return {
count: values.length,
p50: this.percentile(values, 50).toFixed(2),
p95: this.percentile(values, 95).toFixed(2),
p99: this.percentile(values, 99).toFixed(2),
max: Math.max(...values).toFixed(2),
};
}
print() {
console.log('Загальна latency, мс:', this.summary(this.requestLatencies));
const hitRatio =
(this.cacheHits + this.cacheMisses) === 0
? 0
: this.cacheHits / (this.cacheHits + this.cacheMisses);
console.log(
'Кеш:',
JSON.stringify({
hits: this.cacheHits,
misses: this.cacheMisses,
hitRatio: `${(hitRatio * 100).toFixed(2)}%`,
})
);
for (const [stage, latencies] of this.stageLatencies) {
console.log(`Етап "${stage}", мс:`, this.summary(latencies));
}
}
}
const cache = new Map();
const metrics = new Metrics();
function randomInteger(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
async function measureStage(name, operation) {
const startedAt = performance.now();
try {
return await operation();
} finally {
const elapsed = performance.now() - startedAt;
metrics.recordStage(name, elapsed);
}
}
async function handleRequest(requestId) {
const startedAt = performance.now();
const cacheKey = `product:${requestId % 40}`;
const cachedProduct = await measureStage('cache', async () => {
await sleep(randomInteger(1, 3));
return cache.get(cacheKey);
});
if (cachedProduct) {
metrics.cacheHits += 1;
} else {
metrics.cacheMisses += 1;
const product = await measureStage('database', async () => {
await sleep(randomInteger(8, 20));
return { id: requestId % 40, name: 'Product' };
});
await measureStage('external-service', async () => {
await sleep(randomInteger(15, 35));
});
cache.set(cacheKey, product);
}
await measureStage('serialization', async () => {
await sleep(randomInteger(1, 2));
});
metrics.requestLatencies.push(performance.now() - startedAt);
}
async function main() {
for (let requestId = 0; requestId < 200; requestId += 1) {
await handleRequest(requestId);
}
metrics.print();
}
main().catch((error) => {
console.error('Помилка виконання:', error);
process.exitCode = 1;
});Запуск:
node performance-profile.jsЦей приклад навмисно простий, але демонструє кілька важливих властивостей:
latency потрібно збирати для кожного запиту;
перцентилі обчислюються на масиві окремих вимірювань;
hit ratio потрібно рахувати через кількість влучань і промахів;
середнє значення окремого етапу не замінює його перцентилі;
загальна latency та latency етапів мають аналізуватися разом.
У реальній системі не слід зберігати всі вимірювання в пам’яті процесу. Метрики зазвичай агрегують у вигляді гістограм або інших придатних для цього структур і передають у систему спостережуваності.
Якщо запит проходить через кілька сервісів, локальних метрик недостатньо. Потрібно пов’язати всі операції одним ідентифікатором трасування.
Траса може мати таку структуру:
trace: 8f2a
├─ API request: 420 мс
│ ├─ cache.get: 3 мс
│ ├─ database.query: 72 мс
│ └─ payment-service request: 330 мсТака структура дозволяє відрізнити:
час виконання власного сервісу;
час очікування залежності;
мережеву затримку;
час очікування в черзі;
повторні спроби.
Для кожного span корисно фіксувати:
назву операції;
початок і тривалість;
статус;
назву endpoint або залежності;
тип помилки;
ідентифікатор запиту або трасування.
Не слід додавати до метрик необмежено багато унікальних значень. Наприклад, ідентифікатор користувача або повний URL із довільними параметрами можуть створити надмірну кардинальність і ускладнити агрегацію.
Ознаки:
CPU тривалий час близький до граничного значення;
latency зростає разом із RPS;
час очікування бази даних та зовнішніх сервісів не змінився;
профілювання показує дорогі функції або часті обчислення.
Потрібно розрізняти:
час, витрачений на корисну роботу;
час на серіалізацію;
час на збірку сміття;
час на синхронізацію або блокування.
Оптимізація CPU повинна починатися з профілю, а не з інтуїтивного переписування коду.
Ознаки:
зростають p95 і p99 span запитів до бази;
збільшується кількість активних з’єднань;
пул з’єднань часто повністю зайнятий;
запити довго чекають виконання або блокування;
зростає час очікування в черзі.
Потрібно вимірювати окремо:
час отримання з’єднання
+ час очікування в черзі
+ час виконання SQL
+ час передачі результатуЯкщо SQL виконується 20 мс, але отримання з’єднання займає 300 мс, оптимізація самого SQL не усуне головну проблему.
Ознаки:
latency endpoint майже повторює latency зовнішнього сервісу;
локальний CPU та база працюють нормально;
високий p99 залежності спричиняє високий p99 основного endpoint;
тайм-аути або повторні спроби створюють додаткове навантаження.
Слід рахувати не тільки успішні виклики, а й:
тайм-аути;
помилки;
кількість повторних спроб;
час очікування відповіді;
частку запитів, для яких залежність була критичною.
Навіть швидка операція може стати повільною, якщо всі робочі ресурси зайняті. Прикладами є:
пул з’єднань до бази;
кількість worker-потоків;
ліміти одночасних HTTP-запитів;
внутрішня черга повідомлень.
Ознака такого вузького місця — значна різниця між часом очікування ресурсу і часом фактичного виконання операції.
Збільшення розміру пулу не є універсальним рішенням. Воно може лише перенести перевантаження на базу даних або зовнішню систему.
Кеш може зменшити latency та навантаження на повільне джерело, але лише за правильного профілю доступу.
Потрібно перевірити:
hit ratio саме для конкретного endpoint;
hit ratio для різних типів ключів;
latency влучання;
latency промаху;
розмір значень;
частоту протермінування;
кількість одночасних запитів на один відсутній ключ.
Якщо багато запитів одночасно бачать промах і всі звертаються до бази, виникає cache stampede. У такому разі hit ratio може виглядати прийнятним у середньому, але промахи створюють короткочасні піки навантаження і високий p99.
GET /products
RPS: 180
p50: 72 мс
p95: 410 мс
p99: 1 240 мс
cache hit ratio: 91%
database p95: 84 мс
external-service p95: 360 мс
cache p95: 7 мсВисновки:
кеш працює швидко;
p50 прийнятний;
хвіст розподілу дуже великий;
зовнішній сервіс є основним кандидатом на дослідження;
високий hit ratio не усуває повільність промахів.
Наступний крок — перевірити, чи повільні запити пов’язані саме з промахом кешу та чи є в них повторні спроби зовнішнього виклику.
p50: 70 мс
p95: 190 мс
p99: 520 мс
cache hit ratio: 94%Покращилися всі перцентилі, а не лише середнє значення. Це сильніший доказ, що зміна була корисною.
Але потрібно також перевірити:
чи не зросла кількість помилок;
чи не збільшився розмір кешу над допустимим;
чи не зросло навантаження на інші компоненти;
чи однаково покращився результат для всіх класів запитів.
Профілювання в стані спокою не показує проблеми з чергами та конкуренцією за ресурси. Тому вимірювання варто проводити в кількох режимах:
Низьке навантаження — для пошуку базової latency.
Типове навантаження — для перевірки реального профілю.
Пікове навантаження — для виявлення черг і деградації.
Поступове збільшення навантаження — для пошуку точки насичення.
Під час тесту потрібно фіксувати не тільки RPS, а й:
p50, p95, p99;
відсоток помилок;
hit ratio;
CPU;
пам’ять;
кількість з’єднань;
довжину черг;
тайм-аути.
Якщо під час збільшення RPS throughput перестає зростати, а latency продовжує збільшуватися, система наближається до насичення.
Виберіть конкретний endpoint або операцію.
Визначте SLO і період спостереження.
Зберіть базові p50, p95, p99, RPS та error rate.
Розкладіть запит на вимірювані етапи.
Знайдіть етап, який найбільше впливає на хвіст latency.
Перевірте, чи проблема виникає для всіх запитів або лише для окремого класу.
Сформулюйте одну перевірювану гіпотезу.
Змініть один фактор.
Повторіть тест у тих самих умовах.
Порівняйте перцентилі, помилки, hit ratio і навантаження на залежності.
Залиште зміну лише тоді, коли покращення підтверджене вимірюваннями.
Середнє може покращитися, а p99 — погіршитися. Для користувацьких запитів завжди аналізуйте перцентилі.
Один запит не показує розподіл latency. Потрібна достатня вибірка, яка містить різні параметри, стани кешу та відповіді залежностей.
Hit ratio показує частку влучань, але не показує:
час читання кешу;
розмір відповіді;
витіснення даних;
навантаження на джерело після промаху;
вплив на p99.
Якщо вимірювати тільки час обробки в API, можна пропустити:
очікування в балансувальнику;
мережеві затримки;
черги;
блокування в базі;
час зовнішніх сервісів.
У такому випадку неможливо визначити, яка зміна вплинула на результат.
Більший пул може зменшити локальне очікування, але перевантажити базу даних або зовнішній сервіс.
Логування кожного кроку з повними параметрами може саме збільшити latency, обсяг I/O та витрати. Для високочастотних операцій краще використовувати агреговані метрики, вибіркове трасування та обмежений набір атрибутів.
Без базової лінії не можна надійно сказати, що оптимізація спрацювала.
Аналізуйте latency через p50, p95 і p99, а не лише через середнє.
Розкладайте загальний час запиту на окремі етапи та залежності.
Рахуйте hit ratio через чітко визначені hits і misses.
Високий hit ratio не гарантує низької latency.
Розрізняйте час виконання операції та час очікування ресурсу.
Для розподілених систем пов’язуйте операції трасуванням.
Спочатку фіксуйте базову лінію, потім перевіряйте одну гіпотезу.
Після кожної зміни повторюйте вимірювання за тих самих умов.
Вузьким місцем є не обов’язково найповільніша функція, а компонент, який найбільше впливає на latency і масштабованість системи.