Có một team mất cả tuần “tối ưu” một API endpoint chậm. Nhìn code thấy handler phức tạp, nghĩ chắc thuật toán nặng nên viết lại sạch hơn, bỏ vòng lặp thừa, dùng cấu trúc dữ liệu tốt hơn. Deploy lên, P99 gần như không đổi. Mở trace ra mới thấy: handler chỉ tốn 3ms, nhưng middleware logging đồng bộ tới remote syslog tốn 400ms. Bottleneck thật nằm ở chỗ không ai ngó tới.

“Tối ưu hiệu năng” rất dễ biến thành cảm tính kỹ sư, đổi thư viện, thêm index, bật cache dựa trên trực giác, rồi P99 vẫn xấu vì nguyên nhân gốc ở chỗ khác. Bài học: profiling và optimization cần quy trình giống thử nghiệm khoa học, giả thuyết có thể bác bỏ, đo có kiểm soát, kết luận có phạm vi.


Triệu chứng không phải nguyên nhân gốc

Khi ai đó nói “API chậm”, triệu chứng là latency P99 cao. Nhưng nguyên nhân có thể là rất nhiều thứ: CPU nóng trên pod, chờ lock giữa các thread, query database chậm, remote dependency timeout, GC pause dài, DNS resolve chậm, disk I/O do log đồng bộ, hoặc queue chờ thread pool đầy.

Nếu không phân loại, bạn sẽ “tối ưu code hot path” trong khi thực tế đang chờ I/O, và code hot path chỉ chiếm 5% wall time. Hay gặp: team optimize JSON serialization xuống 0.5ms trong khi mỗi request chờ database 200ms, tiết kiệm được 0.25% tổng thời gian, không ai nhận ra sự khác biệt.

Bước đầu tiên luôn là: phân loại bottleneck thuộc nhóm nào.


Phân loại bottleneck

CPU-bound

Dấu hiệu: CPU utilization cao, flame graph dày ở user code hoặc thư viện tính toán. Request xử lý nặng computation, encryption, image processing, complex business logic.

Hành động: xem xét thuật toán, giảm allocation, parallelization có kiểm soát. Flame graph là công cụ chính, nó chỉ rõ function nào ăn bao nhiêu CPU time.

I/O-bound (disk hoặc network)

Dấu hiệu: CPU utilization thấp nhưng latency cao. Trace cho thấy span chờ database hoặc HTTP call chiếm phần lớn thời gian. Đây là loại phổ biến nhất trong thực tế, hầu hết web service đều I/O-bound.

Hành động: batch query (N+1 → 1 query), tune connection pool, giảm round-trip qua caching hoặc denormalization, async I/O có backpressure. Nhưng cẩn thận, cache che giấu bug downstream, cần đo trước khi cache.

Lock và contention

Dấu hiệu: throughput không tăng khi scale thread/instance. CPU không cao nhưng request chậm. Profile cho thấy wait time nhiều.

Hành động: giảm critical section scope, cân nhắc lock-free data structure (chỉ khi có chứng cứ contention thật), hoặc chia partition dữ liệu để giảm tranh chấp. Đừng sử dụng lock-free “vì nhanh”, lock-free code phức tạp hơn nhiều, chỉ justify khi contention là bottleneck đo được.

Remote dependency

Dấu hiệu: error rate và latency tương quan với downstream service. Retry làm trầm trọng hơn (retry storm).

Hành động: timeout hợp lý, circuit breaker, cache có TTL. Nhưng luôn đo trước khi cache, cache che giấu downstream bug, bạn không muốn phát hiện downstream chết sau 24 giờ khi cache expire.

GC và runtime overhead

Dấu hiệu: GC pause log, allocation rate cao, latency spike không tương quan với CPU hay I/O. Phổ biến với JVM, Go (ít hơn), Node.js.

Hành động: giảm allocation (object pooling, buffer reuse), tune GC parameters. Nhưng chỉ sau khi chứng minh GC là bottleneck, premature GC tuning là waste of time.


Phương pháp chứng minh

Bốn công cụ theo thứ tự ưu tiên:

Metrics, throughput, latency histogram (P50/P95/P99), saturation (CPU, pool size, queue depth), error rate. Đây là nơi bắt đầu, nhìn tổng quan trước khi đào sâu.

Traces, một request đi qua đâu, span nào chiếm thời gian. Distributed trace giúp phát hiện bottleneck ở service nào, span nào. Thường bắt đầu từ slow trace rồi drill down.

Sampling profiler (CPU), flame graph cho thấy function nào ăn CPU. Go pprof, Java async-profiler, Node.js 0x hoặc clinic.js. Sampling giảm overhead so với instrumenting.

Wall-clock profiler, khác CPU profiler ở chỗ nó đo thời gian thực (bao gồm chờ I/O, lock wait), không chỉ CPU time. Java async-profiler hỗ trợ wall-clock mode, Go trace tool cũng tương tự. Đây là công cụ quan trọng nhất khi nghi I/O-bound, CPU profiler sẽ không chỉ ra chỗ chờ I/O vì CPU không làm gì trong lúc chờ.


Sai lầm phổ biến khi đo

Dưới đây là những sai lầm thường gặp, để tránh lặp lại.

Đo trên laptop dev với dataset nhỏ rồi kết luận “O(n) ok”, trong khi production n lớn gấp 1000 lần, behavior hoàn toàn khác. Benchmark trên laptop chỉ có giá trị tương đối (so sánh hai approach), không có giá trị tuyệt đối.

Bật profiler nặng liên tục trong production, overhead của profiler thay đổi hành vi hệ thống (Heisenberg effect). Sampling profiler với rate thấp (1-5%) chấp nhận được, full instrumentation thì chỉ bật khi cần rồi tắt.

Chỉ nhìn mean latency, mean che giấu tail latency. P50 = 10ms, P99 = 2000ms → mean có thể 50ms, nhìn “ổn” nhưng 1% user chờ 2 giây. Luôn nhìn histogram, P95, P99.

Tối ưu cold start khi P99 thật là query database, lãng phí sprint cho thứ không phải bottleneck. Luôn xác nhận bottleneck trước khi optimize.


USE method: framework đánh giá nhanh

Brendan Gregg đề xuất USE: Utilization, Saturation, Errors cho mỗi tài nguyên. Áp dụng cho service HTTP:

CPU: utilization cao + saturation thấp (không queue) → compute-bound, có thể scale hoặc optimize code. Utilization thấp + latency cao → nghi I/O, lock, hoặc chờ resource khác.

Network: errors (RST, timeout) vs saturation (bandwidth, NIC queue). Network error thường ẩn sau “API chậm”, trace timeout downstream trông giống “code chậm”.

Disk: log sync fsync đôi khi là thủ phạm P99 bị bỏ quên. Mọi người nhìn query database mà quên logging framework cũng ghi disk.

Quan trọng: “resource” không chỉ là hardware. Thread pool, connection pool, semaphore cũng là tài nguyên có utilization, saturation, errors. Connection pool có 10 connection mà 20 goroutine chờ → saturation = 100%, latency tăng vì queue.


Template báo cáo một trang

Template sau hữu ích mỗi khi cần optimize performance, dán vào ticket hoặc incident report. Template bắt buộc justify mọi thay đổi bằng số liệu, không phải “cảm giác nhanh hơn”.

1) Giả thuyết: (một câu, có thể sai)
   VD: "P99 cao vì query N+1 trong handler getOrders"

2) Chứng cứ:
   - Metrics: P99 = 800ms, CPU = 15%, DB pool wait = 200ms avg
   - Trace ID: abc-123, span DB chiếm 750ms (5 query × 150ms)
   - Profile: wall-clock cho thấy 90% time chờ DB response

3) Kết luận: I/O-bound, query N+1

4) Thay đổi: batch query với WHERE id IN (...), 1 query thay 5
   Diff: +12 lines, -8 lines

5) Rủi ro: query IN (...) với list rất lớn có thể chậm
   Rollback: revert commit, không migration
   Feature flag: không cần (pure optimization)

6) Xác minh: P99 giảm xuống < 200ms, DB query count per request = 1

Nếu không điền được mục 2 bằng số liệu, bạn đang viết fiction. Từ khi áp dụng template này, tỷ lệ “optimize xong không thấy cải thiện” giảm đáng kể.


Baseline và so sánh có kiểm soát

Trước khi tối ưu, chụp baseline ở cùng điều kiện tải, cùng RPS, cùng payload distribution, cache đã warm. Không so sánh P99 lúc 3 giờ sáng (traffic thấp) với P99 lúc 11 giờ trưa (peak).

Sau thay đổi, so sánh P50, P95, P99, không chỉ mean. Kiểm tra cả error rate và CPU per request. Đôi khi code “nhanh hơn” nhưng allocate nhiều hơn, GC pressure tăng, tổng throughput giảm ở high load.

Canary 10% traffic với feature flag là cách giảm rủi ro khi không chắc tối ưu “luôn thắng” trên mọi traffic pattern. Có trường hợp optimize function cho benchmark nhanh 40% nhưng canary production cho thấy P99 tệ hơn, vì benchmark không có GC pressure mà production có.


Khi không nên profiling sâu

Incident đang cháy thì ưu tiên mitigate, rollback, scale out, bật kill-switch, rồi profiling sau khi đã ổn định. Flame graph giữa incident không giúp nếu service đang down.

Vấn đề rõ ràng là cấu hình: connection pool size = 1, thread pool size = 2 cho service nhận 100 concurrent request → fix config trước. Flame graph cho trường hợp này là overkill.

Code mới chưa có traffic: viết benchmark, đo, nhưng đừng optimize prematurely. Deploy, đo production traffic, rồi mới biết bottleneck thật ở đâu.


I/O ẩn: logging, metrics, trace exporter

Đây là lớp hay bị bỏ qua nhất vì nó không nằm trong “business code”. Telemetry exporter (Prometheus push, OTLP gRPC, log shipper) có thể block hoặc buffer đầy khi collector chậm, latency tăng mà business code không đổi.

Khi trace cho thấy CPU thấp nhưng wall time cao, kiểm tra: batch size exporter có quá lớn không, disk flush có đồng bộ không, DNS resolve cho collector có chậm không. Một case điển hình: P99 spike 500ms mỗi 30 giây, đúng chu kỳ flush log buffer ra disk. Chuyển sang async flush, P99 spike biến mất.


Regression hiệu năng: trend dài hạn

Một PR tăng allocation 5%, không ai nhận ra trong một ngày. Nhưng mỗi tuần thêm vài PR nhỏ, sau một tháng allocation tăng 25%, GC pressure tăng rõ, P99 tệ dần.

Giữ chart trend 4 tuần cho: P99 endpoint quan trọng, GC pause duration (nếu có), DB connection pool wait time, CPU per request. Alert khi trend tăng quá ngưỡng, ví dụ P99 tăng 20% so với 4 tuần trước.

Profiling không chỉ là “một lần khi cháy”, nó là kiểm soát thay đổi liên tục, giống unit test nhưng cho hiệu năng.


Micro-benchmark: khi nào hữu ích

Micro-benchmark (Go benchmark, JMH, Benchmark.js) tốt cho so sánh hai thuật toán trên cùng input cố định, “approach A nhanh hơn B 30% cho case này”. Nó không thay thế profiling trên workload thật.

Quy tắc nên theo: nếu micro-benchmark nói “nhanh hơn 30%” nhưng canary production không đổi P99, nghi allocation/GC hoặc lock nằm ngoài đoạn code benchmark. Micro-benchmark đo CPU time thuần, production có I/O, GC, contention, cache, behavior khác.


Đọc flame graph trong 60 giây

Flame graph lần đầu nhìn có thể overwhelming. Follow 4 bước:

Tìm plateau rộng, block ngang chiếm nhiều CPU time tích lũy. Đó là function (hoặc subtree) ăn nhiều thời gian nhất.

Hỏi: đó là thư viện chuẩn (runtime, framework) hay code team? Nếu là thư viện: có version mới fix regression không? Nếu là code team: đi sâu xem logic gì đang chạy.

Kiểm tra: có I/O ẩn trong function “tính toán” không? Function tên processOrder nhưng bên trong gọi HTTP call → flame graph CPU sẽ mỏng (chờ I/O), cần wall-clock profiler mới thấy rõ.


Case study: P99 cao nhưng CPU thấp

Đây là case đầu bài, giải thích chi tiết hơn.

Giả thuyết ban đầu (sai): handler getReport có logic tính toán phức tạp, cần rewrite.

Chứng cứ: trace cho thấy span DB < 5ms, handler compute < 3ms, nhưng tổng request 800ms. Nhìn kỹ trace: middleware logging chiếm 400ms, gửi log đồng bộ qua TCP tới remote syslog server. Syslog server đang chậm, mỗi log write block 50-100ms, handler có 5-8 log call.

Thread pool nhỏ (10 thread) nên request queue up chờ thread available, thêm 200ms wait time. Tổng: 400ms logging + 200ms thread pool queue + 8ms actual work = ~800ms.

Thay đổi: chuyển logging sang async (buffer + background flush), tăng thread pool lên 50 (có giới hạn trên), thêm circuit breaker cho syslog connection.

Kết quả: P99 từ 800ms xuống 25ms. Code business logic không đổi một dòng.

Bài học: wall-time ≠ CPU-time. CPU profiler có thể không chỉ đúng chỗ nếu bottleneck là chờ I/O.


Tóm tắt

Đo trước, đo sau. Giả thuyết phải có thể bác bỏ bằng số liệu, nếu không, bạn đang đoán. Template một trang (giả thuyết → chứng cứ → thay đổi → rủi ro → verify) giữ discipline và tránh “optimize rồi không thấy cải thiện”.

Phân loại bottleneck là bước đầu tiên: CPU-bound, I/O-bound, lock/contention, remote dependency, GC. Mỗi loại có công cụ đo và hành động khác nhau, đừng dùng CPU profiler khi nghi I/O, đừng tối ưu code khi vấn đề là config.

CPU thấp + latency cao → nghi I/O, lock, pool saturation, logging đồng bộ. Mean latency che giấu tail, luôn nhìn P95/P99 và histogram. Baseline phải cùng điều kiện tải, so sánh phải cùng metric.

Profiling không chỉ là hoạt động lúc cháy, giữ trend chart dài hạn để phát hiện regression sớm. Và nhớ: telemetry infrastructure (logging, tracing, metrics exporter) cũng có thể là bottleneck, đừng bỏ qua lớp “non-business code”.


Tham khảo