Kết nối MySQL
Xem thửMột dịch vụ đặt hàng chạy trơn tru ở máy lập trình viên và cả ở môi trường kiểm thử. Đến buổi chạy thử tải đầu tiên, khoảng hai trăm người dùng đặt hàng cùng lúc, và log bắt đầu đầy Too many connections. Không ai sửa cơ sở dữ liệu, không ai đổi code truy vấn. Thủ phạm là một dòng DriverManager.getConnection(...) nằm trong mỗi request: mỗi lần gọi mở một kết nối mới tới MySQL, và MySQL mặc định chỉ cho 151 kết nối đồng thời.
Câu chuyện này là kịch bản điển hình chứ không phải hiếm gặp. Lệnh kết nối trông vô hại ở bài Hello World, nhưng nó là điểm vào của mọi lỗi mạng, lỗi xác thực, lỗi cấu hình và lỗi dữ liệu khi ứng dụng nói chuyện với cơ sở dữ liệu. Bài này đi từ kết nối đơn giản nhất tới cách vận hành đúng: đọc lỗi theo từng trạm, hiểu vài tham số URL hay gây hại âm thầm, và dùng connection pool.
Sau bài này, bạn có thể
- Kết nối tới MySQL 8.4 bằng JDBC mà không để mật khẩu nằm trong code.
- Nhìn một lỗi kết nối và biết nó thất bại ở trạm nào: URL, mạng, xác thực hay quyền trên database.
- Giải thích vì sao tham số URL gõ sai có thể bị bỏ qua mà không báo lỗi, và vì sao múi giờ có thể làm lệch dữ liệu đã lưu.
- Giải thích
close()làm gì với connection pool, cấu hình HikariCP, và chặn lỗi cạn pool trước khi nó gây sự cố.
Lấy mã nguồn và chạy thí nghiệm
Mọi thí nghiệm trong bài đều có mã nguồn chạy được trong thư mục 02-jdbc/02-ket-noi-mysql của kho mã khóa học. Mỗi con số, mỗi thông báo lỗi trong bài được đo từ chính mã đó (MySQL 8.4.9, Connector/J 26.7.0, HikariCP 7.1.0, JDK 17), nên bạn có thể chạy lại và đối chiếu.
git clone https://github.com/canhdominich/java-backend-fundamentals.git
cd java-backend-fundamentals/02-jdbc/02-ket-noi-mysql
docker compose up -d
mvn -q compile exec:exec -Dmain.class=dev.canhdominich.jdbc.HelloConnection
Docker chạy MySQL ở cổng 3307 thay vì 3306 để không đụng MySQL đã cài sẵn trên máy bạn. Thư mục có README ghi đủ yêu cầu môi trường, cách chạy từng thí nghiệm và kết quả mong đợi.
Kết nối tối thiểu
Khai báo hai thư viện trong pom.xml: driver MySQL (Connector/J) và, từ phần sau của bài, HikariCP.
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>26.7.0</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.1.0</version>
</dependency>
Chương trình nhỏ nhất, HelloConnection, đọc thông tin kết nối từ biến môi trường, mở kết nối, hỏi MySQL vài điều và đóng lại bằng try-with-resources. Nó dùng DbConfig, một record giữ URL, tên người dùng và mật khẩu, đọc từ biến môi trường JDBC_URL, DB_USER, DB_PASSWORD và rơi về giá trị mặc định của lab khi biến chưa đặt:
src/main/java/dev/canhdominich/jdbc/DbConfig.java
package dev.canhdominich.jdbc;
public record DbConfig(String url, String user, String password) {
public static DbConfig fromEnvironment() {
return new DbConfig(
env("JDBC_URL", "jdbc:mysql://localhost:3307/shop"),
env("DB_USER", "app"),
env("DB_PASSWORD", "app_pw"));
}
public DbConfig withUser(String newUser) {
return new DbConfig(url, newUser, password);
}
public DbConfig withUrl(String newUrl) {
return new DbConfig(newUrl, user, password);
}
public DbConfig withPassword(String newPassword) {
return new DbConfig(url, user, newPassword);
}
private static String env(String name, String fallback) {
String value = System.getenv(name);
return value == null || value.isBlank() ? fallback : value;
}
}
Và đây là HelloConnection:
src/main/java/dev/canhdominich/jdbc/HelloConnection.java
package dev.canhdominich.jdbc;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class HelloConnection {
public static void main(String[] args) throws SQLException {
DbConfig config = DbConfig.fromEnvironment();
try (Connection connection = DriverManager.getConnection(config.url(), config.user(), config.password())) {
var meta = connection.getMetaData();
System.out.println("Connected to " + meta.getDatabaseProductName() + " " + meta.getDatabaseProductVersion());
System.out.println("Driver: " + meta.getDriverName() + " " + meta.getDriverVersion());
System.out.println("Catalog: " + connection.getCatalog());
System.out.println("isValid(2): " + connection.isValid(2));
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT CONNECTION_ID(), CURRENT_USER()")) {
rs.next();
System.out.println("Server connection id: " + rs.getLong(1));
System.out.println("Authenticated as: " + rs.getString(2));
}
}
}
}
Chạy MySQL bằng Docker Compose rồi chạy chương trình (xem mục Chuẩn bị môi trường và dự án ở đầu bài để có docker-compose.yml và pom.xml): docker compose up -d rồi mvn -q compile exec:exec -Dmain.class=dev.canhdominich.jdbc.HelloConnection. Kết quả:
Connected to MySQL 8.4.9
Driver: MySQL Connector/J mysql-connector-j-26.7.0 (Revision: ...)
Catalog: shop
isValid(2): true
Server connection id: 15
Authenticated as: app@%
Có ba điều đáng dừng lại ở đoạn mã trên.
Không còn Class.forName. Từ JDBC 4.0, driver tự đăng ký qua cơ chế ServiceLoader khi nằm trên classpath, nên HelloConnection kết nối được mà không nạp driver thủ công (bài Giới thiệu JDBC đã chứng minh bằng chương trình DriverDiscovery). Dòng nạp driver thủ công trong nhiều tài liệu cũ là thừa.
Mật khẩu không nằm trong code. DbConfig đọc JDBC_URL, DB_USER, DB_PASSWORD từ biến môi trường, với giá trị mặc định chỉ dùng cho lab. Chuỗi "password" viết cứng trong ví dụ cũ sẽ lọt vào Git; một lần commit nhầm là mật khẩu đó nằm trong lịch sử mãi mãi.
isValid(2) là một phép thử thật. Theo đặc tả JDBC, driver phải kiểm chứng kết nối bằng một truy vấn hoặc cơ chế tương đương và chờ tối đa 2 giây, chứ không chỉ nhìn cờ trong bộ nhớ. Connection pool dựa vào cùng ý tưởng để biết kết nối nằm trong pool còn sống không.
Đọc URL kết nối
jdbc:mysql://<host>:<port>/<database>?<tùy chọn>=<giá trị>&...
| Phần | Vai trò | Sai ở đây thì gặp gì |
|---|---|---|
jdbc:mysql: | DriverManager dùng để chọn driver | No suitable driver found for jdbc:mysq://... |
| host và port | Nơi mở kết nối mạng | Communications link failure |
| database | Schema mặc định của phiên | Access denied to database, hoặc Unknown database |
| tùy chọn sau dấu hỏi | Điều chỉnh hành vi: SSL, múi giờ, thời gian chờ | Thường không lỗi, mà hành xử khác âm thầm |
Hàng cuối là hàng nguy hiểm nhất. Phần dưới đây dành cho nó.
Một lần kết nối đi qua bốn trạm
Cách đọc hình: hàng trên là con đường thành công từ trái sang phải; mỗi mũi tên đứt nét trỏ xuống lỗi xuất hiện khi trạm đó thất bại. Trạm đầu chỉ xem chuỗi URL, trạm hai là mạng, trạm ba là TLS và tài khoản, trạm bốn là quyền trên schema. Đọc thông điệp lỗi rồi tìm đúng trạm trước khi sửa bất cứ thứ gì: sai trạm là sửa nhầm chỗ.
Để thấy từng lỗi bằng mắt, bài dùng chương trình ConnectionErrors. Nó phá đúng một thành phần mỗi lần (mật khẩu, cổng, tên database với user thường, tên database với tài khoản quản trị root, tiền tố URL), rồi in lớp ngoại lệ, SQLState, mã lỗi, thông điệp và nguyên nhân gốc của lỗi nhận được:
src/main/java/dev/canhdominich/jdbc/ConnectionErrors.java
package dev.canhdominich.jdbc;
import java.sql.DriverManager;
import java.sql.SQLException;
public class ConnectionErrors {
public static void main(String[] args) {
DbConfig good = DbConfig.fromEnvironment();
report("Wrong password", good.withPassword("not-the-password"));
report("Wrong port", good.withUrl(good.url().replace(":3307", ":3999")));
report("Unknown database", good.withUrl(good.url().replace("/shop", "/no_such_db")));
String rootPassword = System.getenv().getOrDefault("DB_ROOT_PASSWORD", "root_pw");
report("Unknown database (administrator)", good.withUser("root").withPassword(rootPassword).withUrl(good.url().replace("/shop", "/no_such_db")));
report("Wrong URL prefix", good.withUrl(good.url().replace("jdbc:mysql:", "jdbc:mysq:")));
}
static void report(String label, DbConfig config) {
System.out.println("== " + label);
try (var ignored = DriverManager.getConnection(config.url(), config.user(), config.password())) {
System.out.println("connected (unexpected)");
} catch (SQLException e) {
System.out.println("class : " + e.getClass().getName());
System.out.println("SQLState : " + e.getSQLState());
System.out.println("errorCode: " + e.getErrorCode());
System.out.println("message : " + firstLine(e.getMessage()));
Throwable root = e;
while (root.getCause() != null) {
root = root.getCause();
}
System.out.println("root : " + root.getClass().getName() + ": " + firstLine(root.getMessage()));
}
}
private static String firstLine(String message) {
if (message == null) {
return "";
}
int newline = message.indexOf('\n');
return newline < 0 ? message : message.substring(0, newline);
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.ConnectionErrors. Kết quả đo được:
| Cách phá | Lớp ngoại lệ | SQLState | Mã lỗi | Thông điệp |
|---|---|---|---|---|
| Sai mật khẩu | SQLException | 28000 | 1045 | Access denied for user 'app'@'172.19.0.1' (using password: YES) |
| Sai cổng | CommunicationsException | 08S01 | 0 | Communications link failure, nguyên nhân gốc Connection refused |
| Sai tên database, user thường | SQLSyntaxErrorException | 42000 | 1044 | Access denied for user 'app'@'%' to database 'no_such_db' |
| Sai tên database, user quản trị | SQLSyntaxErrorException | 42000 | 1049 | Unknown database 'no_such_db' |
| Sai tiền tố URL | SQLException | 08001 | 0 | No suitable driver found for jdbc:mysq://localhost:3307/shop |
Ba điều rút ra mà bài cũ chưa nói.
Sai tên database không luôn cho Unknown database. Với user thường như app, MySQL từ chối trước khi tiết lộ database có tồn tại hay không, nên bạn nhận 1044 (Access denied to database). Chỉ tài khoản quản trị mới thấy 1049. Gặp 1044, đừng vội cấp quyền: hãy kiểm tra cả chính tả tên database.
Communications link failure là một nhóm lỗi, không phải một nguyên nhân. Phần "Thời gian chờ" ngay dưới đây cho thấy cùng lớp ngoại lệ này cho cổng sai, tên máy không tồn tại (UnknownHostException) và địa chỉ không định tuyến được (Network is unreachable, hoặc SocketTimeoutException: Connect timed out). Thông điệp bên ngoài giống hệt nhau; chỉ nguyên nhân gốc (getCause() lồng nhau) mới cho biết lỗi thật. Khi ghi log lỗi kết nối, luôn ghi cả chuỗi nguyên nhân.
Địa chỉ sau @ trong thông điệp là tài liệu chẩn đoán. 'app'@'172.19.0.1' cho bạn biết MySQL thấy ứng dụng đến từ địa chỉ nào. Trong Docker đó là địa chỉ cổng của mạng ảo, và tài khoản trong MySQL được định nghĩa theo cặp tên và máy: app@% và app@localhost là hai tài khoản khác nhau.
Thời gian chờ: đừng để kết nối treo vô hạn
Một máy chủ cơ sở dữ liệu tắt hẳn thường dễ xử lý hơn một máy chủ im lặng. Khi gói tin bị bỏ giữa đường, thời gian chờ do giá trị mặc định của driver và hệ điều hành quyết định, và bạn thường không biết con số đó. Chương trình NetworkFailures thử bốn tình huống mạng và đo thời gian tới lúc nhận lỗi: tên máy không tồn tại, cổng đóng, và hai lần kết nối tới địa chỉ 10.255.255.1 (không có máy nào trả lời), một lần đặt connectTimeout=2000 trong URL và một lần đặt DriverManager.setLoginTimeout(1):
src/main/java/dev/canhdominich/jdbc/NetworkFailures.java
package dev.canhdominich.jdbc;
import java.sql.DriverManager;
import java.sql.SQLException;
public class NetworkFailures {
public static void main(String[] args) {
DbConfig good = DbConfig.fromEnvironment();
report("Unknown host", "jdbc:mysql://no-such-host.invalid:3307/shop", good, 0);
report("Closed port", good.url().replace(":3307", ":3999"), good, 0);
report("Unroutable address, connectTimeout=2000", "jdbc:mysql://10.255.255.1:3307/shop?connectTimeout=2000", good, 0);
report("Unroutable address, loginTimeout=1 second", "jdbc:mysql://10.255.255.1:3307/shop", good, 1);
}
static void report(String label, String url, DbConfig credentials, int loginTimeoutSeconds) {
DriverManager.setLoginTimeout(loginTimeoutSeconds);
long start = System.nanoTime();
try (var ignored = DriverManager.getConnection(url, credentials.user(), credentials.password())) {
System.out.println(label + ": connected (unexpected)");
} catch (SQLException e) {
long ms = (System.nanoTime() - start) / 1_000_000;
Throwable root = e;
while (root.getCause() != null) {
root = root.getCause();
}
System.out.printf("%-44s %s SQLState=%s root=%s: %s after %d ms%n", label, e.getClass().getSimpleName(), e.getSQLState(),
root.getClass().getSimpleName(), root.getMessage(), ms);
} finally {
DriverManager.setLoginTimeout(0);
}
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.NetworkFailures. Một lần chạy cho kết quả:
Unknown host CommunicationsException SQLState=08S01 root=UnknownHostException: no-such-host.invalid: Temporary failure in name resolution after 249 ms
Closed port CommunicationsException SQLState=08S01 root=ConnectException: Connection refused after 9 ms
Unroutable address, connectTimeout=2000 CommunicationsException SQLState=08S01 root=SocketTimeoutException: Connect timed out after 2006 ms
Unroutable address, loginTimeout=1 second CommunicationsException SQLState=08S01 root=SocketTimeoutException: Connect timed out after 1006 ms
Cả bốn tình huống cùng lớp ngoại lệ CommunicationsException và cùng SQLState 08S01; chỉ nguyên nhân gốc khác nhau. Hai dòng cuối cho thấy giới hạn thời gian có tác dụng: lỗi trả về sau khoảng 2006 ms và 1006 ms. Một điểm đáng nhớ: với địa chỉ không ai trả lời, kết quả còn phụ thuộc mạng của bạn. Trong các lần chạy lặp lại trên máy tác giả, có lần dòng thứ ba báo Network is unreachable sau chỉ khoảng 30 đến 70 ms thay vì Connect timed out, vì hệ điều hành từ chối ngay khi chưa có đường tới địa chỉ đó. Vì vậy đừng viết test tự động dựa vào thời gian chờ của địa chỉ không tồn tại.
Bài không đo giá trị chờ mặc định của driver. Nhưng bài học đủ rõ: hãy đặt tường minh connectTimeout, để sự cố mạng thành một lỗi nhanh và có thể xử lý, thay vì để luồng của request treo theo mặc định của hệ điều hành.
Ba tùy chọn URL đáng sợ vì chúng không kêu
SSL: mặc định đã mã hóa, tham số cũ vẫn tắt được nó
Chương trình UrlOptions mở kết nối với sáu cách viết tùy chọn mã hóa, rồi hỏi MySQL xem phiên hiện tại có thực sự được mã hóa không bằng SHOW SESSION STATUS LIKE 'Ssl_cipher':
src/main/java/dev/canhdominich/jdbc/UrlOptions.java
package dev.canhdominich.jdbc;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class UrlOptions {
private static final String[] OPTIONS = {"", "useSSL=false", "sslMode=DISABLED", "sslMode=REQUIRED", "sslMode=VERIFY_CA", "useSSl=false"};
public static void main(String[] args) {
DbConfig config = DbConfig.fromEnvironment();
for (String option : OPTIONS) {
System.out.printf("%-20s %s%n", option.isEmpty() ? "(no option)" : option, cipher(config, option));
}
}
static String cipher(DbConfig config, String option) {
String url = option.isEmpty() ? config.url() : config.url() + "?" + option;
try (Connection connection = DriverManager.getConnection(url, config.user(), config.password());
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SHOW SESSION STATUS LIKE 'Ssl_cipher'")) {
rs.next();
String cipher = rs.getString(2);
return cipher.isEmpty() ? "no encryption" : cipher;
} catch (SQLException e) {
Throwable root = e;
while (root.getCause() != null) {
root = root.getCause();
}
return "FAILED " + root.getClass().getSimpleName();
}
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.UrlOptions:
(no option) TLS_AES_256_GCM_SHA384
useSSL=false no encryption
sslMode=DISABLED no encryption
sslMode=REQUIRED TLS_AES_256_GCM_SHA384
sslMode=VERIFY_CA FAILED CertPathValidatorException
useSSl=false TLS_AES_256_GCM_SHA384
Bảng tổng hợp:
| URL | Thuật toán mã hóa của phiên |
|---|---|
| không thêm tùy chọn | TLS_AES_256_GCM_SHA384 |
?useSSL=false (cách viết cũ) | rỗng: không mã hóa |
?sslMode=DISABLED | rỗng |
?sslMode=REQUIRED | TLS_AES_256_GCM_SHA384 |
?sslMode=VERIFY_CA | thất bại: Path does not chain with any of the trust anchors |
?useSSl=false (gõ sai chữ l) | TLS_AES_256_GCM_SHA384, tham số bị bỏ qua |
Cách đọc hình: mỗi hàng là một tùy chọn URL ở cột trái và điều MySQL báo về phiên ở cột phải. Hai hàng cho kết quả đã mã hóa là hàng không có tùy chọn và hàng gõ sai useSSl, vì tùy chọn gõ sai bị bỏ qua. Hai hàng tắt mã hóa là cách viết cũ useSSL=false và cách viết mới sslMode=DISABLED. Hàng cuối đòi kiểm tra chứng chỉ nên thất bại với chứng chỉ tự ký của lab.
Từ bảng ta có bốn kết luận:
- Kết nối mặc định đã dùng TLS. Dòng
useSSL=falsetrong nhiều hướng dẫn cũ tắt mã hóa; nếu bạn chép nó vào ứng dụng thật, dữ liệu và mật khẩu đi qua mạng ở dạng đọc được. - Tham số cũ
useSSLvẫn còn được chấp nhận, nên mã cũ chạy mà không báo gì. - Tên tùy chọn sai chữ hoa chữ thường (
useSSl) bị bỏ qua im lặng: không lỗi, không cảnh báo, và hành vi quay về mặc định. Đây là loại lỗi cấu hình khó thấy nhất, nênUrlOptionsở trên có hẳn một dòng cho nó. Hãy kiểm tra hành vi thật (như hỏiSsl_cipher) thay vì tin rằng tùy chọn đã có tác dụng. VERIFY_CAthất bại với MySQL của lab vì chứng chỉ do chính server tự tạo, không nằm trong kho tin cậy của JVM. Ở môi trường thật, bạn cấp chứng chỉ do tổ chức tin cậy ký, hoặc nạp chứng chỉ CA vào truststore, rồi dùngVERIFY_CA(hoặcVERIFY_IDENTITY, mức kiểm tra thêm tên máy chủ, theo tài liệu Connector/J; lab không chạy mức này).
Public Key Retrieval: lỗi chỉ xuất hiện khi cache còn lạnh
MySQL 8 dùng cơ chế xác thực caching_sha2_password, và nó giữ một bộ nhớ đệm xác thực trong máy chủ. Chương trình PublicKeyRetrieval thử ba lần kết nối với sslMode=DISABLED: không thêm tùy chọn nào, thêm allowPublicKeyRetrieval=true, rồi lại không thêm:
src/main/java/dev/canhdominich/jdbc/PublicKeyRetrieval.java
package dev.canhdominich.jdbc;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class PublicKeyRetrieval {
public static void main(String[] args) {
DbConfig config = DbConfig.fromEnvironment();
System.out.println("1. sslMode=DISABLED : " + attempt(config, "sslMode=DISABLED"));
System.out.println("2. sslMode=DISABLED&allowPublicKeyRetrieval=true: " + attempt(config, "sslMode=DISABLED&allowPublicKeyRetrieval=true"));
System.out.println("3. sslMode=DISABLED again : " + attempt(config, "sslMode=DISABLED"));
}
static String attempt(DbConfig config, String options) {
try (Connection ignored = DriverManager.getConnection(config.url() + "?" + options, config.user(), config.password())) {
return "connected";
} catch (SQLException e) {
return e.getClass().getSimpleName() + " SQLState=" + e.getSQLState() + " " + e.getMessage();
}
}
}
Để thấy lỗi, bộ nhớ đệm phải còn lạnh, tức ngay sau khi khởi động lại MySQL. Khởi động lại container, chờ healthy, rồi chạy:
docker compose restart mysql
mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.PublicKeyRetrieval
1. sslMode=DISABLED : SQLNonTransientConnectionException SQLState=08001 Public Key Retrieval is not allowed
2. sslMode=DISABLED&allowPublicKeyRetrieval=true: connected
3. sslMode=DISABLED again : connected
Tổng hợp:
| Tình huống | Kết quả |
|---|---|
Cache lạnh, sslMode=DISABLED | Thất bại: Public Key Retrieval is not allowed (SQLState 08001) |
Cache lạnh, thêm allowPublicKeyRetrieval=true | Thành công |
Cache ấm (đã có một lần đăng nhập thành công), sslMode=DISABLED | Thành công, không cần tùy chọn |
Hệ quả đáng sợ: ứng dụng chạy tốt ở máy bạn, qua một lần khởi động lại MySQL thì lỗi bất ngờ xuất hiện. Tùy chọn allowPublicKeyRetrieval=true cho phép client xin khóa công khai của server qua kênh chưa mã hóa, nên chỉ dùng cho môi trường phát triển. Cách đúng là giữ TLS bật để không cần tùy chọn đó.
Múi giờ: lỗi dữ liệu, không phải lỗi kết nối
Câu thần chú cũ serverTimezone=UTC thường được thêm "vì có lỗi múi giờ". Trên Connector/J hiện tại, khi JVM chạy ở múi giờ này còn MySQL ở UTC, kết nối mặc định không báo lỗi nào. Thay vào đó xảy ra một điều tệ hơn: dữ liệu lưu lệch. Chương trình TimezoneDemo lưu cùng một thời điểm, 03:00 UTC (epoch 1792119600), qua setTimestamp vào một bảng MySQL, rồi hỏi chính MySQL giờ hiển thị, thời điểm thật (UNIX_TIMESTAMP) và múi giờ của phiên. Nó làm vậy với năm cấu hình URL khác nhau:
src/main/java/dev/canhdominich/jdbc/TimezoneDemo.java
package dev.canhdominich.jdbc;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Instant;
public class TimezoneDemo {
private static final Instant MOMENT = Instant.parse("2026-10-16T03:00:00Z");
public static void main(String[] args) throws SQLException {
DbConfig config = DbConfig.fromEnvironment();
System.out.println("JVM time zone : " + java.util.TimeZone.getDefault().getID());
System.out.println("Stored moment : " + MOMENT + " (epoch " + MOMENT.getEpochSecond() + ")");
for (String option : new String[]{"default", "connectionTimeZone=UTC", "serverTimezone=UTC", "serverTimezone=Foo/Bar",
"connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true"}) {
try {
store(config, option);
} catch (SQLException e) {
Throwable root = e;
while (root.getCause() != null) {
root = root.getCause();
}
System.out.printf("%-24s FAILED %s: %s%n", option, root.getClass().getSimpleName(), root.getMessage());
}
}
}
static void store(DbConfig config, String option) throws SQLException {
String url = option.equals("default") ? config.url() : config.url() + "?" + option;
try (Connection connection = DriverManager.getConnection(url, config.user(), config.password());
Statement statement = connection.createStatement()) {
statement.execute("DROP TABLE IF EXISTS tz_demo");
statement.execute("CREATE TABLE tz_demo (id INT PRIMARY KEY, created_at TIMESTAMP NOT NULL)");
try (PreparedStatement insert = connection.prepareStatement("INSERT INTO tz_demo VALUES (1, ?)")) {
insert.setTimestamp(1, Timestamp.from(MOMENT));
insert.executeUpdate();
}
try (ResultSet rs = statement.executeQuery("SELECT CAST(created_at AS CHAR), UNIX_TIMESTAMP(created_at), @@session.time_zone FROM tz_demo")) {
rs.next();
long epoch = rs.getLong(2);
System.out.printf("%-24s server shows %s, epoch %d, offset from truth %+d h, session time_zone %s%n",
option, rs.getString(1), epoch, (epoch - MOMENT.getEpochSecond()) / 3600, rs.getString(3));
}
}
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.TimezoneDemo. Để đổi múi giờ của JVM, đặt biến môi trường trước lệnh, ví dụ JAVA_TOOL_OPTIONS="-Duser.timezone=America/New_York" mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.TimezoneDemo. Kết quả ở múi giờ Asia/Ho_Chi_Minh:
JVM time zone : Asia/Ho_Chi_Minh
Stored moment : 2026-10-16T03:00:00Z (epoch 1792119600)
default server shows 2026-10-16 10:00:00, epoch 1792144800, offset from truth +7 h, session time_zone SYSTEM
connectionTimeZone=UTC server shows 2026-10-16 03:00:00, epoch 1792119600, offset from truth +0 h, session time_zone SYSTEM
serverTimezone=UTC server shows 2026-10-16 03:00:00, epoch 1792119600, offset from truth +0 h, session time_zone SYSTEM
serverTimezone=Foo/Bar FAILED ZoneRulesException: Unknown time-zone ID: Foo/Bar
connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true server shows 2026-10-16 03:00:00, epoch 1792119600, offset from truth +0 h, session time_zone +00:00
Chạy lặp lại ở các múi giờ JVM khác nhau cho bảng sau:
| JVM chạy ở | Cách kết nối | Server hiển thị | Lệch so với thời điểm thật |
|---|---|---|---|
| Asia/Ho_Chi_Minh | mặc định | 2026-10-16 10:00:00 | +7 giờ |
| Asia/Ho_Chi_Minh | connectionTimeZone=UTC | 2026-10-16 03:00:00 | 0 |
| America/New_York | mặc định | 2026-10-15 23:00:00 | -4 giờ |
| America/New_York | connectionTimeZone=UTC | 2026-10-16 03:00:00 | 0 |
| Asia/Tokyo | mặc định | 2026-10-16 12:00:00 | +9 giờ |
| UTC | mặc định | 2026-10-16 03:00:00 | 0 |
Cách đọc hình: hàng trên là kết nối mặc định: JVM ở Hồ Chí Minh gửi giờ đồng hồ 10:00, MySQL đặt ở UTC lưu nguyên con số đó và thời điểm thật lệch bảy giờ, không có ngoại lệ nào. Hàng dưới đặt connectionTimeZone=UTC nên driver đổi sang UTC trước khi gửi và lưu đúng 03:00.
Khi không nói rõ, driver ghi giờ đồng hồ của JVM vào cột TIMESTAMP, còn server hiểu số đó theo múi giờ của phiên. Chính ứng dụng ấy đọc lại thì thấy đúng (lab đọc ngược ra đúng 03:00 UTC, vì driver đảo lại phép lệch đã làm lúc ghi), nhưng báo cáo SQL, ứng dụng khác hay một cụm máy ở múi giờ khác sẽ thấy giờ sai. Lỗi này nổ ra khi bạn chạy ứng dụng ở một máy chủ có múi giờ khác máy lập trình.
Cách làm đúng: đặt connectionTimeZone=UTC trong URL và lưu mọi thời điểm theo UTC. Tên cũ serverTimezone=UTC vẫn có tác dụng như vậy trong lab (lưu 03:00, epoch đúng), và nếu bạn viết một múi giờ không tồn tại như serverTimezone=Foo/Bar thì được ZoneRulesException: Unknown time-zone ID: Foo/Bar ngay lúc kết nối. Chọn connectionTimeZone để dùng đúng tên hiện hành, và thêm forceConnectionTimeZoneToSession=true nếu bạn cần chính phiên MySQL cũng chạy theo múi giờ đó (lab thấy @@session.time_zone đổi từ SYSTEM sang +00:00).
Connection pool: close() không đóng thật
Quay lại câu chuyện mở đầu. Mở một kết nối là một chuỗi: bắt tay TCP, thương lượng TLS, xác thực, khởi tạo phiên. Chương trình PoolVsDirect đo hai cách mượn 200 lần liên tiếp: measureDirect mở và đóng thật qua DriverManager, measurePooled mượn từ HikariCP. Mỗi lần mượn nó chạy SELECT CONNECTION_ID() để biết kết nối thật phía MySQL, và đếm số mã khác nhau:
src/main/java/dev/canhdominich/jdbc/PoolVsDirect.java
package dev.canhdominich.jdbc;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.LinkedHashSet;
import java.util.Set;
public class PoolVsDirect {
private static final int ROUNDS = 200;
public static void main(String[] args) throws SQLException {
DbConfig config = DbConfig.fromEnvironment();
measureDirect(config);
measurePooled(config);
}
static void measureDirect(DbConfig config) throws SQLException {
Set<Long> ids = new LinkedHashSet<>();
long start = System.nanoTime();
for (int i = 0; i < ROUNDS; i++) {
try (Connection connection = DriverManager.getConnection(config.url(), config.user(), config.password())) {
ids.add(connectionId(connection));
}
}
report("DriverManager", start, ids);
}
static void measurePooled(DbConfig config) throws SQLException {
HikariConfig hikari = new HikariConfig();
hikari.setJdbcUrl(config.url());
hikari.setUsername(config.user());
hikari.setPassword(config.password());
hikari.setMaximumPoolSize(5);
try (HikariDataSource dataSource = new HikariDataSource(hikari)) {
Set<Long> ids = new LinkedHashSet<>();
long start = System.nanoTime();
for (int i = 0; i < ROUNDS; i++) {
try (Connection connection = dataSource.getConnection()) {
ids.add(connectionId(connection));
}
}
report("HikariCP", start, ids);
}
}
static long connectionId(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT CONNECTION_ID()")) {
rs.next();
return rs.getLong(1);
}
}
private static void report(String label, long startNanos, Set<Long> ids) {
long millis = (System.nanoTime() - startNanos) / 1_000_000;
System.out.printf("%-14s %d borrows in %d ms, distinct server connections: %d%n", label, ROUNDS, millis, ids.size());
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.PoolVsDirect:
DriverManager 200 borrows in 5056 ms, distinct server connections: 200
HikariCP 200 borrows in 265 ms, distinct server connections: 1
Cách đọc hình: hàng trên là DriverManager: mỗi lần mượn là một kết nối thật được mở rồi đóng, MySQL thấy 200 kết nối và tổng thời gian 5056 ms. Hàng dưới là HikariCP: kết nối vật lý được giữ lại và cho mượn lại, MySQL chỉ thấy 1 kết nối và tổng thời gian 265 ms.
Trên cùng máy, mỗi lần mở trực tiếp tốn khoảng 25 ms (đã gồm TLS), còn pool tốn khoảng 1,3 ms mỗi lần (con số này đã tính cả lần mở kết nối đầu tiên). Quan trọng hơn là cột sau: MySQL thấy 200 kết nối khác nhau ở nhánh trực tiếp và chỉ 1 ở nhánh pool. Xem hoạt cảnh để thấy close() làm gì ở mỗi nhánh:
Đang tải Animation…
Thử và quan sát
Dự đoán trước rồi bấm Bước tiếp: ở bước ba, hai nhánh dùng cùng một dòng mã (close() trong try-with-resources) nhưng cho kết quả khác hẳn, vì Connection do pool trao ra chỉ là lớp bọc: gọi close() trên nó là trả kết nối vật lý về pool. Ở bước cuối, so sánh nơi áp lực dồn lại: MySQL ở nhánh trực tiếp, hàng đợi trong ứng dụng ở nhánh pool.
Cấu hình pool tối thiểu
HikariConfig hikari = new HikariConfig();
hikari.setJdbcUrl(config.url());
hikari.setUsername(config.user());
hikari.setPassword(config.password());
hikari.setPoolName("lab-pool");
hikari.setMaximumPoolSize(2);
hikari.setConnectionTimeout(1000);
hikari.setLeakDetectionThreshold(2000);
try (HikariDataSource dataSource = new HikariDataSource(hikari)) {
try (Connection connection = dataSource.getConnection()) {
connection.createStatement().execute("SELECT 1");
}
}
| Thuộc tính | Ý nghĩa | Giá trị trong lab |
|---|---|---|
maximumPoolSize | Số kết nối vật lý tối đa của pool | 2, để dễ làm cạn |
connectionTimeout | Thời gian tối đa một lần getConnection chờ kết nối rảnh | 1000 ms |
leakDetectionThreshold | Giữ kết nối quá lâu mà chưa trả thì ghi cảnh báo kèm stack trace | 2000 ms |
Giá trị cho môi trường thật khác với lab; phần "Quyết định thiết kế" bên dưới nói cách chọn.
Làm cạn pool và đọc lỗi
Chương trình PoolExhaustion cấu hình pool tối đa 2 kết nối, mượn đủ 2 kết nối và giữ chúng, rồi mượn lần thứ ba. Phần cuối chương trình trả kết nối để xem pool hồi phục:
src/main/java/dev/canhdominich/jdbc/PoolExhaustion.java
package dev.canhdominich.jdbc;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import java.sql.Connection;
import java.sql.SQLException;
public class PoolExhaustion {
public static void main(String[] args) throws Exception {
DbConfig config = DbConfig.fromEnvironment();
HikariConfig hikari = new HikariConfig();
hikari.setJdbcUrl(config.url());
hikari.setUsername(config.user());
hikari.setPassword(config.password());
hikari.setPoolName("lab-pool");
hikari.setMaximumPoolSize(2);
hikari.setMinimumIdle(2);
hikari.setConnectionTimeout(1000);
hikari.setLeakDetectionThreshold(2000);
try (HikariDataSource dataSource = new HikariDataSource(hikari)) {
HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
Connection first = dataSource.getConnection();
Connection second = dataSource.getConnection();
System.out.printf("borrowed 2: active=%d idle=%d waiting=%d%n", pool.getActiveConnections(), pool.getIdleConnections(), pool.getThreadsAwaitingConnection());
long start = System.nanoTime();
try (Connection third = dataSource.getConnection()) {
System.out.println("third connection obtained (unexpected)");
} catch (SQLException e) {
System.out.printf("third borrow failed after %d ms%n", (System.nanoTime() - start) / 1_000_000);
System.out.println("class : " + e.getClass().getName());
System.out.println("message: " + e.getMessage());
}
Thread.sleep(2500);
first.close();
second.close();
System.out.printf("after close: active=%d idle=%d%n", pool.getActiveConnections(), pool.getIdleConnections());
}
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.PoolExhaustion:
borrowed 2: active=2 idle=0 waiting=0
third borrow failed after 1061 ms
class : java.sql.SQLTransientConnectionException
message: lab-pool - Connection is not available, request timed out after 1001ms (total=2, active=2, idle=0, waiting=0)
after close: active=0 idle=2
Thông điệp tự mô tả khá đầy đủ: total=2, active=2, idle=0 nghĩa là cả hai kết nối đang bị mượn và không còn cái nào rảnh, waiting là số luồng đang xếp hàng. Khi gặp lỗi này ở môi trường thật, đừng tăng maximumPoolSize ngay. Hãy hỏi ai đang giữ kết nối: truy vấn chậm, transaction mở quá lâu hay một luồng quên close().
Trường hợp cuối chính là điều leakDetectionThreshold bắt được. Trong cùng lần chạy, log của pool ghi:
WARN com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@6497b078 on thread main, stack trace follows
java.lang.Exception: Apparent connection leak detected
at dev.canhdominich.jdbc.PoolExhaustion.main(PoolExhaustion.java:26)
Stack trace chỉ đúng dòng đã mượn kết nối mà giữ quá ngưỡng. Khi kết nối được trả lại, pool ghi thêm một dòng INFO "was returned to the pool (unleaked)". Đây là công cụ rẻ nhất để tìm chỗ quên close() hoặc giữ kết nối trong lúc chờ một dịch vụ khác.
Con số quyết định: pool nhân số bản sao
Pool là giới hạn cho một tiến trình. MySQL giới hạn cho tất cả. Chương trình MaxConnectionsDemo dùng tài khoản quản trị hạ max_connections xuống 8, rồi mở kết nối bằng tài khoản app cho tới khi bị từ chối, và cuối cùng khôi phục giá trị 151:
src/main/java/dev/canhdominich/jdbc/MaxConnectionsDemo.java
package dev.canhdominich.jdbc;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public class MaxConnectionsDemo {
public static void main(String[] args) throws SQLException {
DbConfig app = DbConfig.fromEnvironment();
String rootPassword = System.getenv().getOrDefault("DB_ROOT_PASSWORD", "root_pw");
try (Connection admin = DriverManager.getConnection(app.url(), "root", rootPassword);
Statement statement = admin.createStatement()) {
statement.execute("SET GLOBAL max_connections = 8");
List<Connection> opened = new ArrayList<>();
try {
for (int i = 1; i <= 20; i++) {
opened.add(DriverManager.getConnection(app.url(), app.user(), app.password()));
}
} catch (SQLException e) {
System.out.println("opened " + opened.size() + " connections, then " + e.getClass().getSimpleName()
+ " SQLState=" + e.getSQLState() + " errorCode=" + e.getErrorCode() + " message=" + e.getMessage());
} finally {
for (Connection connection : opened) {
connection.close();
}
statement.execute("SET GLOBAL max_connections = 151");
}
}
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.MaxConnectionsDemo:
opened 7 connections, then SQLNonTransientConnectionException SQLState=08004 errorCode=1040 message=Too many connections
Kết nối quản trị cũng chiếm một chỗ nên ứng dụng chỉ mở được 7 kết nối trước khi bị từ chối. Giá trị mặc định đo được trên MySQL 8.4.9 là 151. Với 10 bản sao ứng dụng, mỗi bản pool tối đa 20, tổng có thể lên tới 200 kết nối, đã vượt 151 dù mỗi pool "đủ nhỏ". Quy tắc kiểm tra: số bản sao nhân maximumPoolSize, cộng phần dành cho công cụ quản trị, công việc theo lịch và các dịch vụ khác dùng chung, phải thấp hơn max_connections.
Cách đọc hình: bên trái là các bản sao ứng dụng, mỗi bản có pool tối đa 20 kết nối. Chúng cộng lại thành tối đa 200 kết nối, vượt max_connections mặc định 151, và MySQL từ chối bằng lỗi 1040. Cách sửa là giảm kích thước pool hoặc nâng giới hạn một cách có tính toán, đồng thời chừa chỗ cho công cụ quản trị và dịch vụ khác dùng chung MySQL.
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
Dùng DriverManager hay DataSource | Ứng dụng chạy dài hạn dùng DataSource có pool; DriverManager chỉ cho công cụ chạy một lần như script di chuyển dữ liệu | Pool thêm một thành phần phải cấu hình và theo dõi, đổi lại mỗi lần mượn rẻ và số kết nối được kiểm soát |
maximumPoolSize lớn hay nhỏ | Bắt đầu nhỏ, đo, rồi tăng; xem trang About Pool Sizing của HikariCP | Pool lớn che đi truy vấn chậm và làm MySQL tranh chấp tài nguyên; pool quá nhỏ làm request xếp hàng |
connectionTimeout | Vài giây, ngắn hơn thời gian chờ của người dùng | Ngắn thì lỗi nhanh nhưng dễ báo lỗi khi tải tăng thoáng qua |
leakDetectionThreshold | Bật ở môi trường kiểm thử và staging; ở production đặt cao hơn thời gian truy vấn dài nhất bình thường | Quá thấp thì cảnh báo giả với truy vấn chậm hợp lệ |
| TLS | Giữ mặc định; production dùng chứng chỉ tin cậy và VERIFY_CA trở lên | Cần quản lý chứng chỉ; useSSL=false đơn giản hơn nhưng bỏ bảo vệ dữ liệu trên đường truyền |
| Múi giờ | Đặt connectionTimeZone=UTC và lưu thời điểm theo UTC | Phải chuyển đổi múi giờ khi hiển thị cho người dùng |
Playbook: gặp lỗi kết nối thì làm gì
| Triệu chứng | Kiểm tra đầu tiên | Nguyên nhân hay gặp | Cách xử lý |
|---|---|---|---|
No suitable driver found | Đọc kỹ tiền tố trong URL | Gõ sai jdbc:mysql:, hoặc thiếu Connector/J trên classpath | Sửa URL, kiểm tra dependency |
Communications link failure | Nguyên nhân gốc trong getCause() | Connection refused (sai cổng hoặc MySQL tắt); UnknownHostException (sai tên máy); Connect timed out (tường lửa, mạng) | Sửa theo từng nguyên nhân gốc; đặt connectTimeout |
Access denied for user (1045) | Tên user, mật khẩu, và địa chỉ sau @ | Sai mật khẩu, hoặc tài khoản chưa được tạo cho địa chỉ đó | Kiểm tra tài khoản trong bảng mysql.user theo cặp tên và máy |
Access denied ... to database (1044) | Chính tả tên database, quyền của user | Tên database sai, hoặc user chưa được cấp quyền trên schema | Sửa tên hoặc cấp quyền tối thiểu cần dùng |
Public Key Retrieval is not allowed | sslMode đang là gì | Tắt TLS cùng cache xác thực lạnh | Bật TLS; tạm dùng allowPublicKeyRetrieval=true chỉ ở máy phát triển |
Too many connections (08004, 1040) | SHOW STATUS LIKE 'Threads_connected', số bản sao nhân kích thước pool | Mở kết nối không qua pool, hoặc tổng pool vượt max_connections | Dùng pool, giảm maximumPoolSize, cân đối theo tổng số bản sao |
Connection is not available, request timed out | active, idle, waiting trong thông điệp | Truy vấn chậm, transaction dài, quên close() | Bật leakDetectionThreshold, tìm truy vấn chậm; tăng pool là bước cuối |
| Giờ lưu lệch giữa máy | Múi giờ JVM và @@system_time_zone của MySQL | Không đặt connectionTimeZone | Đặt tường minh, lưu theo UTC |
Lỗi thường gặp khi chạy lab
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Bind for 0.0.0.0:3307 failed: port is already allocated | Cổng 3307 đã bị chiếm | MYSQL_PORT=3308 docker compose up -d rồi export JDBC_URL=jdbc:mysql://localhost:3308/shop |
Access denied for user 'app' ngay lần chạy đầu | Đang kết nối nhầm vào một MySQL khác trên cùng cổng | Kiểm tra JDBC_URL, dùng cổng riêng của lab |
Could not find a valid Docker environment khi mvn test | Docker chưa chạy | Bật Docker Desktop hoặc sudo systemctl start docker |
Bài thực hành
Làm trong thư mục 02-jdbc/02-ket-noi-mysql. Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.
- Chạy kết nối đầu tiên.
docker compose up -d, chờhealthy, chạyHelloConnection. Tiêu chí: thấyConnected to MySQL 8.4vàisValid(2): true. - Phá từng trạm. Chạy
ConnectionErrorsvà đối chiếu bảng lỗi trong bài. Tiêu chí: bạn giải thích được vì sao userappnhận 1044 chứ không phải 1049 khi tên database sai. - Đo nhánh trực tiếp và nhánh pool. Chạy
PoolVsDirect. Tiêu chí: số kết nối khác nhau là 200 ở nhánhDriverManagervà 1 ở nhánhHikariCP; ghi lại hai thời gian trên máy của bạn. - Làm cạn pool. Chạy
PoolExhaustion, sau đó đổimaximumPoolSizethành 3 và mượn bốn lần. Tiêu chí: lần thứ tư lỗi và thông điệp cótotal=3, active=3; log có cảnh báoleak detection triggeredcho các kết nối bị giữ. - Gây lệch múi giờ. Chạy
TimezoneDemovớiJAVA_TOOL_OPTIONS="-Duser.timezone=America/New_York". Tiêu chí: nhánh mặc định lệch đúng -4 giờ, nhánhconnectionTimeZone=UTClệch 0. - Kiểm tra tự động (tùy chọn). Bộ test của dự án nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone,
mvn testchoTests run: 13, Failures: 0, Errors: 0. Đọc tên các phương thức test, mỗi tên là một kết luận của bài. - Mở rộng (tùy chọn). Viết thêm một test chứng minh
connectTimeout=2000làm lỗi kết nối tới10.255.255.1trả về trong khoảng 2 giây trên máy bạn, và dọn dẹp bằngdocker compose down.
Hiểu lầm và giới hạn
- "
close()luôn đóng kết nối." Chỉ đúng khi bạn nhậnConnectiontừDriverManager. Với pool,close()là trả về pool. - "Tăng pool là cách nhanh nhất chữa lỗi cạn pool." Thường nó chỉ dời sự cố sang MySQL, nơi giới hạn dùng chung cho mọi bản sao.
- "Không báo lỗi nghĩa là tùy chọn URL có tác dụng." Tham số gõ sai bị bỏ qua im lặng; hãy kiểm tra hành vi thật.
- "Lệch múi giờ sẽ báo lỗi." Trong lab không có lỗi nào, chỉ có dữ liệu sai.
- Số đo trong bài là kết quả của một máy và một lần chạy: độ trễ mạng, phần cứng và cấu hình MySQL của bạn sẽ cho con số khác. Điều cần kiểm tra là quan hệ giữa các con số.
- Bài chưa đo hành vi của MySQL phiên bản khác 8.4, của driver khác Connector/J, hay của pool khác HikariCP.
Tự kiểm tra
- Hàm
getConnectionthất bại vớiCommunications link failure. Bạn xem thông tin nào đầu tiên, và vì sao thông điệp bên ngoài chưa đủ? - Vì sao user thường nhận mã 1044 khi gõ sai tên database, còn quản trị viên nhận 1049?
- Ứng dụng chạy ở máy chủ UTC lưu giờ khác với máy lập trình ở Hồ Chí Minh dù cùng mã nguồn. Nguyên nhân khả dĩ nhất là gì và sửa thế nào?
- Vì sao
close()trong try-with-resources lại là đúng với pool nhưng nhìn bề ngoài giống đóng thật? - Có 6 bản sao ứng dụng,
maximumPoolSizelà 30, và MySQL dùngmax_connectionsmặc định. Chuyện gì có thể xảy ra, và bạn chỉnh gì?
Gợi ý đáp án. (1) Nguyên nhân gốc trong getCause() (Connection refused, UnknownHostException, Connect timed out), vì cùng một lớp ngoại lệ gom nhiều lỗi mạng. (2) MySQL không tiết lộ sự tồn tại của database cho tài khoản không có quyền trên nó. (3) Thiếu connectionTimeZone: driver ghi giờ đồng hồ của JVM; đặt connectionTimeZone=UTC và lưu theo UTC. (4) Connection do pool trao ra là lớp bọc; close() trên lớp bọc trả kết nối vật lý về pool. (5) Tổng tối đa 180 vượt 151, nên khi tải cao MySQL từ chối kết nối; giảm kích thước pool hoặc nâng max_connections có tính toán.
Điều cần nhớ
- Một lần
getConnectionđi qua bốn trạm: URL, mạng, xác thực, quyền trên schema. Lỗi cho biết trạm nào hỏng. - Mật khẩu và địa chỉ nằm ngoài code; tham số URL gõ sai không báo lỗi.
- Mặc định đã mã hóa bằng TLS;
useSSL=falselà bỏ mã hóa. - Đặt
connectionTimeZone=UTCđể dữ liệu thời gian không lệch giữa các máy. - Ứng dụng thật dùng connection pool;
close()là trả kết nối. Tổng pool của mọi bản sao phải nhỏ hơnmax_connections.
Đọc thêm
- MySQL Connector/J: tài liệu tham chiếu cấu hình, danh sách đầy đủ tùy chọn URL.
- HikariCP: README và các thuộc tính cấu hình
- HikariCP: About Pool Sizing
- MySQL 8.4 Reference Manual: Caching SHA-2 Pluggable Authentication
Bài tiếp theo thực hiện thao tác CRUD với Statement, PreparedStatement và ResultSet trên chính kết nối vừa dựng.