软删除只改了写路径,读路径全是幽灵行地雷
做数据清理任务时,我往一张已有记录的表里加了 is_deleted 列,把 18 条孤儿行标记为已删。Migration 跑完,DB 确认软删成功——然而 UI 上那个统计徽章数字还是不对,配置页面还能看到那几条"已删"的记录。
排查了半天才反应过来:我只改了写路径,没动读路径。
现象
Migration 执行后 DB 里 is_deleted=1 的行明明存在,但三条查询接口返回的仍是全量数据,活跃行和幽灵行混在一起。前端徽章显示 9/67,应该是 4/67——多出来的 5 条正是那些"已删"的孤儿行没被过滤掉。
根因
新列上线的时机不对称,是这个坑最本质的机制:
- 表从一开始就有
is_deleted:所有历史 SELECT 都是在"整个生态已知这列存在"的前提下写的,通常都有过滤。 - 中途新增
is_deleted到旧表:已有的每一条SELECT ... FROM <table>语句都是在不知道这列的时候写下的,语义已经悄悄翻转——它现在返回的是"活跃行 + 幽灵行"的混合集,但代码没有任何报错提示。
迁移脚本本身没问题:
ALTER TABLE node_field_config ADD COLUMN is_deleted TINYINT NOT NULL DEFAULT 0;
UPDATE node_field_config SET is_deleted=1, deleted_at=NOW() WHERE id IN (...);但三处读接口都缺了过滤条件:
// 配置列表接口
`SELECT id, node_id, field_key, field_type, sort
FROM node_field_config
WHERE node_id IN (?) ORDER BY sort ASC`
// ↑ 缺 AND is_deleted = 0,幽灵行照旧被返回每一条在 ALTER TABLE 之前写下的 SELECT / JOIN,现在都会隐式返回幽灵行。没有编译报错,没有运行时异常,数据就这样静静地泄漏出去。最危险的是:你看到 Migration 成功,以为任务完成了。
正解
第一步:三列配套,不只加 is_deleted
ALTER TABLE <table>
ADD COLUMN is_deleted TINYINT NOT NULL DEFAULT 0,
ADD COLUMN deleted_at TIMESTAMP NULL,
ADD COLUMN deleted_by VARCHAR(64) NULL;deleted_at 回答"什么时候删的",deleted_by 回答"谁删的 / 哪个清理任务删的",方便精准回滚。
第二步:grep 全部读路径,逐条分类
Migration 提交之前,强制执行一次全仓扫描:
grep -rn "FROM node_field_config" src/
grep -rn "JOIN node_field_config" src/按命中结果分类处理:
| 命中类型 | 操作 |
|---|---|
| SELECT ... FROM <table> 读 | 加 AND is_deleted = 0 |
| JOIN <table> t ON ... 读 | 加 AND t.is_deleted = 0 |
| INSERT INTO <table> 写 | 不变(新行默认 0) |
| UPDATE ... SET is_deleted=1 软删 | 不变(这就是写路径) |
| DELETE FROM <table> 物理删 | 评估是否该改为软删 |
第三步:写入侧加验证器,防止下一批孤儿行产生
// 写接口入口,事务开始前调用
export async function validateFieldKeys(db, body) {
// 查活跃 key:WHERE is_deleted = 0
// 查软删 key:WHERE is_deleted = 1
// 返回 { reason: 'soft_deleted' | 'never_existed' }
}
// 校验失败返回 422 + { invalidFields: [...] },不静默丢弃第四步:冒烟验证——插入幽灵行,断言所有接口都不返回它
// 1. 直接 INSERT 一行 is_deleted=1 的测试数据
// 2. 调用所有涉及该表的 API
// 3. 断言响应中不包含这条行的 id / key
// 4. 断言统计计数 = COUNT(*) WHERE is_deleted=0
// 任一断言失败 → 还有漏网的读路径,回到第二步正确的交付清单:ALTER TABLE 加列 → UPDATE 软删目标行 → grep 全部读路径并补过滤 → 写侧加验证防复发 → 冒烟测试插幽灵行断言零泄漏。少任何一步都是半成品。
附:为什么前几轮修 UI 没用
这个 bug 曾经被当成"前端计数逻辑错误"反复修了好几轮——每次修一个 hook,下次又出现新的显示偏差。原因很简单:前端在用有问题的 API 数据做计算,在错误的数据上打补丁,永远补不完。
数据层不修,UI 层永远在给幽灵行擦屁股。
一句话外卖
给已有表引入
is_deleted不是加一列,是给每一条既有 SELECT 注入了一个隐式 bug——Migration 之后必须 grep 全部读路径、逐条补过滤,才算真正完成软删改造。