Request & Session Scope — web scope và scoped proxy
Request scope = 1 bean/HTTP request; session scope = 1 bean/session. Cơ chế ThreadLocal, vì sao cần scoped proxy, và pitfall @Async mất request context.
TL;DR: @RequestScope tạo 1 bean instance mỗi HTTP request; @SessionScope tạo 1 instance mỗi HTTP session. Cơ chế không có gì huyền bí — request scope hoạt động nhờ RequestContextHolder giữ HttpServletRequest qua ThreadLocal. Khi singleton inject request-scoped bean, phải dùng scoped proxy (CGLIB subclass) vì singleton tạo lúc startup khi chưa có request; proxy resolve đúng instance tại runtime mỗi lần gọi. Pitfall cổ điển: @Async chuyển sang thread pool khác — ThreadLocal không propagate, request context mất. Session bean bắt buộc implement Serializable cho cluster failover.
Bài Singleton & Prototype scope đã điểm qua các scope cơ bản và cơ chế lưu trữ tổng quát. Bài này đào sâu duy nhất hai web scope: request và session — tập trung vào cơ chế ThreadLocal, lý do cần scoped proxy, và pitfall thực chiến.
1. Vấn đề request scope giải quyết
Giả sử bạn cần gắn một requestId vào mọi log trong suốt 1 HTTP request — để trace phân tán, debug production. Cách naive: truyền requestId qua tất cả method param từ controller xuống service xuống repository. Với 7 tầng lồng nhau, param list phình to và tất cả code phải biết về requestId dù không cần.
Request scope giải quyết bằng cách cho phép bạn khai báo 1 bean "sống trong request" — controller, service, repository đều inject chung bean đó, và mỗi request tự nhiên có instance riêng:
@Component
@RequestScope
public class RequestContext {
private final String requestId = java.util.UUID.randomUUID().toString();
private final long startMs = System.currentTimeMillis();
public String getRequestId() { return requestId; }
public long elapsedMs() { return System.currentTimeMillis() - startMs; }
}
@Service
public class OrderService {
private final RequestContext reqCtx; // inject proxy — resolve per-request
public OrderService(RequestContext reqCtx) {
this.reqCtx = reqCtx;
}
public Order place(OrderRequest req) {
log.info("[{}] placing order", reqCtx.getRequestId());
// ...
}
}
Mỗi HTTP request vào OrderService.place(), reqCtx.getRequestId() trả đúng ID của request đó — không phải ID của request khác đang chạy song song.
2. Cơ chế bên dưới — ThreadLocal, không phải phép màu của Spring
Request scope không có gì huyền bí. Cơ chế là ThreadLocal — một cơ chế Java chuẩn cho phép mỗi thread giữ bản sao dữ liệu riêng.
Spring Web đặt HttpServletRequest hiện tại vào RequestContextHolder — một class với ThreadLocal bên trong — ngay khi Tomcat giao request cho thread:
// Noi dung noi bo Spring (don gian hoa):
public abstract class RequestContextHolder {
private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
new NamedThreadLocal<>("Request attributes");
// DispatcherServlet goi truoc xu ly request:
public static void setRequestAttributes(RequestAttributes attributes) {
requestAttributesHolder.set(attributes);
}
// Bean request-scoped goi khi can lay request hien tai:
public static RequestAttributes currentRequestAttributes() {
RequestAttributes attrs = requestAttributesHolder.get();
if (attrs == null) {
throw new IllegalStateException("No thread-bound request found");
}
return attrs;
}
}
Khi RequestScope.get() (implementation của Scope interface) cần trả instance bean, nó gọi RequestContextHolder.currentRequestAttributes() để lấy HttpServletRequest của thread hiện tại, rồi tra bean từ request.getAttribute(beanName):
// RequestScope.get() -- logic chinh:
public Object get(String name, ObjectFactory<?> objectFactory) {
RequestAttributes attrs = RequestContextHolder.currentRequestAttributes();
Object bean = attrs.getAttribute(name, RequestAttributes.SCOPE_REQUEST);
if (bean == null) {
bean = objectFactory.getObject(); // tao moi neu chua co
attrs.setAttribute(name, bean, RequestAttributes.SCOPE_REQUEST);
}
return bean;
}
Toàn bộ luồng khi Tomcat nhận 1 HTTP request:

Hệ quả trực tiếp: mỗi thread Tomcat giữ ThreadLocal riêng → mỗi request chạy trên thread riêng → mỗi request thấy instance bean riêng. Không có map toàn cục nào track "request A dùng bean X, request B dùng bean Y" — tất cả là ThreadLocal locality.
ThreadLocal phù hợp vì mô hình Tomcat truyền thống: 1 request = 1 thread, từ đầu đến cuối. Thread là đơn vị cô lập tự nhiên. Đây là lý do request scope chỉ hoạt động trong web context với thread-per-request model (Tomcat, Jetty). Virtual thread (Project Loom) cũng giữ ThreadLocal, nên request scope vẫn chạy đúng với Spring Boot 3.2+ virtual thread executor.
3. Tại sao singleton inject request-scoped bean phải qua scoped proxy?
Đây là câu hỏi quan trọng nhất: nếu cơ chế đã là ThreadLocal, tại sao không inject thẳng bean vào singleton?
Vấn đề nằm ở thời điểm tạo: singleton được tạo lúc ApplicationContext khởi động — trước khi bất kỳ HTTP request nào đến. Khi Spring cố inject RequestContext vào OrderService lúc startup, nó phải gọi RequestScope.get(), và method này gọi tiếp RequestContextHolder.currentRequestAttributes() — lúc đó chưa có thread-bound request nào, nên Spring throw IllegalStateException.
Scoped proxy giải quyết bằng cách tách thời điểm inject và thời điểm resolve:

Khi @RequestScope (hoặc @Scope(value="request", proxyMode=ScopedProxyMode.TARGET_CLASS)) được khai báo, Spring không inject bean thật — nó inject 1 proxy object. Proxy này:
- Được tạo lúc startup mà không cần request (nó chỉ là shell CGLIB, chưa gọi
RequestScope.get()). - Mỗi khi method được gọi trên proxy, proxy gọi
RequestScope.get()để lấy instance đúng của request hiện tại. - Delegate method call xuống instance thật đó.
// @RequestScope = shortcut cua:
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
TARGET_CLASS nghĩa là CGLIB tạo subclass của RequestContext. Proxy là subclass — nên inject qua field kiểu RequestContext không cần interface. Bài AOP proxy — JDK vs CGLIB đã giải thích CGLIB tạo subclass bằng bytecode generation ở bước BeanPostProcessor.postProcessAfterInitialization.
ScopedProxyMode.INTERFACES dùng JDK dynamic proxy — chỉ hoạt động khi bean implement interface, và caller inject qua interface. ScopedProxyMode.TARGET_CLASS dùng CGLIB — hoạt động với bất kỳ class nào. @RequestScope và @SessionScope đều mặc định TARGET_CLASS — an toàn cho 99% trường hợp.
4. Pitfall — @Async mất ThreadLocal
ThreadLocal là per-thread. Khi @Async chạy method trên thread pool khác Tomcat thread, ThreadLocal của Tomcat thread không propagate:
@Service
public class OrderService {
private final RequestContext reqCtx; // request-scoped, inject proxy
@Async
public CompletableFuture<Void> processAsync(OrderRequest req) {
// Chay tren thread pool, KHONG phai Tomcat thread
String reqId = reqCtx.getRequestId(); // THROW IllegalStateException
// "No thread-bound request found"
return CompletableFuture.completedFuture(null);
}
}
Proxy gọi RequestScope.get(), rồi method này gọi RequestContextHolder.currentRequestAttributes() — lúc này thread trong pool không có ThreadLocal được set sẵn, nên Spring throw exception.
Ba cách fix:
Cách 1 — Pass data qua method param (khuyến nghị):
@Async
public CompletableFuture<Void> processAsync(OrderRequest req, String requestId) {
// requestId truyen vao tu caller o Tomcat thread
log.info("[{}] processing async", requestId);
return CompletableFuture.completedFuture(null);
}
// Caller:
orderService.processAsync(req, reqCtx.getRequestId()); // lay truoc khi chuyen thread
Đơn giản nhất, rõ ràng nhất về dependency, không hidden state.
Cách 2 — TaskDecorator propagate context:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setTaskDecorator(runnable -> {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
return () -> {
try {
RequestContextHolder.setRequestAttributes(attrs);
runnable.run();
} finally {
RequestContextHolder.resetRequestAttributes();
}
};
});
executor.initialize();
return executor;
}
}
Setup 1 lần, tất cả @Async tự propagate request context. Phù hợp khi bạn có nhiều async method cần request context.
Cách 3 — Tránh request scope trong async hoàn toàn:
Thiết kế lại để async task không cần request context. Async task nên nhận data đủ dùng qua param, không depend vào scoped bean.
Tương tự với session scope: SessionScope cũng resolve qua RequestContextHolder (cùng ThreadLocal với request scope), chỉ khác là tra sang HttpSession thay vì HttpServletRequest. @Async đổi sang thread pool nên cũng mất context. Cùng nguyên nhân, cùng fix.
5. Session scope và Serializable bắt buộc
@SessionScope tạo 1 bean instance mỗi HTTP session — session sống qua nhiều request của cùng 1 user:
@Component
@SessionScope
public class ShoppingCart implements Serializable {
private static final long serialVersionUID = 1L;
private final List<CartItem> items = new ArrayList<>();
public void add(CartItem item) {
items.add(item);
}
public List<CartItem> getItems() {
return List.copyOf(items);
}
public void clear() {
items.clear();
}
}
Về kỹ thuật, Serializable chỉ thật sự cần khi session bị serialize — single-node giữ session in-memory thì không. Nhưng với hệ thống production, hãy coi Serializable là mặc định bắt buộc, vì hai tình huống dưới đây xuất hiện ở hầu hết hệ thống thật:
- Session replication trong cluster: khi app chạy nhiều pod (Kubernetes), user từ pod A có thể failover sang pod B. Session phải serialize để replicate. Field non-serializable →
NotSerializableExceptiontại failover. - Spring Session (Redis/JDBC): nếu dùng Spring Session externalize (
spring.session.store-type=redis), bean phải serialize để lưu ra Redis.
Nếu bean có field non-serializable (vd HttpClient, Connection), đánh dấu transient:
@SessionScope
public class UserDashboard implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private transient HttpClient httpClient; // transient -- khong serialize
}
5.1 Cơ chế SessionScope bên dưới
Session scope cũng dùng ThreadLocal, nhưng resolve sang HttpSession thay vì HttpServletRequest:
// Don gian hoa SessionScope.get():
public Object get(String name, ObjectFactory<?> objectFactory) {
HttpSession session = getSession(); // lay tu RequestContextHolder
Object bean = session.getAttribute(name);
if (bean == null) {
bean = objectFactory.getObject();
session.setAttribute(name, bean);
}
return bean;
}
Bean sống trong HttpSession.attributes — session tồn tại qua nhiều request, bean theo đó sống qua nhiều request.
Cảnh báo thread-safety: 1 user có thể mở 2 tab → 2 request song song, cùng session → 2 Tomcat thread cùng truy cập 1 instance ShoppingCart. ArrayList không thread-safe. Fix: dùng Collections.synchronizedList() hoặc đẩy state ra DB/Redis với lock.
6. Use case thực tế — khi nào dùng
| Use case | Scope phù hợp | Ghi chú |
|---|---|---|
| Request ID / correlation ID cho trace | @RequestScope | Hoặc dùng Micrometer Observation tự động |
| Cache user permission per request | @RequestScope | Tránh query DB nhiều lần trong 1 request |
| Shopping cart truyền thống | @SessionScope | Cần Serializable, thread-safe |
| Multi-step wizard form | @SessionScope | State persist qua nhiều request |
| MDC log context | Không cần scope | Dùng SLF4J MDC + OncePerRequestFilter |
| Current user info | Không cần scope | Dùng SecurityContextHolder (Spring Security) |
Modern REST API (stateless + JWT): session scope ít được dùng vì state thường client-side hoặc trong DB. Request scope vẫn hữu dụng cho correlation ID và per-request cache.
Cơ chế bên dưới — tóm tắt data flow
Sơ đồ hai thread ở section 2 gói trọn cơ chế: cùng một proxy object, cùng một singleton OrderService, nhưng mỗi thread nhận instance bean khác nhau vì ThreadLocal là per-thread. Đây là toàn bộ bí mật của request scope — không có bảng tra request nào cả.
7. Bài toán ví dụ — traceId xuyên các tầng và qua @Async
Đề bài: mọi log trong một request (controller → service → repository) cần gắn cùng một traceId để gộp khi debug, nhưng bạn không muốn truyền traceId qua param của bảy tầng. Ngoài ra, một endpoint gửi email chạy @Async cũng cần traceId đó. Thiết kế.
Phần 1 — traceId xuyên các tầng: khai báo một bean request-scoped giữ traceId, inject vào mọi tầng. Mỗi request có một instance riêng nhờ ThreadLocal, nên mọi tầng đọc cùng một traceId.
@Component
@RequestScope // scoped proxy CGLIB -> inject vao singleton duoc
public class TraceContext {
private final String traceId = UUID.randomUUID().toString();
public String getTraceId() { return traceId; }
}
@Service
public class OrderService {
private final TraceContext trace;
public OrderService(TraceContext trace) { this.trace = trace; }
public void place(OrderRequest req) {
log.info("[{}] placing order", trace.getTraceId()); // cung traceId xuyen request
}
}
Phần 2 — cái bẫy @Async: nếu method @Async gọi trace.getTraceId(), nó chạy trên thread pool khác, ThreadLocal rỗng nên ném IllegalStateException. Cách an toàn là lấy traceId ở Tomcat thread trước khi chuyển sang async, rồi truyền vào như tham số.
@Async
public void sendReceiptAsync(String email, String traceId) {
log.info("[{}] sending receipt", traceId); // traceId lay tu caller, khong doc scoped bean
}
// Caller (dang o Tomcat thread, con request context):
sendReceiptAsync(user.getEmail(), trace.getTraceId());
Nếu có nhiều method async cần context, cấu hình TaskDecorator để tự copy RequestAttributes sang thread pool (xem mục 4) thay vì truyền tay từng chỗ.
Liên hệ các bài khác
- Singleton & Prototype scope: bài này đặt request/session scope trong bức tranh các scope, giải thích singleton/prototype và scope-mismatch trap — nên đọc trước để có context.
- AOP proxy — JDK vs CGLIB: giải thích cơ chế CGLIB proxy tạo ra ở bước
BeanPostProcessor.postProcessAfterInitialization— scoped proxy dùng đúng cơ chế CGLIB subclass đó. - 01-beanfactory-vs-applicationcontext:
Scopeinterface là extension point củaDefaultListableBeanFactory— hiểu container giúp hiểu tại sao scope là "lookup strategy" chứ không phải cache đơn giản như singleton.
Tóm tắt
- Request scope:
RequestScope.get()trarequest.getAttribute(beanName)quaThreadLocalcủa thread đang xử lý — không cần map toàn cục; 1 request = 1 Tomcat thread. - Session scope: tra
HttpSession.getAttribute(beanName); bean bắt buộcSerializablecho cluster replication và Spring Session (Redis/JDBC); field non-serializable đánhtransient. - Session scope không đảm bảo 1 thread/instance: user 2 tab → 2 thread cùng session → race condition trên instance chung; dùng thread-safe collection hoặc externalize state ra Redis.
- Modern REST API thường stateless (JWT): session scope ít dùng; request scope hữu dụng cho correlation ID và per-request cache (tránh query DB nhiều lần trong cùng request).
Tự kiểm tra
- Q1Tại sao request scope hoạt động được khi 100 request chạy đồng thời mà không có map toàn cục nào track "request A dùng bean X, request B dùng bean Y"? Giải thích theo cơ chế
ThreadLocal. - Q2Bean
OrderServicelà singleton. Nó injectRequestContextlà@RequestScope. Nếu KHÔNG có scoped proxy, điều gì xảy ra lúc startup? Scoped proxy giải quyết thế nào? - Q3Tại sao
@Asynclàm vỡ request scope? Nêu 2 trong 3 cách fix và khi nào dùng cách nào. - Q4Session-scoped bean
ShoppingCartdùngArrayListcho fielditems. User mở 2 tab đồng thời thêm sản phẩm. Có race condition không? Vì sao? Đề xuất fix. - Q5Tại sao session-scoped bean bắt buộc implement
Serializable? Điều gì xảy ra nếu field non-serializable không đánhtransient?
Bài tiếp theo: Tổng kết module
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
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