需求背景
水平拆分和垂直拆分一直是最常见的数据库优化方式,笔者所在的部门所使用的数据库一直是主从热备的架构,但数据量在一年前就已经破亿,并以飞快的增长速度不断增加。为了减小数据库的负担,提高数据库的效率,缩短查询时间,水平拆分的工作已经必不可免。
水平拆分带来的问题
而水平拆分必然会带来一些问题,例如原本依赖于数据库自增 id 的主键在分库的场景下,多个分库下 id 做不到全局唯一;引入了分布式事务的问题,如果同一个逻辑事务里,涉及的数据跨多个数据库实例,本地事务将不生效;需要将原本的源库做拆分迁移,如果数据量很大的情况下,不停机的数据迁移也将成为一个难点;引入了跨库聚合的问题,分库分表后,表之间的关联操作将受到限制,就无法 join 位于不同数据库实例的表,结果原本一次查询能够完成的业务,可能需要多次查询才能完成。同时当拆分后的数据库再次达到瓶颈,如何扩容也成为一个问题。
当前主流的分库方案既有基于客户端中间件的,也有引入 MySQL 代理中间层。大部分公司的分库方案都是使用中间件的形式,基于中间件的方式是分库方案中最快的,没有代理中间层,仅需要客户端进行一次哈希计算,不需要经过代理便可直接操作分库节点,不需要多余的 Proxy 机器,不用考虑 Proxy 部署与维护。但是这样需要每种语言都实现一遍客户端中间件的逻辑,维护和开发的成本较高;耦合度相较于中间层的形式来说更高,灵活度不够高。
分库前最重要的工作便是先对数据库进行迁移拆分,将原来的源库按照自身的业务需求和逻辑拆分成多个分库。笔者所在的部门采用的方案为基于自研客户端中间件的分片+不停机数据迁移的方式。由于我们所有的业务代码统一使用了 Golang 编写,因此并无需要重复开发客户端中间件的问题。采用自研的方式是由于我们的需求并不复杂,并不需要引入一些重量级的分库中间件。全局唯一 id 的问题也有自研开发的分布式 id 生成器提供全局唯一的有序id以及其他云文档存储部门生成提供的唯一 file_id 保证。由于我们的业务中,现有的所有事务都是针对同一个 file_id 的,因此基于 file_id 分库后的数据都落在同一数据库实例上,不存在跨库乃至分布式事务的问题。当然,如果有跨库事务与分库这种需求同时存在时,分布式事务将不可避免,有关于分布式事务的方案,可见我写的这篇文章:对于 MySQL 分布式事务的几个看法。跨库 join 的问题,首先我们应当尽量避免跨库 join 操作,这种操作因为需要中间件支持跨库查询并且跨库聚合的操作,不仅增加了中间件开发的复杂度和耦合度,而且十分没有必要。可以从以下几个方面进行优化:1.字段的冗余,将本来需要跨库查询的字段冗余在一张表里;2.设计表结构和分库时,需要 join 的表尽量采用同一个唯一 id 使得需要联表的数据落在同一库里;3.在业务层进行数据的聚合,分别查询后自行聚合筛选;实在没有其他办法时再考虑是否让中间件支持跨库操作。
中间件的开发
具体的 Golang 中间件开发思路如下,由于标准库 sql 包中已经维护了一套非常完善的连接池机制以及数据操作流程,并且有非常优秀的 MySQL 驱动包 go-mysql-driver,我们决定对 go-mysql-driver 包进行封装,我们的中间件是基于 go-mysql-driver 实现 sql 包的一套驱动,对 sql 进行解析,根据开始时的配置,拿取 sql 中的分区键的值( sql 中已经包含分区键的值,即如 select from user where id = '1')或者是分区键所处的位置( sql 中分区键的值为?,即如 select from user where id = ?),并对最终获取到的分区键的值做hash计算,计算出映射的槽,并操作槽对应的 db 实例执行这行 sql,db 实例是使用 go-mysql-driver 驱动打开的,无需重复从头开始造轮子。通过这种实现标准库 sql 包的驱动,从旧驱动改成新驱动,只需要改动一行代码便可轻松切换。
迁移工具的开发
介绍完中间件,下面详细得阐述下,我们的迁移工具的开发以及开发过程中遇到的一些问题:
当前互联网最常用 MySQL 迁移方式有两种,第一种为对业务无侵入的方案,在 MySQL 资源层操作,主要方式为全量迁移后的基于 Binlog 增量迁移后,在 Binlog 追平后切换。第二种为在业务层面上操作的数据库双写方案,大致经历以下过程:开启先写A库再写B库(A为旧库) -> 校验,并等 A、B库 Binlog 差距为0时 -> 开启读B库开关 -> 校验 -> 开启写B库再写A库开关 -> 校验 -> 开启只写B库开关。
由于我们决定整体方案对业务是无感知的,因此我们采用了第一种无侵入的方式,迁移过程中的数据拆分的方式我们借鉴了 Redis 扩容的方式,采用分槽的概念对所有数据根据配置进行分槽映射,并根据每个表设立的分区键与总槽数的 hash 值决定每条数据的 slot 节点。此处采用分槽的设计目的是为了当数据容量过大时可以进行灵活伸缩,而不管是在初次迁移过程中,还是为了以后的扩容迁移,开发一套在线迁移工具势在必行。
由于在迁移的过程中,必须加入自身的业务逻辑,如分库分表等,因此类似于 MySQLDumper 的这种全量迁移工具我们无法使用,我们采用的是类 MySQLDumper 不锁表的全量分库迁移+建立主从复制的增量复制迁移/基于 Binlog 的增量复制迁移。由于我们使用的云数据库的特殊性,无法直接与源库建立主从复制关系,我们采用了基于 Binlog 增量复制的方案。
如何实现全量迁移呢?将 MySQLDumper 过程中执行的命令拿出去分析即可得知
FLUSH /*!40101 LOCAL */ TABLES
FLUSH TABLES WITH READ LOCK
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ
START TRANSACTION /*!40100 WITH CONSISTENT SNAPSHOT */
SHOW VARIABLES LIKE 'gtid\_mode'
SHOW MASTER STATUS
UNLOCK TABLES
show create table `your_table`
SELECT /*!40001 SQL_NO_CACHE */ * FROM `your_table`