前言

在实际项目中,历史数据迁移常常被低估其工程复杂度。早期实现通常被简化为“写一个临时脚本完成数据搬运”,但当数据规模扩大或业务规则复杂化之后,这类任务往往会逐渐演变为一个需要具备事务控制、幂等设计和失败恢复能力的工程问题。

在真实的生产环境中,数据迁移通常不是一个单一的执行动作,而是一个具备明确约束与治理目标的数据处理过程。一个相对规范的历史数据迁移工程,通常需要同时满足以下四个核心目标:

  1. 数据正确性: 确保源数据到目标数据的映射在语义与结构层面保持一致,避免字段错位、状态误转换或关键数据缺失,尤其在涉及资金、用户状态或核心业务实体时必须严格保证一致性。
  2. 幂等性与可重入能力: 迁移任务在任意阶段中断后,经过修复或重试,可以重新执行而不会产生重复数据或副作用,保证多次执行结果一致。
  3. 可恢复性: 支持断点续跑能力,即在任务失败后能够基于持久化的进度信息,从最近一次成功状态继续执行,而无需全量重跑。
  4. 资源可控性: 迁移过程必须对数据库与应用资源消耗进行约束,避免引发长时间锁等待、大事务堆积、慢查询放大或 JVM 内存压力过高等问题。

一、 数据迁移的本质

从软件工程的视角来看,数据迁移的本质绝不是纯粹的数据复制。如果是复制,我们直接用底层存储的备份恢复(如物理复制、闪回)就可以了。

数据迁移的底层逻辑,是在新系统的业务规则下,重新构建旧数据

一个完整的工业级数据迁移生命周期,必然包含以下四个核心阶段:

         ┌───────────────────────┐
         │ 数据读取 (Extraction)  │  --> 从各种异构源头拉取 Raw Data
         └──────────┬────────────┘
                    ↓
         ┌───────────────────────┐
         │ 数据转换 (Transform)   │  --> 核心业务逻辑清洗与状态重构
         └──────────┬────────────┘
                    ↓
         ┌───────────────────────┐
         │ 数据写入 (Loading)     │  --> 批量、高并发、幂等的安全写入
         └──────────┬────────────┘
                    ↓
         ┌───────────────────────┐
         │ 状态记录 (Checkpoint)  │  --> 每一批次的持久化进度锚点
         └───────────────────────┘


二、 架构决策:工具 vs 代码,如何进行架构取舍?

1. 工具的优势与局限

代表工具: DataX, Kettle, Cloud Migration Tools, Canal

什么时候用工具?

  • 异构源搬运: 比如从 MongoDB 迁到 MySQL,或者从 Oracle 迁到 Postgre。工具自带适配器,省去了写驱动的时间。
  • 简单清洗: 只需要改个字段名、过滤掉某些行。
  • 极致吞吐: 工具通常在底层做了零拷贝或极速缓冲区优化,纯搬运速度远超 Java 代码。
  • 工具的“天花板”:
    一旦涉及“跨表聚合”“上下文关联”,工具就抓瞎了。
  • 案例: 旧系统用户信息在 user_t,等级在 level_t。新系统要求合成一张表,且等级要根据过去一年的订单量重新计算。这种逻辑在 DataX 这种流式同步工具里极难实现,强行写 UDF(自定义函数)的开发成本远高于写 Java 代码。

2. SQL 导数的风险边界

代表选手: INSERT INTO ... SELECT ...

  • 什么时候用 SQL?
  • 同库同构: 仅仅是做个备份表,或者表结构完全没变。
  • 为什么生产环境慎用?
  • 资源掠夺: 大规模 SELECT 会瞬间拉高磁盘 I/O,并产生长时间的间隙锁(Gap Locks),直接导致线上正常的业务写入被堆积、超时甚至挂掉。

3. 自定义 Importer:业务重构的“手术刀”

核心定位:它是带有“业务灵魂”的数据重建程序。

我们之所以在很多项目中选择 Importer,是因为它具备工具无法替代的特征:

  • 复杂映射逻辑:
    旧系统:status = 'A', 'B', 'C'
    新系统:status = 1, 2, 3(且需要根据老系统的 audit_log 历史记录来推断当前的 sub_status)。
    这种逻辑只有在 Importer 中,通过注入 Spring Service 调用现有的业务代码才能实现。
  • 分布式 ID 的重新洗牌:
    历史数据迁移往往伴随着主键策略的变更(比如从自增 ID 变成雪花 ID)。Importer 可以在内存中轻松完成新旧 ID 的映射并保持关联表的一致性。
  • 三方服务联动:
    迁移数据时,你可能需要实时调用一次短信接口、推送一次配置或者在 Redis 里预热缓存。这些“副作用”是 SQL 或搬运工具绝对无法完成的。

4. 总结

可以根据以下维度快速判断不同迁移方案的适用边界:

维度 SQL 直接导入 开源迁移工具 自定义 Importer
适用场景 同构数据直接搬运 标准化数据同步 涉及复杂业务规则的数据迁移
数据转换能力 较弱 中等
跨表/跨库处理能力 有限 有限 灵活
业务规则表达能力 较弱 有限
异常处理与重试 基础依赖数据库事务 支持部分失败处理 可按业务自定义
断点恢复能力 通常需要自行实现 多数工具支持 可完全自定义
资源控制能力 较依赖 SQL 本身质量 支持限流、限速 可按代码精细控制
开发与维护成本 中等 较高
可观测性与扩展性 一般 中等

三、 标准迁移架构模型

为了让系统具备良好的扩展性和职责分离能力,标准的 Importer 架构通常呈现为以下清晰的层级:

         ┌──────────────┐
         │  数据源层     │  --> 无论是 CSV 文件、老数据库还是三方 API
         │ CSV / DB / API│
         └──────┬───────┘
                ↓
     ┌────────────────────┐
     │  Importer 读取层     │  --> 统一抽象的 Extraction 读组件
     └────────┬───────────┘
              ↓
     ┌────────────────────┐
     │ 数据转换 + 清洗层    │  --> 纯内存处理的 Transformation 业务域
     └────────┬───────────┘
              ↓
     ┌────────────────────┐
     │  批量写入层         │  --> 高效的 Loading 写入组件(带事务与幂等)
     └────────┬───────────┘
              ↓
     ┌────────────────────┐
     │  正式业务表         │  --> 最终的目标新数据库
     └────────────────────┘


四、实现结构简析

在真正的工程实践中,历史数据迁移本质上都离不开四个核心步骤:

数据抽取(Extract)
        ↓
数据转换(Transform)
        ↓
数据写入(Load)
        ↓
进度记录(Checkpoint)

无论是:

  • Excel 导入
  • 老系统升级
  • 数据补录
  • 分库分表
  • 上云迁移

底层其实都是这一套流程。

一个典型的 Importer 往往长这样:

@Transactional
public void importAll() {

    // 1. 数据抽取
    List<User> list = queryAll();

    // 2. 数据转换 + 批量写入
    saveBatch(list);
}

其中:

方法 本质职责
queryAll() 从老系统、CSV、API 中读取原始数据
convert() 做状态映射、字段清洗、默认值补齐
saveBatch() 批量写入数据库
saveCheckpoint() 记录迁移进度

需要特别注意的是:

数据迁移真正复杂的地方,通常不是“读取数据”,而是“如何稳定地写入数据”。

尤其在数据量逐渐增大之后:

  • 事务怎么拆?
  • 失败怎么恢复?
  • 如何避免重复导入?
  • 如何控制数据库压力?

这些问题会迅速成为系统核心矛盾。

因此:

不同数据规模下,迁移系统的设计思路会完全不同。


五、小规模数据迁移(一次性脚本模型)

5.1 适用场景

小规模迁移通常出现在:

  • 一次性数据补录
  • 测试环境数据恢复
  • 小型系统初始化
  • Excel/CSV 人工导入
  • 小型业务系统升级

这一阶段的数据量通常在:

数据规模 常见情况
几百 ~ 几千 最常见
1 万左右 部分场景仍可接受
更高 需结合单条数据大小评估

这里需要特别说明:

“小规模”并不是固定数字,而是事务是否仍然可控。

真正影响是否还能采用单事务的因素包括:

  • 单事务执行时间
  • 数据库锁持有时间
  • JVM 内存占用
  • Undo Log 增长速度
  • 单条记录大小

例如:

  • 普通小字段表,1 万条数据可能仍然可以单事务处理;
  • 如果包含 TEXT/BLOB 大字段,几百条数据都可能需要拆批。

因此:

工程上判断“小规模”的核心标准,是事务风险是否仍然可接受。


5.2 小规模迁移的核心特点

这一阶段最大的特点是:

可以接受失败后整体重跑。

整体思路更偏向:

“快速、安全地完成一次性任务”。

即使是小规模迁移,如果存在重复执行风险,仍然建议具备基础幂等能力。

例如:

INSERT IGNORE INTO user ...

或者:

INSERT ... ON DUPLICATE KEY UPDATE

配合唯一索引后,即使脚本重复执行,也不会产生重复数据。


5.3 典型实现

@Transactional
public void importAll() {

    // 一次性读取全部数据
    List<User> list = queryAll();

    // 转换并批量写入
    saveBatch(list);
}

这里的实现逻辑非常简单:

queryAll()

负责一次性读取原始数据,例如:

  • 老系统数据库
  • CSV 文件
  • Excel
  • 第三方接口

这一阶段通常直接将数据加载到内存。


saveBatch(list)

负责:

  • 数据清洗
  • 字段转换
  • 默认值补齐
  • 状态映射
  • Batch Insert 批量写入

这里必须强调:

saveBatch 的底层绝对不能是循环单条 Insert。

错误写法:

for (User user : list) {
    userMapper.insert(user);
}

正确做法应该是 JDBC Batch 或批量 SQL。

例如:

INSERT INTO user(id, name)
VALUES
(1, 'A'),
(2, 'B'),
(3, 'C');

这样可以显著减少:

  • SQL 执行次数
  • 网络 IO 往返
  • 数据库连接消耗

5.4 为什么小规模可以使用单事务?

因为:

  • 数据量较小
  • 回滚成本可接受
  • 长事务风险较低
  • 实现复杂度最低

因此:

@Transactional

在小规模场景下是非常合理的方案。

因为它能够保证:

要么全部成功,要么全部失败。

同时:

  • 排查简单
  • 开发成本低
  • 代码最清晰

5.5 小规模阶段最容易犯的错误

(1)过度设计

很多人一开始就引入:

  • Checkpoint
  • 多线程
  • 分布式调度
  • 分片任务

结果:

数据只有几千条,迁移系统却比业务系统还复杂。

这是典型的过度设计。


(2)循环单条写入

错误示例:

for (User user : list) {
    userMapper.insert(user);
}

这种方式会导致:

  • SQL 次数暴涨
  • 网络 IO 增加
  • 数据库压力明显上升

正确做法应该始终是:

Batch Insert。


5.6 小规模迁移的核心原则

这一阶段最重要的不是复杂架构,而是:

简单、稳定、快速完成。

因为:

能用简单方案解决的问题,不应该提前引入复杂系统。


六、中等规模数据迁移(标准批处理模型)

6.1 适用场景

当数据规模继续增长后:

  • 单事务开始不可控
  • JVM 内存压力明显增加
  • 回滚成本迅速上升
  • 长事务风险越来越高

这一阶段通常出现在:

  • 历史订单迁移
  • 用户数据迁移
  • 多年业务归档
  • 多租户迁移
  • 中型业务系统升级

数据规模通常会进入:

数据规模 常见范围
几万 ~ 几十万 最典型
百万以内 部分场景仍属于中规模

但仍然需要强调:

这不是绝对数字,而是工程经验区间。


6.2 中规模阶段最大的变化

这一阶段最大的变化只有一句话:

“单事务已经不可控。”

继续使用:

queryAll() + saveBatch(allData)

会逐渐出现:

  • Full GC
  • JVM 内存持续增长
  • Undo Log 膨胀
  • Binlog 激增
  • 长事务锁等待
  • 回滚耗时极长

因此:

中规模阶段必须开始拆批。


6.3 中规模迁移的核心设计

这一阶段通常会形成:

分页读取
    ↓
分批事务
    ↓
Checkpoint(可选增强)

这里需要特别说明:

中规模阶段,“分批事务”才是真正的核心。

很多人以为:

“分页读取”就已经解决问题了。

其实不是。

如果整个 while 循环仍然共用一个事务:

@Transactional
public void importAll() {

    while (true) {
        ...
    }
}

本质上仍然是:

一个超级大事务。

因此:

中规模阶段必须引入“每批独立事务”。


6.4 正确实现模型

while (true) {

    List<User> list = queryBatch(lastId);

    if (list.isEmpty()) {
        break;
    }

    processBatch(list);

    lastId = list.get(list.size() - 1).getId();

    saveCheckpoint(taskName, lastId);
}

其中:

queryBatch(lastId)

负责:

  • 基于主键游标分页
  • 避免 OFFSET 深分页性能问题
  • 控制单次内存占用

典型 SQL:

SELECT * 
FROM user
WHERE id > ?
ORDER BY id
LIMIT 1000;

processBatch(list)

负责:

  • 数据转换
  • 批量写入
  • 当前批次事务提交

通常会配置:

@Transactional(
    propagation = Propagation.REQUIRES_NEW
)

确保:

每一批数据独立提交。


6.5 中规模阶段是否必须做 Checkpoint?

不一定。

这里需要明确区分:

能力 解决的问题
幂等 重复执行是否安全
Checkpoint 失败后从哪里继续

因此:

情况一:可以接受全量重跑

如果:

  • 数据量不算特别大
  • 重跑成本仍然能接受
  • 导入时间较短

那么:

可以只做幂等,不做 Checkpoint。

例如:

INSERT IGNORE

或者:

ON DUPLICATE KEY UPDATE

即使任务重新执行,也不会产生重复数据。

这种方案在很多中规模场景下已经足够。


情况二:重跑成本已经不可接受

例如:

  • 已经跑了几个小时
  • 已导入几十万数据
  • 失败后重跑代价很高

那么:

就必须引入 Checkpoint。

例如:

saveCheckpoint(taskName, lastId);

用于记录:

  • 当前处理位置
  • 已完成进度
  • 恢复锚点

6.6 中规模阶段的核心目标

这一阶段本质上是在解决:

“如何避免大事务,并让迁移具备基础恢复能力”。

因此:

  • 分页读取
  • 分批事务
  • 基础幂等

基本已经成为标配。

而 Checkpoint 是否必须,则取决于:

“失败后重跑的成本是否还能接受”。


七、大规模数据迁移(高性能状态化系统)

7.1 适用场景

当迁移数据进一步增长后:

  • 单线程已经无法接受
  • 单机吞吐达到瓶颈
  • 数据库压力急剧增加
  • 迁移可能持续数小时甚至数天

这一阶段通常包括:

  • 百万级订单
  • 千万级日志
  • 海量用户数据
  • 分库分表重构
  • 上云迁移

7.2 大规模阶段最大的变化

到了这一阶段:

迁移系统已经不再是“脚本”。

而是:

一个长期运行的后台任务系统。

系统关注点也从:

如何导数据

逐渐变成:

如何长期稳定地导数据

7.3 大规模迁移的核心能力

这一阶段通常必须同时具备:

能力 作用
分片并发 提升整体吞吐
分批事务 降低事务风险
幂等写入 支持重复执行
Checkpoint 支持断点恢复
JDBC Batch 降低数据库交互
限流降压 保护线上数据库

7.4 为什么大规模阶段幂等是“必须”?

因为:

大规模迁移中,重复执行几乎一定会发生。

例如:

  • 线程失败重试
  • 服务重启
  • 网络闪断
  • 分片重新调度

都会导致:

同一批数据被重复消费。

因此:

INSERT ... ON DUPLICATE KEY UPDATE

会逐渐成为标准配置。


7.5 为什么大规模阶段必须做 Checkpoint?

因为:

已经无法接受失败后全量重跑。

例如:

  • 已迁移 3000 万数据
  • 已运行 10 小时
  • 最后阶段任务失败

如果没有 Checkpoint:

只能重新跑一天。

因此:

  • 当前进度
  • 分片状态
  • 失败批次
  • 已完成游标

都必须持久化。

这也是为什么:

大规模迁移本质上已经演变成“状态化任务系统”。


7.6 为什么大规模一定要限流?

很多迁移失败并不是:

“导不进去”。

而是:

“把线上数据库打挂了”。

因此:

  • 固定线程池
  • Batch Size 控制
  • TPS 限流
  • 分时迁移

都会成为必须能力。

因为迁移系统最终比拼的已经不是:

“谁写代码快”。

而是:

“谁更懂生产环境资源治理”。


八、总结

历史数据迁移并不是简单的“写个脚本导数据”,而是一个完整的数据处理工程。

随着数据规模不断增长,系统设计重点也会持续演进:

数据规模 核心方案 核心关注点
小规模 单事务 + Batch Insert 快速、安全完成任务
中规模 分页 + 分批事务 + 基础幂等 避免大事务、支持恢复
大规模 并发分片 + 幂等 + Checkpoint 长期稳定运行与资源治理

整个演进过程,本质上就是:

一次性脚本
    ↓
标准批处理
    ↓
可恢复迁移系统
    ↓
高性能状态化任务平台

数据量越大,系统关注点就越从“功能实现”转向:

  • 事务控制
  • 幂等设计
  • 断点恢复
  • 并发调度
  • 数据库资源治理

历史数据迁移真正困难的,从来不是“把数据导进去”,而是如何在保证数据正确性的前提下,让迁移任务稳定、可恢复、可持续地运行。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐