Hình dung pool kết nối như một đội xe chung của công ty. Có đúng N chiếc; bạn ký mượn một chiếc, chạy việc của mình, rồi ký trả lại. Nó là thứ không ai nghĩ tới cho tới hôm hệ thống đứng — hôm cả đội xe bị giữ hết. Bài này về cách nó đứng.
Cạn pool
Pool 5 kết nối, connection-timeout 2 giây. Tám luồng cùng xin, mỗi luồng giữ 3 giây:
luồng 0: OK sau 3026 ms
luồng 1: OK sau 3025 ms
luồng 2: OK sau 3026 ms
luồng 3: CannotCreateTransactionException - Could not open JPA EntityManager
luồng 4: CannotCreateTransactionException - Could not open JPA EntityManager
luồng 5: CannotCreateTransactionException - Could not open JPA EntityManager
luồng 6: OK sau 3026 ms
luồng 7: OK sau 3026 ms
Năm luồng chạy được, ba luồng nhận ngoại lệ sau 2 giây chờ.
Điểm cần thấy: chúng không xếp hàng chờ tới lượt. Chờ quá connection-timeout là bị từ chối thẳng — quầy cho mượn xe bảo "hết xe, mời về" chứ không để bạn đứng đợi mãi.
Đây là hành vi đúng — chờ vô hạn thì hàng đợi phình ra và mọi request đều chậm, thà từ chối sớm còn hơn. Nhưng nó nghĩa là: kích thước pool là trần thông lượng cứng của ứng dụng.
Bốn tham số
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 10
connection-timeout: 3000
max-lifetime: 1800000
idle-timeout: 600000
leak-detection-threshold: 20000
maximum-pool-size — mặc định 10. Nói ở mục dưới.
connection-timeout — chờ bao lâu rồi bỏ cuộc. Mặc định 30 giây, và tôi thấy đó là quá dài: với web, người dùng đã bỏ đi từ lâu, mà luồng vẫn bị giữ. 2–3 giây hợp lý hơn — hỏng nhanh còn hơn hỏng chậm.
max-lifetime — kết nối sống tối đa bao lâu rồi bị thay. Phải nhỏ hơn giới hạn phía CSDL và phía tường lửa, nếu không bạn sẽ gặp lỗi "connection reset" ngẫu nhiên. Mặc định 30 phút; MySQL mặc định wait_timeout 8 tiếng nên thường ổn, nhưng tường lửa hay proxy có thể cắt sớm hơn nhiều.
leak-detection-threshold — cảnh báo khi một kết nối bị giữ quá lâu. Không bật theo mặc định, và tôi bật ở mọi môi trường không phải sản xuất. Nó chỉ ra đúng dòng mã đang giữ kết nối.
Pool lớn hơn không nhanh hơn
Trực giác nói: thêm kết nối thì phục vụ được nhiều hơn. Sai, và tài liệu của chính HikariCP nói rõ điều này.
CSDL xử lý truy vấn bằng CPU và đĩa, cả hai đều hữu hạn. Trăm kết nối cùng hoạt động trên máy 8 nhân nghĩa là trăm tiến trình tranh nhau 8 nhân — chuyển ngữ cảnh nhiều hơn, tranh khoá nhiều hơn, và mọi truy vấn đều chậm đi. Thêm trăm chiếc xe vào cùng mấy con đường chật và chỗ đỗ có hạn thì ai cũng bò.
Công thức của PostgreSQL wiki:
số kết nối = ((số nhân × 2) + số đĩa hiệu dụng)
Máy 4 nhân, một SSD: khoảng 9–10 kết nối. Đúng bằng mặc định của HikariCP.
Đó là lý do mặc định 10 hợp lý hơn nhiều người tưởng. Trước khi tăng nó, hãy tìm nguyên nhân cạn pool — gần như luôn là giao dịch giữ kết nối lâu hơn cần.
Và nhớ tính tổng: mười container, mỗi cái pool 10, là 100 kết nối tới cùng một CSDL. PostgreSQL mặc định max_connections = 100.
Đặt minimum-idle bằng maximum-pool-size
Mặc định HikariCP đặt hai giá trị bằng nhau, và tài liệu của nó khuyên giữ vậy.
Pool co giãn nghe tiết kiệm, nhưng nó nghĩa là lúc tải tăng đột ngột, bạn phải mở kết nối mới — mà mở kết nối tới PostgreSQL tốn hàng chục mili giây (bắt tay TCP, xác thực, TLS). Đúng lúc cần nhanh nhất thì lại chậm nhất.
Nguyên nhân thật của cạn pool
Gần như không bao giờ là "pool quá nhỏ". Gần như luôn là có ai đó ký mượn xe rồi để nó đỗ ngoài quán trong lúc đi làm việc khác. Bốn nguyên nhân theo thứ tự hay gặp:
Gọi HTTP bên trong giao dịch. Bài 26 đã nói và bài 20 đã đo: RestClient mặc định không có phép chờ. Một dịch vụ treo giữ luôn kết nối CSDL của bạn, và pool cạn sau vài chục request.
Giao dịch quá rộng. @Transactional đặt ở controller, hoặc bao cả một phương thức làm nhiều việc.
Open-session-in-view. Bài 25: kết nối được giữ tới khi response ghi xong, kể cả trong lúc serialize.
Rò rỉ. Lấy kết nối bằng tay mà không đóng. leak-detection-threshold bắt được ngay.
Giám sát
Actuator cho sẵn chỉ số HikariCP:
hikaricp.connections.active đang dùng
hikaricp.connections.pending đang chờ <- quan trọng nhất
hikaricp.connections.timeout số lần bỏ cuộc
hikaricp.connections.usage thời gian giữ một kết nối
pending khác 0 kéo dài là cảnh báo đỏ. Nó nghĩa là request đang xếp hàng chờ kết nối, và bạn còn vài giây trước khi bắt đầu có timeout.
usage cho biết trung bình một kết nối bị giữ bao lâu. Con số đó lớn hơn thời gian truy vấn nhiều lần nghĩa là bạn đang giữ kết nối trong lúc làm việc khác — quay lại bốn nguyên nhân ở trên.
Đọc và ghi tách pool
Với ứng dụng đọc nhiều, tách hai DataSource — một trỏ bản chính, một trỏ bản sao đọc:
@Bean @Primary
@ConfigurationProperties("app.datasource.ghi")
DataSource dsGhi() { return DataSourceBuilder.create().build(); }
@Bean
@ConfigurationProperties("app.datasource.doc")
DataSource dsDoc() { return DataSourceBuilder.create().build(); }
Kèm @Transactional(readOnly = true) ở bài 26 để định tuyến. Chỉ làm khi thật sự cần — độ trễ sao chép nghĩa là bạn đọc ra dữ liệu cũ vài chục mili giây, và điều đó phá vỡ luồng "ghi xong đọc lại ngay".
Nếu chỉ soi một thứ lúc hệ thống đang tải, soi đúng con số chờ trong ba mươi giây:
curl -s localhost:8080/actuator/metrics/hikaricp.connections.pending | jq '.measurements'
Gọi vài lần. Thấy giá trị khác 0 nghĩa là bạn đã ở sát trần — và bảng đầu bài cho thấy quá trần thì không phải chậm đi, mà là ngoại lệ ném thẳng vào mặt người dùng.
Mẫu số chung
Một pool có giới hạn gồm các tài nguyên đắt để tạo, dùng lại được là một mẫu lặp ở khắp nơi — kết nối CSDL (HikariCP, SetMaxOpenConns của Go, pgbouncer), pool luồng, pool kết nối HTTP ở bài trước. Và nó kéo theo một sự thật chung: kích thước pool là một cơ chế kiểm soát nhận vào — một cái trần cứng — và khi bão hoà thì chính sách đúng là hỏng-nhanh (chờ có hạn rồi từ chối), chứ không phải xếp hàng vô hạn, vì hàng đợi không trần chỉ biến "một ít request hỏng" thành "mọi request đều chậm".
Điều thứ hai là cặp phản-trực-giác mà bài này xoay quanh: pool to hơn không nhanh hơn, và thuốc chữa cạn pool là giữ kết nối ngắn lại chứ không phải thêm chỗ. Cả hai là cùng một định luật Little (số-đang-bay = tốc-độ-đến × thời-gian-giữ): hãy chọn kích thước pool theo năng lực thật của nút thắt phía sau — CPU và đĩa của CSDL, chính là công thức (nhân×2)+đĩa của Postgres — và cắt thời gian mỗi kết nối bị giữ (đừng bao giờ gọi HTTP, serialize, hay mở một giao dịch rộng trong lúc đang cầm một kết nối). Thêm chỗ quá điểm gãy của nút thắt chỉ mua thêm chuyển-ngữ-cảnh và tranh khoá — đúng lý do nhiều luồng hơn số nhân làm một việc nặng-CPU chậm đi. Sợi chỉ chung: đừng giữ một tài nguyên chung khan hiếm trong lúc làm việc không liên quan, và khi một pool bão hoà thì đi tìm kẻ giữ lâu trước khi động vào kích thước.
Ngày mai: bộ nhớ đệm — cache của Spring, và hai tầng cache của Hibernate.