Tomcat: connector, thread và chuyện gì xảy ra khi hết thread
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
- Mỗi request (Spring MVC) chiếm một worker thread của Tomcat từ đầu đến cuối.
- Thread gọi payment rồi đứng chờ 10 giây. Không tốn CPU, nhưng thread đó bận.
- Mặc định chỉ có 200 worker. 200 request chờ payment là hết thread. Request
/healthphải xếp hàng.
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:
| Thread | Tên trong thread dump | Việc |
|---|---|---|
| Acceptor | http-nio-8080-Acceptor | Gọi accept() nhận TCP connection mới (sau handshake) |
| Poller | http-nio-8080-Poller | Dùng Selector theo dõi nhiều socket; socket nào có data thì giao cho worker |
| Worker | http-nio-8080-exec-N | Parse HTTP, chạy Filter → Controller → Service → Repository, ghi response |
Ba con số quan trọng:
server.tomcat.threads.max(mặc định 200): số worker tối đa = số request xử lý đồng thời.server.tomcat.max-connections(mặc định 8192): số TCP connection Tomcat giữ cùng lúc (kể cả connection keep-alive đang rảnh).server.tomcat.accept-count(mặc định 100): khi đã đủ max-connections, connection mới nằm chờ trong backlog của OS tối đa bấy nhiêu.
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 limit | Chạy ứng dụng Java (Servlet container) |
| Mô hình | Vài worker process, event loop | Acceptor + Poller (NIO) + worker thread pool |
| Chạy code nghiệp vụ | Không | Có: 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
- Nên chỉnh: khi đo được worker bận 100% do chờ I/O và downstream (DB, payment) còn chịu được thêm tải.
- Không nên tăng
threads.maxlên 2000 khi bị chậm: mỗi thread ~1MB stack, context switch nhiều, và dồn thêm tải xuống DB vốn chỉ có 10 connection Hikari. - Cách đúng hơn: đặt timeout cho payment (bài 9.10), circuit breaker, bulkhead.
- Virtual threads (Java 21,
spring.threads.virtual.enabled=true): mỗi request chạy trên một virtual thread rẻ, lúc chờ I/O thì nhả carrier thread. Hết bài toán "200 thread", nhưng không hết bài toán downstream: DB vẫn chỉ có 10 connection, payment vẫn chậm.
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:
- Công việc vẫn chạy trên một thread nào đó. Nếu là
supplyAsynckhông truyền executor thì làcommonPool(số core − 1), rất dễ nghẽn với code blocking. Luôn truyền executor riêng. - Code blocking (JDBC, RestClient) vẫn giữ thread của pool đó. Async không phải non-blocking I/O.
- Nên đặt
spring.mvc.async.request-timeoutđể request async không treo mãi.
@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 byaccept-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
- Acceptor nhận, Poller canh, Worker làm.
threads.max= đang xử lý;max-connections= đang giữ;accept-count= đang chờ ở OS.- Keep-alive rảnh không chiếm worker (NIO).
- Hết thread thường do downstream chậm, sửa bằng timeout, không phải tăng thread.
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.