Continuous profiling JVM: flame graph trên production

4 câu hỏi phỏng vấn~25 phútTừ khóa: continuous profiling, sampling profiler, flame graph, CPU profile, wall-clock, allocation profile, lock contention, JFR, JDK Flight Recorder, JMC, async-profiler, Pyroscope, Grafana Profiles, span profiles, safepoint bias, overhead

Tiếp đi: profiling. Trace cho thấy span payment.calculateFee mất 600ms mà không gọi ra ngoài gì cả. Làm sao biết bên trong nó làm gì?

Đây đúng là chỗ trace dừng lại và profile bắt đầu. Trace cho biết span nào chậm, profile cho biết hàm nào trong span đó tốn thời gian. Cách chạy JFR/async-profiler bằng tay lúc sự cố đã ở Chẩn đoán JVM production. Bài này nói về chạy liên tục và đọc flame graph.

1. Bài toán

payment-service sau bản 3.2: p99 tăng từ 250ms lên 900ms, CPU mỗi Pod tăng 40%, HPA scale từ 4 lên 9 Pod (tốn tiền). Không có exception nào. Không tái hiện được ở local vì dữ liệu nhỏ.

2. Vấn đề

3. Giải pháp: sampling profiler chạy liên tục

Sampling profiler cứ ~10ms chụp stack trace của các thread, đếm stack nào xuất hiện nhiều. Overhead thường 1–2%, đủ thấp để bật 24/7. Dữ liệu gửi về một backend lưu theo thời gian, như metric.

Loại profileTrả lờiDùng khi
CPUHàm nào đốt CPUCPU cao, HPA scale nhiều
Wall-clockThread đang ở đâu kể cả lúc chờ (I/O, sleep, lock)Latency cao nhưng CPU thấp
AllocationHàm nào cấp phát nhiều bytesGC chạy nhiều, pause cao
LockMonitor/lock nào bị tranh chấp, chờ bao lâuThroughput không tăng khi thêm thread

Công cụ: JFR (có sẵn trong JDK, overhead ~1%), async-profiler (không bị safepoint bias, thấy cả native/kernel frame), backend Pyroscope / Grafana Profiles để lưu và so sánh.

4. Code từng bước

Bước 1: Pyroscope Java agent trong Deployment

env:
- name: JAVA_TOOL_OPTIONS
  value: >-
    -javaagent:/otel/opentelemetry-javaagent.jar
    -javaagent:/pyroscope/pyroscope.jar
- name: PYROSCOPE_APPLICATION_NAME
  value: payment-service
- name: PYROSCOPE_SERVER_ADDRESS
  value: http://pyroscope.monitoring:4040
- name: PYROSCOPE_PROFILER_EVENT
  value: itimer                    # CPU; trong container không cần perf_events
- name: PYROSCOPE_PROFILER_ALLOC
  value: 512k                      # lấy mẫu mỗi 512KB cấp phát
- name: PYROSCOPE_PROFILER_LOCK
  value: 10ms                      # ghi lock chờ > 10ms
- name: PYROSCOPE_LABELS
  value: env=prod,version=3.2

Agent này dùng async-profiler bên trong. Label version giúp so 3.1 với 3.2.

Bước 2: Nối profile với trace (span profiles)

<dependency>
  <groupId>io.pyroscope</groupId>
  <artifactId>otel</artifactId>
</dependency>

Thư viện này gắn span ID vào mẫu profile. Trong Grafana, mở trace ở Tempo → span payment.calculateFee → "Profiles for this span" ra flame graph chỉ của span đó.

Bước 3: JFR chạy liên tục (không cần agent ngoài)

# Thêm vào JAVA_TOOL_OPTIONS
-XX:StartFlightRecording=settings=default,maxage=6h,maxsize=500m,dumponexit=true,filename=/tmp/jfr/
-XX:FlightRecorderOptions=repository=/tmp/jfr

# Lúc cần: dump 30 phút gần nhất từ Pod đang chạy
kubectl exec payment-service-7c84c-abc12 -- jcmd 1 JFR.dump name=1 maxage=30m filename=/tmp/last30.jfr
kubectl cp shop/payment-service-7c84c-abc12:/tmp/last30.jfr ./last30.jfr
jfr print --events jdk.ExecutionSample last30.jfr | head
jfr summary last30.jfr        # mở bằng JDK Mission Control để xem đồ họa

JFR mặc định giữ bộ đệm vòng: luôn có dữ liệu của vài giờ vừa qua, không phải "chạy đúng lúc".

Bước 4: Đọc kết quả và sửa

So sánh (diff flame graph) version=3.1 với version=3.2 trong Grafana:

payment.calculateFee                        48% (3.1: 6%)
 └ FeeRuleEngine.match                      46%
    └ java.util.regex.Pattern.compile       41%   ← mới
       └ Pattern.<init> ...
// 3.2: compile regex trong mỗi lần gọi
boolean matches(String merchantCode) {
    return Pattern.compile(rule.regex()).matcher(merchantCode).matches();
}

// Sửa: compile một lần khi load rule
record FeeRule(String regex, BigDecimal rate, Pattern pattern) {
    FeeRule(String regex, BigDecimal rate) { this(regex, rate, Pattern.compile(regex)); }
    boolean matches(String code) { return pattern.matcher(code).matches(); }
}
# Kiểm chứng: chạy tải rồi so p99 và CPU
hey -z 60s -c 50 -m POST -H 'Content-Type: application/json' \
    -d '{"orderId":"1234","amount":99.0}' http://localhost:8080/payments/fee

5. Cách hoạt động: đọc flame graph

        ┌──────────────────────────────────────────────────────┐
  top   │ Pattern.compile ████████████████████   (plateau rộng) │ ← nơi CPU thật sự tiêu
        │ FeeRuleEngine.match █████████████████████            │
        │ calculateFee ███████████████████████████  │ JSON ███ │
        │ PaymentController.charge ████████████████████████████ │
        │ Tomcat / Thread.run ██████████████████████████████████ │ ← gốc
        └──────────────────────────────────────────────────────┘
  - Trục Y: độ sâu stack (gốc dưới, hàm lá trên).
  - Trục X: tỷ lệ mẫu, KHÔNG phải thời gian; thứ tự trái phải là alphabet.
  - Tìm "plateau": khung rộng ở đỉnh = hàm tự nó tốn nhiều (self time).
  - Khung rộng ở dưới nhưng phía trên chia nhỏ = chi phí rải ra nhiều nơi.

Liên kết 4 tín hiệu:
Alert p99 (metric) → exemplar → trace → span calculateFee 600ms
   → "Profiles for this span" → Pattern.compile 41% → sửa code

6. So sánh

JFRasync-profilerPyroscope / Grafana Profiles
Vai tròRecorder trong JDKProfiler samplingBackend lưu, truy vấn, so sánh
Cài đặtSẵn trong JDK 11+Thư viện nativeServer + agent (dùng async-profiler hoặc JFR)
Dữ liệuCPU, alloc, lock, GC, I/O, thread, event tự định nghĩaCPU, wall, alloc, lock, nativeFlame graph theo thời gian + label
Safepoint biasÍt (lấy mẫu không chờ safepoint)KhôngTheo profiler bên dưới
Xem lịch sử, so sánh versionThủ công, từng fileThủ côngCó sẵn

7. Khi nào nên dùng / không nên dùng

Bật profiler 24/7 trên production thì có làm chậm app không? Sếp mình hơi ngại.

Lo lắng hợp lý, nhưng với sampling profiler thì overhead thấp và đo được. Cách thuyết phục sếp là đo, không hứa:

Kế hoạch an toàn:

1. Bật cho 1 Pod canary của payment-service (label version=canary)
2. So p99 và CPU canary với Pod khác trong 1–2 ngày
3. Nếu chênh < 2%, bật toàn bộ; nếu không, giảm tần suất hoặc tắt alloc/lock
4. Có công tắc: biến môi trường để tắt agent không cần build lại image

Đổi lại: lần đầu tìm ra một Pattern.compile như trên, HPA từ 9 Pod về 4 Pod, tiết kiệm nhiều hơn 2% rất nhiều.

Flame graph của inventory-service toàn là Unsafe.park với SocketInputStream.read, chiếm 80%. Vậy là code của mình không có vấn đề à?

Bạn đang xem wall-clock profile (hoặc CPU profile có tính cả thread chờ). Những frame đó là thread đang chờ, không phải đang làm việc. Cần đọc đúng loại:

FrameNghĩaĐi tiếp đâu
Unsafe.park trong ThreadPoolExecutor.getTaskThread pool rảnh, chờ việcBình thường, lọc bỏ
Unsafe.park trong HikariPool.getConnectionChờ connection DBPool cạn: xem hikaricp_connections_pending, query chậm
SocketInputStream.read dưới JDBCChờ postgres trả kết quảTrace span SQL, EXPLAIN query
SocketInputStream.read dưới RestClientChờ service khácTrace hop đó, timeout

Vậy:

  1. Chuyển sang CPU profile để biết code có đốt CPU không.
  2. Ở wall-clock, lọc chỉ thread request (http-nio-*), bỏ thread pool rảnh.
  3. Nếu thread request chủ yếu chờ Hikari → vấn đề nằm ở DB/pool, không phải ở Java code. Đó cũng là một câu trả lời có giá trị.

Phỏng vấn hỏi về profiling trên production thì trả lời sao?

Q1. Continuous profiling là gì, khác gì profile lúc có sự cố?

🇻🇳 Tiếng Việt:

Là chạy sampling profiler liên tục trên production với overhead khoảng 1–2%, gửi dữ liệu về backend như Pyroscope để lưu theo thời gian. Khác với profile thủ công, nó luôn có dữ liệu của thời điểm sự cố, kể cả khi Pod đã bị thay, và cho phép so sánh trước và sau một bản release. Nó cũng dùng để tối ưu chi phí, tìm hàm đốt CPU nhiều nhất trên toàn hệ thống.

🇬🇧 English:

It means running a sampling profiler constantly in production at around 1–2% overhead and shipping the data to a backend like Pyroscope that stores it over time. Unlike ad-hoc profiling, you always have data from when the incident happened, even if the Pod was replaced, and you can compare before and after a release. It's also used for cost optimization, finding the hottest functions across the fleet.

Q2. Đọc flame graph thế nào?

🇻🇳 Tiếng Việt:

Trục dọc là stack, gốc ở dưới, hàm lá ở trên. Trục ngang là tỷ lệ mẫu, không phải thời gian, và thứ tự trái phải không có ý nghĩa. Em tìm những khung rộng ở đỉnh, tức hàm có self time lớn. Em cũng chú ý loại profile: CPU cho biết đốt CPU, wall-clock gồm cả thời gian chờ, allocation cho biết tạo rác, lock cho biết tranh chấp.

🇬🇧 English:

The vertical axis is the stack, root at the bottom and leaf functions on top. The horizontal axis is the share of samples, not time, and left-to-right order means nothing. I look for wide frames at the top, which are functions with high self time. I also check the profile type: CPU shows what burns CPU, wall-clock includes waiting, allocation shows garbage creation, and lock shows contention.

Q3. Latency tăng nhưng CPU thấp, bạn dùng profile nào?

🇻🇳 Tiếng Việt:

CPU thấp nghĩa là thread đang chờ chứ không tính toán, nên CPU profile sẽ không thấy gì. Em dùng wall-clock và lock profile, lọc theo thread xử lý request, để thấy chúng chờ ở đâu: connection pool, lock, socket tới DB hay service khác. Rồi đối chiếu với trace và metric pool như Hikari pending.

🇬🇧 English:

Low CPU means threads are waiting rather than computing, so a CPU profile won't show much. I use wall-clock and lock profiles filtered to request threads to see where they wait: the connection pool, a lock, or a socket to the database or another service. Then I cross-check with traces and pool metrics like Hikari pending connections.

Q4. Làm sao nối profile với trace?

🇻🇳 Tiếng Việt:

Profiler gắn span ID hiện tại làm label cho mẫu, ví dụ thư viện Pyroscope OTel. Khi mở một span chậm trong Tempo, em xem flame graph chỉ của các mẫu thuộc span đó. Như vậy đường điều tra là metric alert, exemplar dẫn sang trace, span chậm dẫn sang profile, profile chỉ ra dòng code.

🇬🇧 English:

The profiler tags samples with the current span ID, for example via the Pyroscope OTel integration. When I open a slow span in Tempo I can view a flame graph of only the samples belonging to that span. So the investigation path is: a metric alert, an exemplar leading to a trace, the slow span leading to a profile, and the profile pointing at the line of code.

Cách nhớ ngắn gọn


Tự kiểm tra

Trục ngang flame graph biểu diễn gì?

Tỷ lệ số mẫu chứa stack đó, không phải thời gian.

GC chạy liên tục, pause cao. Loại profile nào giúp nhất?

Allocation profile để tìm hàm cấp phát nhiều.

Lấy dữ liệu JFR 30 phút gần nhất từ Pod đang chạy thế nào?

jcmd 1 JFR.dump name=1 maxage=30m filename=... khi đã bật recording liên tục.

Thấy nhiều Unsafe.park dưới HikariPool.getConnection, nghĩa là gì?

Thread chờ connection: pool cạn hoặc query giữ connection lâu.

Vì sao label version trong profile hữu ích?

Để so sánh flame graph trước và sau release, tìm regression.