当你遇到单点单应用支撑不住使用的时候,Z哥给你的普适性建议是:先考虑“扩”,再考虑“切”。这个和写代码一样,“增加”新功能往往比在老功能上改容易。
“扩”的话先考虑「垂直扩」(加硬件,钱能解决的都不是问题),再考虑「水平扩」(无状态改造 多节点部署,这是小手术)。
“切”的话一般就是「垂直切」(根据业务切分,这是大手术),偶尔会用到「水平切」(其实就是单个应用里的分层,比如前后端分离)。
第三篇《分布式系统关注点——弹性架构》我们聊了常见的两种「松耦合」架构模式,为的是让应用程序的「伸缩性」更上一层楼。
以上这些呢都是应用程序层面的工作。一般情况下,在应用程序层面做做手术,再配合以缓存的充分运用,就可以支撑系统发展很长时间了。特别是数据量不大,只是请求量大的「CPU密集型」场景。
但是,如果所处的工作场景是一个非常成熟且具有一定规模的项目,越发展到后面瓶颈总是出现在数据库这里。甚至会出现cpu长期高负荷、宕机等现象。
在如此场景下,就不得不对数据库开刀了。这次Z哥就来和你聊聊做数据库的「伸缩性」有哪些好方法。
核心诉求
面临数据库需要开刀的时候,整个系统往往已经长成这个样子了。
正如前面所说,这时候的瓶颈往往会体现在「CPU」上。
因为对数据库来说,硬盘和内存的扩容相对容易,因为它们都可以直接用“增加”的方式进行。
CPU就不同了,一旦CPU飙高,最多检查下索引有没有做好,完了之后基本就只能干看着。
所以解决这个问题的思路自然就变成了:如何将一个数据库的CPU压力分摊到多个CPU上去。甚至可以做到按需随时增加。
那这不就是和应用程序一样做「切分」嘛。也是分布式系统的「分治」思想体现。
既然是切分,本质上就和应用程序一样,也分为「垂直切分」和「水平切分」。
垂直切分
垂直切分有时候也会被称作「纵向切分」。
同应用程序一样,它是以「业务」为维度的切分方式,在不同的数据库服务器上跑不同业务的数据库,各司其职。
一般情况下,Z哥建议你优先考虑「垂直切分」而不是「水平切分」,为什么呢?你可以随意打开手头项目中的SQL语句看看,我想必然存在着大量的「join」和「transaction」关键字,这种关联查询和事务操作,本质上是一种「关系捆绑」,一旦面临数据库拆分之后,就没法玩了。
此时你只有2个选择。
- 要么将不必要的「关系捆绑」逻辑舍弃掉,这需要在业务上作出调整,去除不必要的“批量操作”业务,或者去除不必要的强一致性事务。不过你也知道,肯定有一些场景是去不完的。
- 要么将「合并」,「关联」等逻辑上浮,体现到业务逻辑层甚至是应用层的代码中。
最终,不管怎么选择,改动起来都是一个大工程。
为了让这个工程尽可能的动作小一些,追求更好的性价比,需要坚持一个原则——“避免拆分紧密关联的表”。
因为两个表之间关联越紧密,意味着对「join」和「transaction」的需求越多,所以坚持这个原则可以使得相同的模块,紧密相关的业务都落在同一个库中,这样它们可以继续使用「join」和「transaction」来工作。
因此,我们应当优先采用「垂直切分」的方式。
做「垂直切分」思路很简单,一般情况下,建议是与切分后的应用程序一一对应就好,不用多也不用少。
实际工作中,要做好「垂直切分」主要体现在「业务」的熟悉度上,所以这里就不继续展开了。
「垂直切分」的优点是:
- 高内聚,拆分规则清晰。相比「水平切分」数据冗余度更低。
- 与应用程序是1:1的关系,方便维护和定位问题。一旦某个数据库中发现异常数据,排查这个数据库的关联程序就行了。
但是这并不是一个「一劳永逸」的方案,因为没人能预料到未来业务会发展的怎么样,所以最明显的缺点就是:对于访问极其频繁或者数据量超大的表仍然存在性能瓶颈。
确实需要解决这个问题的话,就需要搬出「水平切分」了。
题外话:不到迫不得己,尽量避免进行「水平切分」。看完接下去的内容你就知道原因了。
下面Z哥就给你好好聊聊「水平切分」,这才是本文的重点。
水平切分
想象一下,在你做了「垂直切分」之后,还是在某个数据库中发现了一张数据量超过10亿条的表。
这个时候要对这个表做「水平切分」,你会怎么思考这个事情?
Z哥教给你的思路是:
- 先找到“最高频“的「读」字段。
- 再看这个字段的实际使用中有什么特点(批量查询多还是单个查询多,是否同时是其它表的关联字段等等)。
- 再根据这个特点选择合适的切分方案。
为什么要先找到高频的「读」字段呢?
因为在实际的使用中,「读」操作往往是远大于「写」操作的。一般进行「写」之前都得通过「读」来做先行校验,然而「读」还有自己单独的使用场景。所以针对更高频的「读」场景去考虑,产生的价值必然也更大。
比如,现在那张10亿数据量的表是一张订单表,结构是这样:
order (orderId long, createTime datetime, userId long)
下面我们先来看看有哪几种「水平切分」的方式,完了才能明白什么样的场景适合哪种方式。
范围切分
这是一种「连续式」的切分方式。
比如根据时间(createTime)切分的话,我们可以按年月来分,order_201901一个库,order_201902一个库,以此类推。
根据顺序数(orderId)切分的话,可以100000~199999一个库,200000~299999一个库,以此类推。
这种切分法的优点是:单个表的大小可控,扩展的时候无需数据迁移。
缺点也很明显,一般来说时间越近或者序号越大的数据越“新”,因此被访问的频率和概率相比“老”数据更多。会导致压力主要集中在新的库中,而历史越久的库,越空闲。
Hash切分
与「范围切分」正好相反,这是一种「离散式」的切分方式。
它的优点就是解决了「范围切分」的缺点,新数据被分散到了各个节点中,避免了压力集中在少数节点上。
同样,缺点与「范围切分」的优点相反,一旦进行二次扩展,必然会涉及到数据迁移。因为Hash算法是固定的,算法一变,数据分布就变了。
大多数情况下,我们的hash算法可以通过简单的「取模」运算来进行即可。就像下面这样:
假如分成11个库的话,公式就是 orderId % 10。 100000 % 10 = 0,分配到db0。 100001 % 10 = 1,分配到db1。 .... 100010 % 10 = 0,分配到db0。 100011 % 10 = 1,分配到db1。
其实,在某些场景下,我们可以通过自定义id的生成(可以参考之前的文章,《分布式系统中的必备良药 —— 全局唯一单据号生成》)来做到既可以通过hash切分来打散热点数据,又可以减少依赖全局表来定位具体的数据。
比如,在orderId中加入userId的尾数,以此达到orderId和userId取模结果相等的效果。还是来举个例子:
一个用户的userId是200004,如果取一个4bit尾数的话,这里就是4,用0100表示。 然后,我们通过自定义id算法生成orderId的前60位,在后面补上0100。 于是,orderId % 10和 userId % 10的结果就是一样的了。
当然,除了userId之外还想加入其他的因子就不好使了。也就是,可以在不增加全局表的情况下,额外多支持1个维度。
提到了两次全局表,那么啥是全局表呢?
全局表
这种方式就是将用作切分依据的分区Key与对应的每一条具体数据的id保存到一个单独的库或者表中。例如要增加一张这样的表:
代码语言:javascript复制nodeId orderId 01 10000102 10000201 10000301 100004...
如此一来,的确将大部分具体的数据分布在了不同服务器上,但是这张全局表会给人一种「形散神不散」的感觉。
因为请求数据的时候无法直接定位需要的数据在哪台服务器上,所以每一次操作都要先查询一下这张全局表好知道具体的数据被存放在哪里。
这种「中心化」的模式带来的副作用就是瓶颈和风险转移到了这张全局表上。但是,胜在逻辑简单。
好了,那么这几种切分方案怎么选择呢?
Z哥给你的建议是,如果热点数据不是特别集中的场景,建议先用「范围切分」,否则选择另外2种。
选择另外两种的时候,数据量越大越倾向选择Hash切分。因为后者在整体的可用性和性能上都比前者好,就是实现成本高一些。
「水平切分」真正做到了可以“无限扩展”,但是也存在相应的弊端。
1)批量查询、分页等需要做更多的额外工作。特别是当一个表存在多个高频字段用于where、order by或者group by的时候。
2)拆分规则不如「垂直切分」那么明确。
所以还是多说一句“废话”:没有完美的方案只有合适的方案,要结合具体的场景来选择。(欢迎你在留言区提出你有疑惑的场景,和Z哥来讨论讨论)
如何实施
当你在具体实施「水平切分」的时候可以在2个层面动刀,可以是「表」层面,也可以是「库」层面。
表
在同一个数据库下面分表,表名order_0 ,order_1, order_2.....。
它可以解决单表数据过大,但并不能解决CPU负荷的问题。所以,当CPU并没多少压力,只是由于表太大,导致执行SQL操作比较慢的话,可以选择这种方式。
库
这个时候表名可以不变,都叫order,只是分成10个库。那么就是db0-user db1-user db2-user......。
我们前面大篇幅都是基于这个模式在聊,就不多说了。
表 库
也可以既分库又分表,比如先分10个库,然后每个库再分10张表。
这其实是个二级索引的思路,通过库来进行第一次定位,减少一定的资源消耗。
比如,先按年分库,再按月分表。如此一来,如果需要获取的数据只跨月但不跨年,我们就可以在单个库内做聚合运算来完成,不涉及到跨库操作。
不过,不管选择哪种方式来进行,你还是会或多或少面临以下两个问题,逃不掉的。
- 跨库join。
- 全局聚合或者排序操作。
解决第一个问题最佳方式还是需要改变你的编程思维。尽量将一些逻辑、关系、约束等体现在应用程序的代码中,避免因为方便而在SQL中做这些事情。
毕竟代码是可以写成“无状态”的,可以随时做扩展,但是SQL是跟着数据走的,而数据就是“状态”,天然不利于扩展。
当然了,退而求其次,你也可以冗余大量的全局表来应对。只是如此一来,对「数据一致性」工作是个很大的考验,另外,对存储资源也是很大的开销。
第二个问题的解决方案就是需要将原本的一次聚合或者一次排序变成两次操作。其中的遍历多个节点可以以「并行」的方式进行。
那么数据切分完之后程序如何来使用呢?这又可以分为两种模式,「进程内」和「进程外」。
「进程内」的话,可以在封装好的DAL访问框架中做,也可以在ORM框架中做,还可以在数据库驱动中做。这个模式比较知名的解决方案如阿里的tddl。
「进程外」的话,就是代理模式,这个模式比较知名的解决方案是mycat、cobar、atlas等等,相对多一些,因为这种模式对应用程序是「低侵入」的,使用起来像“一个数据库”。但是由于多了一道网络通信,性能上会多一些损耗。
老规矩,下面再分享一些最佳实践。
最佳实践
首先分享两个可以不停机做数据切分的小窍门
。我们以实施hash法做水平切分的例子来看一下。
第一次做切分的时候,你可以以「主-从」的形式将新增的节点作为原始节点的副本,进行全量实时同步。
然后在这个基础上删除不属于它的数据。(当然了,不删也没啥问题,就是多占用一些空间)
这样就可以不用停机了。
第二,随着时间的推移,如果后续支撑不住了,需要二次切分的话,我们可以选择用2的倍数来扩展。
如此一来,数据的迁移变得很简单,只需要做局部的迁移,和第一次做切分的思路是一样的。
当然了,如果选择的切分方式是「范围切分」的话,就没有二次切分时的困扰,数据自然跑到最新的节点上去了。比如我们按年月分表的话。2019年3月的数据自然就落到了xxxx_201903的表中。
到这里,Z哥还是想特别强调的是,能不切分尽量不要切分,可以先使用「读写分离」之类的方案先来应对面临的问题。
如果实在要进行切分的话,务必先「垂直切分」,再考虑「水平切分」。
一般来说,以这样的顺序来考虑,性价比更好。
总结
好了,我们总结一下。
这次呢,Z哥先向你介绍了做数据库切分的两种思路。两种思路通俗理解就是:「垂直拆分」等于“列”变“行”不变,「水平拆分」等于“行”变“列”不变。