先说一个我见过太多次的翻车形态。迁移项目的看板上,同步延迟一直显示 0 秒,源库和目标库的行数比对也是一致的,团队按计划把读写全部切到新库,老库保留只读一周之后回收。切流后第三天,客服开始接到投诉:有客户在 App 里看到的订单状态是"已发货",但物流系统里根本没有这笔单的运单记录。
翻回去查,这批订单的 status 字段在老库是 3、在新库是 2,差异记录两千多条。那时候老库已经回收,只剩切换当天的一份逻辑备份,而这批订单在备份之后又发生过状态变更,等于原始值永远拿不回来了。最后只能靠业务侧的人工流水一条条补,前后折腾了将近两周,还赔进去一批客户的信任。
这里最要命的一点是:监控当时是全绿的。延迟 0 说明同步链路通畅,行数对得上说明没有整行丢失——但这两件事都证明不了"内容一致"。同步工具报的成功,只代表它把收到的事件重放了一遍;它没收到、或者收到了但被下游逻辑改写的那部分,它一概不知。行数比对也是同理,一条记录被 UPDATE 了三次和一次,行数完全一样。
所以这篇要立的判断是这个:迁移的成败不取决于"搬得完",而取决于"敢不敢切、能不能回滚"。数据拷贝只是第一步,真正的技术含量在双写期——两边同时写入、流量逐步切换的那段时间。双写的核心不是"写两次",而是建立一套可量化的校验机制:先做全量基线比对,再做增量持续比对,把不一致率做成一条曲线,只有这条曲线持续收敛到可接受范围并且稳定一段时间,才具备切流和停写老库的条件。没有校验的双写,只是把一份不确定性变成两份。
下面这些结论可以先记住:
很多人把迁移理解成一次性的搬运,其实它更像一段有明确关卡的流程。我习惯把它拆成六段,每段都必须有一个能写进验收单的出口条件——那种"观察一段时间再说"的表述不算条件,因为没人知道观察多久算够,一旦出了问题也追不责。
| 阶段 | 这一阶段在做什么 | 出口条件(可判定) | 最容易出的问题 | 需要提前准备的回滚动作 |
|---|---|---|---|---|
| 全量拷贝期 | 存量数据一次性搬运,记录一个一致的快照点(binlog 位点 / WAL LSN / 一致性快照 ID) | 快照点已固化并落盘记录;全量行数、主键最大最小值、分片 checksum 三项均一致 | 边拷贝边写入导致快照漂移;大表拷贝把线上 IO 打满 | 保留源端快照文件与目标端导入清单,导入失败可整表重来 |
| 增量追平期 | 用 CDC 订阅快照点之后的变更,把目标库追到与源库同步 | 同步延迟 P99 连续 30 分钟低于 1 秒,积压事件数持续为 0,且追平后全量抽检一致 | 大事务导致延迟反复抖动;DDL 变更让订阅中断 | 订阅位点可重置,目标库可清库重放;保留重放脚本与位点记录 |
| 双写期 | 应用同时写两边,读写仍全部走老库,新库只做影子写入与校验 | 双写失败率低于 0.01%;增量比对日不一致条数收敛且连续 7 天低于阈值 | 一边成功一边失败的部分成功;异步重试带来的乱序覆盖 | 双写可一键降级为单写老库;失败队列可重放、可丢弃 |
| 读流量灰度 | 按用户 ID 或分流比例把读请求逐步切到新库,比对两边返回差异 | 灰度到 50% 后影子读差异率低于 0.001%,且无 P0/P1 级业务告警持续 48 小时 | 同一用户请求被分到不同库造成体验跳变;慢查询在新库放大 | 分流开关可按比例秒级回退到 0,路由配置版本化管理 |
| 写流量切换 | 停止双写,单写新库;老库降级为备份或只读,反向同步链路启用 | 切换前新老库延迟低于 1 秒并冻结写入 30 秒;切换后订单创建成功率与 TPS 回到基线 ±5% 内 | 切换瞬间仍有在途请求写老库;反向链路没开导致无法回退 | 反向同步(新→老)已跑通并完成演练;回滚判定人与触发阈值已书面确认 |
| 观察与下线 | 新库独立承载全量读写,老库只读保留,到期后回收 | 连续 14 天核心业务指标无异常,账务对账全部平账,且至少完成一次完整对账周期(含月结) | 留得太久成本翻倍;收得太早遇到月末对账才发现历史差异 | 老库保留期限内可随时重新切回;下线前必须导出一份可恢复的冷备 |
这张表里最容易被跳过的是第三列。实际项目里,绝大部分团队能做到第一列和第二列,也就是"知道要做什么"和"真的在做",但到出口条件这一步就变成拍脑袋了。我见过有人把"双写跑一周"当成出口条件,结果那一周里不一致条数一直在涨,只是没人去看——双写跑了七天,只是让不一致积累得更久。
全量拷贝阶段真正的技术活不是"拷",是"记"。你必须在开始拷贝的那一刻,同时记录一个可以精确对齐的位点:MySQL 是 binlog 的 file + position(或 GTID)、PostgreSQL 是 WAL 的 LSN、MongoDB 是 oplog 的时间戳。没有这个位点,后面的增量追平就没有起点,只能从头重来。
拷贝过程本身要限速。一张两亿行的订单表,如果不限速直接全表扫描导出,很容易把线上主库的 IO 和缓冲池打满,表现为业务侧突然变慢,但监控上数据库 CPU 并不高——这种"查不出原因的慢"最折磨人。常见做法是按主键分片导出,每片之间加 sleep,或者在从库上做导出,把压力挪走。
增量追平的出口条件要写得能测:同步延迟的 P99 连续 30 分钟低于 1 秒,积压事件数持续为 0。注意是 P99 不是平均值,平均值会把偶尔几次几十秒的抖动抹平,而那种抖动往往对应一个大事务或者一次 DDL,恰恰是最容易出问题的时候。
还有个细节:追平之后要再跑一次全量抽检。因为"延迟为 0"只说明当前没有积压,不说明历史事件都正确重放了。如果订阅过程中有事件被跳过、或者下游触发器改写过写入值,延迟照样是 0。
双写不是只有一种做法,选在哪一层实现,决定了后面校验的难度和出问题时的影响面。三种位置各有各的账要算。
在业务代码里写两遍,是最直观的做法。好处是语义最清楚——你知道每个写入点的业务含义,可以针对不同表做差异化处理,比如订单主表双写、日志表不双写。坏处同样明显:侵入大,写入路径多的时候容易漏。一个成熟的订单系统,写订单的路径可能有下单接口、支付回调、退款流程、后台运营改单、定时任务补偿五个以上,漏掉任何一个,那部分数据就永远只在一边。
应用层双写最容易踩的两个坑,值得单独说。
第一个是部分成功。老库写成功、新库写失败(或者反过来),这时候返回什么?返回成功,那这条记录就永久不一致;返回失败,用户会看到下单失败,但老库其实已经落库了,再点一次就是重复订单。正确做法是:主库(通常是老库)写成功即算业务成功,新库写入失败要落到补偿队列里异步重试,同时把双写失败率作为一级监控指标。如果新库失败率超过某个阈值,直接降级为单写,别硬撑。
第二个是异步重试带来的乱序。这个坑更隐蔽。假设同一条订单先发生 UPDATE status=2,再发生 UPDATE status=3,两次都写失败进了重试队列。重试的时候如果并发跑,完全可能 3 先到、2 后到,最终新库停在 status=2。解决办法有两个方向:一是给同一主键的变更事件做串行化(按主键哈希到一个队列,保证顺序);二是用版本号或时间戳做乐观覆盖,新库只接受版本号更大的更新。我更推荐前者,因为后者的前提是两边时钟可比,而时钟这件事本身就不可靠。
通过订阅 binlog/WAL 做数据层同步,应用层完全不用改,这是它最大的吸引力。代价是:它天然存在延迟,且它对业务语义一无所知。同步工具看到的是"某行某字段从 A 变成 B",它不知道这是正常改单还是程序 bug,也不会因为这笔订单金额异常就停下来报警。
有个常被忽略的点是 DDL。很多 CDC 工具对 DDL 的支持是残缺的,源库加了一列,订阅链路可能直接断掉,也可能默默忽略。所以迁移期间原则上要冻结 DDL,实在要改,必须走"先改目标库、再改源库、再重启订阅"的流程,并且改完立刻跑一次结构比对。
在数据库代理层做双写(或者基于 ShardingSphere 这类的中间件能力),对应用透明的同时又能拿到一定的语义信息。代价是代理层成了新的单点,它自身的可用性、连接池容量、超时策略都要单独设计和压测。而且代理层双写同样逃不掉部分成功的问题——代理也得决定一边失败时怎么办。
选哪个?我的看法是这样:如果写入路径收敛、团队对代码掌控力强,应用层双写最可控,因为你能保证"该写的都写了";如果系统老了、写入点散落在各种脚本里,数据层同步更现实,但校验必须做足,因为你无法保证完整性,只能靠比对发现问题。别指望某一种方案能规避校验,三种方案都只是减少了某一类问题,没有一种能替你确认数据是对的。
这是很多团队跳过、但后面一定会付出代价的一步。开始比对之前,必须先写清楚三件事:要做到强一致还是最终一致;允许的最大延迟窗口是多少;窗口内和窗口外的差异分别怎么处理。
强一致的意思是任何时刻读两边结果都一样。在跨库场景下这基本做不到,除非引入分布式事务,代价是写入延迟和可用性都大幅下降,对订单这种高写入量场景不现实。
最终一致的意思是允许短暂不一致,但保证在没有新写入的情况下,经过一个有限时间 T 之后两边收敛。这个 T 就是允许延迟窗口。
关键在于:窗口内的差异不是错误,是预期状态。老库刚写入一条订单、新库还没同步过来,此时比对必然不一致,这是正常的。真正要报警的是"超出窗口仍不一致"——比如 T 定为 5 秒,一条记录在 60 秒后还是不一致,那就说明有事件丢了、被改写了、或者根本没被双写。
所以校验系统必须区分两类差异:待确认差异(pending diff)——在窗口内,只是还没追上,放进队列等待复验;确认差异(confirmed diff)——超出窗口 T 之后仍然不一致,进入修复流程。混在一起统计的话,你会得到一条永远降不下来的曲线,然后慢慢就没人看这个指标了,这比不做校验更糟。
T 定多大?取决于业务。订单创建后一般会立刻查一次,T 定 5 到 10 秒比较合适;如果业务上有"下单后 30 秒才轮询"的设计,T 可以放宽到 60 秒。但注意,T 越大,你发现问题的速度越慢,出问题时的影响面也越大。T 不是越大越安全,而是越大越迟钝。
校验不是跑一个脚本就完事,四种手段覆盖的是不同层面的问题,成本也差很多。我的建议是四种都上,但按阶段有侧重:全量比对在切流前做基线,增量比对贯穿全程,影子读在读灰度期开,业务汇总是最后的兜底。
全量比对的思路是按主键把表切成片,每片算一个 checksum(可以是行数 + 关键字段的聚合哈希,也可以是整片所有行的逐字段哈希),两边对同一片分别计算再比对。分片是为了并行和限速,也是为了定位——发现第 137 片不一致,你就知道去查主键范围落在哪一段。
具体执行上有几个要注意的:比对一定要在从库或者只读副本上跑,别打主库;并发度按从库负载动态调,白天低、夜里高;比对要能断点续比,跑了六个小时被中断就得从头来的脚本,在大表上根本跑不完。还有一点,全量比对期间数据还在变,所以比对结果必然有噪声——解决办法是同一片比对两次不一致时,取该片的精确行级比对做二次确认,而不是直接报差异。
大表怎么权衡?两亿行的表做逐行全字段比对,成本非常高。实用的做法是抽样 + 全量结合:先做一遍分片级的 checksum 全量比对(便宜,能定位到片),对不一致的片再下钻到行级(贵,但范围已经很小)。再加上一层随机抽样,比如每天随机抽 10 万条主键做逐字段比对,用来发现"checksum 恰好碰撞"这种极低概率但确实存在的情况。
增量比对是双写期的主力手段。做法是同时订阅老库和新库的变更流,把变更事件按主键归拢到比对队列,对同一主键比对变更后的字段值差异。因为只比变更过的行,量比全量小几个数量级,可以做到准实时。
这里有个实现细节容易被低估:两边变更流的时钟基准不同,事件到达顺序也不同。所以比对不能简单地"事件来了就比",而是要按主键维护一个待比对集合,每条主键在最后一次变更之后的 T 秒(就是前面定义的延迟窗口)没有被再次变更时,才拿出来做比对。这个"静默期"机制是过滤掉窗口内正常差异的关键。
影子读的做法是:真实的读请求在新老库各执行一次,老库的结果返回给用户,新库的结果只用来比对,记录差异但不返回。它的价值在于能发现前面两种手段都发现不了的问题——比如索引缺失导致排序结果不同、字符集导致中文排序不同、时区导致日期聚合不同。这些问题在字段级比对上是完全一致的,只有在"执行同一条 SQL 看结果"的时候才会暴露。
代价也很实在:新库的读流量翻倍,而且是一次真实查询,会吃掉连接池和 IO。所以影子读必须支持按百分比采样(比如只比对 1% 的请求),并且要有独立的超时和熔断——新库查询超过 50 毫秒就直接放弃这次比对,绝不能让它拖慢真实请求。还有一点,影子读对写敏感的比对结果要做去重,同一个用户在几秒内多次查询同一条订单,差异会被重复计数,按主键 + 时间窗去重再统计才有意义。
最后一类校验比的是业务汇总,不是字段。每天固定时间跑一次:当日订单总数、订单金额合计、各状态订单数分布、退款金额合计、按小时的新增订单曲线。这些数字两边各自算一遍,对不上就说明有问题。
为什么要单独做这一层?因为字段一致但语义不一致的情况太常见了。金额字段两边存的都是 199.00,但一边是 Decimal(10,2)、一边是 float,聚合十万条之后就会出现几毛钱的差异;订单数都是 10000,但一边按 create_time 的 UTC 时区统计、一边按东八区统计,跨日的那部分就分到了不同的日期桶里。字段级比对对这些一律报"一致",只有汇总口径能抓出来。
这一节单独列出来,是因为这些坑的复现成本极高——它们不会让同步报错,不会让比对报警,只会在切流之后的某个业务场景里突然冒出来。
这些东西有没有办法提前发现?有,而且成本很低:在切流前跑一次结构比对(表结构、索引、字符集、排序规则、触发器、存储过程全覆盖),再跑一次参数比对(sql_mode、time_zone、字符集相关的服务端变量)。这两步加起来的工作量不到一天,能挡掉上面八条里的六条。
发现差异只是开始,修才是真正考验设计的地方。我见过不少项目,比对系统做得很漂亮,但修复环节是一团乱麻,最后变成人工导出 Excel 一条条改。
第一件事是定基准:以哪边为准。在切流完成之前,一律以老库为准。理由很直接——老库是业务真实写入的主库,用户在老库上看到的状态才是业务上成立的状态,新库只是副本。这个原则要写进文档,并且不能因为"新库看起来更合理"就动摇。切流完成之后,基准才反转成新库,此时反向同步链路承担的是"让老库跟上新库"的职责。基准只能有一个,两边都不信的时候,宁可暂停切流,也不要靠猜。
第二件事是分清楚自动修复和人工确认的边界。可自动修复的是那些"原因明确、修复方式唯一、影响可逆"的差异,比如某条记录因为双写失败导致新库缺行,直接按老库整行回补。必须人工确认的是涉及金额、状态机、库存扣减这类业务语义的差异——字段值不同可能是老库错了,也可能是新库被某个新逻辑改写成了更正确的值,机器判断不了。我一般把自动修复限制在结构型差异(缺行、多行的增删),字段值差异一律走人工工单。
第三件事是修复动作必须幂等。这条是硬要求。修复脚本被重复执行、被中断后重跑、和网络超时后重试,都不应该产生副作用。实现方式上,用"按主键 upsert 成基准值"而不是"执行一条 UPDATE 语句",前者重复跑多少次结果都一样,后者如果带了相对修改(比如 amount = amount + 10)就会累积。
第四件事是修完必须复验。修复完成之后,要把这条主键重新放回比对队列,走一遍完整的比对流程确认已经一致,而不是修完就标记完成。复验失败要能重新进入修复流程,并且记录重试次数——同一条记录反复修不好,往往说明根因不在数据层,而在某个还在运行的写入逻辑上,这时候要停下来查根因,不要继续修。
回滚这件事,绝大多数团队的准备程度都远低于他们自己以为的水平。常见的心态是"先切过去,真出问题再说",但真出问题的时候,你已经没有老库的最新数据了。
核心动作只有一个:在切流之前,把新库到老库的反向同步链路建好并跑通。正向链路是老库到新库,切流之后老库不再接收写入,如果反向链路没建,从切流那一刻起老库的数据就冻结了。这时候一旦要回滚,你会发现老库缺了切流之后的所有增量,回过去就是数据回退,业务上完全不可接受。
反向链路建好还不够,要实际演练过。演练的内容包括:把反向同步打开、观察延迟、用一批测试数据确认老库能正确追上新库的变更、然后按回滚流程走一遍(把写流量切回老库、读流量切回老库、关掉双向同步中的一条避免循环)。双向同步同时开是会形成环路的,一条记录的更新会来回传递,必须在设计时就确定好防环机制(标记来源、按方向过滤)。
回滚的触发条件也要提前写清楚,并且指定判定人。触发条件应该是可观测的量,比如"订单创建失败率连续 5 分钟超过 1%"、"核心接口 P99 延迟连续 10 分钟超过基线 3 倍"、"出现金额类不一致且确认为新库侧问题"。判定人要明确到岗位(通常是当班的技术负责人 + 业务负责人),决策链路越短越好。迁移窗口期最怕的不是出问题,是出问题之后没人敢拍板。
还有一个容易被忽略的点:回滚的代价随时间递增。切流后 10 分钟回滚,代价很小;三天后回滚,你面对的是三天的增量数据和一大堆已完成的业务动作(已经发货的订单、已经核销的优惠券)。所以回滚窗口要设一个硬上限,比如切流后 24 小时内是"可回滚期",超过之后进入"只修不退",全体按后者做准备。
最后说说指标。切换决策不该由某个人说"我觉得差不多了",而应该由几条曲线共同支撑。至少要监控这六个量:
什么数值算可以切?下面这组是示例判据,具体阈值要按业务量级和容忍度调整,不要直接照抄:
最后一条最常被跳过,但我认为它最重要。很多差异只在特定条件下出现:月末批量结算会触发大量 UPDATE、大促会带来平时十倍的写入量、某个定时任务只在每周一凌晨跑。如果你的双写期只有五个工作日,那这些场景一个都没覆盖到。宁可多跑两周,把该遇到的场景都遇到一遍,也别赌它不会发生。
下面给一份具体的排期示例,针对的是一个日订单量百万级、主库 800GB 左右的订单库迁移。明确说明:以下为典型部署思路,并非特指某一真实客户,时间长度要按实际数据量和业务窗口调整。
目标端的服务器环境这块,我的经验是提前准备好并且留出余量。新库的规格不能照着老库的现状配,因为双写期新库要承受写入 + 影子读 + 全量比对三重压力,实际负载会高于稳态。一个常见的做法是目标端先按稳态的 1.5 倍配置,切流稳定后再评估是否降配。像这类需要独占性能、对 IO 抖动敏感的数据库场景,一万网络的裸金属比较对口,这家深耕 IDC 19 年(成立于 2007 年),标准档 E5-2620 32G/1T 是 ¥999 元/月起,高规格的 E5-2698v4×2 32G/1T 是 ¥3999 元/月起,起价通常对应最低配置与特定付款方式,实际以官网实时价和签约报价为准;如果只是搭校验服务、比对任务调度这类无状态组件,用一万云 ¥25 元/月起那档弹性云就够,成本能压得很低。目标端环境提前一周交付并做完压测,是排在时间表最前面的一项。
六阶段的示例排期与出口条件:
这份表里最该盯的是第 3–4 周占了整整两周。很多项目把双写期压缩到三五天,理由是"多跑一天多一天成本"。但双写期压缩的代价是不一致还没收敛就切了流,而切流之后的修复成本,是双写期的十倍以上。这两周是整个迁移里性价比最高的投入。
没有固定天数,判断标准是不一致率曲线的稳定性,不是日历。我的经验下限是两周,而且要覆盖至少一个完整业务周期(含周末和一次月末或大促)。如果两周之后不一致率还在波动,说明根因没找到,继续延长双写期比强行切流划算得多。压缩双写期省下的那几天机器钱,跟切流后修数据的代价完全不在一个量级。
以主库(切流前是老库)的结果为准返回。主库成功就返回成功,新库失败进补偿队列异步重试,同时计入双写失败率。反过来,如果主库失败就正常返回失败,用户会重试,不存在一致性问题。这里的原则是:不能让新库的可用性拖垮主业务的可用性。还要设一个阈值,新库失败率超过 1% 就自动降级为单写,别等到人工发现。
设计得当不会,设计不当一定会。三条硬约束:影子读的请求必须走独立连接池,不能占用业务连接;必须设独立的超时(建议 50 毫秒以内),超时直接放弃这次比对;必须有采样率和熔断开关,新库压力上来时能一键把影子读关到 0。影子读的结果不返回给用户,只对账不生效,所以即使它慢了,影响的也只是比对覆盖率,不是用户体验——前提是你真的把它做成了旁路。
三个要点。一是只在从库或只读副本上跑,绝不打主库。二是不做一次性全表逐行比对,而是先做分片级 checksum(便宜、能并行、能定位),只对不一致的片下钻到行级;同时每天跑一批随机抽样的逐字段比对做交叉验证。三是限速和断点续比,比对任务要支持按并发度动态调整,白天降到最低,而且要能中途暂停继续,跑了六小时被中断就得重来的脚本在大表上等于不可用。
能,而且成本很低,只是很多团队没做这一步。在切流前跑一次结构比对,覆盖范围要包括:字符集、排序规则、字段类型与精度、索引定义、触发器、存储过程、事件调度器。再跑一次服务端参数比对,重点看 sql_mode、time_zone、字符集相关的变量。这两步加起来最多一天工作量,能挡掉绝大多数隐性差异。等到切流后由业务侧报"搜索结果不对"才发现,那时候排查链路会绕一大圈。
至少保留到跑完一个完整业务对账周期。如果你们的账务是月结,那就要保留到月结完成并且对账平账,通常意味着一个月以上;如果还有季度结算或者退款有效期(比如 15 天无理由、一年质保),就要按最长的那个周期来定。我一般建议保留 4 到 8 周,并且期间老库保持只读 + 反向同步,让它始终可切回。下线前必须导出一份可恢复的冷备,这是最后一道保险。
按出现频率排:第一是大事务,一个批量 UPDATE 几十万行,在源库是一瞬间的事,在目标端要重放很久,表现为延迟突然拉高然后缓慢回落——解决办法是拆事务,或者让同步端支持大事务并行回放。第二是目标端写入瓶颈,索引太多、磁盘 IO 差、参数没调优,表现为延迟持续高位且积压单调上涨——这时候要考虑目标端升配,双写期的目标端负载本来就高于稳态。第三是热点行,少数主键被高频更新,串行回放堵在那里——按主键哈希做并行回放能缓解。第四是订阅位点反复重置或者链路中断又重连,检查一下有没有 DDL 在偷偷执行。
文中提到的产品与价格信息,均来自一万网络官网 https://www.idc10000.net/ 的公开页面:裸金属服务器标准档 E5-2620 32G/1T ¥999 元/月起、E5-2698v4×2 32G/1T ¥3999 元/月起,一万云弹性云 ¥25 元/月起。带"起"字的价格通常对应最低配置、较短租期或特定付款方式,实际成交价需按配置与周期询价。文中所有阈值、天数、比例均为示例判据,用于说明决策方法,不构成适用于任何具体系统的承诺,落地时请按自身业务量级与容忍度重新标定。具体以签约时最新报价与合同为准。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品