## 1 分库分表的介绍
1.1 什么是分库分表?
分库分表其实是两个词
分库: 将一个库里的数据,分开放在多个库里
分表: 将一张表里的数据,分开放在多张表里
1.2 为什么要分库分表?
至于为什么要分库分表?肯定是单个库或者是单个表不足以满足我们的业务需要,不论是从性能角度出发,还是从数据库存储能力的角度出发,每一种类型的数据库当数据量到达一定程度后,都会出现瓶颈。这时候我们就要通过分库分表,提升数据库的存储能力以及性能。
一般而言,我们数据库出现性能问题,我们都会优先去做数据库的主从复制,读写分离。
主从复制带来的问题:
- 写入无法扩展
- 复制延时
- 锁表率上升
- 表变大,缓存率下降
分库分表同时也是解决主从复制带来的一些问题。
总结一句话,数据库顶不住了!!!
1.3 怎样分库分表?
对于Java项目来讲,分库分表一般分为两个阶段
- 数据库重新设计,合理切分数据
引入分库分表的工具,帮助我们做数据库操作
- Sharding-sphere(当当):jar,前身是Sharding-jdbc;
- Cobar(阿里巴巴)
- Mycat(基于Cobar):中间件
2 分库分表的设计
2.1 分库分表的方案
- 只分库
分库能够解决的问题是,数据库读写QPS过高,数据库连接数不足
- 只分表
分表能够解决的问题是,单表数据量过大,查询、存储性能遇到瓶颈
- 即分库又分表
解决上述两种问题
我们应该更据自己系统遇到的问题,灵活的选择分库分表的方案,如果没有性能问题,切记过度设计。
2.2 分库方案
分库主要能够解决读写QPS过高,以及数据库连接不足的问题,我们就从这一点下手。
先分析我们平时业务的QPS峰值,再考虑当大促,或者活动,这种流量暴增情况的峰值,这样的话分库就变成了一道简单的除法题
公式:流量峰值/单个库的承载力=分库的数量。
微服务场景会按照服务拆分,进行数据库的拆分。
2.3 分表方案
分表相对于分库来说更为麻烦,也更为常用。因为一些大数据量的表常常出现,致使数据库的性能瓶颈频繁出现。
分表一般都会进行数据量的增长做出预估。然后根据系统的使用年限,单表数据量做出预估分多少张表。
预估数据时,我们要适当扩大留有足够的空间。
公式:日增长数365系统使用年限/表最大容量=分表的数量
3 如何更科学的切分数据
一般我们在切分数据的时候都是考虑垂直切分或者是水平切分,对于分库和分表来说,水平切分和垂直切分都是一样的设计方案,垂直切分会改变数据的结构,水平切分不会破坏数据的结构,所有切分想要取全量数据都得取切分库、表的并集。
3.1 垂直切分
垂直切分就是将一张表的不同字段切分到不同的表中去。
这种设计方式就是将高频的部分数据拆分出来,最常见的就是商品信息与商品详情,我们经常只关心与自己感兴趣的详情页,不感兴趣的就没必要进行查询。
设计原则:
- 不常用的单独放在一张表中
- 经常一起查询的数据需要放在一张表,减少关联查询(关联变复杂就得不偿失了)
- 大字段(text,blob等)拆分放在附表中
带来的性能提升:
- 避免IO争抢,减少锁表的概率
- 高频数据不会受到其他数据拖累(尤其是大字段IO效率极低)
3.2 水平切分
水平切分是将一张表中的数据按照一定规则放入不同的表中,目的也是为了解决单表数据量过大的问题。
拆分后数据路由问题变成了我们必须要解决的问题。
设计原则:
- 以字段为依据,按照一定策略(Hash、Range等)拆分。
带来的性能提升:
- 直接解决单一表数据量过大而产生的性能问题。
4 水平切分依据详解
好的切分方式一定是较小的数据偏移,能够平滑的扩容。
4.1 Hash方式
Hash 是分库分表是最大众最普遍的方案。
4.1.1 误区
误区一:数据偏斜问题
数据偏斜问题就是指我们插入的数据不能均匀的散落在各个库表。出现的更本原因就是库数量和表数量非互质关系。
用 Hash 值分别对分库数和分表数取余,得到库序号和表序号。稍加思索一下,我们就会发现,以 10 库 100 表为例,如果一个 Hash 值对 100 取余为 0,那么它对 10 取余也必然为 0。
// 1 算Hash
int hash = id.hashCode();
// 2 总分片数
int sumSlot = DB_CNT * TBL_CNT;
// 3 分片序号
int slot = Math.abs(hash % sumSlot);
// 4 计算库序号和表序号的错误案例
int dbIdx = slot % DB_CNT ;
int tblIdx = slot / DB_CNT ;
这就会造成只有 0 库里面的 0 表才可能有数据,而其他库中的 0 表永远为空。 会导致及其严重的数据偏斜问题。
基于Hash的方式我们也不能只考虑库数量和表数量非互质,还需要考虑到扩展性。
误区二:扩容难以持续
我们把 10 库 100 表看成总共 1000 个表,将求得的 Hash 值对 1000 取余,得到一个介于[0,999)中的数,然后再将这个数二次均分到每个库和每个表中,这样看似能够解决上述问题,但是这样依赖了总表数据,后续扩容会非常复杂,不仅要改算法,还要做数据迁移。
4.1.2 正解
方案一:标准的二次分片法
错误案例二,大体思路已经正确,但是过于依赖表的总数量,我们就可以根据分配序号重新计算库序号和表序号的逻辑进行调整,就可以实现标准的二次分片法。
// 1 算Hash
int hash = id.hashCode();
// 2 总分片数
int sumSlot = DB_CNT * TBL_CNT;
// 3 分片序号
int slot = Math.abs(hash % sumSlot);
// 4 二次分片法
int dbIdx = slot / TBL_CNT ;
int tblIdx = slot % TBL_CNT ;
我们可以通过翻倍(2的倍数即可)扩容,扩容都,我们的表序号一定维持不变,库序号可能在原来库,也可能平移到了新库中(原库序号加上原分库数),完全符合我们需要的扩容持久性方案。
方案弊端:
- 这种方式,扩容在前期容易,当分库数量过多时,就耗费资源
- 连续的分片键 Hash 值大概率会散落在相同的库中,某些业务可能容易存在热点库(例如新生成的数据的 Hash 相邻且递增,可能会造成一段时间内生成的新数据会集中在相邻的几个库中)。
方案二:基因法
案例一不合理的主要原因,就是因为库序号和表序号的计算逻辑中,有公约数这个因子在影响库表的独立性。
这也是一种常用的方案,我们称为基因法,即使用原分片键中的某些基因(例如前四位)作为库的计算因子,而使用另外一些基因作为表的计算因子。
// 通过分片键后四位
int dbIdx = Math.abs(id.substring(0, 4).hashCode() % DB_CNT );
int tblIdx = Math.abs(id.hashCode() % TBL_CNT);
此种方案使用时,要综合分片键的样本规则,选取的分片键前缀位数,库数量,表数量,四个变量对最终的偏斜率都有影响。
方案弊端:
- 该方案数据偏斜可能会比较严重,需要做好充分的预估。
方案三:剔除公因数法
基于错误案例一启发,很多场景下我们还是希望相邻的 Hash 能分到不同的库中(计算库序号用 Hash 值对库数量取余)。
为了实现这一需求我们可以想办法去除公因数影响。
int dbIdx = Math.abs(id.hashCode() % DB_CNT);
// 计算表序号时先剔除掉公约数的影响
int tblIdx = Math.abs((id.hashCode() / TBL_CNT) % TBL_CNT);
该方案的特点就是需要维持库序号不变。
方案四:关系表冗余法
我们可以通过一张 “路由关系表” 将分片键对应库关系建立起来。
此方案仍需要通过 Hash 算法计算表序号,但是在计算库序号时,从路由表中读取数据。因为每次数据查询时,都需要读取路由表,所以我们需要将分片键和库序号的对应关系记录同时维护在缓存中以提升性能。
int tblIdx = Math.abs(id.hashCode() % TBL_CNT);
// 从缓存获取
Integer dbIdx = loadFromCache(id);
if (null == dbIdx) {
// 从路由表获取
dbIdx = loadFromRouteTable(id);
if (null != dbIdx) {
// 保存到缓存
saveRouteCache(id, dbIdx);
}
}
if (null == dbIdx) {
// 此处可以自行设计逻辑
dbIdx = selectRandomDbIdx();
saveToRouteTable(id, dbIdx);
saveRouteCache(id, dbIdx);
}
selectRandomDbIdx();方法作用是生成该分片键对应的存储库序号,这里我们可以灵活进行设置,可以自己设置权重,通过权重可以调节数据倾斜问题,这样我们可以在扩容时灵活调整,无需进行任何数据迁移。
该方案虽然看起来很美好,解决了很多问题,但是也会带来新的弊端:
- 每次读取数据都需要访问路由表,虽然增加了缓存,但是还是有一定的性能损耗。
- 如果要使用文件 MD5 摘要值作为分片键,由于样本集过大,无法为每个 md5 值都去指定关系(当然我们也可以使用 md5 前 N 位来存储关系)。
饥饿占位问题
这个在实际的业务场景会出现,一些不活跃的用户可能会浪费掉大量空间
- 通过在代码上增加一些是否活跃的验证,验证过的才分配空间
- 前期将多个库放在一个实例上,后期根据业务增长进行迁移
方案五:一致性Hash法
一致性 Hash 算法是一种比较流行的集群数据分区算法,比如 RedisCluster 即是通过一致性 Hash 算法,使用 16384 个虚拟槽节点进行每个分片数据的管理。
正规的一致性 Hash 算法会引入虚拟节点,每个虚拟节点会指向一个真实的物理节点。这样设计方案主要是能够在加入新节点后的时候,可以有方案保证每个节点迁移的数据量级和迁移后每个节点的压力保持几乎均等。
但是对于数据库来说,出现数据库下线的情况很少出现,新增节点也不会从0开始从其他节点迁移数据,所以说没有必要引入虚拟节点来增加复杂度。
为什么没有必要使用过多的虚节点?
- 花费额外的耗时和内存来加载虚拟节点的配置信息。
- MySQL具有完备的主从同步方案
- 虚拟节点主要解决的问题是节点数据搬迁过程中各个节点的负载不均衡问题,通过虚拟节点打散到各个节点中均摊压力进行处理。
方案三:
4.2 Range方式
Range方式就是根据数据范围划分数据的存放位置。
最经典的按照年月进行分库分表,该方案比较朴实无华。
弊端:
- 数据热点问题无法解决,最新的数据肯定是最活跃的
- 交叉范围数据处理不方便,尤其是在跨年月的数据难以处理。
- 新库新表追加问题,追加不及时可能会出现线上故障
5 分库分表带来的问题
5.1 跨库关联查询
未拆分表之前,我们可以使用 join 关联多张表查询数据,但是经过分库分表后两张表可能都不在一个数据库中,无法使用 join
解决方案:
- 字段冗余:把需要关联的字段放入主表中,避免 join 操作;
- 数据抽象:通过 ETL 等将数据汇合聚集,生成新的表;
- 全局表:比如一些基础表可以在每个数据库中都放一份;
- 应用层组装:将基础数据查出来,通过应用程序组装起来;
5.2 排序、分页、函数计算问题
在使用 SQL 时 order by、limit 等关键字需要特殊处理,
解决方案:
先在每个分片上执行相应的函数,然后再将各个分片的结果汇总,再次计算。
5.3 分布式 ID
我们在使用 Mysql 数据库时,单库单表可以使用自增 id 作为主键,分库分表了之后就行不通了,会出现 id 重复。
分布式 ID 解决方案:
- UUID
- 每个库占用一个id号段
- 基于数据库自增单独维护一张 ID表
- Redis 缓存,通过读取缓存中的值,进行递增
- 雪花算法(Snowflake)
- 美团 Leaf
- 滴滴 Tinyid
- 百度 uid-generator
5.4 分布式事务
分库是无法避免分布式事务问题的
解决方案:
两阶段提交、三阶段提交、基于可靠消息(MQ)的解决方案、柔性事务等等。
5.5 多数据源
分库分表之后会面临从多个库或多个表中获取数据
解决方案:
客户端适配和代理层适配。
也就是说我们得借助分库分表的工具,帮助我们路由到对应的库表。
6 分库分表总结
- 分库是解决数据库连接资源不足的问题,和磁盘IO性能息息相关
- 分表是解决单表数据量过大问题,SQL查询时,即使通过索引也非常耗时,和CPU性能息息相关。
- 分库解决的是写并发问题
- 分表解决的是数据量大问题
- 垂直分是从业务上分,水平分是从数据上分
- 不要盲目进行分库分表,系统复杂度提升并不是什么好事
- 要清楚系统出现了什么问题,评判一下是否需要分库分表
- 根据系统出现的性能瓶颈,合理选取分库分表的方案
- 选key很重要,既要考虑到均匀拆分,也要考虑到非Partition key(分区键)的查询
- 在满足需求的情况下,分库分表的方案越简单越好