需求背景
水平拆分和垂直拆分一直是最常见的数据库优化方式,笔者所在的部门所使用的数据库一直是主从热备的架构,但数据量在一年前就已经破亿,并以飞快的增长速度不断增加。为了减小数据库的负担,提高数据库的效率,缩短查询时间,水平拆分的工作已经必不可免。分库前最重要的工作便是先对数据库进行迁移拆分,将原来的源库按照自身的业务需求和逻辑拆分成多个分库。笔者所在的部门采用的方案为基于自研客户端中间件的分片+不停机数据迁移的方式。
拆分策略
范围拆分
按照时间区间或 ID 区间来切分。例如:按日期将不同月甚至是日的数据分散到不同的库中;将 UserID 为1~9999的记录分到第一个库,10000~20000的分到第二个库,以此类推。
优点:单表大小可控;天然便于水平扩展,后期如果想对整个分片集群扩容时,只需要添加节点即可,无需对其他分片的数据进行迁移;使用分片字段进行范围查找时,连续分片可快速定位分片进行快速查询,有效避免跨分片查询的问题。
缺点:连续分片可能存在数据热点,例如按时间字段分片,有些分片存储最近时间段内的数据,可能会被频繁的读写,而有些分片存储的历史数据,则很少被查询;也有可能造成分片不均,如使用 UserID 区间切分,某些用户产生的数据远远超过别的用户时,将面临分片大小不均匀的问题
Hash 拆分
一般采用 hash 取模 mod 的切分方式,例如:将 Order 表根据 user_id 字段切分到4个库中,余数为0的放到第一个库,余数为1的放到第二个库,以此类推。这样同一个用户的数据会分散到同一个库中,如果查询条件带有分区键的字段,则可明确定位到相应库去查询。采用这样的拆分策略,数据分片相对比较均匀,不容易出现热点和并发访问的瓶颈。
一致性 Hash与哈希槽
普通 Hash 后取模的方式在从库需要再次数据迁移时需要所有从库都打乱重新映射,迁移效率不高问题,可采用mod 2^n 的取模方式优化,虽然解决了迁移过程中效率不高的问题,但是每次新增数据库都得增加(2×原有的数据库数量),不能单个单个增加,显然不够灵活。我们将选择缩小在一致性 Hash 和哈希槽这种伸缩性强的方案之间。一致性哈希算法是分布式缓存系统中常用的算法,解决了普通余数 Hash 算法伸缩性差的问题,解决了分布式环境下机器增加或者减少时,简单的取模运算无法获取较高命中率的问题。哈希槽是在 Redis Cluster 集群方案中采用的,Redis Cluster 集群没有采用一致性哈希方案,而是采用数据分片中的哈希槽来进行数据存储与读取的。我们选择了简单高效的哈希槽方案,相比于一致性哈希需要新增多个虚拟节点来平衡负载,并且需要多一次查询 hash 后离值最近的节点,哈希槽显得更加简单高效。我们将所有数据分为1024个槽,不管以后系统将来如何发展,1024个槽将保持不变,就算考虑将来最极端的情况,1024个槽分别都对应了1024个库后无法再新增槽,我们还可对每个槽的数据进行再次分库迁移拆分。采用分槽的形式后,将数据和数据库实例分离开来,数据与槽绑定,数据在哪个槽是永恒不变的,而数据库实例可以通过配置动态得与槽绑定,将来要扩容只需重新设定每个数据库实例对应了哪些槽,经过人工计算平均划分即可实现分片均匀。
有关于一致性 Hash 算法的可见这篇博文:一致性Hash(Consistent Hashing)原理剖析
水平拆分带来的问题
而水平拆分必然会带来一些问题,例如:
- 原本依赖于数据库自增 id 的主键在分库的场景下,多个分库下 id 做不到全局唯一;
- 引入了分布式事务的问题,如果同一个逻辑事务里,涉及的数据跨多个数据库实例,本地事务将不生效;
- 需要将原本的源库做拆分迁移,如果数据量很大的情况下,不停机的数据迁移也将成为一个难点;
- 引入了跨库聚合的问题,分库分表后,表之间的关联操作将受到限制,就无法 join 位于不同数据库实例的表,结果原本一次查询能够完成的业务,可能需要多次查询才能完成;
- 同时当拆分后的数据库再次达到瓶颈,如何扩容也成为一个问题。
当然对应的是一些解决方案:
- 全局唯一 id 的问题也有自研开发的分布式 id 生成器提供全局唯一的有序id以及其他云文档存储部门生成提供的唯一 file_id 保证。
- 由于我们的业务中,现有的所有事务都是针对同一个 file_id 的,因此基于 file_id 分库后的数据都落在同一数据库实例上,不存在跨库乃至分布式事务的问题。当然,如果有跨库事务与分库这种需求同时存在时,分布式事务将不可避免,有关于分布式事务的方案,可见我写的这篇文章:对于 MySQL 分布式事务的几个看法
- 跨库 join 的问题,首先我们应当尽量避免跨库 join 操作,这种操作因为需要中间件支持跨库查询并且跨库聚合的操作,不仅增加了中间件开发的复杂度和耦合度,而且十分没有必要。可以从以下几个方面进行优化:
3.1 字段的冗余,将本来需要跨库查询的字段冗余在一张表里;
3.2 设计表结构和分库时,需要 join 的表尽量采用同一个唯一 id 使得需要联表的数据落在同一库里;
3.3 在业务层进行数据的聚合,分别查询后自行聚合筛选;实在没有其他办法时再考虑是否让中间件支持跨库操作。
中间件的开发
当前主流的分库方案既有基于客户端中间件的,也有引入 MySQL 代理中间层。大部分公司的分库方案都是使用中间件的形式,基于中间件的方式是分库方案中最快的,没有代理中间层,仅需要客户端进行一次哈希计算,不需要经过代理便可直接操作分库节点,不需要多余的 Proxy 机器,不用考虑 Proxy 部署与高可用的维护,并且引入 Proxy 将加多一层网络延迟。基于客户端中间件的方式则需要每种语言都实现一遍客户端中间件的逻辑,维护和开发的成本较高,耦合度相较于中间层的形式来说更高。
由于我们所有的业务代码统一使用了 Golang 编写,因此并无需要重复开发客户端中间件的问题。采用自研的方式是由于我们的需求并不复杂,并不需要引入一些重量级的分库中间件。
具体的 Golang 分库中间件执行一条 sql 流程分为三个步骤,解析 sql,通过解析语法树看中间件是否支持这条 sql ,并获取 sql 中配置的分区键值或者位置(分区键即为每张表中的某一列,这一列是根据自身义务决定的,一般为主键);获取分区键的值进行 hash 映射,获取分库的 db 实例执行 sql。
大概开发思路如下,由于标准库 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 驱动打开的,无需重复从头开始造轮子。
db,err := sql.open("new_driver")
通过这种实现标准库 sql 包的驱动,从旧驱动改成新驱动,只需要改动一行代码便可轻松切换。
迁移工具的开发
介绍完中间件,下面详细得阐述下,我们的迁移工具的开发以及开发过程中遇到的一些问题:
我们决定整体方案对业务是无感知的,因此我们采用了一种无侵入的方式,迁移过程中的数据拆分的方式我们借鉴了 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`
FLUSH TABLES WITH READ LOCK
先对所有的 Tables 加锁处理
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ
设置会话隔离级别为可重复读(RR)隔离级别
START TRANSACTION /*!40100 WITH CONSISTENT SNAPSHOT */
保存一份当前所有数据的快照
UNLOCK TABLES
获取完快照即可解锁,整个过程锁定时间不超过1s
SHOW VARIABLES LIKE 'gtid\_mode' ,SHOW MASTER STATUS
保存当前快照的 binlog pos位置
后面即可使用 select * 进行全表扫描,对扫描的每一行进行 hash 分库后执行 insert,即可实现全量的分库迁移。
而至于增量复制迁移,基本原理即是将自身模拟成 MySQL 从库,进而接受主库传来的 binlog,对传来的 binlog 做解析,最终实现对每一行 hash 分库后也执行相对应的 insert/update/delete 操作即可实现增量分库迁移。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。