第11章 · 不停机数据迁移(正式版)

2023-02-10T14:11:02+08:00 | 58分钟阅读 | 更新于 2023-02-10T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 11 章。它假设你已经跟着前面的章节把单体服务拆成了微服务(第 10 章),现在要面对一个之前没碰过的现实问题:代码拆开了,但数据库还共用一个。

这一版做了三件事:

  1. 每一步都讲透。数据迁移对你来说是新领域,所以「什么是数据迁移」「什么是双写」「什么是灰度」「UPSERT 是什么」这些词,我都在正文里当场解释,不让你去搜。
  2. 主线清晰。原文里最值钱的是「四阶段模型」和「GORM ConnPool 双写代码」,我会把这套骨架讲透,让你能自己写出来。
  3. 结尾有面试问答。12 道题按「基础 → 进阶 → 追问」排列,每题告诉你一句话结论、展开怎么答、面试官想听什么词、新手最容易答错在哪。

如果你想要「高密度速查版」,可以看原版第 11 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

上一章(第 10 章)我们做了件大事:把单体拆成了微服务,user、post、interactive 各成一个服务。但是——代码层面是分开了,数据库层面呢?所有服务还在共用同一个 content_community 库,用户的表、帖子的表、点赞的表全挤在一起。微服务理论里有个被广泛认同的观点:某个微服务的数据应该单独存储。所以这一章我们要解决的就是这个「拆完代码、拆数据」的硬骨头。

这件事为什么棘手?因为社区类产品是 7×24 小时在线的,你不可能说「今天下午两点到四点停机维护,大家别刷了」——停机一小时可能就是一次生产事故。于是我们必须学会不停机数据迁移:在数据还在被业务疯狂读写的时候,悄悄把它从老库搬到新库,而且全程用户无感知。

这章是真正的「生产硬本领」。学完你会建立一套四阶段模型(先双写 → 再灰度切读 → 再校验 → 最后下线旧库),亲手用 GORM 实现双写,用 Kafka 做增量修复。这套方法不只用在微服务拆分上——任何「换数据库、换表结构、换存储引擎」的线上操作都遵循同样的范式。下一章我们将进入微服务的「通信基础设施」:服务注册与发现。

开读前先看一眼全局:迁移最怕"切一刀、赌一把"。本章把它变成下面这条流水线——先双写、再灰度切读、再校验修复、最后才下线旧库,任何一步出问题都能退回上一步。记住这张图的四个阶段,正文的所有代码都在填这四块。

flowchart LR
    A["业务写入"] --> B["阶段一:双写
新旧库同时写,读仍走旧库"] B --> C["阶段二:灰度切读
读流量按比例迁到新库"] C --> D["阶段三:校验修复
全量比对 + Kafka 增量补偿"] D --> E["阶段四:下线旧库
读写只留新库"] K[("Kafka
变更日志")] -.->|"回放差量"| D

这张图怎么读:数据流向从左到右——「业务写入」先进入「阶段一:双写」,新旧两库同时落笔、读仍走旧库;校验通过后推进「阶段二:灰度切读」,把读流量按比例从旧库挪到新库;再由「阶段三:校验修复」做全量比对,并接收下方「Kafka 变更日志」节点回放的差量,把双写期间漏写的记录补齐;最后才进「阶段四:下线旧库」,读写只留新库。注意 Kafka 指向阶段三的那条虚线是兜底路径:只要校验发现不一致,就靠回放差量把新库修回来;任何一步出问题都能退回上一阶段。


二、本章知识点大纲

先把地图看一遍,知道这章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 说清楚不停机迁移的四阶段模型:SRC_ONLY → SRC_FIRST → DST_FIRST → DST_ONLY,并讲清每个阶段「谁写、谁读、谁为准」。
  2. 用 mysqldump 把一张表导出、再 source 导入到一个新库,跑通「初始化目标表」。
  3. 基于 GORM ConnPool 接口写出一个 DoubleWritePool,能手动切换双写模式并观察读写打向哪个库。
  4. 写通用的 Validator[T] 校验器 + Entity 接口(含 CompareTo),对两张表做全量字段比对。
  5. 理解修复用 UPSERT + Kafka 兜底,并能在 DST_FIRST 阶段故意改错一条数据、看修复把它改回来。

理解(能讲清原理和取舍,不要求背代码)

  1. 双写的核心难点:跨库无法用一个事务包住,要么接受短暂不一致(靠校验修复兜底),要么做「伪跨库事务」。
  2. 为什么目标库写失败要「吞掉错误不返回」——用「最终一致」换「主流程绝对不被拖垮」。
  3. 增量校验为什么基于 utime 字段(以及 binlog / Canal 是更优解),以及「修复覆盖业务最新写入」为什么不需要专门解决。
  4. 全量校验和增量校验可以并行,因为反复校验和修复最终总会一致。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. 异构数据迁移:源表和目标表结构甚至数据库类型都不同,只能定制校验逻辑。
  2. 分布式任务调度:第 09 章讲过的分布式锁,这里用来保证多实例的校验任务不重复跑。
  3. 主从集群下的校验注意点:先读从库、不一致再读主库,修复必须走主库。

前置依赖与补课清单

本章会用到一些前文讲过的东西;凡是前文没讲、但对本章理解必不可少的,我在正文里就地补「补课胶囊」。

需要你已掌握见哪一章本章会不会补补在哪
Gin + GORM 基础(事务、First/Save/批量操作)第 02 章用到了就回顾正文用到时提示
Kafka 生产者 / 消费者、削峰与解耦第 07 章会补基础概念3.8 节 补课胶囊
单体拆分微服务(为什么要把数据库也拆开)第 10 章本章导读已衔接一、本章导读
分布式锁 / 分布式任务调度第 09 章会补关联说明3.7、3.10 节
基本的乐观锁(version 字段)、UPSERT 概念—❗ 前文没讲过,本章必补3.8 节 补课胶囊

不需要你提前懂的:binlog / Canal 细节(第 18 章才展开)、主从集群部署。本章只需知道「有这回事」。

预计时长与学习成果

  • 预计时长:4 小时(精读 + 跟着敲双写与校验代码);只求主干 2 小时可过一遍。
  • 学完你能跑出来的东西(验收标准,做到才算过关):

    一个 DoubleWritePool 能正确路由四阶段读写;一条 mysqldump 导出的数据成功导入新库;Validator 能发现并标记出两库不一致的数据;你在 DST_FIRST 阶段改错一条目标表数据后,修复流程会把它改回源库的值。

flowchart LR
    A["3.1 什么是数据迁移"] --> B["3.2 停机 vs 不停机"]
    B --> C["3.3 什么是双写"]
    C --> D["3.4 异构迁移"]
    D --> E["3.5 四阶段模型"]
    E --> F["3.6 迁移步骤"]
    F --> G["3.7 数据校验逻辑"]
    G --> H["3.8 数据修复"]
    H --> I["3.9 双写实现 ConnPool"]
    I --> J["3.10 增量校验"]
    J --> K["第四节 跟着写
5 步落地"] K --> L["第七节 面试问答"]

这张图怎么读:这是一条没有分支的单向学习路线——从「3.1 什么是数据迁移」起,沿箭头线性经过「3.5 四阶段模型」「3.7 数据校验逻辑」「3.9 双写实现 ConnPool」「3.10 增量校验」等小节,每条边就是「读完上一节再读下一节」;走到「第四节 跟着写」转入动手落地,最后以「第七节 面试问答」收口。卡在哪一节,就回到对应节点重读。

不停机数据迁移为什么迁移四阶段模型校验与修复数据迁移是什么、为什么难停机 vs 不停机(蜗牛悖论)什么是双写 / 灰度SRC/DST 四状态切换mysqldump 导入导出具体 8 步迁移流程全量 / 增量 / 反向校验UPSERT + Kafka 修复GORM ConnPool 双写跟着写:5 步落地
复习提示:怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。如果你只想快速过一遍,可以只读每节的「抛问题」和「小收口」;要真学会,就把每个代码块都敲一遍。正文里凡是第一次出现的陌生词,我都用「补课胶囊」或加粗白话解释当场铺平台阶。

三、正文

3.1 什么是数据迁移、为什么难

先抛问题

我们第 10 章把单体拆成了微服务,代码层面 user、post、interactive 已经是独立服务了。但有个尴尬的现实:数据库还共用一个。问你自己一个问题——

  • 如果 user 服务要把自己的 user 表从 content_community 库挪到一个独立的 user_service 库,正在用的用户会同意「网站停两天」吗?
  • 即便你敢停,那一两张表还好说,「几十 GB 的数据导出导入要几个小时」怎么办?停机几小时,业务方不把你活吃了?

不解决这个,微服务就是「假拆分」——服务之间是分了,数据还黏在一起,任何一个库抖动,所有服务一起挂。

给个直觉:给飞行中的飞机换引擎

类比:数据迁移就像给一架正在天上飞的飞机换引擎。你不能让飞机落地停飞(那是停机迁移);你必须在它继续飞、乘客继续刷手机的时候,把旧引擎拆下来、新引擎装上去,还不能让飞机掉下去。

为什么难?因为数据在迁移的过程中,业务一直在改它。你刚把一批数据搬完,业务又写入了新数据,你搬的那批「已经落后了」。这是数据迁移一切难点的根源。

补课胶囊 · 数据迁移

一句话:把数据从「一个存储位置 / 一种结构」搬到「另一个位置 / 另一种结构」,而且搬的时候系统还在正常对外服务。

类比:就像把家从旧房子搬到新房子的同时,你还在旧房子里正常生活、不断买新东西进来——你永远没法说「某一刻我家东西全搬完了」。

最小示例(一句话):mysqldump 旧库 > 数据.sql && mysql 新库 < 数据.sql 这是最朴素的形式,但离「不停机」还差得远,后面会讲为什么。

回到主线:数据迁移几乎是每个后端工程师职业生涯都会遇到的事,也是极好的面试亮点。本章就是教你「怎么在飞机飞行中换引擎」。

小收口

这一节你只需要记住一句话:数据迁移的难点不在「搬」,而在「搬的时候数据还在变」。后面所有设计(双写、校验、修复)都是为这句话服务的。


3.2 停机迁移 vs 不停机迁移

先抛问题

既然要搬数据,最自然的想法就是:「干脆停下应用,导出,导入,再启动」。这行得通吗?

给个直觉

停机迁移的流程清清楚楚:

  1. 停下应用(挂维护公告);
  2. 从老库导出数据(mysqldump);
  3. 导入新库;
  4. 启动应用。
  • 优点:方案简单,心智负担低,不容易出错。
  • 缺点:应用停了。如果数据量大,导出导入都很慢,应用要停很久,严重影响用户体验,甚至就是一次生产事故。

不停机迁移是核心应用必须采用的方案,难点在于两点:

  • 迁移过程中,数据始终在变动(业务一边写一边迁);
  • 不能对数据库造成太大压力,否则影响线上业务。

下面把两种方案摆在一起比一比,你一眼就能看出为什么核心业务必须选不停机:

维度停机迁移不停机迁移
业务是否中断是,停几分钟到几小时否,用户无感知
数据一致性窗口导出那一刻起就和新库脱节双写 + 校验,持续对齐
适用数据量小表(几分钟能导完)任意规模
风险点停机时长 = 事故时长实现复杂,需校验兜底
典型场景内部系统、低频访问的小库7×24 在线社区、交易系统

一句话记忆:停机迁移赌「数据量小、能快速导完」,不停机迁移赌「方案设计正确」。社区类产品显然属于后者。

蜗牛悖论:为什么「追不上」

原文用了一个很妙的类比——蜗牛悖论:

你每次往前追一段,蜗牛也往前走一段,永远追不上。

对应到迁移:不管你怎么迁,在 t1 时刻你让源库和目标库一致了;可应用立刻又改了源库,目标库瞬间又落后了。你以为「追平了」,其实永远差着「最后一次写入」。

sequenceDiagram
    participant App as 业务应用
    participant SRC as 源库(老库)
    participant DST as 目标库(新库)
    App->>SRC: 写入数据 V1
    Note over SRC,DST: t1 时刻两库一致(都是 V1)
    App->>SRC: 又改了源库 → V2
    Note over DST: 目标库还是 V1,又落后了

这个图就是蜗牛悖论的具象化:一致性是动态的,不是「搬完那一刻」就定格的。

常见坑

复习提示:⚠️ 新手最容易想错的:「我导出的那一刻做个快照(事务 / 锁表),不就能拿到一致的数据了吗?」——能,但只对「一次性全量导出」有效。问题是导出期间业务被卡住或数据在变,大表锁表几小时业务就崩了。所以真正的难题是「导完之后、到彻底切到新库之前,这漫长的时间里数据还在变」——这部分靠双写 + 校验来兜,不是靠锁表。

小收口

停机迁移简单但「伤业务」,不停机迁移难但「保业务」。本课的主角就是不停机迁移,而它的全部设计都是为了驯服「蜗牛悖论」。


3.3 补课胶囊 · 什么是双写(不停机迁移的核心手段)

在继续之前,必须先补一个贯穿全章的概念——双写。你后面会反复看到它。

一句话:在写入「源库(老库)」的同时,也写一份到目标库(新库)。

类比:你搬家时,每天买的菜既记在「旧本子」上也记在「新本子」上。过一段时间,两个本子内容就基本一致了——而这一天里你一直都在正常生活(业务不中断)。

最小示例(伪代码):

// 双写:先写源库,再写目标库
func Write(data User) {
    srcDB.Save(data) // 写老库
    dstDB.Save(data) // 同时写新库
}

回到主线:第 10 章提过「灰度」——那时说的是「流量逐步切到新服务」。这里要落到数据层的灰度:不是一下子只读新库,而是「先双写、再按百分比把读流量切到新库、最后才只读新库」。双写 + 灰度读,就是四阶段模型的两大支柱。

记住:「双写」是让新旧两库在迁移期间持续保持一致的发动机。后面 3.5 节的四阶段模型,本质就是「双写」和「灰度读」在不同阶段的组合。

sequenceDiagram
    participant B as 业务写请求
    participant O as 旧本子(源库)
    participant N as 新本子(目标库)
    B->>O: 写入数据
    B->>N: 同时写入(双写)
    Note over O,N: 两本子内容逐渐一致
    B->>N: 到达灰度点后,读也切到新本子

这张图就是 3.3 节那个「搬家每天记两本账」类比的可视化:写的时候两本都记,读的时候先读旧本、再慢慢切到新本。

小收口

这一节你只需要记住一句话:数据迁移的难点不在「搬」,而在「搬的时候数据还在变」。后面所有设计(双写、校验、修复)都是为这句话服务的。


3.4 异构数据迁移

先抛问题

前面我们假设「老库和新库结构一样,只是换个库」。但现实往往更狠:源表和目标表结构不一样,甚至数据库类型都不一样(比如 MySQL → MongoDB)。这叫什么?

给个直觉

这叫异构数据迁移(heterogeneous migration)。「异构」= 结构/类型不同。它的难点比同构迁移多两层:

  • 数据转换:类型不兼容(MySQL 的 DATETIME 到 MongoDB 的 ISODate)、精度损失(浮点数)、长度溢出(VARCHAR(255) 到短字段截断)。
  • 完整性校验:外键约束、业务关系难以自动验证,因为两边的「主键 / 索引」可能完全对不上。

常见坑

复习提示:⚠️ 异构迁移没法用通用方案。比如源表按 id 查数据,但目标表 id 被重新生成了,你就没法「拿源表 id 去目标表找对应行」——只能用唯一索引、业务键去反查,甚至只能写定制校验逻辑。本章的 Validator 泛型方案在同构场景下通用,异构场景要你自己写「怎么比较这两条数据算相等」。

小收口

同构迁移(换库不换结构)能用通用校验;异构迁移(换结构甚至换数据库)只能定制。本章主线以同构迁移为主,但会把「异构怎么处理」作为了解点保留。

异构数据怎么初始化目标表(落地方式):异构数据不能直接 mysqldump 导入,通常写一个批量迁移程序,分批读源库、转换、写目标库:

// webook/internal/migration/init.go(异构初始化示意)
func MigrateData(ctx context.Context, srcDB, dstDB *gorm.DB) error {
	var srcRecords []SrcRecord
	// 步骤 1:分批读取源库数据,每批 500 条,避免一次加载过多
	err := srcDB.WithContext(ctx).Model(&SrcRecord{}).
		FindInBatches(&srcRecords, 500, func(tx *gorm.DB, batch int) error {
			// 步骤 2:逐条转换为目标库格式(类型/字段映射都在这里)
			for _, src := range srcRecords {
				dst := convertToDst(src)
				// 步骤 3:写入目标库
				if err := dstDB.Create(&dst).Error; err != nil {
					return err
				}
			}
			return nil
		}).Error
	return err
}

落地有两种思路:把源库数据转成 SQL 再导入目标库(快),或直接在代码里用 ORM 插入(慢一点,但类型转换逻辑写起来更自然,上面的示例就是这种)。两种都可以开很多 goroutine 并发执行。并发优化策略有:表内拆分(N 个 goroutine 分别负责 id % N 的数据)、每个 goroutine 负责不同表,两种方式可混合;同样要控制 goroutine 数量,避免影响线上应用。


3.5 不停机迁移的四阶段模型

这是全章的骨架,必须刻进脑子里。

先抛问题

双写和灰度读都有了,但「什么时候双写、什么时候切读、什么时候彻底不要老库」得有个清晰的路线图。否则中途出问题,你连回退到哪一步都不知道。

给个直觉:四盏灯

想象墙上有一排指示灯,代表当前系统处于哪个阶段。每往前拨一盏,就更靠近「只留新库」;一旦出问题,随时可以拨回去。

阶段名称写入读取数据基准
第一阶段SRC_ONLY源表源表—
第二阶段SRC_FIRST双写源表 + 目标表源表源表为准
第三阶段DST_FIRST双写源表 + 目标表目标表目标库为准
第四阶段DST_ONLY目标表目标表—

关键设计:第二、三阶段都是双写,但读哪个表、修复时以哪个表为准会切换。这样一旦出问题,随时能切回上一阶段——因为双写期间两边数据都在持续更新,回退不会丢数据。

stateDiagram-v2
    [*] --> SRC_ONLY: 初始
    SRC_ONLY --> SRC_FIRST: 开启双写
    SRC_FIRST --> DST_FIRST: 校验通过,切读
    DST_FIRST --> DST_ONLY: 校验通过,切写
    DST_ONLY --> [*]
    SRC_FIRST --> SRC_ONLY: 出问题回滚
    DST_FIRST --> SRC_FIRST: 出问题回滚

逐行讲解这张状态图

转移含义为什么能回滚
SRC_ONLY → SRC_FIRST开始双写,但读还走老库新库即使写错,老库是基准,切回 SRC_ONLY 即可
SRC_FIRST → DST_FIRST校验通过,读切到新库老库仍在双写,切回 SRC_FIRST 即可
DST_FIRST → DST_ONLY校验通过,只写新库此时新库已可独立承担,老库可下线
任意 → 上一阶段出问题时回退双写保证两边都有最新数据

小收口

四阶段模型就是「先保证写一致(双写),再保证读一致(灰度切读),最后彻底切换」。记住四个英文单词:SRC_ONLY / SRC_FIRST / DST_FIRST / DST_ONLY,它们是后面所有代码里的 Pattern 常量。

一个具体例子:一条用户数据如何穿越四阶段。假设 user(id=100) 在迁移开始时只有老库有:

  1. SRC_ONLY:用户注册/修改只写老库,新库里还没有 id=100。
  2. SRC_FIRST:开启双写,id=100 的任何修改同时写两库;但读请求走的还是老库。此时新库 id=100 被逐步补齐。
  3. DST_FIRST:读请求切到新库。如果某次校验发现新库 id=100 和老库不一致,修复程序以新库为准把老库改回。
  4. DST_ONLY:连写也只写新库,老库 id=100 不再被触碰,迁移完成,可以下线老库了。

注意第 3 步的「以谁为准」:SRC_FIRST 阶段基准是老库(老库是事实来源),DST_FIRST 阶段基准变成新库——因为此时我们相信新库已经足够可靠,万一老库又双写失败,就以新库为准去修老库。

一个真实场景的呼应:课程里有个「大 V 爆火迁移」的案例用的就是这套模型——某大 V 粉丝涨到几千万,原分片扛不住,于是:配置数据库开关先切单写 → 脚本迁移该大 V 的存量数据 → 校验准确性 → 开启双写 → 切换数据源,最后把大 V 单独放一个库表。四阶段 + 双写 + 校验的完整闭环,和本章方法是同一套,只是迁移对象从「全量数据」换成了「单个热点大 V」。


3.6 具体迁移步骤(把模型落成 8 步)

把上面的模型拆成可执行的 8 步,这就是你实际操作时的工作清单:

  1. 创建目标表:在新库里建好和源表结构一致的表。
  2. 用源表数据初始化目标表:把老数据导进去(3.6 节讲怎么做)。
  3. 执行一次校验,并用源表数据修复目标表(可选,目的是在开启双写前减少差异)。
  4. 业务代码开启双写,读源表,先写源表(SRC_FIRST):此时老库仍是基准。
  5. 开启增量校验和数据修复,保持一段时间:让两库持续对齐。
  6. 切换双写顺序,读目标表,先写目标表(DST_FIRST):此时新库成为基准。
  7. 继续保持增量校验和修复。
  8. 切换为目标表单写,读写都只操作目标表(DST_ONLY):迁移结束。

常见坑

复习提示:⚠️ 第 3 步是可选的。你也可以直接开启双写、跳过它,只是两库初始差异会大一些,后面校验修复要跑更久。另外,这个「全量校验 + 修复」程序本身就可以复用来做「导出导入」,初始化目标表时完全可以拿它来跑。别把它当成一次性脚本。

小收口

这 8 步是后面所有章节(导出导入、校验、修复、双写、增量)的总目录。接下来的 3.7~3.10 节,就是逐个把这几步讲透、写出代码。

补课胶囊 · mysqldump 是什么(对应第 2 步:初始化目标表)

一句话:mysqldump 是 MySQL 自带的命令行工具,原本用于备份——把数据库里的数据和表结构导出成一个 .sql 文本文件,之后再用 mysql 命令把它「灌」进另一个库。

类比:像把冰箱里的菜(数据)拍照打印成清单(.sql 文件),带去新家照着清单把菜重新买齐放冰箱(导入新库)。

最小示例:

# 导出:把 webook 库的 interactive 表导出成 intr.sql
# -h 指定 host(避免走默认 socket 连错实例);--port 指定端口
mysqldump -h 127.0.0.1 --port 3306 -u root -p webook interactive > intr.sql

# 导入:先建好目标库,再把 sql 灌进去
mysql -u root -p webook_intr -e "source /path/to/intr.sql"

预期:执行后 webook_intr.interactive 的数据和 webook.interactive 完全一致。

回到主线:这一步对应 8 步里的「第 2 步:用源表数据初始化目标表」。注意它只适用于同构迁移(结构一样的库);异构迁移要写程序转换,见 3.4 节。

3.7 数据校验逻辑(全量校验与修复)

先抛问题

双写开启后,新旧两库「应该」是一致的。但你说「应该」没用——你得真的去查、去比对,证明它们一致;一旦发现不一致,还得把它修回来。这一步就是「数据校验 + 修复」。

给个直觉

校验就像期末考试:老师(校验程序)把每个学生的两份答卷(源库、目标库)摆在一起,一题一题对,发现谁答错了就打回去重做(修复)。难点在于:学生(业务)还在不断交新答卷,所以考试不能只考一次,得「定期考 + 随时考」。

全量校验基本思路

从源表取一条数据 → 按主键去目标表找对应数据 → 比较所有字段。看起来简单,但有坑:

  • 数据库类型都能转成 Go 类型吗? 比如 MySQL 的 JSON、DECIMAL 转成 Go 后怎么比较?
  • 转成 Go 类型后是否可比较? 浮点数有精度问题(float64 比相等千万别用 == 直接比)。
  • 数据量特别大怎么办? 大厂核心业务一个逻辑表可能数十亿条;想一天内校验完,平均 QPS 得过万——单协程一条条比对根本不可能,只能堆并发(见 3.10 节)。

方案选型

方案优点缺点
每张表写一个 DAO + 比较方法直观,好调试表多时要命,几十张表写断手
泛型通用查询 + Entity 实现比较通用,一张表一套比较逻辑需要实体定义 CompareTo
[]byte 接收数据直接比较通用,不用定义结构体类型信息丢失,精度/格式问题难处理

本课程采用方案二:用 Go 泛型写一个通用 Validator[T],每张表只要实现 Entity 接口(告诉它「怎么比、比哪些列」)就能复用。

校验方案——Validator 定义

先定义两个核心接口。这是全章最该理解清楚的抽象。

// webook/internal/migration/validator.go
package migration

import "gorm.io/gorm"

// Entity 是校验对象的接口:每张要校验的表,都实现它
type Entity interface {
	// ID 主键,整个校验体系围绕 ID 进行
	ID() int64
	// TableName 表名,修复时需要拼 SQL
	TableName() string
	// CompareTo 比较两个实体是否一致
	// 由实现者负责:明确知道每个字段怎么比较、要不要忽略某些列(如 utime)
	CompareTo(other Entity) error
	// Columns 所有列名,修复时用
	Columns() []string
}

// Validator 泛型校验器
type Validator[T Entity] struct {
	baseDB    *gorm.DB // 基准库(第二阶段=源表,第三阶段=目标库)
	targetDB  *gorm.DB // 对比库
	direction Direction // SRC 或 DST
}

// Direction 数据方向:以谁为准
type Direction uint8

const (
	DirectionSRC Direction = iota // 以源表为准
	DirectionDST                  // 以目标表为准
)

逐行讲解

行代码为什么这么写
Entity 接口抽象每张表校验逻辑不关心具体表,只关心「能拿 ID、能比、能说表名」
ID()返回主键校验永远「按主键对齐」,这是数据一致性的锚点
CompareTo自己实现怎么比不同表字段不同,比较逻辑只能交给表自己;还能忽略 utime 这类「本来就会变」的列
Validator[T Entity]泛型一份校验代码,所有表复用,避免「一表一 DAO」
baseDB / targetDB基准 vs 对比第二阶段基准是源库,第三阶段基准变成目标库——靠 direction 切换

校验逻辑实现

// Validate 执行校验,offset 是从哪个 ID 开始(游标分页)
func (v *Validator[T]) Validate(ctx context.Context, offset int64) (int64, error) {
	var base T
	// 步骤 1:从 base 中取一条数据(ID 大于 offset,保证按顺序推进)
	err := v.baseDB.WithContext(ctx).
		Where("id > ?", offset).
		First(&base).Error
	if err == gorm.ErrRecordNotFound {
		// 校验完毕:base 里没有更大的 ID 了
		return offset, nil
	}
	if err != nil {
		return offset, err
	}

	// 步骤 2:从 target 中按主键找对应数据
	var target T
	err = v.targetDB.WithContext(ctx).
		Where("id = ?", base.ID()).First(&target).Error
	if err == gorm.ErrRecordNotFound {
		// target 中没有,需要修复(insert)
		v.notifyFix(base.ID(), "target_missing")
		return base.ID(), nil
	}
	if err != nil {
		log.Warn("查询 target 出错", err)
		return base.ID(), nil
	}

	// 步骤 3:比较数据
	if cmpErr := base.CompareTo(target); cmpErr != nil {
		// 数据不一致,需要修复(update)
		v.notifyFix(base.ID(), "neq")
	}
	return base.ID(), nil
}

预期行为:跑完一轮 Validate,所有「target 缺的」和「target 不一致的」都会被登记成修复任务。它返回最后一个校验过的 ID,下次从那里继续。

反向校验——target 中多出的数据

正向校验只保证「base 有的,target 也有且相等」。但还有一种情况:base 做了硬删除(DELETE),target 里却还留着这条。软删除是 UPDATE(打删除标记),不会有这个问题;硬删除才会。

// ReverseValidate 反向校验:找 target 中有但 base 中没有的记录
func (v *Validator[T]) ReverseValidate(ctx context.Context) error {
	var ids []int64
	cursor := int64(0)
	for {
		ids = ids[:0]
		// 批量取 target 中的 ID,避免一条条查
		err := v.targetDB.WithContext(ctx).
			Where("id > ?", cursor).
			Limit(500).Pluck("id", &ids).Error
		if err != nil || len(ids) == 0 {
			return err
		}

		// 在 base 中找这些 ID
		var found []int64
		v.baseDB.WithContext(ctx).
			Where("id IN ?", ids).Pluck("id", &found).Error
		foundSet := make(map[int64]struct{}, len(found))
		for _, id := range found {
			foundSet[id] = struct{}{}
		}

		// 找出 target 有但 base 没有的,需要修复(delete from target)
		for _, id := range ids {
			if _, ok := foundSet[id]; !ok {
				v.notifyFix(id, "base_missing")
			}
		}
		cursor = ids[len(ids)-1]
	}
}

异构数据的校验

异构数据库校验无法用通用方案:源表用主键查,但目标表主键可能重新生成;字段可能被丢弃或重新计算。只能定制校验逻辑——用唯一业务键(如 email)去目标表反查,再逐字段比。

一个「主键对不上」的具体例子:假设源表 user 的主键 id 是 MySQL 自增整数,目标表(比如 MongoDB)重新用 ObjectId 生成了 _id。这时你拿源表 id=100 去目标表按 _id=100 查,根本查不到——因为目标表里这条记录的 _id 是 507f1f77bcf86cd799439011 这种字符串。所以异构场景只能靠业务唯一键(比如 email)去目标表反查,再逐字段比对 nickname、password 是否相等。这类比较逻辑没法通用,必须由你为每张表定制 CompareTo。

校验调度时机

  • 业务低峰期运行:用定时任务(cron)设置在半夜或流量最低时跑全量校验。
  • 动态判定负载:监控数据库 CPU / QPS,负载高就挂起,负载低再继续。

关联第 09 章:分布式任务调度与分布式锁

补课胶囊 · 分布式任务调度(关联第 09 章)

一句话:当校验程序部署了多个实例(为了快),要保证「同一条数据不被两个实例重复校验」。

类比:第 09 章讲过的分布式锁,就像公共卫生间门口的「有人 / 无人」旋钮——谁拿到锁谁进去用,其他人得等。多实例跑校验时,每条数据(或每个分片)用一个分布式锁标记「我正在校验这条」,别的实例看到锁就跳过。

最小示例(伪代码):

// 抢到锁才校验这个分片,避免多实例重复跑
if lock.Acquire("validate:shard:" + shardID) {
    validator.Validate(ctx, offset)
    lock.Release("validate:shard:" + shardID)
}

回到主线:校验任务「多实例不重复」靠的就是第 09 章的分布式锁思想。本章不展开锁的实现,但你面试被问到「校验怎么在多机并行又不重复」,答案就是它。

校验发现不一致后:三种执行形态

校验和修复从形态上看是两个步骤:校验发现不一致 → 修复。但谁来执行修复、什么时候执行,有三种典型做法:

形态做法特点
同一把梭校验的 goroutine 发现不一致后,立刻自己修复最简单,校验和修复节奏绑定
两把梭发现不一致后交给另一个 goroutine 去修复(可引入 channel)校验和修复解耦,各自控制节奏
消息队列发现不一致后发消息到 MQ(Kafka),消费者消费后再修复削峰 + 精确控制消费速率,间接控制目标表写入速率

本课程采用第三种(Kafka),核心目的是保护目标表——通过 Kafka 解耦和削峰,可以很容易地控制消费者的消费速率。当然,从面试角度说,Kafka 方案也「更高级」一点。

小收口

校验是「发现不一致」,修复是「把不一致改回来」。两者解耦后,校验可以放肆地高频跑,修复则被 Kafka 限流保护。下一节就讲修复具体怎么写。


3.8 数据修复

先抛问题

校验发现了不一致,怎么把它修回正确值?为什么修复不能用普通 INSERT?为什么要用 Kafka?

修复消息结构

校验发现不一致时,往 Kafka 发一条消息,告诉消费者「哪张表、哪个 ID、什么类型的不一致」。

type FixMsg struct {
	ID        int64     `json:"id"`        // 主键
	Type      FixType   `json:"type"`      // 不一致类型:target_missing / neq / base_missing
	Direction Direction `json:"direction"` // 以 SRC 为准还是 DST 为准
	Table     string    `json:"table"`     // 表名
}

type FixType uint8

const (
	FixTypeTargetMissing FixType = iota // 目标表缺数据,执行 insert
	FixTypeNeq                          // 数据不相等,执行 update
	FixTypeBaseMissing                  // 目标表多数据,执行 delete
)

修复逻辑

// Fix 修复一条数据
func (f *Fixer[T]) Fix(ctx context.Context, msg FixMsg) error {
	var base T
	// 再次查找 base 中的最新数据(避免使用校验时的旧快照)
	err := f.baseDB.WithContext(ctx).
		Where("id = ?", msg.ID).First(&base).Error
	if err == gorm.ErrRecordNotFound {
		// base 中没有了,删除 target 中的记录
		return f.targetDB.WithContext(ctx).
			Where("id = ?", msg.ID).Delete(base).Error
	}
	if err != nil {
		return err
	}
	// base 中有,执行 UPSERT(存在则更新,不存在则插入)
	return f.targetDB.WithContext(ctx).Save(&base).Error
}
flowchart TD
    V[校验发现不一致] --> K[发 Kafka FixMsg]
    K --> C[消费者取 base 最新数据]
    C --> D{base 有这条?}
    D -- 有 --> U[UPSERT 到 target]
    D -- 无 --> X[DELETE target 中的记录]

简化版——不用区分类型

实际上修复时不需要区分校验出来的是 target_missing 还是 neq:直接去 base 找数据 → 找到就 UPSERT,没找到就 DELETE。代码更短、更不容易写错。

// SimplifiedFix 简化版修复
func (f *Fixer[T]) SimplifiedFix(ctx context.Context, id int64) error {
	var base T
	err := f.baseDB.WithContext(ctx).Where("id = ?", id).First(&base).Error
	if err == gorm.ErrRecordNotFound {
		return f.targetDB.WithContext(ctx).Where("id = ?", id).Delete(base).Error
	}
	if err != nil {
		return err
	}
	return f.targetDB.WithContext(ctx).Save(&base).Error
}

补课胶囊 · 什么是 UPSERT

一句话:UPSERT = 「有则更新,无则插入」(UPDATE + INSERT 的合体)。

类比:你给通讯录存号码——「这个人有就改他的号,没人就新建一条」。你不用先查「有没有这个人」再决定 insert 还是 update,一条 SQL 全包了。

最小示例(SQL):

-- MySQL 的 UPSERT 写法
INSERT INTO user (id, nickname) VALUES (1, '小明')
  ON DUPLICATE KEY UPDATE nickname = '小明';

如果 id=1 已存在就执行后面的 UPDATE,不存在就 INSERT。

回到主线:GORM 的 Save(&obj) 就是 UPSERT 语义——它先看主键,有就更新、无就插入。修复环节大量使用它,原因马上讲。

为什么用 UPSERT?——并发问题

核心原因:双写阶段,校验时 target 还没有这条数据,但紧接着你修复时,业务的双写已经写进去了。

所以必须用 UPSERT(或 GORM 的 Save),避免唯一键冲突。

复习提示:⚠️ 新手必踩的坑:修复用普通 INSERT 而不是 UPSERT。校验的那一刻 target 没数据,可等你发修复消息、消费者去修的时候,业务双写可能已经把这条写进 target 了。这时再 INSERT 就会主键/唯一键冲突报错。用 Save(UPSERT 语义)就能「有则更新、无则插入」,天然规避。

补课胶囊 · 为什么用 Kafka 解耦(关联第 07 章)

一句话:Kafka 是个消息队列——你往里「投」消息,另一头有人「消费」消息,中间有缓冲。

类比:第 07 章讲过,Kafka 像快递柜。校验程序把「要修的数据」投进柜子(发消息),修复程序按自己的节奏从柜子取(消费)。柜子起到削峰作用:校验发现 1 万条不一致,不会瞬间冲垮数据库,因为消费者可以慢慢取、限流取。

回到主线:修复阶段用 Kafka 主要为了保护目标表——通过削峰控制消费者速率,间接控制目标表写入速率;还易于横向扩展消费者数量。面试时也「更高级」。

小收口

修复的本质是「以基准库为准,把目标库改成和它一样」。简化版直接 UPSERT/DELETE 最稳妥,UPSERT 解决了「双写和修复并发写同一条」的冲突,Kafka 解决了「修复速率压垮目标库」的风险。


3.9 双写实现——基于 GORM ConnPool

这是全章的技术精华。前面我们一直在说「双写」,现在看「双写到底怎么在代码里无侵入地实现」。

先抛问题

要在写入源库的同时也写目标库。最笨的办法是:改每一处 DAO 的 Create/Update/Delete,每个都调两次。但这样侵入式、易遗漏、易引入 bug。有没有更优雅的办法?

三种双写思路对比

思路优缺点
直接改 DAO侵入式,易引入 BUG,易遗漏(某个 DAO 忘了改就漏写)
GORM Hook / CallbackHook 要一个个结构体定义;Callback 收到的 DB 固定,无法动态切换 pattern
GORM ConnPool高级但优雅,能动态切换 pattern,能控制事务

我们选 GORM ConnPool。

补课胶囊 · 什么是 ConnPool

一句话:ConnPool 是 GORM 和数据库之间「真正执行 SQL 的那层接口」——所有 SELECT / INSERT / UPDATE / DELETE 最终都通过它落到数据库。

类比:它是 GORM 的「手」。GORM 负责想(生成 SQL),ConnPool 负责做(把 SQL 发给数据库、拿回结果)。我们只要换一只手,GORM 完全不知情。

回到主线:GORM 把「执行 SQL」和「控制事务」都抽象成了接口。我们替换掉这两个接口的实现,就等于在 GORM 毫不知情的情况下接管了所有数据库操作——这正是 ConnPool 方案优雅的根源。

ConnPool 接口

// gorm.io/gorm 中的底层接口
type ConnPool interface {
	PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
	ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
}

它是 GORM 直接和数据库打交道的出口。ExecContext 处理增删改,QueryContext / QueryRowContext 处理查询。

与它配套的还有两个底层接口,双写事务的实现就靠它们:

  • ConnPoolBeginner:提供 BeginTx 方法开启事务——DoubleWritePool 实现它之后,业务代码里 db.Begin() / db.Transaction(...) 拿到的就是我们的双写事务。
  • TxCommitter:提供 Commit 和 Rollback 方法——DoubleWriteTx 实现它之后,GORM 就能正常提交/回滚我们的事务。

一句话总结:GORM 把「执行 SQL」和「控制事务」都抽象成了接口,我们替换掉这两个接口的实现,就等于在 GORM 毫不知情的情况下接管了所有数据库操作——这正是 ConnPool 方案比改 DAO、改 Callback 高级的地方。

DoubleWritePool 实现

下面这段代码要完整理解、能默写核心逻辑。

// webook/internal/migration/double_write_pool.go
package migration

import (
	"context"
	"database/sql"
	"fmt"
	"sync/atomic"

	"gorm.io/gorm"
)

// Pattern 双写模式
type Pattern uint8

const (
	PatternSRCOnly   Pattern = iota // 只读写源库
	PatternSRCFirst                 // 双写,读源库,源库为准
	PatternDSTFirst                 // 双写,读目标库,目标库为准
	PatternDSTOnly                  // 只读写目标库
)

// DoubleWritePool 双写连接池:替换 GORM 的 ConnPool
type DoubleWritePool struct {
	src     *gorm.DB    // 源库
	dst     *gorm.DB    // 目标库
	pattern atomic.Int32 // 当前模式,用原子变量保证并发安全切换
}

func NewDoubleWritePool(src, dst *gorm.DB) *DoubleWritePool {
	p := &DoubleWritePool{src: src, dst: dst}
	p.pattern.Store(int32(PatternSRCOnly))
	return p
}

// UpdatePattern 切换模式(运维通过 HTTP 接口调用它完成阶段切换)
func (p *DoubleWritePool) UpdatePattern(pat Pattern) {
	p.pattern.Store(int32(pat))
}

// ExecContext 处理增删改(写)
func (p *DoubleWritePool) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
	pat := Pattern(p.pattern.Load())
	switch pat {
	case PatternSRCOnly:
		return p.src.ExecContext(ctx, query, args...)
	case PatternDSTOnly:
		return p.dst.ExecContext(ctx, query, args...)
	case PatternSRCFirst:
		// 双写:基准是老库,先写 src(失败报错),再写 dst(失败吞错)
		res, err := p.src.ExecContext(ctx, query, args...)
		if err != nil {
			return res, err
		}
		if _, e := p.dst.ExecContext(ctx, query, args...); e != nil {
			log.Warn("双写 dst 失败,等校验修复兜底", e)
		}
		return res, nil
	case PatternDSTFirst:
		// 双写:基准是新库,先写 dst(失败报错),再写 src(失败吞错)
		res, err := p.dst.ExecContext(ctx, query, args...)
		if err != nil {
			return res, err
		}
		if _, e := p.src.ExecContext(ctx, query, args...); e != nil {
			log.Warn("双写 src 失败,等校验修复兜底", e)
		}
		return res, nil
	}
	return nil, fmt.Errorf("unknown pattern: %d", pat)
}

// QueryContext 处理查询(读,返回多行)
func (p *DoubleWritePool) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
	pat := Pattern(p.pattern.Load())
	switch pat {
	case PatternSRCOnly, PatternSRCFirst:
		return p.src.QueryContext(ctx, query, args...)
	case PatternDSTOnly, PatternDSTFirst:
		return p.dst.QueryContext(ctx, query, args...)
	}
	return nil, fmt.Errorf("unknown pattern: %d", pat)
}

// QueryRowContext 单行查询
func (p *DoubleWritePool) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
	pat := Pattern(p.pattern.Load())
	switch pat {
	case PatternSRCOnly, PatternSRCFirst:
		return p.src.QueryRowContext(ctx, query, args...)
	case PatternDSTOnly, PatternDSTFirst:
		return p.dst.QueryRowContext(ctx, query, args...)
	}
	return p.src.QueryRowContext(ctx, query, args...)
}

补充说明(接口完整性):gorm.io/gorm 的 ConnPool 接口其实还要求实现 PrepareContext,ConnPoolBeginner 还要求实现 BeginTx。上面为聚焦「双写路由」这条核心逻辑,把这两个方法省略了。实际接入 GORM 时需补全:PrepareContext 按当前 Pattern 委托给 src 或 dst 的 PrepareContext;BeginTx 则在 src、dst 各开启一个事务并返回 DoubleWriteTx(见下文「双写事务」)。

逐行讲解

行代码为什么这么写
pattern atomic.Int32原子变量存模式切换模式是运维操作,可能和正常请求并发,必须用原子操作避免竞态
NewDoubleWritePool初始 SRC_ONLY默认只走老库,保证「没开启迁移时行为和原来完全一致」
ExecContext SRC_FIRST / DST_FIRSTSRC_FIRST 先 src 后 dst;DST_FIRST 先 dst 后 src双写核心:基准库写失败才报错;非基准库失败只打日志,吞掉错误不返回
QueryContext SRC_FIRST读 src第二阶段以源库为准,读走老库
QueryContext DST_FIRST读 dst第三阶段以目标库为准,读走新库

把路由规则浓缩成一张表(面试常让手写):

当前 PatternExecContext(写)QueryContext / QueryRowContext(读)
SRC_ONLY只写 src只读 src
SRC_FIRST先写 src,再写 dst(dst 失败吞错)只读 src
DST_FIRST先写 dst,再写 src(src 失败吞错)只读 dst
DST_ONLY只写 dst只读 dst

手动切 Pattern 的演示场景:运维在管理后台点一下「切到 DST_FIRST」,本质就是调用一次 pool.UpdatePattern(PatternDSTFirst)。此后所有 db.Create(...) 都会走 ExecContext 的双写分支,所有 db.First(...) 都会走 QueryContext 的「只读 dst」分支——业务代码一行没改,读写路由已经悄悄变了。这就是 ConnPool 方案「无侵入」的威力。

为什么 dst 写失败敢「吞错误」? 看下面的坑。

复习提示:⚠️ 新手必踩的坑:双写「先写源、dst 失败吞错误」会不会丢数据?不会永久丢。dst 写失败只是这次没写进去,后面有「全量/增量校验 + 修复」兜底把它补回来。这正是双写方案敢「dst 失败不返回 error」的底气——用「最终一致」换「主流程绝对不被拖垮」。

双写事务

业务经常用事务(一个请求里多次写要要么全成功要么全失败)。双写时意味着源库和目标库各开一个事务。

// DoubleWriteTx 双写事务
type DoubleWriteTx struct {
	srcTx    *gorm.DB // 源库事务
	dstTx    *gorm.DB // 目标库事务
	pattern  Pattern
}

func (t *DoubleWriteTx) Commit() error {
	switch t.pattern {
	case PatternDSTOnly:
		// 只写目标库:只提交 dst
		return t.dstTx.Commit().Error
	case PatternDSTFirst:
		// DST_FIRST:目标库为基准,先提交 dst(失败报错),src 失败吞错
		if err := t.dstTx.Commit().Error; err != nil {
			return err
		}
		if err := t.srcTx.Commit().Error; err != nil {
			log.Warn("src 事务提交失败,等校验修复兜底", err)
		}
		return nil
	case PatternSRCFirst:
		// SRC_FIRST 双写:源库为基准,先提交 src(失败报错),dst 失败吞错
		if err := t.srcTx.Commit().Error; err != nil {
			return err
		}
		if err := t.dstTx.Commit().Error; err != nil {
			log.Warn("dst 事务提交失败,等校验修复兜底", err)
		}
		return nil
	default:
		// SRC_ONLY:只写源库,只提交 src
		return t.srcTx.Commit().Error
	}
}

func (t *DoubleWriteTx) Rollback() error {
	switch t.pattern {
	case PatternDSTOnly:
		// 只写目标库:只回滚 dst
		return t.dstTx.Rollback().Error
	case PatternDSTFirst:
		// DST_FIRST:目标库为基准,先回滚 dst,src 失败吞错
		if err := t.dstTx.Rollback().Error; err != nil {
			return err
		}
		if err := t.srcTx.Rollback().Error; err != nil {
			log.Warn("src 事务回滚失败", err)
		}
		return nil
	case PatternSRCFirst:
		// SRC_FIRST:先回滚 src,dst 失败吞错
		if err := t.srcTx.Rollback().Error; err != nil {
			return err
		}
		if err := t.dstTx.Rollback().Error; err != nil {
			log.Warn("dst 事务回滚失败", err)
		}
		return nil
	default:
		// SRC_ONLY:只回滚 src
		return t.srcTx.Rollback().Error
	}
}

注意:这不是真正的「跨库分布式事务」。src 提交成功、dst 提交失败这种极端情况,确实没法完美回滚(没记录事务内语句)。所以双写事务的兜底依然靠「校验 + 修复」。这是工程上的取舍——追求强一致要上 XA / TCC,代价极高,大多数迁移场景不值得。

双写事务的边界(重要认知):上面的 DoubleWriteTx 并不是真正的「跨库分布式事务」。它只是「源库开一个事务、目标库开一个事务」,各自独立提交。极端情况——源库提交成功、目标库提交失败——确实没法完美回滚(我们没记录事务内具体执行了哪些语句)。所以双写事务的兜底依然靠后面的「校验 + 修复」。

这并不是偷懒,而是工程上的取舍:要做到强一致的跨库事务得引入 XA / TCC / Saga 这类方案,代价极高、性能损耗大,而绝大多数迁移场景用「最终一致 + 校验修复兜底」就足够了。记住一句话:双写事务保证的是「尽量一起成功」,不是「必须一起成功」。

容错策略(以 SRC_FIRST 为例)

场景处理方式
写 SRC 成功,写 DST 失败直接触发修复,或不管,等后续校验找出
SRC 事务开启成功,DST 事务开启失败回滚 SRC 事务
SRC 提交成功,DST 提交失败无法直接修复(没记录事务内语句),依赖后续校验

核心原则:失败就失败,等校验和修复程序找出。但需要监控双写失败频率,确保只是偶发性失败,而不是系统性故障。

flowchart TD
    W[业务写请求] --> P{当前 Pattern?}
    P -- SRC_ONLY --> A[只写源库]
    P -- SRC_FIRST --> B[写源库+写目标库
目标失败吞错] P -- DST_FIRST --> C[先写目标库再写源库
源库失败吞错] P -- DST_ONLY --> D[只写目标库] B -. 失败记录日志 .-> M[监控双写失败频率] C -. 失败记录日志 .-> M

小收口

DoubleWritePool 是「无侵入双写」的终极答案:业务代码完全不需要改,只要把 GORM 的 ConnPool 换成它,再调用 UpdatePattern 切换阶段即可。它用「dst 失败吞错 + 后续校验修复」换取主流程的绝对稳定。


3.10 增量校验与修复

先抛问题

全量校验一次可能要跑好几天。等它跑完,老数据校验过了,但这期间新改的数据又没被校验到了。怎么办?

增量校验的目标

全量校验过的数据不再重复校验,只校验「新修改的数据」。这就是增量校验。

怎么知道「哪些数据被修改过」

思路可行性
借助双写 ConnPool 找出修改过的数据难。UPDATE xx WHERE a=1 你无法知道影响了哪些行(哪些 id)
借助 utime 字段(更新时间)简单可行,但 utime 上必须有索引
借助 MySQL binlog(如 Canal)最佳方案,第 18 章会讲

为什么思路一难:假如 ConnPool 里执行了 UPDATE xx SET xxx WHERE a = 1,sql.Result.RowsAffected() 只能告诉你有几行被改,无法知道具体改了哪些行(哪些主键)。理论上要解析 SQL 语法树、提取 WHERE、再转成 SELECT 查受影响行——工程量太大,不值得。

为什么思路二简单可行:大部分公司设计表结构时都有规范——每张表必须有一个代表更新时间的列(本项目的 utime)。增量校验就是轮询「近期被改过的数据」,再走通用校验逻辑。前提是 utime 列上必须有索引,否则 WHERE utime > ? 会全表扫描。

补课胶囊 · 什么是增量校验

一句话:不重新比对全部数据,只比对「最近一段时间被修改过」的数据,从而用很小代价保持两库持续一致。

类比:图书馆每天闭馆只清点「今天被借还过的书」,而不是把 100 万本书全数一遍。绝大多数书没动,没必要查。

回到主线:增量校验是双写开启期间「日常维持一致」的手段;全量校验是「阶段性兜底」。两者并行,最终一致。

基于 utime 的增量校验

// IncrementalValidate 增量校验:只校验 utime > since 的数据
func (v *Validator[T]) IncrementalValidate(ctx context.Context, since int64, sleepInterval time.Duration) {
	for {
		select {
		case <-ctx.Done():
			return
		default:
		}
		var records []T
		// 步骤 1:取 utime 在 since 之后的数据(近期被修改的)
		err := v.baseDB.WithContext(ctx).
			Where("utime > ?", since).
			FindInBatches(&records, 500, func(tx *gorm.DB, batch int) error {
				// 步骤 2:对已取出的记录直接比对(不要再借 Validate(ctx, r.ID()-1) 重新查库——
				// 那样既浪费已取回的数据,又可能因为无 Order 查到别的行)
				for _, r := range records {
					var target T
					err := v.targetDB.WithContext(ctx).Where("id = ?", r.ID()).First(&target).Error
					if err == gorm.ErrRecordNotFound {
						v.notifyFix(r.ID(), "target_missing")
						continue
					}
					if err != nil {
						log.Warn("查询 target 出错", err)
						continue
					}
					if cmpErr := r.CompareTo(target); cmpErr != nil {
						v.notifyFix(r.ID(), "neq")
					}
				}
				return nil
			}).Error
		if err != nil {
			log.Warn("增量校验出错", err)
		}
		// sleepInterval ≤ 0 表示"跑完一轮全量即退出"
		if sleepInterval <= 0 {
			return
		}
		// 步骤 3:更新 since 为当前时间,下一轮只校验更新的
		// 注意:全书 utime 统一存毫秒时间戳,这里必须用 UnixMilli 保持单位一致
		since = time.Now().UnixMilli()
		time.Sleep(sleepInterval)
	}
}

预期行为:每隔 sleepInterval(比如 30 秒)跑一轮,只校验「上一轮到现在之间被改过」的数据,把不一致登记成修复任务。

模式组合

utime 和 sleepInterval 两个参数组合,可以控制「跑全量还是跑增量」:

utimesleepInterval行为
= 0≤ 0全量校验,结束后退出
= 0> 0全量校验,结束后继续增量校验
近期时间> 0增量校验,保持持续运行

三种调用形态对应的代码(对应上面的模式组合表):

// 形态一:utime=0, sleep=0 → 只跑一次全量校验后退出
go v.IncrementalValidate(ctx, 0, 0)

// 形态二:utime=0, sleep>0 → 先全量,结束后转持续增量
go v.IncrementalValidate(ctx, 0, 30*time.Second)

// 形态三:utime=近期, sleep>0 → 只跑增量,长期驻留(utime 是毫秒,1 小时 = 3600*1000)
go v.IncrementalValidate(ctx, time.Now().UnixMilli()-3600*1000, 30*time.Second)

预期行为:形态一适合「迁移开始前先对齐一次历史数据」;形态二适合「首次开启,既要补历史也要守增量」;形态三适合「历史早已对齐、只需守着最近一小时的变更」。三者共享同一套 Validate 校验逻辑,只是入口参数不同。

增量校验的并发问题

目标库有两个过程在写:

  • 业务写数据(双写);
  • 修复写数据(Kafka 消费者)。

可能出现:修复操作用旧快照值,直接覆盖了业务刚写入的最新值。

解决方案:不需要专门解决。等下次该数据被更新时,或下一轮全量/增量校验,数据会重新一致。核心原则——反复校验和修复,最终总会一致。

sequenceDiagram
    participant B as 业务双写
    participant T as 目标库
    participant F as 修复写
    B->>T: 写入最新值 V2
    F->>T: 用旧值 V1 覆盖(基于旧校验快照)
    Note over T: 暂时错误
    B->>T: 下次更新 V3 / 下一轮校验发现不一致
    F->>T: 重新 UPSERT 正确值
    Note over T: 最终一致

全量校验和增量校验可以并行

  • 资源充足时可以同时开;
  • 有并发问题没关系,反复校验和修复总会一致;
  • 用 Canal 做增量时,常同时开全量校验兜底(第 18 章详述)。

小收口

增量校验是「日常维持」,全量校验是「定期兜底」。两者都依赖「反复校验 + 修复」这个简单而强大的原则:不追求一次成功,追求最终一致。


3.11 性能优化与数据库保护

前面讲的方案在「几万条数据」的表里跑得很轻松,但生产环境往往是数十亿条。这一节把原文的「性能优化」和「数据库保护」补上——这是方案能不能在真实大库上跑起来的关键,也是面试被追问「量大了怎么办」时的标准答案。

3.11.1 为什么需要这些手段

在大厂核心业务上,一个逻辑表可能有数十亿条数据(多半已被分库分表)。如果希望一天内完成全量校验与修复,平均 QPS 就得过万——单协程一条条比对根本不可能,只能靠并发堆出来,同时在并发过程中保护住源表和目标表。性能瓶颈通常在数据库,不在你的程序,所以所有优化都围绕「如何既堆并发又不压垮 DB」展开。

3.11.2 性能优化手段

手段做法说明
按表开 goroutine一张表一个 goroutine多表之间天然并行
按 ID 步长分片一台机器 N 个 goroutine,分别负责尾号 0~N-1单表内部也能并行
多机器分布式校验每台机器负责一段 ID 范围数据量极大时必选,配合分布式锁防重复(见 3.7 节)

两种分片方式可混合使用,但必须控制 goroutine 数量,避免影响线上应用。多实例重复执行同一分片时,用第 09 章的分布式锁保证「同一分片同一时刻只被一个实例校验」。

3.11.3 数据库保护策略

  • 业务低峰期开更多 goroutine,高峰期减少甚至停掉校验;
  • 专门准备从库服务数据迁移(代价高,仅核心业务用);
  • 校验先读从库,不一致再读主库:从库有延迟,但大多数情况下数据一致,能显著降低主库压力;
  • 修复必须走主库:没办法,修复是写操作;
  • Kafka 削峰:消费者侧限流,间接控制目标表写入速率;
  • 接入数据库限流:进一步保护主库。

3.11.4 主从集群下的注意事项

校验最新数据应读主库(从库有延迟)。但主库光服务写请求就负载很高,所以采用「先读从库、不一致再读主库」的优化策略,修复时只能走主库。这条和 3.7 节「调度时机」是一脉相承的:低峰跑、动态判定负载、主从分工。

3.12 统一调度入口与演示流程

把双写池、校验器、修复器、补偿任务全部整合到一个 Scheduler,对外提供 HTTP 接口,运维不用登机器改代码,点点按钮就能推进迁移。

// webook/internal/migration/scheduler.go(示意)
type Scheduler struct {
	validator *Validator[SomeEntity]
	fixer     *Fixer[SomeEntity]
	pool      *DoubleWritePool
}

func (s *Scheduler) FullValidate()                 { go s.validator.FullValidate(context.Background()) }
func (s *Scheduler) IncrementalValidate()          { go s.validator.IncrementalValidate(context.Background(), time.Now().UnixMilli(), 30*time.Second) }
func (s *Scheduler) SwitchPattern(pat Pattern)     { s.pool.UpdatePattern(pat) }

对外暴露的 HTTP 接口(默认监听 8082 管理端口):

POST /migration/full-validate                     # 触发全量校验
POST /migration/incremental-validate              # 触发增量校验
POST /migration/switch-pattern?pattern=src_first  # 切换阶段
GET  /migration/status                            # 查看当前阶段与不一致计数

用 curl 推进迁移的典型操作:

# 切到双写第二阶段(读源库、源为准)
curl -X POST "http://localhost:8082/migration/switch-pattern?pattern=src_first"
# 触发一次全量校验
curl -X POST "http://localhost:8082/migration/full-validate"

一遍完整的演示流程:① 清空新旧两个库;② 在旧库插入一些数据;③ 启动服务,用压测工具(如 wrk)持续发请求模拟线上流量不停;④ Postman / curl 触发全量校验;⑤ 全量通过后开启增量校验;⑥ 切到 DST_FIRST;⑦ 保持一段时间后切到 DST_ONLY,迁移结束。整个过程中流量从没断过——这就是「不停机」的真谛。

3.13 一张图串起整章闭环

把四阶段、双写、校验、修复、增量串成一条主线,你脑子里应该长这样:

flowchart LR
    A[双写开启 SRC_FIRST] --> B[业务读写,新库持续补齐]
    B --> C[增量校验 发现不一致]
    C --> D[发 Kafka 修复消息]
    D --> E[消费者 UPSERT 修目标库]
    E --> F{校验通过?}
    F -- 否 --> C
    F -- 是 --> G[切 DST_FIRST 灰度读]
    G --> H[继续增量校验+修复]
    H --> I[切 DST_ONLY 迁移完成]

这张图怎么读:主链路从「双写开启 SRC_FIRST」出发,业务照常读写、新库借双写持续补齐;「增量校验」一发现不一致,就「发 Kafka 修复消息」,由「消费者 UPSERT 修目标库」。关键分支在「校验通过?」这个菱形:不通过就回流到「增量校验」继续修,通过才「切 DST_FIRST 灰度读」,再经历一轮「增量校验+修复」,最终「切 DST_ONLY 迁移完成」。所以放量切阶段的前提永远是校验收敛,而不是时间到了就切。

这张图就是「反复校验 + 修复 = 最终一致」的可视化——它不是一个一次性的管道,而是一个持续运行直到收敛的闭环。


四、跟着写:分步实现

上一章我们把单体改造成了模块化单体:用户模块已通过 gRPC(9090 端口)对外提供服务,但所有模块仍共用一个 content_community 库。本章动手把 user 表迁到独立的 user_service 库,全程不停机。正文 3.9 节用 ConnPool 讲了双写原理,下面按更朴素的 Repository 层双写落地——不改老 DAO 一行代码,包一层双写仓储。共五步:① 新库建表与迁移配置;② 补偿表与重试 DAO;③ 双写 UserRepository 与补偿定时任务;④ 全量迁移命令与一致性校验;⑤ 灰度读与一键回滚。

说明:本节是「动手主线」。每一步结束时项目都能编译、能跑、能验证。如果你本地没装 MySQL / Redis,至少把代码读懂、把验证思路记下来——这些代码在面试里都是能讲的「真做过」。

第 1 步:新库建表与迁移配置

先建目标库 user_service(表结构与旧库一致),再把双写开关、灰度比例等配置加进 config.yaml。

-- schema/user_service.sql:目标库建表脚本
CREATE DATABASE IF NOT EXISTS user_service DEFAULT CHARSET utf8mb4;
USE user_service;
CREATE TABLE IF NOT EXISTS `user` (
    `id`       BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `email`    VARCHAR(255) NOT NULL DEFAULT '',
    `nickname` VARCHAR(64)  NOT NULL DEFAULT '',
    `password` VARCHAR(255) NOT NULL DEFAULT '',
    `ctime`    BIGINT NOT NULL DEFAULT 0,
    `utime`    BIGINT NOT NULL DEFAULT 0,
    UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB;
# configs/config.yaml:新增 mysql_new 与 migration 两段(其余保持不变)
mysql_new:
  dsn: "root:root@tcp(127.0.0.1:3306)/user_service?charset=utf8mb4&parseTime=True&loc=Local"
migration:
  double_write: true       # 双写开关,回滚时改为 false 并重启
  read_new_ratio: 0.01     # 灰度读比例:1% 的用户读新库(兜底值)
  batch_size: 1000         # 全量迁移每批条数
  batch_interval_ms: 100   # 每批之间休眠,避免打满 DB
  max_retry: 3             # 补偿重试上限,超过则告警

验证:mysql -uroot -proot < schema/user_service.sql 后执行 SHOW TABLES IN user_service; 应输出 user;go build ./... 无任何输出即通过。

第 2 步:补偿表与重试 DAO

双写中新库写失败时,把这次操作记到补偿表,由定时任务重试。两张辅助表都建在旧库。

-- 建在旧库 content_community 中
CREATE TABLE IF NOT EXISTS `migration_failed_log` (
    `id`          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `operation`   VARCHAR(16)  NOT NULL COMMENT 'insert / update / delete',
    `table_name`  VARCHAR(64)  NOT NULL COMMENT '目标表,本章固定为 user',
    `record_id`   BIGINT       NOT NULL COMMENT '目标表主键',
    `old_data`    JSON         COMMENT '修改前的数据快照',
    `new_data`    JSON         COMMENT '修改后的数据快照',
    `retry_count` INT NOT NULL DEFAULT 0,
    `last_error`  VARCHAR(512) NOT NULL DEFAULT '',
    `status`      TINYINT NOT NULL DEFAULT 0 COMMENT '0 待重试 1 成功 2 放弃',
    `ctime`       BIGINT NOT NULL DEFAULT 0,
    `utime`       BIGINT NOT NULL DEFAULT 0,
    KEY `idx_status_retry` (`status`, `retry_count`)
) ENGINE=InnoDB;

补偿 DAO 只有「记失败、捞待重试、回写结果」三个方法,双写仓储和补偿定时任务都依赖它:

// internal/migration/failed_log_dao.go
package migration

import (
	"context"
	"time"

	"gorm.io/gorm"
)

// FailedLog 与补偿表 migration_failed_log 一一对应
type FailedLog struct {
	Id         int64  `gorm:"primaryKey"`
	Operation  string // insert / update / delete
	TableName  string `gorm:"column:table_name"` // 目标表,本章固定 user
	RecordId   int64  `gorm:"column:record_id"`  // 目标表主键
	OldData    string // 改前快照 JSON,排查用
	NewData    string // 改后快照 JSON,重试 update 靠它重放
	RetryCount int    `gorm:"column:retry_count"`
	LastError  string `gorm:"column:last_error"`
	Status     int    // 0 待重试 1 成功 2 放弃
	Ctime, Utime int64
}

// TableName 显式指定表名,防止 GORM 按复数规则猜错
func (FailedLog) TableName() string { return "migration_failed_log" }

type FailedLogDAO interface {
	Insert(ctx context.Context, l FailedLog) error                   // 双写失败记一条
	ListPending(ctx context.Context, limit int) ([]FailedLog, error) // 捞待重试记录
	MarkResult(ctx context.Context, id int64, ok bool, msg string) error
}

type GORMFailedLogDAO struct {
	db *gorm.DB // 补偿表在旧库,注入旧库连接
}

func NewGORMFailedLogDAO(db *gorm.DB) FailedLogDAO {
	return &GORMFailedLogDAO{db: db}
}

func (d *GORMFailedLogDAO) Insert(ctx context.Context, l FailedLog) error {
	l.Ctime, l.Utime, l.Status = time.Now().Unix(), time.Now().Unix(), 0
	return d.db.WithContext(ctx).Create(&l).Error
}

func (d *GORMFailedLogDAO) ListPending(ctx context.Context, limit int) ([]FailedLog, error) {
	var res []FailedLog
	// 只捞「没成功且未达重试上限」的记录,按 id 升序 = 先失败先重试
	err := d.db.WithContext(ctx).
		Where("status = 0 AND retry_count < 3").
		Order("id ASC").Limit(limit).Find(&res).Error
	return res, err
}

func (d *GORMFailedLogDAO) MarkResult(ctx context.Context, id int64, ok bool, msg string) error {
	upt := map[string]any{"utime": time.Now().Unix(), "last_error": msg}
	if ok {
		upt["status"] = 1 // 重放成功
	} else {
		upt["retry_count"] = gorm.Expr("retry_count + 1") // 失败次数自增
	}
	return d.db.WithContext(ctx).Model(&FailedLog{}).Where("id = ?", id).Updates(upt).Error
}

逐行讲解(ListPending):status = 0 AND retry_count < 3 只捞「待重试且没超上限」的;Order("id ASC") 保证先失败先重试(FIFO)。MarkResult 用 gorm.Expr 做原子自增,避免并发重试时计数错乱。

第 3 步:双写 UserRepository 与补偿定时任务

domain.UserRepository 除三个读方法外还有 Create/Update/Delete。双写仓储嵌入旧库仓储:读方法自动透传(SRC_FIRST 阶段读旧库),只重写三个写方法。

// internal/user/repository/double_write_repository.go
package repository

import (
	"context"
	"encoding/json"

	"github.com/yourname/content-community/internal/migration"
	"github.com/yourname/content-community/internal/user/domain"
	"go.uber.org/zap"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

// DoubleWriteUserRepository 嵌入旧库仓储,读透传、写双写
type DoubleWriteUserRepository struct {
	domain.UserRepository             // 旧库仓储(一行不改),读方法全部透传
	dst             *gorm.DB          // 新库连接
	failed          migration.FailedLogDAO // 补偿表 DAO(旧库)
	enable          bool              // migration.double_write 开关
}

func NewDoubleWriteUserRepository(src domain.UserRepository, dst *gorm.DB,
	failed migration.FailedLogDAO, enable bool) domain.UserRepository {
	return &DoubleWriteUserRepository{UserRepository: src, dst: dst, failed: failed, enable: enable}
}

func (r *DoubleWriteUserRepository) Create(ctx context.Context, u *domain.User) (int64, error) {
	id, err := r.UserRepository.Create(ctx, u) // 先写旧库(基准库),它失败才算主流程失败
	if err != nil {
		return 0, err
	}
	if r.enable {
		r.writeDst(ctx, "insert", id, nil, *u)
	}
	return id, nil
}

func (r *DoubleWriteUserRepository) Update(ctx context.Context, u domain.User) error {
	old, err := r.UserRepository.GetByID(ctx, u.Id) // 取旧快照:补偿重试、排查都用
	if err != nil {
		return err // 取不到说明记录不存在
	}
	if err = r.UserRepository.Update(ctx, u); err != nil {
		return err
	}
	if r.enable {
		r.writeDst(ctx, "update", u.Id, &old, u)
	}
	return nil
}

func (r *DoubleWriteUserRepository) Delete(ctx context.Context, id int64) error {
	if err := r.UserRepository.Delete(ctx, id); err != nil {
		return err
	}
	if r.enable {
		r.writeDst(ctx, "delete", id, nil, domain.User{}) // delete 重放只需 record_id
	}
	return nil
}

// writeDst 写新库:失败不打断主流程,只记补偿表 + error 日志(线上再接告警)
func (r *DoubleWriteUserRepository) writeDst(ctx context.Context,
	op string, id int64, old *domain.User, newU domain.User) {
	var err error
	switch op {
	case "insert", "update":
		// UPSERT:有则更新无则插入,天然幂等,补偿重试可安全重放
		err = r.dst.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).
			Create(&newU).Error
	case "delete":
		err = r.dst.WithContext(ctx).Where("id = ?", id).Delete(&domain.User{}).Error
	}
	if err == nil {
		return
	}
	zap.L().Error("双写新库失败,转入补偿", zap.String("op", op), zap.Int64("id", id), zap.Error(err))
	oldB, _ := json.Marshal(old) // old 为 nil 时序列化成 "null",不影响排查
	newB, _ := json.Marshal(newU)
	_ = r.failed.Insert(ctx, migration.FailedLog{
		Operation: op, TableName: "user", RecordId: id,
		OldData: string(oldB), NewData: string(newB), LastError: err.Error(),
	})
}

// ReApplyFailed 重放一条补偿记录(定时任务和手动兜底都调它)
func (r *DoubleWriteUserRepository) ReApplyFailed(ctx context.Context, l migration.FailedLog) error {
	if l.Operation == "delete" {
		return r.dst.WithContext(ctx).Where("id = ?", l.RecordId).Delete(&domain.User{}).Error
	}
	var u domain.User
	if err := json.Unmarshal([]byte(l.NewData), &u); err != nil {
		return err
	}
	return r.dst.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).Create(&u).Error
}

补偿定时任务每 5 分钟扫一遍补偿表,把失败记录重放一遍。它只依赖 ReApplyFailed 这一个方法(单独定义小接口,避免反向依赖 user 包造成循环引用):

// internal/migration/retry_job.go
package migration

import (
	"context"
	"time"

	"go.uber.org/zap"
)

// ReApplier 双写仓储实现它:能重放一条补偿记录(定义小接口避免反向依赖 user 包)
type ReApplier interface {
	ReApplyFailed(ctx context.Context, l FailedLog) error
}

type RetryJob struct {
	failed FailedLogDAO
	repo   ReApplier
}

func NewRetryJob(failed FailedLogDAO, repo ReApplier) *RetryJob {
	return &RetryJob{failed: failed, repo: repo}
}

func (j *RetryJob) Start(ctx context.Context) {
	go func() {
		ticker := time.NewTicker(5 * time.Minute)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				j.runOnce(ctx)
			}
		}
	}()
}

func (j *RetryJob) runOnce(ctx context.Context) {
	logs, err := j.failed.ListPending(ctx, 100)
	if err != nil {
		zap.L().Error("捞取补偿记录失败", zap.Error(err))
		return
	}
	for _, l := range logs {
		if err = j.repo.ReApplyFailed(ctx, l); err == nil {
			_ = j.failed.MarkResult(ctx, l.Id, true, "")
			continue
		}
		_ = j.failed.MarkResult(ctx, l.Id, false, err.Error())
		if l.RetryCount+1 >= 3 {
			// 达到重试上限仍失败:打告警日志,转人工处理(线上接告警平台)
			zap.L().Error("补偿重试达上限,需人工介入", zap.Int64("id", l.Id))
		}
	}
}
复习提示:踩坑提醒:双写顺序永远是「先旧库后新库」——基准在旧库,反过来会出现「新库成功、旧库失败」的丢数窗口;也别想用一个事务包住两个库,跨库分布式事务代价极高,这里用「补偿表 + 幂等重放」换最终一致。

验证:启动时记得 migration.NewRetryJob(failedDAO, dwRepo).Start(ctx)。正常注册一个用户,两库都有数据;把 mysql_new.dsn 改错重启再注册——接口正常返回、旧库有数、补偿表多一条 status=0;改回正确 DSN 重启,最多 5 分钟后该记录变 status=1、新库补齐这条数据。

第 4 步:全量迁移命令与一致性校验

全量迁移做成独立命令 cmd/migrate:游标分页(WHERE id > ?)避免深翻页,进度存 Redis 支持断点续传,写新库一律 UPSERT,重跑不冲突。

// cmd/migrate/main.go
package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/spf13/viper"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
	"github.com/yourname/content-community/internal/user/domain"
)

const checkpointKey = "migration:user:checkpoint" // 断点:已迁移到的最大 id

func main() {
	if len(os.Args) < 2 {
		panic("用法: go run ./cmd/migrate run|verify")
	}
	viper.SetConfigFile("configs/config.yaml")
	if err := viper.ReadInConfig(); err != nil {
		panic("读配置失败: " + err.Error())
	}
	src := mustOpen(viper.GetString("mysql.dsn"))
	dst := mustOpen(viper.GetString("mysql_new.dsn"))
	if os.Args[1] == "run" {
		migrateAll(src, dst)
	} else {
		verify(src, dst)
	}
}

func mustOpen(dsn string) *gorm.DB {
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		panic("连接数据库失败: " + err.Error())
	}
	return db
}

// migrateAll 全量迁移:游标分页读旧库 → UPSERT 写新库 → Redis 记断点(断点续传)
func migrateAll(src, dst *gorm.DB) {
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
	lastId, _ := rdb.Get(ctx, checkpointKey).Int64() // 断点续传:从上次位置继续
	for {
		var batch []domain.User
		// 游标分页避免深翻页:只查 id 更大的一批,永远走主键索引
		if err := src.WithContext(ctx).Where("id > ?", lastId).
			Order("id ASC").Limit(1000).Find(&batch).Error; err != nil {
			panic(err)
		}
		if len(batch) == 0 {
			break // 旧库扫完,全量迁移结束
		}
		// UPSERT:存在则全字段更新,重跑、重试都不会主键冲突
		if err := dst.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).
			Create(&batch).Error; err != nil {
			panic(err) // 本批失败退出;断点在 Redis,重跑续传
		}
		lastId = batch[len(batch)-1].Id
		_ = rdb.Set(ctx, checkpointKey, lastId, 0).Err()
		fmt.Printf("已迁移到 id=%d\n", lastId)
		time.Sleep(100 * time.Millisecond) // 限速,给线上 DB 留喘息空间
	}
	fmt.Println("全量迁移完成")
}

// verify 一致性校验:先比行数,再随机抽 1000 条逐字段比对
func verify(src, dst *gorm.DB) {
	ctx := context.Background()
	var srcCnt, dstCnt int64
	src.WithContext(ctx).Model(&domain.User{}).Count(&srcCnt)
	dst.WithContext(ctx).Model(&domain.User{}).Count(&dstCnt)
	fmt.Printf("行数对比:旧库=%d 新库=%d\n", srcCnt, dstCnt)
	if srcCnt != dstCnt {
		fmt.Println("!! 行数不一致,禁止进入灰度,先排查缺数的主键区间")
		return
	}
	// RAND() 只允许出现在抽样场景,业务代码禁用
	var ids []int64
	src.WithContext(ctx).Model(&domain.User{}).Order("RAND()").Limit(1000).Pluck("id", &ids)
	var a, b []domain.User
	src.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&a)
	dst.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&b)
	diff := 0
	// 按 id 建索引再比对:不能按下标直接对位——若新库缺了某条 id,b 比 a 短,
	// 用 a[i]!=b[i] 会下标越界;即便长度相同也可能因 id 错位而误报
	bMap := make(map[int64]domain.User, len(b))
	for _, u := range b {
		bMap[u.Id] = u
	}
	for _, ua := range a {
		ub, ok := bMap[ua.Id]
		if !ok {
			diff++
			fmt.Printf("新库缺失 id=%d:旧=%+v\n", ua.Id, ua)
			continue
		}
		if ua != ub { // 字段都是基础类型,结构体可直接比较
			diff++
			fmt.Printf("不一致 id=%d:旧=%+v 新=%+v\n", ua.Id, ua, ub)
		}
	}
	fmt.Printf("抽样 %d 条,不一致 %d 条\n", len(a), diff)
}
复习提示:踩坑提醒:别用 LIMIT offset,1000 深翻页——offset 越大 MySQL 扫得越慢,WHERE id > ? 让每批都走主键索引;中途挂掉也不要慌,断点在 Redis、写入是 UPSERT,直接重跑命令即可续传。

验证:go run ./cmd/migrate run(全量迁移,中断后重跑自动续传)→ go run ./cmd/migrate verify(行数 + 抽样一致性校验)。

第 5 步:灰度读与一键回滚

写链路稳定后按比例切读流量。两个关键设计:按 user_id 取模(同一用户永远命中同一个库)和比例存 Redis(10 秒内生效,不用重启服务)。

// internal/user/repository/gray_read.go
package repository

import (
	"context"
	"sync/atomic"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/yourname/content-community/internal/user/domain"
)

const ratioKey = "migration:user:read_ratio"

// GrayReadRouter 嵌入双写仓储:写路径与其余读方法全部透传,只重写 GetByID
type GrayReadRouter struct {
	domain.UserRepository               // 双写仓储(读默认走它透传的旧库)
	dst                 domain.UserRepository // 新库读仓储:同一实现 + 新库连接
	rdb                 *redis.Client
	ratio               atomic.Value // 当前灰度比例 float64,动态刷新
}

func NewGrayReadRouter(dw, dst domain.UserRepository, rdb *redis.Client, def float64) *GrayReadRouter {
	g := &GrayReadRouter{UserRepository: dw, dst: dst, rdb: rdb}
	g.ratio.Store(def) // Redis 没有值时用配置兜底
	go g.loop()
	return g
}

// loop 每 10 秒从 Redis 刷一次比例,调整灰度不用重启服务
func (g *GrayReadRouter) loop() {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()
	for range ticker.C {
		if v, err := g.rdb.Get(context.Background(), ratioKey).Float64(); err == nil && v >= 0 && v <= 1 {
			g.ratio.Store(v)
		}
	}
}

// GetByID 灰度核心:id 尾号落在灰度桶内 → 读新库。
// 按 user_id 取模保证同一用户永远走同一个库,不会「忽新忽旧」。
func (g *GrayReadRouter) GetByID(ctx context.Context, id int64) (domain.User, error) {
	if float64(id%10000) < g.ratio.Load().(float64)*10000 {
		return g.dst.GetByID(ctx, id)
	}
	return g.UserRepository.GetByID(ctx, id)
}

在 main 组装层:dstDB 用 cfg.MySQLNew.DSN 初始化,dstRepo := NewGORMUserRepository(dstDB)(同一实现换连接),再依次 dw := NewDoubleWriteUserRepository(srcRepo, dstDB, failedDAO, cfg.Migration.DoubleWrite)、NewRetryJob(failedDAO, dw).Start(ctx)、gray := NewGrayReadRouter(dw, dstRepo, rdb, cfg.Migration.ReadNewRatio),最后把 gray 当 domain.UserRepository 注入 Service——写走双写、读走灰度。灰度操作与回滚全靠一条 Redis 命令:

redis-cli set migration:user:read_ratio 0.01  # 1% 用户读新库,稳定后逐步放大 10% → 50% → 1
redis-cli set migration:user:read_ratio 0     # 发现异常:一键回滚,全部读旧库

灰度必须按 user_id 这类稳定维度取模,不能按请求随机——同一用户一会儿读新一会儿读旧,会看到「数据时有时无」;而因为旧库全程都在双写、数据始终最新,回滚只是把比例调 0,不丢任何数据。

组装层怎么把这些件接起来(写在 main.go,上层 Service 完全无感):

// internal/main.go(组装层示意)
func main() {
    // ... 略去 viper / redis / gorm 初始化 ...
    srcRepo := repository.NewGORMUserRepository(srcDB)   // 旧库仓储
    dstRepo := repository.NewGORMUserRepository(dstDB)   // 新库仓储(同一实现换连接)
    failedDAO := migration.NewGORMFailedLogDAO(srcDB)    // 补偿表 DAO(建在旧库)

    // 双写仓储:包一层,写走双写、读透传旧库
    dw := repository.NewDoubleWriteUserRepository(srcRepo, dstDB, failedDAO, cfg.Migration.DoubleWrite)
    // 补偿定时任务:每 5 分钟重放失败记录
    migration.NewRetryJob(failedDAO, dw).Start(ctx)
    // 灰度读路由:写走双写、读按 user_id 取模切新库,比例存 Redis 动态刷新
    gray := repository.NewGrayReadRouter(dw, dstRepo, rdb, cfg.Migration.ReadNewRatio)

    // 把 gray 当 domain.UserRepository 注入 Service:上层业务代码一行不用改
    userSvc := service.NewUserService(gray)
}

注意这层「无侵入」的设计哲学:Service 只认 domain.UserRepository 接口,至于背后是单库、双写、还是灰度路由,它完全不知道。这正是第 02 章「依赖接口而非实现」和本章 ConnPool 方案一以贯之的思想。

本章完成后的项目结构

content-community
├── cmd/migrate/main.go                  # 【新建】全量迁移 + 一致性校验命令
├── configs/config.yaml                  # 【修改】新增 mysql_new、migration 配置段
├── schema/user_service.sql              # 【新建】新库建表脚本
└── internal
    ├── migration/                       # 【新建】迁移公共件
    │   ├── failed_log_dao.go            #    补偿表 DAO
    │   └── retry_job.go                 #    补偿重试定时任务
    └── user
        └── repository
            ├── double_write_repository.go  # 【新建】双写仓储
            └── gray_read.go                # 【新建】灰度读路由

验证清单

  • 双写开启后,注册 / 改资料 / 注销三类写操作两库都有数据;停掉新库再写主流程不报错,恢复后补偿自动补齐
  • migrate run 中途 Ctrl-C 再重跑从断点继续且无重复数据;migrate verify 行数一致、抽样 0 条不一致
  • 灰度 1% 时命中用户确实读到新库;任意时刻回滚(比例归零 / 关双写)立刻回旧库且不丢数据

五、工程实践要点

这一节把「生产环境里真正要注意的事」列出来。它们来自原文的工程经验,也是你以后真做迁移时最容易翻车的地方。

  1. 顺序不能乱:先全量校验修复 → 开启双写 SRC_FIRST → 增量校验 → 切 DST_FIRST → 增量校验 → 切 DST_ONLY。每一步都依赖上一步的「两库已趋于一致」。
  2. 回滚是常态不是例外:任何阶段都可以切回上一阶段,因为双写期间两边数据都在更新。把 UpdatePattern 接到 HTTP 接口或配置中心,出问题一键回退。
  3. 监控三件事:双写失败频率、校验不一致数量、修复失败数量。没有监控的迁移是盲飞。
  4. 告警要灵敏:双写失败超过阈值、校验不一致数持续增长,必须告警,否则「悄悄地不一致」会变成「悄悄地丢数」。
  5. 人工介入兜底:数据库查询出错只能记录日志 + 告警,由人工处理——不要试图让程序「猜」该怎么修。
  6. 业务理解是前提:异构迁移、表结构变更必须有人深度理解业务,否则「两条数据算相等」这个判断会写错。
  7. 不要追求一次成功:反复校验和修复,最终一致即可。这章反复强调的一句话:最终一致,不是立刻一致。
  8. 保护数据库优先级最高:低峰期跑、用从库校验、Kafka 削峰、接入限流,任何一项漏了都可能把线上库打挂。

六、自测题与动手练习

自测题(先自己答,答案在第七节)

  1. 不停机迁移为什么被比喻成「给飞行中的飞机换引擎」?它和停机迁移的核心区别在哪?
  2. 四阶段模型里,SRC_FIRST 和 DST_FIRST 的「写」和「读」分别打向哪个库?哪个库是「基准」?
  3. 双写时 dst 写失败为什么敢「吞掉错误不返回」?靠什么机制最终把数据补回来?
  4. 修复操作为什么必须用 UPSERT(Save)而不是普通 INSERT?普通 INSERT 会在什么场景下报错?
  5. 增量校验基于 utime 有什么前提?为什么「修复覆盖业务最新写入」不需要专门解决?
  6. 全量校验和增量校验能不能并行?为什么可以?
  7. 异构数据迁移为什么不能用通用校验方案?举一个「主键对不上」的例子。
  8. 多实例并行跑校验任务时,怎么保证同一条数据不被重复校验?(关联第 09 章)

动手练习(建议真做一遍)

  1. 跑通初始化:用 mysqldump 把一张小表导出成 .sql,再 source 到一个新建的 webook_xxx 库,确认数据完全一致。
  2. 手动切 Pattern:实例化一个 DoubleWritePool,分别 UpdatePattern 到 SRC_FIRST / DST_FIRST / DST_ONLY,打印每次读写实际打向的库,验证路由逻辑正确。
  3. 故意制造不一致:在 DST_FIRST 阶段,手动 UPDATE 改掉目标表某行的一个字段,触发一次校验,观察修复流程通过 Kafka 把它改回源表的值。
  4. 批量校验改造(课后作业):把 Validator 里 base → target 的过程从「一条条取出来比较」改成「一批批取出来比较」——从 base 中取一批 ID,去 target 里查出对应数据,再比较是否相等。可以在 base 有十万条数据的情况下,实测单个 vs 批量两种写法的耗时差距。

七、模拟面试问答

这一节模拟真实面试场景。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点组织。

面试官
【基础】 不停机数据迁移是什么?为什么微服务化之后必须做数据迁移?停机迁移和不停机迁移的核心区别是什么?
候选人

一句话结论:不停机数据迁移是在系统持续对外服务、数据不断被读写的同时,把数据从老库搬到新库;微服务化后「每个服务的数据应独立存储」,所以必须迁;核心区别是「业务停不停」——停机迁移简单但伤体验,不停机迁移难但保业务。

展开:微服务理论要求某服务的数据单独存储,所以拆完代码还要拆数据。停机迁移是「停应用 → 导出 → 导入 → 启动」,简单但应用停很久、影响体验甚至成事故;不停机迁移让业务一边写一边迁,难点在于「数据始终在变动」(蜗牛悖论),必须用双写 + 校验 + 修复来兜。

面试官想听的关键词:微服务数据独立、蜗牛悖论、双写、最终一致、业务不中断。

常见失分点:只说「为了微服务所以要迁」,说不清「代码拆了数据库没拆」这个具体痛点;或把不停机迁移的难点说成「技术复杂」而不是「数据在变」。

面试官
【基础】 四阶段模型是哪四个阶段?每个阶段的「写」和「读」分别打向哪个库?
候选人

一句话结论:四阶段是 SRC_ONLY → SRC_FIRST → DST_FIRST → DST_ONLY;SRC_FIRST 双写、读源库、源为准;DST_FIRST 双写、读目标库、目标为准;两端只读写单库。

展开:SRC_ONLY 只读写源库(初始态);SRC_FIRST 双写源+目标、读源库、源库为基准;DST_FIRST 同样双写、但读目标库、目标库为基准;DST_ONLY 只读写目标库(收尾)。第二、三阶段都是双写,靠「读哪个、以哪个为准」的切换实现平滑过渡,出问题随时切回上一阶段。

面试官想听的关键词:四阶段英文名、双写、基准切换、可回滚、灰度切读。

常见失分点:把 SRC_FIRST 和 DST_FIRST 的「读」说反;或忘了说「两阶段都是双写,只是读和基准不同」。

面试官
【基础】 什么是双写?它为什么是不停机迁移的核心手段?
候选人

一句话结论:双写是在写入源库的同时也写一份到目标库;它让新旧两库在迁移期间持续保持一致,是「飞机换引擎时两边本子都记」的关键机制。

展开:双写保证了「从开启那一刻起,所有新写入都同时进两个库」,于是只要补齐开启前的历史数据,两库就趋于一致。再配合灰度切读(先读老库、再读新库)和校验修复兜底,就能平滑迁移。没有双写,你就只能在「停业务」和「数据不一致」之间二选一。

面试官想听的关键词:同时写两库、持续一致、灰度读、校验兜底、无侵入(ConnPool)。

常见失分点:把双写说成「先写新库再删老库」;或答不出「双写解决的是开启迁移后新数据的一致问题,历史数据要靠导出导入 + 校验补齐」。

面试官
【基础】 数据校验发现不一致后,修复有哪三种执行形态?本课程为什么选 Kafka?
候选人

一句话结论:三种形态是「同一把梭(校验 goroutine 自己修)」「两把梭(交给另一个 goroutine 修)」「消息队列(发 MQ 由消费者修)」;本课选 Kafka 是为了削峰 + 解耦 + 控制消费速率,从而保护目标表。

展开:同一把梭最简单但校验和修复节奏绑定;两把梭解耦但速率仍不可控;消息队列通过 Kafka 缓冲,消费者侧限流,间接控制目标表写入速率,避免修复把新库打挂。从面试角度,Kafka 方案也更「高级」。

面试官想听的关键词:削峰、解耦、控制消费速率、保护目标表、最终一致。

常见失分点:说不清「为什么不直接修而是要解耦」;漏掉「保护目标库写入速率」这个核心动机。

面试官
【进阶】 双写时目标库写失败,为什么敢「吞掉错误不返回」?会不会永久丢数据?
候选人

一句话结论:不会永久丢。dst 写失败只是这次没写进,后面有「全量/增量校验 + 修复」兜底补回;敢吞错是用「最终一致」换「主流程绝对不被拖垮」。

展开:双写的核心取舍是——主流程(源库写入)绝不能因为新库抖动而失败。新库失败只打日志,由后续校验发现不一致、发 Kafka 修复消息、消费者用 UPSERT 把基准库最新数据写回目标库。只要监控双写失败频率是偶发的,就不会出问题。

面试官想听的关键词:最终一致、校验修复兜底、主流程优先、UPSERT、监控失败频率。

常见失分点:答「会丢数据」或答「因为不重要」;正确说法是「暂时不一致,最终会被修复补回」。

面试官
【进阶】 修复为什么必须用 UPSERT(Save)而不是普通 INSERT?请讲清并发场景。
候选人

一句话结论:双写阶段,校验时 target 还没有这条数据,但等你发修复消息、消费者去修时,业务的双写可能已经把这条写进 target 了;此时普通 INSERT 会主键/唯一键冲突报错,UPSERT「有则更新无则插入」天然规避。

展开:修复用的是「去基准库取最新值再写目标库」的策略。从「发现不一致」到「消费者真正写」之间有时间差,这段时间里业务双写可能已插入该记录。UPSERT(MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 或 GORM 的 Save)不区分「插还是改」,一条语句搞定,幂等且安全。

面试官想听的关键词:并发写冲突、主键/唯一键冲突、幂等、Save 语义、时间差。

常见失分点:只说「为了幂等」但讲不清「为什么会有冲突」;答不出「校验快照和修复执行之间的时间差」这个根因。

面试官
【进阶】 GORM ConnPool 双写方案,相比「改 DAO」和「GORM Hook/Callback」好在哪?
候选人

一句话结论:ConnPool 方案无侵入、能动态切换 pattern、能控制事务;改 DAO 侵入易遗漏,Hook 要逐结构体定义、Callback 拿到的 DB 固定无法动态切换。

展开:GORM 把「执行 SQL」(ConnPool)和「控制事务」(ConnPoolBeginner / TxCommitter)都抽象成接口。我们替换这两个接口的实现(DoubleWritePool / DoubleWriteTx),就接管了所有数据库操作,业务代码一行不用改。运维只需调 UpdatePattern 切换阶段,GORM 完全不知情。

面试官想听的关键词:无侵入、接口替换、动态切换、事务可控、ConnPoolBeginner、TxCommitter。

常见失分点:说不出「为什么 Hook/Callback 不行」的具体技术原因(DB 固定、无法动态切);或把 ConnPool 说成「一个具体类」而不是「被替换的接口」。

面试官
【进阶】 增量校验怎么知道「哪些数据被修改过」?为什么不用 binlog 也能做?
候选人

一句话结论:最朴素的办法是借助 utime(更新时间)字段轮询近期数据;更优解是 MySQL binlog(如 Canal)监听变更;不用 binlog 是因为大多数表都有 utime 规范,轮询成本低、够用。

展开:借助双写 ConnPool 提取「影响行」理论上可行,但要解析 SQL 语法树、工程太大不值得。借助 utime 只需 WHERE utime > since 轮询,前提是 utime 列有索引。binlog 是「最佳方案」——精确捕获每一行变更,但引入额外组件,通常放到进阶章节(第 18 章)。

面试官想听的关键词:utime 字段、索引、binlog/Canal、SQL 语法树不可行、轮询。

常见失分点:说「通过 ConnPool 拦截能精确知道改了哪些行」却答不出「无法知道 UPDATE 影响多少行」这个难点;或忘了说「utime 必须建索引」。

面试官
【追问】 增量校验时,「修复写」可能覆盖「业务双写的最新值」,这个问题怎么解决?
候选人

一句话结论:不需要专门解决。修复用的是旧校验快照值,确实可能短暂覆盖业务最新值,但核心原则是「反复校验和修复,最终总会一致」。

展开:目标库同时被业务双写和修复写两个过程写入,时序上修复可能用旧值 V1 盖掉刚写入的 V2。但下次该数据被更新、或下一轮全量/增量校验又会发现不一致,重新 UPSERT 正确值。因为校验是持续运行的,短暂不一致会在后续轮次自愈。

面试官想听的关键词:最终一致、反复校验、自愈、不追求一次成功、时序竞争。

常见失分点:以为必须加锁或版本号解决这个并发问题;正确态度是「接受短暂不一致,靠反复校验收敛」。

面试官
【追问】 如果校验任务要在多台机器并行跑(表有数十亿条),怎么保证不重复校验、不漏校验?
候选人

一句话结论:按 ID 范围或尾号分片,每台机器负责一部分;多实例重复执行同一分片时用第 09 章的分布式锁保证「同一分片同一时刻只被一个实例校验」。

展开:性能瓶颈在数据库不在程序,所以要堆并发——按表开 goroutine、按 ID 步长分片(尾号 0~9)、多机各管一段 ID 范围。多实例部署时,每条数据/每个分片用一个分布式锁(如 Redis 锁)标记「我正在校验这段」,别的实例看到锁就跳过,避免重复劳动也避免重复写修复消息。

面试官想听的关键词:ID 分片、多机并行、分布式锁、不重复不遗漏、数据库瓶颈。

常见失分点:只说「加机器」不说「怎么分配不重叠」;或忘了「多实例重复跑要加锁」这个细节点(关联第 09 章)。

面试官
【追问】 主从集群下做校验要注意什么?为什么「校验先读从库、不一致再读主库」?
候选人

一句话结论:校验最新数据应读主库(从库有延迟),但主库光服务写就负载很高,所以优化成「先读从库、不一致再读主库」,修复必须走主库。

展开:从库有复制延迟,直接读主库做全量校验会把主库打爆。绝大多数情况下从库数据和主库是一致的,先读从库比对,只有发现不一致时才去主库取权威值,能显著降低主库压力。修复(写)没办法,只能走主库。

面试官想听的关键词:主从延迟、从库校验、主库修复、保护主库、读扩散。

常见失分点:说「校验全读主库」——会被追问「主库扛不住写还扛校验吗」;正确是「从库为主、主库兜底」。

面试官
【追问】 如何把这整套方案包装成一个「通用数据迁移平台」讲给面试官听(进阶话术)?
候选人

一句话结论:在四阶段 + 双写 + 校验 + Kafka 修复之上,再封装一层——用户只提供源/目标 DSN 和表配置,平台提供 DoubleWritePool 接入、管理界面触发校验/修复/阶段切换,这就是一个通用迁移平台。

展开:基础版是「针对某张表写一套双写 + 校验」。平台化后,用户只需配置 DSN、表映射;平台把 DoubleWritePool 作为 SDK 供业务接入;Scheduler 的 HTTP API 之上包一个 Web 管理界面,一步步点按钮完成校验、修复、阶段切换。再往前可挑战异构表结构迁移和分库分表规则修改——这是含金量最高的方向。

面试官想听的关键词:配置化、DoubleWritePool SDK、管理界面、Scheduler、异构迁移、分库分表。

常见失分点:只罗列「我做了双写和校验」,没有「抽象成平台」的升华;面试高阶岗时这层升华能拉开差距。


八、本章小结

学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。

复习提示:

可勾选清单(面试前 5 分钟扫一遍)

  • 为什么迁移:微服务化后数据要独立存储;不停机迁移难点是「数据始终在变动」(蜗牛悖论)。
  • 四阶段模型:SRC_ONLY → SRC_FIRST → DST_FIRST → DST_ONLY,每阶段都可回滚;SRC_FIRST 读源库源为准,DST_FIRST 读目标库目标为准。
  • 双写:同时写新旧两库,是让迁移期间持续一致的核心手段;GORM ConnPool 方案无侵入、可动态切 pattern、可控事务。
  • dst 失败吞错:用「最终一致」换「主流程不被拖垮」,靠校验 + 修复兜底。
  • 校验:泛型 Validator[T] + Entity.CompareTo;正向校验 + 反向校验(防目标库多数据);多实例用分布式锁防重复。
  • 修复:Kafka 解耦削峰 + UPSERT(Save);简化版直接 UPSERT/DELETE 不区分类型;并发写冲突靠 UPSERT 规避。
  • 增量校验:基于 utime(须建索引)或 binlog/Canal;与全量校验可并行;「修复覆盖业务最新写入」不解决,靠反复校验收敛。
  • 工程要点:顺序不能乱、回滚是常态、监控三件事(双写失败/不一致数/修复失败)、保护数据库优先级最高。
  • 核心心法:不追求一次成功,反复校验和修复,最终一致即可。

下一章我们将进入服务注册与发现——微服务拆出来后,怎么让客户端找到这些动态变化的实例。本章我们为了让数据「无感迁移」做了双写、校验、灰度,下一章要解决的是「服务地址也在变」这个新问题,带着「动态、解耦、兜底」的思路进入第 12 章会学得更顺。

复习提示:给下一章留个伏笔:本章我们用 DoubleWritePool 把「数据库操作」接管了,靠切换 pattern 实现平滑迁移。下一章服务注册与发现,本质上也是「接管寻址」——把「服务在哪」从写死 IP 变成动态发现。你会发现:迁移、寻址、限流这些生产难题,套路都是「加一层抽象 + 留好回滚 + 持续校验」。这个套路,值得你记一辈子。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!