Entity Lifecycle và Persistence Context: vì sao không gọi save() mà vẫn UPDATE?

4 câu hỏi~25 phútTừ khóa: transient, managed, detached, removed, Persistence Context, first-level cache, dirty checking, flush, merge

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 đề

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áiVí dụÝ nghĩa
Transientnew Product()Object mới, chưa ai quản lý, chưa có trong DB
ManagedentityManager.find(Product.class, 1L)Persistence Context đang theo dõi
DetachedentityManager.detach(product)Có trong DB nhưng không còn được theo dõi
RemovedentityManager.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ứ:

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):

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àoTransientDetached (hoặc transient)Bất kỳ
Kết quảChính object đó thành managedTrả về bản copy managed, object truyền vào vẫn detachedEntity mới → persist; ngược lại → merge
SQLINSERT khi flush (IDENTITY: INSERT ngay)Có thể SELECT trước rồi UPDATENhư trên
FlushCommit
Đồng bộ pending changes xuống DBKết thúc transaction thành công
Gửi INSERT/UPDATE/DELETELàm thay đổi bền vững, transaction khác thấy được
Vẫn rollback đượcKhông rollback được nữa

7. Khi nào nên dùng / không nên dùng

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


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().