Sequelize v6 教程 09:Paranoid(软删除)

主线环境:Node.js、TypeScript、Sequelize v6、MySQL、mysql2
paranoid 不是“永远不删除”,而是让常规删除先记录删除时间,并默认隐藏这些记录。

1. 学习目标

完成本章后,你应该能够:

2. 软删除解决什么问题

普通删除:

DELETE FROM customer_note WHERE id = 10;

软删除:

UPDATE customer_note
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = 10
  AND deleted_at IS NULL;

软删除适合:

它不等于完整审计系统。deleted_at 只能说明何时删除,不能自动记录谁删除、为什么删除、删除前后的所有字段变化。需要完整审计时,应另外设计审计日志。

3. paranoid 的必要条件

Sequelize v6 的 paranoid 模型必须启用时间戳:

timestamps: true,
paranoid: true,

paranoid 依赖一个删除时间戳字段。若 timestamps: false,它不能正常作为 paranoid 模型工作。

Sakila、World 原始表并没有统一的 created_atupdated_atdeleted_at 结构,因此本章假设在 Sakila 旁边创建一个练习用业务表 customer_note,关联到 customer.customer_id

4. MySQL 表设计

CREATE TABLE customer_note (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  customer_id SMALLINT UNSIGNED NOT NULL,
  content VARCHAR(500) NOT NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
    ON UPDATE CURRENT_TIMESTAMP(3),
  deleted_at DATETIME(3) NULL,
  PRIMARY KEY (id),
  KEY idx_customer_note_customer_deleted (customer_id, deleted_at),
  CONSTRAINT fk_customer_note_customer
    FOREIGN KEY (customer_id) REFERENCES customer(customer_id)
    ON UPDATE CASCADE
    ON DELETE RESTRICT
) ENGINE = InnoDB;

为什么 deleted_at 允许 NULL

索引 (customer_id, deleted_at) 对“查询某个客户的有效备注”更有帮助:

WHERE customer_id = ? AND deleted_at IS NULL

不要只因为 Sequelize 自动追加 deleted_at IS NULL 就忽略索引设计,应结合高频查询和 EXPLAIN 决定索引。

5. TypeScript Model 定义

import {
  CreationOptional,
  DataTypes,
  ForeignKey,
  InferAttributes,
  InferCreationAttributes,
  Model,
  Sequelize,
} from "sequelize";

const sequelize = new Sequelize("sakila", "app_user", "password", {
  host: "127.0.0.1",
  dialect: "mysql",
  logging: console.log,
});

class Customer extends Model<
  InferAttributes<Customer>,
  InferCreationAttributes<Customer>
> {
  declare customerId: number;
}

class CustomerNote extends Model<
  InferAttributes<CustomerNote>,
  InferCreationAttributes<CustomerNote>
> {
  declare id: CreationOptional<number>;
  declare customerId: ForeignKey<Customer["customerId"]>;
  declare content: string;

  declare createdAt: CreationOptional<Date>;
  declare updatedAt: CreationOptional<Date>;
  declare deletedAt: CreationOptional<Date | null>;
}

CustomerNote.init(
  {
    id: {
      type: DataTypes.BIGINT.UNSIGNED,
      primaryKey: true,
      autoIncrement: true,
    },
    customerId: {
      type: DataTypes.SMALLINT.UNSIGNED,
      allowNull: false,
      field: "customer_id",
    },
    content: {
      type: DataTypes.STRING(500),
      allowNull: false,
    },
    createdAt: {
      type: DataTypes.DATE(3),
      allowNull: false,
      field: "created_at",
    },
    updatedAt: {
      type: DataTypes.DATE(3),
      allowNull: false,
      field: "updated_at",
    },
    deletedAt: {
      type: DataTypes.DATE(3),
      allowNull: true,
      field: "deleted_at",
    },
  },
  {
    sequelize,
    tableName: "customer_note",
    timestamps: true,
    paranoid: true,
    createdAt: "createdAt",
    updatedAt: "updatedAt",
    deletedAt: "deletedAt",
  },
);

模型属性使用 camelCase,field 映射到 MySQL 的 snake_case 列。deletedAt 的属性类型必须允许 null

配置中的 deletedAt 也可以直接改时间戳属性名,例如:

{
  timestamps: true,
  paranoid: true,
  deletedAt: "destroyTime",
}

这表示 Sequelize 使用 destroyTime 作为删除时间戳属性。已有数据库中推荐通过清晰的属性名配合 field 做映射,避免团队混淆属性名和列名。

6. 软删除:destroy()

const note = await CustomerNote.findByPk(10);

if (note !== null) {
  await note.destroy();
}

近似 SQL:

UPDATE customer_note
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = 10
  AND deleted_at IS NULL;

批量软删除:

const affectedRows = await CustomerNote.destroy({
  where: {
    customerId: 1,
  },
});

这里返回受影响行数。destroy 并不是执行 JavaScript 的 delete,而是由 Sequelize 根据 paranoid 配置选择 UPDATE 或 DELETE。

7. 默认查询会隐藏软删除记录

const notes = await CustomerNote.findAll({
  where: {
    customerId: 1,
  },
});

近似 SQL:

SELECT ...
FROM customer_note
WHERE customer_id = 1
  AND deleted_at IS NULL;

这条条件由 Sequelize 自动添加。findOnefindByPkcountupdate 等常规 Model 查询也会遵循 paranoid 语义。

一个常见误判是:数据库明明存在主键 10,findByPk(10) 却返回 null。原因可能不是记录不存在,而是它已软删除。

8. 查询包括已删除数据

const note = await CustomerNote.findByPk(10, {
  paranoid: false,
});

查询所有数据:

const allNotes = await CustomerNote.findAll({
  where: {
    customerId: 1,
  },
  paranoid: false,
});

只查已删除数据,需要显式写条件:

import { Op } from "sequelize";

const deletedNotes = await CustomerNote.findAll({
  where: {
    deletedAt: {
      [Op.ne]: null,
    },
  },
  paranoid: false,
});

paranoid: false 的作用是停止自动添加 deleted_at IS NULL,它本身并不表示“只查询已删除”。

生产接口不应允许普通用户通过任意 query 参数开启 paranoid: false。查看已删除数据通常属于管理权限,应单独设计路由、鉴权和审计。

9. 恢复:restore()

恢复实例:

const note = await CustomerNote.findByPk(10, {
  paranoid: false,
});

if (note !== null && note.deletedAt !== null) {
  await note.restore();
}

近似 SQL:

UPDATE customer_note
SET deleted_at = NULL
WHERE id = 10;

批量恢复:

await CustomerNote.restore({
  where: {
    customerId: 1,
  },
});

恢复前要重新检查业务约束。例如记录删除期间,可能已经创建了另一条占用相同业务唯一值的有效记录,此时恢复可能失败或导致业务重复。

10. 硬删除:force: true

实例硬删除:

const note = await CustomerNote.findByPk(10, {
  paranoid: false,
});

if (note !== null) {
  await note.destroy({ force: true });
}

批量硬删除:

await CustomerNote.destroy({
  where: {
    deletedAt: {
      [Op.lt]: new Date("2025-01-01T00:00:00.000Z"),
    },
  },
  force: true,
});

近似 SQL:

DELETE FROM customer_note
WHERE deleted_at < ?;

force: true 是不可逆操作,应限制到后台清理任务或明确的管理员能力。执行前确认备份、保留期、外键影响和合规要求,并对批量删除分批处理,避免长事务和大范围锁。

11. 一个 Express 5 删除与恢复示例

import { Request, Response, Router } from "express";

const router = Router();

interface NoteParams {
  noteId: string;
}

router.delete(
  "/customer-notes/:noteId",
  async (req: Request<NoteParams>, res: Response) => {
    const noteId = Number(req.params.noteId);

    if (!Number.isSafeInteger(noteId) || noteId <= 0) {
      res.status(400).json({ code: "INVALID_NOTE_ID" });
      return;
    }

    const deletedCount = await CustomerNote.destroy({
      where: { id: noteId },
    });

    if (deletedCount === 0) {
      res.status(404).json({ code: "NOTE_NOT_FOUND" });
      return;
    }

    res.status(204).send();
  },
);

router.post(
  "/customer-notes/:noteId/restore",
  async (req: Request<NoteParams>, res: Response) => {
    const noteId = Number(req.params.noteId);

    if (!Number.isSafeInteger(noteId) || noteId <= 0) {
      res.status(400).json({ code: "INVALID_NOTE_ID" });
      return;
    }

    const note = await CustomerNote.findByPk(noteId, {
      paranoid: false,
    });

    if (note === null) {
      res.status(404).json({ code: "NOTE_NOT_FOUND" });
      return;
    }

    if (note.deletedAt === null) {
      res.status(409).json({ code: "NOTE_NOT_DELETED" });
      return;
    }

    await note.restore();
    res.json({ data: note });
  },
);

实际工程还要补充身份认证、授权、审计日志和统一错误处理中间件。恢复属于状态变更,不建议设计为 GET。

12. 软删除与关联

假设 Customer 与 CustomerNote 是一对多:

Customer.hasMany(CustomerNote, {
  as: "notes",
  foreignKey: "customerId",
});

CustomerNote.belongsTo(Customer, {
  as: "customer",
  foreignKey: "customerId",
});

默认预加载只取得未删除备注:

const customer = await Customer.findByPk(1, {
  include: [
    {
      model: CustomerNote,
      as: "notes",
    },
  ],
});

管理端需要包含已删除备注时:

const customer = await Customer.findByPk(1, {
  include: [
    {
      model: CustomerNote,
      as: "notes",
      paranoid: false,
    },
  ],
});

父模型软删除不会自动递归软删除所有子模型。数据库的 ON DELETE CASCADE 针对物理 DELETE,也不会因为父表执行 UPDATE 设置 deleted_at 而触发。

如果业务要求级联软删除,必须显式设计服务逻辑,并用事务保证一致性。还要考虑恢复父记录时是否恢复全部子记录,以及如何区分“此前已经独立删除的子记录”。这也是级联软删除比看起来复杂的原因。

13. 唯一约束是软删除最常见的坑

假设用户表要求邮箱唯一:

UNIQUE KEY uk_user_email (email)

用户被软删除后,该行仍存在,所以同一邮箱仍不能重新注册。这不是 Sequelize 错误,而是 MySQL 唯一约束正常工作。

常见业务选择:

  1. 邮箱永久唯一:保留原唯一约束,恢复语义最清晰;
  2. 删除后允许复用:删除时把邮箱改写为不可重复的归档值,同时单独保存审计信息;
  3. 把生命周期建模为状态:不把账号删除,而是禁用/关闭;
  4. 使用额外有效标识设计复合唯一性:必须充分理解 MySQL 对 NULL 和唯一索引的行为,并验证并发场景。

不要天真地建立 UNIQUE(email, deleted_at) 并认为能保证“有效记录 email 唯一”。MySQL 唯一索引允许多行包含 NULL,多个未删除行的 deleted_at 都为 NULL 时,可能仍允许重复 email。可以研究生成列方案,但它增加数据库特定复杂度,应通过迁移、并发测试和团队评审后使用。

无论采用哪种方案,最终一致性必须由 MySQL 唯一约束保证,不能只做“先查询是否存在,再插入”,因为并发请求会产生竞态条件。

14. 事务与并发

“删除备注并写审计记录”应在同一事务中:

await sequelize.transaction(async (transaction) => {
  const note = await CustomerNote.findByPk(10, {
    transaction,
  });

  if (note === null) {
    throw new Error("Customer note not found");
  }

  await note.destroy({ transaction });

  await AuditLog.create(
    {
      action: "CUSTOMER_NOTE_DELETED",
      targetId: String(note.id),
    },
    { transaction },
  );
});

如果多个管理员可能同时操作同一记录,可根据业务重要程度评估行锁、乐观锁或基于受影响行数判断状态。不要把“find 后 destroy”天然看作不可被并发打断的原子操作。

15. Hooks 与批量操作

destroyrestore 可以触发相应 hooks,但批量操作与逐实例 hooks 的行为不同。若依赖实例级 hook,可能需要 individualHooks: true

await CustomerNote.destroy({
  where: {
    customerId: 1,
  },
  individualHooks: true,
});

这通常会先加载实例并逐条执行 hook,SQL 次数和内存占用明显增加。不要把大量业务副作用隐藏在 hooks 中;跨模型写入、审计和外部消息通常在服务层显式组织更容易理解和测试。

16. 统计、报表和管理后台

使用 paranoid 后,每个指标都要明确口径:

软删除不自动形成完整历史版本。若业务要求回答“去年某一天字段值是什么”,需要事件表、历史表或专门审计设计。

17. 数据保留与真正删除

软删除会持续占用:

生产系统通常需要数据生命周期策略,例如:

  1. 前台软删除;
  2. 保留 30 或 90 天;
  3. 后台任务分批硬删除;
  4. 对必须留存的审计记录使用单独表;
  5. 对个人敏感信息按合规要求匿名化或真正删除。

软删除不是逃避数据治理的理由。有些隐私删除请求要求数据不可恢复,此时只设置 deleted_at 明显不够。

18. 与 Redis 的边界

如果某条记录缓存在 Redis:

MySQL 仍是事实来源。对学习项目,先把软删除、恢复、权限和事务处理正确,再考虑 Redis 缓存。

19. 常见错误清单

20. 本章小结

21. 练习题(暂不提供答案)

以下练习基于 MySQL Sakila 或 World,并允许增加练习表:

  1. 在 Sakila 中创建 customer_note 表及对应 Sequelize Model,要求使用 created_atupdated_atdeleted_at,并启用 paranoid。
  2. 新增 3 条客户备注,软删除其中 1 条;分别查询默认结果、包含删除结果、只含删除结果,并记录生成 SQL。
  3. 为“删除备注”和“恢复备注”各设计一个 Express 5 路由,要求校验路径参数、区分 404 与状态冲突,且不能允许普通用户硬删除。
  4. customer_note 增加 (customer_id, deleted_at) 索引,使用 EXPLAIN 比较查询某客户有效备注时的执行计划。
  5. 设计 customer_email_alias 练习表,要求软删除后邮箱是否可复用由你选择。说明选择,并用 MySQL 约束处理并发一致性。
  6. 为 Customer 与 CustomerNote 定义关联,分别查询只包含有效备注、包含所有备注的客户详情。
  7. 实现“软删除备注并写入审计表”的事务;让审计写入故意失败,验证软删除是否回滚。
  8. 设计一个 90 天后分批硬删除备注的后台任务方案,说明每批数量、排序字段、事务范围、失败重试和监控指标,不要求真正编写定时任务。
  9. 在 World 数据库旁创建一个 paranoid 的 city_comment 练习表。分析软删除 Country 不会自动软删除 CityComment 的原因,并提出明确的级联和恢复规则。
  10. 设计缓存某条客户备注的 Redis 边界:说明软删除、恢复和硬删除后分别何时使缓存失效;Redis 部分只需方案,不要求代码。

官方文档