3

背景

前段时间因为业务需要,需要对核心库分库分表,迁移了大概有40亿数据,在此记录以便之后再来看看这种方案的优劣。

写在前面

为什么要拆库拆表?

随着公司业务快速发展,数据库中的数据量猛增,访问性能也变慢了,优化迫在眉睫。分析一下问题出现在哪儿呢? 关系型数据库本身比较容易成为系统瓶颈,单机存储容量、连接数、处理能力都有限。当单表的数据量达到1000W或100G以后,由于查询维度较多,即使添加从库、优化索引,做很多操作时性能仍下降严重。
针对生产环境中出现的这种情况,我们通常有软硬两种方式去处理,“硬”指的是在硬件方面上进行提高,即我们通常挂在嘴边的加存储、加CPU等,这种方案的成本很高(ps:有钱人忽略),并且如果瓶颈不在硬件就很难受了。”软“指的是我们在设计层去做分割,即将打表打散,将压力大的库拆分(星星之火也可以燎原的)。

常见的几种拆表方式

分库分表包括分库和分表两个部分,在生产中通常包括:垂直分库、水平分库、垂直分表、水平分表四种方式。
我们先来了解下垂直和水平的概念:

  • “垂直”通常指的是将一个表按照字段分成多表,每个表存储其中一部分字段。
  • ”水平“ 通常指的是不会改变表结构,将数据按照一定的规则划分到多处。

我们知道这个概念之后再来解释常见的四种分库分表方式:

  • 垂直分表:将一个宽表的字段按访问频次、是否是大字段的原则或者其它特定的规则拆分为多个表,这样既能使业务清晰,还能提升部分性能。拆分后,尽量从业务角度避免联查,否则性能方面将得不偿失。
  • 垂直分库:将多个表按业务耦合松紧归类,分别存放在不同的库,这些库可以分布在不同服务器,从而使访问压力被多服务器负载,大大提升性能,同时能提高整体架构的业务清晰度,不同的业务库可根据自身情况定制优化方案。但是它需要解决跨库带来的所有复杂问题。
  • 水平分库:将一个表的数据(按数据行)分到多个不同的库,每个库只有这个表的部分数据,这些库可以分布在不同服务器,从而使访问压力被多服务器负载,大大提升性能。它不仅需要解决跨库带来的所有复杂问题,还要解决数据路由的问题(数据路由问题后边介绍)。
  • 水平分表:将一个表的数据(按数据行)分到多个同一个数据库的多张表中,每个表只有这个表的部分数据,这样做能小幅提升性能,它仅仅作为水平分库的一个补充优化。

一般来说,在系统设计阶段就应该根据业务耦合松紧来确定垂直分库,垂直分表方案,在数据量及访问压力不是特别大的情况,首先考虑缓存、读写分离、索引技术等方案。若数据量极大,且持续增长,再考虑水平分库水平分表方案。这里我们还要考虑一个问题,对于已有的业务我们如何从原来的单库单表平滑无损的去迁移到新的分片库分片表呢?(读者可以思考下这个问题,这也是本篇的重点)。

分库分表后带来的问题

  • 主键 id 唯一性。
  • 分布式事务问题:在执行分库分表之后,由于数据存储到了不同的库上,数据库事务管理出现了困难。
  • 跨库跨表的 join 问题:在执行了分库分表之后,难以避免会将原本逻辑关联性很强的数据划分到不同的表、不同的库上,这时,表的关联操作将受到限制,我们无法join位于不同分库的表。

前期准备

收敛所有直连DB的情况

为什么会有这一项呢?因为在我们的业务代码中,很难避免的因为种种的问题导致有些业务场景中是直连的DB的,如果是自己团队的还好,如果不是就会造成不可预知的后果。这部分工作如果是工程相对规范且DB监控做的比较好的情况下还比较好排查,否则将很难去梳理全面,所以监控和规范不是没有用的,如果你说没有,拆库拆表试试吧。
收敛除了维护比较好维护之外,业务方对于自己的数据掌控度也比较大,所有的数据写入与读取都有明确的记录(想象一下自己维护的数据不知道被谁偷偷改了的烦恼)。

分布式ID生成器

我们采用分库分表,最佳的实现方式是在不同的分片表中使用全局唯一id。到这有的同学会问了,“为什么呢?我即使分库分表后,每条数据拆分到每个表中,由于MySQL数据库主键自增的缘故,它们的ID在各个表是独立的,查询的时候 select * from 表名,也能够查询出来对应的信息,欸,这也不需要唯一性ID啊“。但我们换个角度考虑,如:电商场景订单量巨大,订单数据存入数据库,肯定需要对数据库进行了分库分表,欸,你有没有发现每个人的订单号肯定都是不同的,这就体现了全局唯一性ID,当然同学又会说,我再开一个字段去单独存储这个订单id不行么?这就是要刚我啊,少侠手下留情。详见:ID生成器详解

梳理ID类型变更对依赖方的影响

既然我们采用了全局唯一id,我们就不得不考虑对依赖方的影响,大概有以下几点:

  • 下游依赖有没有对库表的id进行强制转换类型,例如强制转化为int32。
  • 前端是否有直接读取整形的ID,因为Javascript的数字存储使用了IEEE 754中规定的双精度浮点数数据类型,而这一数据类型能够安全存储 -(2^53-1) 到 2^53-1 之间的数值(包含边界值)。JSON 是 Javascript 的一个子集,所以它也遵守这个规则。而int64 类型的数值范围是 -(2^63-1) 到 2^63-1。使用int64 类型json 对于超出范围的数字,会出现解析错误的情况。

梳理对于binlog的依赖

因为我们需要进行分库分表操作,所以对于原有的依赖老库binlog的地方也要进行相应改造。

梳理分片键是否都可以获取到

这个可以根据自己的业务看是否需要处理

SOP

一定要制定详细的SOP且要严格执行

方案设计(单库单表到分片库表的切换)

方案一

阶段一:创建新库并同步老库数据到新库

  1. 根据binlog同步数据

阶段二:校验数据一致性

  1. 校验binlog同步数据的一致性

阶段三:业务切流量

  1. 业务代码打开开关,切换为读写新库并放量

方案二

阶段一:创建新库并同步数据到新库

阶段二:停服&&校验数据一致性

阶段三:业务切流量

方案三

阶段一: 双写阶段

双写分为几种场景,insert&&update&&delete
  1. 情况一:正常情况(没有失败,更新的记录存在新老库之中)

    1. Insert 业务方双写新库老库: 新库老库正常插入数据,因为是分库分表,所以采用全局唯一id来代替原来的自增id,历史数据保留原有信息
    2. Update 对于新老库进行更新(只针对新库有记录)
    3. Delete 现有业务暂无硬删除,忽略
  2. 情况二: 异常情况(老库失败,新库失败)

    1. 老库失败(老库Insert失败,Update失败)

      1. 因为双写是串行的,所以即使失败了也不需要考虑
    2. 新库失败(新库Insert库失败,新库Update失败)

      1. 新库Insert失败,记录写库信息,发送失败补偿消息,进行数据修复
      2. 新库Update失败,记录写库信息,发送失败补偿消息,进行数据修复
    3. 情况三:失败消息重试

      1. 对于新库写库失败的数据,进行重试,对比新老库中的数据,相同则跳过,不相同用老库数据覆盖更新新库数据(加锁),如果此时仍然失败,重新推送到消息队列
      2. 对于新库还未存在的数据进行更新时,根据更新信息从老库读取数据,然后插入到新库,此过程对新老库记录加锁保证数据的一致性

阶段二:迁移数据 (脚本)

同步老库数据到新库,采用insert ignore防止新老数据冲突

阶段三:数据一致性保证

分批次对比新老库数据(脚本)

  1. 相同, 跳过
  2. 不同,老库覆盖新库(仅老库加锁即可)

阶段四: 切读

  1. 灰度放量新库读
  2. 全量切读

阶段五: 迁移下游依赖

主要是binlog依赖

阶段六: 停写老库

阶段七: 回收资源&&清理开关

方案四

阶段一:创建新库并同步数据到新库(开启老库到新库同步数据)

阶段二:校验数据一致性

阶段三:停服Rename 毫秒级别

阶段四:业务切流量至新库 先切读 再切写

阶段五:开启新库到老库增量数据同步,保证新老数据增量是一致的

方案优缺点简单比较

方案一方案二方案三方案四
优点操作相对简单操作相对简单1. 整个过程不停服平滑迁移且无数据损失 2. 任何阶段零风险回滚。3. 下游有充裕的时间做迁移。4. 方便读新库灰度。1. 操作相对简单2. 下游有充裕的时间做迁移。3. 业务侵入较少。
缺点1. 切流量至新库之后若不符合预期,期间产生的数据均为问题数据且问题数据无法快速恢复。2. 依赖下游迁移进度。3. 切换过程中写入失败的数据会丢失。4. 上线后验证时间短。5. 回滚后再次上线代价较大,以上几个问题有重复出现的风险。同11. 业务侵入较大。2. 双写影响接口性能1. 在切换过程中服务不可用2. 验证时间短3. 老库到新库写切换不干净会导致数据时序问题(老库写和新库写操作同一条数据时)4. 回滚后再次上线后,以上几个问题有重复出现的风险。

总结

在合适的业务场景采用不同的方案才是最好的。

关注我们

欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~
image.png


NoSay
449 声望544 粉丝