如果你维护过带账号体系的业务系统,十有八九会撞上这个坑:表结构里同时存在逻辑删除字段和唯一索引,删除一条记录后想再次插入相同业务标识的数据,数据库直接甩过来一个Duplicate entry,明明业务上已经“删掉了”,数据库却还认为这条记录占着位置。逻辑删除和唯一索引的冲突,几乎是每个后端开发都会踩一遍的经典问题。
这篇文章会把问题彻底讲透:为什么两者天然冲突、网上流传的几种解法各有什么坑、我实际项目里推荐怎么落地、以及踩坑后的排查思路。无论你是刚入行的 CRUD 选手,还是正在做系统重构的技术负责人,都能从中找到可以直接抄走的方案。
1. 先搞清楚:逻辑删除和唯一索引是怎么打起来的
1.1 典型业务场景:既要留痕,又要唯一
逻辑删除的初衷很简单:业务数据不能真删,删了就没法追溯了。比如用户注销后,后台要能查到历史订单、操作日志、关联数据,直接DELETE FROM t_user会把这些链路上的数据全部变成孤儿记录。所以大家习惯性加一个deleted字段,0表示正常,1表示已删除,查询条件统一带上deleted = 0。
唯一索引同样很常见。用户名、手机号、邮箱、会员卡号、优惠券编码,只要是业务上不允许重复的字段,绝大多数系统都会选择建一个UNIQUE KEY,让数据库来兜底唯一性,而不是靠应用层判断。毕竟应用层判断总有并发漏洞,数据库索引才是最可靠的防线。
问题就出在这:一个字段要求“历史记录永远留着”,另一个字段要求“当前数据里不能有重复值”。当这两条规则同时作用于同一张表时,逻辑删除掉的记录,在唯一索引眼里依然是活生生的存在。你删掉的只是业务语义上的数据,数据库索引不知道你删了,它只知道这个索引值已经被占了,别人不能用。
1.2 现场还原:一条 INSERT 触发 Duplicate entry
直接看一个最简单的例子。假设有一张用户表:
CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL, `deleted` tinyint NOT NULL DEFAULT 0, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;然后执行这三条 SQL:
-- 1. 正常插入一个用户 INSERT INTO t_user (username, deleted) VALUES ('zhangsan', 0); -- 2. 业务上删除该用户(逻辑删除) UPDATE t_user SET deleted = 1 WHERE username = 'zhangsan'; -- 3. 用户重新注册,还是用 zhangsan 这个用户名 INSERT INTO t_user (username, deleted) VALUES ('zhangsan', 0);第三条 SQL 会直接报错:
Duplicate entry 'zhangsan' for key 'uk_username'很多新手会想不通:我明明已经把原来的记录标记成deleted=1了,为什么新插入一条deleted=0的记录还会冲突?原因在于,唯一索引的判定规则只关心索引列本身,也就是username这一个字段。MySQL 在建索引时,不会自动把deleted字段拼进来,索引里存的仍然是'zhangsan'这个值。在索引看来,表里已经存在一个username='zhangsan'的记录,你再插入一个同样的值,对不起,重复了。
说白了,唯一索引没有“逻辑删除”的概念,它只知道“这个值在表里出现过且还没被物理删除”。你把deleted改成几都不影响索引的判定。
1.3 一个常见的“错误解法”:把 deleted 塞进唯一索引
我刚遇到这个问题的第一反应,和很多人一样——既然单个username会冲突,那把deleted一起加进唯一索引不就行了?于是把索引改成:
ALTER TABLE t_user DROP INDEX uk_username; ALTER TABLE t_user ADD UNIQUE KEY uk_username_deleted (username, deleted);这样(username, deleted)作为一个组合唯一索引,('zhangsan', 0)和('zhangsan', 1)就是不同的索引值,看起来完美解决了问题。但实际操作一遍就发现,这个方案只扛得住一轮删除:
- 插入
('zhangsan', 0),成功。 - 逻辑删除,变成
('zhangsan', 1),成功。 - 再插入
('zhangsan', 0),成功——到这里看着没问题。 - 再逻辑删除这条新记录,又变成
('zhangsan', 1)。 - 再插入
('zhangsan', 0),此时表里已经存在一条('zhangsan', 1)的旧记录,但注意,现在准备插入的也是('zhangsan', 0),和刚才那条('zhangsan', 1)不冲突?不,真正冲突的是第4步:当第二条记录也要从deleted=0更新为deleted=1时,表里已经有一条('zhangsan', 1)了,UPDATE本身就会撞上唯一索引。
换句话说,deleted只有 0 和 1 两个取值时,组合唯一索引只允许同一个username下有一条“已删除”记录。一旦同一个用户名被删除两次,第二次删除就会触发Duplicate entry 'zhangsan-1'。实际业务里,用户名删了又注册、注册了又删的场景不要太常见,所以这个方案约等于给自己埋了一颗定时炸弹。
2. 四种可行方案:别急着写代码,先看场景
2.1 方案A:删除时改写唯一字段
这个方案的思路很直接:既然唯一索引不想让旧值和新值重复,那我就在删除的时候把旧值的唯一字段改掉,让它不再和未来的新值一样。
具体做法是,删除用户时,不光是deleted = 1,还要把username改成类似zhangsan#DELETED#1699999999999这样的值。这样唯一索引里存的是被污染后的字符串,后续新用户注册zhangsan时,索引里已经没有'zhangsan'了,插入自然顺利通过。
这个方案的优点是逻辑简单,唯一索引保持原样,deleted字段依然是 0/1 语义,MyBatis-Plus 的@TableLogic等框架能力也能继续用。缺点是删除操作必须多维护一个字段的更新,而且如果业务上有“恢复删除用户”的需求,你还要从被污染的字符串里解析出原始用户名。一旦原用户名本身就包含#DELETED#这种分隔符,解析就会出错。
我的建议是,如果选方案A,最好单独加一列original_username来存原始值,删除时只改username为影子值,这样恢复时直接读original_username就行,不用玩字符串解析。代价是多一列存储,但换来的是删除和恢复的清晰语义。
2.2 方案B:联合唯一索引 + 删除标记写成时间戳
这个方案是对上面那个“错误解法”的修正。问题出在deleted只有 0 和 1 两个取值,导致组合唯一索引在删除记录上仍然可能撞车。那如果把deleted的类型从tinyint改成bigint,删除时写入当前的时间戳(而不是写 1),情况就完全不同了。
表结构变成:
CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL, `deleted` bigint NOT NULL DEFAULT 0, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username_deleted` (`username`, `deleted`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;正常数据deleted=0,被删除的数据deleted=当前毫秒时间戳。因为每次删除操作的时间戳几乎不可能相同,所以同一username下存再多删除记录,组合索引(username, deleted)的值也不会重复。同一个用户名,可以支持 N 次“注册-删除-再注册-再删除”,都能正常跑。
这个方案相比方案A,最大优势是原始用户名完全不被污染,恢复的时候只需要把deleted改回 0。代价是查询时deleted=0这个条件仍然要写,而且不能直接用 MyBatis-Plus 默认的@TableLogic注解做自动逻辑删除,需要手动维护。这个坑我后面会单独讲。
2.3 方案C:物理删除 + 审计日志
如果业务上对历史数据的要求不那么高,或者审计需求可以通过日志表来满足,那就没必要在业务表里做逻辑删除。直接物理删除,再去审计表里留一条快照记录。
审计表大致长这样:
CREATE TABLE `t_audit_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `biz_table` varchar(64) NOT NULL, `biz_id` bigint NOT NULL, `operator` varchar(64) NOT NULL, `before_snapshot` json NOT NULL, `after_snapshot` json NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;物理删除后,业务表里完全不存在这条记录,唯一索引自然不会再冲突,恢复数据时从审计表里把快照捞出来重新插入即可。这个方案的优点是彻底解决冲突、表结构简单、索引性能好;缺点是查询历史数据需要连审计表,恢复操作需要自己写快照回放逻辑,操作成本明显更高。
如果你当前是全新项目,而且业务上明确要求“所有删除操作必须有追溯记录,但历史记录不需要实时参与业务查询”,这个方案值得重点考虑。如果是已经在线上跑了好几年的老系统,改造为物理删除需要同步改造所有关联查询,成本会高不少。
2.4 方案D:Redis + 分布式锁做前置拦截
还有一种思路是在应用层做文章:不依赖数据库唯一索引来做业务唯一性校验,而是插入前先查一遍库,确认用户名没被占用,再插入;注册成功后把用户名写入 Redis 做标记,逻辑删除时再把标记删掉。
这个方案的优点是能提前拦截掉大部分重复请求,减少数据库的无效写入,接口可以给出更友好的“用户名已占用”提示。但它有致命问题:应用层查库和插入之间永远存在时间差。两个并发请求同时查到“用户名未被占用”,然后同时执行插入,在数据库来不及建唯一索引兜底的情况下,脏数据就进去了。所以这个方案只能作为前置校验加速提示,不能作为唯一性的最终保障。真要用,必须配合分布式锁将“检查+插入”变成原子操作,而且锁的粒度、过期时间都要精心调,复杂度并不低。
2.5 方案对比与选型建议
| 方案 | 核心思路 | 改动成本 | 唯一性保障 | 恢复难度 | 适合场景 |
|---|---|---|---|---|---|
| 方案A | 删除时改写唯一字段 | 低 | 依赖原唯一索引,可靠 | 中(需解析或额外列) | 存量系统小改动,业务不频繁恢复 |
| 方案B | 联合唯一索引 + 删除时间戳 | 中 | 依赖组合唯一索引,可靠 | 低(deleted 改回 0) | 注册删除频率高,需要频繁恢复 |
| 方案C | 物理删除 + 审计表 | 高 | 彻底无冲突 | 中高(快照回放) | 新项目、审计需求优先 |
| 方案D | Redis + 分布式锁前置拦截 | 中高 | 弱,必须有索引兜底 | 中 | 高并发提示优化,不单独使用 |
我个人在真实项目里最常用的是方案B,因为它把“逻辑删除”和“唯一性约束”这两件原本冲突的事情彻底解耦了。方案A适合那种删除之后基本不恢复、只是留痕的场景;方案C适合新项目且团队有精力做审计链路;方案D永远只能作为辅助手段,不要指望它替代数据库索引。
3. 完整落地:联合索引 + 时间戳删除标记的实战代码
3.1 表结构与索引设计
选定了方案B,第一步就是把表结构调整好。刚才已经给出过 DDL,这里要特别强调几个细节:
deleted字段必须用bigint,不能再用tinyint。因为删除时要写入毫秒时间戳,tinyint装不下,而且0和非0的判断逻辑要重塑。deleted=0代表正常,任何非 0 值都代表该记录已被删除,这个非 0 值同时承担了“删除时间戳”和“唯一性区分”两个职责。
索引选择(username, deleted)的组合唯一索引。由于username是索引前缀,所以用户登录、重名校验这些按username查询的场景依然能走索引。组合索引的唯一性可以保证:同一时刻,同一个用户名下最多只有一个正常记录(deleted=0),而删除记录则依靠不同时间戳互相区分。
3.2 核心 Service 实现:注册、删除、恢复
下面这套代码是我在 Spring Boot + MyBatis-Plus 项目里实际用过的简化版本,核心逻辑可以直接迁移到其他语言和框架。
注册接口:
@Transactional(rollbackFor = Exception.class) public void register(String username, String password) { // 前置检查:避免大部分无谓的数据库写入 Long count = userMapper.selectCount( new LambdaQueryWrapper<User>() .eq(User::getUsername, username) .eq(User::getDeleted, 0L) ); if (count != null && count > 0) { throw new BizException("用户名已被占用"); } User user = new User(); user.setUsername(username); user.setPassword(password); user.setDeleted(0L); try { userMapper.insert(user); } catch (DuplicateKeyException e) { // 并发场景下,数据库唯一索引兜底 throw new BizException("用户名已被占用"); } }删除接口:
@Transactional(rollbackFor = Exception.class) public void removeUser(Long id) { User user = userMapper.selectById(id); if (user == null || user.getDeleted() != 0L) { throw new BizException("用户不存在或已删除"); } // 关键:deleted 写入当前时间戳,而不是 1 userMapper.updateDeleted(id, System.currentTimeMillis()); }恢复接口:
@Transactional(rollbackFor = Exception.class) public void restoreUser(Long id) { User user = userMapper.selectById(id); if (user == null || user.getDeleted() == 0L) { throw new BizException("用户不存在或未删除"); } // 恢复前必须检查,是否已经有正常用户占用了这个 username Long count = userMapper.selectCount( new LambdaQueryWrapper<User>() .eq(User::getUsername, user.getUsername()) .eq(User::getDeleted, 0L) ); if (count != null && count > 0) { throw new BizException("用户名已被新用户占用,无法恢复"); } userMapper.updateDeleted(id, 0L); }这三个接口覆盖了最核心的闭环。删除时写时间戳,让同一用户名下可以有无数条删除记录;恢复时先检查有没有新用户占用,防止把当前活跃数据唯一性搞坏。
3.3 为什么不推荐用 MyBatis-Plus 的 @TableLogic
很多人在 MyBatis-Plus 项目里习惯用@TableLogic自动管理逻辑删除字段。这个注解确实方便,但它默认只支持 0 和 1 两种取值:逻辑删除时自动把deleted更新为 1,查询时自动带上deleted=0。
一旦用上方案B,deleted字段在删除时需要写入时间戳,@TableLogic就完全对不上了。如果你还在实体类的deleted字段上标着@TableLogic,执行删除操作时,MyBatis-Plus 会生成UPDATE t_user SET deleted=1 WHERE id=?这样的 SQL,等于把你精心设计的时间戳方案直接废掉。更麻烦的是,查询时它会自动拼上deleted=0的条件,这个条件本身没问题,但删除标记的写入逻辑已经被破坏了。
所以方案B落地时,我建议干脆去掉@TableLogic,把deleted当普通字段来维护。所有删除操作显式调用updateDeleted(id, timestamp),所有查询条件显式加.eq(User::getDeleted, 0L)。虽然代码稍微多了几行,但逻辑完全可控,不会出现框架行为和业务设计互相打架的诡异问题。
3.4 存量数据平滑迁移
如果系统已经在线上跑了一段时间,表里已经存在历史数据,直接改表结构会遇到一个问题:旧的deleted=1记录还没变成时间戳,直接建组合唯一索引,仍然可能因为两条旧的删除记录具有相同username和相同deleted=1而报错。
所以迁移前要先处理存量删除记录。假设旧表结构是username唯一索引 +deleted tinyint,迁移步骤可以这样:
-- 第一步:把历史删除记录的 deleted 改成不同值。 -- 这里用 1000000000000 + id 的方式,保证每条记录的值都不同。 -- 1000000000000 是 10 的 12 次方,远大于正常时间戳量级,避免和新数据混淆。 UPDATE t_user SET deleted = 1000000000000 + id WHERE deleted = 1; -- 第二步:删除旧唯一索引 ALTER TABLE t_user DROP INDEX uk_username; -- 第三步:修改字段类型 ALTER TABLE t_user MODIFY COLUMN deleted bigint NOT NULL DEFAULT 0; -- 第四步:创建组合唯一索引 ALTER TABLE t_user ADD UNIQUE KEY uk_username_deleted (username, deleted);这个迁移方案里有几个关键点。1000000000000 + id中的id是主键,不可能重复,所以每条删除记录都会得到一个不同的deleted值,组合索引必然不冲突。用1000000000000作为偏移量,是为了让历史标记值和新删除时写入的毫秒时间戳(13 位数字)错开,后续如果需要区分“历史删除”和“新删除”,直接看数值范围就能判断。迁移前记得先备份表,或者至少先跑一遍SELECT确认影响行数,避免大批量UPDATE锁表影响线上业务。
3.5 边界情况与细节处理
方案B整体可靠,但有几个边界情况容易踩坑,提前处理掉能省很多事。
一个是时间戳相同的问题。正常情况下,两次删除操作的系统时间不会精确到同一毫秒,但在极端情况下——比如手动执行 SQL、批量导入、同一事务内连续删除两个相同用户名的记录——毫秒时间戳确实可能撞上。为了彻底避免这种可能性,可以删除时在时间戳基础上再加一个随机后缀和 ID 后缀:
long deletedMark = System.currentTimeMillis() * 1000 + (id % 1000);这个值对bigint完全无压力,又保证了同一毫秒内的多次删除也不会撞车。另一个思路是直接用deleted = id作为删除标记,因为主键永远唯一,但这个方案的缺点是删除标记里不再携带删除时间信息,审计类需求就得另开字段。
另一个是恢复操作的并发问题。restoreUser里先查再更新,如果两个请求同时恢复同一个用户,理论上可能出现都通过检查、然后都执行UPDATE的情况。但因为恢复时是把deleted改回 0,最终只有一条记录会变成正常状态,另一条恢复操作会因状态判断失败或唯一索引冲突而被拦截,问题不大。不过为了稳妥,恢复操作可以加UPDATE t_user SET deleted=0 WHERE id=? AND deleted<>0,用更新行数来判断是否真正恢复了记录。
4. 常见问题与排查技巧实录
4.1 Duplicate entry 报错怎么快速定位
线上环境如果突然出现Duplicate entry 'zhangsan-1699999999999' for key 'uk_username_deleted',第一件事不是改代码,而是先把冲突现场查出来。定位思路分三步:
首先,看表结构和索引定义,确认当前唯一索引到底包含哪些列:
SHOW INDEX FROM t_user;其次,把报错信息里的索引值拆出来,用组合索引的字段去查表。比如报错值是'zhangsan-1699999999999',说明冲突很可能发生在(username, deleted)上,直接用这个组合去查:
SELECT id, username, deleted, create_time FROM t_user WHERE username = 'zhangsan' AND deleted = 1699999999999;最后,如果想知道整个表里有哪些组合已经产生了重复数据,可以跑一个分组查询:
SELECT username, deleted, COUNT(*) FROM t_user GROUP BY username, deleted HAVING COUNT(*) > 1;这个 SQL 在迁移前和迁移后都建议跑一遍。出现重复记录时不要直接删,先通过id字段区分它们来自哪次操作,确认哪条是正常数据、哪条是历史遗留垃圾,再做合并处理。
我见过一个真实案例:系统切方案B后仍然偶发Duplicate entry,查出来是因为迁移脚本执行不完整,部分deleted=1的记录没有被更新成时间戳。这种问题最容易被忽略,因为日志里只看到报错,不知道根因在迁移阶段。
4.2 删除后又注册新用户,恢复冲突怎么办
方案B里恢复操作前会检查当前是否有正常用户占用了同一个用户名。但如果业务上确实需要“把新用户挤掉、把旧用户恢复”,就不能简单拒绝,而是要明确恢复语义。
这里有两种取舍路径。一种是拒绝恢复,提示用户“该用户名已被新账号占用”,让运营人工确认后再决定要不要强制恢复。另一种是允许强制恢复,实现时开启事务,先删除或停用当前占用用户名的活跃记录,再恢复旧记录。第二种方案风险较高,因为当前活跃用户可能绑定了大量订单、权限、支付数据,直接停用会造成连锁反应,所以一定要有明确的业务规则兜底。
我的建议是:恢复操作默认走“拒绝”模式,如果业务确有覆盖需求,单独开一个带审批的接口,并且在事务里记录操作审计日志。这样即使后续出了问题,也能追溯到是哪个运营操作导致的。
4.3 并发插入相同用户名,应用层校验为什么拦不住
前面提过应用层“先查再插”存在竞态风险,这里展开讲一下。假设两个请求同时注册zhangsan,数据库里当前不存在这个用户名。两个请求都执行了SELECT COUNT(*) FROM t_user WHERE username='zhangsan' AND deleted=0,都得到 0,都认为可以插入。然后两个请求都执行INSERT,如果没有数据库唯一索引,两条zhangsan的活跃记录就会同时存在。
有了数据库唯一索引之后,第二个 INSERT 会直接被数据库拒绝,抛DuplicateKeyException。所以无论应用层做了多少前置校验,唯一索引都是最终防线。方案B的组合唯一索引同样承担了这个职责:它能保证同一用户名下只有一个deleted=0的活跃记录,任何并发穿透都会在这里被拦住。
如果你想要更友好的用户体验,可以在INSERT之前用 Redis 做一次预占位,但这只是减少无效数据库写入的手段。真正保证唯一性的一定是数据库索引,不要舍本逐末。
4.4 不同数据库的实现差异提醒
这套方案主要围绕 MySQL 展开,但如果你用的是其他数据库,有几个差异点要提前注意。MySQL 从 8.0 开始支持函数索引,PostgreSQL 一直支持UNIQUE INDEX配合表达式使用,Oracle 也支持函数索引。这意味着在某些数据库里,你可以不额外加deleted列,直接创建类似CREATE UNIQUE INDEX uk_username ON t_user ((CASE WHEN deleted=0 THEN username ELSE NULL END))的索引,让已删除记录自动被排除在唯一约束之外。
但是这里有个陷阱:MySQL 的唯一索引允许多个 NULL 值,但 PostgreSQL 和 Oracle 在旧版本中对 NULL 的处理各不相同。如果你靠CASE WHEN生成 NULL 来绕过唯一约束,一定要先确认目标数据库对 NULL 的唯一性规则,否则可能在切换数据库时出现行为差异。通用的做法还是把deleted设计成可区分值,因为它在任何数据库上的行为都是一致的,不容易出幺蛾子。
说句实在话,逻辑删除和唯一索引的冲突,本质上不是技术做不到,而是设计阶段没把“删除”的定义想清楚。你在业务上把一条记录删了,但在数据库层面它还在,那就必须有一个机制让“新数据”和“旧数据”在唯一性维度上彻底隔离。方案B是我验证过最适合大多数项目的一条路,删除标记写时间戳这个思路也完全不需要依赖特定框架,换成任何语言任何 ORM 都能落地。
最后再分享一个小技巧:如果你的表里除了username之外,还有手机号、邮箱等多个唯一字段,方案B的(username, deleted)索引没法覆盖所有场景。这时候可以给每个唯一字段都建一个对应的组合唯一索引,或者更省事一点,加一个original_username列专门存业务原始值,把唯一性校验全部落到这个列上,删除时同样写时间戳标记。具体怎么做,取决于你的业务到底有多少个“不能重复”的字段,但核心思路不变:让删除状态参与唯一性区分,永远不要只靠业务字段本身。