Tới nội dung chính
caltalys.dev
Persistence Context

Tôi không gọi save() mà DB vẫn UPDATE

Có một cuốn sổ đang theo dõi bạn kể cả khi bạn không gọi save. Chuyện dirty checking và thời điểm flush của persistence context.

Đồ hình trừu tượng của tập 3.1 trong Quyển Một
Caltalys 11 phút đọc

Thằng em không đợi mình viết tiếp. Sau cái câu “có bao nhiêu câu UPDATE đang chạy mà không ai viết ra nó” ở cuối mùa trước, nó tự đi tìm câu trả lời. Ba ngày sau nó gửi mình một cái test, kèm lời nhắn:

thang em Anh, em cần anh xác nhận là em không bị hoa mắt.
@Test
@Transactional
@Commit
void toi_khong_go_save() {
    User u = repo.findById(1L).orElseThrow();
    u.setName("Tên Mới");
    // hết. không save(). không flush(). không gì cả.
}

Nó bật log SQL. Chạy. Và trong log, rành rành:

Hibernate: update users set email=?, name=? where id=?

Hai chi tiết trong câu đó, để lát nữa bạn đi so với log của mình mà không hoang mang. Tham số là ? chứ không phải giá trị - đó là cách Hibernate ghi log, đừng grep theo tên. Và nó đắp lại cả email, cái field không ai đụng tới: dirty checking tìm ra ai dirty, còn câu UPDATE thì mặc định viết lại toàn bộ hàng. Muốn chỉ ghi đúng cột đã đổi thì phải xin riêng bằng @DynamicUpdate, và đó là một cuộc mặc cả khác.

thang em Em không viết câu UPDATE này. Em grep cả codebase - không ai viết cả. Nó ở đâu ra?

Nó không hoa mắt. Câu UPDATE đó có tác giả, và tác giả là nhân vật mình đã hé cho bạn thấy ở bài trước - khi giải thích vì sao readOnly làm một cú ghi biến mất. Hôm đó mình gọi nó là cuốn sổ, và hứa nó sẽ có riêng một mùa. Mùa đó bắt đầu từ hôm nay. Vì cuốn sổ ấy không phải chi tiết phụ của Hibernate - nó là Hibernate. Hiểu nó, bạn hiểu bảy phần mọi hành vi kỳ lạ của ORM. Không hiểu nó, mỗi bug là một lần bói.

Trước hết, thống nhất ba từ, vì cả mùa này sẽ xoay quanh chúng. Persistence context - tên khai sinh của cuốn sổ - là một vùng nhớ mà Hibernate mở ra cho mỗi phiên làm việc, lõi của nó là một cái Map: key là danh tính entity (class + id), value là chính object đó. (Đó là mô hình tinh thần; trong ruột Hibernate 6 thì value còn bọc thêm một lớp EntityHolder nữa - lát nữa soi tận mắt bạn sẽ thấy.) Lại là một cái Map - bạn đếm được đây là cái thứ mấy trong series rồi đấy. Managed là trạng thái của một entity có tên trong sổ: mọi thứ bạn load lên qua repository trong một transaction đều tự động managed, không cần xin. Và snapshot là tấm ảnh cuốn sổ chụp entity ngay lúc ghi tên: một bản sao giá trị của từng field, cất riêng, bạn không nhìn thấy và không sờ được.

Nói gọn: cuốn sổ là danh bạ kèm ảnh chụp - ai được load lên là có tên trong danh bạ, kèm một tấm ảnh lúc mới vào.

Cuốn sổ này sống ở đâu? Trong đúng cái túi của thread mà bạn đã gặp ở mùa transaction - nơi cái vỏ sờ vào để hỏi “có transaction chưa”. Transaction mở, cuốn sổ mở theo, nằm cùng túi. Transaction đóng, cuốn sổ đóng. (Nói cho đủ một câu, để lát nữa bạn không phải sửa lại mental model: đó là vòng đời mặc định, cuốn sổ gắn với transaction. Có cách làm sổ sống dai hơn transaction - và Boot còn bật sẵn một cách như vậy cho bạn mà không hỏi. Hai bài nữa mình mở nó ra. Trong bài này thì luật đơn giản kia đúng.) Nhớ chi tiết này; nó là chìa khóa của nửa sau bài.

Giờ tua lại cái test của thằng em, lần này nhìn bằng mắt của cuốn sổ.

findById(1L) - Hibernate chạy SELECT, đúc object User, và trước khi trao vào tay thằng em, nó làm hai việc sau lưng: ghi tên vào sổ (User#1 → object này), và chụp ảnh (name="Tên Cũ", email=..., ...). Thằng em nhận object, tưởng mình cầm một POJO trơn. Không - nó đang cầm một người có hồ sơ.

u.setName("Tên Mới") - một lệnh Java trần trụi, đổi một field trong RAM. Không Hibernate nào chứng kiến, không sự kiện nào bắn ra. Cuốn sổ không hề biết. Chưa biết.

Rồi test kết thúc, transaction chuẩn bị commit. Và ngay trước khoảnh khắc đó là bước bạn đã thuộc tên từ bài trước: flush. Cuốn sổ mở ra, duyệt từng cái tên trong danh bạ, đặt object hiện tại cạnh tấm ảnh cũ, so từng field. User#1: ảnh ghi “Tên Cũ”, người thật giờ là “Tên Mới”. Lệch. Trong nghề gọi là dirty. Và với mỗi kẻ dirty, cuốn sổ tự tay viết câu UPDATE tương ứng, gửi xuống DB, rồi mới cho commit chạy.

Đó là tác giả của câu SQL không ai viết. Cơ chế tên là dirty checking, và nó chạy trong mọi transaction của mọi app Spring, hàng triệu lần mỗi ngày, kể cả lúc bạn chưa từng nghe tên nó.

Nhưng khoan. Nếu cuốn sổ tự lo hết, thì cái repo.save(u) mà bạn vẫn gọi bấy lâu nay… để làm gì?

Câu trả lời hơi mất mặt: với entity đang managed, gần như để làm cảnh. Bạn gọi hay không, flush vẫn so ảnh, UPDATE vẫn được viết. save() có việc thật của nó - nhưng cho người chưa có tên trong sổ, chuyện của bài sau. Còn cái thói quen “sửa xong phải save cho chắc” trên entity vừa load - nó gần như vô hại, và vô dụng. Codebase đầy những lời cầu nguyện gửi cho một vị thần đằng nào cũng làm việc đó.

Một chi tiết nữa để bức tranh đủ: flush không chỉ chạy trước commit. Nó còn chạy trước một câu query đụng tới bảng có kẻ đang dirty - vì nếu không, query sẽ đọc phải dữ liệu cũ trong khi RAM đã mới. Nên đôi khi bạn thấy UPDATE xuất hiện giữa method, ngay trước một SELECT chẳng liên quan. Không phải ma. Là cuốn sổ dọn nhà trước khi cho khách vào xem.

Đến đây thằng em nhắn:

thang em Ghê thật. Nhưng nếu vậy thì em thấy sợ hơn là thích. Nghĩa là mọi cú set trên entity đều có nguy cơ thành UPDATE? Kể cả lúc em chỉ định sửa tạm để render?

Câu hỏi rất đúng. Và câu trả lời là: chỉ khi cuốn sổ còn mở và người đó còn tên trong sổ. Đó là nửa sau của bài - mặt ngược, và là cặp bài trùng của triệu chứng đầu.

Nó thử ngay:

// service - giả định: spring.jpa.open-in-view=false
@Transactional
public User loadUser(Long id) {
    return repo.findById(id).orElseThrow();
}   // ← transaction đóng ở đây. cuốn sổ đóng theo.

// controller - ngoài transaction
User u = service.loadUser(1L);
u.setName("Tên Mới Hơn Nữa");
// ... và lần này: KHÔNG UPDATE nào cả. DB trơ trơ.

Cùng một object, cùng một cú setName. Trong service: lưu. Ngoài controller: không. Không lỗi, không cảnh báo - chỉ là một cú ghi có lúc ăn có lúc không, tùy vị trí dòng code trên trục thời gian của transaction.

Cái dòng giả định trong đoạn code trên không phải để cho đẹp, mình phải nói rõ ngay kẻo bạn về chạy thử rồi hoang mang: ví dụ này đứng được là nhờ open-in-view đã tắt - mà Boot thì bật sẵn nó. Với cấu hình mặc định của Boot, cuốn sổ chưa đóng ở cái dấu ngoặc kia; nó sống tới hết request, và u vẫn còn tên trong sổ. Cú setName ngoài controller khi đó vẫn không sinh UPDATE, nhưng vì một lý do khác hẳn - và cái lý do khác hẳn đó có mặt tối riêng của nó. Hai bài nữa mình mở ra.

Nói xong điều kiện rồi thì nói được câu này: nếu series có một bảo tàng bug im lặng, đây là hiện vật trưng giữa sảnh.

Cơ chế thì bạn tự ghép được: transaction đóng → cái túi được dọn → cuốn sổ đóng và vứt cùng danh bạ lẫn ảnh chụp. Object u vẫn sống nguyên trong RAM - nhưng tên nó không còn trong sổ nào nữa. Người ta gọi trạng thái đó là detached: từng có hộ khẩu, giờ là người ngoài. Cú setName ngoài controller là bạn sửa hồ sơ của một người mà không văn phòng nào còn quản lý. Sửa thì cứ sửa. Không ai chép.

Hai triệu chứng, đặt cạnh nhau lần cuối: một cú ghi bạn không hề ra lệnh - được thực hiện chu đáo; một cú ghi bạn chủ ý làm - rơi vào khoảng không. Cùng một nguyên nhân: bạn chưa bao giờ là người nói chuyện với DB. Cuốn sổ nói. Và cuốn sổ chỉ nói về những cái tên trong nó, chỉ nói khi nó còn mở. Câu hỏi đúng chưa bao giờ là “mình đã gọi save chưa” - mà là “lúc này, vật này có đang bị theo dõi không”.

Muốn tự kiểm chứng, mười phút và hai thí nghiệm - chính là hai nửa bài này: bật log SQL, chạy cái test không-save của thằng em và xem UPDATE hiện ra; rồi bê đúng cú set đó ra ngoài transaction và xem nó câm lặng. Muốn nhìn tận mặt cuốn sổ thì đừng soi thẳng biến entityManager trong debugger: cái bạn @Autowired vào là một vỏ proxy của Spring, không phải cuốn sổ, và soi xuống dưới nó chỉ có vỏ. Lại đúng khuôn mặt số một của series, lần này đứng chắn ngay trong phép kiểm. Phải bóc ra mới chạm được:

var pc = em.unwrap(SessionImplementor.class).getPersistenceContext();
System.out.println(pc.getEntitiesByKey());                            // danh bạ
System.out.println(Arrays.toString(pc.getEntry(u).getLoadedState())); // tấm ảnh

Dòng thứ hai in ra cái Map danh tính. Dòng thứ ba in ra tấm ảnh chụp - và để ý là nó không nằm trong cái Map kia, mà ở một chỗ riêng, lấy qua getEntry. Danh bạ một nơi, ảnh một nơi, cuốn sổ ghép chúng lại lúc flush.

Còn nguyên lý, thứ sống lâu hơn Hibernate: đây là mẫu thiết kế tên unit of work - thay vì bắt bạn ra lệnh từng cú ghi, hệ thống theo dõi mọi thay đổi trong một phiên rồi đồng bộ một thể ở cuối. Git làm vậy với working tree. React làm vậy với virtual DOM. Đổi lại sự tiện - code gọn, ghi tối ưu, gom batch - bạn trả bằng một điều: khoảnh khắc ghi không còn nằm trong tay bạn. Nó nằm ở cuối phiên, do bộ máy quyết, và mọi hiểu lầm về “phiên đang mở hay đóng” đều thành bug im lặng.

Thằng em, sau hai thí nghiệm, nhắn:

Em không thao tác trên database. Em thao tác trên một cuốn sổ - và cuốn sổ mới là thứ nói chuyện với DB.

thang em Vậy là từ trước tới giờ em hiểu ngược. Em tưởng em ghi DB, còn Hibernate giúp em. Hóa ra cuốn sổ ghi DB, còn em chỉ đang sửa đồ trong RAM - và may rủi nằm ở chỗ sổ có đang nhìn hay không.

Đúng rồi đó. Em không thao tác trên database. Em thao tác trên một cuốn sổ - và cuốn sổ mới là thứ nói chuyện với DB. 😃

À, còn cái chữ “detached” kia - đừng tưởng nó chỉ là một trạng thái vô hại của object ngoài rìa. Người bị gạch tên mà cứ tìm cách quay lại văn phòng, thì có chuyện để kể đấy. Bài sau.

Tài nguyên tập 10

Cheat-sheet tập 10 Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được. Source demo - tập 3.1JDK 17. mvn spring-boot:run → Episode31Runner in khối [EP 3.1]: cùng một lệnh setName(), trong transaction thì DB đổi theo, ngoài transaction thì chỉ đổi trong RAM.

Vị trí trong Lớp Trung Gian Vô Hình

Bài này trả lời

  • Sửa một object rồi mà không gọi save, dữ liệu vẫn xuống database (hoặc ngược lại: gọi rồi mà không xuống)
Bản in
Chia sẻ: Facebook X LinkedIn Email
Nhận bài mới qua email
Powered by follow.it