Về bản web

caltalys.dev

Set cả hai chiều mà FK trong DB vẫn null

Java có hai field, DB chỉ có một cột. Chỉ owning side cầm bút ký, bên mappedBy chỉ là tấm gương, và cuốn sổ không bao giờ nhìn gương.

Caltalys · 29/09/2026 · 8 phút đọc

Thằng em nhắn, và lần này nó bực - không phải bực Spring, mà bực chính mình:

thang em Anh, em vừa học xong cả một mùa về cuốn sổ. Em làm ĐÚNG SÁCH: sửa entity trong transaction, để dirty checking tự lo. Vậy mà lần này cuốn sổ phản em.
@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> items = new ArrayList<>();
}

@Entity
public class OrderItem {
    @ManyToOne
    private Order order;   // cột FK order_id nằm ở bảng này
}

@Transactional
public void addItem(Long orderId, OrderItem item) {
    Order order = repo.findById(orderId).orElseThrow();
    order.getItems().add(item);   // sửa entity managed, trong transaction
    // hết - theo luật dirty checking, cuốn sổ phải thấy và tự lo
}
thang em Transaction mở. Order là managed, tên rành rành trong sổ. Em add con vào list - list đổi, tức là dirty. Flush chạy. Cascade ALL còn giúp em persist luôn thằng item. Và đúng là item được INSERT thật - nhưng cột order_id của nó: NULL. Con sinh ra không cha. Cuốn sổ thấy hết mà, sao nó ghi thiếu?

Lập luận của nó chặt đến mức đáng khen - và sai ở đúng một tiên đề mà cả mùa trước chưa ai nói với nó: cuốn sổ thấy hết, đúng; nhưng không phải thứ gì nó thấy cũng là thứ nó chép.

Đây là bài mở màn của mùa mới, nên trước hết, thống nhất ba từ, vì chỗ này là nguồn của một nửa số bug mapping trên đời. Trong Java, quan hệ hai chiều của nó thật ra là hai mối quan hệ riêng biệt: cha giữ một List<OrderItem>, và con giữ một tham chiếu Order - hai field, hai hướng, sống độc lập, Java không hề ràng chúng với nhau. Nhưng dưới database chỉ có một sự thật duy nhất: cột order_id trên bảng con. Hai lời khai trong RAM, một dòng mực dưới DB - vậy khi hai lời khai lệch nhau, tin ai? JPA buộc phải chọn, và nó chọn từ đầu, bằng khai báo của chính bạn: bên nào mang cột FK là owning side - bên cầm bút, lời khai được chép xuống DB. Bên kia - bên bạn viết chữ mappedBy - là inverse side: tấm gương phản chiếu, để đọc cho tiện, và khi quyết cột FK thì cuốn sổ không nhìn vào đó. (Siết đúng phạm vi kẻo bạn nhớ quá tay: nói cuốn sổ “không bao giờ nhìn” inverse side là sai. Nó có quyền lực riêng, chỉ là không phải quyền viết FK - cascade khai ở chính nó, và với orphanRemoval = true thì một phần tử bị gỡ khỏi collection cha sẽ ăn hẳn một câu DELETE. Không cầm bút, nhưng không hề vô hình.)

Nói gọn, và chỉ nói về cột FK: con cầm bút, cha cầm gương. mappedBy không phải là “ánh xạ bởi” chung chung - nó là lời khai “tôi chỉ là gương; sự thật nằm ở field kia, bên phía bên kia.”

Giờ tua lại vụ án bằng ba từ đó. order.getItems().add(item) - thằng em viết vào tấm gương. Cuốn sổ, đến giờ flush, mở hồ sơ của item ra xem người cầm bút khai gì: field order của nó - null, vì chưa ai set. Sổ chép đúng lời người cầm bút: INSERT ... order_id = NULL. Còn cái list bên cha có thêm phần tử? Sổ thấy chứ. Nhưng đó là gương - mà gương thì, theo đúng khai báo mappedBy của thằng em, không phải chỗ để đọc sự thật.

Không có bug ở đâu cả. Cuốn sổ không phản ai. Nó chỉ trung thành với một nguyên tắc mà chính bạn đã ký lúc viết annotation: một quan hệ, một người ký; sổ chỉ đọc chữ ký. Cascade cũng không cứu được - cascade là người rải thủ tục (bạn nhớ hắn từ bài detached chứ): nó quyết ai được lưu, không quyết lưu giá trị nào. Item được cascade khai sinh đàng hoàng - với đúng nội dung hồ sơ nó đang có: mồ côi.

Fix thì bạn đoán được: viết vào chỗ người cầm bút. item.setOrder(order) - một dòng, FK có giá trị, xong. Nhưng khoan gấp sách, vì nửa sau của bài mới là chỗ ít người đi tới: nếu chỉ viết vào người cầm bút, mà bỏ quên tấm gương, thì sao?

@Transactional
public void addItem(Long orderId, OrderItem item) {
    Order order = repo.findById(orderId).orElseThrow();
    item.setOrder(order);       // bút ký: chuẩn
    itemRepo.save(item);        // DB: order_id = 42. Hoàn hảo.
    
    // ...vài chục dòng sau, cùng transaction, một đồng nghiệp viết:
    if (order.getItems().isEmpty()) {
        applyFirstItemDiscount(order);   // ??? chạy - dù item vừa được thêm
    }
}

DB đúng tuyệt đối. Nhưng order.getItems() - tấm gương - vẫn rỗng. Vì sao? Vì Hibernate không có nghĩa vụ đồng bộ hai chiều trong RAM giữa chừng transaction. Bạn khai với DB qua bút ký, DB sẽ đúng ở lần load sau; còn trong khoảnh khắc này, hai object Java của bạn vẫn là hai field độc lập như Java sinh ra chúng - bạn viết một bên, bên kia không tự biết. Và thế là cú isEmpty() kia đọc gương, gương chưa lau, logic giảm giá chạy sai - không exception, không warning, dữ liệu DB thì hoàn hảo nên soi kiểu gì cũng không thấy. Bug nằm hoàn toàn trong RAM, sống đúng một transaction rồi tự biến mất, để lại mỗi hậu quả.

Hai triệu chứng, đối xứng gọn gàng: viết vào gương, quên bút - DB sai, RAM đẹp. Viết vào bút, quên gương - DB đẹp, RAM sai. Cùng một gốc: hai chỗ ghi cho một sự thật, và bạn chỉ ghi một.

Đến đây thì cái helper method mà mọi tutorial bảo bạn viết - bảo mà hiếm khi giải thích - tự nó hiện ra như hệ quả tất yếu chứ không phải phép lịch sự:

public void addItem(OrderItem item) {
    items.add(item);      // lau gương
    item.setOrder(this);  // ký bút
}

Nó không phải “best practice cho đẹp code”. Nó là giao dịch nguyên tử cho một sự thật có hai chỗ ghi - đảm bảo bút và gương không bao giờ lệch, dù ai gọi từ đâu. Viết nó một lần trong entity, cấm mọi ngả đường khác (để getItems() trả về bản không sửa được, nếu team bạn nghiêm), và cả họ bug hôm nay tuyệt chủng.

Muốn tự kiểm chứng, năm phút, hai thí nghiệm đúng hai nửa bài: chỉ add vào list, bật log SQL, nhìn câu INSERT mang order_id=NULL - bạn thấy sổ chép lời người cầm bút. Rồi chỉ setOrder, và ngay dòng sau in order.getItems().size() - số 0 nhìn thẳng vào mặt bạn trong khi DB đã có dòng mới - bạn thấy tấm gương không tự lau.

Còn nguyên lý, thứ sống lâu hơn JPA: khi một sự thật được lưu ở hai nơi, hệ thống phải chỉ định một nguồn chân lý - và mọi bản còn lại chỉ là cache của nó. DNS có primary và secondary. Git có upstream và local. Hai cái đồng hồ trong nhà thì một cái phải được tin. Bug không nằm ở việc có hai bản - bug nằm ở chỗ người viết không biết bản nào được tin, và viết nhầm vào bản kia. Câu hỏi “ai là owning side” chỉ là phiên bản JPA của câu hỏi vạn năm: source of truth của mày đâu?

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

Trong mọi quan hệ, phải biết ai cầm bút - cuốn sổ chỉ đọc chữ ký của người đó.

thang em Vậy hôm trước em học ‘em không thao tác trên DB, em thao tác trên cuốn sổ’. Hôm nay phải nối thêm: và cuốn sổ cũng không đọc hết những gì em viết - nó chỉ đọc chữ ký của đúng một bên. Em phải biết ai cầm bút.

Đúng rồi đó. Trong mọi quan hệ, phải biết ai cầm bút - cuốn sổ chỉ đọc chữ ký của người đó. 😃

Tài nguyên đi kèm

  • ZIP Source demo - tập 4.1

    JDK 17. mvn spring-boot:run → Episode41Runner in khối [EP 4.1]: chỉ add() vào list thì order_id ra gì, chỉ setOrder() thì ra gì, và helper làm cả hai thì ra gì.

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

https://caltalys.dev/jpa-owning-side

Caltalys · 29/09/2026 · JPA · Hibernate · Mapping