# ora2tidb **Repository Path**: yao-coder/ora2tidb ## Basic Information - **Project Name**: ora2tidb - **Description**: Oracle → TiDB **历史数据迁移**工具。基于 Java 8 + Spring Boot 2.7 + MyBatis-Plus, 以 **ROWID 游标分页**读取 Oracle,**多表 + 表内分段并发**写入 TiDB, 配套 **批级重试 + INSERT IGNORE 幂等**,通过一个无参 REST 接口异步触发。 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-06-04 - **Last Updated**: 2026-06-04 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # ora2tidb-migrator Oracle → TiDB **历史数据迁移**工具。基于 Java 8 + Spring Boot 2.7 + MyBatis-Plus, 以 **ROWID 游标分页**读取 Oracle,**多表 + 表内分段并发**写入 TiDB, 配套 **批级重试 + INSERT IGNORE 幂等**,通过一个无参 REST 接口异步触发。 --- ## 目录 - [它解决什么问题](#它解决什么问题) - [适用场景](#适用场景) - [不适用 / 边界](#不适用--边界) - [整体架构](#整体架构) - [核心设计](#核心设计) - [快速开始](#快速开始) - [配置详解](#配置详解) - [REST API](#rest-api) - [运行机制与并发模型](#运行机制与并发模型) - [可靠性:重试与幂等](#可靠性重试与幂等) - [安全:SQL 注入防御](#安全sql-注入防御) - [可观测性](#可观测性) - [常见问题排查](#常见问题排查) - [技术栈与版本约束](#技术栈与版本约束) - [项目结构](#项目结构) --- ## 它解决什么问题 把 Oracle 里**已存在的、相对静态的历史表数据**整体搬运到 TiDB(MySQL 协议)。 设计目标聚焦三点: 1. **不靠主键也能稳定分页。** 历史表常常没有连续自增主键、或主键类型五花八门(复合键、字符串键、无主键)。 本工具用 Oracle 物理 **ROWID** 作游标分页,不依赖任何业务列,对任意表通用。 2. **大表能并发、能扛中断。** 单表按 ROWID 切成多段并发读写;以「读一批 + 写一批」为最小重试单元, 配合 `INSERT IGNORE` 实现幂等,断点重跑不产生重复行。 3. **只搬数据、零副作用。** 不建表、不改表、不动源库(源端只读)。目标表必须**预先建好**, 程序启动时硬校验目标表存在且覆盖源表全部列,缺列直接 fail-fast。 --- ## 适用场景 下列场景正是本工具的设计靶心: | 场景 | 说明 | | --- | --- | | **Oracle → TiDB 一次性历史数据迁移** | 系统下线 / 国产化替换 / 去 O 项目中,把历史业务表批量搬到 TiDB。 | | **历史/归档表搬迁** | 数据基本不再变化(或迁移窗口内可停写)的大表、归档表、流水表。 | | **无主键 / 主键不规则的表** | 复合主键、字符串主键、甚至没有主键的表——ROWID 分页与业务主键无关,照样分页并发。 | | **超大表的并行装载** | 千万~亿级行的大表,靠表内 ROWID 分段把单表拆成 N 段并发灌入 TiDB,缩短迁移窗口。 | | **含 LOB(CLOB/NCLOB/BLOB)的表** | 自动识别 LOB 列并切换更小的读取批,避免整批 CLOB 物化撑爆堆内存。 | | **非 UTF8 源库(如 ZHS16GBK)** | 引入 `orai18n`,规避读取中文 CLOB 时的 `ORA-29275` 字符集报错。 | | **可重复、可断点续跑的迁移** | 任务失败后直接重新 `POST /migrate`,已写入的行靠唯一键被 `INSERT IGNORE` 跳过。 | | **白名单式、范围可控的迁移** | 只迁清单文件里列出的表,绝不扫描或误迁源库下的其它表。 | **典型用法**:在 TiDB 端先建好目标表(DDL 由 DBA / 其它工具转换并 review), 把要迁的表名写进 `migration-tables.txt`,填好源/目标库连接,启动服务,`POST /migrate`,轮询任务状态直至完成。 --- ## 不适用 / 边界 为避免误用,明确以下**不在职责范围**内的事项: - **不做 DDL 转换 / 不建表 / 不 ALTER。** 目标表须事先建好;缺表、缺列都会直接报错终止该表。 - **不做实时增量 / CDC 同步。** 这是**批量快照**搬迁,不监听 binlog/redo,不做持续同步。 对迁移窗口内仍在高频写入的源表,新增/变更的行不保证被捕获。 - **不做类型/编码的语义转换。** 列值原样透传(依赖 JDBC 驱动的类型映射),不做单位换算、脱敏、清洗。 - **不保证跨表事务一致性。** 各表、各分段独立提交,没有全局一致性快照。 - **任务状态仅存内存。** 进程重启后任务历史清空;分段级进度只在日志里,不落库。 - **依赖目标表唯一键保证幂等。** 目标表**没有主键/唯一键**时,批级重试可能产生重复行(见[可靠性](#可靠性重试与幂等))。 --- ## 整体架构 ``` ┌────────────────────────────────────────────────┐ POST /migrate │ ora2tidb-migrator (JVM) │ ─────────────────────► │ │ │ MigrationController ──► TaskRegistry(内存,异步) │ GET /tasks/{id} │ │ │ ◄───────────────────── │ ▼ │ GET /tasks/{id}/progress MigrationService.runSync() │ │ │ 表级线程池 (concurrent-tables) │ │ ▼ │ │ TableMigrator (每张表一个) │ │ │ 1. 查列元数据 + 目标表硬校验 │ │ │ 2. RowidSegmentSplitter 切 N 段 │ │ │ 3. 段级线程池 (shards-per-table) │ │ ▼ │ │ CursorTableReader (每段一个) │ │ │ ROWID 游标滚动:读一批→写一批 │ │ ┌────┴─────┐ │ │ ▼ ▼ │ │ OracleMapper TidbBatchWriter ──► TidbMapper │ └───┼──────────────────────────────────┼─────────┘ ▼ (只读) ▼ (INSERT IGNORE) ┌─────────────────┐ ┌─────────────────────┐ │ Oracle (源库) │ │ TiDB (目标库) │ │ HikariCP 连接池 │ │ HikariCP 连接池 │ └─────────────────┘ └─────────────────────┘ ``` **双数据源、双 SqlSessionFactory**:Oracle 源(`OracleMyBatisConfig`)与 TiDB 目标 (`TidbMyBatisConfig`)各自独立装配 HikariCP + MyBatis,互不干扰; 入口 `MigrationMain` 显式排除 Spring 的单数据源自动装配。 --- ## 核心设计 ### 1. ROWID 游标分页(替代 OFFSET 翻页) 读取 SQL 形如(见 `OracleMigrationMapper.xml`): ```sql SELECT * FROM ( SELECT , ROWIDTOCHAR(ROWID) AS "__CURSOR_ROWID" FROM "SCHEMA"."TABLE" WHERE ROWID > CHARTOROWID(#{lastCursor}) -- 上批末行游标,开区间 AND ROWID <= CHARTOROWID(#{segEnd}) -- 段右边界,闭区间(可空) ORDER BY ROWID ) WHERE ROWNUM <= #{batchSize} ``` - 每批取末行的 `__CURSOR_ROWID` 作下一批起点,**深翻页不退化**(不像 `OFFSET n` 越翻越慢)。 - 取不满一批即视为本段读完。 - ROWID 是物理地址、全表唯一且有序,**无需任何业务主键**。 ### 2. 表内 ROWID 分段并发 `RowidSegmentSplitter` 按 ROWID 升序采样 S-1 个边界 ROWID,把表切成 S 个不重叠区间 `(-∞,b1]、(b1,b2]、…、(b(S-1),+∞)`,每段交给一个 `CursorTableReader` 并发滚动。 - 仅在切分阶段做 S-1 次采样查询,段内是纯游标滚动,开销可控。 - 数据倾斜导致边界重合时,段数自然减少,区间仍不重叠(用 `LinkedHashSet` 去重)。 - **小表免分段**:总行数 ≤ `shard-chunk-rows` 或 `shards-per-table<=1` 时,单游标整表处理。 ### 3. 批级幂等写入 `CursorTableReader` 以「**读一批 + 写完这批**」为重试单元:每批用独立的 `TidbBatchWriter`, 当批必须 `flush()` 干净。重试时游标未推进、重读相同行,配合 `INSERT IGNORE` 跳过已写入行。 `TidbBatchWriter` 把读取批按「行数 `rows-per-insert` 或字节 `max-batch-bytes` 任一阈值」 二次切分成多条 multi-value `INSERT IGNORE`,防止单条 SQL 触碰 TiDB `txn-total-size-limit` / MySQL 协议 `max_allowed_packet`。 ### 4. 目标表硬校验(fail-fast) 迁移每张表前,先查 `information_schema.columns` 拿目标列集,校验: - 目标表**必须已存在**(否则报错:请先在 TiDB 端建表); - 目标表**必须覆盖源表全部列**(大小写不敏感;缺列报错并列出缺哪些列)。 本工具只搬数据,DDL 由用户/DBA 自行准备。 --- ## 快速开始 ### 前置条件 - **JDK 8**(项目锁定 Java 8,见[版本约束](#技术栈与版本约束))。 - **Maven 3.6+**。 - 可访问的 **Oracle** 源库(只读账号即可)与 **TiDB** 目标库(MySQL 协议,4000 端口)。 - 目标表已在 TiDB 端建好,且每张目标表带主键/唯一键(保证幂等)。 ### 1. 准备表清单 编辑 `src/main/resources/hisdata/migration-tables.txt`,一行一张表名(两端同名、大写,`#` 注释、空行忽略): ``` # 待迁移表清单 AXXXFCWVCH DDGEEM EWREWIU ``` > 也可用环境变量 `MIGRATION_TABLES_FILE=file:/etc/ora2tidb/tables.txt` 指向**外部文件**,改清单无需重新打包。 ### 2. 配置连接(推荐用环境变量,避免明文密码入库) ```powershell # PowerShell 示例 $env:ORACLE_URL = "jdbc:oracle:thin:@//oracle-host:1521/ORCLPDB1" $env:ORACLE_USER = "migrator" $env:ORACLE_PASSWORD = "******" $env:TIDB_URL = "jdbc:mysql://tidb-host:4000/?useSSL=false&useLocalSessionState=true&rewriteBatchedStatements=true&cachePrepStmts=true" $env:TIDB_USER = "root" $env:TIDB_PASSWORD = "******" ``` 源/目标 schema 在 `application.yml` 里改 `migration.source-schema` / `migration.target-schema`。 ### 3. 编译打包 ```powershell mvn clean package # 生成 target/ora2tidb-migrator.jar ``` ### 4. 启动 ```powershell java -jar target/ora2tidb-migrator.jar # 启动日志会打印本次迁移范围:源 schema -> 目标 schema、表清单文件、待迁表数与表名 ``` ### 5. 触发迁移并轮询 ```powershell # 触发(无参,异步)→ 返回 202 + taskId curl -X POST http://localhost:8080/migrate # 轮询任务状态(含每张表的成功/失败、耗时、错误) curl http://localhost:8080/tasks/mig-20260604xxxxxx ``` --- ## 配置详解 全部配置位于 `src/main/resources/application.yml` 的 `migration.*` 节点。 冒号后为缺省值,可用环境变量覆盖。 ### 迁移范围 | 配置项 | 含义 | 默认 | | --- | --- | --- | | `source-schema` | Oracle 源 schema(按大写匹配) | `TGWB` | | `target-schema` | TiDB 目标 schema | `TGUSER` | | `tables-file` | 表清单文件,支持 `classpath:` / `file:` 前缀 | `classpath:hisdata/migration-tables.txt` | ### 源库连接(Oracle,只读) | 配置项 | 含义 | 默认 | | --- | --- | --- | | `source.url` / `source.user` / `source.password` | JDBC 连接 | 环境变量覆盖 | | `source.pool-size` | HikariCP 连接池大小 | `10` | | `source.fetch-size` | 流式读取 fetchSize(`defaultRowPrefetch`) | `1000` | | `source.default-lob-prefetch-size` | LOB 预取大小 | `4000` | ### 目标库连接(TiDB) | 配置项 | 含义 | 默认 | | --- | --- | --- | | `target.url` / `target.user` / `target.password` | JDBC 连接(URL 已带 `rewriteBatchedStatements` 等批量优化参数) | 环境变量覆盖 | | `target.pool-size` | HikariCP 连接池大小 | `10` | ### 并发 | 配置项 | 含义 | 默认 | | --- | --- | --- | | `parallelism.concurrent-tables` | 同时迁移的**表**数 | `2` | | `parallelism.concurrent-shards-per-table` | 单表内并发的**分段**数 | `4` | > **连接占用估算**:真实连接 ≈ `concurrent-tables × concurrent-shards-per-table`(每段任意时刻读或写各占 1 连接)。 > 调大并发前,确认两端连接池 `pool-size` 够用。 ### 批量 | 配置项 | 含义 | 默认 | | --- | --- | --- | | `batch.rows-per-batch` | 每批读取行数(ROWID 游标 batchSize) | `500` | | `batch.rows-per-insert` | 每条 multi-value INSERT 的行数(读取批二次切分) | `200` | | `batch.lob-rows-per-fetch` | **含 LOB 表**的每批读取行数(更小,防 OOM) | `100` | | `batch.max-batch-bytes` | 写入子批字节上限(防超 txn-size / max_allowed_packet) | `16777216`(16MB) | | `batch.shard-chunk-rows` | 分段阈值:表行数 ≤ 此值则不分段、单游标处理 | `100000` | ### 重试(指数退避) | 配置项 | 含义 | 默认 | | --- | --- | --- | | `retry.max-attempts` | 单批最大尝试次数 | `5` | | `retry.base-delay-millis` | 退避基数 | `1000` | | `retry.max-delay-millis` | 退避上限 | `30000` | > 退避公式:第 n 次失败睡 `min(base * 2^(n-1), max)` ms。 --- ## REST API | 方法 & 路径 | 说明 | 返回 | | --- | --- | --- | | `POST /migrate` | 触发一次迁移(**无参、异步**)。已有任务 RUNNING 时返回 409。 | `202` + `{taskId, status, startedAt}` | | `GET /tasks` | 列出进程内全部任务概览(含历史,重启清空)。 | `200` + 任务数组 | | `GET /tasks/{taskId}` | 单任务完整详情,含表级 summary(每张表成功/失败、耗时、错误)。 | `200` / `404` | | `GET /tasks/{taskId}/progress` | 精简版进度,只返回 tables 列表。 | `200` | | `GET /actuator/health` | 健康检查(Spring Actuator)。 | `200` | | `GET /actuator/metrics` | 指标(Spring Actuator)。 | `200` | > 设计上**同一时刻只允许一个迁移任务**:已有任务在 RUNNING 时再次 `POST /migrate` 会得到 `409 Conflict`。 > 所有非 2xx 响应体统一为 `{"error": "..."}`。时间字段为 epoch 毫秒(配置 `write-dates-as-timestamps`),响应均 pretty-print。 ### 示例响应 #### `POST /migrate` — 触发成功 ```http POST /migrate (无请求体、无查询参数) ``` ```http HTTP/1.1 202 Accepted Content-Type: application/json ``` ```json { "taskId" : "mig-20260604103000", "status" : "RUNNING", "startedAt" : 1749000000000 } ``` #### `POST /migrate` — 已有任务在运行(冲突) ```http HTTP/1.1 409 Conflict ``` ```json { "error" : "已有迁移任务在运行: mig-20260604103000" } ``` #### `GET /tasks/{taskId}` — 任务成功(含表级 summary) ```http HTTP/1.1 200 OK ``` ```json { "taskId" : "mig-20260604103000", "status" : "SUCCEEDED", "startedAt" : 1749000000000, "finishedAt" : 1749000182000, "summary" : { "ok" : 3, "failed" : 0, "elapsedMs" : 182000, "tables" : [ { "label" : "TGWB.AXXXFCWVCH", "success" : true, "elapsedMs" : 95000, "error" : null }, { "label" : "TGWB.DDGEEM", "success" : true, "elapsedMs" : 41000, "error" : null }, { "label" : "TGWB.EWREWIU", "success" : true, "elapsedMs" : 46000, "error" : null } ] }, "error" : null } ``` #### `GET /tasks/{taskId}` — 任务部分失败 `status` 为 `FAILED`,但保留 `summary` 让你定位是哪张表、为什么失败: ```http HTTP/1.1 200 OK ``` ```json { "taskId" : "mig-20260604110000", "status" : "FAILED", "startedAt" : 1749006000000, "finishedAt" : 1749006075000, "summary" : { "ok" : 2, "failed" : 1, "elapsedMs" : 75000, "tables" : [ { "label" : "TGWB.AXXXFCWVCH", "success" : true, "elapsedMs" : 52000, "error" : null }, { "label" : "TGWB.DDGEEM", "success" : true, "elapsedMs" : 23000, "error" : null }, { "label" : "TGWB.EWREWIU", "success" : false, "elapsedMs" : 1200, "error" : "目标表 `TGUSER`.`EWREWIU` 缺少以下源表列: [REMARK, EXT_COL]" } ] }, "error" : "1 张表失败" } ``` #### `GET /tasks/{taskId}` — 任务仍在运行 任务 RUNNING 时 `summary` 可能为 `null`(在 `runSync` 结束才写入),运行中的分段级进度看应用日志: ```json { "taskId" : "mig-20260604103000", "status" : "RUNNING", "startedAt" : 1749000000000, "finishedAt" : null, "summary" : null, "error" : null } ``` #### `GET /tasks` — 列出全部任务 ```http HTTP/1.1 200 OK ``` ```json [ { "taskId" : "mig-20260604103000", "status" : "SUCCEEDED", "startedAt" : 1749000000000, "finishedAt" : 1749000182000, "summary" : { "ok" : 3, "failed" : 0, "elapsedMs" : 182000, "tables" : [ ] }, "error" : null }, { "taskId" : "mig-20260604110000", "status" : "RUNNING", "startedAt" : 1749006000000, "finishedAt" : null, "summary" : null, "error" : null } ] ``` #### `GET /tasks/{taskId}/progress` — 精简进度 ```http HTTP/1.1 200 OK ``` ```json { "taskId" : "mig-20260604103000", "status" : "SUCCEEDED", "tables" : [ { "label" : "TGWB.AXXXFCWVCH", "success" : true, "elapsedMs" : 95000, "error" : null }, { "label" : "TGWB.DDGEEM", "success" : true, "elapsedMs" : 41000, "error" : null }, { "label" : "TGWB.EWREWIU", "success" : true, "elapsedMs" : 46000, "error" : null } ] } ``` > 任务还在 RUNNING 时 `tables` 可能为空数组 `[]`(summary 尚未写入)。 #### `GET /tasks/{taskId}` — taskId 不存在 ```http HTTP/1.1 404 Not Found ``` ```json { "error" : "未知 taskId: mig-xxxxxxxxxxxxxx" } ``` --- ## 运行机制与并发模型 ``` TaskRegistry (cached 线程池, 异步提交) └─ MigrationService.runSync() └─ 表级 FixedThreadPool(concurrent-tables) ← 多张表并发 └─ TableMigrator.migrate() / 每张表 └─ 段级 FixedThreadPool(shards-per-table) ← 单表内多段并发 └─ CursorTableReader.run() / 每段 └─ while: 读一批 → 写一批 → 推进游标 ``` - **表级**:`MigrationService` 用 `concurrent-tables` 大小的线程池并发跑多张表,逐个 `Future.get()` 收集结果。 - **段级**:`TableMigrator` 对大表用 `shards-per-table` 大小的线程池并发跑各分段;小表单段直跑。 - **单表失败不传染**:某表抛异常只把该表标 `FAILED` 计入 summary,其余表继续;有任一表失败则整个任务最终为 `FAILED`,但保留 summary 让你看清是哪张表。 --- ## 可靠性:重试与幂等 - **重试单元 = 一批读写。** `RetryPolicy` 只对「可能瞬时」的 `SQLException` 重试: 连接断(SQLState `08*`)、死锁/序列化失败(`40001`)、超时 / connection reset / broken pipe 等。 **数据类错误**(唯一键冲突、类型不匹配)不重试,直接抛出标记 FAILED。 - **幂等靠 `INSERT IGNORE` + 目标表唯一键。** 重试时重读相同行、重写,已存在的行因主键/唯一键冲突被忽略。 - ⚠️ **前提**:目标表**必须有主键或唯一键**。否则批级重试会插入重复行——迁移前务必确认目标表唯一约束。 --- ## 安全:SQL 注入防御 列名、表名在 SQL 里只能用 `${}` 拼接(无法用 `#{}` 占位)。为防注入,所有进入 `${}` 的标识符 (schema / 表名 / 列名)都先过 `Identifiers` 白名单校验(`^[A-Za-z_][A-Za-z0-9_$#]*$`), 再按库类型加引号拼接(Oracle `"col"`,TiDB `` `col` ``)。 行**值**始终用 MyBatis `#{}` 预编译绑定(内层 `foreach` 的标量 `#{cell}`),不参与字符串拼接。 > 标识符来源虽是 `ALL_TAB_COLUMNS` 自省 + 配置文件(非终端输入),仍做纵深防御。 --- ## 可观测性 - **滚动日志**:`logback-spring.xml` 配置滚动文件 + JDBC 噪音降级,日志在 `logs/migration.log`。 - **吞吐指标**:`Metrics` 周期打印累计 `rows / bytes / rows·s⁻¹ / MB·s⁻¹`,任务结束打印 `FINAL` 快照。 - **表级汇总**:每张表跑完日志打印 `OK / FAIL + 标签 + 耗时 + 错误`。 - **Actuator**:`/actuator/health`、`/actuator/metrics`、`/actuator/info`。 --- ## 常见问题排查 | 现象 | 可能原因 / 处理 | | --- | --- | | 启动即报「目标表不存在」 | 目标表没建。本工具不建表,请先在 TiDB 端建好目标表。 | | 启动报「目标表缺少以下源表列」 | 源端 ALTER 加列后目标端没同步。先 `ALTER TABLE` 加列再重跑。 | | 「找不到源表列元数据」 | schema/表名拼写或大小写错、或账号无 `ALL_TAB_COLUMNS` 权限。表名须大写、两端同名。 | | 读取中文 CLOB 报 `ORA-29275` | 非 UTF8 源库(如 ZHS16GBK)字符集问题——已引入 `orai18n` 依赖应对,确认其在 classpath。 | | 写入报 txn 过大 / packet 过大 | 调小 `batch.max-batch-bytes` 或 `batch.rows-per-insert`。 | | 含 LOB 大表 OOM | 调小 `batch.lob-rows-per-fetch`,或加大 JVM 堆。 | | 重试后出现重复行 | 目标表缺主键/唯一键,`INSERT IGNORE` 无法去重。补唯一约束后重来。 | | `POST /migrate` 返回 409 | 已有任务在 RUNNING。等其结束或查 `GET /tasks`。 | | 连接数打满 | 调小并发或调大 `pool-size`;连接 ≈ `concurrent-tables × shards-per-table`。 | --- ## 技术栈与版本约束 | 组件 | 版本 | 说明 | | --- | --- | --- | | Java | **8 (1.8)** | 项目锁定 Java 8。 | | Spring Boot | **2.7.18** | 支持 Java 8 的最后一条主线;切 3.x 需 Java 17+ 且 `javax.*`→`jakarta.*`。 | | MyBatis-Plus | **3.5.7** | 传递 jsqlparser 4.9(兼容 Java 8);**勿升级**到拉入 jsqlparser 5.x 的版本(需 JDK11)。 | | HikariCP | 4.0.3(Boot 管理) | Java 8 兼容的最后版本。 | | Oracle JDBC | ojdbc8 18.3.0.0 | + `orai18n`(非 UTF8 库 CLOB 字符集)。 | | MySQL Connector/J | 8.4.0 | 连 TiDB(MySQL 协议)。 | | Lombok | Boot 管理 | 仅编译期,不打进 jar。 | > ⚠️ **升级红线**:Spring Boot 勿升 3.x、MyBatis-Plus 勿升到 jsqlparser 5.x 线——两者都会破坏 Java 8 兼容。 --- ## 项目结构 ``` src/main/java/com/ora2tidb/ ├── MigrationMain.java # Spring Boot 入口(排除单数据源自动装配) ├── controller/MigrationController.java # REST:/migrate, /tasks/** ├── service/ │ ├── TaskRegistry.java # 内存异步任务注册表(taskId、状态、并发互斥) │ └── MigrationService.java # 核心引擎:表级并发、汇总 ├── core/ │ ├── TableMigrator.java # 单表迁移:列校验→分段→段级并发 │ ├── CursorTableReader.java # 单段 ROWID 游标读写(重试单元) │ └── TablePlan.java # 单表只读执行计划(标识符、批参数、协作者) ├── shard/ │ ├── RowidSegmentSplitter.java # 按 ROWID 采样切分不重叠分段 │ └── Segment.java # 分段边界(开区间起点 / 闭区间终点) ├── io/TidbBatchWriter.java # multi-value INSERT IGNORE,按行数/字节二次切分 ├── mapper/ │ ├── oracle/OracleMigrationMapper # 列元数据、行数、ROWID 采样、游标分页 │ └── tidb/TidbMigrationMapper # 目标列查询、批量 INSERT IGNORE ├── config/ │ ├── MigrationProperties.java # migration.* 配置绑定 + 表清单加载 + fail-fast 校验 │ ├── OracleMyBatisConfig.java # Oracle 数据源 + SqlSessionFactory │ ├── TidbMyBatisConfig.java # TiDB 数据源 + SqlSessionFactory │ └── JacksonConfig.java ├── util/ │ ├── DataSourceFactory.java # HikariCP 数据源构建(驱动级调优) │ ├── Identifiers.java # 标识符白名单校验 + 引用拼接(防注入) │ ├── RetryPolicy.java # 指数退避,仅对瞬时 SQLException 重试 │ └── Metrics.java # 吞吐统计 + 周期日志 └── exception/ # 404 / 409 等 REST 异常处理 src/main/resources/ ├── application.yml # migration.* 全部配置 ├── hisdata/migration-tables.txt # 待迁表清单(一行一张表) ├── mapper/oracle/OracleMigrationMapper.xml ├── mapper/tidb/TidbMigrationMapper.xml └── logback-spring.xml # 滚动日志 + JDBC 噪音降级 ``` --- ## 一句话总结 > 给定一份表清单和两端连接,本工具用 ROWID 游标分页把 Oracle 历史表**并发、幂等、可重跑**地搬进 TiDB—— > 不建表、不改源、只搬数据。