返回博客

软删除只改了写路径,读路径全是幽灵行地雷

做数据清理任务时,我往一张已有记录的表里加了 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,幽灵行照旧被返回
坑:给旧表新增软删除列,等于给所有既有 SELECT 埋了定时炸弹

每一条在 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
// 任一断言失败 → 还有漏网的读路径,回到第二步
正解:Migration 完成 ≠ 软删生效,读路径全部过滤才算完

正确的交付清单:ALTER TABLE 加列 → UPDATE 软删目标行 → grep 全部读路径并补过滤 → 写侧加验证防复发 → 冒烟测试插幽灵行断言零泄漏。少任何一步都是半成品。

附:为什么前几轮修 UI 没用

这个 bug 曾经被当成"前端计数逻辑错误"反复修了好几轮——每次修一个 hook,下次又出现新的显示偏差。原因很简单:前端在用有问题的 API 数据做计算,在错误的数据上打补丁,永远补不完。

数据层不修,UI 层永远在给幽灵行擦屁股。

一句话外卖

给已有表引入 is_deleted 不是加一列,是给每一条既有 SELECT 注入了一个隐式 bug——Migration 之后必须 grep 全部读路径、逐条补过滤,才算真正完成软删改造。