Spring Core & Boot/Request & Session Scope — bean per-request, per-session và scoped proxy
19/41
Bài 19 / 41~13 phútBean Lifecycle & ScopesMiễn phí lượt xem

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.

Tại sao ThreadLocal?

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
    end

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:

  1. Được tạo lúc startup mà không cần request (nó chỉ là shell CGLIB, chưa gọi RequestScope.get()).
  2. 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.
  3. 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.

Khi nao dung INTERFACES vs TARGET_CLASS?

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

Pitfall session + @Async

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:

  1. 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 → NotSerializableException tại failover.
  2. 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 caseScope phù hợpGhi chú
Request ID / correlation ID cho trace@RequestScopeHoặc dùng Micrometer Observation tự động
Cache user permission per request@RequestScopeTránh query DB nhiều lần trong 1 request
Shopping cart truyền thống@SessionScopeCần Serializable, thread-safe
Multi-step wizard form@SessionScopeState persist qua nhiều request
MDC log contextKhông cần scopeDùng SLF4J MDC + OncePerRequestFilter
Current user infoKhông cần scopeDù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 --> BB

Cù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: Scope interface là extension point của DefaultListableBeanFactory — 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ế: RequestContextHolder giữ HttpServletRequest qua ThreadLocal; RequestScope.get() tra request.getAttribute(beanName).
  • Session scope: 1 bean instance / HTTP session. Cơ chế: tra HttpSession.getAttribute(beanName). Bean bắt buộc Serializable.
  • 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.
  • @Async không propagate ThreadLocal: request/session context mất. Fix: pass data qua param hoặc TaskDecorator.
  • 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

Tự kiểm tra
Q1
Tạ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.

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

Q2
Bean OrderService là singleton. Nó inject RequestContext@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).

Q3
Tạ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 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.

Q4
Session-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.

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.

Q5
Tạ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?

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 SerializableNotSerializableException 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

Đặt 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