Giới thiệu Spring Data JPA và JpaRepository
Xem thửMột nhóm có lớp truy cập dữ liệu hơn hai nghìn dòng JDBC và Hibernate viết tay, với từng DAO một: StudentDao, CourseDao, OrderDao, mỗi cái cùng các phương thức findById, save, delete, findAll chép nhau. Mỗi thay đổi nhỏ (thêm một cột) kéo theo sửa bảy chỗ, và mỗi lần ai đó quên đóng một Session thì lại có một rò rỉ kết nối. Khi nhóm chuyển sang Spring Data JPA, các DAO biến thành các interface rỗng và mã giảm đi một nửa. Nhưng ba tuần sau, một báo cáo tới: trang quản trị chậm đột ngột. Nguyên nhân là một lời gọi save trong vòng lặp mà mọi người cho là "đơn giản": mỗi lời gọi gửi hai câu SQL thay vì một, và người dùng đang lưu năm nghìn dòng.
Spring Data JPA làm biến mất phần lớn mã lặp nhưng không làm biến mất SQL. Bài này mở nó ra: bạn sẽ chạy một repository trên MySQL thật, ghi lại từng câu SQL mà mỗi phương thức gửi, và học cách đọc chúng để biết save làm gì, đọc tốn bao nhiêu, và các thao tác hàng loạt có thật sự là hàng loạt không.
Sau bài này, bạn có thể
- Giải thích repository là gì (một interface cộng một proxy) và nó dựa trên Hibernate bạn đã học.
- Dự đoán SQL của
savevới thực thể mới, thực thể tách rời và thực thể đã được quản lý. - So chi phí của
findById,existsById,countvàgetReferenceById. - Viết truy vấn dẫn xuất đơn giản và biết nó sinh
wherethế nào. - Phân biệt
deleteByIdvớideleteAllInBatch, và hiểu giới hạn củasaveAll. - Bắt lỗi trùng khóa bằng đúng loại ngoại lệ của Spring.
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 09-spring-data-jpa/01-jparepository, 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; quản lý Hibernate và Spring Data |
| 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-jparepository/
docker-compose.yml MySQL 8.4, database shop, user app (cổng 3307)
pom.xml parent Boot 4.1.1, starter data-jpa, mysql-connector-j, test, testcontainers
src/main/java/dev/canhdominich/boot/jpa/
JpaApplication.java @SpringBootApplication
Level.java enum BASIC, ADVANCED
Student.java entity: id tự tăng, tên, email duy nhất, level
StudentRepository.java interface JpaRepository có hai truy vấn dẫn xuất
SqlRecorder.java StatementInspector ghi lại mọi câu SQL
RepositoryDemo.java chạy các thí nghiệm và in SQL
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-data-jpa (Spring Data JPA, Hibernate, HikariCP), 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 RepositoryDemo 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>jpa-repository</artifactId>
<version>1.0.0</version>
<name>Spring Data JPA - JpaRepository</name>
<properties>
<java.version>17</java.version>
<main.class>dev.canhdominich.boot.jpa.RepositoryDemo</main.class>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</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 repository chỉ là một interface
Thực thể của bài là Student: một id tự tăng, tên, email duy nhất và một cấp độ. Các annotation (@Entity, @Id, @GeneratedValue, @Column(unique = true), @Enumerated) là những thứ bạn đã học ở chương Hibernate:
src/main/java/dev/canhdominich/boot/jpa/Student.java
package dev.canhdominich.boot.jpa;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Column(unique = true)
private String email;
@Enumerated(EnumType.STRING)
private Level level;
protected Student() {
}
public Student(String name, String email, Level level) {
this.name = name;
this.email = email;
this.level = level;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public Level getLevel() {
return level;
}
}
Level là một enum lưu dạng chuỗi (STRING) như bài ánh xạ đã khuyên:
src/main/java/dev/canhdominich/boot/jpa/Level.java
package dev.canhdominich.boot.jpa;
public enum Level {
BASIC,
ADVANCED
}
Phần mới nằm ở StudentRepository. Đó là một interface không có thân phương thức nào, kế thừa JpaRepository<Student, Long> (kiểu thực thể và kiểu khóa chính). Hai phương thức khai báo thêm không có cài đặt: Spring Data đọc tên của chúng (findByEmail, findByLevel) và sinh truy vấn tương ứng:
src/main/java/dev/canhdominich/boot/jpa/StudentRepository.java
package dev.canhdominich.boot.jpa;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StudentRepository extends JpaRepository<Student, Long> {
Optional<Student> findByEmail(String email);
List<Student> findByLevel(Level level);
}
JpaApplication là lớp ứng dụng như các bài Boot trước. Nhờ starter spring-boot-starter-data-jpa, Boot tự cấu hình Hibernate, DataSource (HikariCP) và quét các interface repository:
src/main/java/dev/canhdominich/boot/jpa/JpaApplication.java
package dev.canhdominich.boot.jpa;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JpaApplication {
public static void main(String[] args) {
SpringApplication.run(JpaApplication.class, args);
}
}
Chương trình đo cần ghi lại SQL. SqlRecorder là một StatementInspector của Hibernate, giống các bài trước, lưu mọi câu SQL trước khi gửi xuống MySQL:
src/main/java/dev/canhdominich/boot/jpa/SqlRecorder.java
package dev.canhdominich.boot.jpa;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.hibernate.resource.jdbc.spi.StatementInspector;
public class SqlRecorder implements StatementInspector {
private static final List<String> STATEMENTS = Collections.synchronizedList(new ArrayList<>());
@Override
public String inspect(String sql) {
STATEMENTS.add(sql.replaceAll("\\s+", " ").trim());
return sql;
}
public static void clear() {
STATEMENTS.clear();
}
public static List<String> statements() {
synchronized (STATEMENTS) {
return new ArrayList<>(STATEMENTS);
}
}
}
RepositoryDemo khởi động ứng dụng (không có web), nối vào MySQL ở cổng 3307 (hoặc theo biến môi trường JDBC_URL, DB_USER, DB_PASSWORD), để Hibernate tạo bảng student (ddl-auto=create-drop) và chạy lần lượt các thao tác, in số câu SQL và nội dung từng câu:
src/main/java/dev/canhdominich/boot/jpa/RepositoryDemo.java
package dev.canhdominich.boot.jpa;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.transaction.support.TransactionTemplate;
public class RepositoryDemo {
public static void main(String[] args) {
run(env("JDBC_URL", "jdbc:mysql://localhost:3307/shop"), env("DB_USER", "app"), env("DB_PASSWORD", "app_pw")).forEach(System.out::println);
}
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.jpa.hibernate.ddl-auto", "create-drop");
properties.put("spring.jpa.properties.hibernate.session_factory.statement_inspector", SqlRecorder.class.getName());
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(JpaApplication.class);
application.setDefaultProperties(properties);
List<String> lines = new ArrayList<>();
try (ConfigurableApplicationContext context = application.run()) {
StudentRepository repository = context.getBean(StudentRepository.class);
TransactionTemplate transaction = context.getBean(TransactionTemplate.class);
lines.add("repository object: " + repository.getClass().getSimpleName().replaceAll("[0-9]+$", "") + " (proxy), target " + AopProxyUtils.ultimateTargetClass(repository).getSimpleName());
lines.add("-- save");
SqlRecorder.clear();
Student an = repository.save(new Student("An", "an@example.com", Level.BASIC));
report(lines, "save(new Student) gives id " + (an.getId() != null));
Student found = repository.findById(an.getId()).orElseThrow();
found.setName("An Nguyen");
SqlRecorder.clear();
repository.save(found);
report(lines, "save(detached student with an id)");
SqlRecorder.clear();
transaction.executeWithoutResult(status -> repository.findById(an.getId()).orElseThrow().setName("An N."));
report(lines, "change inside one transaction, no save call");
lines.add("-- reading");
SqlRecorder.clear();
repository.findById(an.getId());
report(lines, "findById");
SqlRecorder.clear();
repository.existsById(an.getId());
report(lines, "existsById");
SqlRecorder.clear();
repository.count();
report(lines, "count");
SqlRecorder.clear();
transaction.executeWithoutResult(status -> repository.getReferenceById(an.getId()));
report(lines, "getReferenceById, id only used");
SqlRecorder.clear();
transaction.executeWithoutResult(status -> repository.getReferenceById(an.getId()).getName());
report(lines, "getReferenceById, then getName");
lines.add("-- derived queries");
SqlRecorder.clear();
repository.findByEmail("an@example.com");
report(lines, "findByEmail");
SqlRecorder.clear();
repository.findByLevel(Level.BASIC);
report(lines, "findByLevel");
lines.add("-- saving and deleting several");
SqlRecorder.clear();
repository.saveAll(List.of(new Student("B1", "b1@example.com", Level.BASIC), new Student("B2", "b2@example.com", Level.BASIC), new Student("B3", "b3@example.com", Level.ADVANCED)));
report(lines, "saveAll of 3");
SqlRecorder.clear();
repository.deleteById(an.getId());
report(lines, "deleteById");
SqlRecorder.clear();
repository.deleteAllInBatch();
report(lines, "deleteAllInBatch");
lines.add("-- a duplicate email");
repository.save(new Student("C", "c@example.com", Level.BASIC));
try {
repository.save(new Student("C again", "c@example.com", Level.BASIC));
} catch (RuntimeException e) {
List<String> chain = new ArrayList<>();
for (Throwable t = e; t != null; t = t.getCause()) {
chain.add(t.getClass().getSimpleName());
}
lines.add("exception chain: " + chain);
}
}
return lines;
}
private static void report(List<String> lines, String label) {
List<String> statements = SqlRecorder.statements();
lines.add(label + " -> " + statements.size() + " statements");
statements.forEach(sql -> lines.add(" " + sql));
SqlRecorder.clear();
}
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, xem mục Chuẩn bị) rồi mvn -q compile exec:exec. Kết quả (đã bỏ log ở stderr):
repository object: $Proxy (proxy), target SimpleJpaRepository
-- save
save(new Student) gives id true -> 1 statements
insert into student (email,level,name) values (?,?,?)
save(detached student with an id) -> 2 statements
select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
update student set email=?,level=?,name=? where id=?
change inside one transaction, no save call -> 2 statements
select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
update student set email=?,level=?,name=? where id=?
-- reading
findById -> 1 statements
select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
existsById -> 1 statements
select count(*) from student s1_0 where s1_0.id=?
count -> 1 statements
select count(*) from student s1_0
getReferenceById, id only used -> 0 statements
getReferenceById, then getName -> 1 statements
select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
-- derived queries
findByEmail -> 1 statements
select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.email=?
findByLevel -> 1 statements
select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.level=?
-- saving and deleting several
saveAll of 3 -> 3 statements
insert into student (email,level,name) values (?,?,?)
insert into student (email,level,name) values (?,?,?)
insert into student (email,level,name) values (?,?,?)
deleteById -> 2 statements
select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
delete from student where id=?
deleteAllInBatch -> 1 statements
delete s1_0 from student s1_0
-- a duplicate email
exception chain: [DataIntegrityViolationException, ConstraintViolationException, SQLIntegrityConstraintViolationException]
Dòng đầu trả lời câu hỏi "interface này chạy bằng gì?": đối tượng StudentRepository mà Spring tiêm cho bạn là một proxy (tên lớp bắt đầu bằng $Proxy), và đối tượng thật ở sau nó là SimpleJpaRepository, lớp có sẵn của Spring Data cài đặt các phương thức của JpaRepository bằng EntityManager (chính là Hibernate). Không có mã nào bạn viết ở đó.
Cách đọc hình: StudentRepository chỉ là interface. Khi khởi động, Spring tạo một proxy cho nó, proxy chuyển lời gọi cho SimpleJpaRepository, nó dùng EntityManager của Hibernate để gửi SQL tới MySQL.
save: ba con đường tới cơ sở dữ liệu
Ba thí nghiệm đầu cho thấy cùng ý "lưu một thay đổi" nhưng ba kết quả SQL khác nhau:
- Thực thể mới: 1 câu.
save(new Student(...))chỉ gửiinsert. Spring Data xem đối tượng có idnulllà mới và gọipersist; với khóa tự tăng, Hibernate chèn ngay để biết id. - Thực thể tách rời có id: 2 câu. Đối tượng đọc ở một giao dịch trước, sửa tên rồi
savelại: kết quả là mộtselect ... where id=?rồiupdate. Vì có id nên đây không phải đối tượng mới,savegọimerge; để merge, Hibernate phải đọc dòng hiện có và so sánh, rồi mới ghi. Câuupdateliệt kê mọi cột (như bài Hibernate đã thấy). - Thay đổi trong một giao dịch, không gọi
save: 2 câu, cũng cóupdate. Đoạn mã đọc sinh viên bằngfindById, đổi tên, và thoát khỏi giao dịch mà không gọisavevẫn cho raupdate. Đó là dirty checking mà bạn đã học: thực thể đang được quản lý trong ngữ cảnh lưu trữ, nên Hibernate phát hiện thay đổi lúc commit. Hai câu ở đây làselectcủafindByIdcộngupdate, không cóselectthừa của merge.
Nói cách khác, save trên thực thể có id không "thêm" gì ngoài một select: nếu đối tượng đã đang được quản lý trong giao dịch, save là thừa.
Đang tải Animation…
Cách đọc hình: hàng trên: ba con đường và số câu SQL đo được (1, 2, 2). Hàng dưới giải thích: merge cần select trước, dirty checking không cần save, và deleteAllInBatch là một câu cho cả bảng.
Cái bẫy ở đầu bài nằm đây: gọi save trên đối tượng đã có id ngoài giao dịch gửi select rồi update cho mỗi dòng. Với năm nghìn dòng đó là một vạn câu. Cách tránh là đọc và sửa các thực thể trong một giao dịch (bài giao dịch ở cuối chương nói kỹ), hoặc dùng truy vấn cập nhật hàng loạt (bài sau).
Đọc dữ liệu: chọn phương thức theo câu hỏi
findById:selectmọi cột,where id=?, 1 câu.existsById: chỉselect count(*) ... where id=?: không đọc cột nào, rẻ hơn nếu bạn chỉ hỏi "có không".count:select count(*) from student.getReferenceById: 0 câu nếu bạn chỉ dùng id của nó (nó trả một đối tượng đại diện, không đọc dòng), và chỉ gửi 1selectkhi bạn gọi một getter nhưgetName(). Có ích khi bạn chỉ cần một tham chiếu để gắn vào quan hệ mà không muốn đọc dòng thật; bài Hibernate đã nói về proxy kiểu này, và đối tượng đó chỉ dùng được khi giao dịch hoặcSessioncòn mở.findByEmailvàfindByLevel: Spring Data đọc tên phương thức và sinhwhere s1_0.email=?vàwhere s1_0.level=?. Không có chuỗi truy vấn nào do bạn viết; nếu bạn gõ sai tên thuộc tính, lỗi xuất hiện ngay lúc khởi động (bài sau đo).
Cách đọc hình: hàng trên: ba phương thức đọc với SQL khác nhau. Hàng dưới: getReferenceById không gửi câu nào cho tới khi bạn đọc một thuộc tính, và các truy vấn dẫn xuất sinh where theo tên.
Thao tác hàng loạt không phải lúc nào cũng hàng loạt
saveAllcủa 3 thực thể: 3 câuinsert. Không phải một câu. Với khóa tự tăng (IDENTITY), Hibernate phải chèn từng dòng để lấy id; đây là giới hạn đã đo ở bài hiệu năng Hibernate, vàsaveAllkhông vượt qua được nó.deleteById: 2 câu.selectđọc thực thể (để Hibernate có đối tượng để xóa, và để áp dụng cascade nếu có) rồidelete ... where id=?.deleteAllInBatch: 1 câu.delete s1_0 from student s1_0: một lệnh xóa cả bảng, không đọc từng dòng. Đổi lại nó bỏ qua ngữ cảnh lưu trữ và cascade, nên chỉ dùng khi đó đúng là điều bạn muốn.
Cách đọc hình: hàng trên: saveAll gửi từng insert vì khóa tự tăng. Hàng dưới: deleteById đọc rồi xóa, còn deleteAllInBatch là một lệnh duy nhất.
Lỗi trùng khóa: bắt đúng ngoại lệ
Lần save cuối gửi một email đã tồn tại và vi phạm ràng buộc duy nhất. Chuỗi ngoại lệ in ra, từ ngoài vào trong: DataIntegrityViolationException (của Spring), ConstraintViolationException (của Hibernate), SQLIntegrityConstraintViolationException (của JDBC). Spring bọc mọi lỗi truy cập dữ liệu vào một họ ngoại lệ riêng (ở đây DataIntegrityViolationException), nên mã của bạn bắt loại của Spring, không phụ thuộc vào Hibernate hay JDBC bên dưới. Chuỗi nguyên nhân vẫn đủ để bạn lần ngược tới lỗi gốc của MySQL khi cần ghi log.
Cách đọc hình: MySQL từ chối lệnh insert thứ hai; lỗi đi ra qua JDBC, Hibernate rồi được Spring bọc lại. Mã của bạn bắt DataIntegrityViolationException.
| Nhu cầu | Phương thức | SQL đo được |
|---|---|---|
| Lưu thực thể mới | save | 1 insert |
| Cập nhật thực thể tách rời | save | select rồi update |
| Cập nhật trong giao dịch | sửa thuộc tính, không cần save | update lúc commit |
| Kiểm tra tồn tại | existsById | select count(*) theo id |
| Chỉ cần tham chiếu | getReferenceById | 0 câu cho tới khi đọc thuộc tính |
| Xóa tất cả, bỏ qua cascade | deleteAllInBatch | 1 delete |
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
| Truy cập dữ liệu | Repository interface | Ít mã, nhưng SQL ẩn: phải đo |
| Cập nhật | Trong giao dịch, bằng cách sửa thuộc tính | Cần đúng ranh giới giao dịch |
| Khóa chính | IDENTITY | saveAll không gom lô được |
| Truy vấn đơn giản | Truy vấn dẫn xuất theo tên | Tên dài dần khi điều kiện nhiều (bài sau dùng @Query) |
| Lỗi truy cập dữ liệu | Bắt ngoại lệ của Spring | Chuỗi nguyên nhân dài khi ghi log |
| Tạo bảng | ddl-auto=create-drop chỉ trong thí nghiệm | Thật sự dùng công cụ migration (chương sau) |
Playbook: gặp sự cố Spring Data JPA 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ý |
|---|---|---|---|
| Lưu nhiều dòng chậm | Đếm câu SQL | save ngoài giao dịch, mỗi lần select rồi update; hoặc IDENTITY | Gom vào một giao dịch; đổi cách sinh khóa nếu cần gom lô |
Có select thừa trước update | Đối tượng tách rời được save lại | merge | Sửa trong giao dịch, không gọi save |
| Chỉ cần biết có tồn tại | Đang dùng findById | Đọc cả dòng | existsById |
DataIntegrityViolationException | Ràng buộc nào | Trùng khóa duy nhất, thiếu khóa ngoại | Kiểm tra trước, hoặc bắt và trả lỗi 409 |
| Repository không được tìm thấy | Package của interface | Ngoài package của @SpringBootApplication | Chuyển package hoặc @EnableJpaRepositories |
| Bảng không được tạo | spring.jpa.hibernate.ddl-auto | Mặc định với MySQL không tạo bảng | Đặt create-drop trong thí nghiệm, hoặc dùng migration |
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 |
Communications link failure | MySQL chưa sẵn sàng | Chờ docker compose ps báo healthy |
Bài thực hành
Làm trong dự án của bài (thư mục 09-spring-data-jpa/01-jparepository). 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í: các số câu SQL khớp bài. - Thêm truy vấn dẫn xuất. Thêm
List<Student> findByLevelAndNameContaining(Level level, String part)vàoStudentRepositoryvà gọi nó trongRepositoryDemo(sau khi lưu vài sinh viên). Tiêu chí: SQL cówhere s1_0.level=? and s1_0.name like ? escape '\'(điều kiệnContainingthànhlike). - Bỏ lệnh save thừa. Đã có bước đọc và sửa tên trong một giao dịch; viết thêm một lần đọc và sửa ở ngoài giao dịch rồi gọi
save. Tiêu chí: tổng số câu là 3 (selectcủafindById, rồiselectvàupdatecủa merge), so với 2 câu khi làm trong giao dịch; giải thích câu thừa đến từ đâu. - Đo getReferenceById. Gọi
getReferenceByIdvới id không tồn tại rồigetName()trong một giao dịch. Tiêu chí:EntityNotFoundException: No row with the given identifier exists for entity [... Student with id '99999']lúcgetName(), không phải lúc gọigetReferenceById. - Gây lỗi trùng. Chạy lại phần "duplicate email" nhưng bắt
DataIntegrityViolationExceptionvà ingetMostSpecificCause().getMessage(). Tiêu chí: thông báo có dạngDuplicate entry 'd@example.com' for key 'student.UK...'(tên chỉ mục do Hibernate sinh). - 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: 6, 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
- "Spring Data JPA thay thế Hibernate." Nó nằm trên Hibernate; mọi hành vi đã học (dirty checking, merge, LAZY) vẫn đúng.
- "
saveluôn là một câu." Chỉ khi thực thể mới; thực thể có id thêm mộtselect. - "
saveAlllà lưu hàng loạt." Với khóa tự tăng vẫn từng câu một. - "Phải gọi
saveđể cập nhật." Trong một giao dịch, sửa thuộc tính là đủ. - Số đo thuộc về Spring Boot 4.1.1, Hibernate do Boot quản lý và MySQL 8.4. Bài chưa nói về
@Query, phân trang, quan hệ giữa thực thể, giao dịch và N+1 (các bài sau), cũng như Specification và Auditing.
Tự kiểm tra
StudentRepositorykhông có cài đặt nào: ai thực thi các phương thức của nó?savetrên thực thể có id và ngoài giao dịch gửi những câu SQL nào?- Vì sao thay đổi trong một giao dịch không cần
save? - Khác nhau giữa
findById,existsByIdvàgetReferenceByIdvề SQL? deleteAllInBatchkhácdeleteByIdlặp lại ở điểm nào?
Gợi ý đáp án. (1) Một proxy chuyển lời gọi cho SimpleJpaRepository, dùng EntityManager. (2) select rồi update (merge). (3) Thực thể đang được quản lý; dirty checking tự phát hiện và update lúc commit. (4) findById đọc mọi cột; existsById chỉ đếm; getReferenceById không gửi câu nào cho tới khi đọc thuộc tính. (5) Một lệnh delete cho cả bảng so với một select và một delete mỗi dòng; nó bỏ qua ngữ cảnh lưu trữ và cascade.
Điều cần nhớ
- Repository là interface cộng proxy
SimpleJpaRepositorytrên Hibernate; SQL vẫn ở đó, hãy đo nó. - Thực thể mới:
persist(1 câu). Thực thể có id:merge(thêmselect). Trong giao dịch: không cầnsave. - Chọn
existsById,getReferenceByIdhayfindByIdtheo câu hỏi bạn đặt ra. IDENTITYlàmsaveAllthành nhiềuinsert;deleteAllInBatchlà một lệnh nhưng bỏ qua cascade.- Bắt
DataIntegrityViolationExceptionthay vì ngoại lệ của JDBC hay Hibernate.
Đọc thêm
- Spring Data JPA Reference: Getting Started
- Spring Data JPA Reference: Query Methods
- Hibernate ORM 7.4: User Guide
Bài tiếp theo đi vào truy vấn: nhiều cách viết truy vấn (tên, @Query, phân trang, sắp xếp, chiếu) và chi phí của từng cách.