写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 11 章。它假设你已经跟着前面的章节把单体服务拆成了微服务(第 10 章),现在要面对一个之前没碰过的现实问题:代码拆开了,但数据库还共用一个。
这一版做了三件事:
- 每一步都讲透。数据迁移对你来说是新领域,所以「什么是数据迁移」「什么是双写」「什么是灰度」「UPSERT 是什么」这些词,我都在正文里当场解释,不让你去搜。
- 主线清晰。原文里最值钱的是「四阶段模型」和「GORM ConnPool 双写代码」,我会把这套骨架讲透,让你能自己写出来。
- 结尾有面试问答。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 指向阶段三的那条虚线是兜底路径:只要校验发现不一致,就靠回放差量把新库修回来;任何一步出问题都能退回上一阶段。
二、本章知识点大纲
先把地图看一遍,知道这章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 说清楚不停机迁移的四阶段模型:SRC_ONLY → SRC_FIRST → DST_FIRST → DST_ONLY,并讲清每个阶段「谁写、谁读、谁为准」。
- 用
mysqldump把一张表导出、再source导入到一个新库,跑通「初始化目标表」。 - 基于 GORM
ConnPool接口写出一个DoubleWritePool,能手动切换双写模式并观察读写打向哪个库。 - 写通用的
Validator[T]校验器 +Entity接口(含CompareTo),对两张表做全量字段比对。 - 理解修复用
UPSERT+ Kafka 兜底,并能在 DST_FIRST 阶段故意改错一条数据、看修复把它改回来。
理解(能讲清原理和取舍,不要求背代码)
- 双写的核心难点:跨库无法用一个事务包住,要么接受短暂不一致(靠校验修复兜底),要么做「伪跨库事务」。
- 为什么目标库写失败要「吞掉错误不返回」——用「最终一致」换「主流程绝对不被拖垮」。
- 增量校验为什么基于
utime字段(以及 binlog / Canal 是更优解),以及「修复覆盖业务最新写入」为什么不需要专门解决。 - 全量校验和增量校验可以并行,因为反复校验和修复最终总会一致。
了解(知道有这回事,面试能说出名字和一句话定位)
- 异构数据迁移:源表和目标表结构甚至数据库类型都不同,只能定制校验逻辑。
- 分布式任务调度:第 09 章讲过的分布式锁,这里用来保证多实例的校验任务不重复跑。
- 主从集群下的校验注意点:先读从库、不一致再读主库,修复必须走主库。
前置依赖与补课清单
本章会用到一些前文讲过的东西;凡是前文没讲、但对本章理解必不可少的,我在正文里就地补「补课胶囊」。
| 需要你已掌握 | 见哪一章 | 本章会不会补 | 补在哪 |
|---|---|---|---|
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 增量校验」等小节,每条边就是「读完上一节再读下一节」;走到「第四节 跟着写」转入动手落地,最后以「第七节 面试问答」收口。卡在哪一节,就回到对应节点重读。
三、正文
3.1 什么是数据迁移、为什么难
先抛问题
我们第 10 章把单体拆成了微服务,代码层面 user、post、interactive 已经是独立服务了。但有个尴尬的现实:数据库还共用一个。问你自己一个问题——
- 如果 user 服务要把自己的 user 表从
content_community库挪到一个独立的user_service库,正在用的用户会同意「网站停两天」吗? - 即便你敢停,那一两张表还好说,「几十 GB 的数据导出导入要几个小时」怎么办?停机几小时,业务方不把你活吃了?
不解决这个,微服务就是「假拆分」——服务之间是分了,数据还黏在一起,任何一个库抖动,所有服务一起挂。
给个直觉:给飞行中的飞机换引擎
类比:数据迁移就像给一架正在天上飞的飞机换引擎。你不能让飞机落地停飞(那是停机迁移);你必须在它继续飞、乘客继续刷手机的时候,把旧引擎拆下来、新引擎装上去,还不能让飞机掉下去。
为什么难?因为数据在迁移的过程中,业务一直在改它。你刚把一批数据搬完,业务又写入了新数据,你搬的那批「已经落后了」。这是数据迁移一切难点的根源。
补课胶囊 · 数据迁移
一句话:把数据从「一个存储位置 / 一种结构」搬到「另一个位置 / 另一种结构」,而且搬的时候系统还在正常对外服务。
类比:就像把家从旧房子搬到新房子的同时,你还在旧房子里正常生活、不断买新东西进来——你永远没法说「某一刻我家东西全搬完了」。
最小示例(一句话):
mysqldump 旧库 > 数据.sql && mysql 新库 < 数据.sql这是最朴素的形式,但离「不停机」还差得远,后面会讲为什么。回到主线:数据迁移几乎是每个后端工程师职业生涯都会遇到的事,也是极好的面试亮点。本章就是教你「怎么在飞机飞行中换引擎」。
小收口
这一节你只需要记住一句话:数据迁移的难点不在「搬」,而在「搬的时候数据还在变」。后面所有设计(双写、校验、修复)都是为这句话服务的。
3.2 停机迁移 vs 不停机迁移
先抛问题
既然要搬数据,最自然的想法就是:「干脆停下应用,导出,导入,再启动」。这行得通吗?
给个直觉
停机迁移的流程清清楚楚:
- 停下应用(挂维护公告);
- 从老库导出数据(
mysqldump); - 导入新库;
- 启动应用。
- 优点:方案简单,心智负担低,不容易出错。
- 缺点:应用停了。如果数据量大,导出导入都很慢,应用要停很久,严重影响用户体验,甚至就是一次生产事故。
不停机迁移是核心应用必须采用的方案,难点在于两点:
- 迁移过程中,数据始终在变动(业务一边写一边迁);
- 不能对数据库造成太大压力,否则影响线上业务。
下面把两种方案摆在一起比一比,你一眼就能看出为什么核心业务必须选不停机:
| 维度 | 停机迁移 | 不停机迁移 |
|---|---|---|
| 业务是否中断 | 是,停几分钟到几小时 | 否,用户无感知 |
| 数据一致性窗口 | 导出那一刻起就和新库脱节 | 双写 + 校验,持续对齐 |
| 适用数据量 | 小表(几分钟能导完) | 任意规模 |
| 风险点 | 停机时长 = 事故时长 | 实现复杂,需校验兜底 |
| 典型场景 | 内部系统、低频访问的小库 | 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) 在迁移开始时只有老库有:
- SRC_ONLY:用户注册/修改只写老库,新库里还没有
id=100。 - SRC_FIRST:开启双写,
id=100的任何修改同时写两库;但读请求走的还是老库。此时新库id=100被逐步补齐。 - DST_FIRST:读请求切到新库。如果某次校验发现新库
id=100和老库不一致,修复程序以新库为准把老库改回。 - DST_ONLY:连写也只写新库,老库
id=100不再被触碰,迁移完成,可以下线老库了。
注意第 3 步的「以谁为准」:SRC_FIRST 阶段基准是老库(老库是事实来源),DST_FIRST 阶段基准变成新库——因为此时我们相信新库已经足够可靠,万一老库又双写失败,就以新库为准去修老库。
一个真实场景的呼应:课程里有个「大 V 爆火迁移」的案例用的就是这套模型——某大 V 粉丝涨到几千万,原分片扛不住,于是:配置数据库开关先切单写 → 脚本迁移该大 V 的存量数据 → 校验准确性 → 开启双写 → 切换数据源,最后把大 V 单独放一个库表。四阶段 + 双写 + 校验的完整闭环,和本章方法是同一套,只是迁移对象从「全量数据」换成了「单个热点大 V」。
3.6 具体迁移步骤(把模型落成 8 步)
把上面的模型拆成可执行的 8 步,这就是你实际操作时的工作清单:
- 创建目标表:在新库里建好和源表结构一致的表。
- 用源表数据初始化目标表:把老数据导进去(3.6 节讲怎么做)。
- 执行一次校验,并用源表数据修复目标表(可选,目的是在开启双写前减少差异)。
- 业务代码开启双写,读源表,先写源表(SRC_FIRST):此时老库仍是基准。
- 开启增量校验和数据修复,保持一段时间:让两库持续对齐。
- 切换双写顺序,读目标表,先写目标表(DST_FIRST):此时新库成为基准。
- 继续保持增量校验和修复。
- 切换为目标表单写,读写都只操作目标表(DST_ONLY):迁移结束。
常见坑
小收口
这 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 就会主键/唯一键冲突报错。用 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 / Callback | Hook 要一个个结构体定义;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_FIRST | SRC_FIRST 先 src 后 dst;DST_FIRST 先 dst 后 src | 双写核心:基准库写失败才报错;非基准库失败只打日志,吞掉错误不返回 |
QueryContext SRC_FIRST | 读 src | 第二阶段以源库为准,读走老库 |
QueryContext DST_FIRST | 读 dst | 第三阶段以目标库为准,读走新库 |
把路由规则浓缩成一张表(面试常让手写):
| 当前 Pattern | ExecContext(写) | 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 写失败敢「吞错误」? 看下面的坑。
双写事务
业务经常用事务(一个请求里多次写要要么全成功要么全失败)。双写时意味着源库和目标库各开一个事务。
// 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 两个参数组合,可以控制「跑全量还是跑增量」:
| utime | sleepInterval | 行为 |
|---|---|---|
| = 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% 时命中用户确实读到新库;任意时刻回滚(比例归零 / 关双写)立刻回旧库且不丢数据
五、工程实践要点
这一节把「生产环境里真正要注意的事」列出来。它们来自原文的工程经验,也是你以后真做迁移时最容易翻车的地方。
- 顺序不能乱:先全量校验修复 → 开启双写 SRC_FIRST → 增量校验 → 切 DST_FIRST → 增量校验 → 切 DST_ONLY。每一步都依赖上一步的「两库已趋于一致」。
- 回滚是常态不是例外:任何阶段都可以切回上一阶段,因为双写期间两边数据都在更新。把
UpdatePattern接到 HTTP 接口或配置中心,出问题一键回退。 - 监控三件事:双写失败频率、校验不一致数量、修复失败数量。没有监控的迁移是盲飞。
- 告警要灵敏:双写失败超过阈值、校验不一致数持续增长,必须告警,否则「悄悄地不一致」会变成「悄悄地丢数」。
- 人工介入兜底:数据库查询出错只能记录日志 + 告警,由人工处理——不要试图让程序「猜」该怎么修。
- 业务理解是前提:异构迁移、表结构变更必须有人深度理解业务,否则「两条数据算相等」这个判断会写错。
- 不要追求一次成功:反复校验和修复,最终一致即可。这章反复强调的一句话:最终一致,不是立刻一致。
- 保护数据库优先级最高:低峰期跑、用从库校验、Kafka 削峰、接入限流,任何一项漏了都可能把线上库打挂。
六、自测题与动手练习
自测题(先自己答,答案在第七节)
- 不停机迁移为什么被比喻成「给飞行中的飞机换引擎」?它和停机迁移的核心区别在哪?
- 四阶段模型里,SRC_FIRST 和 DST_FIRST 的「写」和「读」分别打向哪个库?哪个库是「基准」?
- 双写时 dst 写失败为什么敢「吞掉错误不返回」?靠什么机制最终把数据补回来?
- 修复操作为什么必须用 UPSERT(Save)而不是普通 INSERT?普通 INSERT 会在什么场景下报错?
- 增量校验基于 utime 有什么前提?为什么「修复覆盖业务最新写入」不需要专门解决?
- 全量校验和增量校验能不能并行?为什么可以?
- 异构数据迁移为什么不能用通用校验方案?举一个「主键对不上」的例子。
- 多实例并行跑校验任务时,怎么保证同一条数据不被重复校验?(关联第 09 章)
动手练习(建议真做一遍)
- 跑通初始化:用
mysqldump把一张小表导出成.sql,再source到一个新建的webook_xxx库,确认数据完全一致。 - 手动切 Pattern:实例化一个
DoubleWritePool,分别UpdatePattern到 SRC_FIRST / DST_FIRST / DST_ONLY,打印每次读写实际打向的库,验证路由逻辑正确。 - 故意制造不一致:在 DST_FIRST 阶段,手动
UPDATE改掉目标表某行的一个字段,触发一次校验,观察修复流程通过 Kafka 把它改回源表的值。 - 批量校验改造(课后作业):把
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)。
常见失分点:把双写说成「先写新库再删老库」;或答不出「双写解决的是开启迁移后新数据的一致问题,历史数据要靠导出导入 + 校验补齐」。
一句话结论:三种形态是「同一把梭(校验 goroutine 自己修)」「两把梭(交给另一个 goroutine 修)」「消息队列(发 MQ 由消费者修)」;本课选 Kafka 是为了削峰 + 解耦 + 控制消费速率,从而保护目标表。
展开:同一把梭最简单但校验和修复节奏绑定;两把梭解耦但速率仍不可控;消息队列通过 Kafka 缓冲,消费者侧限流,间接控制目标表写入速率,避免修复把新库打挂。从面试角度,Kafka 方案也更「高级」。
面试官想听的关键词:削峰、解耦、控制消费速率、保护目标表、最终一致。
常见失分点:说不清「为什么不直接修而是要解耦」;漏掉「保护目标库写入速率」这个核心动机。
一句话结论:不会永久丢。dst 写失败只是这次没写进,后面有「全量/增量校验 + 修复」兜底补回;敢吞错是用「最终一致」换「主流程绝对不被拖垮」。
展开:双写的核心取舍是——主流程(源库写入)绝不能因为新库抖动而失败。新库失败只打日志,由后续校验发现不一致、发 Kafka 修复消息、消费者用 UPSERT 把基准库最新数据写回目标库。只要监控双写失败频率是偶发的,就不会出问题。
面试官想听的关键词:最终一致、校验修复兜底、主流程优先、UPSERT、监控失败频率。
常见失分点:答「会丢数据」或答「因为不重要」;正确说法是「暂时不一致,最终会被修复补回」。
一句话结论:双写阶段,校验时 target 还没有这条数据,但等你发修复消息、消费者去修时,业务的双写可能已经把这条写进 target 了;此时普通 INSERT 会主键/唯一键冲突报错,UPSERT「有则更新无则插入」天然规避。
展开:修复用的是「去基准库取最新值再写目标库」的策略。从「发现不一致」到「消费者真正写」之间有时间差,这段时间里业务双写可能已插入该记录。UPSERT(MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 或 GORM 的 Save)不区分「插还是改」,一条语句搞定,幂等且安全。
面试官想听的关键词:并发写冲突、主键/唯一键冲突、幂等、Save 语义、时间差。
常见失分点:只说「为了幂等」但讲不清「为什么会有冲突」;答不出「校验快照和修复执行之间的时间差」这个根因。
一句话结论:ConnPool 方案无侵入、能动态切换 pattern、能控制事务;改 DAO 侵入易遗漏,Hook 要逐结构体定义、Callback 拿到的 DB 固定无法动态切换。
展开:GORM 把「执行 SQL」(ConnPool)和「控制事务」(ConnPoolBeginner / TxCommitter)都抽象成接口。我们替换这两个接口的实现(DoubleWritePool / DoubleWriteTx),就接管了所有数据库操作,业务代码一行不用改。运维只需调 UpdatePattern 切换阶段,GORM 完全不知情。
面试官想听的关键词:无侵入、接口替换、动态切换、事务可控、ConnPoolBeginner、TxCommitter。
常见失分点:说不出「为什么 Hook/Callback 不行」的具体技术原因(DB 固定、无法动态切);或把 ConnPool 说成「一个具体类」而不是「被替换的接口」。
一句话结论:最朴素的办法是借助 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 变成动态发现。你会发现:迁移、寻址、限流这些生产难题,套路都是「加一层抽象 + 留好回滚 + 持续校验」。这个套路,值得你记一辈子。