Hình dung một phòng khám ghi chú bệnh án bằng cách dán tờ giấy lên chiếc giường khám thay vì kẹp vào hồ sơ của bệnh nhân. Khi mỗi giường phục vụ đúng một người thì chẳng sao. Nhưng nếu nhiều bệnh nhân thay nhau nằm lên cùng một giường, bác sĩ quay lại đọc tờ giấy trên giường sẽ đọc bệnh án của người trước — và kê đơn cho người này bằng tiền sử của người khác. ThreadLocal gắn dữ liệu vào luồng đúng như dán giấy lên giường; trong thế giới một-luồng-mỗi-request nó gọn gàng, còn trên event loop nó hỏng — và câu trả lời tệ hơn "không hoạt động".

ThreadLocal không mất dữ liệu, nó trả về dữ liệu của người khác

Hai request song song vào cùng một verticle, mỗi cái đặt ThreadLocal bằng id của mình rồi đọc lại sau vài chặng bất đồng bộ:

--- REQ-A thấy ---
ngay sau khi đặt : TL=REQ-A
sau timer+compose: TL=REQ-A
cuối cùng        : TL=REQ-A

--- REQ-B thấy ---
ngay sau khi đặt : TL=REQ-B
sau timer+compose: TL=REQ-A     ← giá trị của request KHÁC
cuối cùng        : TL=REQ-A     ← giá trị của request KHÁC
Request B ghi log, kiểm tra quyền và gắn trace id bằng danh tính của request A. Không có ngoại lệ, không có null, không có gì trong log để lần ra. Đây là loại lỗi tệ nhất: nó im lặng và nó sai, chứ không phải thiếu.

Nguyên nhân đơn giản khi nói ra: hai request chạy trên cùng một luồng event loop, xen kẽ nhau. Ai đặt sau thì đè lên. ThreadLocal gắn dữ liệu vào luồng, mà luồng ở đây không thuộc về request nào cả.

Nó nguy hiểm gấp đôi vì trong test đơn giản nó có vẻ chạy đúng — chỉ một request tại một thời điểm thì không ai đè ai.

Context đi được tới đâu

Vert.x có sẵn thứ thay thế: Context. Đo cùng một request qua bốn chặng:

Chặng ThreadLocal context.getLocal()
Ngay sau khi đặt REQ-1 REQ-1
Sau timer + compose REQ-1 REQ-1
Trong executeBlocking null REQ-1
Qua event bus sang verticle khác null null
Quay về event loop REQ-1 REQ-1

Ba điều đọc ra:

ThreadLocal sống sót qua compose chỉ vì tình cờ — cùng một luồng event loop. Thêm một executeBlocking vào giữa là nó thành null ngay, vì phần 5 cho thấy đoạn đó chạy trên luồng worker.

Context đi theo qua compose và cả executeBlocking. Vert.x khôi phục context khi gọi lại handler của bạn, bất kể trên luồng nào. Đây là thứ bạn muốn.

Event bus là ranh giới. Sang verticle khác thì context local biến mất — hợp lý, vì đó có thể là một tiến trình khác trên máy khác. Muốn mang gì qua đó thì phải gắn vào header thông điệp, không có đường tắt.

Mỗi request một context riêng

Kiểm tra danh tính của context trong hai request đồng thời:

context của A: 559592006
context của B: 720552468   -> KHÁC NHAU

Máy chủ HTTP của Vert.x tạo một context nhân bản cho mỗi request, nên putLocal của request này không đụng tới request kia — đúng như bảng ở trên cho thấy: ctxLocal luôn đúng trong khi ThreadLocal lẫn lộn.

Lưu ý phân biệt hai cặp phương thức, vì đặt nhầm là quay lại đúng vấn đề của ThreadLocal:

Phạm vi
context.put / get dùng chung cho cả verticle — mọi request đi qua nó
context.putLocal / getLocal riêng cho context nhân bản, tức riêng từng request

Dữ liệu của request phải dùng putLocal. Dùng put là mọi request ghi đè lên nhau, y hệt ThreadLocal chỉ khác tên gọi.

Cách dùng đúng

// dat mot lan o dau chuoi xu ly
Vertx.currentContext().putLocal("traceId", traceId);

// doc o bat ky dau trong chuoi, ke ca trong executeBlocking
String traceId = Vertx.currentContext().getLocal("traceId");

// qua event bus thi phai gan tay
vertx.eventBus().request("dich-vu", than,
        new DeliveryOptions().addHeader("traceId", traceId));

Và đừng dùng ThreadLocal trong bất kỳ mã nào chạy trên event loop — kể cả thư viện bạn kéo về. Nếu một thư viện log hay bảo mật dựa vào ThreadLocal để giữ ngữ cảnh, nó sẽ hành xử đúng như bảng đầu bài trong ứng dụng Vert.x của bạn. Tìm chúng cũng nhanh:

# tim ThreadLocal trong ma chay tren event loop
grep -rn "ThreadLocal\|MDC.put\|SecurityContextHolder" src/ | head

Mỗi dòng tìm được là một chỗ hai request có thể đọc nhầm dữ liệu của nhau. MDC của SLF4J và SecurityContextHolder của Spring Security đều là ThreadLocal bên dưới.

Mẫu số chung

Trạng thái của một request phải được gắn vào đơn vị công việc (chính request đó), không phải vào cỗ máy tình cờ chạy nó (cái luồng). Luồng là tài nguyên dùng chung và được tái sử dụng, nên mọi thứ ghim vào luồng sẽ rò rỉ ngay khi một luồng phục vụ nhiều đơn vị công việc. ThreadLocal vốn là một cá cược rằng "một luồng = một request" — và event loop, coroutine, hay virtual thread đều xé nát cá cược đó, lặng lẽ trả về dữ liệu của người khác. Lời giải ở đâu cũng là truyền ngữ cảnh theo dòng chảy logic chứ không theo luồng: context.Context của Go (truyền tường minh qua từng lời gọi, không bao giờ goroutine-local), AsyncLocalStorage của Node, AsyncLocal của .NET, coroutine context của Kotlin, và chính ScopedValue mà Java mới thêm để thay ThreadLocal cho virtual thread. Gắn phạm vi vào việc, đừng gắn vào người làm.

Điều thứ hai: cú hỏng sai-trong-im-lặng là loại độc nhất, và nó trốn khỏi test tuần tự. Bug này chỉ hiện ra khi có xen kẽ đồng thời, nên một test chạy đúng một request tại một thời điểm luôn xanh — trong khi production thì gả cho request B danh tính của request A. Đây đúng bài học "đo/thử ở sai điểm vận hành" nhìn từ góc kiểm thử: muốn bắt được lớp lỗi này, test phải ép đồng thời, không phải chạy từng cái một. Và có một ranh giới đi kèm: context theo được trong một tiến trình nhưng dừng ở event bus — qua ranh giới tiến trình thì phải truyền tay (gắn vào header), y hệt cách một trace id được mang xuyên nhiều dịch vụ trong distributed tracing. Cái gì đi ngầm được trong nhà thì ra khỏi cửa phải khai báo.

Bài 32 của sê-ri sẽ dựng lại chuyện này ở quy mô lớn hơn: mang trace id đi xuyên nhiều dịch vụ, nơi ranh giới event bus xuất hiện ở mọi chặng.