Đây là bẫy kinh điển của soft delete: hàng đã "xoá" vẫn nằm trong bảng nên vẫn chiếm chỗ trong unique index.
Cách 1 — partial unique index (gọn nhất trên PostgreSQL): chỉ áp ràng buộc cho hàng còn sống.
create unique index users_email_active_key
on users (email)
where deleted_at is null;Hàng đã xoá không nằm trong index, email được tái sử dụng ngay, và index cũng nhỏ hơn.
Cách 2 — đưa dấu hiệu xoá vào chính khoá. Với database không hỗ trợ partial index (MySQL), dùng cột phụ:
-- deleted_flag = 0 when alive, = id when deleted
unique (email, deleted_flag)Vì mỗi hàng đã xoá có deleted_flag khác nhau nên chúng không đụng nhau, còn hàng sống thì cùng 0 nên vẫn bị chặn trùng. Cách này chạy được nhưng khó đọc hơn.
Cách 3 — không giữ hàng chết trong bảng chính: chuyển sang bảng users_archive khi xoá. Bảng chính sạch, mọi ràng buộc và mọi query trở lại bình thường; đổi lại phải viết logic chuyển bảng.
Hai hệ quả khác của soft delete cũng thường bị hỏi tiếp: mọi query đều phải nhớ điều kiện deleted_at is null (quên một chỗ là rò dữ liệu đã xoá — nên bọc bằng view hoặc global scope của ORM), và khoá ngoại vẫn trỏ tới hàng đã xoá nên database không ngăn được việc tham chiếu bản ghi không còn hiệu lực.