Pool kết nối là thứ không ai nghĩ tới cho tới hôm hệ thống đứng. 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.
Đâ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.
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ỏ". 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".
Thử ba mươi giây
curl -s localhost:8080/actuator/metrics/hikaricp.connections.pending | jq '.measurements'
Gọi lệnh này vài lần lúc hệ thống đang tải. 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.
Ngày mai: bộ nhớ đệm — cache của Spring, và hai tầng cache của Hibernate.