主线环境:Node.js、TypeScript、Sequelize v6、MySQL、
mysql2。
paranoid不是“永远不删除”,而是让常规删除先记录删除时间,并默认隐藏这些记录。
完成本章后,你应该能够:
paranoid: true;destroy、force、restore;普通删除:
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 只能说明何时删除,不能自动记录谁删除、为什么删除、删除前后的所有字段变化。需要完整审计时,应另外设计审计日志。
paranoid 的必要条件Sequelize v6 的 paranoid 模型必须启用时间戳:
timestamps: true,
paranoid: true,
paranoid 依赖一个删除时间戳字段。若 timestamps: false,它不能正常作为 paranoid 模型工作。
Sakila、World 原始表并没有统一的 created_at、updated_at、deleted_at 结构,因此本章假设在 Sakila 旁边创建一个练习用业务表 customer_note,关联到 customer.customer_id。
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:
NULL:当前有效;NULL:已被软删除,值是删除时间。索引 (customer_id, deleted_at) 对“查询某个客户的有效备注”更有帮助:
WHERE customer_id = ? AND deleted_at IS NULL
不要只因为 Sequelize 自动追加 deleted_at IS NULL 就忽略索引设计,应结合高频查询和 EXPLAIN 决定索引。
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 做映射,避免团队混淆属性名和列名。
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。
const notes = await CustomerNote.findAll({
where: {
customerId: 1,
},
});
近似 SQL:
SELECT ...
FROM customer_note
WHERE customer_id = 1
AND deleted_at IS NULL;
这条条件由 Sequelize 自动添加。findOne、findByPk、count、update 等常规 Model 查询也会遵循 paranoid 语义。
一个常见误判是:数据库明明存在主键 10,findByPk(10) 却返回 null。原因可能不是记录不存在,而是它已软删除。
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。查看已删除数据通常属于管理权限,应单独设计路由、鉴权和审计。
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,
},
});
恢复前要重新检查业务约束。例如记录删除期间,可能已经创建了另一条占用相同业务唯一值的有效记录,此时恢复可能失败或导致业务重复。
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 是不可逆操作,应限制到后台清理任务或明确的管理员能力。执行前确认备份、保留期、外键影响和合规要求,并对批量删除分批处理,避免长事务和大范围锁。
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。
假设 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 而触发。
如果业务要求级联软删除,必须显式设计服务逻辑,并用事务保证一致性。还要考虑恢复父记录时是否恢复全部子记录,以及如何区分“此前已经独立删除的子记录”。这也是级联软删除比看起来复杂的原因。
假设用户表要求邮箱唯一:
UNIQUE KEY uk_user_email (email)
用户被软删除后,该行仍存在,所以同一邮箱仍不能重新注册。这不是 Sequelize 错误,而是 MySQL 唯一约束正常工作。
常见业务选择:
NULL 和唯一索引的行为,并验证并发场景。不要天真地建立 UNIQUE(email, deleted_at) 并认为能保证“有效记录 email 唯一”。MySQL 唯一索引允许多行包含 NULL,多个未删除行的 deleted_at 都为 NULL 时,可能仍允许重复 email。可以研究生成列方案,但它增加数据库特定复杂度,应通过迁移、并发测试和团队评审后使用。
无论采用哪种方案,最终一致性必须由 MySQL 唯一约束保证,不能只做“先查询是否存在,再插入”,因为并发请求会产生竞态条件。
“删除备注并写审计记录”应在同一事务中:
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”天然看作不可被并发打断的原子操作。
destroy、restore 可以触发相应 hooks,但批量操作与逐实例 hooks 的行为不同。若依赖实例级 hook,可能需要 individualHooks: true:
await CustomerNote.destroy({
where: {
customerId: 1,
},
individualHooks: true,
});
这通常会先加载实例并逐条执行 hook,SQL 次数和内存占用明显增加。不要把大量业务副作用隐藏在 hooks 中;跨模型写入、审计和外部消息通常在服务层显式组织更容易理解和测试。
使用 paranoid 后,每个指标都要明确口径:
paranoid: false;paranoid: false 并过滤 deletedAt != null;deleted_at,还需要结合创建和删除时间定义历史快照。软删除不自动形成完整历史版本。若业务要求回答“去年某一天字段值是什么”,需要事件表、历史表或专门审计设计。
软删除会持续占用:
生产系统通常需要数据生命周期策略,例如:
软删除不是逃避数据治理的理由。有些隐私删除请求要求数据不可恢复,此时只设置 deleted_at 明显不够。
如果某条记录缓存在 Redis:
MySQL 仍是事实来源。对学习项目,先把软删除、恢复、权限和事务处理正确,再考虑 Redis 缓存。
paranoid: true,却关闭 timestamps;paranoid: false 表示只查已删除数据;force: true,却没有权限和审计保护;UNIQUE(email, deleted_at) 错误推断 MySQL 会限制多个 NULL;paranoid: false 表示包含已删除行,不表示只查已删除行;restore() 恢复记录,force: true 执行物理删除;以下练习基于 MySQL Sakila 或 World,并允许增加练习表:
customer_note 表及对应 Sequelize Model,要求使用 created_at、updated_at、deleted_at,并启用 paranoid。customer_note 增加 (customer_id, deleted_at) 索引,使用 EXPLAIN 比较查询某客户有效备注时的执行计划。customer_email_alias 练习表,要求软删除后邮箱是否可复用由你选择。说明选择,并用 MySQL 约束处理并发一致性。city_comment 练习表。分析软删除 Country 不会自动软删除 CityComment 的原因,并提出明确的级联和恢复规则。