分库分表,我再讲最后一次!
The following article is from vivo互联网技术 Author Han Lei
提起分库分表,对于大部分服务器开发来说,其实并不是一个新鲜的名词。随着业务的发展,我们表中的数据量会变的越来越大,字段也可能随着业务复杂度的升高而逐渐增多,我们为了解决单表的查询性能问题,一般会进行分表操作。
图片来自 Pexels
同时我们业务的用户活跃度也会越来越高,并发量级不断加大,那么可能会达到单个数据库的处理能力上限。此时我们为了解决数据库的处理性能瓶颈,一般会进行分库操作。
不管是分库操作还是分表操作,我们一般都有两种方式应对,一种是垂直拆分,一种是水平拆分。
关于两种拆分方式的区别和特点,互联网上参考资料众多,很多人都写过相关内容,这里就不再进行详细赘述,有兴趣的读者可以自行检索。
此文主要详细聊一聊,我们最实用最常见的水平分库分表方式中的一些特殊细节,希望能帮助大家避免走弯路,找到最合适自身业务的分库分表设计。
【注 1】本文中的案例均基于 MySQL 数据库,下文中的分库分表统指水平分库分表。
【注 2】后文中提到到 M 库 N 表,均指共 M 个数据库,每个数据库共 N 个分表,即总表个数其实为 M*N。
什么是一个好的分库分表方案?
①方案可持续性
前期业务数据量级不大,流量较低的时候,我们无需分库分表,也不建议分库分表。
但是一旦我们要对业务进行分库分表设计时,就一定要考虑到分库分表方案的可持续性。
那何为可持续性?其实就是:业务数据量级和业务流量未来进一步升高达到新的量级的时候,我们的分库分表方案可以持续使用。
一个通俗的案例,假定当前我们分库分表的方案为 10 库 100 表,那么未来某个时间点,若 10 个库仍然无法应对用户的流量压力,或者 10 个库的磁盘使用即将达到物理上限时,我们的方案能够进行平滑扩容。
在后文中我们将介绍下目前业界常用的翻倍扩容法和一致性 Hash 扩容法。
②数据偏斜问题
一个良好的分库分表方案,它的数据应该是需要比较均匀的分散在各个库表中的。
如果我们进行一个拍脑袋式的分库分表设计,很容易会遇到以下类似问题:
某个数据库实例中,部分表的数据很多,而其他表中的数据却寥寥无几,业务上的表现经常是延迟忽高忽低,飘忽不定。
数据库集群中,部分集群的磁盘使用增长特别块,而部分集群的磁盘增长却很缓慢。每个库的增长步调不一致,这种情况会给后续的扩容带来步调不一致,无法统一操作的问题。
这边我们定义分库分表最大数据偏斜率为:(数据量最大样本-数据量最小样本)/数据量最小样本。
一般来说,如果我们的最大数据偏斜率在 5% 以内是可以接受的。
常见的分库分表方案
①Range 分库分表
顾名思义,该方案根据数据范围划分数据的存放位置。
举个最简单例子,我们可以把订单表按照年份为单位,每年的数据存放在单独的库(或者表)中。
/**
* 通过年份分表
*
* @param orderId
* @return
*/
public static String rangeShardByYear(String orderId) {
int year = Integer.parseInt(orderId.substring(0, 4));
return "t_order_" + year;
}
通过数据的范围进行分库分表,该方案是最朴实的一种分库方案,它也可以和其他分库分表方案灵活结合使用。
时下非常流行的分布式数据库:TiDB 数据库,针对 TiKV 中数据的打散,也是基于 Range 的方式进行,将不同范围内的[StartKey,EndKey)分配到不同的 Region 上。
下面我们看看该方案的缺点:
最明显的就是数据热点问题,例如上面案例中的订单表,很明显当前年度所在的库表属于热点数据,需要承载大部分的 IO 和计算资源。
新库和新表的追加问题。一般我们线上运行的应用程序是没有数据库的建库建表权限的,故我们需要提前将新的库表提前建立,防止线上故障。
这点非常容易被遗忘,尤其是稳定跑了几年没有迭代任务,或者人员又交替频繁的模块。
业务上的交叉范围内数据的处理。举个例子,订单模块无法避免一些中间状态的数据补偿逻辑,即需要通过定时任务到订单表中扫描那些长时间处于待支付确认等状态的订单。
常见错误案例一:非互质关系导致的数据偏斜问题
public static ShardCfg shard(String userId) {
int hash = userId.hashCode();
// 对库数量取余结果为库序号
int dbIdx = Math.abs(hash % DB_CNT);
// 对表数量取余结果为表序号
int tblIdx = Math.abs(hash % TBL_CNT);
return new ShardCfg(dbIdx, tblIdx);
}
那么很明显,该方案是一个未达到预期效果的错误方案。数据的散落情况大致示意图如下:
证明如下:
public static ShardCfg shard(String userId) {
// ① 算Hash
int hash = userId.hashCode();
// ② 总分片数
int sumSlot = DB_CNT * TBL_CNT;
// ③ 分片序号
int slot = Math.abs(hash % sumSlot);
// ④ 计算库序号和表序号的错误案例
int dbIdx = slot % DB_CNT ;
int tblIdx = slot / DB_CNT ;
return new ShardCfg(dbIdx, tblIdx);
}
该方案确实很巧妙的解决了数据偏斜的问题,只要 Hash 值足够均匀,那么理论上分配序号也会足够平均,于是每个库和表中的数据量也能保持较均衡的状态。
常用姿势一:标准的二次分片法
事实上,我们只需要换种写法,就能得出一个比较大众化的分库分表方案。
public static ShardCfg shard2(String userId) {
// ① 算Hash
int hash = userId.hashCode();
// ② 总分片数
int sumSlot = DB_CNT * TBL_CNT;
// ③ 分片序号
int slot = Math.abs(hash % sumSlot);
// ④ 重新修改二次求值方案
int dbIdx = slot / TBL_CNT ;
int tblIdx = slot % TBL_CNT ;
return new ShardCfg(dbIdx, tblIdx);
}
它的分配情况如下:
那为何使用这种方案就能够有很好的扩展持久性呢?我们进行一个简短的证明:
通过上面结论我们知道,通过翻倍扩容后,我们的表序号一定维持不变,库序号可能还是在原来库,也可能平移到了新库中(原库序号加上原分库数),完全符合我们需要的扩容持久性方案。
翻倍扩容法前期操作性高,但是后续如果分库数已经是大几十的时候,每次扩容都非常耗费资源。
连续的分片键 Hash 值大概率会散落在相同的库中,某些业务可能容易存在库热点(例如新生成的用户 Hash 相邻且递增,且新增用户又是高概率的活跃用户,那么一段时间内生成的新用户都会集中在相邻的几个库中)。
我们可以将分片键对应库的关系通过关系表记录下来,我们把这张关系表称为"路由关系表"。
public static ShardCfg shard(String userId) {
int tblIdx = Math.abs(userId.hashCode() % TBL_CNT);
// 从缓存获取
Integer dbIdx = loadFromCache(userId);
if (null == dbIdx) {
// 从路由表获取
dbIdx = loadFromRouteTable(userId);
if (null != dbIdx) {
// 保存到缓存
saveRouteCache(userId, dbIdx);
}
}
if (null == dbIdx) {
// 此处可以自由实现计算库的逻辑
dbIdx = selectRandomDbIdx();
saveToRouteTable(userId, dbIdx);
saveRouteCache(userId, dbIdx);
}
return new ShardCfg(dbIdx, tblIdx);
}
但是由于用户也有高频低频之分,可能某些库的数据增长会比较快。当挂载新的数据库节点后,我们灵活的调整了每个库的新权重。
每次读取数据需要访问路由表,虽然使用了缓存,但是还是有一定的性能损耗。
路由关系表的存储方面,有些场景并不合适。例如上述案例中用户 id 的规模大概是在 10 亿以内,我们用单库百表存储该关系表即可。
但如果例如要用文件 MD5 摘要值作为分片键,因为样本集过大,无法为每个 md5 值都去指定关系(当然我们也可以使用 md5 前 N 位来存储关系)。
饥饿占位问题,如下详叙:我们知道,该方案的特点是后续无需扩容,可以随时修改权重调整每个库的存储增长速度。
假定有 2 亿理论用户,假设当前有 3000W 有效用户。
平均每个用户文件量级在 2000 个以内
用户 id 为随机 16 位字符串
初期为 10 库,每个库 100 张表。
那么我们是否可以换一种思路呢?我们使用相对独立的 Hash 值来计算库序号和表序号。
public static ShardCfg shard(String userId) {
int dbIdx = Math.abs(userId.substring(0, 4).hashCode() % DB_CNT );
int tblIdx = Math.abs(userId.hashCode() % TBL_CNT);
return new ShardCfg(dbIdx, tblIdx);
}
为了验证观点,进行如下测试,随机 2 亿个用户 id(16 位的随机字符串),针对不同的 M 库 N 表方案,重复若干次后求平均值得到结论如下:
8库100表
min=248305(dbIdx=2, tblIdx=64), max=251419(dbIdx=7, tblIdx=8), rate= 1.25% √
16库100表
min=95560(dbIdx=8, tblIdx=42), max=154476(dbIdx=0, tblIdx=87), rate= 61.65% ×
20库100表
min=98351(dbIdx=14, tblIdx=78), max=101228(dbIdx=6, tblIdx=71), rate= 2.93%
那么我们是不是可以有办法去除掉公因数的影响呢?下面为一个可以考虑的实现案例:
public static ShardCfg shard(String userId) {
int dbIdx = Math.abs(userId.hashCode() % DB_CNT);
// 计算表序号时先剔除掉公约数的影响
int tblIdx = Math.abs((userId.hashCode() / TBL_CNT) % TBL_CNT);
return new ShardCfg(dbIdx, tblIdx);
}
配置一般包括一个[StartKey,Endkey)的左闭右开区间和一个数据库节点信息,例如:
示例代码:
private TreeMap<Long, Integer> nodeTreeMap = new TreeMap<>();
@Override
public void afterPropertiesSet() {
// 启动时加载分区配置
List<HashCfg> cfgList = fetchCfgFromDb();
for (HashCfg cfg : cfgList) {
nodeTreeMap.put(cfg.endKey, cfg.nodeIdx);
}
}
public ShardCfg shard(String userId) {
int hash = userId.hashCode();
int dbIdx = nodeTreeMap.tailMap((long) hash, false).firstEntry().getValue();
int tblIdx = Math.abs(hash % 100);
return new ShardCfg(dbIdx, tblIdx);
}
正规的一致性 Hash 算法会引入虚拟节点,每个虚拟节点会指向一个真实的物理节点。
应用程序需要花费额外的耗时和内存来加载虚拟节点的配置信息。如果虚拟节点较多,内存的占用也会有些不太乐观。
由于 MySQL 有非常完善的主从复制方案,与其通过从各个虚拟节点中筛选需要迁移的范围数据进行迁移,不如通过从库升级方式处理后再删除冗余数据简单可控。
虚拟节点主要解决的痛点是节点数据搬迁过程中各个节点的负载不均衡问题,通过虚拟节点打散到各个节点中均摊压力进行处理。
常见扩容方案
如下图所示:
断开主从后,若主库不禁写,主库若还有数据写入,这部分数据将无法同步到从库中。
应用集群识别到分库数翻倍的时间点无法严格一致,在某个时间点可能两台应用使用不同的分库数,运算到不同的库序号,导致错误写入。
那么以上述常用姿势四为例,我们离线的清除任务可以简单的通过 sql 即可实现(需要防止锁住全表,可以拆分成若干个 id 范围的子 sql 执行):
delete from db0.tbl0 where hash_val mod 4 <> 0;
delete from db1.tbl0 where hash_val mod 4 <> 1;
delete from db2.tbl0 where hash_val mod 4 <> 2;
delete from db3.tbl0 where hash_val mod 4 <> 3;
具体的扩容步骤可参考下图:
下图中,扩容前配置了三个 Hash 分段,发现[-Inf,-10000)范围内的的数据量过大或者压力过高时,需要对其进行扩容。
时间点 t1:针对需要扩容的数据库节点增加从节点,开启主从同步进行数据同步。
时间点 t2:完成主从同步后,对原主库进行禁写。此处原因和翻倍扩容法类似,需要保证新的从库和原来主库中数据的一致性。
时间点 t3:同步完全完成后,断开主从关系,理论上此时从库和主库有着完全一样的数据集。
时间点 t4:修改一致性 Hash 范围的配置,并使应用服务重新读取并生效。
时间点 t5:确定所有的应用均接受到新的一致性 Hash 范围配置后,放开原主库的禁写操作,此时应用完全恢复服务。
启动离线的定时任务,清除冗余数据。
小结
👇点击关注鸿蒙技术社区👇
了解鸿蒙一手资讯
作者:Han Lei
编辑:陶家龙
出处:转载自公众号vivo互联网技术(ID:vivoVMIC)
精彩文章推荐: