一文读懂数据库优化之分库分表
参考文章:一文读懂数据库优化之分库分表 (opens new window)
本文从 5W1H 角度介绍了分库分表手段,其在解决如 IO 瓶颈、读写性能、物理存储瓶颈、内存瓶颈、单机故障影响面等问题的同时也带来如事务性、主键冲突、跨库 join、跨库聚合查询等问题。anyway,在综合业务场景考虑,正如缓存的使用一样,本着非必须勿使用原则。如数据库确实成为性能瓶颈时,在设计分库分表方案时也应充分考虑方案的扩展性,或者考虑采用成熟热门的分布式数据库解决方案,如 TiDB。
阅读此文你将了解:
- 什么是分库分表以及为什么分库分表
- 如何分库分表
- 分库分表常见几种方式以及优缺点
- 如何选择分库分表的方式
# 1. 数据库常见优化方案
对于后端程序员来说,绕不开数据库的使用与方案选型,那么随着业务规模的逐渐扩大,其对于存储的使用上也需要随之进行升级和优化。
随着规模的扩大,数据库面临如下问题:
- 读压力:并发 QPS、索引不合理、SQL 语句不合理、锁粒度
- 写压力:并发 QPS、事务、锁粒度
- 物理性能:磁盘瓶颈、CPU 瓶颈、内存瓶颈、IO 瓶颈
- 其他:宕机、网络异常
面对上述问题,常见的优化手段有:
索引优化、主从同步、缓存、分库分表每个技术手段都可以作为一个专题进行讲解,本文主要介绍分库分表的技术方案实现。
# 2. Background
# 2.1 什么是分库分表?
对于阅读本文的读者来说,分库分表概念应该并不会陌生,其拆开来讲是分库和分表两个手段:
- 分表:将一个表中的数据按照某种规则分拆到多张表中,降低锁粒度以及索引树,提升数据查询效率。
- 分库:将一个数据库中的数据按照某种规则分拆到多个数据库中,以缓解单服务器的压力(CPU、内存、磁盘、IO)。
# 2.2 为什么需要分库分表?
- 性能角度:CPU、内存、磁盘、IO 瓶颈
- 随着业务体量扩大,数据规模达到百万行,数据库索引树庞大,查询性能出现瓶颈。
- 用户并发流量规模扩大,由于单库(单服务器)物理性能限制也无法承载大流量。
- 可用性角度:单机故障影响面
- 如果是单库,数据库宕机会导致 100%服务不可用,N 库则可以将影响面降低 N 倍。
# 2.3 分库分表带来的问题
# 1)事务性问题
分库可能导致执行一次事务所需的数据分布在不同服务器上,数据库层面无法实现事务性操作,需要更上层业务引入分布式事务操作,难免会给业务带来一定复杂性,那么要想解决事务性问题一般有两种手段:
- 方案一:在进行分库分表方案设计过程中,从业务角度出发,尽可能保证一个事务所操作的表分布在一个库中,从而实现数据库层面的事务保证。
- 方案二:方式一无法实现的情况下,业务层引入分布式事务组件保证事务性,如事务性消息、TCC、Seata 等分布式事务方式实现数据最终一致性。
# 2)主键唯一性问题
在数据库表设计时,经常会使用自增 ID 作为数据主键,这就导致后续在迁库迁表、或者分库分表操作时,会因为主键的变化或者主键不唯一产生冲突,要解决主键不唯一问题,有如下方案:
- 方案一:自增 ID 做主键时,设置自增步长,采用等差数列递增,避免各个库表的主键冲突。但是这个方案仍然无法解决迁库迁表、以及分库分表扩容导致主键 ID 变化问题
- 方案二:主键采用全局统一 ID 生成机制:如 UUID、雪花算法、数据库号段等方式。
# 3)跨库多表 join 问题
首先来自大厂 DBA 的建议是,线上服务尽可能不要有表的 join 操作,join 操作往往会给后续的分库分表操作带来各种问题,可能导致数据的死锁。可以采用多次查询业务层进行数据组装(需要考虑业务上多次查询的事务性的容忍度)
# 4)跨库聚合查询问题
分库分表会导致常规聚合查询操作,如 group by,order by 等变的异常复杂。需要复杂的业务代码才能实现上述业务逻辑,其常见操作方式有:
- 方案一:赛道赛马机制,每次从 N 个库表中查询出 TOP N 数据,然后在业务层代码中进行聚合合并操作。
- 方案二:可以将经常使用到 groupby、orderby 字段存储到一个单一库表(可以是 Redis、ES、MySQL)中,业务代码中先到单一表中根据查询条件查询出相应数据,然后根据查询到的主键 ID,到分库分表中查询详情进行返回。2 次查询操作难点会带来接口耗时的增加,以及极端情况下的数据不一致问题。
# 2.4 什么是好的分库分表方案?
- 满足业务场景需要:根据业务场景的不同选择不同分库分表方案:比如按照时间划分、按照用户 ID 划分、按照业务能力划分等
- 方案可持续性:何为可持续性?其实就是:业务数据量级和流量量级未来进一步达到新的量级的时候,我们的分库分表方案可以持续灵活扩容处理。
- 最小化数据迁移:扩容时一般涉及到历史数据迁移,其扩容后需要迁移的数据量越小其可持续性越强,理想的迁移前后的状态是:同库同表 > 同表不同库 > 同库不同表 > 不同库不同表
- 数据偏斜:数据在库表中分配的均衡性,尽可能保证数据流量在各个库表中保持等量分配,避免热点数据对于单库造成压力。
- 最大数据偏斜率:(数据量最大样本 - 数据量最小样本)/ 数据量最小样本。一般来说,如果我们的最大数据偏斜率在 5%以内是可以接受的。
# 3. 如何分库分表
# 3.1 垂直拆分
操作:将一张 user 表拆分成简要信息表、扩展表
# 1)垂直拆表
- 即大表拆小表,将一张表中数据不同”字段“分拆到多张表中,比如商品库将商品基本信息、商品库存、卖家信息等分拆到不同库表中。
- 考虑因素有将不常用的,数据较大,长度较长(比如 text 类型字段)的拆分到“扩展表“,表和表之间通过”主键外键“进行关联。
- 好处:降低表数据规模,提升查询效率,也避免查询时数据量太大造成的“跨页”问题。
# 2)垂直拆库
- 垂直拆库则在垂直拆表的基础上,将一个系统中的不同业务场景进行拆分,比如订单表、用户表、商品表。
- 好处:降低单数据库服务的压力(物理存储、内存、IO 等)、降低单机故障的影响面
# 3.2 水平拆分
操作:将总体数据按照某种维度(时间、用户)等分拆到多个库中或者表中,典型特征是不同的库和表结构完全一致,如订单按照(日期、用户 ID、区域)分库分表。
# 1)水平拆表
- 将数据按照某种维度拆分为多张表,但是由于多张表还是从属于一个库,其降低锁粒度,一定程度提升查询性能,但是仍然会有 IO 性能瓶颈。
# 2)水平拆库
- 将数据按照某种维度分拆到多个库中,降低单机单库的压力,提升读写性能。
# 4. 常见的水平拆分手段
# 4.1 range 分库分表
顾名思义,该方案根据数据范围划分数据的存放位置。
# 4.1.1 思路:时间范围分库分表
举个最简单例子,我们可以把订单表按照年份为单位,每年的数据存放在单独的库(或者表)中。
时下非常流行的分布式数据库:TiDB 数据库,针对 TiKV 中数据的打散,也是基于 Range 的方式进行,将不同范围内的[StartKey,EndKey)分配到不同的 Region 上。
缺点:
- 需要提前建库或表。
- 数据热点问题:当前时间的数据会集中落在某个库表。
- 分页查询问题:涉及到库表中间分界线查询较复杂。
实例:交易系统流水表则是按照天级别分表。
# 4.2 hash 分库分表
hash 分表是使用最普遍的使用方式,其根据“主键”进行 hash 计算数据存储的库表索引。原理可能大家都懂,但有时拍脑袋决定的分库分表方案可能会导致严重问题。
# 4.2.1 思路一:独立 hash
对于分库分表,最常规的一种思路是通过主键计算 hash 值,然后 hash 值分别对库数和表数进行取余操作获取到库索引和表索引。比如:电商订单表,按照用户 ID 分配到 10 库 100 表中。
const (
// DbCnt 库数量
DbCnt = 10
// TableCnt 表数量
TableCnt = 100
)
// GetTableIdx 根据用户 ID 获取分库分表索引
func GetTableIdx(userID int64) (int64, int64) {
hash := hashCode(userID)
return hash % DbCnt, hash % TableCnt
}
2
3
4
5
6
7
8
9
10
11
12
上述是伪代码实现,大家可以先思考一下上述代码可能会产生什么问题?
答案是:数据倾斜问题。
# 4.2.2 思路二:统一 hash
思路一中,由于库和表的 hash 计算中存在公共因子,导致数据偏斜问题,那么换种思考方式:10 个库 100 张表,一共 1000 张表,那么从 0 到 999 排序,根据 hash 值对 1000 取余,得到 [0,999] 的索引,似乎就可以解决数据偏斜问题:
// GetTableIdx 根据用户 ID 获取分库分表索引
// 例子:1123011 -> 1,1
func GetTableIdx(userID int64) (int64, int64) {
hash := hashCode(userID)
slot := DbCnt * TableCnt
return hash % slot % DbCnt, hash % slot / DbCnt
}
2
3
4
5
6
7
上面会带来的问题?
比如 1123011 号用户,扩容前是 1 库 1 表,扩容后是 0 库 11 表:
# 4.2.3 思路三:二次分片法
思路二中整体思路正确,只是最后计算库序号和表序号的时候,使用了库数量作为影响表序号的因子,导致扩容时表序号偏移而无法进行。事实上,我们只需要换种写法,就能得出一个比较大众化的分库分表方案。
func GetTableIdx(userId int64){
//① 算Hash
hash := hashCode(userId)
//② 分片序号
slot := hash % (DbCnt * TableCnt)
//③ 重新修改二次求值方案
dbIdx := slot / TableCnt
tblIdx := slot % TableCnt
return dbIdx, tblIdx
}
2
3
4
5
6
7
8
9
10
11
从上述代码中可以看出,其唯一不同是在计算库索引和表索引时,采用 TableCnt 作为基数(注:扩容操作时,一般采用库个数 2 倍扩容),这样在扩容时,表个数不变,则表索引不会变。
# 4.2.4 思路四:基因法
由思路二启发,我们发现案例一不合理的主要原因,就是因为库序号和表序号的计算逻辑中,有公约数这个因子在影响库表的独立性。那么我们是否可以换一种思路呢?我们使用相对独立的 Hash 值来计算库序号和表序号呢?
func GetTableIdx(userID int64)(int64,int64){
hash := hashCode(userID)
return atoi(hash[0:4]) % DbCnt, atoi(hash[4:])%TableCnt
}
2
3
4
这也是一种常用的方案,我们称为基因法,即使用 hash 值中的某些基因(例如前四位)作为库的计算因子,而使用另外一些基因作为表的计算因子。
在使用基因法时,要主要计算 hash 值的片段保持充分的随机性,避免造成严重数据偏斜问题。
# 4.2.5 思路五:关系表冗余
按照索引的思想,可以通过分片的键和库表索引建立一张索引表,我们把这张索引表叫做“路由关系表”。每次查询操作,先去路由表中查询到数据所在的库表索引,然后再到库表中查询详细数据。同时,对于写入操作可以采用随机选择或者顺序选择一个库表进入写入。
那么由于路由关系表的存在,我们在数据扩容时,无需迁移历史数据。同时,我们可以为每个库表指定一个权限,通过权重的比例调整来调整每个库表的写入数据量。从而实现库表数据偏斜率调整。
此种方案的缺点是每次查询操作,需要先读取一次路由关系表,所以请求耗时可能会有一定增加。本身由于写索引表和写库表操作是不同库表写操作,需要引入分布式事务保证数据一致性,极端情况可能带来数据的不一致。
且索引表本身没有分库分表,自身可能会存在性能瓶颈,可以通过存储在 redis 进行优化处理。
# 4.2.6 思路六:分段索引关系表
思路五中,需要将全量数据存在到路由关系表中建立索引,再结合 range 分库分表方案思想,其实有些场景下完全没有必要全部数据建立索引,可以按照号段式建立区间索引,我们可以将分片键的区间对应库的关系通过关系表记录下来,每次查询操作,先去路由表中查询到数据所在的库表索引,然后再到库表中查询详细数据。
# 4.2.7 思路七:一致性 Hash 法
一致性 Hash 算法也是一种比较流行的集群数据分区算法,比如 RedisCluster 即是通过一致性 Hash 算法,使用 16384 个虚拟槽节点进行每个分片数据的管理。关于一致性 Hash 的具体原理这边不再重复描述,读者可以自行翻阅资料。其思想和思路五有异曲同工之妙。
# Summary
本文从 5W1H 角度介绍了分库分表手段,其在解决如 IO 瓶颈、读写性能、物理存储瓶颈、内存瓶颈、单机故障影响面等问题的同时,也带来如事务性、主键冲突、跨库 join、跨库聚合查询等问题。anyway,在综合业务场景考虑,正如缓存的使用一样,非必须使用分库分表,则不应过度设计采用分库分表方案。如数据库确实成为性能瓶颈时,在设计分库分表方案时也应充分考虑方案的扩展性。或者说可以考虑采用成熟热门的分布式数据库解决方案,如 TiDB。