先给结论:逻辑删除和唯一索引描述的是两件不同的事,硬凑在一起就会出现「查重说没重复、插入报 Duplicate entry」的矛盾结果。
逻辑删除说的是「这行我不要了,但留着」,唯一索引说的是「这个值在表里只能出现一次」------它不知道也不关心那行是不是已经被标记删除了。重构时逐张表过唯一索引,这是第一个必须提前定的边界,不是等报错再补。
下面把我们过表时想的东西按顺序摊开:问题怎么产生、四种解法和各自被否的理由、最后按什么判据分类落地。
一、问题是怎么产生的(三条机制叠在一起)
一组很常见的配置,看代码没有任何问题:
yaml
# 全局逻辑删除(MyBatis-Plus 示例)
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleteTime
logic-delete-value: now()
logic-not-delete-value: "null"
实体基类里放一个删除时间:
java
/** 删除时间:null 表示正常,非 null 表示已删除 */
@TableLogic
private LocalDateTime deleteTime;
表上再给业务键加唯一索引:
sql
CREATE TABLE biz_bill (
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
tenant_id bigint NOT NULL COMMENT '租户ID',
bill_code varchar(32) NOT NULL COMMENT '单据号',
delete_time datetime(3) DEFAULT NULL COMMENT '删除时间(逻辑删除)',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_bill_code (tenant_id, bill_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
三条机制各自都很对:
deleteById不再发 DELETE,被改写成一条 UPDATE,把delete_time写成当前时间;那一行物理上还在表里。- 唯一索引的构成是
(tenant_id, bill_code),跟delete_time半毛钱关系没有------被标记删除的行照样在索引里占位。 - 应用层查重走的是 MyBatis-Plus 的查询,「未删除」的值是 null,所以 Wrapper 查询会自动追加
delete_time IS NULL,已删除的行永远查不到。
于是流程是这样的:
java
// 查重:注意这里看不到已删除的行
long dup = mapper.selectCount(new LambdaQueryWrapper<Bill>()
.eq(Bill::getTenantId, tenantId)
.eq(Bill::getBillCode, billCode));
if (dup > 0) {
throw new BusinessException("bill.duplicate");
}
// 查重通过,落库
mapper.insert(bill);
// → SQLIntegrityConstraintViolationException:
// Duplicate entry '1001-SO20261001' for key 'uk_tenant_bill_code'
用户看到的是 500,日志里查重结果是 0。两个组件都在按自己的定义正确工作,错的是没人规定「删除」在这套系统里到底意味着什么。
这一条最值得记住:查重语句的过滤条件和唯一索引的列,必须是同一个范围。 查重时排除掉的行,唯一索引却在管,这个偏差就是全部毛病的来源。
二、四种解法,和它们各自被否的理由
方案一:把 delete_time 加进唯一索引
第一反应都是这个:让唯一性只对「未删除的行」成立。
sql
UNIQUE KEY uk_tenant_bill_code (tenant_id, bill_code, delete_time)
这个改法是错的,而且错得很隐蔽------它会让唯一约束直接失效。
SQL 里 NULL 不参与唯一性判重:唯一索引中允许多行在索引列上取 NULL。而逻辑删除的「未删除」值恰好就是 NULL,于是:
| tenant_id | bill_code | delete_time | 索引里算不算重复 |
|---|---|---|---|
| 1 | SO20261001 | NULL | 不算 |
| 1 | SO20261001 | NULL | 不算(可以插进去) |
| 1 | SO20261001 | NULL | 不算(还可以插) |
原本想防的重复,一个也没防住;更要命的是它不报错,插进去一堆同号单据,等你发现时数据已经脏了。
真想走这条路,就得把「未删除」从 NULL 换成一个固定哨兵值(比如 1970-01-01 00:00:00.000),让未删除行在索引列上取同一个值。代价是 delete_time 的可空语义没了(一个字段两种含义),所有 IS NULL 的判断都得改成哨兵比较------为了一处唯一性,把全局的删除判断都改了,不划算。
顺带一句:PostgreSQL 也一样,唯一约束默认把 NULL 当互不相同(15 之前没有 NULLS NOT DISTINCT 这个选项)。结论收窄一句:在把 NULL 当「互不相同」的数据库里(MySQL、PostgreSQL 默认行为),「唯一索引 + 可空逻辑删除列」不成立。
SQL Server 恰好相反:它的唯一索引把 NULL 视为相等,同一索引键只允许存在一行 NULL。也就是说 (tenant_id, bill_code, delete_time) 这种建法在 SQL Server 上反而能管住------未删除的行天然只能有一条。它另有 filtered index,可以只对未删除行建唯一索引,等价于下面的 PostgreSQL 写法。同一个建表语句在三种数据库里语义完全不同,这也是为什么这件事不能照抄别人的方案。
方案二:单独加一列「删除代数值」
给每张表加一列,语义单一:
sql
-- 未删除 = 0;删除时写入本行 id(保证唯一)
del_seq bigint NOT NULL DEFAULT 0,
UNIQUE KEY uk_tenant_bill_code (tenant_id, bill_code, del_seq)
逻辑删除时把 del_seq 一起改成 id:
sql
UPDATE biz_bill
SET delete_time = NOW(3),
del_seq = id
WHERE id = #{id} AND delete_time IS NULL;
这样:未删除的行 del_seq 都是 0,唯一索引照常管住重复;被删除的行各自带一个不同的 del_seq,同一个单据号删几次都不会互相撞。
几个细节:
- 别用删除时间戳当代数值 。
datetime(3)只有毫秒精度,拿时间当代数值就得先论证「同一毫秒不会出现两个同值」,这个前提不值得赌。用主键id更稳:天生唯一、不用解析、不受精度影响。 - 想让这一列自动算出来也行(MySQL 8 的 STORED 生成列可以参与唯一索引),但生成列表达式不能引用自增主键列,只能拿删除时间换算,还是要面对精度问题。手写这一列、在统一的删除方法里维护,比埋一个生成列表达式更容易讲清楚。
- 代价很实在:每张有唯一索引的表都要加这一列,还要保证没有任何删除路径绕过它 。手写的
UPDATE ... SET delete_time = ...、数据订正脚本、后台工具,全都是漏点。所以这一列必须由一个统一的删除入口维护,不能各模块各写各的。
方案三:删除时改写唯一字段
不改表结构,删除时把单据号改成「已删除形态」:
sql
UPDATE biz_bill
SET bill_code = CONCAT(bill_code, '#del#', id),
delete_time = NOW(3)
WHERE id = #{id} AND delete_time IS NULL;
唯一索引原封不动,逻辑上也确实不再重复了。它被否的原因是数据被污染:单据号是外部系统对账、日志、导出报表都在用的业务值,改完之后:
- 字段长度要和
#del#+ 19 位 id 一起预留,原来的varchar(32)得重新评估; - 任何一处「按单据号查」的地方,都得先知道它可能带着后缀;
- 对账、导出、三方接口拿到的是脏值,只能再存一份
original_code兜底------绕一圈又加了一列。
它唯一的适用场景,是这个键从今往后没有任何人再读(比如一次性的导入批次号)。有这前提的表很少。
方案四:干脆不做复用------按业务语义分流
前面三种都是在「让删除过的值可以再用」这个前提下想办法。但先问一句:这个键,业务上到底允不允许复用?
- 单据号、合同编号这类:单号一旦用过就是凭据,作废的单据还要能查到、要对账、要审计。不允许复用 才是正确语义。这时候根本不需要绕:保留唯一索引,让删除过的单号永久占用,新增时查重要包含已删除的行,报错文案写清楚「该单号已被使用(含作废)」。
- 系统字典、系统参数(
dict_type、config_key)这类:删掉就该能重建,但业务上其实不需要「删除」,需要的是「停用」。把删除按钮换成状态开关,问题从源头消失。 - 客户名、供应商名、车牌、手机号这类租户内的自然键:确实会删了再建(录错了重录、客户注销后重新签约),这才是需要方案二的场景。
查重包含已删除的行,MyBatis-Plus 的 Wrapper 帮不上忙------逻辑删除条件是注入到 MP 自己生成的 SQL 上的,手写 SQL 不会自动带上,得单独写一个方法:
java
/** 忽略逻辑删除条件,检查单据号是否被占用(含作废) */
@Select("SELECT COUNT(1) FROM biz_bill WHERE tenant_id = #{tenantId} AND bill_code = #{code}")
long countAllByCode(@Param("tenantId") Long tenantId, @Param("code") String code);
这段代码看着不优雅,但它把「业务上不允许复用」这件事明确写进了代码,比任何隐式行为都可靠。
另外补一个原生方案:PostgreSQL 可以直接建部分唯一索引,只对未删除的行生效------
sql
CREATE UNIQUE INDEX uk_tenant_bill_code
ON biz_bill (tenant_id, bill_code)
WHERE delete_time IS NULL;
这是最干净的解。MySQL 没有等价语法,只能用方案二或方案四模拟。技术选型时这条差别值得算进去。
三、我们最后按什么分了三类
过完一遍表,判据收敛成一句话:这个键在业务上会不会被复用?
| 键的类型 | 处理 | 理由 |
|---|---|---|
| 单据号 / 合同号 / 各类业务编码 | 保留原唯一索引;删除即永久占用;查重包含已删除 | 单号是凭据,作废件要能追溯,复用会毁掉可审计性 |
| 租户内自然键(名称、车牌、手机号) | 加 del_seq,唯一索引带上它 |
会删了重建,且这些值没有凭据属性 |
| 字典 / 参数这类系统配置 | 不做删除,改「停用」状态位 | 需求本来就不是删除,是失效 |
| 纯技术键(对外单号、流水号) | 保证生成器不重复即可,不依赖唯一索引 | 唯一性由生成规则负责,不靠数据库兜 |
三类之外还留了一条兜底:每个有唯一索引的表,都要验证一次「删除后重建同值」的路径。不是每个模块都会想到这个用例,但它是唯一能提前发现问题的办法。
清单里最后落成五条:
- 列出所有带唯一索引的表,逐张标明这个键属哪一类;
- 查重语句的过滤范围必须和唯一索引的列对齐------查重排除的行,索引就不该管;
- 唯一索引冲突必须走业务异常(提示「重复」),不能漏成 500;
- 每条删除路径都收敛到一个入口,
del_seq这类辅助列只在那里维护; - 测试用例补一条「删除 → 重建同值」,这条用例比多加十行注释管用。
四、一句话收口
唯一索引看的是表里有没有这行,逻辑删除只是标记这行不要了------两者对「删除」的定义不一致,就得有人明确指定谁说了算,否则它一定在某个最不合适的时候提醒你。