99 Ngày Spring — Ngày 39: Soft delete & entity patterns
Hôm nay là ba khuôn mẫu entity mà dự án nào chạy đủ lâu cũng gặp: soft delete để không mất dữ liệu vĩnh viễn, optimistic locking để hai người sửa cùng lúc không giẫm lên nhau, và mapping enum — thứ nhìn thì tầm thường nhưng cài sai một chữ là hỏng dữ liệu trong im lặng. Cả ba đều chỉ tốn vài dòng, và cả ba đều có bẫy đáng biết trước.

Soft delete — đánh dấu thay vì xóa
Rất nhiều nghiệp vụ không cho phép mất dữ liệu: hóa đơn phải giữ để đối soát, tài khoản đã xóa vẫn cần cho kiểm toán, và bản ghi xóa nhầm thì phải khôi phục được. Soft delete giải quyết bằng một cột cờ, và Hibernate 6 cho phép làm việc đó gần như trong suốt với tầng service.
// Xóa cứng là mất vĩnh viễn. Rất nhiều nghiệp vụ không cho phép điều đó:
// hóa đơn phải giữ để đối soát, tài khoản xóa rồi vẫn cần cho kiểm toán,
// và bản ghi bị xóa nhầm thì phải khôi phục được.
// SOFT DELETE = đánh dấu thay vì xóa. Hibernate 6 hỗ trợ thẳng:
@Entity
@SQLDelete(sql = "UPDATE san_pham SET da_xoa = true WHERE id = ?")
@SQLRestriction("da_xoa = false") // Hibernate 6.3+; trước đó là @Where
class SanPham {
@Id Long id;
boolean daXoa = false;
}
// repository.delete(sp) giờ chạy UPDATE, và mọi truy vấn tự thêm
// điều kiện "da_xoa = false" — code service KHÔNG phải đổi một dòng.
// Cách thủ công (rõ ràng hơn, dễ kiểm soát hơn, và không dính bẫy bên dưới):
interface SanPhamRepository extends JpaRepository<SanPham, Long> {
List<SanPham> findAllByDaXoaFalse();
@Modifying @Query("update SanPham s set s.daXoa = true where s.id = :id")
void xoaMem(@Param("id") Long id);
}@SQLDeleteđổi lệnhDELETEthànhUPDATE, còn@SQLRestrictiontự thêm điều kiện lọc vào mọi truy vấn — service không đổi một dòng.Cách thủ công dài hơn nhưng nhìn thấy được: không có phép màu ngầm, và bạn kiểm soát chính xác truy vấn nào lọc, truy vấn nào không.
Từ Hibernate 6.3,
@Wheređã đổi thành@SQLRestriction— gặp code cũ thì biết đó là cùng một thứ.
Bốn cái bẫy của soft delete
Đây là phần đáng đọc kỹ nhất. Soft delete rất dễ bật, và cũng rất dễ khiến bạn mất một buổi chiều để hiểu vì sao đăng ký lại một email cũ thì báo trùng khóa.
// BỐN CÁI BẪY của soft delete — biết trước thì tránh được hết:
// 1. UNIQUE INDEX vẫn tính cả bản ghi đã xóa.
// Xóa mềm user "an@x.com" rồi đăng ký lại email đó → lỗi trùng khóa.
// Cách xử lý ở Postgres — chỉ áp unique cho bản ghi còn sống:
CREATE UNIQUE INDEX ux_user_email ON users(email) WHERE deleted = false;
// 2. Thống kê đếm nhầm nếu ai đó viết SQL tay (JdbcClient — Ngày 35)
// mà quên điều kiện: @SQLRestriction chỉ tác dụng với JPA, KHÔNG với SQL thô.
// 3. Bảng phình mãi mãi. Cần một job dọn: xóa cứng những bản ghi
// đã xóa mềm quá 90 ngày, hoặc chuyển sang bảng lưu trữ.
// 4. Quan hệ mồ côi: xóa mềm đơn hàng cha nhưng dòng chi tiết con vẫn "sống".
// Phải quyết định rõ: xóa mềm lan xuống con, hay chỉ đánh dấu ở cha?
// LỜI KHUYÊN THẲNG: đừng bật soft delete cho MỌI entity chỉ vì nó tiện.
// Chỉ dùng cho những bảng nghiệp vụ thật sự cần lịch sử. Với các bảng còn lại,
// xóa cứng vẫn đơn giản hơn và ít bẫy hơn nhiều.Unique index là bẫy phổ biến nhất — lời giải gọn là partial index chỉ áp cho bản ghi còn sống.
@SQLRestrictionkhông tác dụng với SQL thô: mọi câu viết tay bằngJdbcClient(Ngày 35) phải tự thêm điều kiện, kẻo báo cáo đếm cả bản ghi đã xóa.Đừng bật soft delete cho mọi entity. Bảng nào cần lịch sử thật thì bật; còn lại xóa cứng vẫn đơn giản và ít bẫy hơn.
@Version — chống mất cập nhật khi hai người cùng sửa
Bài toán này âm thầm hơn ta tưởng: không ai thấy lỗi, chỉ là thay đổi của một người biến mất. JPA giải nó bằng đúng một dòng — thêm cột @Version — và từ đó Hibernate tự đưa version vào mệnh đề WHERE của mọi lệnh cập nhật.
// OPTIMISTIC LOCKING — bài toán "hai người sửa cùng lúc".
// Hai admin cùng mở sản phẩm #42, A đổi giá 100k, B đổi tên. B lưu sau
// → thay đổi của A biến mất trong im lặng. Đây gọi là LOST UPDATE.
@Entity
class SanPham {
@Id Long id;
String ten;
@Version Long version; // CHỈ CẦN một dòng này
}
// Hibernate tự thêm version vào WHERE và tự tăng lên khi UPDATE:
// UPDATE san_pham SET ten=?, version=6 WHERE id=42 AND version=5
// Nếu B đã lưu trước, version trong DB thành 6, câu lệnh của A khớp 0 dòng
// → Hibernate ném OptimisticLockException. Không ai mất dữ liệu trong im lặng.
// Bắt lỗi đó và trả về thông điệp người dùng hiểu được (Ngày 26 — Problem Detail):
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
ProblemDetail xuLy() {
var pd = ProblemDetail.forStatus(HttpStatus.CONFLICT); // 409
pd.setDetail("Bản ghi vừa được người khác cập nhật. Tải lại rồi thử lại nhé.");
return pd;
}
// Vì sao "optimistic"? Vì nó KHÔNG khóa gì cả — chỉ kiểm tra lúc ghi.
// Đối lập là pessimistic lock (SELECT ... FOR UPDATE) khóa dòng ngay khi đọc:
// an toàn hơn nhưng chặn người khác, dễ deadlock. Với web app, mâu thuẫn
// thực tế rất hiếm, nên optimistic gần như luôn là lựa chọn đúng.Cơ chế rất đơn giản: cập nhật khớp 0 dòng nghĩa là ai đó đã ghi trước bạn — và Hibernate biến điều đó thành exception thay vì im lặng.
Bắt
ObjectOptimisticLockingFailureExceptionrồi trả409 Conflictvới thông điệp người dùng hiểu được, thay vì để lộ 500.Optimistic gần như luôn đúng cho web app: mâu thuẫn thật rất hiếm, nên trả giá bằng một cột version rẻ hơn nhiều so với khóa dòng lúc đọc.
Mapping enum — cái mặc định nguy hiểm
Nếu hôm nay bạn chỉ nhớ được một điều, hãy nhớ điều này: @Enumerated mặc định là ORDINAL, tức lưu vị trí của hằng số. Chèn một hằng số vào giữa enum là toàn bộ dữ liệu cũ đổi nghĩa — không lỗi, không cảnh báo, chỉ là đơn hàng "hoàn tất" bỗng thành "đang giao".
// MAPPING ENUM — chỗ gây mất dữ liệu âm thầm nhất trong JPA.
enum TrangThai { MOI, DANG_GIAO, HOAN_TAT }
@Enumerated(EnumType.ORDINAL) // ❌ MẶC ĐỊNH — và là mặc định TỆ
TrangThai trangThai;
// Lưu 0, 1, 2 theo THỨ TỰ khai báo. Chèn thêm một hằng số vào GIỮA enum
// là toàn bộ dữ liệu cũ mang ý nghĩa khác. Không lỗi, không cảnh báo.
@Enumerated(EnumType.STRING) // ✅ LUÔN dùng cái này
TrangThai trangThai;
// Lưu "MOI", "DANG_GIAO" — đọc được trong DB, và thêm hằng số mới vô hại.
// Đổi lại tốn vài byte, và ĐỔI TÊN hằng số thì phải viết migration (Ngày 34).
// Cần kiểm soát chặt hơn nữa thì dùng mã ổn định + AttributeConverter:
@Converter(autoApply = true)
class TrangThaiConverter implements AttributeConverter<TrangThai, String> {
public String convertToDatabaseColumn(TrangThai t) { return t.getMa(); } // "M", "DG"
public TrangThai convertToEntityAttribute(String ma) { return TrangThai.tuMa(ma); }
}
// Cách này tách hẳn TÊN TRONG CODE khỏi GIÁ TRỊ TRONG DB — đổi tên hằng số
// thoải mái mà không phải đụng tới dữ liệu.
// Kèm ràng buộc ở tầng DB cho chắc, đừng chỉ tin tầng ứng dụng:
ALTER TABLE don_hang ADD CONSTRAINT ck_trang_thai
CHECK (trang_thai IN ('MOI','DANG_GIAO','HOAN_TAT'));Luôn viết
@Enumerated(EnumType.STRING)— vài byte đổi lấy dữ liệu đọc được và an toàn khi enum mở rộng.Với
STRINGthì đổi tên hằng số là một migration (Ngày 34), vì tên hằng đã trở thành dữ liệu.AttributeConvertertách hẳn tên trong code khỏi mã trong DB — đáng dùng khi enum còn thay đổi nhiều hoặc phải khớp mã của hệ thống khác.Thêm ràng buộc
CHECKở tầng database: tầng ứng dụng không phải là nơi duy nhất ghi vào bảng đó.
Bài tập nhỏ
Bật
@SQLDelete+@SQLRestrictioncho một entity, xóa một bản ghi rồi mở database xem: dòng vẫn còn, chỉ đổi cờ.Tạo unique index thường trên email, xóa mềm một user rồi đăng ký lại chính email đó — gặp lỗi trùng khóa, sau đó sửa bằng partial index.
Thêm
@Versionvào entity, mở hai tab cùng sửa một bản ghi, và xác nhận tab lưu sau nhận409thay vì ghi đè.Khai enum với
ORDINAL, lưu vài bản ghi, rồi chèn một hằng số vào giữa và đọc lại dữ liệu cũ — nhìn tận mắt dữ liệu đổi nghĩa.Viết
AttributeConverterlưu mã ngắn, rồi đổi tên một hằng số trong code và xác nhận dữ liệu trong DB không hề bị ảnh hưởng.
Kết lại
Bốn ý gói lại hôm nay: soft delete giữ lại lịch sử với @SQLDelete + @SQLRestriction, nhưng phải xử lý unique index bằng partial index, nhớ rằng SQL thô không được lọc tự động, và đừng bật cho mọi bảng; @Version chặn lost update chỉ bằng một cột, biến chuyện mất dữ liệu âm thầm thành một lỗi 409 tử tế; @Enumerated mặc định ORDINAL là cái bẫy nguy hiểm nhất của tầng entity — luôn dùng STRING, hoặc AttributeConverter khi cần tách tên khỏi dữ liệu; và mọi ràng buộc quan trọng nên có bản sao ở tầng database. Ngày 40 ta khép lại mảng JPA bằng một buổi tổng kết thực chiến: checklist tối ưu hiệu năng gom hết bài học từ Ngày 31 tới giờ. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


