必须用tinyint(1)类型且默认值为0,查询必须显式加where is_deleted = 0;因整型索引效率高、orm映射准、避免null或字符串导致的漏查、统计失真及唯一性失效等问题。

软删除必须用 TINYINT(1) 字段 + 显式 WHERE deleted = 0 过滤,否则索引无效、查询出错、统计失真——这不是可选项,是硬性约束。
为什么 is_deleted 必须用 TINYINT(1) 而不是 VARCHAR 或 ENUM
MySQL 对整型字段的索引查找效率远高于字符串;TINYINT(1) 实际就是布尔语义,ORM(如 Laravel、MyBatis)能自动映射为 boolean,而 VARCHAR('yes') 会多出拼写错误、大小写敏感、空格干扰等问题。更关键的是:ENUM('0','1') 在排序和索引范围扫描时行为不稳定,MySQL 8.0+ 已明确建议避免用于状态标记字段。默认值必须是 0,不能是 NULL,否则所有查询得写 WHERE is_deleted = 0 OR is_deleted IS NULL,极易漏条件。
WHERE is_deleted = 0 必须出现在每一条业务查询里
几乎所有软删除线上事故都源于这里:开发忘了加这个条件。常见漏点包括:
-
LEFT JOIN user_profile ON u.id = up.user_id—— 如果user_profile表也有is_deleted,但没在ON或WHERE中过滤,就会关联出已删除的档案 -
COUNT(*) FROM user用于分页总数,但没加WHERE is_deleted = 0,导致页码错乱 - 后台导出 SQL 独立维护,常被遗忘加过滤,导出含大量“已删除”脏数据
- MyBatis 的
<select></select>标签手写 SQL,未复用通用AND is_deleted = 0片段
UPDATE ... SET is_deleted = 1 必须带双重防护条件
软删除本质是 UPDATE,不是 DELETE,执行安全边界比物理删除更窄。错误写法:UPDATE user SET is_deleted = 1 WHERE id = 123 —— 若重复执行,影响行数为 0,无报错也无日志,但审计链断裂。正确写法必须同时满足两个条件:
- 主键或唯一键锁定目标记录:
WHERE id = 123 - 排除已软删状态:
AND is_deleted = 0
批量操作前,建议先 SELECT id FROM user WHERE id IN (123,456) AND is_deleted = 0 验证存在性;高并发场景下,可加事务 + ROW_COUNT() 检查是否真的更新了 1 行,否则抛异常提示“记录不存在或已被删除”。
is_deleted 字段要不要建索引?怎么建才有效?
要建,但不能盲目建。单列索引 ALTER TABLE user ADD INDEX idx_is_deleted (is_deleted) 是底线配置。但如果表中 90% 以上数据已是 is_deleted = 1,那么 WHERE is_deleted = 0 是低选择性查询,索引可能被优化器忽略——此时必须看 EXPLAIN 的 key 和 rows 输出。更实用的做法是联合索引:ALTER TABLE user ADD INDEX idx_del_status_ctime (is_deleted, status, created_at),尤其适用于后台列表页按状态+时间排序的场景。注意:不要给 is_deleted 单独建 UNIQUE 索引,它毫无意义;唯一性约束必须结合业务字段(如 email)与 is_deleted 组合,且需用 deleted_at 时间戳替代 NULL 才能生效。
最易被忽略的点是:软删除后,原 UNIQUE(email) 约束失效——同一个邮箱可以反复“删除再注册”,数据库不报错。这个问题不会在日志里报错,只会在上线后悄悄破坏业务逻辑。











