为什么需要分库分表
- 当单表的数据量过大时,对于表结构的修改,会引起长时间的锁等待,进而撑爆线程数。
- 单表数据量过大,会导致b+树的层级变高,大于3层后甚至会影响索引的查询性能。
- 同库中不同业务的热点数据,都会互相竞争CPU、内存、文件IO、网络IO,连接数。
分库分表方案
分库分表具体分为垂直拆分和水平拆分两种方式,我们应该遵循先垂直,后水平的拆分方案。
垂直拆分
垂直拆分有两种理解方式
一种就是业务拆分。不同类型的业务拆分到不同的表,甚至不同的库上,类似与微服务的思想。
还有一种是,单表数据过长,拆分成两张表。这样可以避免跨页的问题。也就是一行的数据过大,mysql一页容纳不下,存到另一页,造成额外的寻址导致性能开销。另外很多时候我们查询一行只需要其中常用的几个字段。但是这么长的数据都会加载到mysql内存中,也是额外的开销。
总结一下垂直分表的优点:
1业务划分明确,类似微服务思想职责清晰
2针对不同的数据,可以实现冷热分离,动静分离
3有了对应的拆分,我们可以根据业务热度,考虑成本,适配不同配置的机器,方便扩展与维护。
缺点:
1业务拆分到多个库后,可能会出现跨库join的问题,只能在代码层面聚合
2分布式事务问题
3垂直拆分仍然无法解决单表数据量过大的问题
水平拆分
水平拆分需要针对具体的分片键,设置一定的路由规则,拆分到多张表上。很多时候,在初期我们就已经根据微服务的思想做好了垂直分表,而水平分表随着数据量的增加,是必将面临的一个问题,更多时候还需要不断的拆分,本文将重点讨论水平分表的注意事项与常见方案。
水平分表<span style="background:#FFFFBB;">优点</span>:
1避免单表数据过大,查询成为性能瓶颈
2水平分表的表结构一致,横向扩展时,业务端可以做到无感知无改动
<span style="background:#FFFFBB;">缺点:</span>
1路由规则复杂,难以抽象
2多次拆分扩展难度大,数据扩容难度大
3水平分表也会遇到跨库join的问题
分库分表框架
操作实践
接下来,我们就以水平分表为例,展开讨论其中会遇到的问题,及解决办法。
指导思想
分表时,什么是我们需要考虑的内容?怎么样才能制定一个完善的分库分表方案?
1.方案可持续性
前期业务数据量级不大,流量较低的时候,我们无需分库分表,也不建议分库分表。
但是一旦我们要对业务进行分库分表设计时,就一定要考虑到分库分表方案的可持续性。
那何为可持续性?其实就是:业务数据量级和业务流量未来进一步升高达到新的量级的时候,我们的分库分表方案可以持续使用。
一个通俗的案例,假定当前我们分库分表的方案为 10 库 100 表,那么未来某个时间点,若 10 个库仍然无法应对用户的流量压力,或者 10 个库的磁盘使用即将达到物理上限时,我们的方案能够进行平滑扩容。
在后文中我们将介绍下目前业界常用的翻倍扩容法和一致性 Hash 扩容法。
2.数据偏斜问题
一个良好的分库分表方案,它的数据应该是需要比较均匀的分散在各个库表中的。
如果我们进行一个拍脑袋式的分库分表设计,很容易会遇到以下类似问题:
●某个数据库实例中,部分表的数据很多,而其他表中的数据却寥寥无几,业务上的表现经常是延迟忽高忽低,飘忽不定。
●数据库集群中,部分集群的磁盘使用增长特别块,而部分集群的磁盘增长却很缓慢。每个库的增长步调不一致,这种情况会给后续的扩容带来步调不一致,无法统一操作的问题。
这边我们定义分库分表最大数据偏斜率为:(数据量最大样本-数据量最小样本)/数据量最小样本。
一般来说,如果我们的最大数据偏斜率在 5% 以内是可以接受的。
常见路由策略
水平分表的方案不同,主要来源于路由策略的不同。接下来讨论一下几种不同的路由策略
Range分库分表
顾名思义,就是通过一定的范围,将表数据分配到不同的库中。
比如:
1通过uid,1-1000,1001-2000为范围分不同的库
2根据地区,华南,华中,华东分别在不同的库
3根据时间,每个季度的数据都在新库
这样的分库分表看似简单,但也存在一些致命的缺陷,比如:
1数据热点问题,如果根据时间分表,我们可以认为最新的数据被查询的概率也最大,那么大量的查询都会落在最新的那张表上,<span style="background:#BBFFBB;">没有均匀的分布查询流量</span>
2新表追加问题,一般我们线上运行的应用程序是没有数据库的建库建表权限的,故我们需要提前将新的库表提前建立,防止线上故障。这点非常容易被遗忘,尤其是稳定跑了几年没有迭代任务,或者人员又交替频繁的模块。
Hash取模分库分表
虽然分库分表的方案众多,但是 Hash 分库分表是最大众最普遍的方案,也是本文花最大篇幅描述的部分。
标准的二次分片法
这是最经典的hash分片规则,并且能够兼容后期的扩容方案。
publicstatic ShardCfg shard2(String userId){
// ① 算Hashint
hash = userId.hashCode();
// ② 总分片数int
sumSlot = DB_CNT * TBL_CNT;//1000=10*100
// ③ 分片序号
int slot = Math.abs(hash % sumSlot);986%1000的绝对值=986
// ④ 重新修改二次求值方案int
int dbIdx = slot / TBL_CNT ;986/100=9-->db9
int tblIdx = slot % TBL_CNT ;986%100=86-->tb86
returnnew ShardCfg(dbIdx, tblIdx);
}
根据以上算法,假设我们分10个库100张表,他的分配情况如下:
通过翻倍扩容后,我们的表序号一定维持不变,库序号可能还是在原来库,也可能平移到了新库中(原库序号加上原分库数),完全符合我们需要的扩容持久性方案。
方案缺点:
1翻倍扩容法前期操作性高,但是后续如果分库数已经是大几十的时候,每次翻倍扩容都非常耗费资源。
2连续的分片键 Hash 值大概率会散落在相同的库中,某些业务可能容易存在库热点(例如新生成的用户 Hash 相邻且递增,且新增用户又是高概率的活跃用户,那么一段时间内生成的新用户都会集中在相邻的几个库中)。
路由关系记录表
该方案还是通过常规的 Hash 算法计算表序号,而计算库序号时,则从路由表读取数据。
因为在每次数据查询时,都需要读取路由表,故我们需要将分片键和库序号的对应关系记录同时维护在缓存中以提升性能。
优点:
1.我们可以给每个库设置权重,根据库数据的负载动态调整权重。
2.理论上后续进行扩容的时候,仅需要挂载上新的数据库节点,将权重配置成较大值即可,无需进行任何的数据迁移即可完成。
缺点:
1每次读取数据需要访问路由表,虽然使用了缓存,但是还是有一定的性能损耗。
2路由关系表的存储方面,有些场景并不合适。例如上述案例中用户 id 的规模大概是在 10 亿以内,我们用单库百表存储该关系表即可。但如果例如要用文件 MD5 摘要值作为分片键,因为样本集过大,无法为每个 md5 值都去指定关系(当然我们也可以使用 md5 前 N 位来存储关系)。
拆分后遇到的问题
主键生成问题
由于我们一般用主键作为分片键,在不同表中,如果用主键id自增的方式,会导致主键重复的问题。所以需要引入全局id生成器,具体的id生成器方案,大家感兴趣可自行查阅资料。
非分片键查询问题
大多数场景,我们都是用主键作为分片键,这样路由的规则只和主键相关。我们通过主键查询,很容易就路由到对应的表查询要想要的数据。像这样
但还有百分之20的请求,可能需要查询uid下的所有所有任务数据。而相同uid可能被分到了不同的库,我们需要聚合所有库的查询,然后返回给前端,这样多次数据库连接非常麻烦,且低效。
我们可能会尝试用uid作为分片键,这样相同uid肯定会在同一个库。我们只需要查询一个库就能获取想要的所有数据,看起来很棒,像这样
但是这样会导致我们用主键查询的时候,完全找不到对应的路由关系了。这样的改造就是因小失大,得不偿失。那么怎么同时能够查询主键,又能够根据uid查询呢?
映射关系表
还是用uid进行分片,将主键和需要查询的uid做一个映射关系表,这样需要查询主键的时候,先去映射表找到对应的uid,再通过uid,就能路由到对应的表了。
基因法
或者我们可以截取uid的尾部几位作为特征基因,嵌入主键中。用主键的这部分基因进行分片。这样就像uid寄生了主键一样。看似用主键分片,实际上还是用uid分片。两者都能通过路由规则查询的方式路由到对应的表。
ES查询
那么在传统的商品表中,我们不仅需要查询商户id,还会查询sku,spu,这么多的查询条件,基因法还能有效么?这样的情况下,我们最好是能通过canal,将所有数据聚合进es数据库中,整理出olap供业务端多条件,多场景的查询功能。
扩容方案
扩容方案,是在我们最初做分库分表就该思考好的问题。如果当初没有一个合理的规划,那么当数据量又一次达到负荷,这个锅就会被传递给下一位接手的同事。
翻倍扩容法
翻倍扩容法的主要思维是每次扩容,库的数量均翻倍处理,而翻倍的数据源通常是由原数据源通过主从复制方式得到的从库升级成主库提供服务的方式。故有些文档将其称作"从库升级法"。
理论上,经过翻倍扩容法后,我们会多一倍的数据库用来存储数据和应对流量,原先数据库的磁盘使用量也将得到一半空间的释放。
如下图所示:
时间点 t1:为每个节点都新增从库,开启主从同步进行数据同步。
时间点 t2:主从同步完成后,对主库进行禁写。
此处禁写主要是为了保证数据的正确性。若不进行禁写操作,在以下两个时间窗口期内将出现数据不一致的问题:
●断开主从后,若主库不禁写,主库若还有数据写入,这部分数据将无法同步到从库中。
●应用集群识别到分库数翻倍的时间点无法严格一致,在某个时间点可能两台应用使用不同的分库数,运算到不同的库序号,导致错误写入。
时间点 t3:同步完全完成后,断开主从关系,理论上此时从库和主库有着完全一样的数据集。
时间点t4:从库升级为集群节点,业务应用识别到新的分库数后,将应用新的路由算法。
一般情况下,我们将分库数的配置放到配置中心中,当上述三个步骤完成后,我们修改分库数进行翻倍,应用生效后,应用服务将使用新的配置。
这里需要注意的是,业务应用接收到新的配置的时间点不一定一致,所以必定存在一个时间窗口期,该期间部分机器使用原分库数,部分节点使用新分库数。这也正是我们的禁写操作一定要在此步完成后才能放开的原因。
时间点 t5:确定所有的应用均接受到库总数的配置后,放开原主库的禁写操作,此时应用完全恢复服务。
启动离线的定时任务,清除各库中的约一半冗余数据。
缺点也很明显,就是每次扩容都是翻倍,多次翻倍后,会浪费不少数据库资源。
一致性 Hash 扩容
一致性hash的原理可以参考网上的资料,或者参考笔者的其他文章。
我们一般会有个配置,将一部分虚拟节点映射到对应的真实的库里。当某个库压力过大时,我们只需要针对需要扩容的库,把这一部分虚拟节点分给另一个新的库,灵活的进行扩容。
主要步骤如下:
●时间点 t1:针对需要扩容的数据库节点增加从节点,开启主从同步进行数据同步。
●时间点 t2:完成主从同步后,对原主库进行禁写。此处原因和翻倍扩容法类似,需要保证新的从库和原来主库中数据的一致性。
●时间点 t3:同步完全完成后,断开主从关系,理论上此时从库和主库有着完全一样的数据集。
●时间点 t4:修改一致性 Hash 范围的配置,并使应用服务重新读取并生效。
●时间点 t5:确定所有的应用均接受到新的一致性 Hash 范围配置后,放开原主库的禁写操作,此时应用完全恢复服务。
●启动离线的定时任务,清除冗余数据。
总结
本文主要描述了我们进行水平分库分表设计时的一些常见方案。
我们在进行分库分表设计时,可以选择例如范围分表,Hash 分表,路由表,或者一致性 Hash 分表等各种方案。进行选择时需要充分考虑到后续的扩容可持续性,最大数据偏斜率等因素。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。