hssh对比-流式重构版
1. 整体流程与逻辑说明
1.1 调用链全景图
flowchart TD
CTRL["Controller<br/>(触发 API 请求)"] --> SVC["TableHashCompareServiceImpl<br/>.submitCompare(dto)"]
SVC -->|"异步提交到 tableHashExecutor 线程池"| EXEC["TableHashCompareExecutor<br/>.compare(dto)"]
subgraph COMPARE["compare() 三阶段"]
CTX["阶段一:buildCompareContext()<br/>加载 PG 元数据 + 生成 hash SQL"]
OG["阶段二:prepareOgIterators()<br/>为每个 OG 目标开启 JDBC 连接 + 流式迭代器"]
MERGE["阶段三:executeMerge()<br/>开启 PG 迭代器 → 多路归并 → 清理资源"]
end
EXEC --> CTX --> OG --> MERGE
1.2 业务背景
目的:校验 PostgreSQL(源端,以下简称 PG)与多个 openGauss(目标端,以下简称 OG)在指定时间窗内的数据是否一致。
核心思路:
- 对 PG 和每个 OG 分别执行相同的 hash 查询:
SELECT id, MD5(concat_ws(...)) FROM table WHERE time >= ? AND time < ? ORDER BY id - 由于 SQL 已按 id 排序,可以利用双指针归并算法,一次遍历完成 PG ↔ 所有 OG 的差异比对
- 结果输出三类差异:PG 独有行、OG 独有行、hash 不一致行
关键设计决策:
| 决策 | 原因 |
|---|---|
流式迭代器(HashRowIterator)而非一次性加载 |
时间窗数据量可能很大(百万级),流式逐行消费 O(1) 内存 |
多路归并(MultiWayHashComparer)而非两两比较 |
PG 只需扫描一次,同时与 N 个 OG 做比对,时间复杂度 O(N × rows) |
| 单个 OG 失败不影响其他 | 每个目标迭代器独立 try-catch,失败计入 failedResults(null) |
| MD5 hash 而非逐列比较 | 先用行级 hash 快速定位差异 id,再按需做列级 diff(不在本次范围) |
1.3 数据流向
TableHashCompareDTO (输入)
│
▼
┌─ TableHashCompareExecutor ───────────────────────────────────────┐
│ │
│ PG 数据源 ◄── DataSourceMapper.selectById(pgDataSourceId) │
│ OG 数据源 ◄── DataSourceMapper.selectById(targetId) × N │
│ │
│ PG Connection ──► TableMetadataLoader.loadColumns() │
│ ──► HashSqlBuilder.buildHashQuery(PG) │
│ ──► ExternalJdbcExecutor.openHashRowIterator(PG) │
│ │
│ OG Connection × N ──► HashSqlBuilder.buildHashQuery(OG) │
│ ──► ExternalJdbcExecutor.openHashRowIterator │
│ │
│ MultiWayHashComparer.merge(pgIter, ogIters[]) │
│ │ │
│ ▼ │
│ Map<Long, CompareResult> │
│ │ │
│ ▼ │
│ log 输出(pgOnlyIds / ogOnlyIds / mismatchIds) │
└──────────────────────────────────────────────────────────────────┘
2. 细节流程与逻辑说明
2.1 阶段一:构建比较上下文 (buildCompareContext)
buildCompareContext(pgConnection, pgSchema, tableName, timeColumn, targetIds)
│
├─► ogTableName = tableName.toUpperCase() // OG 端表名大写
│
├─► metadataLoader.loadColumns(pgConnection, pgSchema, tableName)
│ │
│ └─► 查询 information_schema.columns
│ 获取 column_name, ordinal_position, udt_name
│ 返回 List<ColumnMeta>
│
├─► validateTimeColumn(columns, timeColumn)
│ │
│ └─► 检查 timeColumn 是否存在于 columns 中
│ (不存在 → BusinessException(PARAM_ERROR))
│
├─► idUdtName = columns 中 ordinal_position=1 的 udt_name
│ │
│ └─► 用于后续判断 id 比较方式(数值序 vs 字典序)
│
├─► pgHashSql = hashSqlBuilder.buildHashQuery(PG)
│ │
│ └─► 生成: SELECT "id", MD5(concat_ws('|', COALESCE(...), ...))
│ FROM "schema"."table"
│ WHERE "time" >= ? AND "time" < ?
│ ORDER BY "id"
│
└─► fetchSize = properties.getStreamFetchSize() // 默认 1000
返回: CompareContext { pgConnection, columns, idUdtName, pgHashSql, fetchSize, ogTableName, targetIds }
2.2 阶段二:准备 OG 迭代器 (prepareOgIterators)
prepareOgIterators(ctx, timeColumn, startTime, endTime)
│
└─► for each targetId in ctx.targetIds:
try:
├─► ogDs = requireDataSource(targetId, "openGauss")
│ // 查库 → 不存在抛 BusinessException
│
├─► ogConn = jdbcExecutor.openConnection(ogDs)
│ // JDBC 直连(非连接池),解密密码
│
├─► ogHashSql = hashSqlBuilder.buildHashQuery(OG)
│ // 与 PG 相同的列、相同的 hash 表达式
│ // 区别:OG 端表名/标识符为大写
│
├─► ogIter = jdbcExecutor.openHashRowIterator(ogConn, ogHashSql, ...)
│ // PreparedStatement 设置 fetchSize, 绑定时间参数
│ // 返回 HashRowIterator(流式逐行消费)
│
├─► ogIters.add(ogIter)
├─► successfulIds.add(targetId)
└─► ogConnections.add(ogConn)
catch (Exception e):
├─► log.warn("OG 连接/查询失败,跳过")
└─► failedResults.put(targetId, null)
返回: OgIteratorsResult { ogIters, successfulIds, failedResults, ogConnections }
2.3 阶段三:执行多路归并 (executeMerge)
executeMerge(ctx, ogResult, startTime, endTime)
│
├─► try (pgIter = jdbcExecutor.openHashRowIterator(PG)):
│ │
│ ├─► results = multiWayHashComparer.merge(pgIter, ogIters, successfulIds, idUdtName)
│ │ (详见 §2.4)
│ │
│ ├─► results.putAll(ogResult.failedResults) // 合并失败目标
│ │
│ └─► for each entry in results:
│ log.debug: PG扫描行数 / OG扫描行数 / 差异明细
│
└─► finally:
├─► 关闭所有 OG HashRowIterator(释放 ResultSet + PreparedStatement)
├─► 关闭 PG Connection
└─► 关闭所有 OG Connection
2.4 多路归并算法 (MultiWayHashComparer.merge)
这是整个比较流程的核心算法。
merge(pgIter, ogIters, ogTargetIds, idUdtName)
│
├─► 初始化 MergeRuntime:
│ ├─► 为每个 OG 创建 CompareResult
│ ├─► ogRows[i] = advance(ogIters[i]) // 预读各 OG 首行
│ └─► ogExhausted[i] = (ogRows[i] == null)
│
├─► pgRow = advance(pgIter) // 预读 PG 首行
│
└─► while (pgRow != null || 有 OG 未耗尽):
│
├─► minId = findMinId(pgRow, ogRows[], ...)
│ // 在所有当前行中找最小 id
│ // 比较方式由 idUdtName 决定:数值序 (BigInteger) 或字典序
│
├─► pgHas = (pgRow.id == minId) // PG 是否持有最小 id
│
├─► for i in 0..N-1: compareOneOg(i, pgRow, minId, pgHas, idUdtName)
│ │
│ ├─► OG 已耗尽:
│ │ if (pgHas): 计入 PG 独有
│ │
│ ├─► 两侧都有 (pgHas && ogHas):
│ │ PG行数++, OG行数++
│ │ if (rowHash 不同): 计入 mismatchIds
│ │ 推进 OG 指针
│ │
│ ├─► 仅 PG 有:
│ │ PG行数++, 计入 pgOnlyIds
│ │
│ └─► 仅 OG 有:
│ OG行数++, 计入 ogOnlyIds
│ 推进 OG 指针
│
└─► if (pgHas): pgRow = advance(pgIter) // 推进 PG 指针
算法复杂度:
- 时间:O(N × total_rows),其中 N 为 OG 数量
- 空间:O(N),仅保存每个 OG 的当前行和耗尽标志
正确性保证:
- PG 和每个 OG 的 SQL 都
ORDER BY id,保证双指针归并的正确性 - id 比较使用
IdComparatorUtil,与数据库ORDER BY语义一致(数值型 id 用 BigInteger 比较,字符串型 id 用字典序) - hash 表达式使用
ColumnTypeHashStrategyUtil对各列类型做标准化处理(numeric 格式化、float ±0 统一、换行符 normalize、bpchar trim 尾部空格等),确保 PG 与 OG 的 hash 结果可比
2.5 异常处理策略
┌──────────────────────────────────────────────────────────────┐
│ compare() 异常处理 │
├──────────────────────────────────────────────────────────────┤
│ │
│ PG 连接失败 │
│ ├─► log.error │
│ └─► throw 原始异常(向上传播至 submitCompare 的 catch 块) │
│ │
│ OG 连接/查询失败(单个目标) │
│ ├─► log.warn + 跳过该目标 │
│ └─► failedResults.put(targetId, null) │
│ │
│ BusinessException(参数错误、资源不存在等) │
│ ├─► log.debug │
│ └─► throw e(不关闭 PG 连接,交给上层处理) │
│ │
│ Exception(非预期的系统异常) │
│ ├─► log.debug │
│ ├─► closeQuietly(pgConnection) // 安全关闭 │
│ └─► throw new BusinessException(SYSTEM_ERROR) │
│ │
└──────────────────────────────────────────────────────────────┘
2.6 Hash SQL 生成逻辑 (HashSqlBuilder)
生成的 SQL 结构:
SELECT "col_id",
MD5(concat_ws('|',
COALESCE("col_int"::text, 'NULL'),
COALESCE(to_char("col_numeric", 'FM...'), 'NULL'),
COALESCE(CASE WHEN abs("col_float8")=0 THEN '0'
ELSE to_char("col_float8"::double precision, 'FM...') END, 'NULL'),
COALESCE(trim(trailing ' ' from "col_bpchar"::text), 'NULL'),
-- ... 更多列
)) AS row_hash
FROM "schema"."table"
WHERE "col_time" >= ? AND "col_time" < ?
ORDER BY "col_id"
关键处理:
| 列类型 | 表达式 | 目的 |
|---|---|---|
| int2/int4/int8/serial | col::text |
直接转字符串 |
| numeric/decimal | to_char(col, 'FM...') |
统一格式化消除尾零差异 |
| float4/float8 | CASE WHEN abs=0 THEN '0' ELSE to_char(...) |
-0 和 0 统一为 '0' |
| varchar/text | regexp_replace(col, ...) |
统一换行符 (\r\n → \n) |
| bpchar/char | trim(trailing ' ' from col::text) |
去除定长字符尾部空格 |
| date | to_char(col, 'YYYY-MM-DD') |
统一日期格式 |
| timestamp/timestamptz | to_char(col, 'YYYY-MM-DD HH24:MI:SS.US') |
微秒精度统一 |
| bool | CASE WHEN col THEN '1' ELSE '0' |
避免 t/f vs true/false 差异 |
| blob/clob/bytea/text | 排除 | 大对象不参与 hash |
2.7 ID 比较逻辑 (IdComparatorUtil)
compare(leftId, rightId, idUdtName)
│
├─► idUdtName 是数值型 (int2/int4/int8/int16/serial/bigserial/smallserial)?
│ └─► new BigInteger(leftId).compareTo(new BigInteger(rightId))
│ // 与数据库 ORDER BY 数值序一致
│
└─► 否则:
└─► leftId.compareTo(rightId)
// 字典序比较
3. 完整源码
3.1 入口层
TableHashCompareService(接口)
package com.liang.learn.service;
import com.liang.learn.model.dto.TableHashCompareDTO;
import com.liang.learn.model.vo.TableHashTaskVO;
/**
* 两表 hash 比较 Service
*
* @author liang
* @since 2026-05-24
*/
public interface TableHashCompareService {
/**
* 提交异步 hash 比较任务
*
* @param dto 比较参数
* @return 任务 ID,比较结果输出到控制台日志
*/
TableHashTaskVO submitCompare(TableHashCompareDTO dto);
}
TableHashCompareServiceImpl
package com.liang.learn.service.impl;
import com.liang.learn.exception.BusinessException;
import com.liang.learn.model.dto.TableHashCompareDTO;
import com.liang.learn.model.enums.ErrorCode;
import com.liang.learn.model.vo.TableHashTaskVO;
import com.liang.learn.service.TableHashCompareService;
import com.liang.learn.service.tablehash.CompareResult;
import com.liang.learn.service.tablehash.TableHashCompareExecutor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
/**
* 两表 hash 比较 Service 实现
*
* @author liang
* @since 2026-05-24
*/
@Slf4j
@Service
public class TableHashCompareServiceImpl implements TableHashCompareService {
/** 表 hash 比较专用线程池,限制并发避免多个重型任务同时跑 */
private final ThreadPoolTaskExecutor tableHashExecutor;
private final TableHashCompareExecutor compareExecutor;
public TableHashCompareServiceImpl(
@Qualifier("tableHashExecutor") ThreadPoolTaskExecutor tableHashExecutor,
TableHashCompareExecutor compareExecutor) {
this.tableHashExecutor = tableHashExecutor;
this.compareExecutor = compareExecutor;
}
@Override
public TableHashTaskVO submitCompare(TableHashCompareDTO dto) {
String taskId = UUID.randomUUID().toString();
log.debug("[TABLE-HASH] 任务已提交 taskId={} 表名={} 时间窗=[{}, {})",
taskId, dto.getTableName(), dto.getStartTime(), dto.getEndTime());
try {
tableHashExecutor.execute(() -> {
log.debug("[TABLE-HASH] 任务开始执行 taskId={} 线程={}", taskId, Thread.currentThread().getName());
try {
Map<Long, CompareResult> results = compareExecutor.compare(dto);
for (Map.Entry<Long, CompareResult> entry : results.entrySet()) {
CompareResult result = entry.getValue();
if (result == null) {
log.warn("[TABLE-HASH] taskId={} targetId={} 比较失败(连接/查询异常)",
taskId, entry.getKey());
continue;
}
log.debug("[TABLE-HASH] 任务执行完成 taskId={} targetId={} PG扫描={} OG扫描={} PG独有={} OG独有={} hash不一致={}",
taskId, entry.getKey(), result.getPgRowCount(), result.getOgRowCount(),
result.getPgOnlyIds().size(), result.getOgOnlyIds().size(), result.getMismatchIds().size());
if (!result.getPgOnlyIds().isEmpty() || !result.getOgOnlyIds().isEmpty()
|| !result.getMismatchIds().isEmpty()) {
log.info("[TABLE-HASH] 差异明细 taskId={} targetId={} PG独有ids={} OG独有ids={} hash不一致ids={}",
taskId, entry.getKey(), result.getPgOnlyIds(), result.getOgOnlyIds(), result.getMismatchIds());
}
}
} catch (BusinessException e) {
// 异步线程异常无法回传 HTTP,须 error 日志便于运维发现(替代原 TableHashCompareLogger)
log.error("[TABLE-HASH][taskId={}] 失败: code={}, message={}",
taskId, e.getCode(), e.getMessage(), e);
} catch (Exception e) {
log.error("[TABLE-HASH][taskId={}] 失败: {}", taskId, e.getMessage(), e);
}
});
} catch (RejectedExecutionException ex) {
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "比较任务队列已满,请稍后重试");
}
return new TableHashTaskVO(taskId);
}
}
3.2 核心编排层
TableHashCompareExecutor(重构后)
package com.liang.learn.service.tablehash;
import com.liang.learn.exception.BusinessException;
import com.liang.learn.mapper.DataSourceMapper;
import com.liang.learn.model.dto.TableHashCompareDTO;
import com.liang.learn.model.entity.DataSourceDO;
import com.liang.learn.model.enums.ErrorCode;
import com.liang.learn.properties.TableHashProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* 两表 hash 比较编排执行器:PG(源端)与多个 OG(目标端)多路流式归并比较
*
* @author liang
* @since 2026-05-24
*/
@Component
@Slf4j
public class TableHashCompareExecutor {
/** 数据源 DAO */
private final DataSourceMapper dataSourceMapper;
/** 表 hash 配置属性 */
private final TableHashProperties properties;
/** 外部 JDBC 直连执行器 */
private final ExternalJdbcExecutor jdbcExecutor;
/** 表元数据加载器 */
private final TableMetadataLoader metadataLoader;
/** hash 查询 SQL 构建器 */
private final HashSqlBuilder hashSqlBuilder;
/** 多路 hash 归并比较器 */
private final MultiWayHashComparer multiWayHashComparer;
public TableHashCompareExecutor(
DataSourceMapper dataSourceMapper,
TableHashProperties properties,
ExternalJdbcExecutor jdbcExecutor,
TableMetadataLoader metadataLoader,
HashSqlBuilder hashSqlBuilder,
MultiWayHashComparer multiWayHashComparer) {
this.dataSourceMapper = dataSourceMapper;
this.properties = properties;
this.jdbcExecutor = jdbcExecutor;
this.metadataLoader = metadataLoader;
this.hashSqlBuilder = hashSqlBuilder;
this.multiWayHashComparer = multiWayHashComparer;
}
/**
* 执行 hash 比较:PG 分别与多个 OG 目标做多路流式归并,单个目标失败不影响其他目标。
*
* @param dto 表名、时间窗、时间列、目标数据源 ID 列表等比较参数
* @return key 为目标数据源 ID,value 为该目标与 PG 的比较结果
*/
public Map<Long, CompareResult> compare(TableHashCompareDTO dto) {
DataSourceDO pgDataSource = requireDataSource(properties.getPgsqlDataSourceId(), "PG");
String tableName = dto.getTableName().toLowerCase(Locale.ROOT);
log.debug("[TABLE-HASH] 开始比较 表名={} 时间窗=[{}, {}) 目标数据源={}",
tableName, dto.getStartTime(), dto.getEndTime(), dto.getTargetDataSourceIds());
// 1. PG 连接与元数据(一次)
Connection pgConnection = null;
try {
pgConnection = jdbcExecutor.openConnection(pgDataSource);
} catch (Exception e) {
log.error("[TABLE-HASH] PG 连接失败 表名={} 原因={}", tableName, e.getMessage());
throw e;
}
try {
CompareContext ctx = buildCompareContext(
pgConnection, pgDataSource.getSchema(), tableName,
dto.getTimeColumn(), dto.getTargetDataSourceIds());
OgIteratorsResult ogResult = prepareOgIterators(
ctx, dto.getTimeColumn(), dto.getStartTime(), dto.getEndTime());
// 2. 所有目标 OG 均失败时直接返回
if (ogResult.ogIters.isEmpty()) {
log.warn("[TABLE-HASH] 所有目标 OG 均失败 表名={}", tableName);
return ogResult.failedResults;
}
// 3. PG 流式迭代器 + 多路归并
return executeMerge(ctx, ogResult, dto.getStartTime(), dto.getEndTime());
} catch (BusinessException e) {
log.debug("[TABLE-HASH] 比较失败 表名={} 原因={}", tableName, e.getMessage());
throw e;
} catch (Exception e) {
log.debug("[TABLE-HASH] 比较失败 表名={} 原因={}", tableName, e.getMessage());
closeQuietly(pgConnection);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "hash 比较失败: " + e.getMessage());
}
}
/**
* 构建比较上下文:加载 PG 列元数据、校验时间列、生成 PG 侧 hash SQL。
*
* @param pgConnection PG JDBC 连接
* @param pgSchema PG schema 名
* @param tableName 表名(小写)
* @param timeColumn 时间列名
* @param targetIds 目标数据源 ID 列表
* @return 封装了 PG 元数据与 SQL 的比较上下文
*/
private CompareContext buildCompareContext(Connection pgConnection, String pgSchema,
String tableName, String timeColumn,
List<Long> targetIds) {
String ogTableName = tableName.toUpperCase(Locale.ROOT);
List<ColumnMeta> columns = metadataLoader.loadColumns(pgConnection, pgSchema, tableName);
validateTimeColumn(columns, timeColumn);
String idUdtName = columns.stream()
.filter(ColumnMeta::isFirstColumn)
.findFirst()
.orElse(columns.getFirst())
.getUdtName();
String pgHashSql = hashSqlBuilder.buildHashQuery(
pgSchema, tableName, columns, timeColumn, false);
int fetchSize = properties.getStreamFetchSize();
return new CompareContext(pgConnection, columns, idUdtName, pgHashSql, fetchSize,
ogTableName, targetIds);
}
/**
* 为每个目标 OG 数据源开启 JDBC 连接与流式 hash 迭代器,失败目标单独记录并跳过。
*
* @param ctx 比较上下文(含列元数据、fetchSize、OG 表名等)
* @param timeColumn 时间列名
* @param startTime 开始时间(含)
* @param endTime 结束时间(不含)
* @return 成功开启的迭代器列表、成功 ID、失败结果及对应连接
*/
private OgIteratorsResult prepareOgIterators(CompareContext ctx, String timeColumn,
String startTime, String endTime) {
List<HashRowIterator> ogIters = new ArrayList<>();
List<Long> successfulIds = new ArrayList<>();
Map<Long, CompareResult> failedResults = new HashMap<>();
List<Connection> ogConnections = new ArrayList<>();
for (Long targetId : ctx.targetIds) {
try {
DataSourceDO ogDs = requireDataSource(targetId, "openGauss");
String ogSchema = ogDs.getSchema();
Connection ogConn = jdbcExecutor.openConnection(ogDs);
ogConnections.add(ogConn);
String ogHashSql = hashSqlBuilder.buildHashQuery(
ogSchema, ctx.ogTableName, ctx.columns, timeColumn, true);
HashRowIterator ogIter = jdbcExecutor.openHashRowIterator(
ogConn, ogHashSql, startTime, endTime, ctx.fetchSize);
ogIters.add(ogIter);
successfulIds.add(targetId);
log.debug("[TABLE-HASH] OG 迭代器已开启 targetId={}", targetId);
} catch (Exception e) {
log.warn("[TABLE-HASH] OG 连接/查询失败,跳过 targetId={} 原因={}", targetId, e.getMessage());
failedResults.put(targetId, null);
}
}
return new OgIteratorsResult(ogIters, successfulIds, failedResults, ogConnections);
}
/**
* 开启 PG 流式迭代器,执行多路归并比较,并在 finally 中关闭所有资源。
*
* @param ctx 比较上下文(含 PG 连接、hash SQL 等)
* @param ogResult 已就绪的 OG 迭代器及连接
* @param startTime 开始时间(含)
* @param endTime 结束时间(不含)
* @return key 为目标数据源 ID,value 为该目标与 PG 的比较结果
*/
private Map<Long, CompareResult> executeMerge(CompareContext ctx, OgIteratorsResult ogResult,
String startTime, String endTime) {
try (HashRowIterator pgIter = jdbcExecutor.openHashRowIterator(
ctx.pgConnection, ctx.pgHashSql, startTime, endTime, ctx.fetchSize)) {
Map<Long, CompareResult> results = multiWayHashComparer.merge(
pgIter, ogResult.ogIters, ogResult.successfulIds, ctx.idUdtName);
results.putAll(ogResult.failedResults);
for (Map.Entry<Long, CompareResult> entry : results.entrySet()) {
CompareResult r = entry.getValue();
if (r == null) {
log.debug("[TABLE-HASH] targetId={} 失败,无结果", entry.getKey());
} else {
log.debug("[TABLE-HASH] targetId={} PG扫描={} OG扫描={} PG独有={} OG独有={} hash不一致={}",
entry.getKey(), r.getPgRowCount(), r.getOgRowCount(),
r.getPgOnlyIds().size(), r.getOgOnlyIds().size(), r.getMismatchIds().size());
}
}
return results;
} finally {
// 关闭所有 OG 迭代器
for (HashRowIterator ogIter : ogResult.ogIters) {
closeQuietly(ogIter);
}
// 关闭 PG 连接(迭代器不关连接,需手动关闭)
closeQuietly(ctx.pgConnection);
// 关闭 OG 连接
for (Connection ogConn : ogResult.ogConnections) {
closeQuietly(ogConn);
}
}
}
/**
* 校验时间列是否存在于表元数据中。
*
* @param columns 表列元数据
* @param timeColumn 待校验的时间列名
*/
private void validateTimeColumn(List<ColumnMeta> columns, String timeColumn) {
boolean exists = columns.stream()
.anyMatch(col -> col.getColumnName().equalsIgnoreCase(timeColumn));
if (!exists) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "时间字段不存在: " + timeColumn);
}
}
/**
* 按 id 加载数据源,不存在时抛业务异常。
*
* @param id 数据源主键
* @param label 日志/异常中的数据源标签
* @return 数据源实体
*/
private DataSourceDO requireDataSource(Long id, String label) {
DataSourceDO dataSource = dataSourceMapper.selectById(id);
if (dataSource == null) {
throw new BusinessException(ErrorCode.PARAM_ERROR, label + " 数据源不存在, id=" + id);
}
return dataSource;
}
/**
* 静默关闭 JDBC 连接,清理阶段忽略关闭异常。
*
* @param connection 待关闭连接,可为 null
*/
private static void closeQuietly(Connection connection) {
if (connection == null) {
return;
}
try {
connection.close();
} catch (SQLException ignored) {
// ignore close errors during cleanup
}
}
/**
* 静默关闭 AutoCloseable 资源。
*
* @param resource 待关闭资源,可为 null
*/
private static void closeQuietly(AutoCloseable resource) {
if (resource == null) {
return;
}
try {
resource.close();
} catch (Exception ignored) {
// ignore close errors during cleanup
}
}
/**
* 比较上下文,封装 PG 连接与元数据等一次准备的结果。
*/
private static class CompareContext {
/** PG JDBC 连接 */
final Connection pgConnection;
/** 列元数据列表 */
final List<ColumnMeta> columns;
/** 首列 udt_name(用于选择数值/字典序比较器) */
final String idUdtName;
/** PG 侧 hash 查询 SQL */
final String pgHashSql;
/** 流式 fetchSize */
final int fetchSize;
/** OG 表名(大写) */
final String ogTableName;
/** 目标数据源 ID 列表 */
final List<Long> targetIds;
CompareContext(Connection pgConnection, List<ColumnMeta> columns, String idUdtName,
String pgHashSql, int fetchSize, String ogTableName, List<Long> targetIds) {
this.pgConnection = pgConnection;
this.columns = columns;
this.idUdtName = idUdtName;
this.pgHashSql = pgHashSql;
this.fetchSize = fetchSize;
this.ogTableName = ogTableName;
this.targetIds = targetIds;
}
}
/**
* OG 迭代器准备结果,封装成功与失败的 OG 目标信息。
*/
private static class OgIteratorsResult {
/** 成功开启的 OG hash 迭代器列表 */
final List<HashRowIterator> ogIters;
/** 成功开启的 OG 对应的目标数据源 ID */
final List<Long> successfulIds;
/** 失败的 OG 目标及对应的空结果 */
final Map<Long, CompareResult> failedResults;
/** 已开启的 OG JDBC 连接列表 */
final List<Connection> ogConnections;
OgIteratorsResult(List<HashRowIterator> ogIters, List<Long> successfulIds,
Map<Long, CompareResult> failedResults, List<Connection> ogConnections) {
this.ogIters = ogIters;
this.successfulIds = successfulIds;
this.failedResults = failedResults;
this.ogConnections = ogConnections;
}
}
}
3.3 多路归并层
MultiWayHashComparer
package com.liang.learn.service.tablehash;
import com.liang.learn.util.IdComparatorUtil;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* 多路 hash 归并比较器:一个 PG 流式迭代器 + 多个 OG 流式迭代器,一次遍历完成多目标比较。
*
* @author liang
* @since 2026-07-18
*/
@Component
public class MultiWayHashComparer {
/**
* PG 依次与每个 OG 目标做双指针归并,输出各目标的差异结果。
*
* @param pgIter PG 侧 hash 迭代器
* @param ogIters 各 OG 目标迭代器
* @param ogTargetIds 各 OG 目标数据源 ID,与 ogIters 下标一一对应
* @param idUdtName 首列 udt_name,用于选择数值或字典序比较
* @return key 为目标数据源 ID,value 为该目标与 PG 的比较结果
*/
public Map<Long, CompareResult> merge(Iterator<HashRow> pgIter,
List<HashRowIterator> ogIters,
List<Long> ogTargetIds,
String idUdtName) {
MergeRuntime rt = new MergeRuntime(ogIters, ogTargetIds);
HashRow pgRow = advance(pgIter);
while (pgRow != null || anyActive(rt.ogExhausted)) {
// 找到当前最小 id
String minId = findMinId(pgRow, rt.ogRows, rt.ogExhausted, idUdtName);
boolean pgHas = (pgRow != null && idEquals(pgRow.getId(), minId, idUdtName));
for (int i = 0; i < rt.ogIters.size(); i++) {
rt.compareOneOg(i, pgRow, minId, pgHas, idUdtName);
}
if (pgHas) {
pgRow = advance(pgIter);
}
}
return rt.results;
}
/**
* 从迭代器读取下一行
*
* @param iter hash 行迭代器
* @return 下一行,耗尽返回 null
*/
private static HashRow advance(Iterator<HashRow> iter) {
return iter.hasNext() ? iter.next() : null;
}
/**
* 检查是否有尚未耗尽的 OG
*
* @param exhausted 各 OG 耗尽标志
* @return 至少有一个 OG 未耗尽时为 true
*/
private static boolean anyActive(boolean[] exhausted) {
for (boolean e : exhausted) {
if (!e) {
return true;
}
}
return false;
}
/**
* 在 PG 当前行和各 OG 当前行中查找最小 id
*
* @param pgRow PG 当前行
* @param ogRows 各 OG 当前行
* @param exhausted 各 OG 耗尽标志
* @param idUdtName id 类型
* @return 最小 id 值
*/
private static String findMinId(HashRow pgRow, HashRow[] ogRows, boolean[] exhausted, String idUdtName) {
String minId = (pgRow != null) ? pgRow.getId() : null;
for (int i = 0; i < ogRows.length; i++) {
if (exhausted[i] || ogRows[i] == null) {
continue;
}
if (minId == null || IdComparatorUtil.compare(ogRows[i].getId(), minId, idUdtName) < 0) {
minId = ogRows[i].getId();
}
}
return minId;
}
/**
* 比较两个 id 是否相等(按 idUdtName 指定的类型规则)
*
* @param a 第一个 id
* @param b 第二个 id
* @param idUdtName id 类型
* @return 相等返回 true
*/
private static boolean idEquals(String a, String b, String idUdtName) {
return IdComparatorUtil.compare(a, b, idUdtName) == 0;
}
/**
* 多路归并运行时状态,封装各 OG 的迭代器、当前行与耗尽标志,并提供逐行比较逻辑。
*/
private class MergeRuntime {
/** 归并结果(key 为目标数据源 ID) */
final Map<Long, CompareResult> results;
/** OG 目标数据源 ID 列表(与 ogIters 下标对应) */
final List<Long> ogTargetIds;
/** OG 流式迭代器列表 */
final List<HashRowIterator> ogIters;
/** 各 OG 当前行(null 表示已无数据) */
final HashRow[] ogRows;
/** 各 OG 是否已耗尽 */
final boolean[] ogExhausted;
/**
* 初始化归并状态:创建结果容器并预读各 OG 首行。
*
* @param ogIters OG hash 迭代器列表
* @param ogTargetIds 各 OG 对应的目标数据源 ID
*/
MergeRuntime(List<HashRowIterator> ogIters, List<Long> ogTargetIds) {
this.ogIters = ogIters;
this.ogTargetIds = ogTargetIds;
int n = ogIters.size();
this.results = new HashMap<>(n);
for (Long id : ogTargetIds) {
this.results.put(id, new CompareResult());
}
this.ogRows = new HashRow[n];
this.ogExhausted = new boolean[n];
for (int i = 0; i < n; i++) {
this.ogRows[i] = advance(ogIters.get(i));
this.ogExhausted[i] = (this.ogRows[i] == null);
}
}
/**
* 对单个 OG 执行当前最小 id 行的比较:PG 有/OG 有则比对 hash,单侧有则计入独有列表。
*
* @param i OG 下标
* @param pgRow PG 当前行(可能为 null)
* @param minId 本轮最小 id
* @param pgHas PG 是否持有该 id
* @param idUdtName 首列 udt_name(用于选择比较器)
*/
void compareOneOg(int i, HashRow pgRow, String minId, boolean pgHas, String idUdtName) {
CompareResult r = results.get(ogTargetIds.get(i));
if (ogExhausted[i]) {
// OG 已耗尽,PG 仍有数据的行全部是 PG 独有
if (pgHas) {
r.pgRowCount++;
r.getPgOnlyIds().add(minId);
}
return;
}
boolean ogHas = (ogRows[i] != null && idEquals(ogRows[i].getId(), minId, idUdtName));
if (pgHas && ogHas) {
// 两侧都有此行
r.pgRowCount++;
r.ogRowCount++;
if (!pgRow.getRowHash().equals(ogRows[i].getRowHash())) {
r.getMismatchIds().add(minId);
}
ogRows[i] = advance(ogIters.get(i));
if (ogRows[i] == null) {
ogExhausted[i] = true;
}
} else if (pgHas) {
// PG 有、该 OG 没有
r.pgRowCount++;
r.getPgOnlyIds().add(minId);
} else if (ogHas) {
// OG 有、PG 没有
r.ogRowCount++;
r.getOgOnlyIds().add(minId);
ogRows[i] = advance(ogIters.get(i));
if (ogRows[i] == null) {
ogExhausted[i] = true;
}
}
}
}
}
3.4 数据访问层
ExternalJdbcExecutor
package com.liang.learn.service.tablehash;
import com.liang.learn.exception.BusinessException;
import com.liang.learn.model.entity.DataSourceDO;
import com.liang.learn.model.enums.DataSourceTypeEnum;
import com.liang.learn.model.enums.ErrorCode;
import com.liang.learn.service.DataSourceService;
import com.liang.learn.util.IdComparatorUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
/**
* 外部数据库 JDBC 直连执行器
*
* @author liang
* @since 2026-05-24
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ExternalJdbcExecutor {
/** 数据源 Service,用于解密密码 */
private final DataSourceService dataSourceService;
/**
* 打开外部数据库 JDBC 连接
*
* @param dataSource 数据源配置
* @return JDBC 连接,使用后须关闭
*/
public Connection openConnection(DataSourceDO dataSource) {
try {
DataSourceTypeEnum typeEnum = DataSourceTypeEnum.fromCode(dataSource.getDataSourceType());
String driver = typeEnum.resolveDriverClassName(dataSource.getDriverClassName());
String password = dataSourceService.getDecryptedPassword(dataSource.getId());
String url = typeEnum.buildJdbcUrl(
dataSource.getHost(), dataSource.getPort(),
dataSource.getDatabaseName(), dataSource.getSchema());
// 轻量直连,不经过连接池
Class.forName(driver);
Properties props = new Properties();
props.setProperty("user", dataSource.getUsername());
props.setProperty("password", password);
return DriverManager.getConnection(url, props);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.warn("外部库连接失败, dataSourceId={}, reason={}", dataSource.getId(), e.getMessage());
throw new BusinessException(ErrorCode.CONNECTION_FAILED, "外部库连接失败");
}
}
/**
* 开启流式 hash 行迭代器:fetchSize 控制每次拉取行数,ResultSet 不一次性加载到内存。
*
* @param connection 数据库连接(调用方管理生命周期)
* @param sql 已构建的 hash 查询 SQL(含两个时间占位符)
* @param startTime 开始时间(含)
* @param endTime 结束时间(不含)
* @param fetchSize JDBC fetchSize,控制每次网络往返拉取行数
* @return HashRow 流式迭代器,使用后须 close
*/
public HashRowIterator openHashRowIterator(Connection connection, String sql,
String startTime, String endTime,
int fetchSize) {
PreparedStatement ps = null;
try {
ps = connection.prepareStatement(sql);
ps.setFetchSize(fetchSize);
ps.setTimestamp(1, Timestamp.valueOf(startTime));
ps.setTimestamp(2, Timestamp.valueOf(endTime));
ResultSet rs = ps.executeQuery();
return new HashRowIterator(rs, ps);
} catch (SQLException e) {
if (ps != null) {
try {
ps.close();
} catch (SQLException ignored) {
// 清理阶段忽略
}
}
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "开启流式查询失败: " + e.getMessage());
}
}
// ... 其余方法见源文件
}
TableMetadataLoader
package com.liang.learn.service.tablehash;
import com.liang.learn.exception.BusinessException;
import com.liang.learn.model.enums.ErrorCode;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
/**
* 从 PostgreSQL information_schema 加载表列元数据
*
* @author liang
* @since 2026-05-24
*/
@Component
public class TableMetadataLoader {
/** 查询列元数据的 SQL */
private static final String LOAD_COLUMNS_SQL = """
SELECT column_name, ordinal_position, udt_name
FROM information_schema.columns
WHERE table_schema = ? AND table_name = ?
ORDER BY ordinal_position
""";
/**
* 加载指定表的列元数据(从 PG 源库读取)
*
* @param pgConnection PG 连接
* @param schema schema 名
* @param tableName 表名(小写)
* @return 按 ordinal_position 排序的列列表
*/
public List<ColumnMeta> loadColumns(Connection pgConnection, String schema, String tableName) {
try (PreparedStatement ps = pgConnection.prepareStatement(LOAD_COLUMNS_SQL)) {
ps.setString(1, schema);
ps.setString(2, tableName.toLowerCase());
try (ResultSet rs = ps.executeQuery()) {
List<ColumnMeta> columns = new ArrayList<>();
while (rs.next()) {
columns.add(new ColumnMeta(
rs.getString("column_name"),
rs.getInt("ordinal_position"),
rs.getString("udt_name")));
}
// 表不存在或无列时严格失败,避免生成空 hash SQL
if (columns.isEmpty()) {
throw new BusinessException(ErrorCode.NOT_FOUND, "表不存在或无列: " + tableName);
}
return columns;
}
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "读取表结构失败: " + e.getMessage());
}
}
}
3.5 SQL 构建层
HashSqlBuilder
package com.liang.learn.service.tablehash;
import com.liang.learn.util.ColumnTypeHashStrategyUtil;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
/**
* 动态生成 hash 比较 SQL
*
* @author liang
* @since 2026-05-24
*/
@Component
public class HashSqlBuilder {
/**
* 构建 hash 查询 SQL:SELECT id, MD5(concat_ws(...)) AS row_hash
* FROM schema.table WHERE time >= ? AND time < ? ORDER BY id
*
* @param schema schema 名
* @param tableName 表名
* @param columns 列元数据(有序)
* @param timeColumn 时间字段名
* @param uppercaseIdentifiers true 时标识符转大写(openGauss)
* @return 带两个时间占位符的 SQL
*/
public String buildHashQuery(String schema, String tableName, List<ColumnMeta> columns,
String timeColumn, boolean uppercaseIdentifiers) {
String idCol = columns.stream().filter(ColumnMeta::isFirstColumn).findFirst()
.orElseThrow(() -> new IllegalStateException("未找到首列"))
.getColumnName();
String quotedSchema = quoteIdentifier(schema, uppercaseIdentifiers);
String quotedTable = quoteIdentifier(tableName, uppercaseIdentifiers);
String quotedId = quoteColumn(idCol, uppercaseIdentifiers);
String quotedTime = quoteColumn(timeColumn, uppercaseIdentifiers);
List<String> hashParts = columns.stream()
.filter(col -> !ColumnTypeHashStrategyUtil.isExcludedFromHash(col.getUdtName()))
.map(col -> {
String quoted = quoteColumn(col.getColumnName(), uppercaseIdentifiers);
String expr = ColumnTypeHashStrategyUtil.buildExpression(quoted, col.getUdtName());
return ColumnTypeHashStrategyUtil.wrapCoalesce(expr);
})
.collect(Collectors.toList());
return new StringBuilder("SELECT ").append(quotedId).append(", MD5(concat_ws('|', ")
.append(String.join(", ", hashParts))
.append(")) AS row_hash FROM ").append(quotedSchema).append(".").append(quotedTable)
.append(" WHERE ").append(quotedTime).append(" >= ? AND ").append(quotedTime).append(" < ?")
.append(" ORDER BY ").append(quotedId)
.toString();
}
/**
* 构建时间窗行数统计 SQL,用于决定是否启用 keyset 多片
*/
public String buildCountQuery(String schema, String tableName, String timeColumn,
boolean uppercaseIdentifiers) {
String quotedSchema = quoteIdentifier(schema, uppercaseIdentifiers);
String quotedTable = quoteIdentifier(tableName, uppercaseIdentifiers);
String quotedTime = quoteColumn(timeColumn, uppercaseIdentifiers);
return "SELECT COUNT(*) FROM " + quotedSchema + "." + quotedTable
+ " WHERE " + quotedTime + " >= ? AND " + quotedTime + " < ?";
}
/**
* 构建标准化列查询 SQL,用于 hash 不一致时的列级 diff
*/
public String buildNormalizedColumnsQuery(String schema, String tableName, List<ColumnMeta> columns,
boolean uppercaseIdentifiers) {
String quotedSchema = quoteIdentifier(schema, uppercaseIdentifiers);
String quotedTable = quoteIdentifier(tableName, uppercaseIdentifiers);
String quotedId = quoteColumn(columns.get(0).getColumnName(), uppercaseIdentifiers);
List<String> selectParts = columns.stream()
.filter(col -> !col.isFirstColumn())
.filter(col -> !ColumnTypeHashStrategyUtil.isExcludedFromHash(col.getUdtName()))
.map(col -> {
String quoted = quoteColumn(col.getColumnName(), uppercaseIdentifiers);
String expr = ColumnTypeHashStrategyUtil.buildExpression(quoted, col.getUdtName());
return ColumnTypeHashStrategyUtil.wrapCoalesce(expr) + " AS "
+ quoteColumn(col.getColumnName(), uppercaseIdentifiers);
})
.collect(Collectors.toList());
return "SELECT " + quotedId + ", " + String.join(", ", selectParts)
+ " FROM " + quotedSchema + "." + quotedTable
+ " WHERE " + buildIdAnyClause(quotedId);
}
/** 构建 id 批量匹配条件:id 统一转 text,与 varchar[] 参数对齐 */
String buildIdAnyClause(String quotedId) {
return quotedId + "::text = ANY(?)";
}
/** 引用列名 */
public String quoteColumn(String columnName, boolean uppercase) {
return quoteIdentifier(columnName, uppercase);
}
/** 引用 schema / 表名 / 列名 */
public String quoteIdentifier(String name, boolean uppercase) {
String normalized = uppercase ? name.toUpperCase(Locale.ROOT) : name.toLowerCase(Locale.ROOT);
return "\"" + normalized + "\"";
}
}
ColumnTypeHashStrategyUtil
package com.liang.learn.util;
import com.liang.learn.exception.BusinessException;
import com.liang.learn.model.enums.ErrorCode;
import java.util.Locale;
import java.util.Set;
/**
* 列类型到 hash 表达式的映射策略
*
* @author liang
* @since 2026-05-24
*/
public final class ColumnTypeHashStrategyUtil {
/** 不参与 hash 的大对象类型 */
private static final Set<String> EXCLUDED_TYPES = Set.of("blob", "clob", "bytea", "text");
/** numeric/decimal 格式化模板 */
private static final String NUMERIC_FORMAT = "FM99999999999999999990.####################";
/** float 格式化模板 */
private static final String FLOAT_FORMAT = "FM0.####################";
private ColumnTypeHashStrategyUtil() {}
/**
* 判断列类型是否应跳过 hash 计算
*/
public static boolean isExcludedFromHash(String udtName) {
return EXCLUDED_TYPES.contains(udtName.toLowerCase(Locale.ROOT));
}
/**
* 根据列类型生成 hash 用 SQL 表达式(不含 COALESCE)
*/
public static String buildExpression(String quotedColumn, String udtName) {
String type = udtName.toLowerCase(Locale.ROOT);
return switch (type) {
case "int2", "int4", "int8", "int16", "serial", "bigserial", "smallserial" ->
quotedColumn + "::text";
case "numeric", "decimal" ->
"to_char(" + quotedColumn + ", '" + NUMERIC_FORMAT + "')";
case "float4", "float8" ->
"CASE WHEN abs(" + quotedColumn + ")=0 THEN '0' ELSE to_char("
+ quotedColumn + "::double precision, '" + FLOAT_FORMAT + "') END";
case "varchar", "character varying" ->
normalizeNewlines(quotedColumn + "::text");
case "bpchar", "char", "character" ->
"trim(trailing ' ' from " + quotedColumn + "::text)";
case "date" ->
"to_char(" + quotedColumn + ", 'YYYY-MM-DD')";
case "timestamp", "timestamptz" ->
"to_char(" + quotedColumn + ", 'YYYY-MM-DD HH24:MI:SS.US')";
case "bool" ->
"CASE WHEN " + quotedColumn + " THEN '1' ELSE '0' END";
default ->
throw new BusinessException(ErrorCode.BUSINESS_ERROR,
"不支持的列类型: " + udtName);
};
}
/**
* 用 COALESCE 包裹表达式,NULL 统一为 'NULL'
*/
public static String wrapCoalesce(String expression) {
return "COALESCE(" + expression + ", 'NULL')";
}
/**
* 统一换行符为 \n,避免 PG 与 openGauss 文本序列化差异
*/
private static String normalizeNewlines(String textExpr) {
return "regexp_replace(regexp_replace(" + textExpr
+ ", E'\\r\\n', E'\\n', 'g'), E'\\r', E'\\n', 'g')";
}
}
3.6 工具层
IdComparatorUtil
package com.liang.learn.util;
import java.math.BigInteger;
import java.util.Locale;
import java.util.Set;
/**
* 首列 id 比较器,与 SQL ORDER BY 语义对齐
*
* @author liang
* @since 2026-05-24
*/
public final class IdComparatorUtil {
/** 按数值序比较 id 的类型(与 PG bigint/int ORDER BY 一致) */
private static final Set<String> NUMERIC_ID_TYPES = Set.of(
"int2", "int4", "int8", "int16", "serial", "bigserial", "smallserial");
private IdComparatorUtil() {}
/**
* 比较两个 id 字符串
*
* @param leftId 左侧 id
* @param rightId 右侧 id
* @param idUdtName 首列 udt_name
* @return compareTo 语义下的比较结果
*/
public static int compare(String leftId, String rightId, String idUdtName) {
if (isNumericIdType(idUdtName)) {
return new BigInteger(leftId).compareTo(new BigInteger(rightId));
}
return leftId.compareTo(rightId);
}
/**
* 首列是否为数值型 id(与 SQL ORDER BY 数值序一致)
*/
public static boolean isNumericIdType(String udtName) {
return NUMERIC_ID_TYPES.contains(udtName.toLowerCase(Locale.ROOT));
}
}
3.7 数据模型层
TableHashCompareDTO(入参)
package com.liang.learn.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.util.List;
/**
* 两表 hash 比较请求参数
*
* @author liang
* @since 2026-05-24
*/
@Data
@Schema(description = "两表 hash 比较请求")
public class TableHashCompareDTO {
/** 表名(小写,与 PG 一致) */
private static final String IDENTIFIER_PATTERN = "^[a-z][a-z0-9_]*$";
@NotBlank(message = "表名不能为空")
@Pattern(regexp = IDENTIFIER_PATTERN, message = "表名只能包含小写字母、数字和下划线,且以字母开头")
@Schema(description = "表名(小写,与 PG 一致)", example = "test_all_types")
private String tableName;
@NotBlank(message = "时间字段名不能为空")
@Pattern(regexp = IDENTIFIER_PATTERN, message = "时间字段名只能包含小写字母、数字和下划线,且以字母开头")
@Schema(description = "时间字段名(小写)", example = "col_timestamp_without")
private String timeColumn;
@NotBlank(message = "开始时间不能为空")
@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}", message = "开始时间格式须为 yyyy-MM-dd HH:mm:ss")
@Schema(description = "开始时间(含)", example = "2020-01-01 00:00:00")
private String startTime;
@NotBlank(message = "结束时间不能为空")
@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}", message = "结束时间格式须为 yyyy-MM-dd HH:mm:ss")
@Schema(description = "结束时间(不含)", example = "2030-01-01 00:00:00")
private String endTime;
@NotEmpty(message = "目标端数据源ID列表不能为空")
@Schema(description = "目标端 openGauss 数据源 ID 列表", example = "[5, 6, 7]")
private List<Long> targetDataSourceIds;
/** 校验时间范围为左闭右开且 start < end */
@AssertTrue(message = "开始时间必须早于结束时间")
public boolean isTimeRangeValid() {
if (startTime == null || endTime == null) {
return true;
}
return startTime.compareTo(endTime) < 0;
}
}
CompareResult(比较结果)
package com.liang.learn.service.tablehash;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
/**
* 两库 hash 归并比较结果(仅含差异 id 与行数统计,不含行内容)
*
* @author liang
* @since 2026-05-24
*/
@Getter
public class CompareResult {
/** PG 独有行的 id 列表 */
private final List<String> pgOnlyIds = new ArrayList<>();
/** openGauss 独有行的 id 列表 */
private final List<String> ogOnlyIds = new ArrayList<>();
/** 两库均有但 hash 不一致的 id 列表 */
private final List<String> mismatchIds = new ArrayList<>();
/** PG 侧扫描行数(多分片时各片 pgRowCount 累加) */
long pgRowCount;
/** openGauss 侧扫描行数(多分片时各片 ogRowCount 累加) */
long ogRowCount;
/**
* 合并另一分片结果:id 列表追加、行数累加(keyset 多片循环调用)
*/
public void mergeFrom(CompareResult other) {
pgOnlyIds.addAll(other.getPgOnlyIds());
ogOnlyIds.addAll(other.getOgOnlyIds());
mismatchIds.addAll(other.getMismatchIds());
pgRowCount += other.getPgRowCount();
ogRowCount += other.getOgRowCount();
}
}
HashRow(行数据)
package com.liang.learn.service.tablehash;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 单行 hash 查询结果
*
* @author liang
* @since 2026-05-24
*/
@Getter
@AllArgsConstructor
public class HashRow implements Comparable<HashRow> {
/** 行 id(首列值,字符串形式) */
private final String id;
/** MD5 行 hash */
private final String rowHash;
@Override
public int compareTo(HashRow other) {
return this.id.compareTo(other.id);
}
}
HashRowIterator(流式迭代器)
package com.liang.learn.service.tablehash;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Iterator;
import java.util.NoSuchElementException;
/**
* 基于 JDBC ResultSet 的 HashRow 流式迭代器,逐行消费,内存占用 O(1)。
*
* @author liang
* @since 2026-07-18
*/
public class HashRowIterator implements Iterator<HashRow>, AutoCloseable {
/** JDBC 结果集,由外部传入 */
private final ResultSet rs;
/** JDBC PreparedStatement,close 时关闭 */
private final PreparedStatement ps;
/** 预读缓冲行,实现 hasNext/next 语义 */
private HashRow nextRow;
/** 是否已耗尽 */
private boolean exhausted;
public HashRowIterator(ResultSet rs, PreparedStatement ps) {
this.rs = rs;
this.ps = ps;
}
@Override
public boolean hasNext() {
if (exhausted) {
return false;
}
if (nextRow != null) {
return true;
}
try {
if (rs.next()) {
nextRow = new HashRow(rs.getString(1), rs.getString(2));
return true;
}
exhausted = true;
return false;
} catch (SQLException e) {
exhausted = true;
throw new com.liang.learn.exception.BusinessException(
com.liang.learn.model.enums.ErrorCode.SYSTEM_ERROR,
"流式读取行数据失败: " + e.getMessage());
}
}
@Override
public HashRow next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
HashRow row = nextRow;
nextRow = null;
return row;
}
/**
* 关闭 ResultSet 和 PreparedStatement,释放数据库游标资源。
* 不关闭 Connection,由调用方管理连接生命周期。
*/
@Override
public void close() {
closeQuietly(rs);
closeQuietly(ps);
}
private static void closeQuietly(AutoCloseable resource) {
if (resource == null) {
return;
}
try {
resource.close();
} catch (Exception ignored) {
// 关闭阶段忽略异常
}
}
}
ColumnMeta(列元数据)
package com.liang.learn.service.tablehash;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 表列元数据,用于 hash SQL 动态生成
*
* @author liang
* @since 2026-05-24
*/
@Getter
@AllArgsConstructor
public class ColumnMeta {
/** 列名(小写) */
private final String columnName;
/** 列序号,从 1 开始 */
private final int ordinalPosition;
/** PostgreSQL udt_name,如 int8、varchar、timestamp */
private final String udtName;
/** 是否为首列(作为比较 id) */
public boolean isFirstColumn() {
return ordinalPosition == 1;
}
}
TableHashTaskVO(出参)
package com.liang.learn.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* hash 比较任务提交结果
*
* @author liang
* @since 2026-05-24
*/
@Data
@AllArgsConstructor
@Schema(description = "hash 比较任务提交结果")
public class TableHashTaskVO {
@Schema(description = "任务 ID,用于日志追踪")
private String taskId;
}
3.8 基础设施层
TableHashProperties(配置)
package com.liang.learn.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 两表 hash 比较功能配置
*
* @author liang
* @since 2026-05-24
*/
@Data
@Component
@ConfigurationProperties(prefix = "app.table-hash")
public class TableHashProperties {
/** PostgreSQL 数据源 ID(源库) */
private Long pgsqlDataSourceId = 3L;
/** openGauss 数据源 ID(目标库) */
private Long opengaussDataSourceId = 2L;
/** JDBC 流式读取 fetchSize,控制每次从数据库拉取的行数 */
private int streamFetchSize = 1000;
}
DataSourceDO(数据源实体)
package com.liang.learn.model.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 数据源管理实体类
* 对应数据库表 data_source_manage
*
* @author liang
* @since 2026-05-23 18:14:36
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DataSourceDO {
private Long id;
private String dataSourceName;
private String dataSourceCode;
/** 数据源类型:pgsql/mysql/opengauss */
private String dataSourceType;
private String host;
private Integer port;
private String databaseName;
/** 数据库 schema,pgsql/opengauss 使用,mysql 为空 */
private String schema;
private String username;
/** 连接密码,AES 加密存储 */
private String password;
private String driverClassName;
/** 数据源状态:active(活跃)/inactive(非活跃)/disabled(禁用) */
private String status;
private String description;
private String createUser;
private LocalDateTime createTime;
private String updateUser;
private LocalDateTime updateTime;
/** 逻辑删除标记:0-未删除,1-已删除 */
private Integer deleted;
}
BusinessException(业务异常)
package com.liang.learn.exception;
import com.liang.learn.model.enums.ErrorCode;
import lombok.Getter;
/**
* 自定义业务异常类
*
* @author liang
* @since 2026-05-19 21:08:00
*/
@Getter
public class BusinessException extends RuntimeException {
/** 错误码 */
private final int code;
/** 错误枚举 */
private final ErrorCode errorCode;
public BusinessException(int code, String message) {
super(message);
this.code = code;
this.errorCode = null;
}
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
this.errorCode = errorCode;
}
public BusinessException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
this.errorCode = null;
}
}
ErrorCode(错误码枚举)
package com.liang.learn.model.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 错误码枚举类
*
* @author liang
* @since 2026-05-19 21:08:00
*/
@Getter
@AllArgsConstructor
public enum ErrorCode {
SUCCESS(0, "操作成功"),
PARAM_ERROR(1001, "参数校验失败"),
UNAUTHORIZED(1002, "未登录或登录已过期"),
FORBIDDEN(1003, "权限不足"),
NOT_FOUND(1004, "资源不存在"),
BUSINESS_ERROR(2001, "业务逻辑错误"),
CONNECTION_FAILED(2002, "连接测试失败"),
SYSTEM_ERROR(9999, "系统异常");
private final int code;
private final String message;
}
4. 补充说明
4.1 类关系图
TableHashCompareService (接口)
└── TableHashCompareServiceImpl
│
├── 依赖 TableHashCompareExecutor
│ ├── DataSourceMapper (MyBatis mapper)
│ ├── TableHashProperties (配置)
│ ├── ExternalJdbcExecutor (JDBC 直连)
│ │ └── DataSourceService (密码解密)
│ ├── TableMetadataLoader (列元数据)
│ │ └── ColumnMeta (数据类)
│ ├── HashSqlBuilder (SQL 生成)
│ │ └── ColumnTypeHashStrategyUtil (列类型→hash 表达式)
│ ├── MultiWayHashComparer (多路归并)
│ │ └── IdComparatorUtil (id 比较)
│ ├── CompareResult (结果数据类)
│ ├── HashRow (行数据类)
│ └── HashRowIterator (流式迭代器)
│
└── 依赖 ThreadPoolTaskExecutor (异步线程池)
4.2 关键设计模式
| 模式 | 应用位置 | 说明 |
|---|---|---|
| 策略模式 | ColumnTypeHashStrategyUtil |
根据列类型选择不同的 hash 表达式生成策略 |
| 策略模式 | IdComparatorUtil |
根据首列类型选择数值序/字典序比较策略 |
| 迭代器模式 | HashRowIterator |
封装 JDBC ResultSet 为懒加载迭代器 |
| 模板方法 | TableHashCompareExecutor.compare() |
定义三步编排骨架,子步骤委托给各组件 |
| 建造者模式 | HashSqlBuilder |
动态拼接 SQL,不同数据库类型只需调整标识符大小写 |
4.3 重构历史
- 最初版本:
compare()方法 101 行,merge()方法 70 行 - 2026-07-18 重构:
compare()拆分为compare/buildCompareContext/prepareOgIterators/executeMerge四个方法merge()提取MergeRuntime内部类,封装归并状态,compareOneOg()承担单 OG 行比较逻辑- 所有方法均满足 ≤50 行、≤5 参数
4.4 配置项
# application.yml 中的 table-hash 配置
app:
table-hash:
pgsql-data-source-id: 3 # PG 数据源 ID
opengauss-data-source-id: 2 # OG 数据源 ID(默认)
stream-fetch-size: 1000 # JDBC 流式 fetchSize
4.5 线程池隔离
submitCompare() 使用专用的 tableHashExecutor 线程池执行比较任务,避免重型 hash 计算阻塞 HTTP 请求线程。配置示例:
@Bean("tableHashExecutor")
public ThreadPoolTaskExecutor tableHashExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 最多同时执行 2 个比较任务
executor.setMaxPoolSize(4);
executor.setQueueCapacity(10); // 队列满时拒绝新任务
executor.setRejectedExecutionHandler(new CallerRunsPolicy()); // 或抛 RejectedExecutionException
executor.initialize();
return executor;
}
4.6 性能考量
| 场景 | 瓶颈 | 优化措施 |
|---|---|---|
| 百万级行数 | 内存(全量加载) | HashRowIterator 流式消费,O(1) 内存 |
| 多目标 OG | CPU(N 次比较) | 双指针归并,PG 只扫描一次,避免 N 次全量加载 |
| 网络往返 | 数据库→应用传输 | fetchSize 控制每次拉取行数(默认 1000) |
| 大对象列 | MD5 计算开销 | blob/clob/bytea/text 排除出 hash 计算 |
4.7 扩展点
- 列级 diff:当
mismatchIds非空时,可调用ExternalJdbcExecutor.fetchNormalizedRows()和ColumnDiffCalculator做逐列对比 - 更多目标类型:当前只支持
pgsql/opengauss,新增数据库类型只需扩展DataSourceTypeEnum和驱动注册 - 结果持久化:当前只输出日志,可扩展为写入数据库表或消息队列
浙公网安备 33010602011771号