删掉的数据还占着唯一键:逻辑删除 + 唯一索引的四次取舍

先给结论:逻辑删除和唯一索引描述的是两件不同的事,硬凑在一起就会出现「查重说没重复、插入报 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;

三条机制各自都很对:

  1. deleteById 不再发 DELETE,被改写成一条 UPDATE,把 delete_time 写成当前时间;那一行物理上还在表里。
  2. 唯一索引的构成是 (tenant_id, bill_code),跟 delete_time 半毛钱关系没有------被标记删除的行照样在索引里占位。
  3. 应用层查重走的是 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,唯一索引带上它 会删了重建,且这些值没有凭据属性
字典 / 参数这类系统配置 不做删除,改「停用」状态位 需求本来就不是删除,是失效
纯技术键(对外单号、流水号) 保证生成器不重复即可,不依赖唯一索引 唯一性由生成规则负责,不靠数据库兜

三类之外还留了一条兜底:每个有唯一索引的表,都要验证一次「删除后重建同值」的路径。不是每个模块都会想到这个用例,但它是唯一能提前发现问题的办法。

清单里最后落成五条:

  1. 列出所有带唯一索引的表,逐张标明这个键属哪一类;
  2. 查重语句的过滤范围必须和唯一索引的列对齐------查重排除的行,索引就不该管;
  3. 唯一索引冲突必须走业务异常(提示「重复」),不能漏成 500;
  4. 每条删除路径都收敛到一个入口,del_seq 这类辅助列只在那里维护;
  5. 测试用例补一条「删除 → 重建同值」,这条用例比多加十行注释管用。

四、一句话收口

唯一索引看的是表里有没有这行,逻辑删除只是标记这行不要了------两者对「删除」的定义不一致,就得有人明确指定谁说了算,否则它一定在某个最不合适的时候提醒你。

相关推荐
IT_陈寒2 小时前
React的useEffect依赖数组把我坑惨了
前端·人工智能·后端
用户8356290780512 小时前
Python 调整 Excel 工作表显示设置
后端·python
ServBay3 小时前
API Key 泄露如何在 10 分钟内止血?AI 密钥管理必备
后端·ai编程
代码丰3 小时前
大 JSON 放进 Redis 之后,如何用 Stream 解耦耗时任务?
redis·后端·系统架构
武子康4 小时前
手机加语音配件,断开连接后谁来保住这段话?
人工智能·后端·agent
captain3765 小时前
Spring MVC(4)-响应
java·spring boot·后端
武子康5 小时前
Cosmos Curator 总览:从一条样本的组成,选对数据处理入口
人工智能·后端·agent
北冥you鱼6 小时前
Go 语言 CRUD 实战:从接口设计到真实业务场景
开发语言·后端·golang
锋行天下6 小时前
Langgraph 实现类似Langchain记忆上下文过长裁剪
人工智能·后端