Request & Session Scope — bean per-request, per-session và scoped proxy
Request scope và session scope là hai web scope của Spring: 1 bean instance mỗi HTTP request, 1 bean instance mỗi HTTP session. Bài này giải thích cơ chế ThreadLocal bên dưới request scope, tại sao singleton inject request-scoped bean phải qua scoped proxy, pitfall @Async mất ThreadLocal, và session bean bắt buộc Serializable.
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 phải magic — 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 Spring magic
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:
sequenceDiagram
participant T as Tomcat thread
participant DS as DispatcherServlet
participant TL as ThreadLocal
participant RS as RequestScope
participant Bean as RequestContext bean
T->>DS: handle(request, response)
DS->>TL: setRequestAttributes(request)
DS->>T: dispatch to controller
T->>RS: getBean("requestContext")
RS->>TL: currentRequestAttributes()
TL-->>RS: HttpServletRequest
RS->>Bean: getAttribute / create if absent
Bean-->>T: instance dung cho request nay
T->>DS: return response
DS->>TL: resetRequestAttributes()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 phải có 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:
flowchart TB
subgraph Startup["Luc startup (no request)"]
direction TB
AC["ApplicationContext"]
OS["OrderService (singleton)"]
Proxy["RequestContextProxy<br/>(CGLIB subclass cua RequestContext)<br/>-- tao duoc, khong can request"]
AC -->|"inject"| OS
AC -->|"tao proxy"| Proxy
OS -->|"giu reference"| Proxy
end
subgraph Runtime["Moi HTTP request"]
direction TB
Call["reqCtx.getRequestId()"]
ProxyR["Proxy.getRequestId()"]
Resolve["RequestScope.get()<br/>-- lookup ThreadLocal"]
RealBean["RealRequestContext<br/>instance cua request nay"]
Call --> ProxyR
ProxyR --> Resolve
Resolve --> RealBean
RealBean -->|"return requestId"| ProxyR
endKhi @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 dùng ThreadLocal (SessionAttributesHolder). @Async không propagate session 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();
}
}
Serializable là bắt buộc, không phải tùy chọn. Lý do kép:
- 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
flowchart TB
subgraph ThreadA["Tomcat Thread A (request R1)"]
TLA["ThreadLocal: R1 context"]
BA["RequestContext bean: id=R1"]
end
subgraph ThreadB["Tomcat Thread B (request R2)"]
TLB["ThreadLocal: R2 context"]
BB["RequestContext bean: id=R2"]
end
subgraph OS["OrderService (singleton)"]
Proxy["RequestContextProxy"]
end
ThreadA -- "reqCtx.getRequestId()" --> Proxy
Proxy -- "RequestScope.get()" --> TLA
TLA --> BA
ThreadB -- "reqCtx.getRequestId()" --> Proxy
Proxy -- "RequestScope.get()" --> TLB
TLB --> BBCùng proxy object, cùng 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.
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: 1 bean instance / HTTP request. Cơ chế:
RequestContextHoldergiữHttpServletRequestquaThreadLocal;RequestScope.get()trarequest.getAttribute(beanName). - Session scope: 1 bean instance / HTTP session. Cơ chế: tra
HttpSession.getAttribute(beanName). Bean bắt buộcSerializable. - Scoped proxy (CGLIB
TARGET_CLASS): inject lúc startup được (proxy tạo mà không cần request); resolve đúng instance lúc runtime per method call. @Asynckhông propagate ThreadLocal: request/session context mất. Fix: pass data qua param hoặcTaskDecorator.- Session scope không đảm bảo 1 thread/instance: user 2 tab → race condition. Dùng thread-safe collection.
- Modern app: session scope ít dùng; request scope hữu dụng cho correlation ID và per-request cache.
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.▸
ThreadLocal.Tomcat dùng mô hình thread-per-request: mỗi request được xử lý trên 1 thread riêng, từ đầu đến cuối. ThreadLocal là biến Java lưu bản sao riêng cho mỗi thread — thread A và thread B có bản sao độc lập.
Khi Tomcat nhận request, DispatcherServlet gọi RequestContextHolder.setRequestAttributes(request) để đặt HttpServletRequest của request đó vào ThreadLocal của thread đang xử lý. Mọi bean @RequestScope khi cần lookup sẽ gọi RequestContextHolder.currentRequestAttributes() — nhận đúng request của thread mình.
Không cần map toàn cục vì thread là đơn vị cô lập tự nhiên. 100 thread đồng thời = 100 ThreadLocal độc lập, mỗi cái trỏ đến request của riêng nó.
Q2Bean OrderService là singleton. Nó inject RequestContext là @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?▸
OrderService là singleton. Nó inject RequestContext là @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?Không có proxy: lúc startup, Spring cố inject RequestContext vào OrderService. Để inject, Spring phải gọi RequestScope.get(), và method này gọi tiếp RequestContextHolder.currentRequestAttributes(). Lúc startup chưa có HTTP request nào nên ThreadLocal rỗng, Spring throw IllegalStateException: No thread-bound request found — app không khởi động được.
Có scoped proxy: Spring inject không phải bean thật mà là 1 proxy object (CGLIB subclass của RequestContext). Proxy được tạo lúc startup — nó không gọi RequestScope.get() khi tạo, nên không cần request context.
Mỗi khi method được gọi trên proxy (reqCtx.getRequestId()), proxy lúc đó mới gọi RequestScope.get() để lookup ThreadLocal, lấy đúng instance của request hiện tại rồi delegate. Proxy tách thời điểm inject (startup) khỏi thời điểm resolve (per-request).
Q3Tại sao @Async làm vỡ request scope? Nêu 2 cách fix và khi nào dùng cách nào.▸
@Async làm vỡ request scope? Nêu 2 cách fix và khi nào dùng cách nào.@Async chạy method trên thread pool riêng, không phải Tomcat thread. ThreadLocal là per-thread — Tomcat thread có request context, nhưng thread pool thread không được copy ThreadLocal từ caller. Khi method async gọi request-scoped bean, proxy gọi RequestContextHolder.currentRequestAttributes() nhưng ThreadLocal ở thread pool rỗng, nên Spring throw IllegalStateException.
Cách 1 — Pass data qua param: extract data cần thiết từ request-scoped bean trước khi gọi async, truyền qua method param. Đơn giản, explicit, không có hidden state. Dùng khi data cần nhỏ gọn (vd chỉ requestId, userId).
Cách 2 — TaskDecorator: cấu hình executor với TaskDecorator lấy RequestAttributes từ caller thread và set vào thread pool thread trước khi chạy runnable. Dùng khi có nhiều @Async method cần request context, không muốn sửa từng method một. Lưu ý: setup 1 lần trong AsyncConfigurer.
Q4Session-scoped bean ShoppingCart dùng ArrayList cho field items. User mở 2 tab đồng thời thêm sản phẩm. Có race condition không? Vì sao? Đề xuất fix.▸
ShoppingCart dùng ArrayList cho field items. User mở 2 tab đồng thời thêm sản phẩm. Có race condition không? Vì sao? Đề xuất fix.Có race condition. 1 session = 1 instance ShoppingCart. User mở 2 tab → 2 HTTP request song song trên 2 Tomcat thread, nhưng cùng session → cùng instance ShoppingCart. 2 thread đồng thời gọi cart.add(item) trên cùng ArrayList.
ArrayList.add() không thread-safe: nó kiểm tra capacity, mở rộng internal array nếu cần, rồi ghi element. 2 thread đồng thời có thể cùng kiểm tra capacity (đủ cho 1 element), cùng ghi vào cùng slot → mất item hoặc ArrayIndexOutOfBoundsException khi array resize race.
Fix 1 — Thread-safe collection:
private final List<CartItem> items = Collections.synchronizedList(new ArrayList<>());
Hoặc CopyOnWriteArrayList nếu read nhiều hơn write. synchronizedList đủ cho cart thông thường.
Fix 2 — Externalize state: persist cart vào DB/Redis, mỗi request load và save với transaction. Scalable hơn cho multi-pod deployment, không lo session scope race condition.
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 đánh transient?▸
Serializable? Điều gì xảy ra nếu field non-serializable không đánh transient?Lý do bắt buộc Serializable:
1. Session replication trong cluster: khi app chạy nhiều pod, Tomcat hoặc session clustering (vd Hazelcast, Redis-based) cần serialize session để replicate sang pod khác. Failover — user từ pod A sang pod B — session được deserialize. Bean không Serializable → NotSerializableException tại thời điểm replicate.
2. Spring Session externalize: nếu dùng spring.session.store-type=redis hoặc jdbc, Spring Session serialize toàn bộ session attributes (bao gồm scoped bean) để lưu ra external store. Cần Serializable.
Field non-serializable không có transient: khi Java serialize object, nó duyệt mọi field. Gặp field kiểu không implement Serializable (vd HttpClient, Connection, ExecutorService) mà không có transient, Java throw NotSerializableException tại runtime (failover hoặc session flush). Fix: đánh dấu field đó transient và tái tạo nó sau khi deserialize nếu cần (override readObject hoặc @PostConstruct không apply sau deserialize — phải dùng readResolve hoặc lazy init).
Bài tiếp theo: Tổng kết module
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