跳至主要內容

MySQL-高可用篇-⑤数据迁移

holic-x...大约 28 分钟JAVAMySQL

MySQL-高可用篇-⑤数据迁移

学习核心

  • 数据迁移核心

    • 常见数据迁移场景
  • 如何安全、有效实现数据迁移

学习资料

数据迁移核心

常见数据迁移场景:

  • 大表拆分:对 MySQL 做了分库分表之后,需要从原来的单实例数据库迁移到新的数据库集群上
  • 系统从传统部署方式向云上迁移的时候,也需要从自建的数据库迁移到云数据库上
  • 一些在线分析类的系统,MySQL 性能不够用的时候,就需要更换成一些专门的分析类数据库,比如说 HBase

数据迁移重点

​ 从概念上理解分析,数据迁移无非是将数据从一个数据库拷贝到另一个数据库:

  • 可通过 MySQL 主从同步的方式做到准实时的数据拷贝;
  • 可通过 mysqldump 工具将源库的数据导出,再导入到新库;

​ 但是这两种方式仅支持单库到单库的迁移,无法支持单库到多库多表的场景。且数据迁移的过程应该要注意几个方面的内容:在线迁移、数据完整性、支持回滚等

  • 在线迁移:数据迁移一般是不停机迁移场景,在迁移的过程中要注意增量数据的写入
  • 数据完整性:即保证数据迁移的源库和目标库中的数据是完全已知的
  • 支持回滚:数据迁移的过程需要做到可以支持回滚,一旦迁移过程中出现问题,可以like回滚到源库,而不会对系统的可用性造成影响

举个例子,如果采用最基础的binlog同步方式,其迁移过程分析如下:

  • 同步数据:采用binlog同步方式同步数据
  • 切换数据库:在同步完成之后切换数据库

​ 基于这种迁移思路,无法满足可回滚的要求。一旦迁移后出现问题,由于已经有增量的数据写入新库而没有写入旧库,难以再进行复原。但基于实际情况考虑,不管是哪种方案,一定要保证每执行一个步骤后,一旦出现问题,可以快速地回滚到上一个步骤

常见数据迁移方案

  • 有损迁移:指的是停机变更方案,由于需要一段时间停止服务,对业务是有损的
  • 无损迁移:平滑迁移(无损迁移),服务在迁移过程中不需要停机,最多只需要短暂的重启时间,对于业务几乎没有任何影响

数据库迁移方案

1.有损迁移(停机变更方案)

​ 有损方案指的是停机变更方案,需要一段时间停止服务,因此对于业务是有损的。

​ 这种方案可能看起来很low,但是却简单粗暴。实际上目前存在相当一部分迁移方案都是使用凌晨停机迁移的方式(例如有时候会看到停机维护公告),因为这种方式成本低简单粗暴、无需考虑新老库数据临界情况不一致的问题、整个迁移周期非常短不存在灰度放量与多次修改代码发布的情况,缺点就是短暂业务有损,需要DBA、研发值班。

有损迁移方案核心流程

  • 发布停机公告,服务停机:通过用户流量监控,选择流量较小的时段执行变更,尽可能较少业务损失
  • 数据迁移:通过数据导入导出工具(脚本等),将数据从源表导到目标表(为了提升数据导出速度,可以采用一些优化方案提升数据批量操作,例如多线程读写,或者联系DBA直接进行物理复制)
  • 配置修改,服务重启,数据验证:迁移动作完成,将服务的数据库配置调整到连接目标库,然后启动服务验证业务功能是否正常响应
    • 如果验证通过则进入下一步
    • 如果验证不通过则需校验并修正
  • 用户流量打开:服务正常响应,验证无误后,则可打开用户流量,放开业务功能
  • 迁移完成

​ 基于上述方案迁移,同样需要观察一段时间,确认服务重新上线是否可以正常运作,并做好针对因数据库迁移引发的问题的应急方案

级联同步方案

​ 这种方案也比较简单,比较适合数据从自建机房向云上迁移的场景。因为迁移上云,最担心云上的环境和自建机房的环境不一致,会导致数据库在云上运行时,因为参数配置或者硬件环境不同出现问题。

​ 在自建机房准备一个备库,在云上环境上准备一个新库,通过级联同步的方式在自建机房留下一个可回滚的数据库,具体的步骤如下:

  • 先将新库配置为旧库的从库,用作数据同步;

  • 再将一个备库配置为新库的从库,用作数据的备份;

  • 等到三个库的写入一致后,将数据库的读流量切换到新库;

  • 然后暂停应用的写入,将业务的写入流量切换到新库(由于这里需要暂停应用的写入,所以需要安排在业务的低峰期)

image-20240711214508650

​ **这种方案的回滚方案也比较简单,**可以先将读流量切换到备库,再暂停应用的写入,将写流量切换到备库,这样所有的流量都切换到了备库,也就是又回到了自建机房的环境,就可以认为已经回滚了。

​ 上面的级联迁移方案可以应用在,将 MySQL 从自建机房迁移到云上的场景,也可以应用在将 Redis 从自建机房迁移到云上的场景,如果有类似的需求可以直接拿来应用。

​ 这种方案优势是简单易实施,在业务上基本没有改造的成本;缺点是在切写的时候需要短暂的停止写入,对于业务来说是有损的,不过如果在业务低峰期来执行切写,可以将对业务的影响降至最低。

2.无损迁移(不停机迁移)

​ 平滑迁移(无损迁移),服务在迁移过程中不需要停机,最多只需要短暂的重启时间,对于业务几乎没有任何影响;无损迁移常见的迁移方案有“双写”方案

双写方案

(1)双写方案核心思路

​ 所谓双写,简而言之就是修改线上代码,在之前所有写老库(增删改操作)的地方,都加上对新库的增删改。双写方案的核心流程分析如下:

  • 【1】数据同步:将源库的数据同步到目标库,同时需要关注增量数据的同步(例如可以使用开源工具Canal、Binlog做迁移)
  • 【2】双写配置
    • 写配置:支持写源库、目标库,并且预留热切换开关(通过开关控制写状态:只写源库、只写目标库、同步双写)
    • 读配置:支持读源库、目标库,并且预留热切换开关(通过开关控制读状态:读源库、读目标库)
  • 【3】双写服务上线:配置双写、读源库,上线服务版本
  • 【4】数据验证:服务上线一段时间后,选择一个时间点进行数据验证,确认源库和目标库的数据是否一致(如果数据库中的数据量很大,做全量数据校验不太现实,可以选择抽取部分数据进行校验,确保数据一致即可)
  • 【5】读配置切换:数据验证无误,可以慢慢将流量切换到目标库。一次切换全量读流量可能会对系统产生未知的影响,建议采用灰度的方式来切换,比如开始切换 10% 的流量,如果没有问题再切换到 50% 的流量,最后再切换到 100%
  • 【6】写配置切换:继续观察一段时间,确认迁移后运行没有问题,则可以进一步将数据库配置改造为“写目标库”,至此迁移工作完成

image-20240711204859992

​ 结合上述图示步骤理解双写方案流程,其中步骤2和步骤3都要考虑数据校验和数据修复的问题。基于双写方案的迁移,是一种比较通用的方案,无论是迁移 MySQL 中的数据,还是迁移 Redis 中的数据,甚至迁移消息队列都可以使用这种方式,在实际的工作中可以拿来使用。其具备如下特点:

  • 优点:迁移的过程可以随时回滚,将迁移的风险降到了最低
  • 缺点:迁移时间周期比较长,且由于要实现“双写”机制,对应用有一定的改造成本

(2)双写方案细节
初始化目标库数据

​ 在构建好目标库之后,首先需要借助工具将源库的数据同步到目标库。由于源库还关联线上服务,会有不断地业务数据写入旧库,因此不仅要将源库数据复制到目标库,还需要确保这两个库的数据是实时同步的,因此需要引入一个实时同步程序来实现两个数据库中数据的实时同步。而这个同步程序的实现方案可以有canal、binlog等多个方式。

​ 在初始化目标库之后,可以校验和修复一下数据,检验源库和目标库是否一致,如果不一致需考虑相应的补偿措施

双写配置

双写配置实现方案

​ 双写配置这一步骤实际上是对业务系统进行改造,让其支持双写机制。这个双写改造的思路大致有两个方向:侵入式和非侵入式。

  • 侵入式方案:直接修改业务代码,将目标库的写入操作嵌入其中。这种方案不仅开发工作量大,测试成本也很高,凡是修改的业务代码相应的模块功能都要重新测试以保证业务功能的正常运作。(工作量大且容易出错,一般不建议使用)

  • 非侵入式方案:结合数据库中间件提供的功能进行扩展。基于这个方案可有两个实现思路方向:

    • AOP(面向切面编程)方案:即通过拦截调用的方式对方法进行增强,将其篡改为双写模式。不同的框架可能有不同的实现思路,例如:interceptor、middleware、hook、handler、filter,该方案的关键就是捕捉增删改调用并篡改为双写模式
    • 数据库操作抽象方案:即从数据库层操作进行调整,将原来对源库的操作修改双写模式(Session、Connection、Connection Pool、Executor)

​ 但不管使用哪种方案,都必须确保预留的热切换开关在运行期间可以随时切换状态,这是一个动态切换概念。大部分场景中这个热切换开关是通过一个标记位实现,可以通过配置中心或者调用接口可以随时灵活控制该值。

双写配置注意事项

​ 无论是基于哪种方案实现双写,其都应确保如下原则:即双写操作的前提是以“源库”为主,“目标库”为辅

  • 对于单条数据的重复变更,以最新的数据为准,不能用旧数据替换了比它更新的数据;

  • 双写时,只有当老库执行写操作成功,才会对新库执行操作;

  • 新库执行写操作失败不能影响旧库的写操作成功的结果;

双写服务上线

​ 需注意此处上线双写服务的同时要下线数据同步程序,但实际场景中很难做到完全无缝操作,可能会出现数据不一致的情况,主要考虑有以下两方面的原因:

  • 停止同步程序和开启双写服务这两个过程很难做到无缝衔接
  • 双写的策略无法保证新旧库强一致

​ 因此此处还需要引入一个【对比/补偿程序】,这个程序用于检查源库和目标库的数据是否一致,如果不一致则还需进行补偿机制。

​ 开启双写服务后,还需要稳定运行一段时间,在这个期间需要不断地进行检查,确认是否出现数据不一致的情况,可通过定时器完成该操作

如果双写服务运行期间出现问题,可以随时切换到只写源库,只要确保源库正常运行即可

如何实现【对比/补偿程序】?

​ 在上面的整个切换过程中,如何实现这个对比和补偿程序,是整个这个切换设计方案中的一个难点。这个对比和补偿程序的难度在于,要对比的是两个都在随时变换的数据库中的数据。这种情况下,并没有类似复制状态机这样理论上严谨实际操作还很简单的方法来实现对比和补偿。但还是可以根据业务数据的实际情况,来针对性地实现对比和补偿,经过一段时间,把新旧两个数据库的差异,逐渐收敛到一致。一般对数据的补偿场景有如下几种情况:

  • 针对时效性强且确定下来不会再变动的数据(例如订单)

​ 像订单这类时效性强的数据,是比较好对比和补偿的。因为订单一旦完成之后,就几乎不会再变了,那对比和补偿程序,就可以依据订单完成时间,每次只对比这个时间窗口内完成的订单。补偿的逻辑也很简单,发现不一致的情况后,直接用旧库的订单数据覆盖新库的订单数据就可以了。基于此,切换双写期间,少量不一致的订单数据,等到订单完成之后,会被补偿程序修正。后续双写的时候,只要没有出现新库频繁写入失败的情况,就可以保证两个库的数据完全一致。

  • 针对一般场景下可能经常变动的数据,有更新时间(例如商品数据等一些基本信息数据项)

​ 比较麻烦的是更一般的情况,比如像商品信息这类数据,随时都有可能会变化。如果说数据上有更新时间,那对比程序可以利用这个最近更新时间,每次在旧库取一个更新时间窗口内的数据,去新库上找相同主键的数据进行对比,发现数据不一致,还要对比一下更新时间。如果新库数据的更新时间晚于旧库数据,那可能是对比期间数据发生了变化,这种情况暂时不要补偿,放到下个时间窗口去继续对比。另外,时间窗口的结束时间,不要选取当前时间,而是要比当前时间早一点儿,比如 1 分钟前,避免去对比正在写入的数据。

  • 针对一般场景(通用的补偿策略)

​ 如果数据连时间戳也没有,那只能去旧库读取 Binlog,获取数据变化,然后去新库对比和补偿。

​ 有一点需要说明的是,上面这些方法如果严格推敲,都不是百分之百严谨的,都不能保证在任何情况下,经过对比和补偿后,新库的数据和旧库就是完全一样的。但是,在大多数情况下,这些实践方法还是可以有效地收敛新旧两个库的数据差异,可以酌情采用

读、写配置先后切换

读配置切换

​ 当数据验证通过(确认源库和目标库在运行一段时间后是一直保持同步的),则可考虑慢慢将源库的读流量切换到目标库,考虑到一次性切换可能会出现一些未知的问题,因此可以采用类似灰度发布的方式,通过灰度切换的形式进行切换(例如:0->10%->50%->100%)

如果读配置切换期间出现任何问题,可以随时切换回源库

写配置切换

​ 收尾工作:待程序再稳定运行一段时间,则可以停掉【对比/补偿程序】,将写配置切换到只写目标库,完成该步骤则可摘掉源库(下线源库)。

需注意的是,整个过程只有这个步骤是不可逆的。因为这个步骤已经将对源库的写操作取消,如果在这个过程中出现问题,且期间有业务不断请求写入源库的话,则此时会很难回退到源库版本

继续思考一个问题?能否做到这一步也能进行回退?

​ 实际上当前步骤不可回退的原因主要是因为源库和目标库的数据版本已经不一致了,且这个过程中不断有新的业务数据写入。如果希望它也能够切换回旧库版本,类似地采用反向思维,将对比/补偿程序反过来,用【目标库】的数据去补偿【旧库】的数据,以此支持回退旧库。

(3)双写方案的另一个思路

​ 结合上述步骤拆解分析,可以看到上述方案是先做目标库的初始化和实时同步,然后再上线双写服务(上线的同时下线同步程序), 那么在这一过程中就可能会出现数据不一致的场景,需要执行相应的补偿措施来达到数据完全一致。实际上也可以考虑通过调整操作步骤和同步思路来进行改造,步骤如下:

  • 【1】双写:实现增量数据同步
  • 【2】历史数据同步
  • 【3】定时任务校验数据一致性并修复
  • 【4】灰度放量,异常时可回滚
  • 【5】功能全量,完成平滑迁移

image-20240711204926260

​ 其和上述介绍的双写方案不同之处在于对源库的同步时机,可以结合下述图示对比理解

image-20240711194728704

image-20240711194758225

​ 基于方案(1)的思路,其数据一致性被破坏的原因在于两个方面:

  • 同步程序和双写程序无法做到完全无缝切换
  • 双写的策略无法保证新旧库强一致

​ 基于方案(2)的思路,也需要考虑该方案对数据一致性的影响,下述分别从增删改三个方面拆解操作对数据的影响,以及应该如何处理以确保数据一致性

  • 新增:新增数据,通过双写直接写入源库、目标库
  • 修改/删除:此处修改/删除操作都是基于对已有数据的操作,因此要考虑其操作的数据生成的时间节点
    • 如果数据是在双写引入后生成,则直接通过双写程序同步更新即可
    • 如果数据是在双写引入前就存在(说明是源库的历史数据部分),需要考虑当前数据是否已经迁移到目标库
      • 如果数据已经迁移到目标库中,则正常通过双写程序同步更新即可
      • 如果数据还没迁移到目标库中,此时双写程序执行会发现“异常情况”:(有可能程序做了限定,会检查数据是否存在,不存在则抛出异常,基于这种场景就会触发异常),下述考虑正常执行的情况分析
        • 删除操作:源库会正常删除该数据(影响行数1);目标库正常执行(影响行数0),后续同步到该条数据的节点时源库已经删除了这条数据,目标库自然也不会同步不存在的数据,因此综合来看该场景并不会破坏数据的一致性
        • 修改操作:源库会正常修改该数据(影响行数1);目标库正常执行(影响行数0),后续同步到该条数据的节点时源库已经修改了这条数据,目标库相应同步修改后的数据,因此综合来看该场景并不会破坏数据的一致性

​ 综合上述场景分析来看,这些操作在理想情况下好像并不会破坏数据的一致性,但是却存在时间差攻击的可能性。例如在数据同步的过程中同时恰好修改了该数据,那么就可能出现数据一致性被破坏的问题,参考下述步骤说明:(将迁移步骤拆分为两步:【1】从源库获取数据;【2】将数据同步到目标库)

时间操作操作结果
T1迁移工具要修改数据A,执行了步骤【1】迁移工具拿到了数据A的旧数据oldValue
T2用户请求修改数据A:执行双写源库中数据A被修改为newValue;目标库无影响
T3迁移工具继续执行步骤【2】迁移工具将数据A的oldValue插入目标库

​ 基于上述修改操作过程分析,修改操作存在数据不一致的可能(源库数据被更新,但目标库同步的还是源库的旧数据),为了解决这个更新问题,可以通过调整策略的方式来解决修改操作的导致数据不一致性的问题。解决方案:将对新库的更新操作调整为新增操作(带主键的新增),基于此来分析修改操作。

​ 当对未迁移的数据进行更新时,源库正常更新,目标库执行新增操作(主键以确保数据一致性),则此时它们已经是相同的两条记录了。等到同步到该数据节点的时候,会发现目标库中已经存在了该数据,此时进一步对比源库数据的更新时间和目标库中数据的更新时间发现是相同的,也不会执行替换操作,所以数据的一致性就不会受到破坏。

​ 类似地,继续分析删除操作。

时间操作操作结果
T1迁移工具要迁移数据A,执行了步骤【1】迁移工具拿到了数据A
T2用户请求删除数据A:执行双写源库中数据A被删除;目标库无影响
T3迁移工具继续执行步骤【2】迁移工具将数据A插入目标库

​ 基于上述删除操作过程分析,删除操作存在数据不一致的可能(源库数据被删除,目标库却同步了一条“不存在”的数据)。因此,为了保证数据的一致性,切库之前,新库和老库的数据校验是必要的

​ 通过定时任务校验数据一致性并修复,其实现核心为写一个数据校验和修复的数据校验的定时任务,将旧库和新库中的数据进行比对,完全一致则符合预期;如果出现某种极端情况下导致的老库新库数据不一致情况,则以旧库中的(最新的)数据为准。通过定时任务做多轮新老库的数据校验,比对新老库每个表的每条数据,接着如果有不一样的,按照以最新数据为准的原则,重新从老库读数据再次写新库,直到两个库的**数据追平,**完全一致为止。

​ 待程序稳定运行一段时间之后,便可慢慢逐渐灰度,慢慢放量,直到所有的请求都走目标库之后,迁移工作基本完成,最后再进行双写程序的收尾工作,将写操作切换到“只写目标库”即可完成所有的迁移内容。

(4)双写方案的扩展问题
数据一致性问题

如果在双写过程中,写入源表成功了,但是写入目标表失败了,该怎么办? =》 不管

​ 首先这个场景并不违背双写的原则,其次双写引入后有相应的数据校验和恢复机制,可以通过这个机制来补偿目标表写入失败的问题。

​ 如果要考虑解决方案,可以考虑通过消息队列的方式来进行补偿,但是难以确定被影响的行。

主键问题

如果在源表中使用的是自增主键,那么在双写的时候写入目标表要不要写入主键? =》

​ 需要在写入源表的时候拿到自增主键,然后写入目标表的时候设置好主键。因为实际场景中并不能确保目标表自增的主键和源表自增的主键是同步的(同一个值),因此为了确保源表数据和目标表数据完全一致,需要在源表插入的时候拿到自增主键的值,然后用这个值作为目标表插入的主键。

增量校验和数据修复

​ 增量校验基本上就是一边保持双写,一边校验最新修改的数据,如果不一致,就要进行修复。它的核心是以旧库数据为基础,校验并更新新库数据

有两种实现方案:

  • 方案1:利用更新时间戳,比如说 update_time 这种列;=》定时查询每一张表,然后根据更新时间戳来判断某一行数据有没有发生变化
  • 方案2:利用 binlog(相比之下 binlog 更加高级一点)

方案1:利用更新时间戳

​ 其核心是定时查询每一张表,然后根据更新时间戳来判断某一行数据有没有发生变化。但是这种方案的使用有两个前提条件:

  • 条件1:所有的表都是有更新时间戳
  • 条件2:所有的表都是软删除

​ 条件1好理解,本质是就是要根据更新时间戳进行判断;条件2软删除则是针对删除场景去理解:如果是硬删除场景下,无法发现目标库删除失败的问题?

​ 因为检验都是以源表为基础进行遍历,如果是硬删除操作,则源表中的数据会被删除,因此没有校验根据,也就无法明确这条被删除的数据在目标表中是不是也被删掉了。为了解决这个问题,可以通过采用补救措施反向全量校验的方式来进行补救,即通过遍历目标表记录,判断记录在源表中是否存在,如果不存在则说明记录被删除,则目标表相应也要删除这条记录,进而达到补救的目的。

方案2:利用 binlog

​ 监听 binlog 的方案:binlog 只用于触发校验和修复这个动作,当收到 binlog 之后,先用 binlog 中的主键,去查询源表和目标表,再比较两者的数据。如果不一致,就用源表的数据去修复目标表。

思考:既然binlog中有完整的数据信息,为什么不直接用binlog中的数据,还要再查一遍呢?=》主要是为了防止时间差攻击,例如要防止 binlog 是很古老的数据,而目标表是更加新的数据这种情况

思考:既然源表和目标表都有binlog,为什么不直接对比两个binlog呢?=》这种方案理论可行,但是会遇到两个棘手的问题。该方案虽然能够进一步减轻数据库查询的压力,但是实在过于复杂,得不偿失。

  • binlog 生成存在时间差:一次双写,可能立刻就收到了源表的 binlog,但是可能过了好久才收到目标表的 binlog。反过来,先收到目标表的 binlog,隔了很久才收到源表的 binlog也一样。所以需要缓存住一端的 binlog,再等待另外一端的 binlog
  • 顺序问题:如果有两次双写操作的是同一行,那么可能先收到源表第一次的 binlog,再收到目标表第二次双写的 binlog,怎么解决这个问题呢?你只能考虑利用消息队列之类的东西给 binlog 排个序,确保 binlog 收到的顺序和产生的顺序一致
主从延迟问题

​ 在校验和修复的时候,如果读取的都是从库,则可能会遇到两种异常情况,一种是目标表主从延迟,另一种是源表主从延迟。其解决方案有两种:

  • 思路1:全部读主库,校验和修复都以主库数据为准。方式简单粗暴,但其缺点在于对主库的压力会比较大。
  • 思路2:双重校验(主从库双重校验),第一次校验的时候读从库,如果发现数据不一致,再读主库,用主库的数据再校验一次。修复的时候就只能以主库数据为准。这种方案的基本前提是,主从延迟和数据不一致的情况是小概率的,所以最终会走到主库也是小概率的。
切换双写顺序

​ 结合上述双写过程分析,可能很多情况下直接在双写的时候就直接切换到目标表单写。但是这样直接切换的风险太大了,万一出了问题都没法回滚。在两者间引入逐渐灰度的步骤,其核心也是在于提供一个过渡的阶段(提供一个回滚的余地),一旦这个过程出现问题,还可切换到源表。

3.增量迁移

​ 这种迁移方案一般应用于特殊的业务数据,这类业务数据的历史数据价值不大具备有效期属性,如用户的优惠券数据;例如有这样的一个业务场景:用户优惠券表由于历史原因未做分库分表,目前数据增长过快,查询、写入性能变差;该业务属于部门核心业务,每天都有大量的用户流量和数据写入;为了保证业务的可用性需要对优惠券大表做拆分。

针对以上业务场景,考虑用户优惠券本身的特性(存在有效期),采取以下方案:灰度期间,用户优惠券新增数据全部保存至新表中,**旧表的历史数据不迁移仅更新,**共存阶段同时查询新库和旧库数据做数据聚合,运行N个月后(旧库数据已全部业务失效)用户请求的读写全部切到新库中,旧库直接作为归档。

​ 该方案的特点就是历史数据天然的无需迁移,方案逐渐放量、无需考虑回滚,适用于解决大表拆分问题

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3