caltalys.dev
readOnly=true mà tôi vẫn insert được
readOnly không phải cái khoá, nó là lời nhắn - và chỉ những ai chịu nghe mới nghe. Bài này hé lộ cuốn sổ của Hibernate.
Thằng em nhắn, và lần này nó mở đầu bằng một câu mình rất thích:
Chuyện là nó viết cụm API báo cáo. Đọc xong mấy bài best-practice, nó gắn @Transactional(readOnly = true) lên cả class service - “cho an toàn, ai lỡ tay ghi gì trong luồng báo cáo thì bị chặn ngay”. Nghe rất hợp lý. Rồi tuần sau, một bạn khác trong team thêm một dòng audit log vào đúng luồng đó:
@Service
@Transactional(readOnly = true)
public class ReportService {
public Report monthlyReport(Long shopId) {
jdbc.update("INSERT INTO audit_log(shop_id, action) VALUES (?, 'VIEW_REPORT')", shopId);
return buildReport(shopId);
}
}
Nó review code, thấy dòng insert, cười thầm: “để xem mày chạy kiểu gì qua cái cờ của tao.” Merge. Chạy. Insert thành công. Bảng audit_log mọc row đều đặn, ngay giữa một transaction mang danh read-only. Không exception, không warning, không một dòng log nào.
readOnly = true mà ghi vẫn ghi. Vậy nó true cái gì? Câu hỏi đúng đấy. Và câu trả lời bắt đầu từ việc đọc lại một chữ mà ai cũng lướt qua trong javadoc của chính cái cờ: “This just serves as a hint.” Lời gợi ý. Không phải mệnh lệnh, không phải rào chắn. Cả mùa này chúng ta đã học @Transactional là lời khai - thì readOnly cũng chỉ là một dòng trong tờ khai đó, và giá trị của lời khai phụ thuộc hoàn toàn vào việc ai đọc nó, và người đọc quyết định làm gì.
Vậy ai đọc? Đi từ dưới lên.
Tầng database: bạn nghĩ Spring sẽ tự gõ xuống DB một câu lệnh kiểu SET TRANSACTION READ ONLY - Postgres, MySQL, Oracle đều có lệnh đó thật. Nhưng không. Spring không gõ câu lệnh nào cả. Chuyện này thậm chí có hồ sơ đàng hoàng: đã có người đề nghị Spring làm đúng điều đó, và Spring team từ chối - vì lệnh này mỗi DB một kiểu, framework không muốn ôm phần vendor-specific.
Nhưng nói “Spring không làm gì ở tầng dưới” thì lại quá tay, và đây là chỗ mình phải cẩn thận hơn bản đầu của bài này. Spring có làm một việc: nó bật cái cờ read-only trên chính cái Connection của JDBC (Connection.setReadOnly(true)) - mặc định là bật, tắt được bằng prepareConnection = false trên dialect. Cái cờ đó không phải một câu SQL; nó là một lời nhắn cho driver. Và từ đây trở xuống thì không còn là chuyện của Spring nữa: mỗi driver hiểu lời nhắn đó một kiểu. Có driver bỏ qua hoàn toàn. Có driver dịch nó thành một transaction read-only thật - driver của Postgres chẳng hạn, mặc định readOnlyMode=transaction, sẽ mở transaction bằng BEGIN READ ONLY, và lúc đó DB chặn thật, nổ thật.
Nên câu đúng phải là: không có một cái rào nào do Spring dựng, còn có rào hay không thì tùy driver bên dưới bạn. Vụ án của thằng em xảy ra trên một stack mà cái cờ kia không được dịch thành gì cả - cụ thể: driver Postgres có tham số readOnlyMode, và nếu nó được đặt thành ignore thì cờ read-only bị bỏ qua hoàn toàn (mặc định transaction thì ngược lại, sẽ mở BEGIN READ ONLY). Đội của nó chạy ignore, di sản từ một lần ai đó gỡ rối chuyện khác. Nên INSERT đi thẳng xuống, DB nhận, DB ghi, DB commit. Đổi driver, hoặc đổi một tham số kết nối, cùng đoạn code đó có thể nổ ngay dòng insert. Đây đúng là kiểu chuyện mà cả series này lải nhải: bạn chưa từng chọn cái mặc định đang quyết hộ bạn. Trước khi tin bài này hay tin bất kỳ ai, chạy thử đúng đoạn code trên với đúng driver của bạn - và đó cũng là lý do phần dưới đây quan trọng hơn phần này: dù DB có chặn hay không, thì Hibernate vẫn xử lý cái cờ theo cách riêng của nó, và cách đó mới là thứ không đổi.
Tầng Hibernate mới là nơi cái cờ được đọc, và đây là phần hay: Hibernate đọc xong không dựng rào - nó tắt một tính năng.
Để hiểu tính năng gì, mình phải hé cho bạn xem trước một thứ mà đáng ra để dành trọn cho mùa sau. Khi bạn load một entity trong transaction, Hibernate ghi nó vào một cuốn sổ - kèm một bản chụp trạng thái lúc load. Đến cuối transaction, ngay trước commit, có một bước tên là flush: Hibernate mở sổ, so từng entity với bản chụp, thấy field nào lệch - gọi là dirty - thì tự viết câu UPDATE tương ứng. Tự. Không cần bạn gọi save(). Đó là lý do code Spring bạn đọc hằng ngày sửa entity xong cứ thế return mà dữ liệu vẫn được lưu - không phải phép màu, là cuốn sổ đang chép hộ bạn ở cửa ra.
Giờ thì readOnly = true dịch sang tiếng Hibernate được: “chuyến này khỏi chép.” Cụ thể là hai việc. Một, đặt flush mode về MANUAL - bước flush trước commit bị bỏ, cuốn sổ không so, không viết UPDATE nào. Hai - cái này mới có từ Spring 5.1, trước đó chỉ có việc một - đánh dấu cả session là read-only để Hibernate vứt luôn bản chụp ngay từ lúc load: đỡ RAM, đỡ CPU so sánh. Cộng lại, readOnly là một cú tối ưu thật sự, đo được. Nhưng để ý cả hai việc đều cùng một hướng: ngừng giúp bạn ghi. Không việc nào là chặn bạn ghi.
Và thế là hai mặt của cái cờ lộ ra, ngược nhau đến trớ trêu:
Câu INSERT tường minh của bạn kia - nó không đi qua cuốn sổ. JdbcTemplate nói chuyện thẳng với connection, native query cũng vậy. Cuốn sổ bị tắt bút thì kệ cuốn sổ; đường của nó không chạm tới. Ghi tường minh: cuốn sổ không cản. Nó có xuống được tới dữ liệu hay không thì lại là câu hỏi của tầng dưới - cái cờ trên Connection và cái driver đang cầm nó, như vừa nói ở trên. Trên stack của thằng em, tầng dưới không cản, nên insert qua thật. Đó là triệu chứng nó thấy.
Còn mặt kia mới là thứ đáng sợ, vì chưa ai trong team nó để ý. Cũng trong cụm service đó có một method “chỉ đọc” nhưng tiện tay dọn dữ liệu:
public Shop shopInfo(Long id) {
Shop shop = repo.findById(id).orElseThrow();
if (shop.getPhone() != null) {
shop.setPhone(normalize(shop.getPhone())); // chuẩn hóa luôn, tiện mà
}
return shop;
}
Ở một class thường, đoạn này lưu thật - cuốn sổ thấy phone lệch bản chụp, tự viết UPDATE, nhiều dev còn không nhận ra mình đang ghi DB. Nhưng trong class readOnly này: bút đã tắt, bản chụp đã vứt. setPhone đổi object trong RAM, response trả ra số điện thoại đẹp đẽ - và DB không hề đổi. Không lỗi. Không log. Lần gọi sau lại trả số cũ chưa chuẩn hóa, thỉnh thoảng có cache thì lại ra số mới, và cả team đuổi theo một “bug chập chờn” trong khi thật ra là một cú ghi đã bốc hơi từ ba tháng trước.
Xếp hai mặt cạnh nhau: cú ghi bạn muốn chặn thì đi qua ngon lành, cú ghi bạn tưởng đã lưu thì biến mất không dấu vết. Cùng một cái cờ. Vì cái cờ chưa bao giờ là rào - nó là công tắc của người chép hộ. Tắt người chép hộ thì kẻ tự đi đường riêng không hề hấn gì, còn kẻ đang dựa vào người chép thì trắng tay.
Còn một cú lừa thứ ba, nhỏ nhưng nối thẳng về bài trước nên phải kể nốt. Nhớ chuyện đi chung xe không? readOnly là thuộc tính của việc mở transaction. Nếu method của bạn khai readOnly = true nhưng vỏ sờ túi thấy đã có một transaction read-write đang chạy - nó đi chung, và lời khai readOnly bị lờ đi không một tiếng động. Bạn tưởng đoạn code này đang chạy chế độ tiết kiệm; thật ra nó đang ngồi trên chuyến xe của người khác, theo luật của người khác. Lời khai chỉ có giá trị lúc mở xe mới - khách đi ghép thì điều khoản của tài xế.
Vậy dùng readOnly thế nào cho đúng? Dùng nó đúng nghĩa của nó: công cụ tối ưu, không phải công cụ an toàn. Gắn cho các luồng thuần đọc để đỡ dirty-check, đỡ RAM, và để các cơ chế routing đọc-ghi (nếu bạn tách replica) biết đường đưa query sang bản sao. Còn nếu điều bạn cần là chặn ghi thật sự - thì đừng trông vào cái cờ, mà đi dựng rào ở nơi có quyền dựng, và kiểm chứng trên đúng driver của mình: một DB user chỉ có quyền SELECT cho luồng báo cáo là cách chắc chắn nhất; còn nếu bạn định dựa vào việc driver dịch cờ thành transaction read-only thật, hãy tra đúng tham số của nó và ghi lại lựa chọn đó ở đâu đó, vì nó là thứ người sau sẽ không đoán ra. Nói cách khác, đây là chỗ để tự cấu hình để connection read-only được set thật xuống driver. Rào phải nằm ở tầng có khả năng nói “không”. Cái cờ của Spring nằm ở tầng chỉ biết nói “khỏi giúp”.
Muốn tự kiểm chứng, mười phút: viết hai method giống hệt nhau cùng sửa một entity không gọi save() - một cái readOnly = true, một cái không. Chạy, mở DB xem. Một bên UPDATE xuất hiện như ma làm, một bên tuyệt không. Ngay khoảnh khắc đó bạn sẽ thấy cuốn sổ - thấy nó bằng sự vắng mặt của nó.
Còn nguyên lý, thứ sống lâu hơn cái cờ: một lời khai gửi cho tầng trung gian chỉ đổi được hành vi của tầng trung gian - nó không tự trở thành luật ở tầng dưới. Cache-Control không ép được proxy nào tôn trọng nó. chmod không cản được root. Còn cờ readOnly thì không tự bảo đảm rằng DB sẽ từ chối cú ghi của bạn - nó có được thi hành hay không là chuyện của dialect, của driver, và của database bên dưới, ba thứ bạn phải đi tra chứ không được giả định. Muốn có luật, phải hỏi: tầng nào thực thi được, và lời khai của mình có xuống được tới đó không.
Thằng em đọc xong, im lặng một lúc. Rồi nó nhắn một câu - và mình biết ngay mùa sau sẽ phải viết gì:
Lời khai gửi tầng trung gian chỉ đổi hành vi tầng trung gian, không tự thành luật ở tầng dưới.
readOnly gì hết - em sửa entity xong không gọi save() mà Hibernate vẫn tự viết UPDATE?? Vậy giờ trong codebase của em… có bao nhiêu câu UPDATE đang chạy mà không ai viết ra nó? Đúng rồi đó. Đếm không nổi đâu. Và cái cuốn sổ đang chép sau lưng em ấy - nó là toàn bộ mùa sau. 😃
Tài nguyên đi kèm
-
ZIP Source demo - tập 2.3
JDK 17.
mvn spring-boot:run→Episode23Runnerin khối[EP 2.3]: đếm user trước và sau khi chạyINSERTbên trong một hàm@Transactional(readOnly = true).https://caltalys.dev/downloads/book1/2.3-spring-deep-dive-demo.zip