在现代化后端开发中,逻辑删除(Logical Delete) 几乎是互联网项目的标配。通过用一个 is_delete(0-未删除,1-已删除)字段来代替物理删除,既能防止用户误删数据,又便于后续的数据留存分析。
然而,当逻辑删除碰上唯一索引(Unique Key),一个隐藏的巨坑就悄然出现了。
今天这篇博客,我们就来聊聊这个经典冲突的本质,以及如何用“最省事、最高效”的硬核技巧完美解决它。
🛑 一、 完美的逻辑删除,被“唯一索引”砸了场子
假设我们有一张用户表 sys_user,为了防止用户名重复,我们对 username 字段加了唯一索引。
当用户 Tom(id = 12)决定注销账号时,我们执行了逻辑删除:
UPDATE sys_user SET is_delete = 1 WHERE id = 12;
此时,数据依然躺在数据库里,只是状态变成了“已删除”。
过了几天,一个新用户也想注册 Tom 这个名字。当他提交注册时,MySQL 会无情地抛出暴击:
ERROR 1062 (23000): Duplicate entry 'Tom' for key 'uk_username'
痛点所在:旧的 Tom 虽死犹生,依然霸占着唯一索引的位置,导致全新的 Tom 无法注册。
💡 二、 绝妙的黑客解法:“变名释放法”
为了解决这个问题,业界有很多方案(比如将 is_delete 改为存储删除时间戳,并建立联合唯一索引)。但在中小型项目或 SaaS 系统中,最轻量、最省事的办法是在逻辑删除的同时,悄悄把冲突的字段重命名。
核心思路:注销时,把
username改为username_用户ID(例如Tom_12)。由于 ID 是全局唯一的,这样不仅瞬间释放了Tom这个名字,还能完美保留历史数据是谁留下的。
那么,在 Java(尤其是使用 MyBatis-Plus)的实际开发中,这个逻辑到底该用 delete 还是 update?要不要加事务?如何应对潜在的问题?
🛠️ 三、 核心实现与潜在问题剖析
在落地这个技巧时,很多开发者容易踩进“非原子性操作”的泥潭。我们来看看两种不同的实现方式及其背后的考量。
❌ 潜在问题:多步操作下的“数据撕裂”
如果我们在 Java 代码中盲目地使用框架自带的 API,写出如下代码:
// ⚠️ 潜在高风险写法
public boolean deleteUser(Long id) {
SysUser user = this.getById(id);
// 1. 先改名
String newUsername = user.getUsername() + "_" + id;
this.lambdaUpdate().eq(SysUser::getId, id).set(SysUser::getUsername, newUsername).update();
// 2. 再逻辑删除(MyBatis-Plus 的 remove 实际上在底层执行的是 UPDATE)
this.removeById(id);
return true;
}
问题分析:
并发与一致性风险:这两步操作分别向数据库发送了两次
UPDATE请求。如果第一步改名成功,而第二步逻辑删除时服务器突然断电或网络闪断,这个用户就会变成一个“顶着奇怪名字(Tom_12)却依然处于存活状态”的僵尸账号,业务直接乱套。性能开销:查一次、改一次、删一次,Java 与数据库交互了 3 次,在高并发场景下对网络和数据库连接池是极大的浪费。
🏁 四、 最佳实践解决方案
针对上述问题,我们可以通过以下两套规范的方案来解决:
🚀 方案 A:单条 SQL 原子流(强烈推荐 ⭐⭐⭐⭐⭐)
不需要复杂的事务管理,直接利用 MySQL 单条语句的原子性,把“改名”和“划掉状态”合并成一条 SQL。
1. 在 Mapper 层利用 @Update 注解编写一条纯正的 SQL:
public interface UserMapper extends BaseMapper<SysUser> {
@Update("UPDATE sys_user SET is_delete = 1, username = CONCAT(username, '_', id) " +
"WHERE id = #{id} AND is_delete = 0")
int logicalDeleteWithUsername(@Param("id") Long id);
}
2. 在 Service 层直接一行代码调用:
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
public boolean deleteUser(Long id) {
// 一条 SQL 搞定,无网络往返开销,MySQL 自身保证要么同时成功,要么同时失败
return userMapper.logicalDeleteWithUsername(id) > 0;
}
}
优势:只与数据库通信 1 次,性能拉满;自带原子性保护,甚至不需要开启 Spring 事务。
📦 方案 B:纯正的框架流(必须开启 Spring 事务 ⚠️)
如果你在项目中有着严格的规范,不允许手写原生 SQL,必须完全使用 MyBatis-Plus 的标准 API,那么必须严格加上 @Transactional 注解。
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, SysUser> implements UserService {
@Transactional(rollbackFor = Exception.class) // 🔐 核心:任何异常必须回滚
public boolean deleteUser(Long id) {
SysUser user = this.getById(id);
if (user == null) {
return false;
}
// 1. 第一步:修改用户名为 username_id
String newUsername = user.getUsername() + "_" + id;
this.lambdaUpdate()
.eq(SysUser::getId, id)
.set(SysUser::getUsername, newUsername)
.update();
// 2. 第二步:执行逻辑删除(加上 @TableLogic 注解后,removeById 底层也会自动转为 UPDATE)
boolean removed = this.removeById(id);
return removed;
}
}
优势:全线复用框架自带 API,代码可读性好。
注意:必须使用
rollbackFor = Exception.class确保强一致性,防止改名后删除失败导致的数据污染。
📝 五、 总结与碎碎念
逻辑删除虽然爽,但底层的约束冲突不得不防。对于大部分中小型 SaaS 系统、个人项目:
首选方案 A:利用
CONCAT(username, '_', id)单条 SQL 搞定。少一次网络 IO,在面临高并发请求时,系统的吞吐量就是通过这一个个省下来的几毫秒堆积起来的。在编写代码时,时刻注意那些看似“两步走”的操作,凡是多步写操作,要么合成一条写,要么老老实实挂上
@Transactional。
希望这篇踩坑记录能帮到正在设计数据库和重构后端架构的你!