Soft Delete Pattern trong Database: Xóa Bản Ghi An Toàn, Khôi Phục Dữ Liệu và Tối Ưu Index với PostgreSQL và MySQL

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Vấn Đề Với Lệnh Xóa Vĩnh Viễn

Hồi mới vào nghề, tôi từng làm ở một sản phẩm SaaS mà nhân viên hỗ trợ vô tình xóa mất tài khoản của khách hàng. Không có bản backup nào đủ gần thời điểm đó. Dữ liệu mất trắng. Chỉ một sự cố đó thôi đã thay đổi hoàn toàn cách tôi xử lý việc xóa dữ liệu từ đó về sau.

Hard delete — DELETE FROM users WHERE id = 42 — mặc định là không thể hoàn tác. Trong các hệ thống production, đó là một rủi ro thực sự. Người dùng click nhầm nút. Script có bug. Admin gõ sai điều kiện WHERE. Khi dữ liệu đã mất, uy tín với khách hàng cũng tiêu theo.

Tôi đã làm việc với MySQL, PostgreSQL và MongoDB qua hàng chục dự án khác nhau. Mỗi database xử lý vòng đời dữ liệu theo cách riêng. Nhưng với các hệ thống quan hệ, soft delete là thứ tôi luôn nghĩ đến ngay khi một tính năng cần nút “Xóa”. Nó đã cứu tôi không ít lần.

Soft Delete Thực Sự Là Gì

Soft delete có nghĩa là đánh dấu một bản ghi là đã xóa thay vì xóa nó vật lý khỏi database. Hàng đó vẫn còn trong database — bạn chỉ gắn cờ để các câu truy vấn của ứng dụng bỏ qua nó.

Cách triển khai phổ biến nhất là dùng một cột timestamp cho phép null:

ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL;

Khi một user bị “xóa”, bạn đặt deleted_at = NOW(). Khi họ đang hoạt động, giá trị vẫn là NULL. Mọi câu truy vấn cần tôn trọng trạng thái xóa đều thêm điều kiện WHERE deleted_at IS NULL.

Một số team thích dùng boolean is_deleted BOOLEAN DEFAULT FALSE. Tôi không đồng ý với cách đó. Phiên bản timestamp cho bạn biết thời điểm xóa xảy ra — điều này quan trọng cho việc kiểm tra audit, cửa sổ lưu trữ 30 ngày theo GDPR, và debug sự cố production lúc 2 giờ sáng.

Các Cột Cần Thêm Vào

  • deleted_at TIMESTAMP NULL — thời điểm bản ghi bị soft-delete
  • deleted_by INTEGER NULL REFERENCES users(id) — ai đã xóa (tùy chọn nhưng hữu ích)

Bỏ qua cột is_deleted boolean riêng biệt. Nó thừa, và lại thêm một cột có thể bị lệch giá trị so với deleted_at. Chỉ cần kiểm tra deleted_at IS NULL là đủ.

Triển Khai Soft Delete trong PostgreSQL

Thiết Lập Schema

CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  user_id     INTEGER NOT NULL REFERENCES users(id),
  total       NUMERIC(10, 2) NOT NULL,
  created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
  deleted_at  TIMESTAMP NULL
);

Câu Lệnh Soft Delete và Khôi Phục

-- Soft delete một đơn hàng
UPDATE orders
SET deleted_at = NOW()
WHERE id = 101 AND deleted_at IS NULL;

-- Khôi phục đơn hàng đã soft-delete
UPDATE orders
SET deleted_at = NULL
WHERE id = 101;

-- Chỉ lấy đơn hàng đang hoạt động
SELECT * FROM orders
WHERE deleted_at IS NULL
ORDER BY created_at DESC;

-- Chỉ lấy đơn hàng đã xóa (màn hình khôi phục cho admin)
SELECT * FROM orders
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC;

Partial Index — Chìa Khóa Hiệu Năng

PostgreSQL hỗ trợ partial index — index chỉ bao gồm các hàng thỏa mãn một điều kiện nhất định. Với soft delete, bạn chỉ index các hàng đang hoạt động và hoàn toàn bỏ qua các hàng đã xóa:

-- Chỉ index bản ghi đang hoạt động — nhỏ hơn và nhanh hơn nhiều
CREATE INDEX idx_orders_user_active
ON orders (user_id)
WHERE deleted_at IS NULL;

-- Cho các truy vấn theo thời gian trên bản ghi đang hoạt động
CREATE INDEX idx_orders_created_active
ON orders (created_at DESC)
WHERE deleted_at IS NULL;

Nếu không làm vậy, một index đầy đủ trên user_id sẽ bao gồm cả các hàng đã xóa. Query planner phải quét qua tất cả trước khi lọc. Với bảng 5 triệu hàng mà 80% đã bị soft-delete, mỗi câu truy vấn lãng phí bốn triệu lần đọc vô ích. Partial index giữ tập dữ liệu làm việc chỉ còn khoảng 1 triệu hàng đang hoạt động. MySQL không hỗ trợ partial index natively — đây là một trong những lý do tôi thích PostgreSQL hơn cho các schema sử dụng nhiều soft delete.

Dùng View Để Code Ứng Dụng Gọn Hơn

Rải WHERE deleted_at IS NULL khắp nơi trong mọi câu truy vấn là một mối nguy về bảo trì. Một kỹ sư quên thêm vào, và các bản ghi đã xóa sẽ lọt ra ngoài UI. Một view tập trung bộ lọc đó vào một chỗ:

CREATE VIEW active_orders AS
SELECT * FROM orders
WHERE deleted_at IS NULL;

-- Câu truy vấn trong ứng dụng trở nên đơn giản hơn
SELECT * FROM active_orders WHERE user_id = 5;

PostgreSQL cho phép bạn biến đây thành một updatable view. Một lưu ý: đảm bảo cả team đều biết view này tồn tại và sử dụng nó nhất quán. Một câu truy vấn thẳng vào bảng orders bị lọt qua sẽ khó phát hiện trong code review.

Triển Khai Soft Delete trong MySQL

Schema và Câu Lệnh Truy Vấn

Cú pháp MySQL gần như giống với PostgreSQL:

CREATE TABLE products (
  id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(255) NOT NULL,
  price      DECIMAL(10,2) NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  deleted_at DATETIME NULL DEFAULT NULL,
  INDEX idx_deleted_at (deleted_at)
);
-- Soft delete
UPDATE products SET deleted_at = NOW() WHERE id = 55;

-- Khôi phục
UPDATE products SET deleted_at = NULL WHERE id = 55;

-- Chỉ lấy bản ghi đang hoạt động
SELECT * FROM products WHERE deleted_at IS NULL;

Giải Pháp Thay Thế Partial Index Trong MySQL

MySQL không hỗ trợ partial index. Cách thay thế gần nhất là dùng composite index với deleted_at ở đầu. Các câu truy vấn lọc theo WHERE deleted_at IS NULL AND user_id = ? sẽ sử dụng cột ngoài cùng bên trái trước:

-- Composite index: deleted_at + user_id
ALTER TABLE orders ADD INDEX idx_soft_user (deleted_at, user_id);

MySQL 8.0.13 đã bổ sung functional index, mở ra một lựa chọn gọn gàng hơn — một generated column hoạt động như bộ lọc partial index:

-- MySQL 8.0.13+: generated column thay thế partial index
ALTER TABLE orders
  ADD COLUMN is_active TINYINT(1) GENERATED ALWAYS AS (IF(deleted_at IS NULL, 1, NULL)) STORED,
  ADD INDEX idx_active_user (is_active, user_id);

Generated column lưu giá trị 1 cho các hàng đang hoạt động và NULL cho các hàng đã xóa. Index nó lại, và bạn có được hành vi lọc gần giống với partial index của PostgreSQL.

Kinh Nghiệm Thực Tiễn Từ Các Dự Án Thực Tế

Ràng Buộc Unique Cần Xử Lý Đặc Biệt

Đây là cái bẫy mà nhiều team vấp phải. Giả sử bạn có ràng buộc UNIQUE trên cột users.email. Một người dùng soft-delete tài khoản, rồi đăng ký lại với cùng địa chỉ email đó. Ràng buộc sẽ chặn lần đăng ký mới — dù hàng cũ đã bị “xóa”.

Trong PostgreSQL, cách khắc phục là dùng partial unique index:

-- Chỉ áp dụng ràng buộc unique cho các hàng đang hoạt động (chưa bị xóa)
CREATE UNIQUE INDEX uq_users_email_active
ON users (email)
WHERE deleted_at IS NULL;

Trong MySQL, một lựa chọn là chuyển email vào bảng lưu trữ deleted_users khi xóa. Cách khác là đưa deleted_at vào unique key — nhưng cách này bị hỏng nếu cùng một email bị soft-delete hai lần.

Khóa Ngoại và Cascading

Soft delete không tự động cascade như ON DELETE CASCADE. Soft-delete một user, và các đơn hàng của họ vẫn ở đó với trạng thái đang hoạt động. Bạn cần xử lý việc lan truyền một cách tường minh. Cách tôi hay dùng:

  • Với quan hệ cha-con, soft-delete bản ghi cha tất cả bản ghi con trong cùng một transaction.
  • Dùng logic ở tầng ứng dụng hoặc trigger để lan truyền soft delete xuống các bảng liên quan.
-- Atomic: soft-delete user và toàn bộ đơn hàng của họ cùng lúc
BEGIN;
  UPDATE orders SET deleted_at = NOW() WHERE user_id = 42 AND deleted_at IS NULL;
  UPDATE users SET deleted_at = NOW() WHERE id = 42;
COMMIT;

Dọn Dẹp Các Bản Ghi Soft-Deleted Cũ

Soft delete không miễn phí về mặt lưu trữ. Một bảng tích lũy các hàng soft-deleted qua nhiều năm sẽ phình to, làm chậm quá trình backup và phức tạp hóa việc bảo trì index. Tôi chạy một job dọn dẹp định kỳ để hard-delete các bản ghi đã bị soft-delete hơn 90 ngày — điều chỉnh khoảng thời gian này cho phù hợp với chính sách lưu trữ dữ liệu hoặc nghĩa vụ GDPR của bạn:

-- PostgreSQL: hard-delete các bản ghi đã soft-delete hơn 90 ngày
DELETE FROM orders
WHERE deleted_at IS NOT NULL
  AND deleted_at < NOW() - INTERVAL '90 days';

Tương đương trong MySQL:

DELETE FROM orders
WHERE deleted_at IS NOT NULL
  AND deleted_at < DATE_SUB(NOW(), INTERVAL 90 DAY);

Lên lịch chạy cái này dưới dạng cron job hoặc database event. Luôn test trên staging trước. Với các bảng lớn, hãy xóa theo lô — ví dụ 10.000 hàng mỗi lần — để tránh giữ table lock quá lâu.

Tích Hợp với ORM

Bạn không cần phải tự làm tay tất cả. Sequelize, Django ORM, Laravel Eloquent và SQLAlchemy đều có hỗ trợ soft delete — tích hợp sẵn hoặc qua plugin nhỏ. Với Python và SQLAlchemy:

from sqlalchemy import Column, DateTime, func
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
    pass

class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, nullable=False)
    deleted_at = Column(DateTime, nullable=True, default=None)

    def soft_delete(self, session):
        self.deleted_at = func.now()
        session.flush()

    def restore(self, session):
        self.deleted_at = None
        session.flush()

    @classmethod
    def active(cls, session):
        return session.query(cls).filter(cls.deleted_at.is_(None))

Thư viện SQLAlchemy-SoftDelete và custom manager của Django có thể áp dụng bộ lọc này ở cấp model. Điều đó có nghĩa là mọi câu truy vấn trên model đó đều tự động loại trừ các hàng đã xóa — không ai trong team có thể quên điều kiện WHERE, vì đó không phải trách nhiệm của họ nữa.

Khi Nào Soft Delete Không Phải Lựa Chọn Phù Hợp

Soft delete không phù hợp với mọi tình huống. Ba trường hợp tôi sẽ bỏ qua nó:

  • Bảng event hoặc log có lưu lượng cao — các hàng theo thiết kế là bất biến. Hãy dùng archive hoặc partition thay vì đánh dấu xóa.
  • Bảng cực kỳ lớn — nếu 80% bản ghi bị soft-delete, mọi truy vấn về dữ liệu hoạt động đều đang lọc bỏ phần lớn bảng. Một bảng archive là cách tách biệt gọn gàng hơn.
  • Yêu cầu nghiêm ngặt về quyền xóa dữ liệu theo GDPR — soft delete một mình không đáp ứng Điều 17. Bạn vẫn cần cơ chế purge thực sự xóa dữ liệu cá nhân. Soft delete cho bạn một cửa sổ phục hồi; nó không thay thế việc xóa thật sự.

Để Mọi Thứ Hoạt Động Bền Vững

Một cột deleted_at. Đó là toàn bộ thay đổi schema. Nhưng nó đã giúp tôi có thể khôi phục các lần xóa nhầm trong chưa đến năm giây — một câu UPDATE, xong việc. Partial index trong PostgreSQL giữ hiệu năng đọc ổn định khi các hàng đã xóa ngày càng chất đống. Job purge 90 ngày ngăn bảng phình to vô thời hạn.

Tất cả sẽ vô nghĩa nếu bộ lọc không được áp dụng nhất quán. Hãy khóa chặt nó ở tầng infrastructure — view, ORM query scope, hoặc row-level security của PostgreSQL. Làm một lần, làm đúng, và phần còn lại của team sẽ không bao giờ phải nghĩ đến nó nữa.

Share: