数据迁移方面的工作:
- 重构老系统:使用新的表结构来存储数据
- 单库拆分分库分表、分库分表扩容
- 大表修改表结构定义
数据备份工具
MySQL上常用的两款数据备份工具:mysqldump和XtraBackup
mysqldump:一个用于备份和恢复数据库的命令行工具,允许用户导出MySQL数据库的结构、数据及与表之间的关系,以便在数据库发生问题时进行恢复。是一个逻辑备份工具,导出的内容是一条条SQL
XtraBackup:使用了InnoDB存储引擎的数据备份技术,支持增量备份和恢复,并且支持多主机备份和恢复。是一个物理备份工具,相当于直接复制InnoDB的底层存储文件。
如果你使用的不是 MySQL,可以自己收集一下你使用的数据库的工具。要注意分析这些工具的优缺点,尤其是导入导出速度以及可行的优化手段。
innodb_autoinc_lock_mode
innodb_autoinc_lock_mode
是InnoDB引擎里面控制自增主键生成策略的参数,有三个取值:
- 0:使用表自增键锁,但是锁在INSERT语句结束之后就释放了
- 1:使用表自增锁,如果是普通的INSERT INTO VALUE 或者 INSERT INTO VALUES 语句,申请了主键就释放锁,而不是整个INSERT语句执行完毕才释放。如果是INSERT SELECT 等语句,因为无法确定究竟要插入多少行,所以都是整个INSERT语句执行完毕才释放。
- 2:使用表自增锁,所有的语句都是申请了主键就立刻释放
在执行 INSERT INTO VALUES 的时候,不管插入多少行,都只申请一次主键,一次申请够,这些主键必然是连续的。所以你可以从返回的最后一个 ID 推测出全部 ID
面试准备
innodb_autoinc_lock_mode
的值,这回影响主键生成策略,在数据迁移的时候需要考虑处理主键问题- 公司的
binlog
模式,在后面增量校验和修复数据里面使用的是行模式的binlog
- 公司是否有统一的数据库规范,比如必须要更新时间戳、不能硬删除,只能软删除
- 使用的ORM框架怎么实现双写
- 公司是否做过数据迁移?如果做过,具体的方案是什么
重构系统:
这个系统是我们公司的一个核心系统,但是又有非常悠久的历史。在我刚接手的时候,它已经处于无法维护的边缘了。但是不管是重构这个系统,还是重新写一个类似的系统,已有的数据都是不能丢的。所以我的核心任务就是重新设计表结构,并且完成数据迁移。为此我设计了一个高效、稳定的数据迁移方案。
实际落地了单库拆分 分库分表
进公司的时候,刚好遇上单库拆分分库分表。我主要负责的事情就是设计一个数据迁移方案,把数据从单库迁移到分库分表上。
解决方案
数据迁移方案的基本步骤:
- 创建目标表
- 用源表数据初始化目标表
- 执行一次校验,并修复数据,此时用源表数据修复目标表数据
- 业务代码开启双写,读源表,并且先写源表,数据以源表为主
- 开启增量校验和数据修复,保持一段时间
- 切换双写顺序,此时读目标表,并且先写目标表,数据以目标表为准
- 继续保持增量校验和数据修复
- 切换为目标表单写,读写都只操作目标表
不考虑数据校验的话,整个数据迁移过程是这样的
图右边的两步都要考虑校验和修复数据的问题
初始化目标表数据
在创建了一个目标表之后,第一步是先尝试初始化目标数据,问题是你怎么拿到源表数据
基本思路有两个:
- 使用源表的历史备份
- 源表导出数据
以mysqldump为例 关键词是加快导入和导出速度
我选择了从源表导出数据,使用的是 mysqldump 工具。mysqldump 是一个开源的逻辑备份工具,优点是使用简单 ,能够直接导出整个数据库。缺点则是导出和导入的速度都比较慢 ,尤其是在数据量非常大的情况下。
所以我针对 mysqldump 做了一些优化,来提高导出和导入的性能。
加快导出速度能做的事情并不多,主要就是开启 extended-insert 选项,将多行合并为一个 INSERT 语句。
加快导入速度就可以做比较多的事情。
- 关闭唯一性检查和外键检查,源表已经保证了这两项,所以目标表并不需要检查。
- 关闭 binlog,毕竟导入数据用不着 binlog。
- 调整 redo log 的刷盘时机,把
innodb_flush_log_at_trx_commit
设置为 0。
注意在第 2、3 点里面,可以把话题引向的 binlog 和 redolog 内容。反过来,你也可以利用 binlog 和 redolog 把话题引导到这一节课的内容,灵活应对就可以。
第一次校验与修复
在初始化数据之后,可以先尝试立刻校验和修复一下数据,因为如果你前面用的是备份数据,那么备份数据已经落后生产数据了 。
比如你用的是昨天的备份,那么今天的修改目标表就没有。还有如果你是导出的数据,那么导出数据到你导入数据的这段时间,数据发生了变化,目标表依旧是没有的。
如果你们公司有明确的数据库规范的话,比如说所有的表都需要有update_time这个字段,那么校验和修复的时候就可以采用增量的方案 。因为只有update_time晚于你导出数据的时间点,才说明这一行的数据已经发生了变更。在修复的时候就直接用源表的数据覆盖掉目标表的数据。
业务开启双写,以源表为准
首先要解释清楚是怎么做到双写的。
支持双写大体上有两个方向:侵入式和非侵入式 两种。
侵入式方案就是直接修改业务代码 ,要求业务代码在写了源表之后再写目标表。但是侵入式方案是不太可行的,或者说代价很高。因为这意味着所有的业务都要检查一遍,然后修改。既然修改了,自然还要测试。所以,一句话总结就是工作量大还容易出错。
非侵入式一般和你使用的数据库中间件有关,比如ORM框架。这一类框架一般会提供两种方式来帮你解决类似的问题。
- AOP(Aspect Oriented Program 面向切面编程)方案:不同框架有不同叫法,比如说可能叫做
interceptor,middleware,hook,handler,filter
。这个方案的关键是捕捉到发起的增删改调用,篡改为双写模式。
- 数据库抽象操作:可能叫做
Session, Connection, Connection Pool, Executor
等,就是将源表的操作修改为双写模式。
不管用哪种方案,都是确保一个东西,就是双写可以在运行期间随时切换状态 ,单写源表、先写源表、先写目标表、单写目标表都可以。
大多数时候都是利用一个标记位,然后你可以通过配置中心或者接口直接修改它的值。
以GORM为例
开启双写在 GORM 上面还是比较容易做到的。最开始我觉得可以考虑使用 GORM 的 Hook 机制,用 DELETE 和 SAVE 两个 Hook 就可以了 。但是这要求我必须给每一个模型或表都定义类似的 Hook,还是比较麻烦的。后来我仔细翻了 GORM 的文档,确认可以考虑使用 GORM 的 ConnPool 接口。所以我用装饰器模式封装了两个数据源,每次执行语句的时候,都根据标记位来执行双写逻辑。
go
func (m *DouleWritePool) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
if m.mode == '源表优先' {
err = m.source.QueryContext(ctx, query, args...)
if err == nil {
m.target.QueryContext(ctx, query, args...)
}
} else if {
//...
}
}
在双写的时候,可以往两个方向进一步刷亮点:数据一致性问题和主键问题
数据一致性问题
正常面试官都可能会问到,如果在双写过程中,写入源表成功了,但是写入目标表失败了,该怎么办?
最基础的回答就是不管
写入源表成功,但是写入目标表失败,这个是可以不管的。因为我后面有数据校验和修复机制,还有增量校验和修复机制,都可以发现这个问题。
然后可以提出一个曾经思考过但是最终没有实施的方案,这能够证明你在数据一致性上有过很深的思考。关键词是难以确定被影响的行。
在设计方案的时候,我考虑过在写入目标表失败的时候,发一个消息到消息队列,然后尝试修复数据。但是这个其实很难做到,因为我不知道该修复哪些数据。比如一个UPDATE语句在目标表上执行失败,我没办法根据UPDATE语句推断出源表上哪些行被影响到了。
主键问题
如果在源表中使用的是自增主键,那么在双写的时候写入目标表要不要写入主键?答案是要的。
需要在写入源表的时候拿到自增主键,然后在写入目标表的时候设置好主键。因为其实你并不能确保目标表自增的主键和源表自增的主键是同一个值。比如在并发场景下两次插入。
因此你可以介绍你是如何处理这个问题的
在双写的时候比较难以处理的问题是自增主键问题。为了保持源表和目标表的数据完全一致,需要在源表插入的时候拿到自增主键的值,然后用这个值作为目标表插入的主键。
此外还可以进一步展示一个更加高级的亮点,也就是我在前置知识里说到的innodb_autoinc_lock_mode
取值会影响到自增主键的连续性,抓住关键词 自增主键的连续性
在处理批量插入的时候更要小心一些。正常来说,批量插入如果使用的是VALUES语法,那么生成的主键是连续的,可以从返回的最后一个主键推测出前面其他行的主键。即使
innodb_autoinc_lock_mode
的取值是2也能保证这一点。但是如果是多个INSERT INTO VALUE语句,或者INSERT SELECT语句,这些语句生成的主键就可能不连续。在双写之前,都要先改造这一类的业务。
增量校验和数据修复
增量校验基本就是一边保持双写,一边校验最新修改的数据,如果不一致,就要进行修复。这里有两个方案。第一个方案是利用更新时间戳,比如说update_time
这种列;第二个方案是利用binlog。相比之下binlog更加高级,在面试的时候应该优先考虑这个方案。
利用更新时间戳
利用更新时间戳的思路很简单,就是定时查询每一张表,然后根据更新时间戳来判断某一行数据有没有发生变化。
go
for {
// 执行查询
// SELECT * FROM xx WHERE update_time >= last_time
rows := findUpdatedRows()
for row in rows {
// 找到目标行,要用主键来找,用唯一索引也可以,看你支持到什么程度
tgtRow = findTgt(row.id)
if row != tgtRow {
// 修复数据
fix()
}
}
// 用这一批数据里面最大的更新时间戳作为下一次的起始时间戳
last_time = maxUpdateTime(row)
// 睡眠一下
sleep(1s)
}
所以可以介绍基本的策略,关键词是更新时间戳
我们采用的方案是利用更新时间戳找出最近更新过的记录,然后再去目标表里面找到对应的数据,如果两者不相等,就用源表的数据去修复目标表的数据。这个方案有两个条件:所有的表都是更新时间戳的,并且删除是软删除的。
这里提到的第二个条件就是准备展示的第一个亮点,你可以等面试官追问为什么必须是软删除,也可以自己接着回答。
如果不是软删除,那么源表删掉数据之后,如果目标表没删除,在我们的匹配逻辑里是找不到的。在这种场景下,还有一个补救措施,就是反向全量校验。也就是说从目标表里再次查询全量数据,再去源表里找对应的数据,如果源表里没找到,就说明源表已经删了数据,那么目标表就是可以删除数据了
接下来可以进一步展示第二个亮点,主从同步延迟引发的问题。假设你在校验和修复的时候,读的是从库,那么会遇到两种异常情况,一种是目标表主从延迟,另一种是源表主从延迟。
那么怎么解决呢?简单粗暴的方法就是全部读主库,校验和修复都以主库数据为准 。缺点是对主库的压力比较大。
更加高级的方案:双重校验
校验和修复的时候要小心主从同步的问题 ,如果校验和修复都是用从库的话,那么就会出现校验出错,或者修复出错的情况。按照道理来说,强制走主库就可以解决问题,但是这样对主库的压力比较大 。
所以我采用的是双重校验方案,第一次校验的时候读从库,如果发现数据不一致,再读主库,用主库的数据再校验一次。修复的时候就只能以主库数据为准。这种方案的基本前提是,主从延迟和数据不一致的情况是小概率的,所以最终会走到主库也是小概率的。
在这个回答里面你没有提到任何异常情况的具体场景,如果面试官问到了,就回答上面图里面的两种情况。这个亮点能够凸显你在主从延迟方面的积累,也会把话题引到主从模式和主从同步上,所以要做好准备。
利用binlog
binlog是一个更加高级的方案,它还有一些变种,所以理解和记忆的难度也更高一些。这里的binlog是基于行的binlog模式。
最简单的形态就是把binlog当作一个触发器,回答的关键词就是binlog触发。
在校验和修复数据的时候,采用的是监听binlog的方案。binlog只用于触发校验和修复这个动作 ,当我收到binlog之后,会用binlog中的主键,去查询源表和目标表,再比较两者的数据。如果不一致,就用源表的数据去修复目标表。
如果更进一步,会觉得binlog里面本来就有数据,那么干嘛不直接用binlog里的数据?所以就有了第二个形态:binlog的数据被看作源表数据。
拿到binlog之后,我用主键去目标表查询数据,然后把binlog里面的内容和目标表的数据进行比较。如果数据不一致,再用binlog里的主键去源表里查询到数据,直接覆盖目标表的数据。
这里有一点不同,就是发现不一样之后,需要查询源表,再用查询到的数据去覆盖目标表的数据。因为你要防止binlog是很古老的数据,而目标表是更加新的数据这种情况。
紧接着,会发现目标表也有binlog,所以干嘛不直接比较源表的binlog和目标表的binlog?如果binlog不一样,那不就说明是目标表那边出了问题吗?
这种方案理论上是可行的,但是它有两个非常棘手的问题。
- 一次双写,可能立刻就收到了源表的 binlog,但是你过了好久才收到目标表的 binlog。反过来,先收到目标表的 binlog,隔了很久才收到源表的 binlog 也一样。所以你需要缓存住一端的 binlog,再等待另外一端的 binlog。
- 顺序问题,如果有两次双写操作的是同一行,那么你可能先收到源表第一次的 binlog,再收到目标表第二次双写的 binlog,你怎么解决这个问题呢?你只能考虑利用消息队列之类的东西给 binlog 排个序,确保 binlog 收到的顺序和产生的顺序一致。
虽然能够进一步减轻数据库查询的压力,但是实在过于复杂,得不偿失。
切换双写顺序
这一步本身就是一个亮点。因为很多人都是在双写的时候直接切换到目标表单写。但是这样直接切换的风险太大了,万一出了问题都没法回滚。
所以中间要引入一个双写的时候先写目标表,且业务读目标表的步骤,这样万一切换到写目标表出了问题还可以回滚。
介绍这一步的时候补充说明一下
引入这一步,是为了能够在切换到以目标表为准之前有一个过渡阶段。也就是说,通过先写目标表,再写源表这种方式,万一发现数据迁移出现了问题,还可以回滚为先写源表,再写目标表,确保业务没有问题。
保持增量校验和修复
在切换了双写顺序之后,保持增量校验和修复是顺理成章的,方案和步骤5一样,不过步骤5的校验和修复都是以源表为准,那么在这一步,就是以目标表为准。
- 不管什么先后顺序、什么并发问题,在修复的时候你永远用主表的最新数据去修复,绝对不会出问题
- 如果源表或者目标表本身也是分库分表的,那么无非就是查询、修复数据的时候使用对应的分库分表规则而已
- 整个方案在第八步之前,都是可以回滚的。一旦切换到第八步,就不可能回滚了。