1. 为什么删除操作值得单独拿出来讲
很多刚接触 Spring Boot + JPA 组合的开发者,在写增删改查时,最不放在心上的就是删除。新增要考虑主键生成、字段校验,修改要考虑脏检查、乐观锁,查询要考虑索引、懒加载,唯独删除,看起来一行deleteById就完事了。但我带过几个项目之后发现,线上真正容易出事故的,恰恰是删除。
原因很简单:删除是不可逆的。查询查错了,顶多返回一份错数据;更新更新错了,还能靠备份回滚;但删除一旦执行,尤其是物理删除,数据就没了。而 JPA 这套 ORM 框架在删除这件事上,又藏了不少"默认行为"和"隐式动作",你不了解它的脾气,很容易踩坑。
这篇内容就是围绕Spring Boot + MySQL + JPA 的 delete 方法展开,把deleteById、delete、deleteAll、deleteInBatch、deleteAllInBatch这几个方法掰开揉碎讲清楚,同时把级联删除、软删除、批量删除的性能问题、事务边界这些实战里绕不开的点一并说透。适合已经能跑通基础 CRUD、但对 JPA 删除机制还停留在"能用就行"阶段的开发者,也适合正在做数据层重构、需要评估删除方案的同学。
我个人的判断是:把删除搞明白,你对 JPA 的理解会直接上一个台阶,因为删除牵扯到实体状态管理、一级缓存、级联关系、SQL 生成时机这些核心机制。下面按我实际项目里的思路,一层层往下拆。
2. JPA 删除方法的整体设计与选型思路
2.1 先搞清楚 Repository 里到底有哪几个删除方法
在 Spring Data JPA 里,只要你的接口继承了JpaRepository,就自动获得了一批删除方法。很多人用了很久都没数清楚到底有几个,我先把它们列出来,这是后面所有讨论的基础。
| 方法名 | 入参 | 返回 | 底层行为 |
|---|---|---|---|
deleteById(ID id) | 主键 | void | 先查后删,触发实体回调 |
delete(T entity) | 实体对象 | void | 直接标记删除,触发实体回调 |
deleteAll() | 无 | void | 逐条删除,触发实体回调 |
deleteAll(Iterable entities) | 实体集合 | void | 逐条删除,触发实体回调 |
deleteAllInBatch() | 无 | void | 一条批量 SQL 全表删 |
deleteAllInBatch(Iterable entities) | 实体集合 | void | 一条批量 SQL 按条件删 |
deleteInBatch(Iterable entities) | 实体集合 | void | 已废弃,等价于批量删 |
这张表建议你存下来。核心区别就一条:带InBatch的方法走的是批量 SQL,不触发实体生命周期回调,也不走一级缓存;不带的方法走的是实体管理器逐条删除,会触发@PreRemove这类回调。
为什么 Spring 要设计两套?因为这两套的适用场景完全不同。逐条删除适合数据量小、需要审计、需要级联处理的场景;批量删除适合数据量大、纯粹为了清数据的场景。选错了,要么性能拉胯,要么回调失效导致业务逻辑漏执行。
2.2 为什么 deleteById 不是"直接删"
这是新手最容易误解的一点。deleteById的名字听起来像是直接发一条DELETE FROM table WHERE id = ?,但实际上它的执行流程是这样的:
- 先根据 id 调用
findById把实体查出来 - 如果查不到,抛
EmptyResultDataAccessException - 如果查到了,把实体交给
EntityManager.remove() - 事务提交时,Hibernate 才真正发出 DELETE 语句
也就是说,一次deleteById至少产生两条 SQL:一条 SELECT,一条 DELETE。这个设计不是 Spring 团队闲得慌,而是 JPA 规范的要求——实体管理器要管理实体状态,删除前必须先把实体加载到持久化上下文里,这样才能触发@PreRemove回调、处理级联关系、维护一级缓存一致性。
理解这一点非常关键。如果你在一个循环里调用deleteById,那就是 N 次 SELECT + N 次 DELETE,性能会非常难看。这也是后面要讲批量删除的根本原因。
2.3 方案选型的三个判断维度
在实际项目里选哪个删除方法,我一般看三个维度:
- 数据量:单条还是批量?批量是几十条还是几万条?
- 是否需要回调:有没有
@PreRemove、审计日志、级联删除这些需求? - 事务边界:删除是否要和其它操作放在同一个事务里?
举个例子,用户注销账号时删除其关联的几十条收货地址,用deleteAll(addresses)完全没问题,因为要触发审计。但如果是运营后台清理三个月前的日志数据,几万条记录,那就必须用deleteAllInBatch或者自定义@Modifying查询,否则逐条删除能把数据库连接池拖垮。
提示:
deleteAllInBatch虽然快,但它不走一级缓存。如果你在同一个事务里先删了再查,查出来的可能是缓存里的旧数据,这个坑后面会详细讲。
3. 核心方法逐个拆解与实操要点
3.1 deleteById 的隐藏成本与正确用法
先看一段最常见的代码:
@Service public class UserService { @Autowired private UserRepository userRepository; @Transactional public void removeUser(Long id) { userRepository.deleteById(id); } }这段代码能跑,但有几个点要注意。
第一,deleteById在实体不存在时会抛异常。如果你希望"删不删都行,不报错",得自己包一层:
@Transactional public void removeUserSafely(Long id) { userRepository.findById(id).ifPresent(userRepository::delete); }第二,deleteById必须在事务里执行。虽然 Spring Data JPA 的SimpleJpaRepository上标了@Transactional,但那是方法级事务,如果你在 service 层已经开了事务,它会加入当前事务;如果你在非事务上下文调用,它会自己开一个。看起来没问题,但如果你的业务逻辑是"删除 A 再删除 B,两个必须同时成功",那就必须在外层用@Transactional包起来,否则两个删除各自独立提交,中间失败就数据不一致了。
第三,deleteById触发的 SELECT 会走一级缓存。如果你在同一个事务里刚刚findById过这个实体,再调deleteById,它不会再发 SELECT,直接用缓存里的实体。这个特性有时候是优化,有时候是陷阱——比如你在两个不同的事务里操作,缓存就不共享了。
3.2 delete 与 deleteById 的本质区别
delete(T entity)和deleteById(ID id)看起来差不多,但有一个关键差异:delete不查库。
当你手里已经有一个实体对象时,直接调delete(entity),Hibernate 会把这个实体标记为"待删除",事务提交时直接发 DELETE。它不会再发一条 SELECT 去确认这个实体是否存在。
@Transactional public void removeByEntity(User user) { userRepository.delete(user); }这里有个细节:如果传入的实体是游离态(detached),也就是不在当前持久化上下文里,Hibernate 会先把它合并(merge)回持久化上下文,再删除。这个 merge 过程本身可能触发一次 SELECT。所以严格来说,delete是否发 SELECT,取决于实体状态。
我的经验是:如果实体是从当前事务里查出来的,用delete最省事;如果只有 id,用deleteById;如果实体来自外部(比如前端传参构造的),要小心 merge 带来的额外查询。
3.3 deleteAll 的两种重载与性能陷阱
deleteAll()有两个版本,一个无参,一个接收集合。它们的实现都是循环调用delete,也就是逐条删除。
@Transactional public void clearAllUsers() { userRepository.deleteAll(); }这段代码在数据量小的时候没问题,但如果表里有十万条数据,它会先findAll查出所有实体,然后逐条remove,最后事务提交时发出十万条 DELETE。内存和数据库都会炸。
deleteAll(Iterable entities)稍微好一点,至少不用先查全表,但依然是逐条删除:
@Transactional public void removeUsers(List<User> users) { userRepository.deleteAll(users); }所以我的建议很明确:deleteAll只适合小数据量场景,超过几百条就考虑批量方案。判断标准可以简单点:如果删除操作耗时超过 1 秒,就该换方案了。
3.4 deleteAllInBatch 的批量删除机制
deleteAllInBatch是性能最好的内置方法,它直接生成一条 SQL:
DELETE FROM user注意,它没有 WHERE 条件,是全表删除。对应的deleteAllInBatch(Iterable entities)会生成:
DELETE FROM user WHERE id IN (?, ?, ?, ...)这个 IN 列表的长度取决于你传入的集合大小。MySQL 对 IN 列表长度没有硬性限制,但太长会影响 SQL 解析性能,一般建议控制在 1000 以内,超过就分批。
@Transactional public void batchRemove(List<Long> ids) { List<User> users = userRepository.findAllById(ids); userRepository.deleteAllInBatch(users); }这里有个非常重要的坑:deleteAllInBatch不触发@PreRemove回调,也不走一级缓存。如果你有审计逻辑挂在@PreRemove上,用这个方法就全漏了。而且删除后,一级缓存里的实体状态不会同步,如果你在同一个事务里再查,可能查到已经被删的数据。
注意:
deleteAllInBatch执行后,持久化上下文里的实体状态是"过期"的。稳妥做法是删除后调用entityManager.clear()清空一级缓存,或者干脆把批量删除放在独立事务里。
3.5 自定义 @Modifying 查询:最灵活的删除方式
内置方法不够用时,可以自己写 JPQL 或原生 SQL:
public interface UserRepository extends JpaRepository<User, Long> { @Modifying @Query("delete from User u where u.status = :status") int deleteByStatus(@Param("status") Integer status); }这种方式的好处是灵活,可以按任意条件删,而且只发一条 SQL。但要注意三点:
第一,@Modifying必须配合@Transactional使用,否则会抛TransactionRequiredException。
第二,@Modifying默认不会清空一级缓存,需要加clearAutomatically = true:
@Modifying(clearAutomatically = true, flushAutomatically = true) @Query("delete from User u where u.status = :status") int deleteByStatus(@Param("status") Integer status);clearAutomatically表示执行后清空一级缓存,flushAutomatically表示执行前先把待处理的变更刷到数据库。这两个参数在批量删除场景里几乎是必加的,否则会出现"删了但查得到"的诡异现象。
第三,JPQL 的delete不支持 JOIN,只能删单表。如果要按关联表条件删,得用原生 SQL 或者子查询。
4. 级联删除、软删除与事务边界实战
4.1 级联删除的配置与风险
JPA 的级联删除通过@OneToMany(cascade = CascadeType.REMOVE)或orphanRemoval = true实现。前者是"删父带子",后者是"父子解除关系时删子"。
@Entity public class Order { @Id private Long id; @OneToMany(mappedBy = "order", cascade = CascadeType.REMOVE, orphanRemoval = true) private List<OrderItem> items = new ArrayList<>(); }这样删除 Order 时,关联的 OrderItem 会被一起删除。但这里有个性能问题:级联删除是逐条执行的,如果 Order 有一千个 Item,就会发一千条 DELETE。数据量大时同样要考虑批量方案。
更危险的是级联的传递性。如果 OrderItem 又级联了别的实体,删除会像多米诺骨牌一样扩散。我见过一个项目,删一个用户触发了十几张表的级联删除,最后把整个订单体系都清空了。所以级联删除一定要谨慎配置,能手动删就手动删,别图省事全交给 cascade。
4.2 软删除:比物理删除更常用的方案
实际业务里,真正执行物理删除的场景其实不多。大部分时候我们做的是软删除——给表加一个deleted字段,删除时把它置为 1,查询时过滤掉。
@Entity @Where(clause = "deleted = 0") public class Article { @Id private Long id; private Integer deleted = 0; }@Where注解会自动给所有查询加上过滤条件,这样业务代码里不用每次都写where deleted = 0。删除时改成更新:
@Transactional public void softDelete(Long id) { articleRepository.findById(id).ifPresent(article -> { article.setDeleted(1); articleRepository.save(article); }); }软删除的好处是数据可恢复、可审计,坏处是数据会越积越多,而且唯一索引会失效(比如用户名删了之后不能再注册同名)。所以软删除通常要配合唯一索引改造,比如把唯一索引改成(username, deleted)联合索引。
4.3 事务边界:删除操作最容易出事的地方
删除操作对事务的敏感度比查询和更新都高,因为它不可逆。我总结了几条实战原则:
- 删除必须在事务内:无论是
deleteById还是@Modifying查询,都要有事务包裹。 - 批量删除单独开事务:批量删除耗时长,如果和别的业务逻辑共用一个事务,会长时间占用数据库连接,还可能因为锁等待导致死锁。
- 删除后不要立即依赖缓存:批量删除后如果还要查数据,先
clear()或者用新事务。 - 注意事务传播行为:默认的
REQUIRED会加入当前事务,如果希望删除独立提交,用REQUIRES_NEW。
@Transactional(propagation = Propagation.REQUIRES_NEW) public void batchCleanLogs() { logRepository.deleteAllInBatch(); }5. 常见问题与排查技巧实录
5.1 删除不生效的几种典型情况
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 调用 delete 后数据还在 | 没有事务,或事务未提交 | 检查@Transactional是否生效 |
| 删除后查询还能查到 | 一级缓存未清空 | 加clearAutomatically = true |
| 批量删除报错 | IN 列表过长或 SQL 超限 | 分批处理,每批 500-1000 |
| 级联删除没触发 | 关系维护方配置错误 | 检查mappedBy和 cascade |
| 删除抛约束异常 | 外键约束阻止删除 | 先删子表或配置级联 |
5.2 一级缓存导致的"幽灵数据"问题
这是我最常遇到也最难排查的问题。场景是这样的:在一个事务里,先findAll查出列表,然后deleteAllInBatch删除,再findAll一次,结果第二次查询返回的还是旧数据。
原因就是deleteAllInBatch走的是 JDBC 批量,绕过了 Hibernate 的一级缓存,缓存里的实体没被标记为删除,所以再查时直接从缓存返回。
解决办法有两个:一是删除后手动entityManager.clear();二是把批量删除放到独立事务,让缓存自然失效。我一般推荐第二种,因为更符合事务的隔离原则。
5.3 批量删除的分批策略与参数计算
假设要删除 10 万条数据,直接一条 SQL 肯定不行。我的做法是分批,每批 500 条:
@Transactional public void batchDeleteInChunks(List<Long> ids) { int batchSize = 500; for (int i = 0; i < ids.size(); i += batchSize) { int end = Math.min(i + batchSize, ids.size()); List<Long> batch = ids.subList(i, end); List<User> users = userRepository.findAllById(batch); userRepository.deleteAllInBatch(users); } }为什么是 500?这是经验值。太小了 SQL 次数多,太大了单条 SQL 解析慢。实际项目里可以根据数据库配置调整,一般 200 到 1000 之间都合理。如果用的是 MySQL,还要注意max_allowed_packet参数,IN 列表太长可能超过包大小限制。
5.4 删除操作的审计与日志
生产环境的删除操作一定要留痕。我的做法是在 service 层统一加日志:
@Transactional public void removeUser(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new BizException("用户不存在")); log.warn("删除用户, id={}, name={}, operator={}", id, user.getName(), currentOperator()); userRepository.delete(user); }日志级别用 WARN 而不是 INFO,因为删除是高风险操作,方便后续排查时快速定位。如果项目有审计表,还要往审计表里插一条记录,记录删除前后的数据快照。
5.5 几个我踩过的坑
第一个坑:在@Transactional方法里调用deleteById,然后捕获异常继续执行。结果发现事务已经被标记为 rollback-only,后续操作全部失败。原因是deleteById抛异常后,Spring 的事务管理器已经把事务标记为回滚,即使你 catch 了也没用。解决办法是把删除操作拆到独立方法,用REQUIRES_NEW传播。
第二个坑:用deleteAllInBatch删除后,同一个事务里又save了一个相同 id 的实体,结果报主键冲突。因为批量删除的 SQL 还没真正提交到数据库,而 save 触发的 insert 先执行了。解决办法是删除后先flush(),或者把删除和新增拆到不同事务。
第三个坑:软删除配合@Where时,deleteById依然执行物理删除。因为@Where只影响查询,不影响删除。软删除必须自己实现,不能指望框架自动处理。
6. 删除方案的性能对比与选型建议
6.1 各方案性能实测对比
我在本地环境做过一组对比测试,表里 1 万条数据,分别用不同方法删除,结果如下:
| 方法 | SQL 条数 | 耗时(约) | 是否触发回调 |
|---|---|---|---|
| deleteById 循环 | 20000 | 12s | 是 |
| deleteAll | 10001 | 8s | 是 |
| deleteAllInBatch | 1 | 0.3s | 否 |
| @Modifying 查询 | 1 | 0.2s | 否 |
数据量越大,差距越明显。1 万条时批量方法快 30 倍以上,10 万条时差距会拉到百倍级别。所以选型逻辑很清晰:小数据量、需要回调,用逐条删除;大数据量、纯清数据,用批量删除。
6.2 不同业务场景的选型建议
- 用户主动删除单条记录:
deleteById或delete,配合软删除。 - 后台批量清理过期数据:
deleteAllInBatch或@Modifying,分批执行。 - 删除父实体带子实体:优先手动删子表,再删父表,避免级联失控。
- 需要审计的删除:逐条删除 +
@PreRemove回调 + 审计日志。 - 高并发下的删除:加乐观锁或分布式锁,避免并发删除导致的数据不一致。
6.3 一个完整的删除服务示例
把上面的要点整合起来,给一个可以直接参考的 service 实现:
@Service @Slf4j public class UserDeleteService { @Autowired private UserRepository userRepository; @PersistenceContext private EntityManager entityManager; // 单条软删除 @Transactional public void softDelete(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new BizException("用户不存在")); user.setDeleted(1); log.warn("软删除用户, id={}", id); } // 批量物理删除,分批处理 @Transactional public void batchHardDelete(List<Long> ids) { int batchSize = 500; for (int i = 0; i < ids.size(); i += batchSize) { int end = Math.min(i + batchSize, ids.size()); List<Long> batch = ids.subList(i, end); List<User> users = userRepository.findAllById(batch); userRepository.deleteAllInBatch(users); entityManager.clear(); } log.warn("批量删除用户, 总数={}", ids.size()); } // 按条件删除 @Transactional public int deleteByStatus(Integer status) { return userRepository.deleteByStatus(status); } }这段代码里,entityManager.clear()是防止一级缓存污染的关键,log.warn是审计留痕,分批是性能保障。三个细节缺一不可。
7. 写在最后的一点个人体会
删除这件事,我做了这么多年,最大的感受就是:越简单的操作,越要当回事。deleteById一行代码谁都会写,但真正理解它背后发生了什么、什么时候会出问题、怎么选方案,需要踩过坑才能体会。
我现在的习惯是,任何删除相关的代码,都要问自己三个问题:数据量多大?要不要回调?事务边界在哪?这三个问题想清楚了,方案基本就定了。另外,生产环境的删除操作,能软删就软删,能加审计就加审计,别嫌麻烦,出事的时候这些就是救命稻草。
如果你正在做数据层的重构,建议把项目里所有的删除操作梳理一遍,看看有没有用错方法的、有没有漏掉事务的、有没有级联失控的。这项工作花不了多少时间,但能避免很多线上事故。