ThreadLocal: Thành ngữ đúng, nhưng sai thời đại
Các container Servlet Java EE, khoảng năm 1999: một thread cho mỗi request. Một thread xử lý chính xác một request từ đầu đến cuối, sau đó kết thúc. ThreadLocal lưu trữ giá trị được đánh khoá theo thread hiện tại. Với mô hình một-thread-mỗi-request, giá trị được lưu trong ThreadLocal thuộc về đúng một request. Thành ngữ: đúng.
Thread pool đã thay đổi hợp đồng. Một thread xử lý request A, lưu principal A vào ThreadLocal, hoàn thành request A, và trả về pool. Thread pool không reset trạng thái thread. ThreadLocal.remove() dọn dẹp, nhưng gọi nó đòi hỏi kỷ luật rõ ràng. Khi kỷ luật thất bại, request B chạy trên cùng thread và đọc principal A trong ThreadLocal.
Rò rỉ 5 bước:
1. Request A đến. Server gán Thread-7.
2. Thread-7 gọi ThreadLocal.set(principal_A) khi request bắt đầu.
3. Request A hoàn tất. Thread-7 được trả về pool. ThreadLocal.remove() không được gọi.
4. Request B đến. Server gán Thread-7 (tái sử dụng từ pool).
5. Thread-7 đọc ThreadLocal.get(): trả về principal_A. Request B chạy dưới danh tính sai.
Tại sao các bài kiểm tra không phát hiện được
Unit test chạy cô lập: không có thread pool, không có tái sử dụng. Integration test sử dụng thread mới hoặc reset trạng thái giữa các test. Load test khởi động với người dùng đúng & độ đồng thời thấp. Lỗi chỉ xuất hiện khi có tái sử dụng thread pool với các request chồng chéo, điều kiện chỉ xảy ra trong môi trường production dưới lưu lượng bình thường, không có trong bất kỳ cấu hình test nào kiểm tra điều này.
Hậu quả bảo mật
Principal của User A bị rò rỉ sang request của User B. Không phải crash. Không phải ngoại lệ. Đây là vi phạm ranh giới bảo mật thầm lặng: User B thực hiện hành động dưới danh tính User A, đọc dữ liệu của User A, hoặc bỏ qua quyền của User B. Hệ thống không sinh lỗi. Log cho thấy request B đã được xác thực. Mọi thứ trông đều đúng.
The Five Steps
Chính năm bước của rò rỉ ThreadLocal mới quan trọng: lỗi không xảy ra tại thời điểm đoạn mã sai chạy. Nó xảy ra sớm hơn, khi thiếu bước dọn dẹp.
Giá Trị Gắn Với Phạm Vi
ThreadLocal gắn một giá trị vào một luồng. Một luồng tồn tại lâu hơn một request. Không khớp.
Scope-attached values gắn một giá trị vào một đơn vị công việc. Khi đơn vị công việc kết thúc, giá trị cũng kết thúc theo. Không cần dọn dẹp rõ ràng. Không cần remove() để quên.
Java 21: ScopedValue
// ThreadLocal (DEFECT carrier)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // set at request start
// ... request handling ...
PRINCIPAL.remove(); // PHẢI gọi; thường bị quên
// ScopedValue (carrier ĐÚNG)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... xử lý request ...
// giá trị tự động biến mất khi run() kết thúc
});
Go: context.Context
// context.Context mang giá trị một cách tường minh; phạm vi = chuỗi lời gọi hàm
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx được truyền tường minh; biến mất khi hàm kết thúc
Python asyncio: contextvars.ContextVar
# ContextVar được giới hạn trong mỗi tác vụ async
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # chỉ đặt cho tác vụ này
# ... xử lý tác vụ ...
PRINCIPAL.reset(token) # hoặc: phạm vi kết thúc cùng tác vụ
Đặc điểm chung của chúng: vòng đời khớp với đơn vị công việc. Khi request kết thúc (run() trả về, hàm trả về, tác vụ hoàn thành), giá trị cũng kết thúc. Không cần dọn dẹp để tránh quên. Không có pool nào bị hỏng.
Xác định & Thay thế
Một ứng dụng Java EE lưu trữ tenant ID trong ThreadLocal khi bắt đầu request. Dưới tải cao, tenant ID của A lại xuất hiện trong các request của tenant B. Các truy vấn của tenant B trả về dữ liệu của tenant A. Không có ngoại lệ nào được ném ra. Lỗi chỉ xuất hiện trong kiểm thử tải production.