10.000 request đồng thời, thread pool, connection pool10,000 concurrent requests, thread and connection pools
Gateway giới hạn số request đang xử lý và từ chối phần dư. Mỗi instance API chạy virtual thread (Java) hoặc goroutine (Go), nhưng mọi tài nguyên khan hiếm đều có giới hạn riêng: pool kết nối DB và một bulkhead cho dịch vụ pricing. Bật “Pricing chậm” hoặc “Đột biến lưu lượng” để xem hệ thống xuống cấp có kiểm soát.The gateway caps in-flight requests and rejects the rest. Each API instance runs on virtual threads (Java) or goroutines (Go), but every scarce resource has its own limit: the DB connection pool and a bulkhead for the pricing service. Turn on “Pricing slows down” or “Traffic spike” to see controlled degradation.
15.000 request mỗi giây vào trang sản phẩm, thời gian xử lý trung bình 80 ms. Theo định luật Little: 5.000 × 0,08 = 400 request đang xử lý trên toàn cụm.5,000 requests per second hit the product page at 80 ms each. By Little's law: 5,000 × 0.08 = 400 requests in flight across the cluster.
Định luật Little: số việc đang xử lý = thông lượng × thời gian. 5.000 rps, mỗi request giữ kết nối DB 8 ms thì cần 40 kết nối cho cả cụm, 10 cho mỗi instance trong 4 instance. Đặt 20 để có dư. Đoán mò bằng số tròn thường sai cả hai chiều.Little's law: work in flight = throughput × time. At 5,000 rps with each request holding a DB connection for 8 ms you need 40 connections cluster-wide, 10 per instance across 4 instances. Set 20 for headroom. Round guesses are usually wrong in both directions.
PostgreSQL chạy mỗi kết nối bằng một process. Vượt quá số lõi CPU và đĩa, các truy vấn tranh nhau CPU, khoá và bộ nhớ nên thông lượng giảm. Giữ pool nhỏ (HikariCP gợi ý khoảng số lõi × 2), để request chờ ở pool với connectionTimeout ngắn, cân nhắc PgBouncer khi có nhiều instance.PostgreSQL runs a process per connection. Beyond the CPU cores and disks, queries fight over CPU, locks and memory, and throughput drops. Keep the pool small (HikariCP suggests roughly cores × 2), let requests wait at the pool with a short connectionTimeout, and consider PgBouncer with many instances.
Nếu mọi lời gọi dùng chung một pool thread, pricing chậm sẽ giữ hết thread và cả /orders cũng chết. Bulkhead: mỗi phụ thuộc có semaphore hoặc pool riêng, chờ permit có hạn, hết permit thì fallback. Thêm circuit breaker để ngừng gọi khi lỗi kéo dài.When every call shares one thread pool, a slow pricing service holds all threads and /orders dies too. Bulkhead: give each dependency its own semaphore or pool, wait for a permit with a bound, fall back when none is free. Add a circuit breaker to stop calling during a sustained failure.
Một chặng không có timeout giữ thread, kết nối và permit vô thời hạn. Đặt timeout ở mọi chặng: connect, read, chờ pool, và một deadline cho cả request. Truyền deadline xuống dưới (context trong Go) để công việc bị huỷ khi client đã bỏ đi.A hop without a timeout holds threads, connections and permits indefinitely. Put a timeout on every hop: connect, read, pool acquire, and a deadline for the whole request. Propagate the deadline (context in Go) so work is cancelled once the client has gone.
Hàng chờ không giới hạn chỉ dời lỗi sang sau: request chờ lâu rồi timeout, trong khi server vẫn tốn công xử lý chúng. Giới hạn số request đang xử lý và độ dài hàng chờ; phần dư trả 503 (quá tải chung) hoặc 429 (vượt hạn mức của từng client) kèm Retry-After.An unbounded queue only postpones failure: requests wait, time out, and the server still spends work on them. Cap in-flight requests and queue length; answer the excess with 503 (overall overload) or 429 (a client over its quota) plus Retry-After.
Tồn kho 40 ms rồi giá 35 ms là 75 ms. Gọi song song còn khoảng 40 ms và giữ thread ngắn hơn, nên cùng số thread phục vụ được nhiều request hơn. Mỗi nhánh vẫn phải có timeout và bị huỷ khi nhánh kia lỗi.Stock at 40 ms then price at 35 ms is 75 ms. In parallel it is about 40 ms and each request holds its thread for less time, so the same threads serve more requests. Every branch still needs a timeout and must be cancelled when the other fails.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
server (Tomcat / net/http) | spring.threads.virtual.enabled, server.tomcat.max-connections, ReadTimeout, WriteTimeout | Virtual thread hoặc goroutine cho mỗi request; giới hạn thật nằm ở các pool phía dướiA virtual thread or goroutine per request; the real limits live in the pools below |
hikari pool | maximumPoolSize=20, minimumIdle=20, connectionTimeout=250ms, maxLifetime=30m | Pool cố định kích thước; chờ quá 250 ms thì báo lỗi thay vì treoFixed-size pool; waiting longer than 250 ms fails instead of hanging |
pgxpool | pool_max_conns=20, pool_min_conns=20, pool_max_conn_lifetime=30m | Tương đương Hikari bên Go; thời gian chờ lấy kết nối do context của request quyết địnhThe Go counterpart of Hikari; the acquire wait is bounded by the request context |
bulkhead pricing | maxConcurrentCalls=40, maxWaitDuration=50ms, readTimeout=300ms | 40 ≈ 1.000 rps × 35 ms, cộng phần dư cho đuôi phân phối40 ≈ 1,000 rps × 35 ms, plus headroom for the tail |
admission (gateway) | maxInFlight=2000, maxQueue=500, queueTimeout=100ms, retryAfter=2s | Vượt giới hạn thì trả 503 trong dưới 1 msBeyond the limits, answer 503 in under 1 ms |
Endpoint báo giá gọi song song DB và pricing, đặt deadline cho cả request và chặn pricing bằng semaphore. Bản Go thêm bước từ chối sớm khi quá tải.The quote endpoint calls the DB and pricing in parallel, sets a deadline for the whole request and guards pricing with a semaphore. The Go version also sheds load early.
// application.yml: spring.threads.virtual.enabled=true
// spring.datasource.hikari.maximum-pool-size=20
@Service
@RequiredArgsConstructor
public class QuoteService {
private final StockRepository stock;
private final PricingClient pricing; // read timeout 300 ms
private final PriceCache cache;
private final Semaphore pricingBulkhead = new Semaphore(40);
private final ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
public Quote quote(long productId) throws Exception {
var stockF = CompletableFuture.supplyAsync(() -> stock.available(productId), vt);
var priceF = CompletableFuture.supplyAsync(() -> price(productId), vt);
return stockF.thenCombine(priceF, (s, p) -> new Quote(productId, s, p))
.orTimeout(500, TimeUnit.MILLISECONDS)
.get(); // TimeoutException → 504
}
private Money price(long productId) {
if (!tryEnter()) {
return cache.lastKnown(productId); // bulkhead full: degrade
}
try {
Money m = pricing.get(productId);
cache.put(productId, m);
return m;
} finally {
pricingBulkhead.release();
}
}
private boolean tryEnter() {
try {
return pricingBulkhead.tryAcquire(50, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
}type Server struct {
db *pgxpool.Pool // pool_max_conns=20
pricing *PricingClient
cache *PriceCache
inflight chan struct{} // cap 500 per instance: admission limit
bulkhead chan struct{} // cap 40: concurrent pricing calls
}
func (s *Server) Quote(w http.ResponseWriter, r *http.Request) {
select {
case s.inflight <- struct{}{}:
defer func() { <-s.inflight }()
default:
w.Header().Set("Retry-After", "2")
http.Error(w, "overloaded", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
id := r.PathValue("id")
var stock int
var price Money
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
return s.db.QueryRow(gctx,
"SELECT available FROM stock WHERE product_id = $1", id).Scan(&stock)
})
g.Go(func() (err error) {
price, err = s.price(gctx, id)
return err
})
if err := g.Wait(); err != nil {
http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
return
}
writeJSON(w, Quote{ID: id, Stock: stock, Price: price})
}
func (s *Server) price(ctx context.Context, id string) (Money, error) {
select {
case s.bulkhead <- struct{}{}:
defer func() { <-s.bulkhead }()
case <-time.After(50 * time.Millisecond):
return s.cache.LastKnown(id), nil // bulkhead full: degrade
case <-ctx.Done():
return Money{}, ctx.Err()
}
p, err := s.pricing.Get(ctx, id)
if err == nil {
s.cache.Put(id, p)
}
return p, err
}| Pool thread nền tảng (Tomcat 200)Platform thread pool (Tomcat 200) | Virtual thread / goroutineVirtual threads / goroutines | |
|---|---|---|
| Chi phí mỗi requestCost per request | Thread hệ điều hành, stack dự trữ khoảng 1 MBAn OS thread with about 1 MB of reserved stack | Vài KB, stack tự giãn; hàng chục nghìn là bình thườngA few KB with a growable stack; tens of thousands are normal |
| Giới hạn đồng thờiConcurrency limit | Kích thước pool là giới hạn ngầm địnhThe pool size is an implicit limit | Không có giới hạn ngầm; phải tự đặt semaphore, pool, admissionNo implicit limit; you must add semaphores, pools and admission control |
| Rủi ro thường gặpTypical risk | Hết thread khi một phụ thuộc chậmThread starvation when a dependency slows down | Dồn tải xuống DB; pinning khi gặp synchronized (Java 21–23)Flooding the DB; pinning inside synchronized (Java 21–23) |
| Việc nặng CPUCPU-bound work | Phù hợp, đặt pool bằng số lõiFits well; size the pool to the cores | Không lợi gì thêm; vẫn bị giới hạn bởi số lõiNo extra gain; still bound by the cores |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Virtual thread chỉ làm việc chờ rẻ hơn. Nút thắt thường là pool DB, CPU của DB hoặc một phụ thuộc chậm. Nhiều thread hơn còn có thể dồn thêm tải xuống DB. Xem độ bão hoà của từng pool và p99 của từng chặng để tìm nút thắt.Virtual threads only make waiting cheaper. The bottleneck is usually the DB pool, DB CPU or a slow dependency, and more threads can push even more load onto the DB. Look at each pool's saturation and each hop's p99 to find it.
Định luật Little: kết nối cần = số truy vấn mỗi giây × thời gian giữ kết nối, chia cho số instance, cộng phần dư. Kiểm tra lại với giới hạn phía DB (max_connections, số lõi) vì tổng các pool không được vượt quá nó.Little's law: connections = queries per second × time a connection is held, divided by the number of instances, plus headroom. Check it against the DB side (max_connections, cores), because the sum of all pools must stay below it.
Request chờ quá lâu sẽ timeout phía client, nhưng server vẫn tốn công xử lý nó. Hàng chờ dài làm tăng độ trễ của mọi request. Từ chối sớm giữ p99 ổn định cho phần được nhận và cho client biết khi nào thử lại.A request that waits too long times out on the client while the server still spends work on it, and a long queue raises latency for everyone. Rejecting early keeps p99 stable for admitted requests and tells the client when to retry.
Bulkhead giới hạn số lời gọi đồng thời tới một phụ thuộc để nó không chiếm hết tài nguyên. Circuit breaker theo dõi tỉ lệ lỗi và ngừng gọi hẳn trong một khoảng thời gian. Thường dùng cả hai: bulkhead chặn lan truyền, breaker cho phụ thuộc thời gian hồi phục.A bulkhead caps concurrent calls to one dependency so it cannot take every resource. A circuit breaker watches the error rate and stops calling for a while. They are usually combined: the bulkhead contains the damage, the breaker gives the dependency time to recover.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.