Command Palette

Search for a command to run...

[Advanced Java] Connection Pool với HikariCP: tối ưu kết nối database

Connection pool là một cấu trúc dữ liệu khá nhàm chán nhưng lại quyết định phần lớn cách một service hành xử khi tải cao: một số lượng cố định các connection tới database đã mở sẵn, cho mượn khi có ai xin và nhận lại khi họ dùng xong. HikariCP là thư viện gần như ai cũng dùng, và nó là mặc định của Spring Boot.

Phần gây nhầm lẫn không nằm ở config. Nó nằm ở chỗ close() trên một pooled connection không đóng cái gì cả. Nó trả connection về pool. Mọi thứ còn lại trong bài — timeout, cảnh báo leak, cách chọn cỡ pool — đều là hệ quả của đúng một sự thật đó, nên chúng ta bắt đầu từ đây.

Một pool các connection đang mở, cho mượn một cái rồi nhận lại ngay khi close()

Mọi chương trình, dòng log và exception bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6 (arm64) với HikariCP 5.1.0, slf4j-api 2.0.13, slf4j-simple 2.0.13 và sqlite-jdbc 3.46.1.3.

Connection pool thực chất là gì

Pool sở hữu một tập connection có giới hạn và đã được mở sẵn. getConnection() lấy một cái ra khỏi tập đó và đánh dấu là đang dùng; close() đặt nó trở lại. Trên đường đi bình thường không có gì được tạo mới hay bị huỷ, và đó chính là toàn bộ ý tưởng.

Lý do điều này quan trọng nằm ở cái giá của thứ đang được tái sử dụng. Mở một connection tới một database server là một lần TCP connect, thường thêm một lần TLS handshake, rồi authentication, rồi thiết lập session — một cuộc trao đổi nhiều vòng qua network trước khi chạy được một query nào. Làm việc đó mỗi HTTP request là khác biệt giữa một service scale được và một service thì không.

⚠️ Các ví dụ ở đây chạy trên SQLite, mà SQLite là một embedded file database: không có server, không socket, không handshake. jdbc:sqlite:/path/to/file.db chỉ là mở một file. Nghĩa là động lực thường thấy của pooling gần như không áp dụng cho SQLite — hầu như không có chi phí kết nối nào để bù đắp. Cái SQLite cho ta là một JDBC driver thật để trình diễn cơ chế của pool, và cơ chế đó nằm trong HikariCP chứ không nằm trong driver, nên nó giống hệt nhau khi chạy với PostgreSQL, MySQL hay Oracle. Vì vậy bài này không đăng bất kỳ so sánh tốc độ pooled với unpooled nào. Một con số nhanh hơn đo trên SQLite là con số vô nghĩa, và lập luận về lợi ích hoàn toàn đến từ cái giá của một connection tới server thật, thứ mà bài này không đo.

Chỉ cần bốn file jar là chạy được, không cần build tool nào.

Bash
curl -sSO https://repo1.maven.org/maven2/com/zaxxer/HikariCP/5.1.0/HikariCP-5.1.0.jar
curl -sSO https://repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.13/slf4j-api-2.0.13.jar
curl -sSO https://repo1.maven.org/maven2/org/slf4j/slf4j-simple/2.0.13/slf4j-simple-2.0.13.jar
curl -sSO https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.46.1.3/sqlite-jdbc-3.46.1.3.jar
 
CP="HikariCP-5.1.0.jar:slf4j-api-2.0.13.jar:slf4j-simple-2.0.13.jar:sqlite-jdbc-3.46.1.3.jar"
javac -cp "$CP" -d classes src/PoolBasics.java
java  -cp "classes:$CP" PoolBasics /tmp/demo.db

slf4j-api không phải tuỳ chọn: thiếu nó trên classpath thì HikariCP không khởi động. slf4j-simple là binding thực sự in log ra, và bạn nên có nó, vì chẩn đoán hữu ích nhất của HikariCP — cảnh báo leak ở phần sau — chỉ tồn tại dưới dạng một dòng log, không có gì khác.

close() không đóng connection, và đây là bằng chứng

Mượn một connection, ghi nhớ nó thực sự là object nào, trả về, mượn lại lần nữa, rồi so sánh.

Java
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.Statement;
 
public class PoolBasics {
    public static void main(String[] args) throws Exception {
        HikariConfig cfg = new HikariConfig();
        cfg.setJdbcUrl("jdbc:sqlite:" + args[0]);
        cfg.setPoolName("demo-pool");
        cfg.setMaximumPoolSize(1);
 
        try (HikariDataSource ds = new HikariDataSource(cfg)) {
            Connection a = ds.getConnection();
            Connection realA = a.unwrap(Connection.class);
            System.out.println("handed out  : " + a.getClass().getName());
            System.out.println("underlying  : " + realA.getClass().getName());
            System.out.println("proxy   A id: " + System.identityHashCode(a));
            System.out.println("real    A id: " + System.identityHashCode(realA));
 
            try (Statement st = a.createStatement()) {
                st.execute("SELECT 1");
            }
            a.close();
            System.out.println("proxy.isClosed() after close(): " + a.isClosed());
            System.out.println("real.isClosed()  after close(): " + realA.isClosed());
 
            Connection b = ds.getConnection();
            Connection realB = b.unwrap(Connection.class);
            System.out.println("proxy   B id: " + System.identityHashCode(b));
            System.out.println("real    B id: " + System.identityHashCode(realB));
            System.out.println("same proxy object      : " + (a == b));
            System.out.println("same real connection   : " + (realA == realB));
            b.close();
        }
    }
}
Text
handed out  : com.zaxxer.hikari.pool.HikariProxyConnection
underlying  : org.sqlite.jdbc4.JDBC4Connection
proxy   A id: 653687670
real    A id: 1508395126
proxy.isClosed() after close(): true
real.isClosed()  after close(): false
proxy   B id: 32017212
real    B id: 1508395126
same proxy object      : false
same real connection   : true

Hãy đọc kỹ bốn dòng cuối. Các con số identity thay đổi sau mỗi lần chạy, nhưng quan hệ giữa chúng thì không:

  • Thứ bạn nhận được không phải connection của driver. Nó là một HikariProxyConnection, một wrapper được sinh ra lúc chạy. unwrap(Connection.class) chạm tới org.sqlite.jdbc4.JDBC4Connection nằm bên dưới.
  • Sau close(), proxy báo isClosed() == true còn connection thật báo false. Cả hai câu trả lời đều đúng: cái handle đã chết, còn connection vẫn sống.
  • Lần mượn thứ hai nhận được một proxy object khác bọc quanh cùng một connection bên dưới. Đó chính là pool đang làm việc của nó.

Vòng đời của một pooled connection: close() trả nó về tập idle, chỉ retirement mới đóng socket thật

Tuy vậy trả connection về không phải là một thao tác rỗng. HikariCP reset lại state của lần mượn để người mượn tiếp theo nhận được một session sạch, và phần quan trọng nhất của lần reset đó là transaction.

Java
try (Connection c = ds.getConnection()) {
    c.setAutoCommit(false);
    try (Statement st = c.createStatement()) {
        st.executeUpdate("INSERT INTO audit (id) VALUES (1)");
    }
    // no commit(), no rollback() -- just close()
}
try (Connection c = ds.getConnection(); Statement st = c.createStatement()) {
    ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM audit");
    rs.next();
    System.out.println("rows after uncommitted insert + close(): " + rs.getInt(1));
    System.out.println("autoCommit on the next borrow          : " + c.getAutoCommit());
}
Text
rows after uncommitted insert + close(): 0
autoCommit on the next borrow          : true

Lệnh insert chưa commit đã bị rollback lúc trả về, và autoCommit được khôi phục lại giá trị mà pool cấu hình trước khi connection quay lại tập idle. Đóng một pooled connection khi còn việc chưa commit sẽ âm thầm vứt bỏ phần việc đó — hành vi này là đúng, và cũng là một cách rất im lặng để mất dữ liệu nếu bạn tưởng rằng close sẽ commit hộ mình.

Những config thật sự quan trọng và mỗi cái điều khiển điều gì

HikariCP cố tình đưa ra rất ít lựa chọn. Quan điểm của dự án là phần lớn thiết lập của một pool chỉ là cách che đậy một cái bug, và một pool càng ít núm vặn thì càng ít cấu hình sai. Trên thực tế bạn chỉ set khoảng chín thứ, thường là ít hơn.

PropertyMặc địnhNó thật sự điều khiển gì
jdbcUrlkhông cóDriver và đích đến. Ở đây là jdbc:sqlite:/path/file.db; với server thì là host, port và tên database.
poolNameHikariPool-NTiền tố trên mọi dòng log và exception của pool này. Hãy set nó — đây là thứ cho bạn biết pool nào đã timeout lúc 3 giờ sáng.
maximumPoolSize10Trần cứng của số connection. Cũng là trần của số statement mà ứng dụng có thể đang chạy đồng thời ở database.
minimumIdlebằng maximumPoolSizeSố connection giữ sẵn khi rảnh. Để nguyên bằng nhau nghĩa là pool cỡ cố định, và đó là hình dạng được khuyến nghị.
connectionTimeout30000 msgetConnection() chờ bao lâu để có connection rảnh trước khi throw. Giá trị dưới 250 ms bị từ chối thẳng.
idleTimeout600000 msMột connection dư có thể nằm không bao lâu trước khi bị loại bỏ. Chỉ có tác dụng khi minimumIdle nhỏ hơn maximumPoolSize.
maxLifetime1800000 msTuổi tối đa của một connection trước khi pool cho nghỉ, dù nó còn khoẻ hay không. Giá trị dưới 30000 ms bị bỏ qua.
leakDetectionThreshold0 (tắt)Một lần mượn được kéo dài bao lâu trước khi HikariCP log cảnh báo kèm stack trace lúc lấy connection. Tối thiểu 2000 ms.
autoCommittrueGiá trị autoCommit mà mỗi lần mượn bắt đầu, và cũng là giá trị được khôi phục lúc trả về.

Những giá trị mặc định trên không phải viết theo trí nhớ. Đây là một pool khởi động chỉ với jdbcUrl và in ra những gì nó tự quyết:

Text
poolName               = HikariPool-1
maximumPoolSize        = 10
minimumIdle            = 10
connectionTimeout      = 30000
idleTimeout            = 600000
keepaliveTime          = 0
maxLifetime            = 1800000
validationTimeout      = 5000
leakDetectionThreshold = 0
autoCommit             = true

HikariCP cũng từ chối những cấu hình mà nó cho là sai thay vì lặng lẽ chấp nhận. Set connectionTimeout dưới ngưỡng sàn thì throw ngay cả trước khi pool kịp khởi động:

Text
Exception in thread "main" java.lang.IllegalArgumentException: connectionTimeout cannot be less than 250ms
	at com.zaxxer.hikari.HikariConfig.setConnectionTimeout(HikariConfig.java:194)

còn một pool cấu hình maxLifetime = 5000, idleTimeout = 1000leakDetectionThreshold = 500 vẫn khởi động được, nhưng không phải với những giá trị bạn yêu cầu:

Text
WARN com.zaxxer.hikari.HikariConfig - clamp-pool - maxLifetime is less than 30000ms, setting to default 1800000ms.
WARN com.zaxxer.hikari.HikariConfig - clamp-pool - leakDetectionThreshold is less than 2000ms or more than maxLifetime, disabling it.
WARN com.zaxxer.hikari.HikariConfig - clamp-pool - idleTimeout has been set but has no effect because the pool is operating as a fixed size pool.

Cảnh báo thứ ba mới là cái đáng nhớ. Vì minimumIdle mặc định bằng maximumPoolSize, pool nguyên bản là pool cỡ cố định, và idleTimeout hoàn toàn vô tác dụng cho tới khi bạn hạ minimumIdle xuống. Hãy đọc log lúc khởi động một lần; nó nói cho bạn biết pool thực sự chấp nhận những thiết lập nào.

Hết connection trong pool và connectionTimeout

Đây mới là hành vi người ta thực sự gặp trên production, và đáng để tái hiện có chủ đích một lần cho quen mặt. Một pool cỡ hai, mượn cả hai connection mà không trả, rồi xin cái thứ ba:

Java
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:sqlite:" + db);
cfg.setPoolName("orders-pool");
cfg.setMaximumPoolSize(2);
cfg.setConnectionTimeout(2000);
 
try (HikariDataSource ds = new HikariDataSource(cfg)) {
    Connection c1 = ds.getConnection();
    Connection c2 = ds.getConnection();
    System.out.println("borrowed 2 of 2, none returned");
    try {
        Connection c3 = ds.getConnection();
        System.out.println("got a third connection: " + c3);
    } catch (SQLException e) {
        System.out.println(e.getClass().getName());
        System.out.println(e.getMessage());
    }
    c1.close();
    c2.close();
}
Text
borrowed 2 of 2, none returned
java.sql.SQLTransientConnectionException
orders-pool - Connection is not available, request timed out after 2005ms (total=2, active=2, idle=0, waiting=0)

Dòng thông báo đó là chuỗi hữu ích nhất mà HikariCP sinh ra, và đáng đọc từng phần một:

  • orders-pool chính là poolName bạn đã set. Khi trong một process có nhiều pool, đây là thứ cho biết database nào đang cạn kiệt.
  • 2005msconnectionTimeout 2000 ms bạn cấu hình hết hạn, cộng vài mili giây lệch lịch. Nó là thiết lập của bạn quay ngược lại, không phải phép đo của bất cứ thứ gì.
  • total=2, active=2, idle=0 nói rằng pool đã chạm trần và mọi connection đều đang được cho mượn. Không có gì hỏng cả; chỉ là có ai đó đang giữ thứ bạn cần.
  • waiting=0 là số thread khác còn đang xếp hàng tại thời điểm thông báo được tạo. Con số này lớn nghĩa là cả ứng dụng đang tắc lại sau cái pool này.

SQLTransientConnectionException kế thừa SQLTransientException, và qua đó là SQLException; chữ "transient" là lời hứa về kiểu lỗi chứ không phải về code của bạn: thử lại có thể thành công, vì nguyên nhân là lần mượn của người khác chứ không phải một câu statement hỏng.

Cách sửa không bao giờ là tăng timeout. Hãy trả connection về:

Text
--- same work, connections returned ---
borrow 1 ok, underlying=1072601481
borrow 2 ok, underlying=1072601481
borrow 3 ok, underlying=1072601481
borrow 4 ok, underlying=1072601481
borrow 5 ok, underlying=1072601481

Năm lần mượn tuần tự qua một block try (Connection c = ds.getConnection()) hoàn tất mà không hề chạm trần, và nhìn identity mà xem — cả năm lần đều nhận đúng một connection vật lý. Một pool cỡ hai phục vụ năm lần mượn bằng một connection, vì một lần mượn kết thúc ngay lập tức thì không bao giờ cần tới cái thứ hai.

Leak detection: dòng log tự tìm bug hộ bạn

Một connection không bao giờ được trả về là mất khỏi pool vĩnh viễn. Đủ nhiều cái như vậy thì mọi getConnection() đều kết thúc bằng cái timeout ở trên, với thông báo cho biết pool đã cạn nhưng không cho biết ai làm nó cạn. leakDetectionThreshold trả lời câu hỏi thứ hai, và phần lớn developer chưa từng nhìn thấy output của nó.

Đây là một repository method với đúng cái bug kinh điển — một lệnh return sớm bỏ qua phần close:

Java
static int countRows(HikariDataSource ds) throws Exception {
    Connection c = ds.getConnection();          // borrowed here
    Statement st = c.createStatement();
    ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM orders");
    rs.next();
    return rs.getInt(1);                        // returns without c.close()
}

Chạy nó với một pool có cfg.setLeakDetectionThreshold(2000) rồi chờ. Đây là bản chụp thật, không chỉnh sửa:

Text
[main] INFO com.zaxxer.hikari.HikariDataSource - orders-pool - Starting...
[main] INFO com.zaxxer.hikari.pool.HikariPool - orders-pool - Added connection org.sqlite.jdbc4.JDBC4Connection@6537cf78
[main] INFO com.zaxxer.hikari.HikariDataSource - orders-pool - Start completed.
rows = 3
[orders-pool housekeeper] WARN com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered for org.sqlite.jdbc4.JDBC4Connection@6537cf78 on thread main, stack trace follows
java.lang.Exception: Apparent connection leak detected
	at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:99)
	at LeakDemo.countRows(LeakDemo.java:10)
	at LeakDemo.main(LeakDemo.java:29)
active connections still out: 1

LeakDemo.countRows(LeakDemo.java:10) chính là dòng 10 — lời gọi ds.getConnection(). HikariCP chụp lại stack trace ngay lúc lấy connection và phát lại nó khi lần mượn sống lâu hơn threshold, nên cảnh báo gọi đúng tên dòng code đã lấy connection và đúng thread còn đang giữ nó. Trong một sự cố thật, đó là khác biệt giữa một buổi chiều đoán mò và một dòng sửa.

Dòng thời gian của một lần leak: hết threshold, housekeeper log stack trace, trả về muộn thì báo cáo bị rút lại

Bộ phát hiện này khá sòng phẳng về chuyện báo nhầm. Một lần mượn chỉ chậm chứ không mất sẽ được rút lại báo cáo khi nó quay về:

Text
INFO com.zaxxer.hikari.pool.ProxyLeakTask - Previously reported leaked connection org.sqlite.jdbc4.JDBC4Connection@6537cf78 on thread main was returned to the pool (unleaked)

Có ba điều cần nhớ. Giá trị nhỏ nhất được chấp nhận là 2000 ms, thấp hơn là tính năng bị tắt âm thầm kèm cảnh báo đã thấy ở trên. Cảnh báo chỉ là báo cáo, không phải cứu chữa — HikariCP không lấy lại connection, và getActiveConnections() ở trên vẫn trả về 1. Và chi phí chỉ là một scheduled task cho mỗi lần mượn, nên bật nó trên production với ngưỡng cao hơn query chậm nhất hợp lệ của bạn là chuyện hoàn toàn bình thường.

Chọn cỡ pool một cách trung thực

Bản năng mách bảo rằng pool lớn hơn thì phục vụ được nhiều traffic hơn. Không phải vậy, và lý do mang tính cấu trúc chứ không phải do đo đạc.

Hai panel: cùng một lượng tải qua pool nhỏ và pool lớn, hàng đợi dời từ pool vào bên trong database

Pool là một hàng đợi đặt trước database, mà bản thân database cũng chỉ có mức song song hữu hạn — số core hữu hạn, số ổ đĩa hữu hạn, và các lock nội bộ khiến công việc bị tuần tự hoá bất kể bạn mở bao nhiêu session. Nếu database của bạn chạy hiệu quả được sáu statement cùng lúc thì mở sáu mươi connection cũng không làm nó chạy sáu mươi. Năm mươi tư cái còn lại vẫn chờ; chúng chỉ đơn giản là chờ bên trong server, trong một hàng đợi mà bạn không nhìn thấy, không giới hạn được và không timeout được, thay vì chờ trong pool của bạn nơi connectionTimeout cho bạn một lỗi nhanh và bắt được.

Đó là toàn bộ lập luận, và cũng là lý do tài liệu của chính HikariCP khuyến nghị pool nhỏ hơn nhiều so với điểm khởi đầu của phần lớn team. Một pool quá cỡ còn tốn bộ nhớ thật và áp lực lập lịch thật ở phía database cho mỗi session, nên qua một ngưỡng nào đó pool lớn hơn là chậm hơn chứ không chỉ là không nhanh hơn.

Thứ bài viết này cố tình không đưa cho bạn là một con số. Giá trị maximumPoolSize đúng cho hệ thống của bạn đến từ việc đo database của bạn, với đúng tập query của bạn, ở đúng mức đồng thời của bạn — và đo nghĩa là một benchmark tử tế: một bộ sinh tải, một trạng thái ổn định đã được làm nóng, và các percentile độ trễ, chứ không phải một vòng lặp kẹp timer hai đầu. Không thứ gì trên trang này được đo theo cách đó, vì SQLite dù sao cũng không cho ra một câu trả lời chuyển được sang một database server. Hãy coi lập luận là thứ mang đi được, còn con số là thứ bạn phải tự tìm.

Dù vậy vẫn có ba ràng buộc nên áp dụng trước khi benchmark bất cứ điều gì:

  • maximumPoolSize nhân với số instance của service phải nằm dưới giới hạn connection của chính database, còn chừa chỗ cho migration, session quản trị và hệ thống monitoring.
  • Thread pool và connection pool là hai resource có giới hạn khác nhau và phải được chọn cỡ cùng nhau: các thread xử lý request mà đông hơn số connection thì chỉ xếp hàng ở getConnection(), và cái hàng đợi đó chính là nơi độ trễ của bạn đi mất.
  • maxLifetime phải ngắn hơn một cách an toàn so với mọi ngưỡng cắt idle nằm giữa bạn và database — timeout của chính server, một load balancer, một proxy, một bảng NAT. Bên nào đóng trước cũng vậy, nếu pool là bên chậm hơn thì nó sẽ phát ra một connection đã chết từ lâu.

Quan sát pool thở bằng HikariPoolMXBean

ds.getHikariPoolMXBean() cho bạn các con số cấu trúc trực tiếp của pool. Đây là các phép đếm chứ không phải phép đo thời gian, nên vừa rẻ để lấy mẫu vừa an toàn để suy luận.

Java
HikariPoolMXBean mx = ds.getHikariPoolMXBean();
CountDownLatch holding = new CountDownLatch(4);
CountDownLatch release = new CountDownLatch(1);
for (int i = 0; i < 8; i++) {
    workers.submit(() -> {
        try (Connection c = ds.getConnection(); Statement st = c.createStatement()) {
            st.execute("SELECT 1");
            holding.countDown();
            release.await();
        }
        return null;
    });
}
holding.await();
while (mx.getThreadsAwaitingConnection() < 4) Thread.onSpinWait();
sample("8 tasks, pool of 4", mx);
release.countDown();

Với maximumPoolSize = 4, minimumIdle = 4 và tám task đều giữ connection tới khi được thả:

Text
at rest                total=1 active=0 idle=1 waiting=0
8 tasks, pool of 4     total=4 active=4 idle=0 waiting=4
after all returned     total=4 active=0 idle=4 waiting=0

Dòng giữa là lúc pool bão hoà: bốn connection đang ra ngoài, không cái nào rảnh, bốn thread đang nằm chờ bên trong getConnection(). Bốn thread đó hẳn đã nhận đúng cái timeout exception ở phần trước nếu bên giữ không thả kịp — waiting chính là con số sẽ biến thành SQLTransientConnectionException nếu nó không rút xuống.

Dòng đầu tiên là một chi tiết đáng biết: total=1 chứ không phải 4. HikariCP khởi động với đúng một connection rồi làm đầy tới minimumIdle trên một thread nền, nên một pool được lấy mẫu ngay sau khi khởi tạo thì chưa đủ cỡ. Cảnh báo dựa trên idle == 0 trong vài khoảnh khắc đầu của process sẽ gọi bạn dậy vì không có gì cả.

Trong một service thật, tín hiệu đáng theo dõi là getThreadsAwaitingConnection() lớn hơn 0 kéo dài, nghĩa là pool đang là nút thắt cổ chai, và getIdleConnections() dính chặt ở 0, nghĩa là bạn đang chạm trần. Cũng bean đó cung cấp softEvictConnections() để cho nghỉ toàn bộ tập connection hiện tại một cách nhẹ nhàng sau khi xoay credential hoặc sau một lần failover.

Những lỗi phổ biến khiến bạn mất cả pool

Không close. Mọi thứ còn lại trong danh sách này chỉ là biến thể của nó. Hãy dùng try (Connection c = ds.getConnection()) không có ngoại lệ nào; một lệnh close() đặt ở cuối method sẽ bị bỏ qua bởi mọi lệnh return sớm và mọi lần throw. Bật leakDetectionThreshold để lỗi này tự tố cáo chính nó.

Giữ connection xuyên qua một việc chậm mà không phải query. Một connection được mượn trước một lời gọi HTTP, một lần upload file, một phép tính dài hay một Thread.sleep là một connection bị rút khỏi vòng lưu thông suốt quãng thời gian đó. Hãy mượn ngay trước statement và trả ngay sau đó. Đây cũng chính là kiểu hỏng biến một pool khoẻ mạnh thành pool cạn kiệt khi tải lên mà không cần thay đổi một dòng code nào, chỉ vì một service phía sau bắt đầu chậm.

Một pool cho mỗi request, mỗi class hoặc mỗi method. HikariDataSource là object ở phạm vi ứng dụng. Tạo một cái cho mỗi đơn vị công việc cho bạn toàn bộ chi phí của một pool và không một chút lợi ích nào, mà nó còn kêu rất to trong log:

Text
INFO com.zaxxer.hikari.HikariDataSource - per-request-pool-0 - Starting...
INFO com.zaxxer.hikari.pool.HikariPool - per-request-pool-0 - Added connection org.sqlite.jdbc4.JDBC4Connection@3c0ecd4b
INFO com.zaxxer.hikari.HikariDataSource - per-request-pool-0 - Start completed.
INFO com.zaxxer.hikari.HikariDataSource - per-request-pool-0 - Shutdown initiated...
INFO com.zaxxer.hikari.HikariDataSource - per-request-pool-0 - Shutdown completed.

Nếu log của bạn hiện Starting... nhiều hơn một lần cho mỗi pool trong suốt vòng đời process, bạn đang dính đúng cái bug này.

Pool lớn hơn mức database cho phép. Mỗi instance của service lại nhân maximumPoolSize lên. Mười instance, mỗi cái năm mươi connection, là năm trăm session đòi chỗ trên một server có thể chỉ được cấu hình cho một trăm, và lỗi sẽ xuất hiện dưới dạng bị từ chối kết nối ngay lúc deploy, khi instance cũ và mới cùng chạy.

maxLifetime dài hơn ngưỡng cắt idle của người khác. Nếu database, một proxy hay một load balancer đóng connection nhàn rỗi sau mười phút mà maxLifetime của bạn là ba mươi, pool sẽ rất tự tin phát ra những socket đã bị đóng từ hai mươi phút trước. Hãy giữ maxLifetime thấp hơn một khoảng an toàn so với ngưỡng ngắn nhất trên đường đi.

Dùng lại cái handle sau khi đã trả nó về. Proxy đã chết dù connection thì chưa, và thông báo rất thẳng thừng:

Text
java.sql.SQLException: Connection is closed

Ngược lại, gọi close() hai lần thì vô hại — lần thứ hai không làm gì cả.

FAQ

Gọi close() trên connection của HikariCP có thật sự đóng connection tới database không?

Không. Nó trả connection về pool. Thứ bạn đang giữ là một HikariProxyConnection bọc quanh connection thật của driver; close() đánh dấu proxy đã chết và đặt connection bên dưới trở lại tập idle. Chạy phép kiểm tra ở trên, proxy báo isClosed() == true trong khi org.sqlite.jdbc4.JDBC4Connection thật báo false, và lần getConnection() tiếp theo trả về một proxy mới bọc đúng object cũ đó. Những con đường duy nhất thực sự đóng socket là retirement do maxLifetime hoặc idleTimeout, một lời gọi evictConnection() tường minh, một lần validation thất bại, và việc shutdown chính cái pool.

Thông báo "Connection is not available, request timed out after 30000ms" nghĩa là gì?

Nghĩa là mọi connection trong pool đều đang được cho mượn và không cái nào quay về trước khi connectionTimeout hết hạn, nên getConnection() throw SQLTransientConnectionException. Các con số trong ngoặc nói nốt phần còn lại: total là số connection đang tồn tại, active là số đang được mượn, idle là số đang rảnh, còn waiting là số thread khác đang xếp hàng sau bạn. Khi active bằng totalidle bằng 0 thì không có gì hỏng — chỉ là có ai đó đang giữ thứ bạn cần. Tăng timeout chỉ làm lỗi đến muộn hơn; hai cách sửa thật sự là tìm ra lần mượn không bao giờ trả bằng leakDetectionThreshold, và rút ngắn phần việc làm trong lúc đang giữ connection.

maximumPoolSize nên đặt bao nhiêu?

Nhỏ hơn bạn nghĩ, và cuối cùng phải là một con số đo được chứ không phải đoán. Pool là hàng đợi đặt trước một database chỉ chạy được một lượng việc nhất định cùng lúc; làm pool rộng ra không làm database rộng ra, nó chỉ dời chỗ xếp hàng vào bên trong server nơi bạn không giới hạn hay timeout được. Tài liệu của chính HikariCP khuyến nghị pool nhỏ hơn nhiều so với điểm khởi đầu của phần lớn team. Hãy đặt trần từ giới hạn của database và số instance của bạn, rồi đo bằng một bài load test thật ở trạng thái ổn định và đọc percentile độ trễ, chứ không phải bấm giờ quanh một vòng lặp. Và hãy chọn cỡ nó cùng lúc với thread pool xử lý request — chúng là hai resource có giới hạn khác nhau, và cái nhỏ hơn mới quyết định mức đồng thời của bạn.

Bật leakDetectionThreshold trên production có an toàn không?

Có, và đây là một trong những thiết lập giá trị nhất mà HikariCP có. Chi phí là một scheduled task cho mỗi lần mượn, không đáng kể so với một vòng đi về tới database. Hãy đặt ngưỡng cao hơn query hợp lệ chậm nhất của bạn để việc chạy bình thường không bị báo nhầm — vài giây là mức thường gặp — và nhớ hai giới hạn: giá trị dưới 2000 ms sẽ âm thầm tắt tính năng, và cảnh báo không thu hồi lại gì cả, nên connection vẫn nằm ngoài cho tới khi ai đang giữ nó chịu trả. Nếu một lần mượn chậm nhưng thật thà bị báo rồi sau đó hoàn tất, HikariCP sẽ log một dòng (unleaked) để rút lại báo cáo.

Dùng SQLite thì có thật sự cần connection pool không?

Phần lớn là không, và bài viết này không hề nói ngược lại. SQLite là embedded file database, không server, không socket, không handshake authentication, nên gần như không có chi phí thiết lập kết nối nào để bù đắp — mà đó lại là thứ chính yếu pooling sinh ra để tránh. Cái pool vẫn cho bạn với SQLite là một giới hạn về mức đồng thời và một vòng đời thống nhất, cũng có ích khi xét tới cơ chế khoá phía writer của chính SQLite. Lý do dùng SQLite để trình diễn là vì mọi cơ chế ở đây — proxy, đường trả về, timeout, leak detection, metrics — đều nằm trong HikariCP chứ không nằm trong driver, nên chúng hành xử y hệt với một server thật. Còn lập luận về lợi ích thì, khác với cơ chế, không chuyển được từ SQLite sang.

Vì sao maxLifetime phải ngắn hơn idle timeout của database?

Vì bên nào hết giờ trước thì bên đó thắng, mà pool lại không được báo. Nếu một server, proxy hay load balancer nằm giữa bạn và database đóng connection sau mười phút nhàn rỗi trong khi maxLifetime của bạn là ba mươi phút, pool vẫn giữ những entry mà nó tin là khoẻ và phát ra một socket đầu kia đã đóng — biểu hiện thành lỗi broken pipe hoặc connection reset trên một query chẳng có gì sai. Hãy đặt maxLifetime thấp hơn một khoảng an toàn so với ngưỡng ngắn nhất trên toàn đường đi. HikariCP cũng từ chối maxLifetime dưới 30000 ms, đưa nó về mặc định 1800000 ms kèm một cảnh báo lúc khởi động.

Một HikariDataSource dùng cho hai database khác nhau được không?

Không. Một pool gắn với đúng một jdbcUrl và một bộ credential, và mọi việc nó làm — chọn cỡ, validate, cho nghỉ — đều giả định mọi connection trong đó là thay thế được cho nhau. Hai database nghĩa là hai HikariDataSource, mỗi cái có poolName riêng để log và exception còn đọc được, và mỗi cái được chọn cỡ độc lập. Điều đó cũng có nghĩa là giới hạn của chúng cộng dồn vào tổng ngân sách connection mà các server cho phép, thứ rất dễ quên khi cái pool thứ hai được thêm vào sau đó nhiều tháng.

Kết luận

Connection pool là một tập connection đang mở có giới hạn, và toàn bộ bề mặt của HikariCP đều suy ra từ đó. close() là mũi tên trả về chứ không phải một lệnh tắt — nên một lần thiếu close() sẽ rút vĩnh viễn một connection khỏi vòng lưu thông, nên maximumPoolSize là trần của mức đồng thời thật sự của bạn ở database, và nên connectionTimeout là cơ chế biến một pool bị kẹt thành một exception nhanh và có tên thay vì một lần treo.

Ba thiết lập làm phần lớn công việc. poolName, để mọi dòng log và exception đều nói rõ database nào. leakDetectionThreshold, để một connection bị mất tự khai ra stack trace lúc nó được lấy thay vì vài ngày sau mới hiện hình thành một lần cạn pool bí ẩn. Và một maximumPoolSize chọn theo mức việc database thật sự chạy nổi cùng lúc, chứ không theo lượng traffic bạn kỳ vọng — pool không làm database rộng ra được, nó chỉ quyết định chỗ đứng chờ nằm ở đâu. Mọi thứ trình diễn ở đây chạy trên SQLite để cho thấy cơ chế; còn lập luận vì sao nên pool thì đến từ cái giá mở một connection tới server thật, và đó là phép đo bạn phải tự thực hiện trên database của mình.

Đó là hết Part 5 của khoá này. Part 6 chuyển sang testing, bắt đầu từ unit testing với JUnit 5.

Bài viết liên quan

[Advanced Java] JDBC trong Java: Kết nối và thao tác Database

JDBC trong Java trên OpenJDK 21 với một database SQLite thật: kiến trúc driver phía sau DriverManager, try-with-resources cho Connection, Statement và ResultSet, một màn SQL injection chạy thật mà PreparedStatement chặn được, ResultSet như một cursor, transaction với commit, rollback và Savepoint, batch update, getGeneratedKeys và cách đọc một SQLException.

[Advanced Java] Kết nối Spring Boot với Database bằng Spring Data JPA

Spring Data JPA trên Spring Boot 4.1.1 và Hibernate 7.4.5: JDBC, JPA, Hibernate và repository xếp chồng thế nào, entity mapping cùng DDL sinh ra, derived query method, vấn đề N+1 đếm bằng log SQL thật, LazyInitializationException, @Transactional dựa trên proxy, dirty checking và flush khác commit.

[Advanced Java] 4 tính chất OOP dưới góc nhìn nâng cao: thiết kế object sai ở đâu

4 tính chất OOP trong Java nhìn xa hơn phần định nghĩa, chạy thật trên OpenJDK 21: encapsulation bảo đảm bằng behaviour thay vì accessor, method được inherit đi vòng qua guard của subclass, cách viết lại bằng delegation, switch theo type bạn sở hữu và type bạn không sở hữu, abstraction rò rỉ ngay trong signature, và một subclass compile được, test của nó pass, vẫn làm hỏng caller.

[Advanced Java] Unit Testing trong Java với JUnit 5

Unit testing trong Java với JUnit 5.11.3 trên OpenJDK 21: kiến trúc Platform, Jupiter và Vintage, các callback lifecycle, mỗi test method một instance mới, message báo lỗi thật của từng assertion, assertThrows và assertAll, DisplayName, Nested, Disabled và Tag, parameterized test với đủ các argument source, assumption so với assertion, và những thói quen khiến một test trở nên vô dụng.