Về bản web

caltalys.dev

"detached entity passed to persist" - người bị gạch tên xin quay lại văn phòng

Vì sao một entity từng được quản lý lại thành tội đồ ở request sau, và merge thật ra làm gì với nó.

Caltalys · 18/09/2026 · 11 phút đọc

Thằng em nhắn, kèm một stacktrace và một câu mở đầu rất tự tin:

thang em Anh, em biết chữ detached nghĩa là gì rồi - bài trước anh vừa dạy. Nhưng em không hiểu sao nó lại là một cái tội.

Chuyện của nó là một màn hình edit kinh điển. GET để load form, user sửa, POST để lưu:

// GET /orders/5/edit
Order order = orderService.load(5L);      // load xong, transaction đóng
session.setAttribute("editing", order);   // giữ lại cho lượt POST

// POST /orders/5/edit - một request KHÁC, vài phút sau
Order order = (Order) session.getAttribute("editing");
order.setNote(form.getNote());
orderService.saveWithItems(order);        // 💥

Và cú nổ:

jakarta.persistence.EntityExistsException
Caused by: org.hibernate.PersistentObjectException:
           detached entity passed to persist: com.acme.Order

Một chuyện về cái tên, trước khi bạn đi tìm nó trong log của mình: thứ bay ra tới tay bạn là EntityExistsException; câu detached entity passed to persist - câu thật sự nói cho bạn biết chuyện gì đã xảy ra - nằm ở getCause(). Và nó còn đổi tên thêm lần nữa tuỳ bạn đứng đâu: gọi Session của Hibernate trần thì bạn nhận thẳng PersistentObjectException, còn nếu còn một bean @Repository của Spring đứng giữa thì lớp dịch ngoại lệ bọc tiếp thành DataIntegrityViolationException. Cùng một tiếng chuông, tên đổi theo tầng. Bắt exception theo tên là cách nhanh nhất để viết một khối catch không bao giờ chạy.

thang em Cái object này em load từ chính Hibernate ra chứ đâu. Giờ em đưa lại cho nó lưu, nó hất tay em: đồ detached. Load ra thì được mà nhận lại thì không?

Để trả lời, phải nói một chuyện mà bài trước mình mới kể một nửa. Bài trước bạn biết hai trạng thái: managed - có tên trong sổ, và detached - từng có tên, sổ đã đóng. Nhưng bức tranh đầy đủ có bốn, và cả bốn chỉ là bốn câu trả lời cho đúng một câu hỏi: vật này đứng ở đâu so với cuốn sổ?

Transient - chưa từng có tên. Object bạn vừa new, chưa ID, chưa hàng nào dưới DB, cuốn sổ chưa nghe tên bao giờ. Một đứa trẻ chưa khai sinh. Managed - có tên trong sổ, có ảnh chụp, mọi cú set đều được flush soi tới, như bạn đã thấy. Detached - từng managed, nhưng cuốn sổ theo dõi nó đã đóng; nó có ID, có thể có cả hàng thật dưới DB, chỉ là không sổ nào đang nhìn. Removed - có tên trong sổ nhưng đã bị đánh dấu xóa, flush tới sẽ thành DELETE.

Nói gọn: transient là chưa khai sinh, managed là có hộ khẩu, detached là bị gạch tên, removed là đang chờ xóa sổ.

Và các động từ của JPA - persist, merge, remove - không phải “lệnh ghi DB” như tên chúng gợi ý. Chúng là thủ tục hành chính chuyển trạng thái, mỗi thủ tục chỉ nhận đúng loại hồ sơ của nó.

persist là thủ tục khai sinh: nhận một transient, ghi tên vào sổ, cấp ID. Nó được thiết kế cho người chưa từng tồn tại. Giờ nhìn lại cú nổ của thằng em: nó đưa vào một Order có ID 5 rành rành - một kẻ rõ ràng đã từng tồn tại, thậm chí đang nằm dưới DB. Yêu cầu “khai sinh” một người đã có giấy khai sinh, văn phòng từ chối là đúng luật. Thông báo lỗi, dịch sát nghĩa: anh đưa tôi một người bị gạch tên và bảo tôi khai sinh lại - không được.

thang em Nhưng em có gọi persist đâu anh. Em gọi saveWithItems của service em mà.

Thì mở nó ra xem.

@Transactional
public void saveWithItems(Order order) {
    em.persist(order);        // <-- đây
    // ...
}

Nó gọi thẳng em.persist. Không có ai đứng giữa cả - và đó mới là chỗ đáng tiền, vì nếu có một người đứng giữa thì đã không nổ.

Người đó tên là save(). Mở source SimpleJpaRepository.save() - cái mà một dòng orderRepository.save(order) sẽ chạy vào - nó chỉ có ba dòng, đại ý: nếu entity là mới thì persist, không thì merge. Và cách nó đoán “mới”: nhìn ID null hay không (mặc định). Order của thằng em ID = 5, không null - save() sẽ rẽ sang merge, và không có cú nổ nào cả. Ba dòng, và ba dòng đó chính là toàn bộ phần biết chọn thủ tục mà saveWithItems không có.

Đây là một biến thể của cái motif bạn đã gặp suốt hai mùa, chỉ đảo chiều: hôm nay lớp trung gian không gây ra bug - nó là thứ vắng mặt. Và nó vắng mặt vì một lý do rất hợp lý, thứ lý do làm mọi bug hay trở nên hay: người viết saveWithItems chọn em.persist thay vì save() vì persist nghe rõ ràng hơn, ít ma thuật hơn, “em biết chính xác nó làm gì”. Đúng thế thật. Nó làm chính xác đúng một việc - và một việc đó là thủ tục sai.

Vậy thủ tục đúng cho người bị gạch tên là gì? merge - thủ tục xin nhập lại hộ khẩu. (Nó dễ tính hơn persist: đưa vào một transient nó cũng nhận, gặp người chưa khai sinh thì nó làm luôn cả thủ tục khai sinh. Nhưng chuyện hôm nay là người bị gạch tên.) Và đây là chỗ cả bài hôm nay dồn về, vì merge làm một việc gần như không ai đoán đúng ở lần đầu.

Bạn nghĩ merge(order) nghĩa là: “cuốn sổ ơi, nhận lại object này, quản lý tiếp giùm.” Không. Cuốn sổ không bao giờ nhận lại object của bạn. Nó làm thế này: lấy ID từ object bạn đưa, tìm (hoặc load từ DB) bản managed của chính nó, chép từng giá trị từ object của bạn sang bản đó, rồi trả về… bản của nó. Hai object. Object bạn đưa vào - vẫn detached, nguyên si, người ngoài như cũ. Object bay ra từ merge - mới là người có hộ khẩu.

Order managed = em.merge(order);
order == managed;        // false - HAI object khác nhau

Văn phòng không nhận hồ sơ viết tay của bạn vào tủ. Họ chép nội dung sang mẫu của họ, lưu mẫu của họ, và đưa lại cho bạn mẫu đó. Tờ giấy của bạn, bạn cầm về.

Vì sao phải cầu kỳ vậy? Vì cuốn sổ có một luật thép từ bài trước: mỗi danh tính, đúng một object. Order#5 trong sổ chỉ được phép là một instance duy nhất - mọi ảnh chụp, mọi dirty checking dựa trên điều đó. Nếu sổ “nhận lại” object của bạn trong khi biết đâu nó đã đang quản một Order#5 khác (load ở đầu request chẳng hạn), luật vỡ. Nên nó chọn đường an toàn tuyệt đối: bản trong sổ là chân lý, đồ bên ngoài chỉ được chép nội dung, không được thế chỗ.

Và từ cái chi tiết “trả về instance khác” đó, đẻ ra bug im lặng của hôm nay - cặp bài trùng của cú exception ồn ào ở đầu:

Order managed = em.merge(order);   // ai đó viết đúng dòng này...
order.setStatus(CONFIRMED);        // ...rồi tiện tay sửa tiếp OBJECT CŨ

Không lỗi. Không cảnh báo. merge xong xuôi tử tế. Chỉ là cú setStatus rơi lên người ngoài - kẻ không sổ nào nhìn - và đến flush, cuốn sổ so ảnh bản của nó, thấy sạch, chẳng viết gì. Cái status bốc hơi đúng kiểu bạn đã gặp ở bài readOnly: RAM đổi, DB trơ. Tệ nhất là dạng nửa nạc nửa mỡ: sửa vài field trước merge (được chép sang, lưu ngon) và vài field sau merge trên object cũ (bay màu) - dữ liệu lưu một nửa, và bạn sẽ thề trước team rằng Hibernate có bug.

Hai triệu chứng hôm nay: một cú nổ to đùng khi đưa nhầm thủ tục, một cú im lặng khi làm đúng thủ tục nhưng cầm nhầm tờ giấy đi về. Cùng một gốc: cuốn sổ quản lý theo danh tính, không theo cái reference bạn đang cầm. Object trên tay bạn và “entity” trong mắt Hibernate là hai khái niệm chỉ thỉnh thoảng trùng nhau - đúng vào lúc nó managed. Ngoài khoảnh khắc đó, bạn cầm một bản chép không ai chứng thực.

Fix cho màn hình edit của thằng em, xếp theo mức mình khuyên: một - đừng giữ entity qua request. Session/form giữ DTO hoặc chỉ giữ ID; lượt POST load lại entity managed mới tinh trong transaction, đắp giá trị từ form lên, để dirty checking tự lo phần còn lại. Sạch, không merge, không state machine. Hai - nếu phải merge, kỷ luật một dòng: sau merge, mọi thao tác đi tiếp trên giá trị trả về, coi object cũ như đã hết vai. Ba - với cascade, rà lại xem PERSIST có đang được rải lên những đứa con có thể detached không. Đây là biến thể khó thấy nhất của lỗi hôm nay, và nó chạy được thật: cha mới tinh, ID null, nên save() rẽ đúng vào persist - hoàn toàn hợp lệ - rồi cascade bê nguyên thủ tục đó xuống lũ con, trong đó có một đứa detached đi ké từ request trước. Lúc ấy bạn không đưa nhầm hồ sơ, bạn đưa một bộ hồ sơ mà trong đó có một tờ nhầm, và cascade là người rải thủ tục lên từng tờ. Dấu hiệu nhận ra nó: tên trong thông báo lỗi là tên đứa con, không phải tên cha. Hỏi tiếp một câu: liệu MERGE mới là thứ bạn cần?

Muốn tự kiểm chứng: chạy em.merge(order) rồi in order == managed - một chữ false đáng giá hơn ba trang docs. Rồi tái hiện bug nửa nạc: sửa một field trước merge, một field sau merge trên object cũ, mở DB đếm xem bao nhiêu field sống sót. Một cái bẫy sẽ chặn bạn ngay trước khi chạy được dòng đầu tiên, nói ra để bạn khỏi bỏ dở: order là từ khoá SQL, nên một bảng tên order cần trích dẫn định danh mới tạo được. Đổi tên entity đi - trong demo đi kèm nó tên là ShopOrder, đúng vì lý do này.

Còn nguyên lý, thứ sống lâu hơn JPA: khi một hệ thống quản lý vật thể theo danh tính, thì mọi API “đưa vật vào hệ thống” đều phải trả lời một câu: vật của tôi, hay bản sao của hệ thống? Git nhận file của bạn nhưng lưu blob của nó. Kubernetes nhận YAML của bạn nhưng vận hành object trong etcd. Sửa bản của mình sau khi đã nộp - thay đổi không đi đâu cả. Luôn hỏi: bản nào là bản sống?

Thằng em đọc xong, nhắn:

Nộp hồ sơ xong thì làm việc với bản được đóng dấu, đừng viết thêm vào bản nháp.

thang em Vậy merge không phải là ‘hòa cái của em vào sổ’. Là ‘sổ tự làm bản của sổ, chép đồ của em sang - còn cái của em, từ đó là giấy lộn’.

Đúng rồi đó. Nộp hồ sơ xong thì làm việc với bản được đóng dấu, đừng viết thêm vào bản nháp. 😃

Tài nguyên đi kèm

  • ZIP Source demo - tập 3.2

    JDK 17. mvn spring-boot:run → Episode32Runner in khối [EP 3.2]: persist() một entity detached ném gì, merge() trả về object nào, và field sửa sau merge() trên object cũ đi đâu.

    https://caltalys.dev/downloads/book1/3.2-spring-deep-dive-demo.zip

https://caltalys.dev/hibernate-detached-merge

Caltalys · 18/09/2026 · Hibernate · JPA · Persistence Context