Spring Data JPA
Xem thửMột lập trình viên đổi tên thuộc tính name thành fullName trong entity Customer, chạy kiểm thử cục bộ thấy xanh và merge. Pipeline triển khai ném ngay No property 'name' found for type 'Customer' vì repository còn phương thức findByName; sự cố dừng trước khi có người dùng nào bị ảnh hưởng, chính xác như thiết kế. Cũng đội đó, ba tháng sau, có một màn hình xuất báo cáo gọi findAll() trên bảng 3 triệu hàng, một trang danh sách gọi Page trên bảng lớn và chậm vì phép đếm, và một thao tác @Modifying làm giao diện hiển thị dữ liệu cũ dù cơ sở dữ liệu đã đúng. Spring Data JPA tiết kiệm rất nhiều mã, nhưng mỗi thứ nó giấu đi là một hành vi cần hiểu.
Bài này mở nắp chiếc hộp: repository được tạo ra thế nào, truy vấn sinh từ tên phương thức ra sao, Page, Slice và Window khác nhau ở những câu SQL nào, projection tiết kiệm đến đâu, và persistence context quyết định điều gì khi bạn chỉ "sửa một trường". Mọi kết quả đều đo bằng lab PostgreSQL 16 thật với Spring Boot 4.1.1, Spring Data JPA 4.1.1 và Hibernate 7, bằng cách đọc log org.hibernate.SQL.
Sau bài này, bạn có thể
- Giải thích Spring Data JPA tạo repository và chọn chiến lược truy vấn như thế nào, và đọc stack trace lúc khởi động.
- Viết truy vấn từ tên phương thức,
@Query,Specification, ví dụ (Query by Example) và đoạn mã tùy biến, và chọn đúng công cụ cho từng bài toán. - Dùng projection kiểu interface, record và động để chỉ đọc các cột cần thiết, và chứng minh bằng SQL.
- Chọn
Page,SlicehoặcWindow(scroll) dựa trên câu SQL mà từng loại chạy. - Dùng
@Modifyingđúng cách mà không để lại entity cũ trong persistence context. - Giải thích dirty checking, entity managed và detached, và khi nào
save()thực sự cần thiết.
Vị trí trong ngăn xếp
Cách đọc hình
Có bốn tầng, và nhiều lỗi khó hiểu đến từ việc nhầm tầng. Spring Data JPA (tầng trên cùng) cho bạn interface repository và biến tên phương thức thành truy vấn. Nó gọi Jakarta Persistence, là API chuẩn (EntityManager, JPQL, @Entity). Hibernate là cài đặt của chuẩn đó, nơi dirty checking, cache cấp một, sinh SQL và ánh xạ kiểu diễn ra. Cuối cùng là JDBC và pool kết nối như hai bài trước. Khi một câu SQL lạ xuất hiện, hãy hỏi: nó đến từ Spring Data (tên phương thức), từ Hibernate (quan hệ, flush) hay từ truy vấn bạn tự viết.
Repository được tạo ra như thế nào
Bạn chỉ khai báo một interface:
public interface PersonRepository extends JpaRepository<Person, Long>,
JpaSpecificationExecutor<Person>, PersonRepositoryCustom {
Optional<Person> findByEmail(String email);
List<Person> findByCityAndAgeGreaterThanOrderByLastNameAsc(String city, int age);
boolean existsByEmail(String email);
long countByCity(String city);
}
Không có lớp cài đặt nào, vậy ai trả lời khi bạn gọi findByEmail? Chuỗi sự kiện lúc khởi động và lúc gọi:
Đang tải sơ đồ…
Vai trò từng thành phần, với tên lớp thật của Spring Data:
JpaRepositoryFactoryBeanvàJpaRepositoryFactorydựng một proxy cho mỗi interface repository. Lab xác nhận bằngAopUtils: proxy là JDK dynamic proxy (jdk=true) và lớp đích làSimpleJpaRepository.SimpleJpaRepositorylà cài đặt mặc định của các phương thức quen thuộc (save,findById,findAll,delete...).QueryExecutorMethodInterceptorđứng trong chuỗi chặn của proxy. Khi gọi một phương thức không thuộcSimpleJpaRepositoryhay các đoạn tùy biến, nó chuyển cho mộtRepositoryQueryđã được dựng sẵn cho phương thức đó.- Việc dựng diễn ra lúc khởi động. Với chiến lược mặc định
CREATE_IF_NOT_FOUND(doJpaQueryLookupStrategyquyết định), thứ tự tìm là: truy vấn khai báo bằng@Query(SimpleJpaQuery), truy vấn đặt tên (NamedQuery), rồi suy ra từ tên phương thức (PartTreeJpaQuery, dùngPartTreeđể phân tích).
Hệ quả đáng giá nhất: sai tên thuộc tính làm ứng dụng không khởi động, chứ không lỗi lúc chạy. Lab thêm repository với findByNme trong entity có thuộc tính name:
PropertyReferenceException: No property 'nme' found for type 'Thing'; Did you mean 'name'
Hãy coi đây là lợi thế: đổi tên thuộc tính mà quên cập nhật repository thì pipeline bắt ngay. Spring Data 4 còn có xử lý AOT cho repository, sinh sẵn mã cài đặt lúc build thay vì tạo ở runtime, hữu ích cho ứng dụng GraalVM native; hành vi từ phía bạn không đổi.
Giao dịch có sẵn trong repository
SimpleJpaRepository khai báo @Transactional(readOnly = true) ở mức lớp và @Transactional trên các phương thức ghi. Điều này nghĩa là gọi findAll() ngoài mọi transaction vẫn chạy trong một transaction chỉ đọc ngắn. Lab dùng callback @PostLoad ghi lại trạng thái transaction:
TX-OUTSIDE readOnly=true active=true
TX-INSIDE readOnly=false active=true
Gọi từ service có @Transactional bình thường thì repository tham gia transaction của service (cờ chỉ đọc là false). Gọi nhiều phương thức repository liên tiếp mà không có transaction ở service nghĩa là mỗi phương thức một transaction riêng, và entity tải về sẽ detached ngay sau mỗi lời gọi; đây là gốc của nhiều lỗi dirty checking bên dưới.
Truy vấn từ tên phương thức
Spring Data phân tích tên theo mẫu find...By + điều kiện nối bằng And, Or. Một số từ khóa thường dùng:
| Từ khóa | Ví dụ | Điều kiện sinh ra |
|---|---|---|
And, Or | findByCityAndAge | city = ? and age = ? |
GreaterThan, LessThan, Between | findByAgeBetween | so sánh khoảng |
Containing, StartingWith, Like | findByLastNameContaining | like ? |
In, NotIn | findByCityIn | in (...) |
IsNull, IsNotNull | findByEmailIsNull | kiểm tra null |
IgnoreCase | findByEmailIgnoreCase | so sánh không phân biệt hoa thường |
OrderBy...Asc/Desc | findByCityOrderByIdAsc | order by |
First, Top | findFirst10By... | giới hạn số hàng |
exists, count, delete | existsByEmail, countByCity | câu lệnh tương ứng |
Câu SQL thực tế của findByCityAndAgeGreaterThanOrderByLastNameAsc("Hanoi", 30) (lab):
select p1_0.id,p1_0.age,p1_0.city,p1_0.created_at,p1_0.created_by,p1_0.email,p1_0.first_name,p1_0.last_name,p1_0.updated_at
from people p1_0 where p1_0.city=? and p1_0.age>? order by p1_0.last_name
Quy tắc lựa chọn: tên phương thức hợp với truy vấn có một đến ba điều kiện đơn giản. Khi tên dài hơn khoảng bốn điều kiện hoặc cần nối, hãy viết @Query:
@Query("select p from Person p where p.city = :city and p.age >= :minAge order by p.id")
List<Person> adultsIn(@Param("city") String city, @Param("minAge") int minAge);
@Query mặc định là JPQL (HQL của Hibernate 7), hoạt động trên entity và thuộc tính chứ không phải bảng và cột; thêm nativeQuery = true để dùng SQL thuần khi cần tính năng riêng của cơ sở dữ liệu. Ưu tiên tham số có tên (:city) hơn vị trí (?1) vì đọc rõ và ít lỗi khi thêm tham số.
Projection: đọc ít cột hơn
Mặc định mọi truy vấn trả entity nên đọc toàn bộ cột (chín cột ở lab). Khi chỉ cần vài trường, dùng projection:
public interface PersonName {
String getFirstName();
String getLastName();
}
public record PersonCard(String firstName, String city) {
}
List<PersonName> findNamesByCity(String city);
<T> List<T> findByCity(String city, Class<T> type);
Câu SQL ghi lại được:
| Kiểu trả về | SQL |
|---|---|
Entity Person | chọn 9 cột |
Interface PersonName | select p1_0.first_name,p1_0.last_name from people p1_0 where p1_0.city=? |
Record PersonCard (động, truyền Class) | select p1_0.first_name,p1_0.city from people p1_0 where p1_0.city=? |
Hai điều rút ra. Một: projection đóng (interface chỉ có getter bám thuộc tính, hoặc record) cho phép Spring Data chỉ chọn cột cần thiết, và không tạo entity managed (không tốn bộ nhớ persistence context, không có dirty checking). Hai: projection mở (dùng @Value với SpEL) không thể tối ưu cột nên vẫn nạp entity đầy đủ theo tài liệu của Spring Data; chỉ dùng khi thật cần. Dùng record cho DTO ở các màn hình danh sách là mặc định tốt.
Phân trang: Page, Slice và Window
Cùng một nhu cầu "lấy trang tiếp theo" có ba cách, khác nhau ở chi phí:
Page<Person> findByCity(String city, Pageable pageable);
Slice<Person> findByAgeGreaterThan(int age, Pageable pageable);
Window<Person> findFirst10ByCityOrderByIdAsc(String city, ScrollPosition position);
Lab bật log SQL và gọi từng loại (20 người ở Hanoi, trang 5 phần tử):
| Cách | Số câu SQL | Chi tiết |
|---|---|---|
Page trang 0, cỡ 5 | 2 | truy vấn dữ liệu fetch first ? rows only và select count(p1_0.id) ... where p1_0.city=?; total=20, pages=4 |
Page trang 0, cỡ 50 (trang chưa đầy) | 1 | Spring Data bỏ qua phép đếm vì đã biết tổng |
Slice trang 0, cỡ 5 | 1 | không đếm; lấy thêm một hàng để biết hasNext=true |
Window, vị trí keyset() ban đầu | 1 | ... order by p1_0.id fetch first ? rows only |
Window, vị trí tiếp theo | 1 | ... where (p1_0.city=?) and (p1_0.id>?) order by p1_0.id |
Phép đếm của Page có thể đắt hơn cả truy vấn dữ liệu trên bảng lớn, vì nó phải đếm mọi hàng khớp. Nếu giao diện chỉ cần "trang sau" hoặc cuộn vô hạn, hãy dùng Slice. Với dữ liệu thay đổi liên tục hoặc phân trang sâu, dùng Window với ScrollPosition.keyset(): truy vấn trang kế dùng điều kiện id > ? nên cùng chi phí ở trang 1 hay trang 10.000 (dùng chỉ mục (city, id) như migration của lab), trong khi offset phải duyệt và bỏ qua mọi hàng đứng trước. Lab lấy hai cửa sổ: 10 phần tử với hasNext=true, rồi 10 phần tử với hasNext=false. Điều kiện của keyset: thứ tự sắp xếp phải xác định duy nhất (ở đây theo id).
Thao tác ghi hàng loạt với @Modifying
Cập nhật nhiều hàng bằng một câu lệnh hiệu quả hơn nạp từng entity:
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Person p set p.city = :city where p.age > :age")
int moveOlderThan(@Param("age") int age, @Param("city") String city);
Câu lệnh này chạy thẳng trong cơ sở dữ liệu và bỏ qua persistence context. Entity đã nạp trước đó vì vậy trở thành cũ. Lab nạp người có email p24@x.io (thành phố Hue), chạy lệnh chuyển mọi người trên 30 tuổi sang Saigon, và in ra:
MODIFYING rows=10 staleCity=Hue reloaded=Saigon
Đối tượng đã nạp vẫn báo Hue (cũ), còn nạp lại sau lệnh trả Saigon. Hai cờ giải quyết vấn đề: flushAutomatically đẩy các thay đổi chờ xuống trước khi chạy lệnh (để lệnh thấy dữ liệu mới nhất), và clearAutomatically làm sạch persistence context sau lệnh để lần đọc kế tiếp lấy dữ liệu thật. Trade-off: xóa persistence context làm mọi entity đang giữ trở thành detached, nên hãy đặt các thao tác hàng loạt thành bước riêng.
Specification, Query by Example và đoạn mã tùy biến
Khi điều kiện lọc được ghép động theo đầu vào (màn hình tìm kiếm với nhiều bộ lọc tùy chọn), tên phương thức không còn phù hợp. Dùng Specification qua JpaSpecificationExecutor:
Specification<Person> adults = (root, query, cb) -> cb.ge(root.get("age"), 30);
Specification<Person> inHue = (root, query, cb) -> cb.equal(root.get("city"), "Hue");
List<Person> result = people.findAll(adults.and(inHue));
Lab trả về 5 người cho tổ hợp trên. Mỗi Specification là một mảnh điều kiện tái sử dụng, ghép bằng and, or, và chỉ thêm khi bộ lọc tương ứng có giá trị. Lưu ý rằng root.get("age") dùng tên thuộc tính dạng chuỗi nên không kiểm tra lúc biên dịch; dùng metamodel được sinh ra (Person_.age) hoặc kiểm thử tích hợp để chặn lỗi gõ sai.
Query by Example hữu ích cho lọc đơn giản theo mẫu:
ExampleMatcher matcher = ExampleMatcher.matching()
.withIgnoreNullValues()
.withIgnorePaths("age")
.withMatcher("firstName", ExampleMatcher.GenericPropertyMatchers.contains().ignoreCase());
List<Person> found = people.findAll(Example.of(new Person("first1", null, null, null, 0), matcher));
Lab trả 11 kết quả (First1 và First10 đến First19). Hạn chế của QBE: chỉ khớp bằng hoặc so sánh chuỗi, không hỗ trợ khoảng hay nhóm điều kiện lồng nhau.
Khi cần truy vấn mà Spring Data không biểu diễn được, thêm đoạn mã tùy biến: một interface đặt tên tùy ý và lớp cài đặt có hậu tố Impl:
public interface PersonRepositoryCustom {
List<Person> searchByText(String text);
}
class PersonRepositoryCustomImpl implements PersonRepositoryCustom {
private final EntityManager entityManager;
PersonRepositoryCustomImpl(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public List<Person> searchByText(String text) {
return entityManager.createQuery(
"select p from Person p where lower(p.firstName) like :t or lower(p.lastName) like :t order by p.id", Person.class)
.setParameter("t", "%" + text.toLowerCase() + "%")
.getResultList();
}
}
Repository kế thừa thêm PersonRepositoryCustom và Spring Data tự nối cài đặt vào proxy (lab tìm "last2" ra 7 người). Đây là lối thoát khi bạn cần EntityManager, hoặc cần giao cho jOOQ hay JdbcClient cho các truy vấn báo cáo.
Auditing: ai tạo, ai sửa, lúc nào
Bật auditing để các trường thời gian và người tạo tự điền:
@Entity
@Table(name = "people")
@EntityListeners(AuditingEntityListener.class)
public class Person {
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@CreatedBy
private String createdBy;
}
@Configuration(proxyBeanMethods = false)
@EnableJpaAuditing
class AuditConfig {
@Bean
AuditorAware<String> auditorAware() {
return () -> Optional.of("tester");
}
}
Lab lưu một người và đọc lại: createdAt và updatedAt khác null, createdBy=tester. Trong ứng dụng thật, AuditorAware lấy người dùng từ ngữ cảnh bảo mật.
Managed, detached và dirty checking
Đang tải Animation…
Thử và quan sát
Dự đoán trước: gọi setEmail trên entity lấy từ repository ngoài transaction, cơ sở dữ liệu có được cập nhật không? Chạy biến thể ngoài transaction và quan sát chữ "detached" ở bước đầu.
Lab kiểm chứng bốn trường hợp với cột city (đọc log SQL):
| Trường hợp | Kết quả |
|---|---|
Đổi city trên entity nạp trong transaction ghi | một câu select rồi update people set age=?,city=?,... lúc commit, không cần gọi save |
Đổi city trên entity đã detached (nạp ngoài transaction) | 0 câu update, dữ liệu trong cơ sở dữ liệu không đổi |
Đổi city trong transaction chỉ đọc (readOnly=true) | 0 câu update: Hibernate không flush ở chế độ chỉ đọc |
save(detached) | một select rồi update (đưa entity về trạng thái managed bằng merge) |
Một chi tiết thực tế trong câu update: Hibernate mặc định liệt kê mọi cột của entity trong SET, kể cả cột không đổi. Với bảng nhiều cột hoặc cột lớn, có thể dùng @DynamicUpdate của Hibernate để chỉ cập nhật cột thay đổi, đổi lại tốn thêm công tính toán và làm giảm khả năng tái sử dụng câu lệnh đã chuẩn bị. Quy tắc dùng save: với entity mới thì luôn gọi; với entity managed trong transaction thì không cần nhưng vô hại; với entity detached thì bắt buộc nếu muốn ghi lại.
Quyết định thiết kế và đánh đổi
| Bài toán | Công cụ | Lý do và giới hạn |
|---|---|---|
| Tìm theo một đến ba điều kiện cố định | Tên phương thức | Ngắn, kiểm tra lúc khởi động |
| Truy vấn cố định nhưng phức tạp, có nối | @Query (JPQL) | Đọc rõ, có tên tham số |
| Bộ lọc tùy chọn ghép động | Specification | Tái sử dụng, nhưng chuỗi tên thuộc tính |
| Danh sách cho màn hình | Projection record | Ít cột, không managed |
| Danh sách rất lớn, cuộn vô hạn | Slice hoặc Window keyset | Không đếm, chi phí ổn định |
| Cập nhật hàng loạt | @Modifying với clearAutomatically | Bỏ qua persistence context |
| Báo cáo, thống kê phức tạp | JdbcClient hoặc jOOQ | Đừng ép JPA làm việc của SQL |
Sổ tay xử lý sự cố
- Triệu chứng: ứng dụng không khởi động với
No property 'x' found for type. Nguyên nhân: tên phương thức tham chiếu thuộc tính không tồn tại. Sửa: đổi tên cho đúng; đây là hành vi mong muốn. - Triệu chứng: trang danh sách chậm khi bảng lớn. Nguyên nhân: truy vấn đếm của
Page. Kiểm tra: log SQL cócount(...)đắt. Sửa: dùngSlicehoặcWindow, hoặc tự cung cấp truy vấn đếm gọn bằng thuộc tínhcountQuerycủa@Query. - Triệu chứng: phân trang sâu chậm dần. Nguyên nhân:
offsetlớn. Sửa: scroll với keyset và chỉ mục khớp thứ tự. - Triệu chứng: dữ liệu hiển thị cũ sau
@Modifying. Nguyên nhân: persistence context chưa làm sạch. Sửa:clearAutomatically = true. - Triệu chứng: sửa field mà không có
UPDATE. Nguyên nhân: entity detached hoặc transaction chỉ đọc. Kiểm tra: log SQL,TransactionSynchronizationManager. Sửa: đặt@Transactionalở service hoặc gọisavetrên detached. - Triệu chứng:
findAll()làm hết bộ nhớ. Nguyên nhân: nạp toàn bộ bảng. Sửa: phân trang,Streamvới@Transactional(readOnly = true)hoặc xử lý theo lô. - Triệu chứng: truy vấn đọc nhiều cột không cần. Nguyên nhân: trả entity cho màn hình danh sách. Sửa: projection.
Hiệu năng và vận hành
- Bật tạm
logging.level.org.hibernate.SQL=debugkhi điều tra; không bật vĩnh viễn ở production vì tốn I/O. Dùnghibernate.generate_statisticskết hợp Micrometer khi cần số đếm. - Mọi phương thức truy vấn nên đi cùng chỉ mục mà bạn đã xác nhận bằng
EXPLAINở bài trước. - Tránh gọi repository trong vòng lặp; gom thành một truy vấn
Inhoặc dùngsaveAllvới batching đã nói ở bài trước. - Kiểm thử repository bằng
@DataJpaTestvới cơ sở dữ liệu thật qua Testcontainers khi truy vấn dùng tính năng riêng của PostgreSQL.
Bài thực hành
- Dựng entity
Personvới migration, repository có các phương thức ở trên, và kiểm thử Testcontainers. - Tái hiện lỗi khởi động bằng phương thức
findByNmetrong một ứng dụng riêng. - Bật log SQL và lập bảng số câu SQL cho
Page,Slice,Window. - Viết ba lần dirty checking (managed, detached, chỉ đọc) và xác nhận số câu
update. - Viết
@Modifyinghai lần, có và không cóclearAutomatically, và ghi lại giá trị entity đã nạp. - Viết
Specificationghép từ hai bộ lọc tùy chọn.
Tiêu chí hoàn thành: bảng ghi 2, 1 và 1 câu SQL cho ba cách phân trang (trang chưa đầy của Page chỉ 1 câu); bốn trường hợp dirty checking cho 1, 0, 0 và 1 câu update; entity cũ giữ giá trị cũ khi không dùng clearAutomatically. Gợi ý nếu Window cho kết quả lặp: bảo đảm sắp xếp theo khóa duy nhất như id.
Tự kiểm tra
- Gọi
findByEmailtrên interface không có lớp cài đặt: những đối tượng nào tham gia và lỗi sai tên thuộc tính xuất hiện ở thời điểm nào? - Màn hình danh sách dùng
Pagevà chậm khi bảng có 20 triệu hàng. Bạn kiểm tra gì và có những lựa chọn nào? - Vì sao
Windowvới keyset giữ chi phí ổn định ở trang sâu cònoffsetthì không? - Một service gọi
repo.findByEmail(...)rồisetCity(...)mà không có@Transactional. Dữ liệu có đổi không và vì sao? - Sau
@Modifyingkhông cóclearAutomatically, một entity đã nạp báo giá trị cũ. Giải thích và cho cách sửa. - Khi nào chọn projection record thay vì trả entity?
- Bạn có 6 bộ lọc tùy chọn cho màn hình tìm kiếm. Tên phương thức,
@QueryhaySpecification?
Gợi ý đáp án. (1) Proxy JDK, QueryExecutorMethodInterceptor và PartTreeJpaQuery; lỗi xuất hiện lúc khởi động khi dựng truy vấn. (2) Log SQL có câu count đắt; dùng Slice, Window, hoặc truy vấn đếm gọn và đệm tổng. (3) Keyset dùng điều kiện id > ? đi thẳng vào chỉ mục; offset phải bỏ qua mọi hàng đứng trước. (4) Không đổi: repository chạy mỗi lời gọi trong transaction riêng nên entity detached ngay; cần @Transactional ở service. (5) @Modifying bỏ qua persistence context; dùng clearAutomatically = true và flushAutomatically = true. (6) Màn hình chỉ đọc cần ít cột, không cần cập nhật. (7) Specification, vì số tổ hợp điều kiện bùng nổ nếu dùng tên phương thức.
Điều cần nhớ
- Repository là proxy JDK quanh
SimpleJpaRepository; truy vấn dựng lúc khởi động nên lỗi tên bị bắt sớm. - Repository có transaction chỉ đọc riêng; thay đổi cần transaction ghi ở service.
- Projection giảm cột;
SlicevàWindowtránh phép đếm; keyset giữ chi phí phẳng. @Modifyingbỏ qua persistence context: dùng hai cờ làm sạch.- Chỉ entity managed trong transaction ghi mới được dirty checking.
Học liệu chọn lọc
- Spring Data JPA Reference
- Spring Data JPA: Query Methods
- Spring Data Commons: Projections
- Spring Data JPA: Specifications
- Spring Data JPA: Auditing
- Hibernate ORM User Guide: Persistence Context
- Jakarta Persistence Specification
- Mã nguồn nên đọc: SimpleJpaRepository, JpaQueryLookupStrategy, PartTreeJpaQuery.
- Sách: High-Performance Java Persistence (Vlad Mihalcea); Spring Data (Mark Pollack và cộng sự).