上篇《分库分表的正确姿势》中已经将分库分表的方法论全面阐述清楚了,总体看下来用一个字形容,那就是爽!尤其是分库分表技术能够让数据存储层真正成为三高架构,但前面爽是爽了,接着一起来看看分库分表后产生一系列的后患问题,注意我这里的用词,是一系列而不是几个,也就是分库分表虽然好,但你要解决的问题是海量的。
一、垂直分表后带来的隐患
垂直分表后当试图读取一条完整数据时,需要连接多个表来获取,对于这个问题只要在切分时,设置好映射的外键字段即可。当增、删、改数据时,往往需要同时操作多张表,并且要保证操作的原子性,也就是得手动开启事务来保证,否则会出现数据不一致的问题。
这里可以看到,垂直分表虽然会有后患问题,但带来的问题本质上也不算大问题,就是读写数据的操作会相对麻烦一些,下面来看看其他的分库分表方案,接下来是真正的重头戏。
二、水平分表后带来的问题
水平分表就是将一张大表的数据,按照一定的规则划分成不同的小表,当原本的一张表变为多张表时,虽然提升了性能,但问题随之也来了~
2.1、多表联查问题(Join)
之前在库中只存在一张表,所以非常轻松的就能进行联表查询获取数据,但是此时做了水平分表后,同一张业务的表存在多张小表,这时再去连表查询时具体该连接哪张呢?似乎这时问题就变的麻烦起来了,怎么办?解决方案如下:
- ①如果分表数量是固定的,直接对所有表进行连接查询,但这样性能开销较大,还不如不分表。
- ②如果不想用①,或分表数量会随时间不断变多,那就先根据分表规则,去确定要连接哪张表后再查询。
- ③如果每次连表查询只需要从中获取1~3个字段,就直接在另一张表中设计冗余字段,避免连表查询。
第一条好理解,第二条是啥意思呢?好比现在是按月份来分表,那在连表查询前,就先确定要连接哪几张月份的表,才能得到自己所需的数据,确定了之后再去查询对应表即可,这里该如何落地实现呢?大家别急,会在后续的《库内分表篇》去详细讲解。
2.2、增删改数据的问题
当想要增加、删除、修改一条数据时,因为存在多张表,所以又该去哪张表中操作呢?这里还是前面那个问题,只要在操作前确定好具体的表即可。但还有一种情况,就是批量变更数据时,也会存在问题,要批量修改的数据该具体操作哪几张分表呢?依旧需要先定位到具体分表才行。
2.3、聚合操作的问题
之前因为只有一张表,所以进行sum()、count()....、order by、gorup by....等各类聚合操作时,可以直接基于单表完成,但此刻似乎行不通了呀?对于这类聚合操作的解决方案如下:
- ①放入第三方中间件中,然后依赖于第三方中间件完成,如ES。
- ②定期跑脚本查询出一些常用的聚合数据,然后放入Redis缓存中,后续从Redis中获取。
- ③首先从所有表中统计出各自的数据,然后在Java中作聚合操作。
前面两种操作比较好理解,第三种方案是什么意思呢?比如count()函数,就是对所有表进行统计查询,最后在Java中求和,好比分组、排序等工作,先从所有表查询出符合条件的数据,然后在Java中通过Stream流进行处理。
上述这三种方案都是比较合理且常规的方案,最好是选择第一种,这也是一些大企业选用的方案。
到这里就大致聊清楚了单库中分表的一些主要问题,一般情况下库内无需做分表,只有一些特殊场景下需要做,具体的会在后续《库内分表篇》讲解,下面聊聊分库问题,分库带来的问题更多更复杂。
三、垂直分库后产生的问题
垂直分库是按照业务属性的不同,直接将一个综合大库拆分成多个功能单一的独享库,分库之后能够让性能提升N倍,但随之而来的是需要解决更多的问题,而且问题会比单库分表更复杂!
3.1、跨库Join问题
因为将不同业务的表拆分到了不同的库中,而往往有些情况下可能会需要其他业务的表数据,在单库时直接join连表查询相应字段数据即可,但此时已经将不同的业务表放到不同库了,这时咋办?跨库Join也不太现实呀,此时有如下几种解决方案:
- ①在不同的库需要数据的表中冗余字段,把常用的字段放到需要要数据的表中,避免跨库连表。
- ②选择同步数据,通过广播表/网络表/全局表将对应的表数据直接完全同步一份到相应库中。
- ③在设计库表拆分时创建ER绑定表,具备主外键的表放在一个库,保证数据落到同一数据库。
- ④Java系统中组装数据,通过调用对方服务接口的形式获取数据,然后在程序中组装后返回。
这四种方案都能够解决需要跨库Join的问题,但第二、三种方案提到了广播表/网络表/全局表、ER绑定表似乎之前没听说过对嘛?这其实是分库分表中的一些表名词,后续《分库分表实践篇》具体落地时会详细讲解。但往往垂直分库的场景中,第四种方案是最常用的,因为分库分表的项目中,Java业务系统那边也绝对采用了分布式架构,因此通过调用对端API接口来获取数据,是分布式系统最为常见的一种现象。
3.2、分布式事务问题
分布式事务应该是分布式系统中最核心的一个问题,这个问题绝对不能出现,一般都要求零容忍,也就是所有分布式系统都必须要解决分布式事务问题,否则就有可能造成数据不一致性。
在之前单机的MySQL中,数据库自身提供了完善的事务管理机制,通过begin、commit/rollback的命令可以灵活的控制事务的提交和回滚,在Spring要对一组SQL操作使用事务时,也只需在对应的业务方法上加一个@Transactional注解即可,但这种情况在分布式系统中就不行了。
为什么说MySQL的事务机制会在分布式系统下失效呢?因为InnoDB的事务机制是建立在Undo-log日志的基础上完成的,以前只有一个Undo-log日志,所以一个事务的所有变更前的数据,都可以记录在同一个Undo-log日志中,当需要回滚时就直接用Undo-log中的旧数据覆盖变更过的新数据即可。
但垂直分库之后,会存在多个MySQL节点,这自然也就会存在多个Undo-log日志,不同库的变更操作会记录在各自的Undo-log日志中,当某个操作执行失败需要回滚时,仅能够回滚自身库变更过的数据,对于其他库的事务回滚权,当前节点是不具备该能力的,所以此时就必须要出现一个事务管理者来介入,从而解决分布式事务问题。
上述提到的通过第三方:事务管理者来介入,这只属于分布式事务问题的一种解决方案,解决方案如下:
- ①Best Efforts 1PC模式。
- ②XA 2PC、3PC模式。
- ③TTC事务补偿模式。
- ④MQ最终一致性事务模式。
上述是解决分布式事务问题的四种思想,目前最常用的Seata的两种模式就是基于XA-3PC和TCC思想实现的,但对于这些具体的讲解,会放到后面的《分布式事务篇》中讲解,这里了解即可。
3.3、部分业务库依旧存在性能瓶颈问题
这种情况前面说到过,经过垂直分库之后,某些核心业务库依旧需要承载过高的并发流量,因此一单节点模式部署,依然无法解决所存在的性能瓶颈,对于这种情况直接再做水平分库即可,具体过程请参考阶段二。
四、水平分库后需要解决的问题
水平分库这种方案,能够建立在垂直分库的基础上,进一步对存储层做拓展,能够让某些业务库具备更高的并发处理能力,不过水平分库虽然带来的性能收益巨大,但产生的问题也最多!
4.1、聚合操作和连表问题
对单个业务库做了水平分库后,也就是又对单个业务库做了横向拓展后,一般都会将库中所有的表做水平切分,也就是不同库中的所有表,每个水平库节点中存储的数据是不同的,这时又会出现4.2阶段聊到的一些问题,如单业务的聚合操作、连表操作会无法进行,这种情况的解决思路和水平分表时一样,先确定读写的数据位于哪个库表中,然后再去生成SQL并执行。
当然,对于这类情况,如果只是因为并发过高导致出现性能瓶颈,最好的选择是直接做业务库集群,也就是拓展出的所有节点,数据都存一模一样的,这样无论在任何库中都具备完整数据,所以就无需先定位目标数据所处的库再操作。
但如果数据量也比较大,就只能做水平分库分表了,相同业务不同的数据库节点,存储不同范围的数据,能够在最大程度上提升存储层的吞吐能力,但会让读写操作困难度增高,因为读写之前需要先定位读写操作到底要落到哪个节点中处理。
4.2、数据分页问题
以MySQL数据库为例,如果是在之前的单库环境中,可以直接通过limit index,n的方式来做分页,而水平分库后由于存在多个数据源,因此分页又成为了一个难题,比如10条数据为1页,那如果想要拿到某张表的第一页数据,就必须通过如下手段获取:
这种方式可以是可以,但略微有些繁杂,同时也会让拓展性受限,比如原本有两个水平分库的节点,因此只需要从两个节点中拿到第一页数据,然后再做一次过滤即可,但如果水平库从两节点扩容到四节点,这时又要从四个库中各自拿10条数据,然后做过滤操作,读取前十条数据显示,这显然会导致每次扩容需要改动业务代码,对代码的侵入性有些强,所以合理的解决方案如下:
- ①常用的分页数据提前聚合到ES或中间表,运行期间跑按时更新其中的分页数据。
- ②利用大数据技术搭建数据中台,将所有子库数据汇聚到其中,后续的分页数据直接从中获取。
- ③上述聊到的那种方案,从所有字库中先拿到数据,然后在Service层再做过滤处理。
上述第一种方案是较为常用的方案,但这种方案对数据实时性会有一定的影响,使用该方案必须要能接受一定延时。第二种方案是最佳的方案,但需要搭建完善的大数据系统作为基础,成本最高。第三种方案成本最低,但对拓展性和代码侵入性的破坏比较严重。
4.3、ID主键的唯一性问题
在之前的单库环境时,对于一张表的主键通常会选用整数型字段,然后通过数据库的自增机制来保证唯一性,但在水平分库多节点的情况时,假设还是以数据库自增机制来维护主键唯一性,这就绝对会出现一定的问题,可能会导致多个库中出现ID相同、数据不同的情况,如下:
上述两个库需要存储不同的数据,当插入数据的请求被分发到对应节点时,如果再依据自增机制来确保ID唯一性,因为这里有两个数据库节点,两个数据库各自都维护着一个自增序列,因此两者ID值都是从1开始往上递增的,这就会导致前面说到的ID相同、数据不同的情况出现,那此时又该如何解决呢?如下:
这时可以根据水平库节点的数量来设置自增步长,假设此时有两个库,那自增步长为2,两个库的ID起始值为:{DB1:1}、{DB2:2},最终达到上图中的效果,无论在插入数据的操作落入哪个节点,都能够确保ID的唯一性。当然,保障分布式系统下ID唯一性的解决方案很多,如下:
- ①通过设置数据库自增机制的起始值和步长,来控制不同节点的ID交叉增长,保证唯一性。
- ②在业务系统中,利用特殊算法生成有序的分布式ID,比如雪花算法、Snowflake算法等。
- ③利用第三方中间件生产ID,如使用Redis的incr命令、或创建独立的库专门做自增ID工作。
上述这几种方案都是较为主流的分布式ID生成的方案,同时也能够保证ID的有序性,能够最大程度上维护索引的性能,相对来说第一种方案成本最低,但是会限制节点的拓展性,也就是当后续扩容时,数据要做迁移,同时要重新修改起始值和自增步长。
一般企业中都会使用第二种方案,也就是通过分布式ID生成的算法,在业务系统中生成有序的分布式ID,以此来确保ID的唯一性,但具体该如何去实现,这点会放到后续的《分布式ID篇》讲解。
4.4、数据的落库问题
对数据库做了水平拆分后,数据该怎么存储呢?写操作究竟该落到哪个库呢?读操作又如何确定数据在哪个库中呢?比如新增一条ID=987215的数据,到底插入到哪个库中,需要这条数据时又该如何查询?这显然是做了水平拆分之后必须要思考的问题,这定然不能随便写,因为写过的数据在后续业务中肯定会用到,所以写数据时需要根据一定规则去落库,这样才能在需要时能定位到数据。
但数据的拆分规则,或者被称之为路由规则,具体的还是要根据自己的业务来进行选择,在拆分时只需遵循:数据分布均匀、查询方便、扩容/迁移简单这几点原则即可。
一般简单常用的数据分片规则如下:
- ①随机分片:随机分发写数据的请求,但查询时需要读取全部节点才能拿取数据,一般不用。
- ②连续分片:每个节点负责存储一个范围内的数据,如DB1:1~500W、DB2:500~1000W....。
- ③取模分片:通过整数型的ID值与水平库的节点数量做取模运算,最终得到数据落入的节点。
- ④一致性哈希:根据某个具备唯一特性的字段值计算哈希值,然后再通过哈希值做取模分片。
- ..........
总之想要处理好数据的落库问题,那首先得先确定一个字段作为分片键/路由键,然后基于这个字段做运算,确保同一个值经计算后都能分发到同一个节点,这样才能确保读写请求正常运转,但关于具体该如何去做,会在后续的《MySQL分库分表实践篇》去落地。
4.5、流量迁移、容量规划、节点扩容等问题
流量迁移、容量规划、节点扩容等这些问题,在做架构改进时基本上都会碰到,接着依次聊一聊。
4.5.1、流量迁移
线上环境从单库切换到分库分表模式,数据该如何迁移才能保证线上业务不受影响,对于这个问题来说,首先得写脚本将老库的数据同步到分库分表后的各个节点中,然后条件允许的情况下先上灰度发布,划分一部分流量过来做运营测试。
如果没有搭建完善的灰度环境,那先再本地再三测试确保没有问题后,采用最简单的方案,先挂一个公告:“今日凌晨两点后服务器会进入维护阶段,预计明日早晨八点会恢复正常运转”!然后停机更新,但前提工作做好,如:Java代码从单库到分库分表要改进完善、数据迁移要做好、程序调试一切无误后再切换,同时一定要做好版本回滚支持,如果迁移流量后出现问题,可以快捷切换回之前的老库。
4.5.2、容量规划
关于分库分表首次到底切分成多少个节点合适,对于这点业内没有明确规定,更多的要根据自身业务的流量、并发情况决定,根据实际情况先做垂直分库,然后再对于核心库做水平分库,但水平分库的节点数量要保证的是2的整倍数,方便后续扩容。
4.5.3、节点扩容
扩容一般是指水平分库,也就是当一个业务库无法承载流量压力时,需要对相应的业务的节点数量,但扩容时必须要考虑本次增加节点会不会影响之前的业务,因为很多情况下,当节点的数量发生改变时,可能会影响数据分片的路由规则,这时就要考虑扩容是否会影响原本的路由规则。
扩容一般都是基于水平分库的基础上,进一步对水平库做节点扩容,目前业内有两种主流做法:水平双倍扩容法、异步双写扩容法。
水平双倍扩容法
想要使用双倍扩容法对节点进行扩容,首先必须要求原先节点数为2的整数倍,同时路由规则必须要为数值取模法、或Hash取模法,否则依旧会造成扩容难度直线提升。同时双倍扩容法还有一种进阶做法,被称之为从库升级法,也就是给原本每个节点都配置一个从库,然后同步主节点的所有数据,当需要扩容时仅需将从库升级为主节点即可,过程如下:
起初某个业务的水平库节点数量为2,因此业务服务中的数据源配置为{DB0:144.xxx.xxx.001、DB1:144.xxx.xxx.002},当读写数据时,如果路由键经哈希取模运算后的结果为0,则将对应请求落到DB0处理,如若取模结果为1,则将数据落到DB1中处理,此时两节点的数据如下:
- DB0:{2、4、6、8、10、12、14、16.....}
- DB1:{1、3、5、7、9、11、13、15......}
同时DB0、DB1两个节点都各有一个从节点,从节点会同步各自主节点的所有数据,此时假设两个节点无法处理请求压力时,需要进一步对水平库做扩容,这时可直接将从节点升级为主节点,过程如下:
经过扩容之后,节点数量变成了4,所有首先得修改业务服务中的数据源配置为{DB0:144.xxx.xxx.001、DB1:144.xxx.xxx.002}、{DB2:144.xxx.xxx.003、DB3:144.xxx.xxx.004},然后将路由算法修改为%4,最终数据分片如下:
- DB0:{4, 8, 12, 16, 20, 24.....}
- DB1:{1, 5, 9, 13, 17, 21......}
- DB2:{2, 6, 10, 14, 18, 22.....}
- DB3:{3, 7, 11, 15, 19, 23.....}
此时要注意,因为DB2原本属于DB0的从库,所以具备原本DB0的所有数据,现在再观察上述的数据分片情况,对比如下:
- 扩容后的DB2:{2, 6, 10, 14, 18, 22.....}
- 扩容前的DB0:{2、4、6、8、10、12、14、16.....}
此时大家应该会发现,扩容后DB2中落入的分片数据,原本都是存在于DB0中的,而DB2原先就是DB0的从库,所以也具备之前DB0中数据,因此采用这种扩容法,基本上无需做数据迁移!
好比现在要查询ID=10的数据,根据原本Hash(XX)%2的路由算法,会落入到DB0中读取数据,而根据现在Hash(XX)%4的路由算法,应该落入到DB2中读取数据,因为DB2具备原本DB0的数据,所以也无需在扩容后,再次从DB0中将数据迁移过来(DB1、DB3亦是同理)。
为了不占用存储空间,也可以在凌晨两点到六点这段业务低峰期,去跑脚本删除重复的数据,因为目前DB0、DB2之间的数据完全相同,都包含了对方要负责的分片数据,所以在跑脚本的时候就是要从自身库中删除对方的数据(DB1、DB3亦是同理)。
这种从库升级扩容法是双倍扩容法的进阶实现,但这种情况会浪费一倍的机器,所以适用于一些流量特别大的场景,因为在这种扩容法中无需做数据迁移,同时扩容之后的4个节点,也可以再为其配置4个从库,以便于下次扩容。
当然,如果你感觉这种做法太浪费机器,也可以使用传统的双倍扩容法,即每次扩容之后,要手动从原本的库中将分片数据迁移过来,如果数据量较大时,迁移数据的时间会较长,所以只能做离线迁移。
同时在离线迁移的过程中,线上数据还有可能发生变更,所以离线迁移后还需要核对数据的一致性,过程会更繁琐一些,所以省机器还是省麻烦,需要诸位在两者之间做抉择。
如果你在的公司财大气粗,我推荐从库升级法,因为从库升级法不仅仅能够避免数据迁移,而且还能保证高可用,当主节点宕机时可以让从节点直接上线顶替,再配合Keepalived+VIP做重启,基本上能够让数据库真正达到7x24小时不间断运行。
异步双写扩容法
前面聊到的水平双倍扩容法,仅仅只是扩容时的一种方案,除此之外还有另一种方案称之为异步双写扩容法,大体示意图如下:
对于需要扩容时的情况,首先依旧把新的数据写入到老库中,然后写完之后同步给MQ一份,后续再由MQ的消费者去将新数据写到新库中,同时新库在这期间,会去同步老库中原有的数据,这个动作持续到所有旧数据全部同步完成后,再以老库作为校验基准,核对数据无误后,再将模式切换为扩容后的分库模式:
切换到分库模式后,记得在业务代码中去掉双写的逻辑,改为路由分片的逻辑,稍微总结一下整个流程,如下:
- 第一步:修改应用服务代码,加上MQ双写方案,配置新库同步老库数据,然后部署。
- 第二步:等待新库同步复制老库中所有老数据,期间新写入的数据也会通过MQ写入新库。
- 第三步:老库中的所有老数据全部同步完成后,以老库作为校验基准,校对新库中的数据。
- 第四步:校对新老库之间的数据无误后,修改应用配置和代码,将双写改为路由分片,再次部署。
看到这里相信诸位已经明白了两种扩容方法,这两种方案都是分库分表扩容时最常用的方案,但异步双写扩容法,更适用于垂直分库后的第一次单节点扩容。而水平双倍扩容法,则适用于水平分库后的第N次扩容。
其实异步双写方案,也可以用来做后续的节点扩容,但会相对来说比较麻烦一些。
4.6、分库后的多维度查询问题
之前单库的一张表,好比以zz_users用户表为例,既可以通过user_id检索数据,如:
- select * from zz_users where user_id = 10086;
也可以通过user_name进行数据的查找,如:
- select * from zz_users where user_name = '竹子爱熊猫';
但是现在水平分库后呢,执行SQL究竟该落入哪个库是根据路由键和路由算法来决定的,假设这里路由键为user_id,此时通过user_id查询当然没有问题,但通过user_name查询时,就无法通过路由键定位具体DB了,那又该怎么办呢?解决方案如下:
- ①淘宝方案:对于订单库实现了多库多维路由键拆分,直接用了三个水平库集群,一个以用户ID作为路由键,一个以商户ID作为路由键,一个以订单时间作为路由键,三个分库集群中数据完全相同,从而满足不同维度查询数据的业务需求。
- ②数据量小的时候时,可以通过ES维护路由键的二级索引,如1:竹子、2:熊猫....。
如果业务数据量特别巨大,可以选择第一种方案做分库集群,但如果数据量不是百亿级别的规模,则可以通过ES建立路由键的二级索引,当基于非路由键字段查询时,先从ES中查到路由键值,然后再根据路由键查询数据库的数据返回。
4.7、外键约束问题
一般的项目基本上都会存在主外键,虽然不会在表上添加主外键约束,但也会从逻辑上保障主外键关系,但经过水平分库后,所有的读写操作会基于路由键去分片,那此时以订单表、订单详情表为例,假设用户选择了三个购物车的商品提交订单,此时会产生一条订单记录、以及对应的三条订单详情记录,关系大致如下:
- 订单数据:order_id=1, pay_money=8888, user_id=666, shop_order_row=3, ......
- 订单详情数据: order_info_id=1, shop_name='黄金竹子', shop_count=1, unit_price=6000..... order_info_id=2, shop_name='白玉竹子', shop_count=2, unit_price=1111..... order_info_id=3, shop_name='翠绿竹子', shop_count=1, unit_price=666.....
假设商品库有两个水平节点,两个商品库都具备订单表、订单详情表,而订单表的路由键是order_id,订单详情表的路由键是order_info_id,数据分片的路由算法为取模,那此时数据的落库情况为:
- order_id % 2:1 % 2 = 1,order_id=1的订单数据落入DB1商品库中。
- order_info_id % 2: 1 % 2 = 1、3 % 2 = 1,order_info_id = 1、3的订单详情数据落入DB1商品库中。 2 % 2 = 0,order_info_id = 2的订单详情数据落入DB0商品库中。
但此时order_id=1,order_info_id=1、2、3的数据是存在外键关系的,如果按照上述的路由方式去对数据做分片,最终就会导致通过order_id=1的订单ID查询订单详情时,只能在DB1中查询到两条订单详情数据,这个问题显然是业务上无法接受的。
所以面对于这种业务上存在主外键关系的表,必须要能够确保外键所在的数据记录,必须要和主键数据记录落到同一个库中去,否则后续无法通过主键值查询到完整的数据,对于这种情况的解决方案是通过绑定表去实现(绑定表也是分库分表中的一个新概念,这里放到后面实践篇讲解,目前简单了解即可)。
五、分库后程序怎么访问数据库?
在之前的单库模式下,业务系统需要使用数据库时,只需要在相关的配置文件中,配置单个数据源的地址、用户、密码等信息即可。但分库分表后由于存在多个数据源,程序怎么样访问数据库,配置和代码该怎么写呢?这是一个更应该关心的问题,具体可分为几种方式:
- 编码层:在代码中通过框架提供的数据源动态切换类实现,如Spring.AbstractRoutingDataSource类。
- 框架层:一般的ORM框架也会提供切换数据源的实现类,比如MyBatis.Interceptor接口拦截SQL实现。
- 驱动层:在JDBC驱动层拦截SQL语句,然后改写SQL实现,如Sharding-JDBC框架的原理就是如此。
- 代理层:所有使用数据库的业务服务都连接代理中间件,由中间件来决定落库位置,如MyCat实现。
- 服务层:如今较为流行的分布式数据库,基本上都自带分库分表功能,如TiDB、OceanBase....
一般编码层或框架层都无法单独实现数据源的切换,两者必须配合起来完成,用MyBatis.Interceptor接口拦截SQL语句,然后根据SQL中的路由键做运算,最终再通过
Spring.AbstractRoutingDataSource类去动态切换数据源。但这种方案的工程量很大,实现过程也较为繁杂,所以下面直接来看一些成熟的方案,如下:
- 工程(依赖、Jar包、不需要独立部署,可集成在业务项目中) 淘宝网:TDDL 蘑菇街:TSharding 当当网:Sharding-Sphere-JDBC
- 进程(中间件、需要独立部署的第三方进程) 早年最热门、基于阿里Cobar二开的MyCat 阿里B2B:Cobar 奇虎360:Atlas 58同城:Oceanus 谷歌开源:Vitess 当当网:Sharding-Sphere-Proxy
其实早些年大家听说的比较多的应该是MyCat这款分库分表中间件,也包括前些年一般用的比较多也是它,但最近几年MyCat不再进一步优化了,而且其核心创始人之一整天在技术群里吹水卖课,导致原本前景无限的一款产品给玩凉了,关于其创始人Leader-us的一些故事大家可参考>>知乎回答<<,这里个人不做过多评价。
先如今要考虑做分库分表时,可首先选用当当网的Sharding-Sphere框架,早些年原本只有Sharding-JDBC驱动层的分库分表,但到了后续又推出了代理层的Sharding-Proxy中间件,最终合并成立了Sharding-Sphere项目。
近几年该项目也交由Apache软件基金会来孵化,并且列入到顶级项目孵化列表,随着Apache接手后也有了充实的后背保障,因此更建议分库分表时选用Sharding-Sphere来实现,官网:《Sharding-Sphere官网》。
从社区生态和GitHub的趋势来看,基本上近些年的增长也十分迅猛,并且Sharding-Sphere中对分库分表后产生的一系列问题,也提供了一站式解决方案。
在实际的开发过程中,通常是先进行维度拆分形成微服务结构,然后再进行水平拆分
分库分表
比如我们有一张表,随着业务的不断进行,mysql中表中数据量达到了10亿,若是将数据存放在一张表中,则性能一定不会太好,根据我们使用的经验,mysql数据库一张表的数据记录极限一般在5000万左右,所以我们需要对进行分片存储(水平拆分),按照5000万一个单位来拆分的话,需要切片数量20个,也就是20个数据库表
如果将20个相同业务表存放在同一个数据库中,那么单个数据库实例的网卡I/O、内存、CPU和磁盘性能是有限的,随着数据库访问频率的增加,会导致单个数据库实例和数据库达到性能瓶颈,因此我们需要将20个表分到多个数据库和多个数据库实例中,具体的评估如下:
【TODO 对数据库实例和数据库表的数量的评估】
如何进行分库分表
分库分表是对数据库拆分的一种解决方案,根据实施切片逻辑的层次不同,我们将分库分表方案大致分为三大类:客户端分片、代理分片和支持事务的分布式数据库
- 客户端分片
所谓的客户端分片即在使用数据库的应用层直接操作分片逻辑,分片规则需要在同一个应用的多个节点间进行同步,每个应用层嵌入一个操作切片的逻辑实现。
在客户端分片,目前主要有以下三种方式:
- 在应用层直接实现
这是一种非常通用的解决方案,直接在应用层读取分片规则,解析分片规则,根据分片规则实现切分的路由逻辑,从应用层直接决定每次操作应该使用哪个数据库实例中的对应的数据库
这种解决方案虽然有一定的代码侵入,但是实现起来比较简单,但是切片的逻辑是自己开发的, 如果生产上遇到了问题,能快速定位解决;
当然这种方式也存在缺点:代码的耦合度比较高,其次这种实现方式会让数据库保持的链接比较多,这要看应用服务的节点数量,需要提前进行容量上的评估
- 通过定制JDBC协议实现
这种解决方案主要是为了解决1中解决方案中的代码耦合,通过定制JDBC协议来实现(主要是针对业务逻辑层提供与JDBC一致的接口),让分库分表在JDBC的内部实现
目前当当网开源的框架:Sharding JDBC 就是使用这种解决方案来实现的
- 通过定制ORM框架实现
目前ORM框架非常流行,流行的JPA、Mybatis和Hibernate都是优秀的ORM框架,通过定制ORM框架来实现分库分表方案,常见的有基于Mybatis的分库分表方案的解决;
- 代理分片
代理分片就是在应用层和数据库层之间添加一个代理层,把分片的路由规则配置在代理层,代理层对外提供与JDBC兼容的接口给应用层,在业务实现之后,在代理层配置路由规则即可;
这种方案的优点:让应用层的开发人员专注于业务逻辑的实现,把分库分表的配置留给代理层处理
同样的业务存在缺点:增加了代理层,这样的话对每个数据库操作都增加了一层网络传输,这对性能是有影响的,同时需要维护增加的代理层,也有了硬件成本,线上生产环境出现了问题,不能迅速定位,需要有一定的技术专家来维护
我们常见的 Mycat就是基于此种解决方案来实现的
- 支持事务的分布式数据库
支持分布式事务的框架,目前有OceanBase、TiDB框架,这些框架将可伸缩特定和分布式事务的实现包装到了分布式数据库内部实现,对使用者透明,使用者不需要直接控制这些特性,但是对事务的支持不如关系型数据,适合大数据日志系统、统计系统、查询系统、社交网站等
分库分表的架构设计
上面我们介绍过数据拆分的两种方式:垂直拆分和水平拆分;
拆分方式 | 优点 | 缺点 |
垂直拆分 | 1. 拆分后业务清晰,拆分规则明确 | 1. 部分业务表无法进行关联、只能通过接口的方式来解决,提高了系统的复杂度 |
水平拆分 | 1. 单裤单表的数据保持一定的量级,有助于性能的提高 | 1. 切分后数据是分散的,很难利用数据库的关联查询,跨库查询性能较差 |
综上所述,我们发现垂直拆分和水平拆分具有共同点:
- 存在分布式事务问题
- 存在跨节点join的问题
- 存在跨节点合并排序、分页的问题
- 存在多数据源管理的问题
垂直拆分更偏向于业务拆分的过程,在技术上我们更倾向于水平切分的方案;
常见的分片策略:
- 按照哈希切片
对数据库的某个字段进行来求哈希,再除以分片总数后取模,取模后相同的数据为一个分片,这样将数据分成多个分片的方法叫做哈希分片
我们大多数在数据没有时效性的情况下使用哈希分片,就是数据不管是什么时候产生的,系统都需要处理或者查询;
优点 | 缺点 |
数据切片比较均匀,数据压力分散的效果好 | 数据分散后,对于查询需求需要进行聚合处理 |
- 按照时间切片
按照时间的范围将数据分布到不同的分片上,比如我们可以将交易数据按照与进行切片,或者按照季度进行切片,由交易数据的多少来决定按照什么样的时间周期来进行切片
这种切片方式适合明显时间特点的数据,常见的就是订单历史查询
分布式事务
本博文不进行分布式事务的分析和实践,后期我会更新一系列的分布式事务的博文,一起探讨分布式事务的原理、解决方案和代码实践等,本博文简单介绍了分布式事务的解决方案;
上面说到的,不管是垂直拆分还是水平拆分,都有一个共同的问题:分布式事务
我们将单表的数据切片后存储在多个数据库甚至是多个数据库实例中,所以依靠数据库本身的事务机制不能满足需要,这时就需要用到分布式事务来解决了
三种解决方案
- 两阶段提交协议
两阶段提交协议中的两阶段是:准备阶段和提交阶段,两个阶段都是由事务管理器(协调者)发起,事务管理器能最大限度的保证跨数据库操作的事务的原子性。
具体的交互逻辑如下:
优点 | 缺点 |
是分布式系统环境下最严格的事务实现防范, | 1. 难以进行水平伸缩,因为在提交事务过程中,事务管理器需要和每个参与者进行准备和提交的操作协调 |
- 最大努力保证模式
这是一种非常通用的保证分布式一致性的模式,适合对一致性要求不是十分严格的但是对性能要求比较高的场景
最大努力保证模式:在更新多个资源时,将多个资源的提交尽量延后到最后一刻进行处理,这样的话,如果业务流程出现问题,则所有的资源更新都可以回滚,事务仍然保持一致。
最大努力保证模式在发生系统问题,比如网络问题等会出现问题,造成数据一致性的问题 ,这是就需要进行实时补偿,将已提交的事务进行回滚
一般情况下,使用消息中间件来完成消费者之间的事务协调,客户端从消息中间件的队列中消费消息,更新数据库,此时会涉及到两个操作,一是从消息中间件消费消息,二是更新数据库,具体的操作步骤如下:
- 开启消息事务
- 接收消息
- 开启数据库事务
- 更新数据库
- 提交数据库事务
- 提交消息事务
上述步骤最关键的地方在5和6,如果5成功了,但是6出现了问题,导致消息中间件认为消息没有被成功消费,既有的机制会重新再消费消息,就会出现消息重复消费,这是需要幂等处理来避免消息的重新消费
其次我们还需要注意消息消费的顺序性问题,以及消费过程中是否调用远程接口等耗时操作
优点 | 缺点 |
性能较高 | 1. 数据一致性不能完美保证,只能是最大保证 |
- 事务补偿机制
以上提到的两种解决方案:两阶段提交协议对系统的性能影响较大,最大努力保证模式会是多个分布式操作互相嵌套,有可能互相影响,那么我们采用事务补偿机制:
事务补偿即在事务链中的任何一个正向事务操作,都必须存在一个完全符合回滚规则的可逆事务。如果是一个完整的事务链,则必须事务链中的每一个业务服务或操作都有对应的可逆服务。对于Service服务本身无状态,也不容易实现前面讨论过的通过DTC或XA机制实现的跨应用和资源的事务管理,建立跨资源的事务上下文.
我们通过跨银行转账来说明:
首先调用取款服务,完全调用成功并返回,数据已经持久化。然后调用异地的存款服务,如果也调用成功,则本身无任何问题。如果调用失败,则需要调用本地注册的逆向服务(本地存款服务),如果本地存款服务调用失败,则必须考虑重试,如果约定重试次数仍然不成功,则必须log到完整的不一致信息。也可以是将本地存款服务作为消息发送到消息中间件,由消息中间件接管后续操作。
最后添加的重试机制是最大程度的确保补偿服务执行,保持数据的一致性,如果重试之后还是失败,则将操作保存在消息中间件中,等待后续处理,这样就更多了一重保障
分库分表引起的问题
由于将完整的数据分成若干份,在以下的场景中会产生多种问题
- 扩容与迁移
在分库分表中,如果涉及的分片已经达到了承载数据的最大值,就需要对集群进行扩容,通常包括以下的步骤
- 按照新旧分片规则,对新旧数据库进行双写
- 将双写前按照旧分片规则写入的历史数据,根据新分片规则迁移写入新的数据库
- 将按照旧的分片规则查询改为按照新的分片规则查询
- 将双写数据库逻辑从代码中下线,只按照新的分片规则写入数据
- 删除按照旧分片规则写入的历史数据
2步骤中迁移数据时,数据量非常大,通常会导致不一致,因此需要先迁移旧的数据,洗完后再迁移到新规则的新数据库下,再做全量对比,对比评估在迁移过程中是否有数据的更新,如果有的话就再清洗、迁移,最后以对比没有差距为准
- 分库分表维度导致的查询问题
进行了分库分表以后,如果查询的标准是分片的主键,则可以通过分片规则再次路由并查询,但是对于其他主键的查询、范围查询、关联查询、查询结果排序等,并不是按照分库分表维度查询的;
这样的话,解决方案有以下三种:
- 在多个分片表中查询后合并数据集,这种方式的效率最低
- 冗余记录多份数据,方便查询, 缺点是需要额外维护一份数据,浪费资源
- 通过搜索引擎解决,但如果实时性要求很高,就需要实现实时搜索,可以利用大数据相关特性来解决
- 跨库事务难以实现
同时操作多个库,则会出现数据不一致的情况,此时可以引用分布式事务来解决
- 同组数据跨库问题
要尽量把同一组数据放到同一数据库服务器上,不但在某些场景下可以利用本地事务的强一致性,还可以是这组数据自治
主流的解决方案
目前针对mysql的分库分表,行业内主流的解决方案有:ShardingJDBC、Mycat
Mycat代理分片框架
Mycat是一款面向企业级应用的开源数据库中间件产品,他目前支持数据库集群,分布式事务与ACID,被普遍视为基于Mysql技术的集群分布式数据库解决方案
Mycat支持多种分片规则:
- 枚举法
- 固定分片的hash算法
- 范围约定
- 求模法
- 日期列分区法
- 通配取模
- ASCII码求模通配
- 编程指定
- 截取数据哈希解析
- 一致性Hash
六、分库分表后患问题总结
在本章中补齐了分库分表带来的一系列问题的剖析,以及问题产生后相应的解决方案,但在这里仅给出了基本的解决方案,但并未对其进行落地实践,毕竟内容较多不可能面面俱到,具体的落地会在后续的章节中徐徐道来~
最后附言:所谓的架构师并不是指能做架构选型即可,比如决定缓存用Redis、消息中间件用RocketMQ、ORM框架用MyBatis-Plus、微服务框架用SpringCloud Alibaba.......,真正的架构是要能够清楚各层面中主流的技术方案,毕竟能够充分结合业务去做选型,最关键的是自身也得清楚选用某个技术栈后能够带来的性能收益,以及引入某项技术后会带来的风险问题及解决方案。
比如当你考虑对存储层做分库分表时,就得先考虑清楚本章中提到的所有问题,以及具体该如何解决,只考虑追求技术风潮,不考虑技术风险的做法,显然无法被称作一位合格的架构师。一名合格的架构除开能全面考虑到技术风险且给出解决方案外,应当还得将项目架构设计的更为合理才行,比如设计的技术架构具备很强的弹性伸缩能力,可根据业务的增长/萎靡进行动态的伸缩调整。
能否设计并将分库分表方案落地,这也是每一名技术人员进阶道路上的一个大门槛,当你迈过了这道门槛,距离成为一名初阶架构也就不远了,本章中聊到的一些疑难杂症,也希望能够引起诸君的思考,作为一位技术者,不应该仅关注平时开发中的业务,不应该被日常的增删改查蒙蔽双眼,更应该在工作空闲之余适当思索技术,否则你会很难真正跳出困境。