软删除 + 唯一索引的幽灵冲突:MySQL 不懂你的 is_deleted
某后台管理系统有张配置表,支持「添加 → 删除 → 再添加同名配置」。删除走软删除(is_deleted=1),列表查询过滤掉已删除行,看起来完全正常。但用户一旦把某条配置删掉再重建,必然报 500:
ER_DUP_ENTRY: Duplicate entry 'report-42-fieldKey' for key 'uniq_report_key'
前端显示「该 key 不存在」,数据库却说「重复了」。
现象
- 第一次创建:成功
- 删除后再创建同名配置:
ER_DUP_ENTRY报错 SELECT ... WHERE is_deleted=0查不到任何冲突行- 触发条件精确:必须经历「创建 → 软删除 → 再创建」这一个完整循环
根因
软删除的本质是逻辑删除,物理行依然存在于表中。而 MySQL 唯一索引不理解任何业务语义——它只看索引列的值,不管 is_deleted 是 0 还是 1。
假设唯一索引定义为:
UNIQUE KEY uniq_report_key (report_id, `key`)软删除后,(report_id='42', key='fieldKey', is_deleted=1) 这一行还躺在表里,牢牢占住了索引槽位。再 INSERT 同样的 (report_id, key) 组合,唯一约束立刻触发。
is_deleted=1 的行对业务层是「不可见」的,但对 MySQL 唯一索引是完全可见的。只要物理行还在,该 key 组合就永久被占用,后续任何同 key 的 INSERT 都会失败。这个 bug 在第一次删除前完全无法复现,往往在灰度或上线后才爆出来。
正解
有两条出路,按场景选一个:
方案 A:软删除时顺手清除同 key 的旧 tombstone
// DELETE 接口核心逻辑
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT report_id, sort, `key` FROM configs WHERE id = ? AND is_deleted = 0',
[id]
)
const { report_id, sort, key: fieldKey } = rows[0]
// 先清理同 key 的旧软删除行,释放唯一索引槽位
await pool.execute(
'DELETE FROM configs WHERE report_id = ? AND `key` = ? AND is_deleted = 1',
[report_id, fieldKey]
)
// 再标记当前行为软删除
await pool.execute('UPDATE configs SET is_deleted = 1 WHERE id = ?', [id])逻辑很直白:同一个 key 在历史上只可能有一条「有效」记录,旧的 tombstone 没有任何保留价值,直接物理清除即可。
方案 B:把 is_deleted 纳入唯一索引
-- MySQL 8.0+
ALTER TABLE configs
DROP KEY uniq_report_key,
ADD UNIQUE KEY uniq_report_key (report_id, `key`, is_deleted);这样 (42, 'fieldKey', 0) 和 (42, 'fieldKey', 1) 被视为两个不同的 key 组合,可以共存。
注意局限:这个方案允许同一个 key 存在多条 is_deleted=1 的历史行,查询时必须始终带 is_deleted=0 过滤,否则历史行会乱入。
推荐方案 A。在软删除当前行之前,先 DELETE 掉同 key 的所有已删除行——它们是无用的幽灵,释放掉即可。既不改表结构,又让「删除 → 重建」循环永远通畅。
额外加固:POST 时提前 409
光靠数据库报 ER_DUP_ENTRY 还不够优雅,在 API 层加一道前置校验,把数据库异常转成可读的业务错误:
// POST 创建接口,先做存在性检查
const [existing] = await pool.execute(
'SELECT id FROM configs WHERE report_id = ? AND `key` = ? AND is_deleted = 0',
[reportId, key]
)
if ((existing as RowDataPacket[]).length) {
return errorResponse('该 key 已存在', 409)
}一句话外卖
软删除的「逻辑不可见」只对业务层有效,MySQL 唯一索引永远活在物理层——软删除前必须清理同 key 的旧 tombstone,否则每次「删除再重建」都是定时炸弹。