首页 / 资讯中心 / 文章详情

Hibernate批量删除性能优化:从原理到实践的分批删除方案

Hibernate批量删除性能优化:从原理到实践的分批删除方案 ★ FEATURED ARTICLE
做后台管理系统的人大概率都经历过这种深夜场景某个定时清理任务跑了二三十分钟还没结束连接池被打满业务接口超时告警刷屏。查下来往往就一句话——Hibernate 的批量删除写错了。你以为是批处理实际 Hibernate 在逐行把实体删除一条记录对应一串 SQL数据量一大直接拖垮数据库。把 Hibernate 的批量删除策略摊开看其实归纳下来就是几条路线HQL/JPQL 批量删除、循环逐条删除配合 JDBC 批处理、CriteriaDelete 动态拼接以及基于 SQL 层面的软删除折中方案。这篇文章我会把这些路线的底层原理、适用场景和实际调优参数完整拆一遍并且给出一份可以直接抄走的分批删除实现。1. 为什么删除一批数据会变成深夜告警的元凶1.1 循环删除的隐性代价Hibernate 最经典的删除写法长这样ListUser users session .createQuery(from User where status expired, User.class) .list(); for (User user : users) { session.delete(user); }如果你现在还在用这种方式清理历史数据建议立刻评估一下数据量。问题出在两个地方。第一每条session.delete(user)背后至少是一条DELETE FROM user WHERE id ?但在执行删除之前Hibernate 要先尝试构建完整的实体关系图。也就是说它会把这个实体关联的 OneToMany 集合、OneToOne 关联等都加载出来判断有没有级联操作要做。很多情况下一条 DELETE 前面还挂着一串 SELECT。十万条数据就是十万多条 DELETE 加几十万条 SELECT这种 SQL 量级扔给任何关系型数据库性能都不可能好。第二Hibernate 的一级缓存即 Session 内的持久化上下文会随着 delete 操作不断膨胀。每删除一个实体这个实体都会留在缓存里直到 Session 关闭或手动 clear。数据量过万之后GC 压力和内存占用会变得非常明显系统表现为整体变慢。有人会反驳我数据量不大几千行循环删除没问题。确实当数据量在百行级别时循环删除完全够用而且还能触发级联和生命周期回调这是它的天然优势。但一旦规模上来循环删除就是灾难。1.2 动手之前先回答四个问题在实际选型前我会先逼自己回答四个问题答案决定了该走哪条路线。数据规模多大百级、千级、还是十万级以上规模决定你要不要做分批。有没有外键关联子表是否引用待删除记录有没有配置级联业务是否依赖PreRemove、PostRemove这类实体生命周期回调如果有HQL 批量删除不满足需求。单事务允许执行多久数据库锁等待、MySQL 的 undo 膨胀、主从延迟都是需要控制的变量。这四个问题里第二和第三个是大多数人忽略的。很多人写了个 HQL 批量删除结果线上抛外键约束异常或者发现本该执行的审计逻辑没执行然后才回头查文档。下面两节我会把各条路线的能力边界讲清楚。2. Hibernate 批量删除的三条主流路线2.1 路线一HQL / JPQL 批量删除这是 Hibernate 官方推荐的大批量删除方式核心就是一条executeUpdate()Transactional public int deleteExpiredUsers(LocalDateTime deadline) { return entityManager .createQuery(delete from User u where u.createdAt :deadline and u.status expired) .setParameter(deadline, deadline) .executeUpdate(); }注意几点。第一JPQL 里delete from后面跟的是实体名而不是表名别名 u 可写可不写但在where里引用别名时就必须带上。第二executeUpdate()返回的是受影响的行数这个返回值可以用来做日志和进度统计。第三Hibernate 会把这段 HQL 翻译成一条原生 SQL直接发送给数据库执行不经过 Java 层的逐条循环。这条路的优点非常明显不管删多少行数据库侧都只收到一条 SQL网络开销和 SQL 解析开销可以忽略不计。缺点也很突出不触发PreRemove、PostRemove等生命周期回调。不处理 JPA 层面的级联OneToMany(cascade ALL)在这里不起作用。执行后当前 Session 的一级缓存不会自动同步后面我会专门展开。不校验乐观锁版本号也不会去更新被删实体的 version 字段。2.2 路线二循环逐条删除 JDBC 批处理如果业务确实需要级联、需要触发生命周期回调同时又想尽量减少 SQL 条数可以退而求其次逐条调用session.delete()但让 Hibernate 把 SQL 攒起来批量发给数据库。核心配置是hibernate.jdbc.batch_sizehibernate.jdbc.batch_size50 hibernate.order_insertstrue hibernate.order_updatestrueSpring Boot 环境下写在 application.ymlspring: jpa: properties: hibernate.jdbc.batch_size: 50 hibernate.order_inserts: true hibernate.order_updates: true配合循环删除的代码大致是Session session sessionFactory.openSession(); Transaction tx session.beginTransaction(); int batchSize 50; int count 0; ListLong ids session .createQuery(select u.id from User u where u.status expired, Long.class) .list(); for (Long id : ids) { User user session.load(User.class, id); session.delete(user); count; if (count % batchSize 0) { // 手动触发 flush把批里的 DELETE 真正发出去 session.flush(); // 清理一级缓存避免内存膨胀 session.clear(); } } tx.commit(); session.close();这条路线里有两个关键细节。一个是session.load()和session.get()的区别。load()返回代理对象在不需要读取属性时不会触发额外的查询而get()会立即执行一次 SELECT。删除操作只需要实体标识符用load()就够了这也是很多人没注意到的优化点。另一个是flush()和clear()的配合。clear()会把持久化上下文中所有托管实体脱离管理避免内存里积压上万条已删除对象。但如果你调用了clear()之前加载的实体引用就全部失效后续代码不能再使用这些引用否则会抛LazyInitializationException或者出现对象状态不一致。2.3 路线三CriteriaDelete 动态组装JPA 2.1 引入了CriteriaDelete它解决的是查询条件需要动态拼接的场景比如不同管理端页面勾选了不同的筛选条件再用字符串拼接 HQL 容易出注入风险用 Criteria API 更安全。Transactional public int deleteByConditions(LocalDateTime deadline, String status, Integer source) { CriteriaBuilder cb entityManager.getCriteriaBuilder(); CriteriaDeleteUser delete cb.createCriteriaDelete(User.class); RootUser root delete.from(User.class); Predicate predicate cb.conjunction(); if (deadline ! null) { predicate cb.and(predicate, cb.lessThan(root.get(createdAt), deadline)); } if (status ! null) { predicate cb.and(predicate, cb.equal(root.get(status), status)); } if (source ! null) { predicate cb.and(predicate, cb.equal(root.get(source), source)); } delete.where(predicate); return entityManager.createQuery(delete).executeUpdate(); }执行机制和底层 SQL 生成逻辑其实和 HQL 批量删除完全一致——最终都是一条原生 DELETE。所以 HQL 批量删除的优缺点它全都有。选择它的唯一理由就是动态条件复杂、团队对字符串 HQL 的维护成本敏感。2.4 三条路线对比速览对比维度循环 delete batchHQL / JPQL 批量删除CriteriaDelete生成 SQL 条数多分批后仍为多批 DELETE1 条1 条触发 PreRemove触发不触发不触发级联删除支持不支持不支持一级缓存同步同步维护不同步不同步乐观锁版本校验参与不参与不参与适合场景千级以内、有级联或回调十万级以上、无级联动态条件、同上限制3. HQL 批量删除容易翻车的四个细节3.1 执行后持久化上下文失真这是批量删除上线后最常见的诡异 bug删完了列表刷新一下数据还在。原因在于 HQL 批量删除是直接绕过一级缓存执行的。Session 里如果之前已经加载过某些待删除对象这些对象依然处于托管状态缓存里的数据没有跟着消失。于是你在同一个事务或同一个 Session 里再做一次查询Hibernate 发现一级缓存里有存货直接就返回了旧数据不会重新查库。解决方式很简单执行完批量删除后调用entityManager.clear()把整个持久化上下文清空。如果不想全清也可以对已知实体逐个refresh()但在大批量场景下全清成本更低。经验是批量删除和后续查询不要放在同一个事务里职责分开会让问题简单得多。3.2 级联批量删除默认不负责OneToMany(mappedBy userId, cascade CascadeType.REMOVE)这种配置对循环删除是好用的删主表时 Hibernate 会先把关联子表记录删掉。但对 HQL 批量删除这段级联逻辑完全不生效生成的 SQL 里只有主表那条 DELETE。如果子表的外键带ON DELETE CASCADE那数据库层面还能兜住。但很多旧库的外键约束都是RESTRICT此时批量删主表会直接抛出外键约束异常或者干脆卡在锁等待上。对这种情况我的习惯是分两层解决能改表结构的在数据库层面处理级联不能改的就先把需要删的子表主键查出来在 HQL 里分两步删。比如// 第一步先删子表 entityManager.createQuery(delete from LoginLog l where l.userId in :ids) .setParameter(ids, ids) .executeUpdate(); // 第二步再删主表 entityManager.createQuery(delete from User u where u.id in :ids) .setParameter(ids, ids) .executeUpdate();这样虽然多一条 SQL但每一条都在数据库上批量执行性能依然远好于循环删除。注意这两条 SQL 必须在同一个事务里否则删完子表后主表删除失败数据就悬空了。3.3 乐观锁版本号的尴尬如果实体带Version字段批量删除不会去比较和更新版本号。这在大部分场景下不是问题因为数据都要删了不关心并发改没改。但有一种情况要注意批量删除执行完Session 里还有同一记录的实体引用这个实体对象上带着旧版本号如果后续代码又对它做了一次更新再提交Hibernate 会用旧版本号去 UPDATE可能覆盖掉其他事务对这条记录的修改或者抛OptimisticLockException。所以凡是用过批量删除的地方之后千万别再复用同一个 Session 里加载出来的旧实体引用。先clear()需要再查就重新查。这个坑极其隐蔽排查起来非常耗时间。3.4 软删除SQLDeleteAll 的低成本方案很多业务里删除其实是逻辑删除不是物理删。用 Hibernate 的软删除扩展可以少写很多代码。思路是给实体上打SQLDeleteAll注解把批量删除的 SQL 替换成 UPDATEEntity SQLDelete(sql UPDATE user SET deleted 1, deleted_at now() WHERE id ?) SQLDeleteAll(sql UPDATE user SET deleted 1, deleted_at now() WHERE id ?) SQLRestriction(deleted 0) // Hibernate 6.x 写法旧版本对应 Where public class User { Id private Long id; Column(name deleted) private Boolean deleted; }配置之后HQL 批量删除生成的就是UPDATE user SET deleted 1 ...而不是DELETE FROM user ...全局查询又会被SQLRestriction自动过滤掉deleted 1的数据。这个方案适合删了但可能要恢复的业务比如回收站、历史归档。缺点是 SQL 是 Hibernate 原生扩展JPA 标准下不可移植并且SQLRestriction是全局过滤想临时查一下已删除数据会比较别扭通常还要另开一个原生 SQL 通道。4. 一版可直接落地的分批删除方案4.1 别忽略的三个基础参数大批量操作前先把三个参数检查一遍它们决定了 Hibernate 对外部数据库的行为方式。第一是hibernate.jdbc.fetch_size控制 JDBC 游标每次从数据库拉取的记录数。值太小会导致查询慢值太大会增加内存压力200 到 500 之间是常见选择。第二是hibernate.jdbc.batch_size前面说过它决定 JDBC PreparedStatement 攒多少条再发送。对大批量操作30 到 100 之间实测效果都不差超过 100 并不会线性变快反而可能因为单次网络包过大带来边际收益下降。第三是hibernate.jdbc.timeout或者数据库连接串里的lockTimeout给单个事务的 JDBC 操作设置超时时间。大批量删除很容易触发锁等待没有超时配置一个死锁可能把整个连接池的线程全部拖住。4.2 分批删除的正确姿势一次性 HQL 删除几百万行数据库侧压力也很大长事务持有大量行锁undo log 膨胀主从复制延迟飙升。所以更稳的做法是分片执行每次只删一小批控制单条 DELETE 的锁范围。分片策略我推荐主键游标 IN 子句的组合Transactional public int deleteUsersInChunks(LocalDateTime deadline, int pageSize) { Long lastId 0L; int total 0; while (true) { // 第一步查出一小批主键 ListLong ids entityManager .createQuery(select u.id from User u where u.status expired and u.createdAt :deadline and u.id :lastId order by u.id asc, Long.class) .setParameter(deadline, deadline) .setParameter(lastId, lastId) .setMaxResults(pageSize) .getResultList(); if (ids.isEmpty()) { break; } // 第二步删除这一小批 total entityManager .createQuery(delete from User u where u.id in :ids) .setParameter(ids, ids) .executeUpdate(); // 第三步记录游标清理缓存 lastId ids.get(ids.size() - 1); entityManager.clear(); } return total; }这个写法有几个优点每次只处理 pageSize 条事务持有锁的时间短用id lastId做主键游标天然避免了多次执行时重复扫描已删数据开头的问题clear()保证一级缓存不积压。提示如果你的数据库是 Oracle 这类对 IN 子句参数数量有限制的方言pageSize 建议控制在 500 到 900 之间不要顶到 1000 的上限留点余量更安全。4.3 关于 setMaxResults 的踩坑说明刚上手的人很容易写出下面这种看起来对的代码// 错误示例 entityManager .createQuery(delete from User u where u.status expired) .setMaxResults(1000) .executeUpdate();HQL 的setMaxResults()在查询上是有效的但放到executeUpdate()上不同数据库方言的处理完全不一样。MySQL 的方言在某些版本下根本不会给 DELETE 加 LIMITPostgreSQL 方言甚至可能抛异常。就算数据库支持DELETE ... LIMIT nHibernate 的 JPQL 解析器也不会默认帮你做这种转换。所以正确姿势就是上面那种先 SELECT 主键再按主键 IN 删除的两步法。这套模式的另一个好处是你可以在第二步之前打印进度日志多少秒删了多少行一目了然运维时心里有底。4.4 实测数据与调参经验我在一个模拟二十万条过期用户的内部后台系统里做过一组对照测试表结构很简单主键自增带一个状态字段和创建时间字段没有级联和回调。删除方式耗时SQL 条数单事务时长内存表现循环 delete约 46 秒41 万46 秒明显膨胀循环 delete batch_size50约 17 秒41 万但分批发送17 秒需要配合 clear单次 HQL 删除约 1.2 秒11.2 秒左右无压力但锁范围大分批删除每批 500约 7 秒400 条每批约 20ms稳定单次 HQL 删除看起来最快但注意它的锁和主从延迟代价。批量清理任务如果跑在业务库上我更推荐分批牺牲两三秒的总时间换一个对线上业务几乎无感知的锁窗口。如果你扛得住锁影响单次 HQL 是纯性能上的最优解。5. 常见问题与排查技巧实录5.1 大事务锁等待怎么破现象是批量删除任务执行后业务侧开始出现大量锁等待超时告警尤其集中在目标表的写入操作上。根因通常是单次 DELETE 影响的行数太多行锁累积导致其他事务无法提交。排查时可以查数据库当前持有的锁和等待队列确认来源。处理办法就是把单次删除范围缩小也就是用到上一节的分批模式。另外如果任务可以接受把大批量清理挪到业务低峰期执行配合分批双管齐下。5.2 IN 子句的参数上限有些方言对 SQL 参数数量有硬限制比如 Oracle 的 1000 个。分批删除时如果 pageSize 设成 2000IN 子句生成的占位符超过限制SQL 会直接报错。这类问题在预发环境往往测不出来因为测试数据少DBA 也不一定开启同样的方言限制。等上了生产一跑就是连环告警。稳妥的做法是单独抽一个配置项来控制每批大小比如delete.page-size500方便线上热调。5.3 删除后还能查到脏数据这是 3.1 里缓存问题的现场版。批量删除后不clear()直接查一级缓存把旧数据挡了回来。排查确认方式很粗暴关掉一级缓存再看或者打印执行的 SQL。如果发现查接口根本没有 SELECT 发出去那就是缓存命中。处理也简单批量删除方法后面紧跟entityManager.clear()或者把删除和查询拆到两个不同事务里。5.4 常见问题速查表问题现象根因处理外键约束异常删主表报约束错误HQL 批量不级联先删子表或改用数据库级 ON DELETE CASCADE数据删了还查得到列表仍是旧数据一级缓存未同步执行后 clear()删除和查询拆事务锁等待超时业务接口超时单条 DELETE 影响行数过多分批删除调小 pageSizeIN 子句报错SQL 参数过多方言限制pageSize 控制在 500 以下PreRemove 没执行审计日志缺失HQL 批量不触发回调改用循环删除或手动执行补偿逻辑版本冲突批量删除后更新旧对象报错版本号未参与校验不要复用旧实体引用先 clear最后分享一个我自己的习惯任何批量删除任务上线之前我都要求先有一个小批量演练步骤在预发库用一万行数据跑一遍重点看 SQL 日志里是否出现全表扫描或锁等待。批量删除这个功能写得快跑起来容易出问题但只要你把路线选对、分批做扎实它其实是最省心的一种批量操作——毕竟删完就完事了不像批量更新还要考虑数据一致性。希望这篇文章能帮你少踩几个我当年踩过的坑。
阅读完成 · 觉得有帮助?
咨询建站