Request & Session Scope: Cơ Chế Scoped Proxy Cho Web
Tìm hiểu cơ chế ThreadLocal của Request và Session scope trong Spring Web, vai trò của Scoped Proxy và cách tránh mất context với @Async.
TL;DR: @RequestScope tạo một bean instance cho mỗi HTTP request; @SessionScope tạo một instance cho mỗi HTTP session. Request scope vận hành dựa trên RequestContextHolder lưu HttpServletRequest qua ThreadLocal. Khi một Singleton bean tiêm một bean request-scoped, Spring bắt buộc phải dùng Scoped Proxy (CGLIB subclass) để trì hoãn việc lấy instance thật đến khi có request thực tế. Cạm bẫy lớn nhất là @Async: khi chuyển sang thread pool khác, ThreadLocal không tự lan truyền khiến request context bị mất hoàn toàn.
Nắm vững hai web scope này giúp bạn quản lý trạng thái phiên làm việc và ngữ cảnh phân tán chuẩn xác mà không gây rò rỉ bộ nhớ.
1. Request Scope giải quyết bài toán chia sẻ dữ liệu nào trong Web?
Trong ứng dụng web, nhu cầu gắn một mã định danh duy nhất (requestId hay traceId) vào mọi log xuyên suốt từ Controller xuống Service và Repository là rất phổ biến. Nếu truyền thủ công qua tham số của từng hàm, danh sách tham số sẽ phình to và làm bẩn mã nghiệp vụ.
Request Scope giải quyết triệt để vấn đề này bằng cách cho phép bạn định nghĩa một bean sống theo vòng đời của một HTTP request:
@Component
@RequestScope
public class RequestContext {
private final String requestId = UUID.randomUUID().toString();
public String getRequestId() { return requestId; }
}
@Service
public class OrderService {
private final RequestContext context; // Tiem Scoped Proxy
public OrderService(RequestContext context) {
this.context = context;
}
public void processOrder() {
log.info("[{}] Processing order logic", context.getRequestId());
}
}
Mỗi khi có một HTTP request gọi tới OrderService, phương thức context.getRequestId() luôn trả về chính xác ID của request đó mà không bị nhầm lẫn với các request khác đang chạy song song.
2. RequestContextHolder lưu trữ thông tin qua ThreadLocal như thế nào?
Cơ chế nền tảng giúp Request Scope vận hành là ThreadLocal. Trong mô hình servlet truyền thống của Tomcat, mỗi request được giao cho một worker thread xử lý từ đầu đến cuối:
// Logic co ban ben trong RequestContextHolder
public abstract class RequestContextHolder {
private static final ThreadLocal<RequestAttributes> attributesHolder =
new NamedThreadLocal<>("Request attributes");
public static void setRequestAttributes(RequestAttributes attributes) {
attributesHolder.set(attributes);
}
public static RequestAttributes currentRequestAttributes() {
RequestAttributes attrs = attributesHolder.get();
if (attrs == null) {
throw new IllegalStateException("No thread-bound request found");
}
return attrs;
}
}
Khi nhận request, DispatcherServlet lưu request vào RequestContextHolder. Khi Spring cần lấy một request-scoped bean, nó truy xuất từ request.getAttribute(beanName):

Vì mỗi worker thread có bản sao ThreadLocal riêng biệt, các request chạy song song tự nhiên được cách ly mà không cần đến bảng lưu trữ toàn cục nào.
3. Vì sao Singleton bắt buộc phải dùng Scoped Proxy khi tiêm Web Bean?
Singleton Bean được khởi tạo ngay lúc ứng dụng khởi động (ApplicationContext startup) khi chưa hề có bất kỳ HTTP request nào. Nếu Spring cố gắng tiêm một bean request-scoped trực tiếp vào Singleton tại thời điểm này, RequestContextHolder.currentRequestAttributes() sẽ ném ngay IllegalStateException vì ThreadLocal đang rỗng.
Để giải quyết mâu thuẫn về thời điểm khởi tạo, Spring sử dụng Scoped Proxy:

Annotation @RequestScope là viết tắt của:
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
Thay vì tiêm đối tượng thật, Spring tiêm một CGLIB Proxy rỗng vào Singleton Bean lúc khởi động. Khi một phương thức trên proxy được gọi trong lúc xử lý request, Proxy mới tra cứu ThreadLocal để tìm bean thật tương ứng của request hiện tại và ủy quyền lời gọi xuống đó.
4. Tại sao annotation @Async làm mất Request Context và cách xử lý?
Khi một phương thức được đánh dấu @Async, Spring sẽ đẩy tác vụ đó sang một thread pool khác (TaskExecutor) thay vì chạy trên Tomcat worker thread hiện tại:
@Service
public class OrderService {
private final RequestContext context;
@Async
public CompletableFuture<Void> sendEmailAsync() {
// Chay tren thread pool: ThreadLocal khong tu lan truyen!
String reqId = context.getRequestId(); // Nem IllegalStateException
return CompletableFuture.completedFuture(null);
}
}
Vì ThreadLocal chỉ gắn liền với thread ban đầu, worker thread mới không hề có thông tin về request hiện tại, dẫn tới lỗi No thread-bound request found.
Hai giải pháp khắc phục:
- Truyền dữ liệu tường minh qua tham số hàm (Khuyến nghị): Lấy dữ liệu cần thiết từ request bean trước khi gọi hàm bất đồng bộ và truyền vào qua tham số. Cách này trong sáng và dễ kiểm thử nhất.
- Sử dụng TaskDecorator: Cấu hình executor với bộ trang trí tự động sao chép
RequestAttributessang thread con:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(runnable -> {
RequestAttributes context = RequestContextHolder.getRequestAttributes();
return () -> {
try {
RequestContextHolder.setRequestAttributes(context);
runnable.run();
} finally {
RequestContextHolder.resetRequestAttributes();
}
};
});
executor.initialize();
return executor;
}
}
5. Session Scope yêu cầu Serializable vì lý do gì trong môi trường Cluster?
@SessionScope duy trì một bean instance duy nhất cho toàn bộ phiên làm việc của một người dùng (HTTP Session):
@Component
@SessionScope
public class UserCart implements Serializable {
private static final long serialVersionUID = 1L;
private final List<String> itemIds = new ArrayList<>();
private transient HttpClient client; // Danh dau transient neu khong the serialize
public void addItem(String id) { itemIds.add(id); }
}
Bean session bắt buộc phải implements Serializable vì hai lý do thực tế trong môi trường phân tán:
- Sao lưu Session trong Cluster (Replication): Khi chạy nhiều instance ứng dụng phía sau Load Balancer, nếu một server gặp sự cố, session sẽ được chuyển giao sang server khác thông qua serialization.
- Lưu trữ ngoài với Spring Session: Khi lưu trữ session tập trung trong Redis hoặc Database, đối tượng bắt buộc phải được chuyển đổi thành chuỗi byte.
Nếu một trường trong bean không thể tuần tự hóa (như database connection hay socket), bạn bắt buộc phải đánh dấu trường đó bằng từ khóa transient.
6. Khi nào nên và không nên sử dụng Web Scope trong ứng dụng hiện đại?
| Tình huống sử dụng | Phạm vi phù hợp | Ghi chú kiến trúc |
|---|---|---|
| Lưu Correlation ID / Trace ID | @RequestScope | Rất hữu ích khi muốn gắn ID vào log mọi tầng |
| Lưu cache tạm trong nội bộ request | @RequestScope | Tránh truy vấn lặp lại cùng một dữ liệu trong 1 request |
| Giỏ hàng ứng dụng Monolith cũ | @SessionScope | Bắt buộc Serializable và xử lý tranh chấp đa luồng |
| REST API Stateless dùng JWT | Không dùng Web Scope | Đẩy trạng thái về Client hoặc lưu trong Redis/Database |
| Lưu thông tin người dùng hiện tại | SecurityContextHolder | Nên dùng chuẩn của Spring Security thay vì Session |
7. Thiết kế luồng truyền Trace ID xuyên suốt các tầng và qua luồng Async?
Khi xây dựng hệ thống phân tán, việc giữ trọn vẹn traceId từ tầng web xuống các tác vụ ngầm được thiết kế chuẩn mực như sau:
@Service
public class PaymentProcessingService {
private final RequestContext requestContext;
private final NotificationService notificationService;
public PaymentProcessingService(RequestContext requestContext,
NotificationService notificationService) {
this.requestContext = requestContext;
this.notificationService = notificationService;
}
public void processPayment() {
String currentTraceId = requestContext.getRequestId();
log.info("[{}] Processing payment", currentTraceId);
// Trich xuat traceId truoc khi goi sang thread pool bat dong bo
notificationService.sendReceiptAsync(currentTraceId);
}
}
Bằng cách truyền currentTraceId trực tiếp như một giá trị nguyên thủy vào phương thức bất đồng bộ, hệ thống vừa giữ được khả năng truy vết liền mạch, vừa tránh được ngoại lệ rỗng ThreadLocal.
Tóm tắt
@RequestScopevà@SessionScopesử dụngThreadLocalquaRequestContextHolderđể cô lập dữ liệu theo từng luồng xử lý web.- Singleton bean tiêm web-scoped bean bắt buộc phải thông qua Scoped Proxy để tách biệt thời điểm khởi tạo lúc startup khỏi lúc gọi method.
- Phương thức
@Asyncchạy trên thread pool khác nên không cóThreadLocaltừ Tomcat; cần truyền dữ liệu qua tham số hoặc dùngTaskDecorator. - Session bean bắt buộc phải implement
Serializableđể hỗ trợ cơ chế nhân bản trong môi trường multi-node và Spring Session.
Tự kiểm tra
- Q1Cơ chế cốt lõi nào giúp Request Scope cô lập dữ liệu giữa hàng trăm HTTP request chạy đồng thời mà không cần bảng quản lý tập trung?
- Q2Tại sao Spring Container sẽ ném ngoại lệ IllegalStateException lúc startup nếu tiêm trực tiếp một Request-scoped bean vào Singleton bean mà KHÔNG dùng Scoped Proxy?
- Q3Vai trò chính của Scoped Proxy (CGLIB subclass) khi tiêm vào một Singleton bean là gì?
- Q4Điều gì xảy ra khi bạn gọi một phương thức trên Request-scoped bean bên trong một method được đánh dấu @Async?
- Q5Tại sao các Session-scoped bean bắt buộc phải implements giao diện java.io.Serializable trong ứng dụng production?
Bài tiếp theo: Tổng Kết Module 03: Vòng Đời Và Phạm Vi Spring Bean
Bài này có giúp bạn hiểu bản chất không?
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.
Singleton Và Prototype Scope: Cạm Bẫy Scope Mismatch
Bài tiếpTổng Kết Module 03: Vòng Đời Và Phạm Vi Spring Bean
Lộ trình Junior Java Backend Developer
Chặng 13/21 · Spring IoC, DI & Bean — bài 11/17 của chặng
Thảo luận & góp ý
Hỏi đáp về bài này