两表 Hash 比较 — JDBC 真流式改造说明
1. 背景与问题
原实现中 HashRowCursor 已使用 setFetchSize(compareFetchSize),并在应用层通过 Iterator + StreamingHashComparer.merge() 做双指针归并,业务上不会把全表 hash 装入 List。
但在 PostgreSQL / openGauss JDBC 默认 autoCommit=true 时:
fetchSize通常无法启用服务端游标(portal);- 驱动可能在客户端一次性或大块缓冲整个
ResultSet; - 表现为:
executeQuery()或 merge 前期耗时很长、堆内存随时间窗行数上涨。
这属于 「应用层真流式、JDBC 层假流式」。
2. 改造目标
| 目标 | 说明 |
|---|---|
| 启用 JDBC 服务端游标 | autoCommit=false + fetchSize > 0 |
| 保持只读语义 | setReadOnly(true),比较结束 rollback |
| 不破坏后续差异查询 | 归并后先关闭 hash 游标,再在同一连接上执行步骤 6、7 |
| 改动范围小 | 仅在 TableHashCompareExecutor(做法 A),不改 ExternalJdbcExecutor 全局行为 |
3. 设计思路
3.1 为何在 Executor 配置连接,而非 ExternalJdbcExecutor
ExternalJdbcExecutor可能被其他场景复用,全局setAutoCommit(false)会影响调用方事务语义。- hash 比较的流式需求仅存在于打开
HashRowCursor到归并结束这一段;在编排器内显式配置,职责清晰。
3.2 为何 setReadOnly(true)
- 比较任务只做
SELECT,只读事务可提示数据库优化,且避免误写。 - 结束时用
rollback()而非commit():无持久化变更,释放事务状态即可。
3.3 为何 merge 后必须立刻关闭游标
PostgreSQL 协议下,同一连接在 autoCommit=false + 服务端游标时,通常只允许一个未关闭的 ResultSet。
原流程在游标仍打开时,于同一 pgConnection / ogConnection 上执行 fetchRowsByIds、fetchNormalizedRows,可能触发:
SQLException: Another ResultSet is already open- 或未定义行为
因此:步骤 5 归并结束 → 关闭双游标 → 步骤 6、7 差异明细查询。
finally 中仍保留 closeQuietly(pgCursor/ogCursor),用于异常提前退出时的兜底。
3.4 资源关闭顺序
关闭 HashRowCursor(ResultSet + PreparedStatement)
→ rollback(若 autoCommit=false)
→ close Connection
与 JDBC 规范及 PG 驱动建议一致。
3.5 与本改造无关的优化
以下问题不能仅靠 JDBC 真流式解决,需另做(索引、时间分片、SQL 减负等):
- 时间窗内行数极大时的
ORDER BY idSort(库端work_mem、临时文件); - 无
(timeColumn, id)索引时的全表/大范围扫描; - 差异 id 极多时
CompareResult三个 List 及步骤 6、7 的内存。
4. 执行流程(改造后)
openConnection (PG / OG)
→ configureStreamingConnection(autoCommit=false, readOnly=true)
→ loadColumns(元数据,短查询)
→ HashRowCursor.open × 2(fetchSize 不变)
→ StreamingHashComparer.merge
→ close 两个 HashRowCursor ← 新增:释放 ResultSet,供后续查询
→ fetchRowsInBatches / fetchNormalizedInBatches(差异明细)
→ logSummary
finally:
→ close cursors(若尚未关闭)
→ endReadOnlyTransaction(rollback)
→ close connections
5. 代码变更摘要
涉及文件:src/main/java/com/liang/learn/service/tablehash/TableHashCompareExecutor.java
未改文件(行为配合,无需修改):
HashRowCursor.java— 继续使用setFetchSize(fetchSize)TableHashProperties.compareFetchSize— 默认 1000ExternalJdbcExecutor.openConnection— 仍为DriverManager默认连接
5.1 连接打开后配置流式
pgConnection = jdbcExecutor.openConnection(pgDataSource);
ogConnection = jdbcExecutor.openConnection(ogDataSource);
configureStreamingConnection(pgConnection);
configureStreamingConnection(ogConnection);
private static void configureStreamingConnection(Connection connection) {
try {
connection.setAutoCommit(false);
connection.setReadOnly(true);
} catch (SQLException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "配置流式连接失败: " + e.getMessage());
}
}
5.2 归并后关闭游标
CompareResult result = streamingHashComparer.merge(pgCursor, ogCursor, idUdtName);
// 真流式下同一连接仅能有一个 ResultSet,归并后须先关闭游标再查差异明细
closeQuietly(pgCursor);
closeQuietly(ogCursor);
pgCursor = null;
ogCursor = null;
// 6. 对 PG独有 / OG独有 id 批量查全行并输出日志
// ...
5.3 finally 中结束只读事务
} finally {
closeQuietly(pgCursor);
closeQuietly(ogCursor);
endReadOnlyTransaction(pgConnection);
endReadOnlyTransaction(ogConnection);
closeQuietly(pgConnection);
closeQuietly(ogConnection);
}
private static void endReadOnlyTransaction(Connection connection) {
if (connection == null) {
return;
}
try {
if (!connection.isClosed() && !connection.getAutoCommit()) {
connection.rollback();
}
} catch (SQLException ignored) {
// ignore rollback errors during cleanup
}
}
5.4 新增 import
import java.sql.SQLException;
6. 配置与前置条件
| 项 | 要求 |
|---|---|
app.table-hash.compare-fetch-size |
必须 > 0(默认 1000) |
| PG / OG 各一条连接 | 每连接同时仅一个 hash ResultSet,当前架构满足 |
| 驱动 | PostgreSQL JDBC、openGauss JDBC(兼容 PG 游标语义) |
可选(不能替代 setAutoCommit(false)):
jdbc:postgresql://...?defaultRowFetchSize=1000
7. 改造前后对比
| 维度 | 改造前 | 改造后 |
|---|---|---|
| JDBC 结果集 | 驱动常全量/大块缓冲 | 服务端按 fetchSize 分批 FETCH |
| 应用 merge | 逐行 next(),一致行不进 List |
不变 |
| 差异查询时机 | 游标可能仍打开 | 游标已关闭,可安全复用连接 |
| 事务 | 隐式 autocommit | 只读事务 + rollback 清理 |
8. 验证建议
- 堆内存:大表、0 差异场景,对比改造前后 merge 阶段堆峰值。
- 阶段耗时:
HashRowCursor.open返回时刻 vsmerge结束时刻;真流式下 open 不应占绝大部分墙钟时间。 - 功能回归:有差异表跑通步骤 6、7 日志;确认无「ResultSet already open」类错误。
9. 后续可选工作(本次未做)
- 时间分片:解决库端 Sort / 超时;无时间索引的普通堆表上分片通常不能降总 I/O。
- 索引:
(timeColumn, id),使分片与单片查询都能 Index Scan。
浙公网安备 33010602011771号