批处理与分页
批处理用来减少写入数据库的往返,分页用来限定一次读取的数据范围。本文先区分执行批次与事务提交,再比较 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 各自减少的成本。
- 说明稳定排序和并发插入、删除怎样影响分页结果。
参考回答
它慢在 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 本身无法保证并发修改时完全不重不漏,因为游标只记录位置,没有冻结数据版本。
排序字段变化仍会让记录跨页,删除也会改变后续结果。
唯一且稳定的排序解决同值边界,还要规定空值、翻页方向和查询条件的处理方式。精确导出需要固定事务快照、冻结数据集或业务版本。固定最大 ID 只约束新增范围,已有行的内容仍可变化。
13.5 hibernate.jdbc.batch_size=50 是否表示每 50 条自动提交?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分单批语句上限、上下文清理和事务提交,并说明分批提交后的失败范围。
相关内容:Hibernate 批次与提交频率。
参考回答
hibernate.jdbc.batch_size=50 只设置单批语句上限,事务提交仍由 commit 控制。
即使每 50 条 flush、clear,一万条数据也可以留在同一个事务中。
如果要求每 50 条提交,就要显式划分事务。这样后续批次失败时,前面已提交的批次仍然成功,需要按这个范围设计恢复和重试。