拓冰建站拓冰建站
首页 / 资讯中心 / 正文

逻辑删除与唯一索引冲突怎么办?四种方案对比与实战落地

如果你维护过带账号体系的业务系统十有八九会撞上这个坑表结构里同时存在逻辑删除字段和唯一索引删除一条记录后想再次插入相同业务标识的数据数据库直接甩过来一个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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;然后执行这三条 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很多新手会想不通我明明已经把原来的记录标记成deleted1了为什么新插入一条deleted0的记录还会冲突原因在于唯一索引的判定规则只关心索引列本身也就是username这一个字段。MySQL 在建索引时不会自动把deleted字段拼进来索引里存的仍然是zhangsan这个值。在索引看来表里已经存在一个usernamezhangsan的记录你再插入一个同样的值对不起重复了。说白了唯一索引没有“逻辑删除”的概念它只知道“这个值在表里出现过且还没被物理删除”。你把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步当第二条记录也要从deleted0更新为deleted1时表里已经有一条(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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;正常数据deleted0被删除的数据deleted当前毫秒时间戳。因为每次删除操作的时间戳几乎不可能相同所以同一username下存再多删除记录组合索引(username, deleted)的值也不会重复。同一个用户名可以支持 N 次“注册-删除-再注册-再删除”都能正常跑。这个方案相比方案A最大优势是原始用户名完全不被污染恢复的时候只需要把deleted改回 0。代价是查询时deleted0这个条件仍然要写而且不能直接用 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;物理删除后业务表里完全不存在这条记录唯一索引自然不会再冲突恢复数据时从审计表里把快照捞出来重新插入即可。这个方案的优点是彻底解决冲突、表结构简单、索引性能好缺点是查询历史数据需要连审计表恢复操作需要自己写快照回放逻辑操作成本明显更高。如果你当前是全新项目而且业务上明确要求“所有删除操作必须有追溯记录但历史记录不需要实时参与业务查询”这个方案值得重点考虑。如果是已经在线上跑了好几年的老系统改造为物理删除需要同步改造所有关联查询成本会高不少。2.4 方案DRedis 分布式锁做前置拦截还有一种思路是在应用层做文章不依赖数据库唯一索引来做业务唯一性校验而是插入前先查一遍库确认用户名没被占用再插入注册成功后把用户名写入 Redis 做标记逻辑删除时再把标记删掉。这个方案的优点是能提前拦截掉大部分重复请求减少数据库的无效写入接口可以给出更友好的“用户名已占用”提示。但它有致命问题应用层查库和插入之间永远存在时间差。两个并发请求同时查到“用户名未被占用”然后同时执行插入在数据库来不及建唯一索引兜底的情况下脏数据就进去了。所以这个方案只能作为前置校验加速提示不能作为唯一性的最终保障。真要用必须配合分布式锁将“检查插入”变成原子操作而且锁的粒度、过期时间都要精心调复杂度并不低。2.5 方案对比与选型建议方案核心思路改动成本唯一性保障恢复难度适合场景方案A删除时改写唯一字段低依赖原唯一索引可靠中需解析或额外列存量系统小改动业务不频繁恢复方案B联合唯一索引 删除时间戳中依赖组合唯一索引可靠低deleted 改回 0注册删除频率高需要频繁恢复方案C物理删除 审计表高彻底无冲突中高快照回放新项目、审计需求优先方案DRedis 分布式锁前置拦截中高弱必须有索引兜底中高并发提示优化不单独使用我个人在真实项目里最常用的是方案B因为它把“逻辑删除”和“唯一性约束”这两件原本冲突的事情彻底解耦了。方案A适合那种删除之后基本不恢复、只是留痕的场景方案C适合新项目且团队有精力做审计链路方案D永远只能作为辅助手段不要指望它替代数据库索引。3. 完整落地联合索引 时间戳删除标记的实战代码3.1 表结构与索引设计选定了方案B第一步就是把表结构调整好。刚才已经给出过 DDL这里要特别强调几个细节deleted字段必须用bigint不能再用tinyint。因为删除时要写入毫秒时间戳tinyint装不下而且0和非0的判断逻辑要重塑。deleted0代表正常任何非 0 值都代表该记录已被删除这个非 0 值同时承担了“删除时间戳”和“唯一性区分”两个职责。索引选择(username, deleted)的组合唯一索引。由于username是索引前缀所以用户登录、重名校验这些按username查询的场景依然能走索引。组合索引的唯一性可以保证同一时刻同一个用户名下最多只有一个正常记录deleted0而删除记录则依靠不同时间戳互相区分。3.2 核心 Service 实现注册、删除、恢复下面这套代码是我在 Spring Boot MyBatis-Plus 项目里实际用过的简化版本核心逻辑可以直接迁移到其他语言和框架。注册接口Transactional(rollbackFor Exception.class) public void register(String username, String password) { // 前置检查避免大部分无谓的数据库写入 Long count userMapper.selectCount( new LambdaQueryWrapperUser() .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 LambdaQueryWrapperUser() .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查询时自动带上deleted0。一旦用上方案Bdeleted字段在删除时需要写入时间戳TableLogic就完全对不上了。如果你还在实体类的deleted字段上标着TableLogic执行删除操作时MyBatis-Plus 会生成UPDATE t_user SET deleted1 WHERE id?这样的 SQL等于把你精心设计的时间戳方案直接废掉。更麻烦的是查询时它会自动拼上deleted0的条件这个条件本身没问题但删除标记的写入逻辑已经被破坏了。所以方案B落地时我建议干脆去掉TableLogic把deleted当普通字段来维护。所有删除操作显式调用updateDeleted(id, timestamp)所有查询条件显式加.eq(User::getDeleted, 0L)。虽然代码稍微多了几行但逻辑完全可控不会出现框架行为和业务设计互相打架的诡异问题。3.4 存量数据平滑迁移如果系统已经在线上跑了一段时间表里已经存在历史数据直接改表结构会遇到一个问题旧的deleted1记录还没变成时间戳直接建组合唯一索引仍然可能因为两条旧的删除记录具有相同username和相同deleted1而报错。所以迁移前要先处理存量删除记录。假设旧表结构是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 deleted0 WHERE id? AND deleted0用更新行数来判断是否真正恢复了记录。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查出来是因为迁移脚本执行不完整部分deleted1的记录没有被更新成时间戳。这种问题最容易被忽略因为日志里只看到报错不知道根因在迁移阶段。4.2 删除后又注册新用户恢复冲突怎么办方案B里恢复操作前会检查当前是否有正常用户占用了同一个用户名。但如果业务上确实需要“把新用户挤掉、把旧用户恢复”就不能简单拒绝而是要明确恢复语义。这里有两种取舍路径。一种是拒绝恢复提示用户“该用户名已被新账号占用”让运营人工确认后再决定要不要强制恢复。另一种是允许强制恢复实现时开启事务先删除或停用当前占用用户名的活跃记录再恢复旧记录。第二种方案风险较高因为当前活跃用户可能绑定了大量订单、权限、支付数据直接停用会造成连锁反应所以一定要有明确的业务规则兜底。我的建议是恢复操作默认走“拒绝”模式如果业务确有覆盖需求单独开一个带审批的接口并且在事务里记录操作审计日志。这样即使后续出了问题也能追溯到是哪个运营操作导致的。4.3 并发插入相同用户名应用层校验为什么拦不住前面提过应用层“先查再插”存在竞态风险这里展开讲一下。假设两个请求同时注册zhangsan数据库里当前不存在这个用户名。两个请求都执行了SELECT COUNT(*) FROM t_user WHERE usernamezhangsan AND deleted0都得到 0都认为可以插入。然后两个请求都执行INSERT如果没有数据库唯一索引两条zhangsan的活跃记录就会同时存在。有了数据库唯一索引之后第二个 INSERT 会直接被数据库拒绝抛DuplicateKeyException。所以无论应用层做了多少前置校验唯一索引都是最终防线。方案B的组合唯一索引同样承担了这个职责它能保证同一用户名下只有一个deleted0的活跃记录任何并发穿透都会在这里被拦住。如果你想要更友好的用户体验可以在INSERT之前用 Redis 做一次预占位但这只是减少无效数据库写入的手段。真正保证唯一性的一定是数据库索引不要舍本逐末。4.4 不同数据库的实现差异提醒这套方案主要围绕 MySQL 展开但如果你用的是其他数据库有几个差异点要提前注意。MySQL 从 8.0 开始支持函数索引PostgreSQL 一直支持UNIQUE INDEX配合表达式使用Oracle 也支持函数索引。这意味着在某些数据库里你可以不额外加deleted列直接创建类似CREATE UNIQUE INDEX uk_username ON t_user ((CASE WHEN deleted0 THEN username ELSE NULL END))的索引让已删除记录自动被排除在唯一约束之外。但是这里有个陷阱MySQL 的唯一索引允许多个 NULL 值但 PostgreSQL 和 Oracle 在旧版本中对 NULL 的处理各不相同。如果你靠CASE WHEN生成 NULL 来绕过唯一约束一定要先确认目标数据库对 NULL 的唯一性规则否则可能在切换数据库时出现行为差异。通用的做法还是把deleted设计成可区分值因为它在任何数据库上的行为都是一致的不容易出幺蛾子。说句实在话逻辑删除和唯一索引的冲突本质上不是技术做不到而是设计阶段没把“删除”的定义想清楚。你在业务上把一条记录删了但在数据库层面它还在那就必须有一个机制让“新数据”和“旧数据”在唯一性维度上彻底隔离。方案B是我验证过最适合大多数项目的一条路删除标记写时间戳这个思路也完全不需要依赖特定框架换成任何语言任何 ORM 都能落地。最后再分享一个小技巧如果你的表里除了username之外还有手机号、邮箱等多个唯一字段方案B的(username, deleted)索引没法覆盖所有场景。这时候可以给每个唯一字段都建一个对应的组合唯一索引或者更省事一点加一个original_username列专门存业务原始值把唯一性校验全部落到这个列上删除时同样写时间戳标记。具体怎么做取决于你的业务到底有多少个“不能重复”的字段但核心思路不变让删除状态参与唯一性区分永远不要只靠业务字段本身。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门