Spring Production-Ready/Ba thứ gãy sau khi bật virtual threads
4/26
Bài 4 / 26~13 phútModern Java trong SpringMiễn phí lượt xem

Ba thứ gãy sau khi bật virtual threads

Pinning khi gặp synchronized và JDBC pool, chi phí ThreadLocal khi thread rẻ đi, và chuyện thread pool thôi làm van chặn tải — ba lỗi hay gặp nhất.

TL;DR: Bật spring.threads.virtual.enabled=true chỉ đổi loại executor bên dưới: code nghiệp vụ cũ vẫn chạy nguyên logic, và ba thứ hay gãy theo. Pinning: virtual thread block bên trong khối synchronized không nhả được carrier trên JDK 21, còn JDK 24 (JEP 491) đã gỡ phần lớn giới hạn đó. ThreadLocal thành đắt: SecurityContextHolder, MDC, @RequestScope nhân bản theo số thread, từ vài trăm platform thread nhảy lên hàng nghìn virtual thread cùng sống. Thread pool cũ vốn là van chặn tải; bỏ pool thì backpressure biến mất theo — connection pool của HikariCP không tự lớn lên, phải tự dựng lại giới hạn bằng Semaphore hoặc rate limiter.

TaskFlow bật spring.threads.virtual.enabled=true cho service xử lý đơn hàng đúng theo quyết định ở bài trước — phần lớn thời gian một request là chờ I/O (gọi payment gateway, query đơn hàng), CPU gần như rảnh trong lúc chờ đó. Trước khi đọc tiếp, thử nhớ lại: quyết định đó dựa trên tiêu chí gì, và virtual threads không giúp gì cho loại tải nào?

Tiêu chí là I/O-bound so với CPU-bound: virtual threads thắng khi phần lớn thời gian xử lý nằm ở chờ mạng hoặc DB, vì carrier thread được nhả ra cho việc khác trong lúc chờ. Với tải CPU-bound — vòng lặp tính toán nặng, không blocking — số lượng thread không phải nút thắt, bật virtual threads không đổi gì, đôi khi còn chậm hơn vì chi phí quản lý continuation.

Đợt Cyber Monday tiếp theo, traffic TaskFlow nhảy từ khoảng 300 request/giây lên đỉnh 3.000 request/giây trong một cửa sổ 10 phút. Ba thứ gãy trong cùng một buổi tối đó — không phải vì quyết định bật virtual threads sai, mà vì ba chỗ khác của hệ thống vẫn đang giả định một thế giới có 200 thread bị giới hạn.

1. Pinning — vì sao virtual thread bị ghim vào carrier?

21:03, dashboard latency của TaskFlow vọt từ p99 80ms lên hơn 4 giây. CPU trên node gần như rảnh, dưới 15%, nhưng thread dump cho thấy hàng trăm request đang chờ — không phải chờ DB hay payment gateway, mà chờ chính nhau.

Bạn đã biết từ java-internals: carrier thread là platform thread thật chạy virtual thread bên trên, cơ chế mount/unmount cho phép một carrier phục vụ luân phiên hàng nghìn virtual thread. Pinning là khi unmount đó không chạy được — virtual thread giữ chặt carrier suốt thời gian block. Theo JEP 444, pinning xảy ra trong đúng hai tình huống: chạy bên trong khối/method synchronized, hoặc gọi native method/foreign function. TaskFlow rơi vào tình huống đầu.

Thủ phạm nằm trong một class ghi audit log có từ thời TaskFlow còn chạy Java 8, chưa ai đụng vào từ đó. Đọc code dưới và thử tìm bug trước khi xem đáp án:

🐛 Tìm bug trong code này
public class AuditLogger {

  private final RestTemplate restTemplate;

  public AuditLogger(RestTemplate restTemplate) {
      this.restTemplate = restTemplate;
  }

  public synchronized void log(AuditEvent event) {
      // audit call chay tren virtual thread sau khi bat spring.threads.virtual.enabled
      restTemplate.postForEntity("/audit-events", event, Void.class);
  }
}
flowchart LR
    VT["Virtual Thread<br/>goi log(event)"] -- "enter synchronized" --> MON["JVM monitor lock"]
    MON --> CT["Carrier Thread"]
    CT -- "restTemplate blocking call" --> WAIT{"JDK 21:<br/>unmount duoc?"}
    WAIT -- "khong - bi ghim" --> STUCK["CT bi khoa toi khi HTTP xong<br/>khong VT nao khac dung duoc CT"]
    WAIT -. "JDK 24 JEP 491: co" .-> FREE["CT duoc nha ngay<br/>chay VT khac trong luc cho"]

Cách phát hiện chắc chắn và chạy được trên mọi bản từ JDK 21 trở đi: JDK Flight Recorder, event jdk.VirtualThreadPinned bật mặc định với ngưỡng 20ms — soi event này ra đúng thủ phạm mà không tốn overhead đáng kể, nên dùng được cả trên production.

Cờ -Djdk.tracePinnedThreads chỉ còn dùng được tới JDK 23

Tài liệu và bài viết cũ thường chỉ bạn chạy -Djdk.tracePinnedThreads=full để in stack trace lúc bị ghim. Cờ đó có thật trên JDK 21–23, nhưng JDK 24 đã gỡ hẳn nó, không có cờ thay thế — chính vì JEP 491 làm synchronized hết ghim nên công cụ debug riêng cho nó không còn lý do tồn tại. Chạy cờ này trên JDK 24 trở lên thì không có gì xảy ra cả, và đó là cái bẫy: bạn tưởng "không có output nghĩa là không pin". Dùng JFR để khỏi phụ thuộc version.

Hành vi khác nhau theo JDK version

JDK 21 (JEP 444): synchronized luôn ghim virtual thread block bên trong nó — không có cách nào tránh ngoài đổi cơ chế khoá. JDK 24 (JEP 491 — Synchronize Virtual Threads without Pinning): JVM tách monitor khỏi carrier, virtual thread có thể unmount ngay cả khi đang giữ lock — gỡ phần lớn giới hạn này. Native method và foreign function call thì JEP 491 không đụng tới, hai trường hợp đó vẫn ghim bất kể version.

Cách sửa không phụ thuộc version: đổi synchronized sang java.util.concurrent.locks.ReentrantLock. Field khoá đã khai sẵn, thân log() để trống — tự điền trước khi xem đáp án:

public class AuditLogger {

    private final RestTemplate restTemplate;
    private final ReentrantLock lock = new ReentrantLock();

    public AuditLogger(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public void log(AuditEvent event) {
        /* TODO: khoa bang lock, goi restTemplate.postForEntity ben trong,
           dam bao lock.unlock() luon chay du postForEntity co nem loi hay khong */
    }
}
Tự điền trước khi xem đáp án

Viết thân log(): gọi lock.lock(), thực hiện restTemplate.postForEntity(...), và đảm bảo lock.unlock() luôn chạy — kể cả khi có exception giữa chừng. Khối lệnh Java nào đảm bảo một đoạn code luôn chạy dù có lỗi hay không?

Đáp án — thân log() đầy đủ:

public void log(AuditEvent event) {
    lock.lock();
    try {
        restTemplate.postForEntity("/audit-events", event, Void.class);
    } finally {
        lock.unlock();
    }
}

ReentrantLock không phải monitor của JVM — block bên trong nó, hoặc chờ để giữ nó, đều để carrier unmount bình thường. Xác nhận đã sửa: theo dõi event jdk.VirtualThreadPinned qua JFR, count cho AuditLogger về 0 (trên JDK 21–23 có thể đối chiếu thêm bằng -Djdk.tracePinnedThreads=full — không còn stack trace nào trỏ vào class này).

2. ThreadLocal thành đắt khi thread rẻ đi

21:11, tám phút sau khi audit logging được vá tạm bằng cách tắt tính năng, một cảnh báo khác nổ ra: heap usage của TaskFlow tăng dựng đứng, full GC chạy liên tục dù throughput không tăng tương ứng.

Bạn đã biết cơ chế ThreadLocal từ java-internals: mỗi Thread object giữ một map riêng, key là instance ThreadLocal. Cơ chế không đổi khi chuyển sang virtual thread — điều đổi là mẫu số. Platform thread pool cũ giới hạn 200, nên tối đa 200 map ThreadLocal tồn tại cùng lúc bất kể tải cao thế nào. Virtual thread không có trần đó — mỗi request là một Thread object mới toanh, và ở đỉnh burst Cyber Monday, TaskFlow có hơn 6.000 virtual thread sống cùng lúc.

Ba chỗ trong Spring TaskFlow đang dựa thẳng vào ThreadLocal: SecurityContextHolder giữ user đang đăng nhập (spring-security), MDC giữ context log như request-id (spring-core — MDC là ThreadLocal), và TransactionSynchronizationManager giữ Connection của một @Transactional theo thread hiện tại (spring-rest-data). Ở 200 thread, ba thứ đó cộng lại là một khoản bộ nhớ cố định, dễ đoán; ở 6.000 virtual thread, cùng khoản đó nhân lên 30 lần — không phải vì có nhiều việc hơn, mà vì có nhiều Thread object hơn.

Hướng đi dài hạn là ScopedValue (đã học cơ chế ở java-internals: giá trị bất biến, phạm vi rõ ràng). Nhưng Spring framework hiện chưa thay SecurityContextHolder hay TransactionSynchronizationManager sang ScopedValue nội bộ — việc đó nằm ngoài tầm một service team. Thứ bạn kiểm soát được: đừng nhét object nặng (cache lớn, danh sách dài) vào ThreadLocal tự tạo, và cân nhắc ScopedValue cho context bạn tự truyền tay qua các lớp gọi nhau.

Cách nhớ

ThreadLocal không đắt vì bản thân nó nặng — nó đắt vì số Thread giờ nhiều gấp mấy chục lần, và ThreadLocal nhân bản đúng theo số Thread đó.

Cách phát hiện: chụp allocation profile (async-profiler --event alloc, hoặc heap dump ngay lúc đỉnh tải) và tìm object type nào tăng tuyến tính theo số thread đang sống — MDC map hoặc SecurityContext là ứng viên đầu tiên cần soi. Xác nhận đã sửa: theo dõi heap usage ở cùng mức tải sau khi đã cắt bớt object nặng khỏi ThreadLocal tự viết — đường cong phải phẳng lại thay vì tăng tuyến tính theo số request đồng thời.

3. Mất backpressure — van chặn tải biến mất

21:14, ba phút sau, đợt gãy nghiêm trọng nhất trong đêm: dashboard payment API báo lỗi hàng loạt, log service tràn ngập dòng "Connection is not available, request timed out after 30000ms".

Trước khi bật virtual threads, Tomcat giới hạn 200 thread xử lý đồng thời — con số đó vô tình làm luôn vai trò van chặn tải: dù 3.000 request/giây cùng gõ cửa, chỉ 200 request được xử lý cùng lúc, phần còn lại xếp hàng ở tầng network, chưa từng chạm tới tầng nghiệp vụ. Sau khi bật virtual threads, Tomcat chấp nhận gần như không giới hạn số connection đồng thời — mỗi request có ngay một virtual thread riêng, hàng đợi 200 đó không còn.

Hệ quả: cả 3.000 request cùng chạm tới HikariCP pool của TaskFlow (mặc định 10 connection) gần như đồng thời. Mười request đầu tiên lấy được connection, xử lý bình thường. 2.990 request còn lại xếp hàng chờ ở HikariCP, mỗi request chờ tối đa 30 giây (connectionTimeout mặc định) rồi văng lỗi. Payment gateway ở downstream cũng nhận đúng kiểu burst đó — nó có giới hạn rate riêng, và khi TaskFlow không còn tự chặn tải ở tầng của mình, downstream bị dội ngược theo.

flowchart TB
    BURST["Cyber Monday burst<br/>3000 request/giay"] --> TOMCAT["Tomcat + virtual threads<br/>khong con hang doi 200"]
    TOMCAT --> POOL{"HikariCP pool<br/>10 connection"}
    POOL -- "10 request dau" --> OK["Xu ly binh thuong"]
    POOL -- "2990 request con lai" --> TIMEOUT["Cho toi 30s roi timeout"]
    TIMEOUT --> DOWNSTREAM["Payment gateway<br/>nhan burst tuong tu"]

Điểm quan trọng nhất trong cả ba cạm bẫy: connection pool không tự lớn theo virtual thread. HikariCP vẫn giữ đúng maximumPoolSize đã cấu hình — 10 hay 20, không đổi. Cái đổi là số request cùng lúc đòi lấy 10 chỗ đó: từ tối đa 200 (bị Tomcat chặn từ trước) lên hàng nghìn (không còn gì chặn).

Cách sửa: dựng lại giới hạn tường minh ở đúng chỗ pool cũ từng làm — ví dụ một Semaphore kích thước khớp maximumPoolSize, đặt trước bước cần connection:

public class OrderService {

    private final Semaphore dbGate = new Semaphore(10);
    private final OrderRepository orderRepository;

    public Order placeOrder(Order order) {
        dbGate.acquireUninterruptibly();
        try {
            return orderRepository.save(order);
        } finally {
            dbGate.release();
        }
    }
}

Ở quy mô production, rate limiter hoặc bulkhead của Resilience4j làm cùng việc với Semaphore viết tay, cộng thêm timeout/fallback có sẵn — nguyên tắc giống nhau: giới hạn số request chạm vào một tài nguyên hữu hạn, thay vì để tất cả cùng chen.

Xác nhận đã sửa: lặp lại burst test ở cùng 3.000 request/giây, theo dõi thời gian lấy connection của HikariCP — phải quay về mức bình thường, và request vượt giới hạn nên fail nhanh, có kiểm soát, thay vì chờ 30 giây rồi timeout hàng loạt.

Đào sâu

Đào sâu — spec và tài liệu chính thức
  • JEP 444 — Virtual Threads — định nghĩa chính xác hai tình huống pin (synchronized, native/foreign call) và event JFR jdk.VirtualThreadPinned; cờ -Djdk.tracePinnedThreads mô tả ở đây chỉ tồn tại tới JDK 23.
  • JEP 491 — Synchronize Virtual Threads without Pinning — thay đổi ở JDK 24: monitor gắn với virtual thread thay vì carrier, gỡ phần lớn pinning do synchronized gây ra.
  • Spring Boot Reference — Task Execution and Scheduling — hành vi chính xác của spring.threads.virtual.enabled: executor và scheduler chuyển sang SimpleAsyncTaskExecutor/SimpleAsyncTaskScheduler, và mọi property liên quan tới pooling bị bỏ qua.

Ghi chú: hai JEP là spec chính thức của OpenJDK — đọc để biết đúng điều kiện pin thay vì suy đoán theo trí nhớ. Tài liệu Spring Boot xác nhận flag chỉ đổi loại executor, không thêm bảo vệ nào cho ThreadLocal hay connection pool — đúng lý do vì sao ba cạm bẫy này là trách nhiệm của bạn.

Liên hệ các bài khác

  • Bài 02 — Virtual threads trong Boot — quyết định bật virtual threads dựa trên tiêu chí I/O-bound; bài này giả định bạn đã bật rồi và cần biết nó gãy ở đâu.
  • Bài 04 — RestClient — cách gọi HTTP outbound tường minh với timeout. Nếu AuditLogger ở trên dùng client hiện đại với timeout rõ ràng thay vì lời gọi blocking trần trong synchronized, một phần rủi ro pinning đã giảm ngay từ cách viết.
  • Bài 07 — Mini-challenge TaskFlow v4 — áp lại cả ba cạm bẫy này vào một tình huống thực hành trên chính TaskFlow.

Tóm tắt

  • Trước khi đổ lỗi cho virtual threads khi có sự cố lạ, hỏi: chỗ nghẽn có đang nằm trong khối synchronized bọc một lời gọi blocking không?
  • JDK bạn đang chạy quyết định mức độ nghiêm trọng của pinning: 21 vẫn ghim ở synchronized, 24 (JEP 491) đã gỡ phần lớn — kiểm version trước khi áp kết luận cũ.
  • Đối tượng từng "rẻ" khi chỉ có 200 platform thread — SecurityContext, MDC map, resource của transaction — có thể thành nguồn ngốn bộ nhớ khi số thread nhảy lên hàng nghìn.
  • Van chặn tải dựng lại bằng Semaphore đặt trước bước gọi DB, kích thước khớp maximumPoolSize HikariCP — thiếu nó, virtual thread dồn nghìn request vào một pool cố định.
  • Semaphore, rate limiter hay bulkhead không phải tuỳ chọn "cho chắc" — chúng thay đúng vai trò van chặn tải mà pool cũ từng làm.

Tự kiểm tra

Tự kiểm tra
Q1
Đoạn code AuditLogger ở phần 1 vốn đã chạy blocking bên trong synchronized từ thời platform thread, nhưng không ai để ý. Vì sao nó chỉ gây sự cố nghiêm trọng SAU khi TaskFlow bật virtual threads?
Với platform thread pool cỡ 200, cùng hành vi đó chỉ làm chậm throughput trong một trần đã biết trước, không ai thấy bất thường. Sau khi bật virtual threads, mẫu số đổi hẳn: carrier pool chỉ còn bằng số CPU core, một con số nhỏ như 8. Cùng khối synchronized đó giờ ghim carrier — tài nguyên khan hiếm hơn nhiều — nên một nhúm request audit log đủ khoá hết carrier và kéo sập cả request không liên quan.
Q2
Nếu TaskFlow nâng lên JDK 24 nhưng vẫn giữ nguyên code synchronized ở SpotTheBug phần 1, đúng đoạn code đó có còn pin không? Bạn có nên vẫn đổi sang ReentrantLock?
Không còn pin theo cơ chế cũ: JEP 491 tách monitor khỏi carrier, nên block bên trong synchronized ở JDK 24 unmount được bình thường. Vẫn nên đổi sang ReentrantLock: JEP đó chỉ sửa trường hợp synchronized, native/foreign function call vẫn ghim ở mọi version, và service thường phải chạy được trên nhiều JDK khác nhau qua thời gian.
Q3
Cơ chế ThreadLocal ở phần 2 không hề thay đổi khi chuyển sang virtual thread. Vậy chính xác điều gì đã đổi khiến nó trở thành vấn đề đáng để ý hơn?
Không phải cơ chế đổi — mỗi Thread object vẫn giữ một map riêng, key là instance ThreadLocal, y hệt trước giờ. Điều đổi là số Thread object đang sống cùng lúc: từ trần cứng 200 lên hàng nghìn không giới hạn. Vì chi phí ThreadLocal tỉ lệ với số Thread đang sống chứ không phải lượng việc thật, "thread rẻ đi" vô tình làm tổng chi phí đó tăng vọt dù throughput không tăng tương ứng.
Q4
Trong đợt Cyber Monday, vì sao pool HikariCP 10 kết nối lại timeout hàng loạt dù bản thân DB server hoàn toàn khoẻ mạnh?
Trước khi bật virtual threads, Tomcat chỉ cho tối đa 200 request chạm tới tầng nghiệp vụ cùng lúc. Con số đó vẫn lớn hơn 10 chỗ trong pool, nhưng là một trần cố định: hàng đợi chờ connection luôn bị chặn ở 200, mà mỗi lượt giữ connection chỉ vài mili-giây nên hàng đợi giải phóng kịp, gần như không ai chờ tới mức timeout. Sau khi bỏ giới hạn đó, gần 3.000 request cùng chạm tới tầng nghiệp vụ trong một khoảnh khắc, tất cả cùng đòi 10 chỗ trong pool; phần lớn chờ quá 30 giây rồi văng lỗi — không phải vì DB chậm, mà vì có quá nhiều request đến cùng lúc so với sức chứa pool.
Q5
Sau khi thêm Semaphore giới hạn 10 lượt gọi DB đồng thời như ở phần 3, 2.990 request còn lại trong đợt burst sẽ ra sao?
Chúng xếp hàng ở Semaphore thay vì ở HikariCP — số request chạm được vào pool tại một thời điểm đúng bằng 10, khớp maximumPoolSize, nên pool không bao giờ phục vụ quá công suất nữa. Nhưng xếp hàng ở Semaphore không tự động là điều tốt: cần kèm timeout hoặc giới hạn độ sâu hàng đợi, để request vượt sức chứa fail nhanh và rõ ràng thay vì treo vô thời hạn.

Bài tiếp theo: RestClient — HTTP client outbound tường minh

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

RestClient — gọi HTTP ra ngoài mà không mất kiểm soát