必须用 TINYINT UNSIGNED NOT NULL DEFAULT 0,因 MySQL 无真正布尔类型,TINYINT(1) 易被误算,BIT(1) 驱动兼容差;UNSIGNED 防负值,NOT NULL 避免 NULL 导致查询遗漏,DEFAULT 0 保证新数据默认可见。

直接加 is_deleted 字段并强制所有查询带上 WHERE is_deleted = 0 是最可控的方案,但不加索引、不改唯一约束、不统一类型,上线当天就可能出数据错乱或性能雪崩。
为什么必须用 TINYINT UNSIGNED NOT NULL DEFAULT 0
MySQL 没有真正布尔类型,BOOLEAN 和 TINYINT(1) 都是别名,但容易被 ORM 或客户端当成数值参与计算;BIT(1) 在某些驱动里会转成二进制字节,导致 WHERE is_deleted = 1 匹配失败——你查不到已删数据,却以为逻辑删没生效。
-
UNSIGNED排除负数写入(比如误设-1),避免脏状态干扰判断 -
NOT NULL强制每行都有明确状态,否则WHERE is_deleted = 0会漏掉NULL行(因为NULL = 0永远为 false) -
DEFAULT 0让新插入数据天然“可见”,不用每个INSERT都手动写is_deleted = 0
所有 SELECT 必须显式加 AND is_deleted = 0,不能只靠 ORM 拦截
MyBatis-Plus 的 @TableLogic、Laravel 的 SoftDeletes 等拦截只覆盖 Mapper/Model 层调用。一旦绕过——比如 DBA 直连查表、定时任务脚本、报表 SQL、跨库 JOIN、后台导出用 JDBC 手写查询——就会把已删数据全暴露出来。
面向 CRM 系统的 MySQL 8 数据库模式设计。当用户需要为客户关系管理(CRM)系统设计、评审、优化或生成数据库模式时,应使用此项技能。
- 高频查询语句(如
SELECT * FROM order WHERE user_id = ?)必须补上AND is_deleted = 0 - 视图(
CREATE VIEW v_order AS SELECT ... WHERE is_deleted = 0)可作兜底,但 MySQL 视图不支持对is_deleted做INSERT/UPDATE,仅限查询 - 禁止用生成列(如
is_active AS (is_deleted = 0))替代条件——它无法走索引,且语义模糊
唯一索引冲突必须改索引,不是改业务逻辑
当 username 有唯一索引,用户 A 被软删后,新用户 B 注册同名账号会报 Duplicate entry。这不是业务该妥协,而是索引没区分状态。
- 执行
ALTER TABLE user DROP INDEX idx_username, ADD UNIQUE INDEX idx_username_deleted (username, is_deleted) - 这样
(username='alice', is_deleted=0)和(username='alice', is_deleted=1)就是两个独立索引项 - 注意:如果业务要求“一个用户名只能存在一个未删除实例”,这个方案完全满足;若允许同名多删,也无需额外处理
索引设计要让 is_deleted 参与高效过滤
is_deleted 单独建索引(INDEX idx_is_deleted (is_deleted))效果有限——因为绝大多数查询不会只查“所有已删数据”,而是查“某用户下未删订单”。这时候需要联合索引。
- 把
is_deleted放在联合索引左侧,例如INDEX idx_user_status_deleted (user_id, status, is_deleted) - 这样
WHERE user_id = 123 AND status = 'paid' AND is_deleted = 0才能命中索引,避免全表扫描 - 如果表里已删数据占比长期超 30%,考虑定期归档物理清理,否则索引膨胀和查询抖动会越来越明显
最常被忽略的是:软删除不是加个字段就完事,它是从建表、索引、SQL 编写、ORM 配置到 DBA 权限管控的全链路约束。漏掉任意一环,轻则数据错乱,重则审计失效、合规翻车。

















