跳到主要内容
约 11 分钟5 张卡

批处理与分页

批处理用来减少写入数据库的往返,分页用来限定一次读取的数据范围。本文先区分执行批次与事务提交,再比较 Offset 和 Keyset 分页,最后说明如何验证结果与性能。示例采用 JDBC、MySQL 8.0 和 Hibernate ORM 5.6/6.2。

这些优化需要在真实事务和数据规模下验证,才能确认最终 SQL 的行为符合预期。

1. 面试核心​

先记住这几条,再读正文
  • 批执行与事务。 JDBC batch 把多组参数交给驱动批量执行,整批共同成功或回滚需要由事务保证。卡 · 批大小与事务范围
  • JPA 分批写入。 JPA 批量写入时,先用 flush() 同步待写变化,再用 clear() 解除实体管理,避免丢失尚未同步的修改。卡 · MyBatis 与 JPA 的本地状态
  • 深分页成本。 深 Offset 分页仍需跳过前方记录,覆盖索引可以减少回表,但无法消除偏移扫描。卡 · Offset 分页成本
  • 游标与数据版本。 Keyset 分页依赖唯一稳定的排序,精确导出还需固定快照或业务版本。卡 · Keyset 分页
  • 批大小与提交。 Hibernate 5.6/6.2 的 hibernate.jdbc.batch_size=50 把单批语句上限设为 50,事务提交仍由 commit 控制。卡 · Hibernate 批次与提交频率

2. JDBC 批处理​

多行数据使用同一种 SQL 结构时,可以先收集参数,再交给驱动批量执行。这样能减少逐条发送语句的开销:

try (PreparedStatement statement = connection.prepareStatement(SQL)) {
for (OrderRow row : rows) {
statement.setLong(1, row.id());
statement.setBigDecimal(2, row.amount());
statement.addBatch();
}
int[] counts = statement.executeBatch();
}

PreparedStatement 保存带占位符的 SQL,addBatch() 把当前这组参数加入批次。executeBatch() 再把累积的命令交给驱动执行,返回各命令的更新计数。

驱动会根据数据库协议和配置合并发送或改写语句。实际往返次数、单批支持的大小,以及生成主键的返回方式,都需要结合驱动验证。可以用 JDBC 日志和数据库指标确认批处理是否按预期发生。

3. 批大小与事务范围​

批大小决定一次交给驱动多少条语句,提交频率决定多少次写入属于同一个事务。比如,导入一百万行时,可以按固定大小执行批次,再按一致性要求决定多久提交一次。这样可以分别控制参数内存和事务范围。

选择时,要区分执行批次与事务大小带来的成本:

  • 批次太小,减少网络往返的收益有限。
  • 批次太大,会增加参数内存,以及单次失败的定位和重试成本。
  • 事务太大,会延长锁持有时间、增加日志保留量,并扩大回滚范围。

批处理失败时,BatchUpdateException 会提供更新计数。驱动可以在失败处停止,也可以继续执行剩余命令,因此要结合事务状态决定重试范围。涉及唯一约束和幂等时,应先设计重试协议,再增大批量。幂等是指同一业务重复执行,仍能保持正确结果。

4. MyBatis 与 JPA 的本地状态​

使用框架批量写入时,数据库执行之外还存在内存中的待写语句和实体。控制批大小时,需要同时管理这些本地状态。

MyBatis 的 ExecutorType.BATCH 会延迟执行更新,最终影响行数要从刷新批次后的结果读取。执行时机与返回值见 MyBatis。

JPA 的持久化上下文会保留受管实体,也就是由框架跟踪变化的对象。批量导入应按块 flush()、clear():先把待写变化同步到数据库,再解除管理,限制上下文保留的实体数量。主键策略、实体关系和 Hibernate 批处理配置还会影响语句能否合批。

批量 DML 直接修改数据库后,已加载实体仍保留旧值,需要在明确边界清理或刷新。处理顺序见 JPA。

5. Offset 分页成本​

Offset 分页先跳过指定数量的记录,再返回一页。页码越靠后,需要跳过的记录越多,例如:

select id, created_at
from orders
order by created_at desc, id desc
limit 20 offset 1000000;

这条 MySQL 8.0 查询需要从排序结果中跳过前一百万行,再返回 20 行。匹配排序的索引可以减少排序开销;覆盖索引包含全部查询列,可以减少回表取列。即便如此,扫描并跳过大量索引项的成本仍然存在。

并发插入或删除还会移动分页位置,让相邻两页出现重复或遗漏。后台管理页面偶尔跳到指定页时,可以使用 Offset。持续下拉或批量顺序扫描,则更适合从上一页末行继续查询。

6. Keyset 分页​

Keyset 分页用上一页最后一条记录的排序值定位下一页,这组值称为游标。查询直接从游标之后继续,省去从头跳过大量记录的过程:

select id, created_at
from orders
where (created_at, id) < (:lastCreatedAt, :lastId)
order by created_at desc, id desc
limit 20;

时间字段会重复,所以示例在 created_at 后补上主键 id,让组合排序唯一。排序键还要保持稳定,才能让记录持续处于游标的同一侧。索引设计则需同时考虑过滤条件与排序顺序。

游标应保存或编码以下定位信息:

  • 上一页末行的排序值,例如 created_at 和 id。
  • 翻页方向,以及与方向匹配的比较规则。
  • 查询条件或其摘要,让游标绑定当前查询范围。

服务端应校验这些参数,也可以给游标签名,检测客户端篡改。筛选条件变化时,应重新从第一页开始。

Keyset 适合持有上一页游标的连续翻页。任意跳页和精确总页数则需额外查询或定位索引,因此要根据页面是否需要这些能力选择分页方式。

7. 批处理结果与事务示例​

更新计数说明哪些命令执行过,事务状态决定哪些结果已经提交。整批需要共同成功时,可以像下例一样,用新获取的独立连接执行并提交:

try (Connection connection = dataSource.getConnection()) {
connection.setAutoCommit(false);
try {
// 使用该连接 addBatch / executeBatch,核对更新计数。
connection.commit();
} catch (SQLException e) {
try { connection.rollback(); }
catch (SQLException rollbackFailure) { e.addSuppressed(rollbackFailure); }
throw e;
}
}

示例关闭自动提交,批量执行成功后 commit,遇到 SQL 异常则尝试 rollback。若回滚也失败,异常会附加到原异常中。Spring 管理的连接应交给 Spring 控制事务,内部代码应沿用该事务边界。

读取 update counts,也就是各命令的更新计数时,要区分以下三种结果:

  • 非负数表示该命令成功,并给出影响行数。
  • SUCCESS_NO_INFO=-2 表示执行成功,但驱动未给出精确行数。
  • EXECUTE_FAILED=-3 表示该命令失败,用于驱动失败后仍继续执行的情况。

如果驱动在失败处中止,数组只包含中止前成功命令的结果。因此,负数也有明确含义,不能统一当作影响零行。

重试前还要确认 autoCommit 配置和事务是否已提交。自动提交下,部分命令可能已经提交;提交阶段断线时,调用方则无法直接判断最终结果。这时应按业务唯一键核对数据库,确认哪些操作已经生效。只有确认未提交且已回滚时,才重试整个业务单元。

8. 游标与一致性边界​

游标解决的是从哪里继续读取,完整性还取决于边界条件和数据变化。上例要求 created_at 与 id 非空,并且组合排序唯一。倒序的下一页使用严格小于;只记录时间,会漏掉同一时间下尚未返回的记录。

MySQL 8.0 对元组比较的优化效果需要检查执行计划。元组就是把多个字段作为一组比较,也可以与下面的等价条件对比:

created_at < :t OR (created_at = :t AND id < :id)

Keyset 可以避免前方插入造成的 Offset 位置移动,但它本身不是数据库快照。排序字段更新会让记录跨过游标,删除也会改变后续结果,因此仍会出现重复或遗漏。

精确导出需要固定事务快照、冻结数据集,或按固定业务版本读取。按单调主键扫描时,还可以记录最大 ID 作为截止点,限定本次扫描的新增范围。这个截止点只限制后续新增,已有行的内容仍会变化。

9. 关联分页的结果规模​

关联分页需要先明确一页的单位是父对象,还是 join 展开的结果行。一对多关联会让一个父对象占多行,直接 LIMIT 会截断展开结果,导致父对象数量不稳定。

先分页查父 ID,再抓取本页关联并恢复第一阶段顺序,可以控制父对象页大小。两次读取之间的一致性还取决于事务隔离或业务版本,具体过程见 N+1 查询。

10. 目标数据库的访问测试​

数据访问测试要验证最终 SQL 在目标数据库上怎样执行。H2 可以测试少量通用逻辑,MySQL 8.0 的方言、锁、索引、时区和执行计划则应在目标版本上验证。关键 Repository/Mapper 测试可以用 Testcontainers 创建隔离的数据库容器。

测试至少覆盖以下正确性与执行行为:

  • 检查参数绑定是否正确,覆盖空值、枚举和时间类型。
  • 验证唯一约束、外键,以及失败后的事务回滚结果。
  • 检查动态 SQL、排序字段白名单和分页边界是否符合预期。
  • 统计关联查询数量,发现 N+1 问题,并确认写入实际进行了批处理。
  • 验证并发更新、锁等待和超时后的处理结果。
  • 执行数据库迁移脚本,检查它是否兼容已有数据。

自动回滚的测试只执行到回滚,覆盖不到提交后的行为。需要验证提交后缓存、事件或数据可见性时,应显式完成 commit,再用新事务读取并检查结果。对于目标数据库在提交时检查的约束,也要让测试走到真实提交。

11. 性能与结果验证​

性能验证要同时证明执行更快、结果仍正确,数据规模也应接近实际使用。为便于比较,记录以下配置、成本与结果:

  • 保存批大小、行宽、索引、驱动配置和提交频率,保证不同测试的条件可比较。
  • 记录吞吐、P99、重试次数和数据库资源消耗,判断加速是否只是转移了成本。
  • 核对影响行数、重复键、遗漏记录及返回顺序,并验证并发修改后的分页结果。

只有性能和结果都满足要求,才采用新的批处理或分页方案。

12. Hibernate 批次与提交频率​

Hibernate 的批处理配置控制语句怎样交给驱动,事务仍由外层提交边界决定。Hibernate ORM 5.6/6.2 默认 hibernate.jdbc.batch_size=0,设为正数可启用有状态 Session 的 JDBC 批处理:

hibernate.jdbc.batch_size=50
hibernate.order_inserts=true
hibernate.order_updates=true

这里的 batch_size 把交给驱动的单批语句上限设为 50。两个 order 配置调整插入和更新的执行顺序,让相同 SQL 更容易进入同一批次。排序本身也有成本,所以需要按负载验证。使用 IDENTITY 主键策略时,Hibernate 5.6/6.2 会关闭 JDBC 插入合批。

每 50 条调用 flush()、clear(),可以限制上下文保留的实体数量。若一万条数据只在末尾 commit,它们仍属于一个大事务。要缩短事务,就需要分批 commit,同时接受已经提交的批次独立成功,并为后续失败设计恢复方式。

13 · 面试题卡片​

本页题目与参考回答

13.1 limit 1000000, 20 为什么慢,怎样改进​

出现公司:CVTE

考察重点

  • 解释为什么只返回 20 行,仍要扫描并跳过大量记录。
  • 区分覆盖索引、先查主键再关联详情,以及 Keyset 各自减少的成本。
  • 说明稳定排序和并发插入、删除怎样影响分页结果。

相关内容:Offset 成本、Keyset、游标边界。

参考回答

它慢在 MySQL 仍需沿排序结果跳过前一百万行,才能返回后面的 20 行。

查询列无法由索引覆盖时,还会增加回表成本。

需要任意跳页时,可以保留 Offset,先用覆盖索引查本页主键,再关联详情,减少回表量。连续翻页则适合用 created_at 与 id 组成唯一游标,并建立匹配索引。

优化后应在目标数据库和实际数据量下比较执行计划、扫描行数与接口延迟。还要检查并发插入、删除后的重复与遗漏,确认查询结果仍符合要求。

13.2 executeBatch 抛异常后,能直接从失败位置继续吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点

  • 解释更新计数的含义,以及驱动失败后停止或继续执行的区别。
  • 根据自动提交、回滚和提交结果未知等状态,确定正确的重试范围。

相关内容:批大小、批处理结果。

参考回答

不能直接从猜测的失败位置继续,因为驱动可在失败后停止,也可继续执行后面的命令。

需要读取更新计数:SUCCESS_NO_INFO=-2 表示成功但行数未知,EXECUTE_FAILED=-3 表示命令失败。

随后确认 autoCommit 和事务状态。自动提交下,部分命令可能已经生效;确认未提交且已回滚时,可以重试整个业务单元。若提交阶段断线导致结果未知,则先按业务唯一键核对,再确定需要重试的操作。

13.3 JPA 批量插入为什么要先 flush 再 clear?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点

  • 解释 flush 怎样同步待写变化,以及 clear 怎样减少上下文保留的实体。
  • 说明主键生成策略和批处理配置怎样影响插入合批。

相关内容:本地状态、批处理事务。

参考回答

先 flush 再 clear,才能把待写变化同步到数据库后,再解除实体管理。

反过来执行,会丢弃尚未同步的修改。按块处理还能减少持久化上下文保留的对象,但最终提交仍由事务控制。

语句能否合批还取决于 SQL 形态、实体关系、主键生成策略和 hibernate.jdbc.batch_size。例如,Hibernate 5.6/6.2 的 IDENTITY 策略会关闭 JDBC 插入合批。

13.4 Keyset 分页能保证并发修改下完全不重不漏吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点

  • 解释唯一排序、非空字段和严格比较怎样处理页边界。
  • 区分固定快照与最大 ID 截止点,并说明排序字段修改的影响。

相关内容:Keyset、一致性边界。

参考回答

Keyset 本身无法保证并发修改时完全不重不漏,因为游标只记录位置,没有冻结数据版本。

排序字段变化仍会让记录跨页,删除也会改变后续结果。

唯一且稳定的排序解决同值边界,还要规定空值、翻页方向和查询条件的处理方式。精确导出需要固定事务快照、冻结数据集或业务版本。固定最大 ID 只约束新增范围,已有行的内容仍可变化。

13.5 hibernate.jdbc.batch_size=50 是否表示每 50 条自动提交?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分单批语句上限、上下文清理和事务提交,并说明分批提交后的失败范围。

相关内容:Hibernate 批次与提交频率。

参考回答

hibernate.jdbc.batch_size=50 只设置单批语句上限,事务提交仍由 commit 控制。

即使每 50 条 flush、clear,一万条数据也可以留在同一个事务中。

如果要求每 50 条提交,就要显式划分事务。这样后续批次失败时,前面已提交的批次仍然成功,需要按这个范围设计恢复和重试。

14. 参考资料​