Skip to content
DevRecall
← Roadmap
31

Scenarios: Performance, Scale & Memory

drafted

Mock interview tình huống production — 10k rps, peak ×1000, slow API, 3MB JSON, JSON.stringify block event loop, memory leak, CPU 100%, pool exhaustion, logs 100GB/ngày và log dữ liệu nhạy cảm.

0/60 rated · mastery 0%

Bài học

5 bài · ~81 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~17 phút· 12 câuverify

    Triage một bài scale: hỏi lại, ước lượng và đo đúng chỗ

    Khung trả lời cho mọi câu 10k rps / API chậm: câu hỏi làm rõ, Little's Law, percentile và fan-out, đo từ client tới DB, load test không tự lừa mình.

  2. 02~15 phút· 12 câuverify

    Peak, quá tải và tự bảo vệ: queue, autoscale, load shedding, retry storm

    Peak ×1000 và flash sale: tách nhận khỏi xử lý (202 + queue), toán drain backlog, autoscale theo lag và lịch, 429 vs 503, load shedding, retry storm và cầm máu endpoint quá tải.

  3. 03~12 phút· 9 câuverify

    Database dưới tải: pool exhaustion, N+1, max_connections và ghi theo batch

    Các kịch bản DB là tài nguyên cạn trước: pool leak và transaction bọc HTTP call, too many clients khi scale pod, N+1, concurrency không giới hạn, và ghi 5k event/s bằng batch/COPY an toàn.

  4. 04~19 phút· 13 câuverify

    Event loop và payload lớn: JSON 3MB, stream, worker, nén và CPU 100%

    Vì sao một JSON.stringify chặn mọi request, đo nó thế nào, và thứ tự fix: làm ít hơn, pre-serialize, ETag đúng, nén ở proxy, stream/NDJSON, worker có giới hạn, và săn CPU 100%.

  5. 05~18 phút· 9 câuverify

    Memory leak trong production: chứng minh, cầm máu và tìm retainer

    RSS cao chưa phải leak; đọc memoryUsage theo thời gian, 3-snapshot technique an toàn trên production, các leak kinh điển (Map, listener, timer, fetch body, AsyncLocalStorage) và sizing heap theo container.

Tóm tắt & cheat sheet của track

TL;DR

  • Đây là track mock interview: câu hỏi là một tình huống production ("API phải chịu 10k rps", "RAM tăng 100% sau 2 ngày", "JSON 3MB"). Điểm không nằm ở việc kể tên công nghệ mà ở thứ tự suy nghĩ: hỏi lại → ước lượng → đo → khoanh vùng → fix theo ưu tiên → nói trade-off.
  • Mọi câu "scale" đều quy về vài con số: throughput (rps), latency (p50/p99), concurrency = rps × latency (Little's Law), và tài nguyên hữu hạn đầu tiên cạn (CPU event loop, DB connection, memory, quota bên thứ ba).
  • Node.js có một điểm yếu cố định: một event loop cho mọi request. Việc sync nặng (JSON.stringify vài MB, regex backtracking, bcrypt sync, vòng lặp O(n²)) làm chậm tất cả request chứ không chỉ request đó.
  • Memory leak = heap tăng đơn điệu sau GC, không phải "RSS cao". Chứng minh bằng heap snapshot (3-snapshot technique), mitigation trước (restart có kiểm soát, giới hạn cache), root cause sau.
  • Quá tải là chuyện khi nào, không phải nếu: thiết kế sẵn rate limit (429), load shedding (503 + Retry-After), queue làm buffer (202), backpressure, và timeout/retry có budget.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
Throughput vs latencySố request/giây vs thời gian một request10k rps, p99 = 120 ms
Little's LawL = λ × W: số request đang xử lý = arrival rate × thời gian ở trong hệ thống2000 rps × 0.05 s = 100 request in-flight
Percentile (p50/p95/p99)Giá trị mà X% request nhanh hơnp99 = 800 ms: 1/100 request chậm hơn 800 ms
Tail amplificationRequest gọi N dependency → xác suất dính 1 cái chậm tăng nhanh100 call, mỗi call 1% chậm → 63% request chậm
Event loop lag / ELUĐộ trễ timer so với lịch / tỷ lệ thời gian event loop bậnmonitorEventLoopDelay() p99 = 250 ms
Pool waitThời gian chờ lấy connection, trước khi query chạypool.waitingCount = 180
BackpressureTín hiệu "chậm lại" từ phía nhận truyền ngược về phía gửires.write() trả false → chờ 'drain'
Load sheddingChủ động từ chối một phần request để phần còn lại sốngLag > 200 ms → 503 + Retry-After: 2
Rate limitingGiới hạn theo client/key, lỗi phía client429 Too Many Requests
202 Accepted + queueNhận việc, xử lý sau, trả link theo dõiPOST /exports → 202, Location: /jobs/42
Heap vs RSS vs externalV8 heap (JS object) / toàn bộ memory process / Buffer ngoài heapheapUsed 300MB, rss 1.1GB, external 600MB
Retained sizeMemory được giải phóng nếu object này bị GCMap 1 entry nhưng retain 400 MB
Structured cloneThuật toán copy object giữa thread (workerData, postMessage)Gửi object 3MB sang worker = copy trên main thread
RedactionXoá/che field nhạy cảm trước khi log được ghipino redact: ['req.headers.authorization']

Cơ chế hoạt động

Một request đi qua đâu và thời gian nằm ở đâu

Mỗi mũi tên là một nơi latency có thể nằm. "API chậm" chỉ có ý nghĩa khi biết đoạn nào chậm: đo bằng tracing (span cho DB, HTTP), Server-Timing, metric pool wait, event loop lag.

Quyết định khi quá tải

Peak ×1000: API nhận nhanh, worker xử lý đều

Memory leak: 3-snapshot technique

Khung trả lời

Dùng lại cho mọi câu "hệ thống chậm / quá tải / phải chịu X rps":

  1. Hỏi lại (30 giây): loại request (đọc/ghi, CPU/I-O), payload size, SLO (p99 bao nhiêu), peak vs trung bình, data có cache được không, consistency yêu cầu gì.
  2. Ước lượng: rps peak, concurrency (Little's Law), số instance, số DB connection, bandwidth (rps × payload).
  3. Cầm máu trước (nếu đang cháy): rate limit, tắt feature nặng bằng flag, trả stale cache, scale ngang, rollback deploy.
  4. Đo, không đoán: tracing, RED metrics theo endpoint, event loop lag, pool wait, DB slow query, heap/RSS.
  5. Fix theo ưu tiên: làm ít việc hơn (cache, projection, pagination) → làm việc rẻ hơn (index, batch, bỏ N+1) → làm song song có giới hạn → scale ngang → đổi kiến trúc (queue, CQRS, sharding).
  6. Trade-off + chứng minh: nói cái giá (stale data, eventual consistency, chi phí, độ phức tạp) và cách verify (load test, dashboard, alert).

Trade-offs

Lựa chọnĐượcMấtKhi nào
Scale ngang thêm podNhanh, không đổi codeDồn tải xuống DB, tốn tiền, DB connection tăng theo số podBottleneck là CPU app, app stateless
Cache (CDN/Redis/in-process)Giảm DB load mạnh nhất cho read-heavyStale data, invalidation, stampedeĐọc nhiều, chấp nhận stale vài giây–phút
Queue + 202Hấp thụ spike, worker chạy đềuKhông có kết quả ngay, cần idempotency, UX polling/webhookViệc không cần kết quả sync (order processing, export, email)
Rate limit (429)Bảo vệ khỏi 1 client/tenant ồn àoCó thể chặn nhầm client hợp lệMulti-tenant, public API
Load shedding (503)Giữ latency cho phần request được nhậnMột phần user bị lỗiToàn hệ thống vượt capacity
Compression ở proxy/CDNGiảm bandwidth 5–10× với JSON (verify theo data)CPU (nhất là brotli quality cao)Payload > vài KB, client mạng chậm
Pre-serialize + cache stringBỏ hẳn chi phí stringify mỗi requestMemory, invalidationResponse giống nhau cho nhiều user
worker_threadsKhông block event loop chínhChi phí clone dữ liệu vào/ra, pool phức tạpCPU nặng với input nhỏ, output nhỏ
Provision cho peakĐơn giản, không lo scale lagTốn tiền 23/24 giờPeak không đoán trước, cost không quan trọng
Scheduled + reactive autoscaleRẻ, chịu được peak đã biếtPhải dự đoán; reactive scale mất vài phútPeak có giờ cố định

Pitfalls

  • Trả lời "scale thêm server" trước khi hỏi bottleneck là gì — thêm pod khi DB đang 95% CPU chỉ làm DB chết nhanh hơn.
  • Nói về average latency; interviewer muốn nghe p99 và tail.
  • Promise.all trên 10k phần tử — tự DDoS DB/bên thứ ba, cạn pool.
  • Retry ở mọi tầng không có budget → retry storm nhân tải lên 10–30×.
  • Autoscale theo CPU cho worker I/O-bound, hoặc tin rằng autoscale phản ứng kịp spike 30 giây.
  • Tin rằng worker_threads làm JSON.stringify "miễn phí" — dữ liệu phải clone qua thread.
  • Stream JSON mà quên dấu phẩy, quên backpressure, gọi res.end() trước khi data tới.
  • Nhầm RSS cao là leak; hoặc set --max-old-space-size lớn hơn memory limit của container.
  • Không consume body của fetch response → giữ socket/memory.
  • Log req.headers/req.body nguyên con → token, password, số thẻ nằm trong log store 30 ngày và ai cũng đọc được.

Cheat sheet

  • Little's Law: in-flight = rps × latency(s). 2000 rps × 50 ms = 100 concurrent → pool 20/pod × 5 pod là vừa khít, không có headroom.
  • Postgres max_connections mặc định 100; pods × pool size phải nhỏ hơn con số đó (trừ connection cho admin/replication) → PgBouncer khi nhiều pod.
  • Fan-out tail: P(slow) = 1 - (1 - p)^N; p = 1%, N = 100 → 63%.
  • Bandwidth: 3 MB × 1000 rps = 3 GB/s = 24 Gbit/s — một endpoint có thể làm đầy NIC trước khi CPU cạn.
  • Log: 100 MB/ngày ≈ 1.2 KB/s (không phải vấn đề); 100 GB/ngày ≈ 1.2 MB/s ≈ ~1,200 dòng 1 KB mỗi giây (vấn đề là chi phí lưu trữ + index).
  • 429 = client vượt quota của nó; 503 = server không còn capacity. Cả hai nên có Retry-After.
  • Đo event loop: perf_hooks.monitorEventLoopDelay(), performance.eventLoopUtilization(); CPU: node --cpu-prof; memory: --heapsnapshot-signal=SIGUSR2, --heapsnapshot-near-heap-limit=3.
  • --max-old-space-size ≈ 70–75% memory limit container (phần còn lại cho Buffer, native, stack) — con số kinh nghiệm, verify với workload.
  • Log an toàn: redact ở logger, không bao giờ log password/token/PAN/CVV, retention có hạn, quyền đọc log theo vai trò.

Personal notes (Notion)

Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.

Interview Questions

Showing 60 / 60

rps = 2000, W_request = 50 ms  → in-flight requests = 100
W_db_hold = 5 ms               → busy connections   = 2000 × 0.005 = 10
pods = 5, pool = 4/pod         → 20 connections (2× headroom) ✓
P(page slow) = 1 - (1 - 0.01)^N
N = 1   → 1%
N = 40  → 33%
N = 100 → 63%
# closed model: 100 connections, mỗi connection chờ response rồi mới gửi tiếp
npx autocannon -c 100 -d 60 https://staging.example.com/products/42
# k6 open model: giữ 10k req/s bất kể server chậm
# scenarios: { load: { executor: 'constant-arrival-rate', rate: 10000, timeUnit: '1s',
#   duration: '5m', preAllocatedVUs: 2000, maxVUs: 5000 } }
import { Pool } from 'pg';
// 20 pods × max 10 = 200 connections > max_connections = 100
export const pool = new Pool({
  max: 10,
  // default connectionTimeoutMillis = 0 → wait forever for a free client
});
app.post('/orders/:id/confirm', async (req, res) => {
  const client = await pool.connect();
  await client.query('BEGIN');
  const { rows } = await client.query('SELECT * FROM orders WHERE id = $1 FOR UPDATE', [req.params.id]);
  if (rows.length === 0) return res.status(404).end();          // (a)

  const payment = await paymentApi.capture(rows[0].payment_id);  // (b) 300 ms – 30 s
  await client.query('UPDATE orders SET status = $1 WHERE id = $2', ['confirmed', req.params.id]);
  await client.query('COMMIT');
  client.release();
  res.json({ ok: true, payment });
});
app.get('/orders', async (req, res) => {
  const orders = await db.query('SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 50', [req.user.id]);
  for (const o of orders.rows) {
    o.items = (await db.query('SELECT * FROM order_items WHERE order_id = $1', [o.id])).rows;
    o.shipping = (await db.query('SELECT * FROM shipments WHERE order_id = $1', [o.id])).rows[0];
  }
  res.json(orders.rows);
});
SELECT pid, state, wait_event_type, wait_event, now() - xact_start AS tx_age, left(query, 80)
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY tx_age DESC NULLS LAST;
POST /orders HTTP/1.1
Idempotency-Key: 7f3c...

HTTP/1.1 202 Accepted
Location: /orders/jobs/8812
Retry-After: 2
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 }); h.enable();
let inFlight = 0;
app.use((req, res, next) => {
  const lagMs = h.percentile(99) / 1e6;
  if ((lagMs > 200 || inFlight > 500) && !req.path.startsWith('/checkout')) {
    res.set('Retry-After', String(1 + Math.floor(Math.random() * 3)));
    return res.status(503).json({ error: 'overloaded' });
  }
  inFlight++; res.on('close', () => inFlight--); next();
});
setInterval(() => h.reset(), 5000).unref();
inflow  = 100k/min, capacity = 20k/min, duration = 60 min
backlog = (100k - 20k) × 60 = 4.8M
drain   = 4.8M / 20k = 240 min after peak (if inflow drops to 0)
const [user, cart, recs] = await Promise.allSettled([
  getUser(id),
  getCart(id),
  getRecs(id, { signal: AbortSignal.timeout(80) }),
]);
const products = await repo.findAllToEnrich(); // 20,000 rows
await Promise.all(
  products.map(async (p) => {
    const data = await supplier.get(`/items/${p.sku}`);
    await repo.update(p.id, data);
  }),
);
const ac = new AbortController();
try {
  const [a, b] = await Promise.all([
    fetch(urlA, { signal: ac.signal }),
    fetch(urlB, { signal: ac.signal }),
  ]);
} catch (e) {
  ac.abort(); // stop the calls that are still running
  throw e;
}
gateway (3 retries) → api (3 retries) → service (3 retries) → db
1 user request can become up to 4 × 4 × 4 = 64 DB calls
HTTP/1.1 200 OK
Server-Timing: db;dur=412, cache;desc="miss";dur=2, render;dur=38, ext-pricing;dur=910
app.use((req, res, next) => {
  const start = Date.now();
  next();
  const ms = Date.now() - start;
  metrics.histogram('http_duration_ms', ms, { route: req.path });
});
consumer.on('message', async (e) => {
  await db.query(
    'INSERT INTO events (id, tenant_id, type, payload, created_at) VALUES ($1, $2, $3, $4, $5)',
    [e.id, e.tenantId, e.type, e.payload, e.createdAt],
  );
});
const buffer: Event[] = [];
app.post('/events', (req, res) => {
  buffer.push(req.body);
  res.status(202).end();
});
setInterval(async () => {
  const batch = buffer.splice(0, buffer.length);
  if (batch.length) await repo.insertMany(batch);
}, 1000);
app.use(compression({
  threshold: 1024,
  filter: (req, res) =>
    res.getHeader('Content-Type')?.toString().startsWith('text/event-stream')
      ? false
      : compression.filter(req, res),
}));
app.get('/catalog', async (req, res) => {
  const version = await redis.get('catalog:version');       // cheap
  const etag = `W/"catalog-${version}"`;
  if (req.headers['if-none-match'] === etag) return res.status(304).end();
  const body = await getSerializedCatalog(version);          // cached string
  res.set({ ETag: etag, 'Cache-Control': 'private, max-age=30' }).type('json').send(body);
});
import { performance } from 'node:perf_hooks';
const t0 = performance.now();
const s = JSON.stringify(bigObject);
console.log({ ms: performance.now() - t0, mb: Buffer.byteLength(s) / 1e6 });
app.get('/export', (req, res) => {
  const stream = db.queryStream('SELECT * FROM orders'); // object mode, one row per 'data'
  res.write('[');
  stream.on('data', (row) => {
    res.write(JSON.stringify(row));
  });
  res.end(']');
});
// main thread still pays: structured clone of bigObject (sync)
new Worker('./stringify.js', { workerData: bigObject });
// better: pass a small query and let the worker load + serialize
pool.run({ reportId: 42 }); // piscina, worker returns a transferable Buffer
app.get('/products', {
  schema: { response: { 200: { type: 'array', items: {
    type: 'object',
    properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' } },
  } } } },
}, async () => repo.list());
import { gzipSync } from 'node:zlib';
let snapshot: { version: string; gz: Buffer } | undefined;
async function rebuild(version: string) {              // off the request path
  const json = JSON.stringify(await repo.loadCatalog());
  snapshot = { version, gz: gzipSync(json, { level: 6 }) }; // once per change
}
app.get('/catalog', (req, res) => {
  res.set({ 'Content-Type': 'application/json', 'Content-Encoding': 'gzip', ETag: `W/"${snapshot!.version}"` });
  res.end(snapshot!.gz);
});
const priceCache = new Map<string, { value: Price; at: number }>();

export async function getPrice(tenantId: string, sku: string, currency: string, userId: string) {
  const key = `${tenantId}:${sku}:${currency}:${userId}`;
  const hit = priceCache.get(key);
  if (hit && Date.now() - hit.at < 60_000) return hit.value;
  const value = await pricing.compute(tenantId, sku, currency, userId);
  priceCache.set(key, { value, at: Date.now() });
  return value;
}
io.on('connection', (socket) => {
  const session = loadSessionSnapshot(socket.data.userId); // ~200 KB
  priceBus.on('tick', (t) => {
    if (session.watchlist.includes(t.symbol)) socket.emit('tick', t);
  });
  setInterval(() => {
    socket.emit('heartbeat', { at: Date.now(), watched: session.watchlist.length });
  }, 10_000);
});
async function check(url: string) {
  const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
  if (!res.ok) return { url, ok: false, status: res.status }; // body never read
  return { url, ok: true, data: await res.json() };
}
node --max-old-space-size=1536 --heapsnapshot-near-heap-limit=2 dist/main.js
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 2**20, 'MB')"
function applyPromotions(products: Product[], promos: Promotion[]) {
  return products.map((p) => {
    const promo = promos.find((x) => x.skus.includes(p.sku)); // nested linear scans
    return { ...p, finalPrice: promo ? p.price * (1 - promo.rate) : p.price };
  });
}
import pino from 'pino';
import pinoHttp from 'pino-http';
const logger = pino({ level: 'info' });
app.use(pinoHttp({ logger })); // logs req with headers (Authorization, Cookie)
app.post('/login', (req, res) => {
  logger.info({ body: req.body }, 'login attempt'); // { email, password }
  // ...
});
app.use((err, req, res, next) => {
  logger.error({ err, config: err.config }, 'upstream failed'); // axios error: config.headers
  res.status(500).end();
});