Entity Lifecycle và Persistence Context: vì sao không gọi save() mà vẫn UPDATE?
Tiếp đi: trạng thái entity và Persistence Context.
Đây là một trong những bài quan trọng nhất của JPA. Hiểu bài này thì N+1, @Transactional, LazyInitializationException ở các bài sau đều dễ.
1. Bài toán: cập nhật giá Product
Trong shop-app, bảng products:
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price NUMERIC(15, 2) NOT NULL,
stock INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL
);
Service:
@Transactional
public void updatePrice(Long id, BigDecimal price) {
Product product = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found"));
product.setPrice(price);
// Không gọi save()
}
Chạy xong, database vẫn có giá mới. Tại sao?
2. Vấn đề
- Nếu nghĩ JPA giống JDBC ("phải gọi lệnh mới ghi"), bạn sẽ không đoán được lúc nào SQL chạy.
- Ngược lại, có lúc gọi setter ngoài transaction thì không có gì được ghi.
- Có lúc sửa object một cách vô tình (ví dụ chuẩn hóa dữ liệu để hiển thị) và Hibernate âm thầm UPDATE.
Muốn kiểm soát, phải biết Entity đang ở trạng thái nào và ai đang quản lý nó.
3. Giải pháp: Entity Lifecycle + Persistence Context
Một Entity có bốn trạng thái:
persist()
+-----------+ ----------> +-----------+
| TRANSIENT | | MANAGED | <--- find() / query
| new Obj() | | |
+-----------+ +-----------+
/ | ^
remove() / | \ merge() (trả về
v | \ instance managed MỚI)
+-----------+ | detach() / clear()
| REMOVED | | / transaction kết thúc
| DELETE khi| v
| flush | +-----------+
+-----------+ | DETACHED |
+-----------+
| Trạng thái | Ví dụ | Ý nghĩa |
|---|---|---|
| Transient | new Product() | Object mới, chưa ai quản lý, chưa có trong DB |
| Managed | entityManager.find(Product.class, 1L) | Persistence Context đang theo dõi |
| Detached | entityManager.detach(product) | Có trong DB nhưng không còn được theo dõi |
| Removed | entityManager.remove(product) | Đánh dấu xóa, DELETE khi flush |
Persistence Context là "vùng làm việc" của EntityManager: một map (EntityClass, id) → instance kèm snapshot ban đầu. Nó cung cấp ba thứ:
- Identity: trong cùng context, một dòng DB chỉ có một Java object.
- First-level cache:
find()lần hai không cần SELECT. - Dirty checking: so sánh với snapshot lúc flush, sinh UPDATE cho field thay đổi.
Trong Spring, mặc định một transaction = một Persistence Context.
4. Code từng bước
Bước 1: Dependency. Dùng lại spring-boot-starter-data-jpa + postgresql từ bài 4.3.1.
Bước 2: Config, bật log SQL để thấy lúc nào SQL thực sự chạy:
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace # in giá trị tham số (Hibernate 6)
Bước 3: Code Java. Entity:
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(precision = 15, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private Integer stock;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
protected Product() {}
@PrePersist
void beforeInsert() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
// getters, setters
}
Demo first-level cache và dirty checking:
@Service
public class ProductDemoService {
private final ProductRepository repository;
public ProductDemoService(ProductRepository repository) {
this.repository = repository;
}
@Transactional
public void identityDemo() {
Product p1 = repository.findById(1L).orElseThrow(); // SELECT
Product p2 = repository.findById(1L).orElseThrow(); // không SELECT
System.out.println(p1 == p2); // true
}
@Transactional
public void dirtyCheckingDemo() {
Product product = repository.findById(1L).orElseThrow(); // MANAGED
product.setStock(50); // chưa có SQL
product.setStock(60); // vẫn chưa có SQL
} // flush trước commit: 1 UPDATE stock=60
public void detachedDemo() { // KHÔNG có @Transactional
Product product = repository.findById(1L).orElseThrow();
// findById tự mở/đóng transaction riêng → product đã DETACHED
product.setStock(999); // không ai theo dõi → không UPDATE
repository.save(product); // save() thấy id != null → merge() → UPDATE
}
}
Bước 4: Test bằng curl (giả sử có endpoint gọi các method trên):
curl -X POST http://localhost:8080/demo/identity
# log: 1 câu select ... from products where id=?
# console: true
curl -X POST http://localhost:8080/demo/dirty
# log: select ... ; update products set ...,stock=? where id=? (chỉ 1 UPDATE)
5. Cách hoạt động: dirty checking và flush
BEGIN TRANSACTION
|
v
SELECT product WHERE id = 1
|
v
Product MANAGED + snapshot {stock=10, price=100}
|
v
product.setStock(50) -- chỉ đổi field Java, chưa SQL
|
v
Flush (trước commit / trước query JPQL liên quan / flush() thủ công)
| so sánh current vs snapshot → stock khác
v
UPDATE products SET stock = 50 ... WHERE id = 1
|
v
COMMIT
Flush xảy ra khi nào (FlushMode AUTO mặc định):
- Trước khi commit transaction.
- Trước khi chạy JPQL/HQL query có thể bị ảnh hưởng bởi thay đổi chưa flush.
- Khi gọi
entityManager.flush()hoặcrepository.saveAndFlush().
Flush ≠ Commit:
@Transactional
public void test() {
Product product = repository.findById(1L).orElseThrow();
product.setStock(100);
repository.flush(); // UPDATE đã gửi xuống DB
throw new RuntimeException("Rollback"); // nhưng transaction rollback
}
UPDATE đã chạy nhưng không được commit. Transaction khác không thấy giá trị 100.
6. So sánh
| persist() | merge() | save() (Spring Data) | |
|---|---|---|---|
| Đầu vào | Transient | Detached (hoặc transient) | Bất kỳ |
| Kết quả | Chính object đó thành managed | Trả về bản copy managed, object truyền vào vẫn detached | Entity mới → persist; ngược lại → merge |
| SQL | INSERT khi flush (IDENTITY: INSERT ngay) | Có thể SELECT trước rồi UPDATE | Như trên |
| Flush | Commit |
|---|---|
| Đồng bộ pending changes xuống DB | Kết thúc transaction thành công |
| Gửi INSERT/UPDATE/DELETE | Làm thay đổi bền vững, transaction khác thấy được |
| Vẫn rollback được | Không rollback được nữa |
7. Khi nào nên dùng / không nên dùng
- Nên dựa vào dirty checking khi update Entity trong
@Transactional: code gọn, chỉ UPDATE khi có thay đổi. - Không nên giữ Persistence Context quá lớn (load 100.000 Entity): tốn RAM cho snapshot, dirty checking chậm. Dùng bulk update hoặc
flush()+clear()theo batch. - Không nên sửa Entity managed chỉ để hiển thị. Map sang DTO rồi sửa DTO.
- Dùng
@Transactional(readOnly = true)cho method chỉ đọc: Hibernate bỏ qua dirty checking.
@Transactional có lúc "không hoạt động": gọi nội bộ, method private, checked exception... Bài sau đi qua các bẫy đó.Vậy trong updatePrice() có cần gọi repository.save(product) cho chắc không?
Không cần. Entity đang managed thì save() gần như không làm gì thêm (Spring Data gọi merge(), với entity đã managed thì trả lại chính nó). Gọi cũng không sai, nhiều team giữ cho dễ đọc.
Chỉ cần chỉnh một điểm: save() không có nghĩa là "ghi ngay". Nó không flush, không commit. Muốn SQL chạy ngay để bắt lỗi constraint sớm thì dùng saveAndFlush(); vẫn chưa commit.
Nơi save() thực sự cần: Entity transient (tạo mới) hoặc detached (nhận từ request, từ cache, từ transaction khác).
findById() lần hai không SELECT, vậy first-level cache này có chia sẻ giữa các request không?
Không. First-level cache gắn với một Persistence Context, tức là một transaction (một request). Request khác có context riêng, sẽ SELECT lại.
Request A (Tx A) Request B (Tx B)
Persistence Context A Persistence Context B
Product(id=1) (trống) → SELECT lại
Cache chia sẻ giữa các transaction là second-level cache của Hibernate, tùy chọn, phải cấu hình cache provider. Nó khác Redis: L2 cache gắn với ORM, Redis là data store dùng chung đa mục đích.
Phỏng vấn hỏi Persistence Context và dirty checking thì trả lời sao?
Q1. Persistence Context là gì?
🇻🇳 Tiếng Việt:
Persistence Context là tập các Entity instance đang được một EntityManager quản lý. Nó đảm bảo mỗi dòng DB chỉ có một object trong context, đóng vai trò first-level cache và hỗ trợ dirty checking. Trong Spring, mặc định nó sống theo transaction.
🇬🇧 English:
The Persistence Context is a set of managed entity instances associated with an EntityManager. It provides identity management, dirty checking and first-level caching. In Spring it is transaction-scoped by default.
Q2. Dirty checking là gì? Vì sao không gọi save() vẫn UPDATE?
🇻🇳 Tiếng Việt:
Khi load Entity, Hibernate lưu snapshot. Lúc flush (thường trước commit), nó so sánh trạng thái hiện tại với snapshot và sinh UPDATE cho Entity đã thay đổi. Vì Entity đang managed trong transaction nên không cần gọi save().
🇬🇧 English:
Dirty checking is Hibernate's mechanism for detecting changes to managed entities. It keeps a snapshot when the entity is loaded and, at flush time, compares the current state with that snapshot and issues UPDATE statements. Because the entity is managed inside a transaction, calling save() is not required.
Q3. Flush khác commit thế nào? save() khác saveAndFlush() thế nào?
🇻🇳 Tiếng Việt:
Flush đồng bộ thay đổi xuống database, còn commit mới kết thúc transaction. Dữ liệu đã flush vẫn có thể bị rollback. save() persist hoặc merge nhưng không nhất thiết flush ngay; saveAndFlush() flush ngay nhưng vẫn không commit.
🇬🇧 English:
Flush synchronizes pending changes to the database, while commit completes the transaction; flushed changes can still be rolled back. save() persists or merges an entity without necessarily flushing, and saveAndFlush() also flushes immediately but still does not commit.
Cách nhớ ngắn gọn
- 4 trạng thái: Transient → Managed → Detached / Removed.
- Managed + transaction = setter là đủ, flush sẽ UPDATE.
- Persistence Context = identity + first-level cache + dirty checking, sống theo transaction.
- Flush gửi SQL, commit mới chốt.
merge()trả về bản managed mới, đừng tiếp tục sửa object cũ.
Tự kiểm tra
Trong cùng transaction gọi findById(1L) hai lần, có mấy câu SELECT? p1 == p2?
Một SELECT; p1 == p2 là true vì Persistence Context đảm bảo identity.
Gọi setter trên Entity ngoài transaction có UPDATE không?
Không. Entity đã detached, không ai theo dõi. Phải save()/merge() trong transaction.
Ba thời điểm flush xảy ra với FlushMode AUTO?
Trước commit, trước JPQL query có thể bị ảnh hưởng, khi gọi flush()/saveAndFlush().
flush() rồi ném RuntimeException thì dữ liệu có còn không?
Không. UPDATE đã gửi nhưng transaction rollback.
Vì sao update 100.000 Entity bằng vòng lặp setter là ý tưởng tồi?
Load toàn bộ vào memory, giữ snapshot, dirty checking tốn CPU, 100.000 câu UPDATE. Dùng bulk UPDATE hoặc batch + clear().