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)在指定时间窗内的数据是否一致。

核心思路

  1. 对 PG 和每个 OG 分别执行相同的 hash 查询:SELECT id, MD5(concat_ws(...)) FROM table WHERE time >= ? AND time < ? ORDER BY id
  2. 由于 SQL 已按 id 排序,可以利用双指针归并算法,一次遍历完成 PG ↔ 所有 OG 的差异比对
  3. 结果输出三类差异: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 重构历史

  1. 最初版本compare() 方法 101 行,merge() 方法 70 行
  2. 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 和驱动注册
  • 结果持久化:当前只输出日志,可扩展为写入数据库表或消息队列
posted @ 2026-07-19 15:57  景之1231  阅读(8)  评论(0)    收藏  举报