Spring JDBC và JdbcTemplate
Xem thửMột dịch vụ báo cáo nội bộ có một lớp ReportDao hơn bốn trăm dòng JDBC thuần: mỗi phương thức mở kết nối, tạo PreparedStatement, gán tham số, chạy truy vấn, duyệt ResultSet, và đóng mọi thứ trong khối finally. Có mười hai phương thức như vậy. Hai tháng trước, một người thêm phương thức thứ mười ba và quên đóng ResultSet trong một nhánh ít đi tới. Không có gì xảy ra trong tuần đầu. Rồi một nhánh lỗi hiếm được kích hoạt hàng nghìn lần mỗi ngày, các kết nối rò rỉ dần, và vào một buổi chiều pool cạn: mọi trang đều chờ kết nối cho tới khi hết thời gian. Nguyên nhân không phải SQL sai, mà là mã chép lại gần như giống nhau mười ba lần, và một lần người ta chép thiếu.
Spring JDBC (JdbcTemplate) là lời giải gọn nhất cho đúng loại vấn đề này: bạn vẫn viết SQL của mình, nhưng phần lặp lại (mượn và trả kết nối, đóng tài nguyên, dịch lỗi) do template làm. Bài này dựng một DAO nhỏ trên MySQL, và đo từng thứ nó làm: kết nối có được trả về pool không, lỗi được dịch thành loại nào, thời gian chờ truy vấn hoạt động ra sao, và giao dịch cần gì.
Sau bài này, bạn có thể
- Viết DAO bằng
JdbcTemplatethay cho JDBC thuần, với khóa tự sinh và số dòng ảnh hưởng. - Giải thích template giải phóng tài nguyên thế nào và kiểm chứng kết nối được trả về pool.
- Đọc các ngoại lệ
DataAccessExceptionquan trọng và chọn loại để bắt. - Đặt thời gian chờ truy vấn và biết ngoại lệ nó gây ra.
- Dùng
@Transactionalcho JDBC và hiểu điều xảy ra khi thiếu nó.
Chuẩn bị môi trường và dự án
Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Spring Boot 4.1.1, MySQL 8.4.9). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 10-spring-jdbc/01-jdbctemplate, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.
Yêu cầu môi trường
| Thành phần | Phiên bản đã kiểm chứng | Ghi chú |
|---|---|---|
| JDK | 17 trở lên | java -version |
| Maven | 3.9 trở lên | mvn -v |
| Spring Boot | 4.1.1 | khai báo ở parent trong pom.xml |
| MySQL | 8.4 (image mysql:8.4) | cổng 3307 của máy bạn |
| Docker | bất kỳ bản gần đây | MySQL cho demo và Testcontainers cho test |
Cấu trúc
01-jdbctemplate/
docker-compose.yml MySQL 8.4, database shop, user app (cổng 3307)
pom.xml parent Boot 4.1.1, starter jdbc, mysql-connector-j, test, testcontainers
src/main/java/dev/canhdominich/boot/jdbc/
JdbcApplication.java @SpringBootApplication
Student.java record sinh viên
StudentDao.java truy cập dữ liệu bằng JdbcTemplate
RegistrationService.java hai phương thức: có và không có @Transactional
JdbcTemplateDemo.java chạy mọi thí nghiệm
Khai báo thư viện: pom.xml
pom.xml
Dự án Maven dùng spring-boot-starter-parent 4.1.1 với spring-boot-starter-jdbc (Spring JDBC, HikariCP, giao dịch), mysql-connector-j (driver, phạm vi runtime) và spring-boot-starter-test; testcontainers-mysql chạy MySQL thật trong bộ test. Plugin exec-maven-plugin chạy JdbcTemplateDemo bằng mvn -q compile exec:exec.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>dev.canhdominich.javabackend</groupId>
<artifactId>spring-jdbctemplate</artifactId>
<version>1.0.0</version>
<name>Spring JDBC - JdbcTemplate</name>
<properties>
<java.version>17</java.version>
<main.class>dev.canhdominich.boot.jdbc.JdbcTemplateDemo</main.class>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<configuration>
<executable>java</executable>
<arguments>
<argument>-classpath</argument>
<classpath/>
<argument>${main.class}</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
</project>
Cấu hình Docker Compose
docker-compose.yml
Khởi động MySQL 8.4 bằng một lệnh: tạo database shop, tài khoản app với mật khẩu app_pw, và mở cổng 3307 của máy bạn (thay đổi được bằng biến MYSQL_PORT). Phần healthcheck cho docker compose ps biết khi nào MySQL đã sẵn sàng.
services:
mysql:
image: mysql:8.4
container_name: jdbc-lab-mysql
environment:
MYSQL_ROOT_PASSWORD: root_pw
MYSQL_DATABASE: shop
MYSQL_USER: app
MYSQL_PASSWORD: app_pw
ports:
- "${MYSQL_PORT:-3307}:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot_pw"]
interval: 3s
timeout: 3s
retries: 30
Khởi động và kiểm tra MySQL:
docker compose up -d
docker compose ps
Chờ cột STATUS hiện healthy. MySQL lắng nghe ở cổng 3307 của máy bạn; nếu bạn đang chạy một lab khác dùng cùng cổng, chạy docker compose down ở lab đó trước. Biến môi trường mặc định của chương trình: JDBC_URL=jdbc:mysql://localhost:3307/shop, DB_USER=app, DB_PASSWORD=app_pw.
Một DAO bằng JdbcTemplate
Dự án dùng spring-boot-starter-jdbc: nó kéo spring-jdbc (có JdbcTemplate), HikariCP (pool kết nối) và tự cấu hình một DataSource từ các thuộc tính spring.datasource.*. Bạn chỉ cần tiêm JdbcTemplate vào lớp của mình. Dữ liệu là một record:
src/main/java/dev/canhdominich/boot/jdbc/Student.java
package dev.canhdominich.boot.jdbc;
public record Student(Long id, String name, String email) {
}
StudentDao là nơi tập trung mọi thao tác. Mỗi phương thức chỉ chứa SQL và cách chuyển một dòng kết quả thành đối tượng, không có kết nối, try/finally hay close nào:
src/main/java/dev/canhdominich/boot/jdbc/StudentDao.java
package dev.canhdominich.boot.jdbc;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.List;
import java.util.Optional;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
@Repository
public class StudentDao {
private final JdbcTemplate jdbc;
public StudentDao(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public void createTable() {
jdbc.execute("drop table if exists student");
jdbc.execute("create table student (id bigint auto_increment primary key, name varchar(100) not null, email varchar(100) not null unique)");
}
public long insert(String name, String email) {
KeyHolder keys = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement("insert into student (name, email) values (?, ?)", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, name);
statement.setString(2, email);
return statement;
}, keys);
return keys.getKey().longValue();
}
public Student findById(long id) {
return jdbc.queryForObject("select id, name, email from student where id = ?", (rs, row) -> new Student(rs.getLong("id"), rs.getString("name"), rs.getString("email")), id);
}
public Optional<Student> findByEmail(String email) {
try {
return Optional.of(jdbc.queryForObject("select id, name, email from student where email = ?", (rs, row) -> new Student(rs.getLong("id"), rs.getString("name"), rs.getString("email")), email));
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
public List<Student> findAll() {
return jdbc.query("select id, name, email from student order by id", (rs, row) -> new Student(rs.getLong("id"), rs.getString("name"), rs.getString("email")));
}
public int rename(long id, String name) {
return jdbc.update("update student set name = ? where id = ?", name, id);
}
public int count() {
return jdbc.queryForObject("select count(*) from student", Integer.class);
}
}
Các điểm đáng nhìn kỹ:
executechạy câu lệnh DDL (xóa và tạo bảngstudent).updatevớiKeyHolder. Để lấy khóa tự sinh,insertđưa choupdatemột hàm tạoPreparedStatement(với cờRETURN_GENERATED_KEYS) và mộtGeneratedKeyHoldernhận khóa.updatetrả về số dòng ảnh hưởng, còn khóa nằm trongkeys.queryForObjectvới lambda. Lambda(rs, row) -> new Student(...)là đoạn duy nhất bạn viết cho việc duyệtResultSet: nó nhận một dòng và trả về một đối tượng; template tự lặp qua các dòng và tự đóngResultSet.querytrảList,queryForObjecttrả một giá trị, cònupdatecho mọi lệnh ghi (insert,update,delete).
JdbcApplication là lớp ứng dụng như các bài Boot trước:
src/main/java/dev/canhdominich/boot/jdbc/JdbcApplication.java
package dev.canhdominich.boot.jdbc;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JdbcApplication {
public static void main(String[] args) {
SpringApplication.run(JdbcApplication.class, args);
}
}
RegistrationService có hai phương thức giống hệt nhau: chèn một sinh viên rồi ném lỗi. Chỉ khác là phương thức thứ hai có @Transactional. Nó dùng để đo giao dịch ở phần cuối:
src/main/java/dev/canhdominich/boot/jdbc/RegistrationService.java
package dev.canhdominich.boot.jdbc;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class RegistrationService {
private final StudentDao dao;
public RegistrationService(StudentDao dao) {
this.dao = dao;
}
public void registerTwoWithoutTransaction(String first, String second) {
dao.insert(first, first + "@example.com");
throw new IllegalStateException("failure after the first insert");
}
@Transactional
public void registerTwoInTransaction(String first, String second) {
dao.insert(first, first + "@example.com");
throw new IllegalStateException("failure after the first insert");
}
}
JdbcTemplateDemo khởi động ứng dụng (không có web), nối vào MySQL ở cổng 3307 (hoặc theo JDBC_URL, DB_USER, DB_PASSWORD), và chạy các thí nghiệm. Với các lỗi, nó in tên ngoại lệ do Spring ném, tên nguyên nhân gốc và phần đầu của thông báo:
src/main/java/dev/canhdominich/boot/jdbc/JdbcTemplateDemo.java
package dev.canhdominich.boot.jdbc;
import com.zaxxer.hikari.HikariDataSource;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
public class JdbcTemplateDemo {
public static void main(String[] args) {
run(env("JDBC_URL", "jdbc:mysql://localhost:3307/shop"), env("DB_USER", "app"), env("DB_PASSWORD", "app_pw"));
}
public static List<String> run(String url, String user, String password) {
Map<String, Object> properties = new HashMap<>();
properties.put("spring.datasource.url", url);
properties.put("spring.datasource.username", user);
properties.put("spring.datasource.password", password);
properties.put("spring.main.web-application-type", "none");
properties.put("spring.main.banner-mode", "off");
properties.put("logging.level.root", "OFF");
SpringApplication application = new SpringApplication(JdbcApplication.class);
application.setDefaultProperties(properties);
List<String> lines = new ArrayList<>();
try (ConfigurableApplicationContext context = application.run()) {
StudentDao dao = context.getBean(StudentDao.class);
JdbcTemplate jdbc = context.getBean(JdbcTemplate.class);
RegistrationService registration = context.getBean(RegistrationService.class);
HikariDataSource pool = (HikariDataSource) context.getBean(DataSource.class);
dao.createTable();
lines.add("-- writing and reading");
long an = dao.insert("An", "an@example.com");
long binh = dao.insert("Binh", "binh@example.com");
lines.add("generated keys: " + an + ", " + binh);
lines.add("findById(" + an + ") -> " + dao.findById(an));
lines.add("findAll -> " + dao.findAll().size() + " students");
lines.add("rename existing id -> rows updated " + dao.rename(an, "An Nguyen"));
lines.add("rename missing id -> rows updated " + dao.rename(9999, "Nobody"));
lines.add("count -> " + dao.count());
lines.add("-- connections: borrowed and returned for you");
lines.add("active connections after the calls above: " + pool.getHikariPoolMXBean().getActiveConnections());
lines.add("-- exceptions with a clear meaning");
lines.add("queryForObject, no row -> " + describe(() -> dao.findById(9999)));
lines.add("findByEmail wrapper for the same case -> " + dao.findByEmail("nobody@example.com"));
lines.add("queryForObject, two rows -> " + describe(() -> jdbc.queryForObject("select name from student where name like ?", String.class, "%n%")));
lines.add("duplicate email -> " + describe(() -> dao.insert("Another", "an@example.com")));
lines.add("bad SQL -> " + describe(() -> jdbc.queryForObject("select nope from student", Integer.class)));
jdbc.setQueryTimeout(1);
lines.add("query timeout of 1 s on select sleep(3) -> " + describe(() -> jdbc.queryForObject("select sleep(3)", Integer.class)));
jdbc.setQueryTimeout(-1);
lines.add("-- transactions");
int before = dao.count();
lines.add("without @Transactional, failure after one insert: " + describe(() -> registration.registerTwoWithoutTransaction("c1", "c2")) + "; rows added " + (dao.count() - before));
int middle = dao.count();
lines.add("with @Transactional, same failure: " + describe(() -> registration.registerTwoInTransaction("d1", "d2")) + "; rows added " + (dao.count() - middle));
lines.add("active connections at the end: " + pool.getHikariPoolMXBean().getActiveConnections());
}
lines.forEach(System.out::println);
return lines;
}
private static String describe(Runnable action) {
try {
action.run();
return "no exception";
} catch (RuntimeException e) {
Throwable root = e;
while (root.getCause() != null) {
root = root.getCause();
}
String message = e.getMessage() == null ? "" : e.getMessage().split("\n")[0];
if (message.length() > 110) {
message = message.substring(0, 110) + "...";
}
return e.getClass().getSimpleName() + " (root cause " + root.getClass().getSimpleName() + ")" + (message.isEmpty() ? "" : ": " + message);
}
}
private static String env(String name, String fallback) {
String value = System.getenv(name);
return value == null || value.isBlank() ? fallback : value;
}
}
Chạy MySQL (docker compose up -d) rồi mvn -q compile exec:exec. Kết quả (đã bỏ log ở stderr):
-- writing and reading
generated keys: 1, 2
findById(1) -> Student[id=1, name=An, email=an@example.com]
findAll -> 2 students
rename existing id -> rows updated 1
rename missing id -> rows updated 0
count -> 2
-- connections: borrowed and returned for you
active connections after the calls above: 0
-- exceptions with a clear meaning
queryForObject, no row -> EmptyResultDataAccessException (root cause EmptyResultDataAccessException): Incorrect result size: expected 1, actual 0
findByEmail wrapper for the same case -> Optional.empty
queryForObject, two rows -> IncorrectResultSizeDataAccessException (root cause IncorrectResultSizeDataAccessException): Incorrect result size: expected 1, actual 2
duplicate email -> DuplicateKeyException (root cause SQLIntegrityConstraintViolationException): PreparedStatementCallback; Duplicate entry 'an@example.com' for key 'student.email'
bad SQL -> BadSqlGrammarException (root cause SQLSyntaxErrorException): StatementCallback; bad SQL grammar [select nope from student]
query timeout of 1 s on select sleep(3) -> QueryTimeoutException (root cause MySQLTimeoutException): StatementCallback; SQL [select sleep(3)]; Statement cancelled due to timeout or client request
-- transactions
without @Transactional, failure after one insert: IllegalStateException (root cause IllegalStateException): failure after the first insert; rows added 1
with @Transactional, same failure: IllegalStateException (root cause IllegalStateException): failure after the first insert; rows added 0
active connections at the end: 0
Việc template làm hộ bạn
Hai dòng active connections đo điều quan trọng nhất của đầu bài: sau khi chạy hơn chục lời gọi (chèn, đọc, sửa, đếm, kể cả các lời gọi bị lỗi), pool HikariCP báo 0 kết nối đang được giữ. Mỗi lời gọi mượn một kết nối, và trả nó lại dù thành công hay lỗi, nhờ try/finally nằm trong template thay vì trong mã bạn. Lỗi "quên đóng" ở câu chuyện đầu bài không thể xảy ra với mã bạn viết, vì không có dòng close nào để quên.
Cách đọc hình: chuỗi từ trái sang phải rồi xuống hàng dưới là các việc template làm quanh SQL của bạn: mượn kết nối, chuẩn bị và gán tham số, chạy và ánh xạ dòng, đóng tài nguyên, trả kết nối (đo: 0 kết nối đang giữ), dịch ngoại lệ. Bạn chỉ viết SQL và lambda ánh xạ dòng.
Ngoại lệ có nghĩa
SQLException của JDBC là một ngoại lệ kiểm tra chung chung phải đọc mã lỗi mới biết chuyện gì. Template bắt nó và ném một ngoại lệ không kiểm tra thuộc họ DataAccessException, có loại cụ thể:
- Không có dòng nào từ
queryForObject:EmptyResultDataAccessException, thông báoIncorrect result size: expected 1, actual 0. Đó là lý dofindByEmailbọc lời gọi và trảOptional.empty(): "không tìm thấy" là kết quả bình thường, không phải lỗi. - Hai dòng từ
queryForObject:IncorrectResultSizeDataAccessException(expected 1, actual 2): truy vấn của bạn không xác định một dòng duy nhất. - Email trùng:
DuplicateKeyException, nguyên nhân gốcSQLIntegrityConstraintViolationExceptionvới thông báoDuplicate entry 'an@example.com' for key 'student.email'. Mã của bạn bắtDuplicateKeyExceptionđể trả lỗi 409, không phải đọc mã lỗi 1062 của MySQL. - SQL sai:
BadSqlGrammarException, kèm câu SQL trong thông báo: có ích khi gỡ lỗi. - Hết thời gian chờ:
QueryTimeoutException, xem dưới đây.
Chúng không kiểm tra, nên không phải khai báo throws; và vì cùng là DataAccessException nên một tầng trên có thể bắt chung mà không phụ thuộc MySQL.
Cách đọc hình: mỗi ô là một tình huống đã chạy và loại ngoại lệ nhận được. Nguyên nhân gốc của JDBC vẫn nằm trong chuỗi getCause() để ghi log.
Thời gian chờ truy vấn
jdbc.setQueryTimeout(1) đặt giới hạn một giây cho mọi câu lệnh. Câu select sleep(3) chạy lâu hơn nên bị hủy: kết quả là QueryTimeoutException (nguyên nhân gốc MySQLTimeoutException, thông báo Statement cancelled due to timeout or client request). Nếu không đặt, một truy vấn chậm sẽ giữ kết nối và luồng cho tới khi xong, và vài truy vấn chậm có thể cạn pool. Giới hạn nên là con số có chủ đích cho từng loại truy vấn (báo cáo khác thao tác tức thời), và hãy xử lý QueryTimeoutException một cách rõ ràng thay vì để nó thành lỗi 500 không giải thích.
Giao dịch: JdbcTemplate không tự có
Hai dòng cuối kết quả là điểm cần nhớ nhất của bài. registerTwoWithoutTransaction chèn một sinh viên rồi ném IllegalStateException; registerTwoInTransaction làm y hệt nhưng có @Transactional:
- Không có
@Transactional:rows added 1. Mỗi lời gọiupdatecủa template tự mượn kết nối, chạy ở chế độ tự commit và trả kết nối. Dòng thứ nhất đã được lưu vĩnh viễn trước khi lỗi xảy ra. Một thao tác nghiệp vụ gồm nhiều câu lệnh thành "xong một nửa". - Có
@Transactional:rows added 0. Spring mở một giao dịch cho phương thức, vàJdbcTemplatetự nhận ra giao dịch đó nên dùng chính kết nối của nó. Ngoại lệ không kiểm tra ra khỏi phương thức làm giao dịch rollback.
Đang tải Animation…
Cách đọc hình: hàng trên: không có @Transactional, mỗi câu lệnh tự commit nên dòng thứ nhất còn lại (1). Hàng dưới: có @Transactional, lỗi làm rollback tất cả (0).
Mọi điều bạn đã học về giao dịch ở bài Spring Data JPA áp dụng nguyên vẹn: ngoại lệ kiểm tra mặc định commit, gọi bằng this bỏ qua proxy, REQUIRES_NEW để tách giao dịch. Khác biệt chỉ là bên dưới không có Hibernate: JdbcTemplate đưa câu lệnh thẳng xuống MySQL, nên thay đổi lộ ra ngay ở cơ sở dữ liệu (không có "flush lúc commit" và dirty checking).
| Tiêu chí | JDBC thuần (chương 2) | JdbcTemplate | Spring Data JPA |
|---|---|---|---|
| SQL | Bạn viết | Bạn viết | Sinh ra hoặc @Query |
| Mượn, trả kết nối | Bạn viết | Template | Spring |
| Ánh xạ dòng sang đối tượng | Bạn viết vòng lặp | Một lambda hoặc RowMapper | Tự động |
| Lỗi | SQLException chung | DataAccessException có loại | Cùng họ ngoại lệ |
| Giao dịch | Bạn viết commit/rollback | @Transactional | @Transactional |
| Dùng khi | Hiếm khi | Cần kiểm soát SQL, báo cáo, batch | CRUD và quan hệ thông thường |
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
| Truy cập dữ liệu | JdbcTemplate khi cần kiểm soát SQL | Tự viết SQL và ánh xạ, không có quan hệ tự động |
| Thiếu một dòng | Bọc EmptyResultDataAccessException thành Optional | Một lớp bọc cho mỗi truy vấn một dòng |
| Thời gian chờ | Đặt cho từng nhóm truy vấn | Phải chọn con số; quá thấp gây lỗi giả |
| Giao dịch | @Transactional trên phương thức dịch vụ | Phải nhớ quy tắc rollback và bẫy this |
| Bắt lỗi | DataAccessException có loại | Không phụ thuộc MySQL, nhưng mất mã lỗi gốc nếu không đọc nguyên nhân |
Playbook: gặp sự cố Spring JDBC 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ý |
|---|---|---|---|
| Pool cạn, trang chờ kết nối | Số kết nối đang giữ | Truy vấn chậm, hoặc mã tự mở kết nối không đóng | Đặt thời gian chờ; dùng template thay vì tự mượn kết nối |
EmptyResultDataAccessException | Truy vấn queryForObject | Không có dòng khớp | Bọc thành Optional hoặc dùng query và lấy phần tử đầu |
IncorrectResultSizeDataAccessException | Điều kiện truy vấn | Điều kiện không duy nhất | Thêm điều kiện hoặc ràng buộc unique |
DuplicateKeyException | Ràng buộc nào | Chèn trùng khóa duy nhất | Kiểm tra trước, hoặc bắt và trả 409 |
| Dữ liệu nửa vời sau lỗi | Có @Transactional không | Nhiều lệnh, không có giao dịch | Thêm @Transactional ở phương thức dịch vụ |
QueryTimeoutException | Truy vấn nào, chạy bao lâu | Giới hạn quá thấp hoặc truy vấn thiếu chỉ mục | Tối ưu truy vấn hoặc nới giới hạn có chủ đích |
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 | docker compose down ở lab trước, hoặc MYSQL_PORT=3308 docker compose up -d rồi export JDBC_URL=jdbc:mysql://localhost:3308/shop |
Dòng query timeout không thấy QueryTimeoutException | Máy chủ trả kết quả nhanh hơn 1 giây | select sleep(3) luôn chậm hơn 1 giây trên MySQL; kiểm tra bạn dùng đúng MySQL |
Bài thực hành
Làm trong dự án của bài (thư mục 10-spring-jdbc/01-jdbctemplate). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.
- Chạy thí nghiệm.
docker compose up -drồimvn -q compile exec:exec. Tiêu chí: mọi dòng khớp bài, hai dòngactive connectionsđều là 0. - Thêm một phương thức. Thêm vào
StudentDaophương thứcdelete(long id)trả số dòng ảnh hưởng và gọi nó từJdbcTemplateDemovới một id có và một id không có. Tiêu chí: 1 rồi 0. - Bắt lỗi trùng. Trong
JdbcTemplateDemo, bắtDuplicateKeyExceptionkhi chèn email trùng và ingetMostSpecificCause().getMessage(). Tiêu chí: thông báo cóDuplicate entry 'an@example.com' for key 'student.email'. - Sửa dữ liệu nửa vời. Chép
registerTwoWithoutTransactionthành một phương thức mới có@Transactionalvà gọi nó. Tiêu chí:rows added 0. - Thử gọi bằng this. Thêm vào
RegistrationServicemột phương thức không annotation gọithis.registerTwoInTransaction(...)và gọi phương thức mới từ demo. Tiêu chí:rows added 1(không phải 0); giải thích vì sao bằng sơ đồ proxy của bài trước. - Chạy bộ test (tùy chọn). Bộ test nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone,
mvn testchoTests run: 4, Failures: 0, Errors: 0(cần Docker). Dọn dẹp bằngdocker compose down -v.
Hiểu lầm và giới hạn
- "
JdbcTemplatetự có giao dịch." Mặc định mỗi lệnh tự commit; cần@Transactional. - "JDBC với Spring chậm hơn JDBC thuần." Nó làm cùng việc; điều khác là bạn không còn mã lặp dễ sai.
- "
queryForObjecttrảnullkhi không có dòng." Nó némEmptyResultDataAccessException. - Số đo thuộc về Spring Boot 4.1.1 (Spring JDBC 7.0.9, HikariCP do Boot quản lý) và MySQL 8.4; số kết nối đo ở thời điểm chạy trong một tiến trình. Bài chưa nói về
RowMapperdùng chung, tham số có tên, batch vàJdbcClient(bài sau), cấu hình pool, haySimpleJdbcInsert.
Tự kiểm tra
- Những việc nào
JdbcTemplatelàm hộ bạn so với JDBC thuần? queryForObjectkhông có dòng nào thì xảy ra điều gì, và xử lý thế nào?- Vì sao bắt
DuplicateKeyExceptiontốt hơn đọc mã lỗi của MySQL? - Không có
@Transactional, một phương thức chèn hai dòng và lỗi sau dòng đầu để lại gì? setQueryTimeout(1)với một truy vấn chạy ba giây gây lỗi gì?
Gợi ý đáp án. (1) Mượn và trả kết nối, chuẩn bị câu lệnh, đóng tài nguyên, duyệt ResultSet (qua lambda), dịch lỗi. (2) EmptyResultDataAccessException; bọc thành Optional hoặc dùng query. (3) Một loại cụ thể, không phụ thuộc cơ sở dữ liệu, thay vì một SQLException chung. (4) Dòng đầu vẫn được lưu (tự commit): dữ liệu nửa vời. (5) QueryTimeoutException.
Điều cần nhớ
JdbcTemplategiữ SQL ở tay bạn và lo phần lặp lại: kết nối, tài nguyên, dịch lỗi.- Đo được 0 kết nối đang giữ sau các lời gọi, kể cả khi lỗi.
- Bắt
DataAccessExceptioncó loại (DuplicateKeyException,EmptyResultDataAccessException, ...). - Đặt thời gian chờ truy vấn có chủ đích.
- Giao dịch không tự có: dùng
@Transactional, với đúng các quy tắc đã học.
Đọc thêm
- Spring Framework Reference: Data Access with JDBC
- Spring Framework Reference: Using JdbcTemplate
- Spring Framework Reference: Declarative Transaction Management
Bài tiếp theo hoàn thiện chương: ánh xạ dòng sang đối tượng (RowMapper), tham số có tên, và ghi hàng loạt bằng batch.