Bài hôm qua kết bằng lời khuyên "đừng dùng DriverManager trực tiếp, hãy dùng pool". Hôm nay là con số đằng sau lời khuyên đó, và câu hỏi mà tôi thấy trả lời sai nhiều nhất: đặt kích thước pool bao nhiêu?

Câu trả lời phổ biến trên mạng là "pool nhỏ nhanh hơn pool lớn". Tôi đo được cả trường hợp câu đó đúng lẫn trường hợp nó sai, và khác biệt giữa hai trường hợp mới là thứ đáng nhớ.

Đo trên PostgreSQL 16 trong container, HikariCP 5.1.

Mở một kết nối tốn bao nhiêu

  mượn+trả từ pool : 0,27 micro giây/lần
  mở kết nối mới   : 4 134 micro giây/lần

Hơn mười lăm nghìn lần.

Bốn mili giây cho một kết nối mới nghe có vẻ nhỏ, cho tới khi bạn nhớ rằng nó nằm trên mỗi request. Một endpoint xử lý hết 12 ms thì 4 ms trong đó là mở kết nối — một phần ba thời gian, tiêu vào việc không liên quan gì tới nghiệp vụ.

Bốn mili giây ấy đi đâu? Bắt tay TCP, rồi bắt tay TLS nếu có, rồi xác thực bằng mật khẩu, rồi máy chủ tạo một tiến trình mới — với PostgreSQL, mỗi kết nối là một tiến trình hệ điều hành riêng, không phải một luồng. Đó là lý do con số này lớn hơn nhiều so với cảm giác trực quan, và là lý do PostgreSQL đặc biệt nhạy với số kết nối.

Pool giải quyết bằng cách giữ sẵn các kết nối đã mở. getConnection() chỉ là lấy một cái ra khỏi hàng đợi; close() không đóng gì cả mà trả nó về pool — đây là chỗ hay gây hiểu nhầm khi đọc mã, vì close() trên một kết nối từ pool có nghĩa hoàn toàn khác với close() trên kết nối trần.

Pool cạn trông như thế nào

Pool tối đa 2 kết nối, connectionTimeout 1 giây, 4 luồng cùng xin và mỗi luồng giữ 2 giây:

  luồng 1: mượn được sau    5 ms
  luồng 2: mượn được sau   41 ms
  luồng 3: HỎNG sau 1005 ms -> SQLTransientConnectionException
  luồng 0: HỎNG sau 1005 ms -> SQLTransientConnectionException
           Connection is not available, request timed out after 1001ms
           (total=2, active=2, idle=0, waiting=0)

Đây là thông báo đáng thuộc lòng, vì nó là triệu chứng của rất nhiều sự cố sản xuất.

Đọc phần trong ngoặc: total=2 là số kết nối pool đang có, active=2 là số đang bị mượn, idle=0 là số rảnh. Pool không hề hỏng — nó đang làm đúng việc, chỉ là không còn gì để cho mượn.

Điều quan trọng: hai luồng đầu chạy hoàn toàn bình thường. Pool cạn không làm sập ứng dụng mà làm nó chậm rồi hỏng một phần, và tỷ lệ hỏng tăng dần theo tải. Trên biểu đồ giám sát, nó trông giống hệt "cơ sở dữ liệu chậm" — trong khi cơ sở dữ liệu hoàn toàn rảnh rỗi.

Nguyên nhân gần như luôn là một trong hai:

Rò rỉ kết nối — có chỗ nào đó mượn mà không trả. Một Connection nằm ngoài try-với-tài-nguyên là đủ. Bật leakDetectionThreshold (ví dụ 30 giây) để Hikari ghi cảnh báo kèm dấu vết ngăn xếp của chỗ đã mượn — nó chỉ thẳng vào dòng gây lỗi, thứ mà thông báo "connection is not available" không bao giờ làm được.

Giữ kết nối quá lâu — mượn kết nối rồi trong lúc đang giữ thì đi gọi một API bên ngoài, hoặc xử lý một tệp. Kết nối bị giam suốt thời gian đó dù chẳng dùng tới. Nguyên tắc: mượn muộn nhất, trả sớm nhất, và không bao giờ gọi mạng khi đang cầm kết nối.

Kích thước pool: chỗ lời khuyên phổ biến sai

Tôi đo hai lần với hai loại tải khác nhau. Kết quả ngược nhau.

Lần một — truy vấn chờ, không tốn CPU (pg_sleep(0.02)), 40 luồng:

  pool= 4 -> tổng  2526 ms | chờ mượn trung bình  123,4 ms
  pool= 8 -> tổng  1361 ms | chờ mượn trung bình   60,3 ms
  pool=16 -> tổng   940 ms | chờ mượn trung bình   40,6 ms
  pool=32 -> tổng   870 ms | chờ mượn trung bình   40,1 ms
  pool=64 -> tổng   857 ms | chờ mượn trung bình   38,8 ms

Pool càng lớn càng nhanh, tới khoảng 16 thì bão hoà. Ở đây "pool nhỏ nhanh hơn" sai hoàn toàn — pool 4 chậm gần ba lần pool 16.

Lần hai — truy vấn tốn CPU thật, và tôi giới hạn PostgreSQL còn 2 nhân:

  pool= 2 -> 1311 ms  (152 truy vấn/giây)
  pool= 4 -> 1291 ms  (154 truy vấn/giây)   <- tốt nhất
  pool= 8 -> 1506 ms  (132 truy vấn/giây)
  pool=16 -> 2013 ms  ( 99 truy vấn/giây)   <- chậm hơn 35%
  pool=32 -> 1989 ms  (100 truy vấn/giây)
  pool=64 -> 1879 ms  (106 truy vấn/giây)

Cùng khối lượng công việc, cùng số luồng gọi. Pool 16 cho thông lượng thấp hơn pool 4 khoảng 35%.

Lý do là hai nhân CPU chỉ chạy được hai việc một lúc. Ném vào 16 truy vấn cùng lúc không làm chúng xong nhanh hơn — chúng chỉ tranh nhau CPU, và hệ điều hành mất thêm thời gian chuyển ngữ cảnh giữa 16 tiến trình. Công thêm, kết quả trừ.

Hai phép đo trên nói cùng một điều: pool nên bằng chỗ nghẽn thật sự, không bằng số người dùng. Truy vấn chờ (đĩa chậm, mạng, khoá) thì pool lớn có ích. Truy vấn tính toán thì pool lớn hơn năng lực CPU của máy chủ CSDL chỉ làm mọi thứ chậm đi.

Tôi nói rõ chỗ này vì lời khuyên "pool nhỏ luôn nhanh hơn" được lặp lại rất nhiều, và nó chỉ đúng một nửa. Nếu bạn đọc nó rồi hạ pool xuống 4 trong khi tải của bạn là chờ I/O, bạn vừa tự làm chậm hệ thống ba lần.

Vậy đặt bao nhiêu

Điểm khởi đầu tôi dùng, dựa trên số nhân CPU của máy chủ cơ sở dữ liệu chứ không phải máy chạy ứng dụng:

pool ≈ số nhân × 2

Cộng thêm nếu truy vấn của bạn chờ đĩa nhiều. Ví dụ máy CSDL 4 nhân, truy vấn hỗn hợp: bắt đầu ở 8–10.

Ba điều cần nhớ khi áp con số này:

Đây là điểm khởi đầu để đo, không phải đáp án. Hãy đo thông lượng ở vài kích thước như tôi làm ở trên — mất mười phút và cho câu trả lời đúng cho chính tải của bạn.

Cộng dồn qua các instance. Chạy 6 bản ứng dụng, mỗi bản pool 20, là 120 kết nối tới cùng một CSDL. PostgreSQL mặc định max_connections = 100, nên bản thứ sáu sẽ không kết nối được — và đây là sự cố kinh điển khi mở rộng theo chiều ngang: mọi thứ chạy tốt cho tới lúc bạn thêm một instance.

Pool lớn giấu vấn đề chứ không chữa. Pool cạn thường xuyên nghĩa là truy vấn quá chậm hoặc có chỗ giữ kết nối quá lâu. Tăng pool làm triệu chứng biến mất một thời gian, rồi quay lại ở tải cao hơn — lúc đó khó tìm hơn nhiều.

Vài tham số HikariCP đáng chỉnh

HikariConfig cf = new HikariConfig();
cf.setJdbcUrl(url); cf.setUsername(u); cf.setPassword(p);
cf.setMaximumPoolSize(10);
cf.setConnectionTimeout(3_000);
cf.setMaxLifetime(1_800_000);
cf.setLeakDetectionThreshold(30_000);

connectionTimeout — chờ tối đa bao lâu để mượn được kết nối. Mặc định 30 giây là quá dài cho một ứng dụng web: khi pool cạn, request nằm chờ 30 giây rồi mới báo lỗi, mà người dùng đã bỏ đi từ lâu và tải dồn ứ. Vài giây thường tốt hơn — thà hỏng nhanh.

maxLifetime — tuổi thọ tối đa của một kết nối. Phải đặt ngắn hơn giới hạn của cơ sở dữ liệu và của mọi tường lửa trên đường đi. Nếu không, bạn sẽ gặp lỗi "connection reset" ngẫu nhiên và rất khó tái hiện, vì đầu kia đã lặng lẽ cắt kết nối mà pool không biết.

minimumIdle — mặc định bằng maximumPoolSize, tức là pool giữ đủ số kết nối luôn sẵn sàng. Đó là mặc định đúng: đặt nhỏ hơn nghĩa là những request đến sau một khoảng vắng sẽ phải trả 4 mili giây mở kết nối.

Đừng bật autoCommit=false ở mức pool rồi quên. Kết nối được tái sử dụng, nên trạng thái bạn để lại sẽ theo sang người mượn tiếp. Hikari khôi phục lại giá trị mặc định khi trả kết nối, nhưng chỉ về đúng cái bạn đã cấu hình.

Trong Spring Boot, Hikari là mặc định và chỉnh qua application.yml:

spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      connection-timeout: 3000
      leak-detection-threshold: 30000

Ba con số nên có trên bảng giám sát

Hikari phát sẵn các chỉ số này qua Micrometer, và tôi thấy ba cái đủ để biết pool có khoẻ không:

Số kết nối đang hoạt động so với kích thước tối đa. Chạm trần thường xuyên là dấu hiệu phải xem lại.

Thời gian chờ mượn. Bình thường phải gần bằng không — trong phép đo đầu bài là 0,27 micro giây. Thấy nó lên hàng mili giây là pool đang căng.

Số luồng đang chờ. Lớn hơn 0 kéo dài nghĩa là ứng dụng đang xếp hàng trước cơ sở dữ liệu.

Ba chỉ số này báo trước sự cố sớm hơn nhiều so với việc chờ tới lúc thấy SQLTransientConnectionException trong log.

Thử ba mươi giây

Đặt leakDetectionThreshold thành 5000 trong môi trường phát triển, rồi chạy ứng dụng như bình thường.

Nếu có chỗ nào mượn kết nối quá năm giây mà chưa trả, Hikari sẽ ghi một cảnh báo kèm dấu vết ngăn xếp của chỗ đã mượn. Đó là cách rẻ nhất để tìm rò rỉ kết nối, và nó chỉ thẳng vào dòng gây lỗi thay vì bắt bạn đoán từ một thông báo hết giờ chờ.

Ngày mai bắt đầu một chặng mới của sê-ri: Thread và vòng đời của nó — tạo, chạy, chờ, kết thúc, và vì sao gọi run() không hề tạo ra luồng nào.