Mọi ứng dụng đều cần mang theo vài thứ suốt một request: id người dùng, trace id, tenant. Trong thế giới một luồng mỗi request, ThreadLocal giải quyết chuyện đó gọn gàng. Bài này đo xem nó hỏng thế nào khi chuyển sang event loop — 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));

đừ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.

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.

Thử ba mươi giây

# 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.