1. 项目概述与需求拆解
1.1 任务编号背后的真实场景
dballgts01e19-2 这个编号,你要是第一次看到,八成和我刚拿到需求单时一样懵。拆开来看,其实是 DB-ALL-GTS-01E19-2:DB 代表数据库层任务,ALL 代表全量迁移,GTS 是我们内部“通用数据同步服务”的缩写,01E19 是任务批次,-2 表示这是该批次的第二次执行版本。说白了,这就是一次标准的大体量数据库全量数据同步任务。
这个任务对应的是我们线上订单系统升级时的一次数据搬迁。旧订单库运行了好几年,积累了 1.2 亿条订单主记录,加上关联的明细、支付流水、退款记录,整体数据量接近 800GB。业务方要求把这些历史数据完整迁移到新的业务库中,老系统继续并行运行一段时间,等新系统稳定后再切换下线。
我在这篇文章里要讲的,不是“怎么敲一条 copy 命令”那种入门操作,而是从接到这个任务编号开始,到方案选型、参数调优、执行迁移、数据校验、问题排查的完整过程。如果你正在做数据迁移、数据库运维,或者后端系统里需要处理大数据量同步,这篇内容的实操细节和踩坑记录可以直接拿去用。
1.2 这次迁移要解决的核心问题
全量迁移听起来简单,但真正落地的时候,我给自己列了四个必须满足的硬性指标。
第一,数据不能丢、不能重、不能错。1.2 亿条记录,差一条都算事故。第二,迁移窗口要控制在 6 个小时以内。虽然我们采用了先迁移、后切换的策略,但迁移期间两边系统是并行的,窗口拉得越长,新老数据出现双写冲突的概率就越高。第三,不能影响线上业务。源库是生产主库,白天订单量很大,全量查询如果直接打在主库上,很可能拖垮线上交易。第四,要为后续增量同步留好接口。全量迁移只是第一步,完成之后还要做持续的增量数据同步,所以迁移方案里必须考虑数据位点和时间戳标记,否则后面接增量会非常痛苦。
这四个问题,决定了后面所有的技术选型。只盯着“把数据搬过去”这一个目标做方案,大概率会在执行中翻车。
1.3 为什么这个案例值得复盘
说实话,这种任务在大多数公司里并不罕见,但能把每一步都想清楚、把每个参数都测过的团队并不多。我见过太多同类项目,最后都是靠开发半夜手动补数据硬扛过去的。
这次任务之所以值得写下来,是因为我们走了完整的流程:先做容量和带宽评估,再对比迁移路线,然后设计分层校验方案,最后在限定的窗口内完成了 800GB 数据的整体搬迁,且校验差异最终归零。中间还踩了一个很有代表性的坑——目标端触发器在不知不觉中改写了数据,直接导致校验不一致。这类问题在常规文档里几乎不会提到,但实际生产环境里发生的概率一点都不低。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 全量同步方案选型与技术思路
2.1 三条主流路线,我为什么没选前两条
接到任务后,我首先把市面上常见的全量迁移方案过了一遍,梳理成三个方向:逻辑导出导入、物理文件拷贝、同步组件实时搬运。
逻辑导出导入是最传统的方式,比如 MySQL 的 mysqldump、PostgreSQL 的 pg_dump。优点是好上手,一条命令就能跑。缺点也很明显,单线程导出在大数据量下实在太慢,800GB 数据用 mysqldump 导,运气好也要两三个小时起步,到目标端再导入一遍又是几个小时。而且逻辑导入会触发大量的行级 insert 操作,索引重建、约束检查都会拖慢速度,整体窗口很难压进 6 小时。如果只是几十 GB 的数据,我可能会选这条路,但 800GB 的场景下直接排除。
物理文件拷贝,比如直接拷贝 MySQL 的数据文件目录,速度确实快,但要保证源库和目标库的版本、存储引擎、甚至操作系统层面的兼容性。我们这次目标库和源库版本跨度比较大,还涉及表结构字段调整,物理拷贝根本没法在拷贝过程中做字段映射。这个方案也放弃了。
最后选的是基于同步组件的数据搬运方案。我们用内部基于 DataX 封装的 GTS 服务,它支持多通道并发读取、批量写入、字段映射和自定义分片逻辑。本质上,它把一个大任务拆成多个可并行的小任务,能充分利用源库和目标库的 IO 能力。更重要的是,它对业务侵入极小,读端可以指向从库,写端可以灵活适配目标表结构,正好满足我们不停服、不影响主库的要求。
2.2 同步链路的设计思路
确定组件方案后,我画了一条非常明确的数据链路:源库主库 → 源库只读从库 → GTS 同步任务 → 目标业务库 → 校验任务。
这里最关键的设计决策是“读从库”。为什么不直接读主库?因为全量迁移要扫描整张表,这个读压力在普通机械盘或者 SSD 上都会产生明显的 IO 开销,而主库同时在承接线上订单写入。我曾经见过一个项目,就是因为全量迁移直接扫主库,导致业务 SQL 响应时间从 20ms 直接飙到 800ms,差点引发线上事故。
从库只读节点就没有这个问题,它专门为分析型查询和备份服务,全量查询只会让它自身负载升高,不影响主库写入链路。当然,前提是主从复制延迟要控制在可接受范围内,我们在迁移前专门盯了半小时的 Seconds_Behind_Master,确认延迟稳定在 1 秒以内才动的手。
链路确定之后,数据搬移顺序也有讲究。不能上来就搬大表,否则一旦配置有问题,大表跑到一半报错,排查起来非常麻烦。我们的顺序是:先同步表结构,再搬小表,然后搬中等量级的表,最后处理几张大表。小表通常几分钟就能跑完,跑完顺带验证目标端的连接、写入、字符集配置是否正确。大表放在最后,是因为我们预留了足够的时间片,并且可以在小表跑通后复用已经验证过的配置。
2.3 数据校验方案不能只 count
很多团队做迁移,校验阶段就一条 SQL:select count(*) from old_table 和 select count(*) from new_table,行数一致就认为迁移成功。这个做法我强烈建议不要用。
行数一致只能说明“进出的行数一样”,完全不能证明数据内容一致。比如源表某一行被同步组件写了两遍,同时另一行因为主键冲突丢失,目标表行数依然可能对得上,但数据实际已经错了。更隐蔽的情况是字段值被截断、时区偏移、精度丢失,这些通过 count 完全察觉不到。
我设计的校验方案分三层:
第一层是总量级校验。对每张表分别做 count(*),这个只能作为最基础的快速筛选,用来发现明显的丢表、重复表问题。
第二层是分组聚合校验。按一个稳定的维度分组,比如按订单日期的月份分组,然后分别对比 count(*)、sum(amount)、max(id)、min(id)。这层能把差异定位到具体的时间范围,比单纯总数要精细得多,而且查询成本可控,全表扫完也就几分钟。
第三层是明细抽样校验。对第二层中发现差异的分组,把该分组的全量主键集合导出,用集合差运算找出完全不一致的 key,再对 key 定位到的具体记录做逐字段比对。这套组合下来,既能保证校验效率,又能把问题精确到行级。
3. 实操过程:从准备到落地
3.1 迁移前的清单与容量评估
实际操作前,我先花了大半天做准备工作。这里重点说几个容易被忽略的评估环节。
首先是容量评估。源库 800GB,目标库不能只准备 800GB,因为数据导入后还要创建索引,索引通常会占用数据量的 20%-40%,加上临时文件、日志空间,至少要预留 1.5 倍容量。我们给目标库申请了 2TB 的空间,实际用下来大概 1.3TB,这个余量是必要的。
然后是网络带宽评估。源库和目标库之间是 1Gbps 内网链路,理论上每秒能传 128MB,但实际 TCP 传输和数据库协议开销会打折扣,实测单流传输稳定在 70-80MB/s。按 80MB/s 算,800GB 裸数据传输需要 819200MB / 80MB/s,约 10240 秒,也就是 2.85 小时。但要注意,这只是纯数据写入目标库的时间,还没有算索引构建、校验比对和容错重试。所以我们把整体窗口定为 6 小时,预留了接近一倍的安全余量。
数据库参数也做了预先调整。目标库的 max_allowed_packet 调到了 128MB,避免大批量写入时报 packet 超限;innodb_buffer_pool_size 调到了物理内存的 70%;临时关闭了目标端的非必要外键约束,等数据迁移完成后再统一开启和校验。源库侧不改参数,只在从库上把 max_execution_time 设成 0,防止慢查询被数据库 kill 掉。
注意,这里有一个经验:尽量不要在迁移过程中保留目标端的外键约束。外键会让每一行插入都触发关联表检查,在批量写入场景下性能损耗非常明显。我们是在结构同步阶段先建表,不建外键,数据校验通过后再补外键,并额外跑一次外键完整性检查。
3.2 DataX 任务配置里的关键参数
准备工作完成后,下一步就是编写同步任务配置。我用一个简化后的例子来说明核心参数的含义,实际生产配置比这个复杂,但关键参数是通用的:
json复制{
"job": {
"content": [
{
"reader": {
"name": "mysqlreader",
"parameter": {
"username": "sync_user",
"password": "******",
"connection": [
{
"jdbcUrl": [
"jdbc:mysql://read-only-slave:3306/order_db?useSSL=false&characterEncoding=utf8&connectTimeout=5000"
],
"table": ["t_order"]
}
],
"splitPk": "id",
"where": "id >= 0 AND id < 5000000",
"column": ["id", "order_no", "user_id", "amount", "status", "create_time", "update_time"]
}
},
"writer": {
"name": "mysqlwriter",
"parameter": {
"username": "sync_user",
"password": "******",
"writeMode": "insert",
"batchSize": 4096,
"connection": [
{
"jdbcUrl": "jdbc:mysql://target-db:3306/order_db_new?useSSL=false&characterEncoding=utf8&rewriteBatchedStatements=true",
"table": ["t_order"]
}
]
}
}
}
],
"setting": {
"speed": {
"channel": 8
}
}
}
}
这里面的 splitPk 是分片字段,DataX 会按这个字段把读取范围切分成多段,交给不同 channel 并发执行。我建议选择分布均匀、单调递增的主键字段,比如自增 id。如果用 create_time 这种字段做分片,很容易因为时间分布不均导致某些 channel 跑到一半,另一些已经空闲。
batchSize 是单次批量写入的行数,设成 4096 是经过实测的。太小会频繁提交事务,网络往返开销大;太大又容易造成目标端内存压力,反而触发内存溢出。rewriteBatchedStatements=true 这个参数非常关键,它能让 JDBC 驱动把多条 insert 语句重写成一条多值 insert,写入性能提升可能是好几倍。我第一次跑的时候漏了这个参数,写入速度只有 30MB/s,加上之后直接翻倍。
channel 是并发通道数。数据量小的时候,channel 越多越快;数据量大的时候,channel 设置过大会让源库 IO 和 CPU 迅速打满,反而拖垮整个任务。我们压测了 4、8、12、16 四个档位,8 是当时环境下的最优解。
3.3 执行过程与调优记录
任务真正跑起来后,我记录了完整的执行数据。
第一轮是小表测试,选了 5 张数据量在百万级以内的表,每张耗时 2-5 分钟,全部通过。这一步验证了目标库连接、字符集、批量写入都没问题。
第二轮开始跑大表。第一张是 t_order_detail,总行数 4800 万。我按主键 id 分成了 10 个分片,每个分片 500 万行,顺序提交到 GTS 服务。第一次执行配置是 channel=4、batchSize=1024,实测吞吐只有 45MB/s,跑了大概两个小时才完成一张表。这个速度对整体窗口来说太危险了。
我停下来调优。把 channel 调整为 8,batchSize 调整为 4096,同时确保目标端 JDBC 连接带上了 rewriteBatchedStatements=true。通过率和延迟的变化非常明显,吞吐直接拉到 75MB/s,单表耗时从 2 小时缩短到 1 小时 15 分钟左右。
这里还想强调分片的容错设计。DataX 本身没有自动断点续传,如果任务跑到一半失败,整个 channel 的分片都要重跑。我们为每一片建立了任务状态表,记录 task_id、range_start、range_end、status、elapsed_ms。某一片失败后,只需要找出 status 为 failed 的分片,单独重跑那一片就行,不用全表再扫一遍。这个表结构很简单,但在几千万行的大表场景里,能省的时间是以小时计的。
全部表迁移完成后,我又对每个分片做了一次“重复写入检查”,确认目标表里没有重复主键记录。这种细节很多人容易忽略,但恰恰是保证数据质量的关键一环。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
整个迁移过程中,我们踩了不少坑。我整理了一张速查表,把最常见的问题、可能原因和排查思路都列出来,方便你直接对照。
| 现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 迁移后行数不一致 | 源库在迁移过程中有并发写入,或分片边界重复处理 | 先跑三层校验,确认差异所在分区 | 对差异分区单独重跑,必要时锁定源表为只读 |
| 中文乱码 | 客户端连接字符集与目标表字符集不一致 | 检查 jdbcUrl 的 characterEncoding,查目标表 COLLATE | 统一使用 utf8mb4,连接串显式声明字符集 |
| 写入速度上不去 | 缺少 rewriteBatchedStatements,或 channel 过低 | 监控吞吐和源端 IO 使用率 | 开启批量重写,压测后调整 channel |
| 目标库出现重复主键 | 分片范围重叠或任务重复执行 | 查询目标表重复键,对照分片状态表 | 清理重复数据,修正分片范围边界 |
| 迁移任务中途失败 | 网络抖动或源端慢查询被 kill | 查任务日志,看报错堆栈,查源库慢查询日志 | 断点续跑失败分片,避免全量重跑 |
| 校验时发现字段值被截断 | 目标表字段长度小于源表 | 对比两张表的 DDL,找出长度不一致字段 | 调整目标表字段长度,重刷该分区数据 |
4.2 一次“校验不一致”的完整排查过程
整个任务中印象最深的,不是迁移速度,而是校验阶段出现的 413 条差异。
当时三层校验已经跑到最后一层,明细抽样发现 update_time、status、raw_data 三个字段有 413 条记录和目标端不一致。看到这个数字的时候,我的第一反应是同步任务某个分片写漏了,或者分片边界重叠导致数据被覆盖。我立刻调出分片状态表,把 413 条记录对应的主键范围拉出来,发现它们分散在 6 个不同的分片里,并没有集中在某一两个分片,这基本排除了“某一整片写错”的可能。
接着我做了两件事:第一步,用这些主键去查源库当前数据,发现源库现在这些记录的值也是新值;第二步,去查目标库的 binlog,看这 413 条记录在写入目标库之后,是否还有后续的 update 操作。
查出来的结果让我很意外。目标库的 binlog 显示,这些记录在同步任务写入之后,又被一条业务 update 语句更新过。也就是说,数据在同步完成后被“二次修改”了。
顺着 binlog 的 thread_id 往上追,发现是一个目标端触发器导致的。当初业务方为了兼容老系统接口,在 t_order 表上建了一个触发器:insert 之后自动把 status 改成某个系统默认值。这个触发器在业务代码改造后早就没人记得了,但它是存在的。同步任务每写入一条新记录,触发器就静默修改了几条字段,我们看到的 update_time 变化就是这么来的。
处理方式不复杂:先确认业务方不再需要这个触发器,然后删除它,再把 413 条记录按源库当前值重刷一遍。之后连续三小时重新跑校验,差异归零。
这件事给了我一个很深的教训:全量迁移前,只检查源库和目标库的表结构是不够的,还必须盘点目标端的触发器、事件、外键和定时任务。你以为“只是写数据”,但在数据库层面,写数据这个动作可能触发一连串你不知道的副作用。
4.3 迁移后的稳定性检查与收尾细节
校验全部通过,不代表任务结束了。我把迁移后的稳定性检查分成三个阶段。
第一阶段是迁移当天。校验通过后,先别急着切流,我习惯让两个库并行跑至少 2-3 天,每天跑一次增量对比。这个阶段最容易发现的问题,是源库还有未暴露的写入任务在持续产生数据,而这些数据没有进入增量同步通道。
第二阶段是切流前。把目标库的外键约束、唯一索引全部补齐,并重新做一次完整校验。注意,补齐约束后可能会导致部分历史数据违反约束,比如源库本身就存在重复业务键。这种脏数据要在切流前处理掉,否则切流当天业务会报错。
第三阶段是切流后一周。这个阶段我主要盯三件事:目标库主从延迟是否正常、慢查询有没有明显增多、源库是否可以安全下线。特别提醒一句,源库数据不要切完流立刻删,至少要保留一个月。我遇到过不止一次,切流两周后业务方说“某张报表还要取旧库的数据”,如果当时手快把库删了,就真的欲哭无泪了。
最后再分享一个小习惯,是我做了这么多次迁移之后养成的:所有校验脚本、分片状态表、执行日志,我都统一归档到当天的任务目录里,命名格式就是任务编号加日期。这次项目能写出一份完整的复盘,就是因为每一步都有记录可查。数据迁移这个活儿,不怕慢,不怕数据量大,最怕的是出了问题之后不知道当时每个环节实际发生了什么。有记录,你才有底气做任何判断。
