什么是分库分表,为什么要分库分表

介绍分库分表之前,要说下数据库架构的演进过程。最早的数据库是单体应用,和我们的业务系统部署在同一个机器上。随着业务发展,数据库和业务系统分开部署,大量的读请求会触发高频次的随机IO,这在一定程度上影响了写请求,且我们的业务几乎都是读多写少,因此数据库演变成了一主多从的部署方式,且实现了读写分离。写只写主库,读只读从库。架构类似如下:



一主多从


小米之前基于开源KingShard中间件做了一层代理,客户端发起请求后,代理会解析SQL,根据sql类型以及是否有显示指定主库来决定应该把请求发给主库还是从库。


读写分离一定程度上分摊了高并发请求和检索性能,但如果单库或者单表的数据量过大,其TPS/QPS以及查询能力都会下降。下面是我们现在一个业务系统的几张表,都几十G了。


下面是数据库健康状况监控的例子:


数据库监控


当单库或者单表的数据量过大时,无论如何进行性能调优(重建索引,优化SQL等,增加单机配置),都无法改变当前的读请求的性能,此时单DB的性能已经达到了瓶颈。如果读写请求频次不高,可以忍一忍。但如果还是读写非常频繁的业务,特别是高并发场景下,基本上整个业务会受到DB的拖累,严重的会导致整个系统崩溃。此时,就要考虑进行分库分表了。


分库分表的基本思想就是将之前揉在一个机器上的库和表根据某种规则进行拆分,将单表数据分散到多个字库或者多张表上,不同的库表会部署在不同的机器上,从而减轻单个节点的压力。类似下图,单张1000w条的数据表,根据某个规则拆分成10张表:


分表示例


怎么做分库分表;

拆分方式


拆分方式包括两种:垂直拆分和水平拆分


1)垂直拆分


垂直拆分主要是拆库,根据业务功能区划分,比如在电商系统中订单,商品,库存,履约,风控等业务应该使用独立的数据库。记得在2018年,那时我们后端所有业务的数据表都在一个库中,有个负责拼团的同学在自己代码中引入了一个bug,大概逻辑是遍历所有用户并在每个循环内都执行一次慢查询,用户数量应该有几十万。不出意外,在618的时候,系统崩掉了,导致30分钟都下不了单。导致出现问题的根本原因就是其他业务的慢查询拖垮了DB,从而影响了使用同一个DB的其他业务,尤其是下单,这简直是个灾难。因此,我们痛定思痛,开始进行拆库,各个业务线逐步将数据库拆分出去。


垂直分库


垂直拆分是和分布式系统相辅相成的,微服务的建立的前提应该就是底层数据源的隔离,否则毫无意义。那么垂直拆分的原则在上面也说了,要根据业务去划分,这个地方倒是不复杂。


2)水平分库分表


水平拆分包括既分库又分表,也可以不分库只分表,两种方式的目的都是将单张表数据分散到多个分表,每个分表的数量成倍减少,从而减轻了对单表的读写压力。既分库又分表的做法是不仅仅分表,还要根据一定规则分到不同的库中,不同的库可以部署在不同的机器上。相比于只分表不分库的做法,分库分表可以降低单台机器的瓶颈,毕竟单台机器的CPU,内存,磁盘IO,带宽等都是有限的。因此,通常情况下我们都会采用分库分表的做法。


水平分库分表


拆分规则


分库分表该如何去拆分?该采用什么规则将数据平均分散到各个分表上?是不是符合我们实际的业务场景?


目前业界通用的分表规则主要有以下几种:


按照范围分配

基于子库数量的哈希取模

一致性Hash

1、按照范围range分配


思想: 按照某个字段值以及某种规则拆分,每个子表都划分一定范围的数据。可以按照时间或者其他的分片字段。


优点: 基于范围查询或更新速度快,需要的数据可能落在同一张子表中,避免遍历全部子表。比如我们按照年分表,多数请求查询某一年的。


缺点: 有可能造成数据倾斜,数据不均衡。还是拿月份举例吧。对于电商系统,11月份大促期间的订单量要远远大于其他月份。


适用场景: 比较适合于有大量范围查询需求的场景。


2、Hash取模


思想: 选定某个分片字段,对子库(表)数量进行hash取模算出下标,从而决定落到哪一个分片上。比如用户 uid,分了16个库(分库按照序号排列 0~15)。 uid %16 计算出对应分库下标。


hash取模


优点: 数据相对比较均衡,基本上不会造成数据倾斜。


缺点: 最大的缺点就是不容易扩缩容。因为它是hash取模的,基数是子库数量,数量变化之后,需要重新计算下标。这种变化对数据的迁移相对比较麻烦的。


适用场景: 本分片规则是比较常用的规则,也是大家的首选。算法简单,其缺点可以说是瑕不掩瑜。因为我们在分表时可以尽量将字库数量设置多一些。或者可以评估未来几年的数据量。


3、一致性hash


思想: 是对按照实际子库数量hash取模的改进。首先对分片字段hash,随后对2^32除余,从而确保值落到2^32 - 1区间,按照顺时针找到第一个节点就是对应服务器的位置,算法是hash(key)%2^32。下面是网上找到的一个比较容易理解的图:


一致性hash


优点: 它的优点就是解决了第二个分片方案存在的问题,由于某台服务器节点只会影响该节点逆时针方向的节点,即影响数据范围比较小。因此相对于第二种,它在进行扩容缩容时所产生的影响比较小。


缺点: 从该结构上可以看出来,整个闭环很大,如果节点较少,非常容易造成数据分配不均衡。为了解决这个问题,引入了虚拟节点。虚拟节点本身不是真实存在,其只是真实节点的复制品,比如现在有个A,B,那么复制后就有A,A1,A2,A3;B,B1,B2,B3.他们像正常服务器节点那样分布在整个Hash闭环中,然后Key还按照正常寻址,寻址哪个,就找到对应得真实服务器节点。比如成熟的分库方案Mycat,就在一致性hash算法中引入了虚拟节点,虚拟节点的数量默认是真实节点的160倍。


适用场景: 如果未来可能会经历相对较频繁的扩容缩容可选择一致性hash算法。


拆分多少分表合适?


主要要考虑以下几个因素:


实例数考虑QPS的吞吐能力

分库数考虑扩容拆分

分表数考虑数据分布,性能提升

在做分库分表时,要充分考虑当下以及未来的数据量,避免或者减少扩容。


小米是如何做的?


做为一个精品电商平台,小米有品自16年成立起,业务在不断地扩展,在2020年年初,数据库中的几大ToC表已经很庞大了。比如用户优惠券数据表单表就达到了10亿多行,而且米粉节或者618,一次灌券就可以达到千万或者上亿级别,即存在较高的读写请求和并发处理。记得有个哥们在线进行了一次DDL,它要加个字段,虽然是晚上执行的,但还是造成了大量的死锁情况(基于此,我之前还写了一篇如何避免在线DDL出现死锁问题的文章哈哈,感兴趣的可以参考: 千与千寻-Mysql在线DDL操作 )。此时,在单表的情况下,性能已经出现了瓶颈。因此,我们首先拿优惠券开刀。


一、优惠券分库分表


刚开始我们也是对现有开源的分库分表技术做了一下调研,主要包括应用层面的和中间件层面两种。


技术选型


下面是我画的一个脑图:


分库分表技术调研


在做优惠券时,采用了Sharding-JDBC这种代码侵入式的方式。主要是考虑到其不需要任何的第三方依赖,只需要引入jar包即可,此外其支持所有的第三方数据库链接,此外其还内置了分布式主键id的生成以及支持分布式事务。嗯,当当虽然让这对夫妻搞得乌烟瘴气,但真的开发出了一个牛逼的东西,现在也属于Apache孵化的项目。看下其业务架构图:


Sharding-JDBC


在业务代码和底层DB之间,加了一个Sharding-JDBC的代理。对于我们应用来讲,完全可以无感知使用。除了Sharding-JDBC,还有另外一个产品叫Sharding-Proxy,其主要是为了提供对异构语言的支持,可以对任何实现Mysql协议的客户端实现无差别支持,且是以中间件的形式部署。架构如下:


Shardingsphere业务架构


确定的分库分表规则


优惠券只是进行了分库,即将单表数据分到多个子库上,每个子库的表名完全相同,每个数据库使用的用户名密码也都完全一致。这样做的好处就是可以大大减少业务层面的代码改动。


根据业务评估,选择了64个子库。分片规则采用用户id % 64,分片数据库名是在原dbname上加了序列号后缀。如db_00 ,db_01,,,,,,,db_63。结构如下:


二、订单表分库


优惠券做完分库之后半年,我们又开始了订单相关的分库分表。


和上次相同的是,我们依然采用的是分库,但不同的是,这次没有采用优惠券的分库方式,而是使用了小米自研的Gaea(目前已开源, GitHub - XiaoMi/Gaea: Gaea is a mysql proxy, it's developed by xiaomi b2c-dev team. )。


之所以考虑使用中间件的代理方式,是因为订单系统相对复,还涉及到新老订单系统的同时改动,如果使用sharding-jdbc这种应用层代理,研发需要改动的地方过多,风险较大。而反观gaea,由我们DBA团队统一维护和管理,分库之后,依然走小米自研的DB代理,对业务方来说是透明的。只需要提供给业务方用户名密码即可,业务方在代码层面不用做任何改动。


Gaea


分库分表如何做数据迁移?

数据迁移包括存量和增量数据。


主要方案:


1、分库全量数据写入


思想:数据向所有分库全量写入,随后删除。


优点:迁移简单,在切换前完成数据同步后,停掉交易即可完成切换。


缺点:这种方案需要后续删除非对应分片的数据,风险较大。


2、业务双写


思想:存量数据全量导入分库,增量数据通过业务系统双写到原库和分库中从而达到一致;


优点:保证


缺点:业务系统实现双写改动比较麻烦


3、存量导入,增量同步


我们采用的是第三种方式,具体操作流程如下:


1、将数据库分成64个子库;


2、找一台Mysql Slave从库,停下来;


3、根据我们的分片规则 uid % 64 dump出 64个源文件;


4、将文件分别source导入到对应的子库中,该步骤是非常耗时的,存量文件基本有几十G,几百G大小,而且中间不能停,这个要DBA操作执行的;


5、重启该台slave,并将新增的数据实时同步到分库中;


6、在切换前,停止线上交易,我们当时晚上停了几个小时;


7、上线分库数据源代码;


8、内网验证;


9、开启线上交易;


以上是进行数据迁移以及上线前的一些工作。其中第5步要说一下,即增量数据的同步。我们采用了一套组合拳,使用的是canal-server+canal-adapter+sharding-proxy完成了增量同步。


这里简单介绍一下cannal,是阿里开发的一个产品,用于完成数据库的增量同步,并将结果发送到下游,如消息队列,ES等。


Canal


下面是我们完成旧库到分库的增量同步流程图:


数据增量同步架构图


基本思想就是利用canal通过binlog同步增量数据,其只负责接收,canal-adapter负责将数据发送到下游,即Gaea或者Sharding-Proxy,随后gaea或Sharding-proxy根据配置的分片键分库规则将数据分配目标子库中。


分库分表可能出现的问题;

1、join联查问题


这是比较普遍的问题,join语句在分库分表中本身就应该避免,通常在ToC的业务都不使用join,高性能Mysql也说尽量使用多次的短语句查询。比如可以使用uid查支付表以及订单表。就算是join也尽量实现落到同一个子库中的数据表进行join,即同一用户的相关数据表落到相同的子库中。这也是目前多数成熟的中间件都支持的场景,比如Mycat,Gaea,他们称之为ER分片。Sharding-JDBC虽然支持跨库跨表查询,但性能非常差,它会遍历取所有相关的子库表,然后聚合,最后算出笛卡尔积。所以尽量避免这种做法。


如果还有其他更多的所有数据的Join联查操作,比如要统计分析,可以再做个合库(我们就是这么干的,就是为了给数据组使用)。


2、跨节点的分页、排序、聚合等


其实这个实现还是比较简单的,如果where条件指定了分片字段,那就直接落到同一个分片上执行即可;如果没有分片字段,就在每个分片上执行相应的函数,返回结果后,将所有结果进行整合,计算,获取最终的结果。


举例,现在要进行一个分页查询:


select oid,pay_time from shop_order order by pay_time limit 0,100;


在每个分片上查出100个,最终比较返回.


当然,这种聚合、分片等查询最好不要在分库分表的情况下进行。数据一多,分的库和表越多,性能越差。


3、全局性ID


实际应用中,对于分布式系统,我们希望能有一个ID生成器用来生成全局的、唯一的ID,比如用户id,订单号,支付单号等等。


目前,存在的主要有以下几种。


1、UUID


这个应该都很熟悉,通用唯一识别码,它是由32位的16进制数组成的。


类似:00112233-4455-6677-8899-aabbccddeeff。


其生成规则有很多的版本,有根据网卡或时间生成的,有随机生成的,有基于时间生成的等等。具体可见: UUID维基百科


但其并不适合做为我们实际系统中的ID生成器,因为其是随机的,无序的,在实际中我们系统是有序的,此外无序的索引在Mysql中查询数据也是有性能的影响,容易造成频繁的页分裂。此外,UUID标识位较多,较占空间。


2、数据库主键自增


使用Mysql的主键当作ID生成器,不同的系统可以统一通过DB代理访问数据库,并获取生成的主键ID。


这种方式是相对比较简单的一种做法,不需要额外做什么操作,只需要部署好DB。但其本身也存在缺点,因为该方式是强依赖DB的,假如DB挂了,那服务完全不可用。且ID生产性能受限于Mysql性能。此外,如果后续Mysql分库分表的话,很难保证ID不出现重复。


3、Redis,zk


其实说实话,基本没有用的,虽然两个都能实现。然而Redis非常可能导致数据丢失,从而出现重复。zk的话本身集群的性能一般。


4、SnowFlake算法


该算法是Twitter开源的一个算法,生成的ID是由63位数字组成的long型整数,41位的时间戳+5位的数据中心id+5位机器id+12位递增序列号。


第1位是符号位,0是正数,1是负数。


41位时间戳是毫秒时间戳,不过这个时间戳并不是绝对时间戳,而是一个差值,差值是当前时间戳减去起始时间戳,而起始时间戳的值取决于自己的选择。时间戳最多可以用69年。


中间的10位是机器ID,由5为数据中心ID和5为机器ID组成,也就是总共可部署1024个机器。


最后的12bit是循环位,用来同一毫秒内生成不同的id,12位最多可以生成4095个,因此同一毫秒内,允许生成4095个ID。


该算法最大的缺点就是性能超好,没有其他的依赖,全局唯一。如果部署1024个机器的话,理论上每秒可产生40多亿个id。


但该算法有一个众所周知的缺点就是时钟回拨的问题,即因为机器的原因,时钟回拨了,可能导致当前时间戳还要小于之前的时间戳,这样就出现ID重复。


针对这个问题,目前有很多的解决方案。


方案1:每台都维护一个上个时间戳 lastTimestamp,每次都会将当前时间戳和上一个时间戳进行比较。如果差值较小(有多小完全取决于实际业务需求,可以5ms,可以10ms),可以等待,一直到时间超过lastTimestamp;如果差值较大,无法一直阻塞,可以直接抛出异常,然后将时钟回滚。或者增加在后面再增加扩展位(但我觉得这种方式不妥,因为扩展位的位数也是有限的,不是解决问题的优先方案);


方案2:百度提了一个UIdGenerator,源码: UIDGenerator .


其基本思想是不采用SnowFlake算法中传统的取当前时间戳,而是通过AtomicLong类型,采用逐步+1的方式生成时间戳,这里只需要维护一个初始时间戳即可。这样不需要再依赖服务器的时间,不会出现回拨问题。


当然这样做的弊端就是,如果你希望通过序列号来获取时间的话,这样做是不可取的,因为通过这个方法获取的不一定就是那时实际的时间戳。


目前这个项目,最后一次维护还是3年前。


5、美团Leaf


其主要有两个方案:Leaf-segment方案和Leaf-snowflake方案。


Leaf-segment方案就是基于数据库自增ID来实现的,它在数据库的性能上的提升做了一定的改进,即它一利用代理去一次生成一段的ID(segment),然后存储下来,等用完了再去请求数据库。这样做的一个明显有点就是没必要每次生成ID都需要请求一次数据库,减少IO,提升了性能。此外,在此基础上,美团又提出了buffer的优化,即当已获取的一段ID号用了一定数量后,且未完全耗尽时,会异步再去请求数据库并存储下来。这对客户端来讲是无感知的,因此性能又得到了极大的提升。


Leaf-snowflake方案是一种类似snowflake的一种实现方式。它主要是采用了ZK去管理配置机器节点。


但说实话,我不知道这个方案有什么实际的意义,我是觉得用了ZK反倒影响性能。


关于Leaf更详细的介绍可见: Leaf——美团点评分布式ID生成系统


下面是用JAVA实现的SnowFlake算法,改进之处就是对于时钟回拨的处理,参考了美团。


public class OrderIdGeneratorServiceImpl implements OrderIdGeneratorService {

......

}

上述的workId应该采用ZK管理比较合适,我上面就是随意瞎写的,不规范,因为workerid是和机器有关的,每台机器是一样的。


4、分布式事务问题


分库分表之后会涉及到多个实际不同的节点的事务提交,因此就要考虑分布式事务了。分布式中间件比如Mycat,以及jar包Sharding-JDBC都是支持分布式事务的。而分布式事务的底层实现逻辑主要包括XA事务,TCC,AT,消息表等几种方式。


XA事务


XA事务的基础是两阶段提交以及其中的角色:资源管理器和事务协调器。


流程:


1、第一阶段。资源管理先执行prepare操作,但不实际提交;


2、事务协调器发现所有资源管理器都ready了,就会告知资源管理器执行commit操作,反之就是执行rollback操作。


XA事务的最大优点是实现起来比较简单。但最大的问题就是性能差,事务管理器要确保所有资源的一致性,容易造成同步阻塞。在并发场景下使用XA事务的话,基本上这个系统也就没法用了。因此XA也很少用于实际的业务中。


TCC事务


Try-Commit-Cancel,该机制是相当于XA的一种补偿性机制。即事务可以先执行commit,如果后面的rollback了。对于前面的要执行逆向操作。再修改回原有的状态。


TCC的目的就是减少底层数据源的依赖,由业务自信决定事务的粒度。


Try,执行prepare,预留资源;


Confirm,不做任何检查,真正地进行资源操作;


Cancel ,执行回滚操作。


相比于XA,TCC的并发性更好,因为由业务自己决定粒度,但缺点就是可能业务自己要写很多的代码,实现起来相对复杂,就算是借助分布式框架,同样需要自己写prepare,confirm,cancel等流程。


说一个我们有品在做分布式事务的一个实现。即订单在下单的时候扣除优惠的活动库存:


1、prepare阶段。首先,当开始执行优惠结算的时侯,订单调用优惠的服务,优惠服务首先会向资源表中写入订单的唯一资源并进行锁定,唯一键是资源id+资源类型。该阶段会将资源锁定(根据status字段实现);


2、commit。接下来,如果订单正常执行下单或者结算操作,会调用优惠的commit接口,优惠服务会将属于该订单的资源表commit,其实也是修改status字段;


3、cancel阶段。如果订单在执行过程中出现问题,那么必须调用优惠的服务进行回滚,优惠要将资源表回滚,也是改状态,然后根据具体优惠类型进行具体回滚操作,比如回滚活动库存等。


上面是正常的流程,还是要注意其他的问题,比如幂等,空回滚和空悬挂等问题。


幂等 :即如果重复调用commit或者cancel都能保证结果的一致性;


空回滚: 即没有执行try,直接执行cancel,通常是发生在try因为阻塞长时间没有执行,就调用了cancel。此时,cacel应该什么也不做,这就是空回滚。想实现这个还是比较容易的,执行cancel时要判断try是否执行,主要根据相应的id判断即可,比如订单库存,可以使用订单号,商品库存可以使用sku,活动库存可以使用活动id和sku等来判断是否存在。


悬挂: 如果此时已经执行了上步的空回滚,此时try又开始执行了,那么try应该判断当前的资源是否已经回滚了,如果回滚了,就不应该执行try操作。


对于幂等性,空回滚,悬挂,实现的前提都是通过建立事务资源id,状态等来实现。


TCC的实现可以使用成熟的分布式框架如Seata,也可以自实现,我们部门优惠服务就是自实现的,主要使用的是ebay提出的本地消息表的概念。将资源通过数据表以及Redis来实现资源的锁定,提交以及回滚等。现在的消息队列Rocketmq也支持事务消息,其保证的是最终一致性。


RocketMQ实现分布式事务


Saga


它的思想是对于每一个资源都配置一个补偿节点,在提交事务时,依次执行本地事务(可以是异步的),如果其中有失败的,就执行已经执行过的事务的补偿操作。可以看到,它没有prepare阶段,每个阶段直接就提交事务,但如果有失败的,可以自动执行反向的回滚操作。因此,这种模式的并发性更好一些。



我推荐使用Seata实现分布式事务,对于Sharding-JDBC这种分库分表代理来说,已经内置了Seata。


分库分表的下一代解决方案;

分库分表是目前用于提升DB性能的比较通用的解决方案,但分库分表后,随着数据量不断增大,仍然有可能遇到瓶颈。如果在继续扩大分库分表的数量,会比较麻烦,如果之前是按照hash取模进行分片的,还要重新对数据进行分片。所以,分库分表是较好的提升性能的方案,但却不是最终的解决方案。


下一代解决方案应该是原生分布式数据库,开发者和DBA都不需要自己分库分表,所有的工作都是原生数据库来完成,使用者仍然可以像单库单表一样使用。


其实Mysql本身也提供了分布式解决方案,比如Mysql NDB Cluster,但cluster是基于内存的(最初的版本要所有数据都在内存,现在只需索引在内存中即可),比较耗费机器,且部署比较复杂,每台机器都需要启动多个进程,通常都不会选用。但在电信业务中用得还是比较多的。


目前国内也有很多比较好的分布式数据库产品,如TiDB,还有俄罗斯的ClickHouse,阿里的云分布式数据库,他们共同点都是分布式是存储数据,可以动态扩缩容,且采用了列式存储。不过ClickHouse主要还是用于OLAP场景,即更适合于处理读请求。而TiDB在处理OLTP,OLAP方面都提供了较好的方案,而且TiDB和Mysql可以实现无缝切换,其提供了从Mysql向TiDB迁移的方案。目前我们公司已经逐步用起来了,现在的初级用法是使用TiDB做从库,不过后续会考虑逐步完全替代Mysql。


下面是官网给的tiDB介绍:


TiDB 是 PingCAP 公司自主设计、研发的开源分布式关系型数据库,是一款同时支持在线事务处理与在线分析处理 (Hybrid Transactional and Analytical Processing, HTAP) 的融合型分布式数据库产品,具备水平扩容或者缩容、金融级高可用、实时 HTAP、云原生的分布式数据库、兼容 MySQL 5.7 协议和 MySQL 生态等重要特性。目标是为用户提供一站式 OLTP (Online Transactional Processing)、OLAP (Online Analytical Processing)、HTAP 解决方案。TiDB 适合高可用、强一致要求较高、数据规模较大等各种应用场景。


我觉得最重要的就是一个纯天然的分布式关系型数据库,其扩展性非常好,可以实现弹性的扩缩容。看到这儿,是不是觉得分库分表太死板了,也快过时了。未来Mysql会逐步被分布式数据库取代。


有话要说