Tomcat: connector, thread và chuyện gì xảy ra khi hết thread

5 câu hỏi~25 phútTừ khóa: servlet container, embedded Tomcat, NIO connector, acceptor, poller, worker, threads.max, accept-count, max-connections, keep-alive, virtual threads

Tiếp đi. Bài trước biết Tomcat là chương trình Java tự mở port 8080 rồi. Giờ nhìn vào bên trong Tomcat nhé.

Ở bài 9.8 ta thấy Java hoàn toàn tự mở port được (ServerSocket, Netty...). Tomcat chỉ là một chương trình Java đã làm sẵn việc đó, cộng thêm rất nhiều thứ khác. Bài 9.8 ta đã đặt Nginx trước order-service. Bài này nhìn vào bên trong cổng :8080: Tomcat nhận connection thế nào và dùng thread ra sao.

1. Bài toán: order-service chậm hẳn lúc cao điểm

Client ──HTTPS──► Nginx :443 (api.shop.example)
                     │ proxy_pass http://127.0.0.1:8080
                     ▼
              order-service (Spring Boot, embedded Tomcat :8080)
                     │ HTTP                │ JDBC
                     ▼                     ▼
              payment-service          Postgres (VM khác)

Một buổi sale, payment-service phản hồi chậm 10 giây. Vài phút sau, mọi API của order-service đều treo, kể cả GET /health không gọi payment. CPU VM chỉ 15%. Vì sao?

2. Vấn đề: thread bị giữ, không phải CPU hết

3. Giải pháp: hiểu Tomcat để cấu hình đúng

Tomcat = web server + Servlet container. Nó mở port, đọc byte HTTP, parse thành HttpServletRequest, gọi Servlet (với Spring là DispatcherServlet), rồi ghi response. Spring Boot nhúng sẵn Tomcat (spring-boot-starter-web), nên java -jar order-service.jar là đã có Tomcat bên trong.

Phần mở port gọi là connector. Spring Boot 3 dùng NIO connector (Http11NioProtocol) với 3 loại thread:

ThreadTên trong thread dumpViệc
Acceptorhttp-nio-8080-AcceptorGọi accept() nhận TCP connection mới (sau handshake)
Pollerhttp-nio-8080-PollerDùng Selector theo dõi nhiều socket; socket nào có data thì giao cho worker
Workerhttp-nio-8080-exec-NParse HTTP, chạy Filter → Controller → Service → Repository, ghi response

Ba con số quan trọng:

4. Code từng bước

Bước 1: dependency (đã có sẵn)

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Bước 2: cấu hình application.yml

server:
  port: 8080
  address: 127.0.0.1          # chỉ Nginx trên cùng VM gọi được
  forward-headers-strategy: native
  tomcat:
    threads:
      max: 200
      min-spare: 10
    max-connections: 8192
    accept-count: 100
    connection-timeout: 5s     # chờ request line đầu tiên sau khi connect
    keep-alive-timeout: 20s    # connection keep-alive rảnh bao lâu thì đóng
    max-keep-alive-requests: 100
management:
  endpoints.web.exposure.include: health,metrics

Bước 3: Java: xem request chạy trên thread nào

@RestController
@RequestMapping("/orders")
class OrderController {
    private static final Logger log = LoggerFactory.getLogger(OrderController.class);
    private final PaymentClient paymentClient;

    OrderController(PaymentClient paymentClient) { this.paymentClient = paymentClient; }

    @PostMapping
    OrderResponse create(@RequestBody CreateOrder req) {
        log.info("thread={}", Thread.currentThread().getName()); // http-nio-8080-exec-7
        PaymentResult p = paymentClient.charge(req);             // thread đứng chờ ở đây
        return new OrderResponse(req.orderId(), p.status());
    }
}

Bước 4: test

# 300 request song song, payment đang chậm
seq 300 | xargs -P300 -I{} curl -s -o /dev/null -w "%{http_code} %{time_total}\n" \
  -X POST http://127.0.0.1:8080/orders -H 'Content-Type: application/json' -d '{"orderId":"o-{}"}'

# xem số worker đang bận
curl -s http://127.0.0.1:8080/actuator/metrics/tomcat.threads.busy
# thread dump: thấy 200 thread exec-N cùng đứng ở SocketInputStream.read
jcmd $(pgrep -f order-service) Thread.print | grep -c 'http-nio-8080-exec'

5. Cách hoạt động

        TCP SYN
Client ─────────► OS kernel (backlog / accept queue, size ≈ accept-count)
                     │ accept()
                     ▼
               [Acceptor thread] ── connections < max-connections ? ──► đăng ký socket
                     │
                     ▼
               [Poller thread]  Selector theo dõi 8192 socket
                     │ socket có data
                     ▼
               [Worker pool: exec-1 .. exec-200]
                     │ Filter → DispatcherServlet → Controller → Service
                     ▼
               ghi response, trả socket về Poller (keep-alive) hoặc đóng

Khi 200 worker đều bận:
  request mới đã đọc → nằm trong hàng đợi của executor, chờ worker rảnh
  connection vượt max-connections → nằm ở accept queue của OS (accept-count)
  accept queue đầy → client bị connection refused / connect timeout
  ngoài ra: Nginx chờ quá proxy_read_timeout → trả 504 cho client

Keep-alive: nhờ NIO, một connection rảnh (đang chờ request kế tiếp) không chiếm worker, chỉ nằm trong Poller. Vì vậy 8192 connection nhưng chỉ cần 200 thread. Thread chỉ bị chiếm khi đang xử lý request.

6. So sánh: Nginx vs Tomcat

Nginx (bài 9.8)Tomcat
Vai tròReverse proxy, TLS, static file, rate limitChạy ứng dụng Java (Servlet container)
Mô hìnhVài worker process, event loopAcceptor + Poller (NIO) + worker thread pool
Chạy code nghiệp vụKhôngCó: Controller, Service, JDBC
Port trong ví dụ:443 public:8080 chỉ 127.0.0.1

Xem lại bài 9.8 Nginx để thấy phần cấu hình proxy_pass.

7. Khi nào tăng thread, khi nào không

Bài 3.3 có nói Tomcat threads khác application worker threads. Nhìn vào thread dump thì phân biệt chúng thế nào?

Nhìn tên thread là biết: đó là hai pool khác nhau, cấu hình riêng.

http-nio-8080-exec-N      ← Tomcat pool (server.tomcat.threads.max)
paymentExecutor-N         ← pool mình tạo (ThreadPoolTaskExecutor / ExecutorService)
ForkJoinPool.commonPool   ← mặc định của CompletableFuture.supplyAsync()

Tomcat thread nhận request và gọi Controller, Service. Nếu trong Service bạn executor.submit(...) thì task chạy trên pool của bạn. Nhưng nếu Tomcat thread ngay sau đó gọi future.get() thì nó vẫn đứng chờ, chẳng được giải phóng. Muốn trả Tomcat thread về pool, Controller phải trả về CompletableFuture/DeferredResult (Servlet async).

Một REST API backend, Tomcat mặc định có 200 thread. Vậy có phải chỉ xử lý được tối đa 200 request cùng lúc?

Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: 200 là số request đang được xử lý cùng lúc, không phải số client tối đa. Request thứ 201 không bị từ chối ngay. Nó chờ (trong hàng đợi executor hoặc accept queue). Chỉ khi chờ quá lâu thì phía trước timeout (Nginx 504), hoặc khi mọi hàng đợi đầy thì client nhận connection refused.

Và 200 thread xử lý được rất nhiều request/giây nếu mỗi request nhanh: request 50ms thì 200 thread ≈ 4000 req/s. Hết thread chỉ xảy ra khi request chậm, thường do chờ downstream.

Vậy chỉ cần cho REST API trả về CompletableFuture là Tomcat thành async, không cần cấu hình gì thêm?

Đúng một nửa. Spring MVC tự bật Servlet async khi Controller trả CompletableFuture, không cần config thêm. Tomcat thread được trả về pool ngay. Nhưng:

@PostMapping("/async")
CompletableFuture<OrderResponse> createAsync(@RequestBody CreateOrder req) {
    return CompletableFuture.supplyAsync(() -> orderService.create(req), paymentExecutor);
}

Phỏng vấn hỏi về Tomcat thread thì trả lời sao?

Q1. Tomcat xử lý một request như thế nào?

🇻🇳 Tiếng Việt:

Spring Boot nhúng Tomcat với NIO connector. Acceptor thread nhận TCP connection, Poller dùng Selector theo dõi các socket, khi có data thì giao cho một worker thread trong pool. Worker thread parse HTTP, chạy filter, DispatcherServlet, controller cho đến khi ghi xong response. Với Spring MVC, một request chiếm một worker thread suốt thời gian xử lý.

🇬🇧 English:

Spring Boot embeds Tomcat with the NIO connector. The acceptor thread accepts TCP connections, the poller watches sockets with a Selector, and when data arrives it hands the socket to a worker thread from the pool. The worker parses HTTP and runs the filters, DispatcherServlet and controller until the response is written. In Spring MVC one request holds one worker thread for its whole duration.

Q2. Khi cả 200 thread đều bận thì sao?

🇻🇳 Tiếng Việt:

Request mới không bị từ chối ngay mà phải chờ. Connection vẫn được nhận cho tới max-connections, sau đó nằm ở accept queue cỡ accept-count. Hàng đợi đầy thì client bị connection refused hoặc connect timeout; còn proxy phía trước như Nginx có thể trả 504. Nguyên nhân thường là downstream chậm, nên em xử lý bằng timeout, circuit breaker và bulkhead thay vì chỉ tăng số thread.

🇬🇧 English:

New requests are not rejected immediately, they wait. Tomcat keeps accepting connections up to max-connections, then they wait in the OS accept queue sized by accept-count. When that is full, clients get connection refused or connect timeouts, and a proxy like Nginx may return 504. The root cause is usually a slow downstream, so I fix it with timeouts, circuit breakers and bulkheads rather than just raising the thread count.

Q3. Nginx và Tomcat khác nhau thế nào, sao cần cả hai?

🇻🇳 Tiếng Việt:

Tomcat là servlet container chạy code Java. Nginx là reverse proxy event-driven, giỏi TLS termination, phục vụ static file, giữ nhiều connection chậm, rate limit và load balancing. Nginx đứng ở port 443 public, Tomcat chỉ listen nội bộ 8080.

🇬🇧 English:

Tomcat is a servlet container that runs our Java code. Nginx is an event-driven reverse proxy that is good at TLS termination, static files, holding many slow connections, rate limiting and load balancing. Nginx listens on public port 443 and Tomcat only listens internally on 8080.

Q4. Virtual threads giải quyết được gì?

🇻🇳 Tiếng Việt:

Với Java 21 và spring.threads.virtual.enabled=true, Tomcat chạy mỗi request trên một virtual thread. Khi chờ I/O, virtual thread nhả carrier thread nên giới hạn 200 thread không còn là nút cổ chai. Nhưng tài nguyên downstream như connection pool DB vẫn giới hạn, nên vẫn cần timeout và kiểm soát concurrency.

🇬🇧 English:

With Java 21 and spring.threads.virtual.enabled=true, Tomcat runs each request on a virtual thread. While waiting on I/O the virtual thread releases its carrier thread, so the 200-thread limit is no longer the bottleneck. Downstream resources like the DB connection pool are still limited, so we still need timeouts and concurrency limits.

Cách nhớ ngắn gọn


Tự kiểm tra

Ba loại thread của NIO connector là gì?

Acceptor (accept connection), Poller (Selector theo dõi socket), Worker http-nio-8080-exec-N (chạy request).

threads.max, max-connections, accept-count khác nhau thế nào?

Số request xử lý đồng thời; số connection Tomcat giữ; số connection chờ ở accept queue của OS khi đã đủ max-connections.

Vì sao /health cũng treo khi payment chậm?

Mọi worker đều bị request gọi payment giữ; /health phải chờ worker rảnh.

Controller gọi executor.submit(...).get() có giải phóng Tomcat thread không?

Không. Tomcat thread vẫn block ở get(). Phải trả về CompletableFuture/DeferredResult.

Bật virtual threads rồi có cần timeout cho payment nữa không?

Có. Virtual threads chỉ bỏ giới hạn thread, request vẫn chờ lâu và downstream vẫn bị dồn tải.