高并发大流量情况下带来的海量数据分库分表的正确姿势

时间:2024-03-30 11:24:52

注意!!!

不要为了分库分表而分库分表!!!
引入SOA架构中的一句话:架构不是一蹶而起的,而是慢慢演进的


一、为什么需要分库分表?

请求数太高:
在高并发情况下,大量请求落入数据库,最终会导致数据库的活跃连接数增加,进而逼近甚至达到数据库可承载活跃连接数的阈值。在业务Service层来看就是,可用数据库连接少甚至无连接可用。接下来面临的就是并发量、吞吐量、连接异常、崩溃、宕机;

数据查询慢:
一、单表或单库数据量过大引起的,具体参考第三条;
二、单库整体并发连接数接近系统阈值,从而导致此请求获取不到连接数或者已经获取但是遇到CPU瓶颈,导致SQL所查询的表就算数据行很少也同样出现查询过慢的现象;

数据量太大:
一、当一个库的数据存储量太大时,就算每张表的并发数不多,但是因为是海量数据,单库中存在大量的数据表,每张表都有一部分并发请求,导致最终单库的连接数阈值(最大连接数默认100,最大可设为16384,但是一般按硬件和库的业务属性来合理配置,一般在500-1200之间)成为数据库的瓶颈;
二、当一张表数据太多时也导致单表查询速度严重下降,虽然innoDB存储引擎的表允许的最大行数为10亿,但是如果一张表的数据行记录达到上亿级,那么我就算通过索引去查询一条数据,它也需要至少经过上十次到几十次磁盘IO,从而导致单表查询速度直线下降;一般一张表的数据行为1000万左右是最合适的,因为表数据为1000万时建立的索引如果是B+Tree类型的话一般树高在3~5之间,所以查询的速度自然也是很快速的;

单体架构通病:
单库中遇到问题需要修复时影响了整个库中所有数据,而分库时只需要修复某个库就好了;

其实以上问题都是属于数据库遭遇到了瓶颈,但是只不过根据情况不同分为不同类型的数据库瓶颈,但是最终对于客户端而言就是数据库不可用了或者变慢了。

二、数据库瓶颈

2.1、IO瓶颈

磁盘IO瓶颈:
一、磁盘读IO瓶颈,数据库的缓存机制会将一些经常查询的热点数据放入缓存区,避免每次请求都走磁盘IO的方式去拿取数据,而当热点数据太多,数据库缓存放不下,每次查询时会产生大量的IO,降低查询速度;
解决思路: 尽量分散数据,避免大量热点数据聚集在一个库(垂直分库、水平分库),减少不必要的字段数据(垂直分表);
二、单表数据过多导致查询数据需要经过大量磁盘IO后才拿到数据集;
解决思路: 尽量将一张表热点字段与冷数据字段分离(垂直分表),将数据行拆分为多张1000万左右的表(水平分表);

网络IO瓶颈:
当一个请求的SQL执行后返回的结果集数据太多,导致网络带宽不够,网络带宽成为瓶颈。
解决思路: 将一个请求分散到不同的库中去查询数据(分库),避免造成单节点数据结果集过大导致的网络瓶颈;

2.2、CPU瓶颈

一、客户端请求的SQL问题,如SQL中包含join联表查询,group by,order by之类的聚合操作或者非索引字段条件查询等,造成CPU运算操作消耗直线增加;
解决思路: SQL优化,建立合适的索引,在业务Service层进行业务计算进行数据聚合;
第二种:单表数据量太大,查询时扫描的行太多,SQL效率低,CPU率先出现瓶颈;
解决思路: 水平分表;

下面我将以我以前负责的一条业务线给大家演示分库分表的架构演进。

三、传统业务单库架构到分库分表

13年期间我所在的公司新开一条业务线切入金融领域,最开始的因为担心风险,没有投入太多的成本,处于一个试错阶段,所以当初就把所有业务都怼入一个war包,所有数据共享一个库资源,如下:
高并发大流量情况下带来的海量数据分库分表的正确姿势

而在那段时间,金融领域快速发展,慢慢的,Java搭建的金融核心系统开始出现响应变慢,甚至时不时宕机,整个系统由于单节点的Tomcat遇到了瓶颈,后来实在因为Tomcat三天俩头宕机,开始了业务架构的改进,如下:
高并发大流量情况下带来的海量数据分库分表的正确姿势

(为什么没有使用Nginx对Tomcat进行横向拓展做水平集群,因为当时考虑到业务发展速度,如果仅仅只是通过Nginx来做,可能以后还是需要对架构进行升级,所以到后来一致决定直接引入SOA架构对系统进行改造)
慢慢的到后来发现,Java程序这一边的确是可以抗住每天的流量了,但是因为在做Java程序研发的同时没有去做数据库的拓展,所以当时还是共享库,慢慢的,流量被放入数据库(有些同学可能会疑惑,为什么不使用NoSQL技术做缓存来拦截一部分请求,做了,但是也挡不住流量请求,做过金融开发的同学应该知道,它不像其他领域读多,的确它的读也多,但是它对数据库的写操作也是相当多的,而NoSQL却无法挡住写的流量,而且频繁的写入导致缓存一致性问题也很难保证),最终数据库成了整个系统的瓶颈口,为了去解决这个问题,最终采取服务独享库(类似于垂直分库),如下:

高并发大流量情况下带来的海量数据分库分表的正确姿势

而数据库这边,由于某些库是需要经常被访问到的(资金库、信审库),所以一个节点去抗这部分流量显的还是有点吃力(吞吐量下降、响应速度变慢),最终采取了对数据库进行横向扩容,架构如下:
高并发大流量情况下带来的海量数据分库分表的正确姿势

最终,根据服务不同的业务规模,拆成了规模不同、业务不同的库,但是这其中的拆分规则到底是什么呢?

四、分库分表正确的拆分手段

谈到分库分表拆分规则的时候大家脑海里其实都有两个概念:水平、垂直
那么水平和垂直又该怎么拆呢?什么场景下怎么拆?拆完之后出现的问题又该怎么去解决呢?那么我们一步步分析到底怎么拆,拆完的问题怎么去解决。
首先来看《阿里Java开发规范手册》的规范:
高并发大流量情况下带来的海量数据分库分表的正确姿势

拆分规则:垂直分表 – 水平分表 – 垂直分库 – 水平分库

1. 垂直分表:结构不同,数据不同(表级别)

场景: 当一张表由于字段过多导致查询速度过慢或者导致大量数据冗余,此时就可以考虑垂直分表;
例:我有一张表,总共23个字段,但是对于程序来说,一般只需要其中的几个字段的数据,假设此时这张表中的热点字段为8个,冷字段为15个,那么我们就可以根据冷热字段来对表进行拆分,如下:
高并发大流量情况下带来的海量数据分库分表的正确姿势

做了垂直分表之后我们能很好的控制单表的数据量,不过需要在冷字段表中多加一个列作为热字段表的映射,保证在需要用到冷数据时也能找到。

2. 水平分表:结构相同,数据不同(表级别)

场景: 当一张表由于表数据行过多造成的查询效率下降,此时我们就可以考虑水平柴彪;
例:我有一张表,里面有三千万条数据,当我去执行一条在索引上的SQL时也需要一定时间(比1000万数据时慢了好几倍),我此时可以把这一张表拆为3张1000万的表,如下:
高并发大流量情况下带来的海量数据分库分表的正确姿势

做了水平分表之后我们能够很好的控制数据行,3000W数据的表和1000W数据的表查询速度其实不仅仅只是3倍的差距,一般使用自增ID作为主键的表,主键索引在1000W记录的树高在35之间,代表着我只需要经过35次磁盘IO就能拿回数据,而3000W记录的索引树高确远远不是(3~5)*3那么简单,所以一般企业做水平分表时可以在700-1200W之间为一张表(阿里订单表500W为一张表,因为能够很好的把树高控制在3以内);

总结:无论是垂直分表还是水平分表,他们都是建立在单库压力不高,但是单表性能不够的时候进行的,因为它们都属于库内分表,如果是数据库整体压力很大导致的查询效率低下,那么再怎么做分表也是无济于事,所以分表操作只建立在单库压力不高但是单表查询效率低下的情况下适用。
高并发大流量情况下带来的海量数据分库分表的正确姿势

3. 垂直分库:结构不同,数据不同(库级别)

场景: 当我单库在高并发情况下遇到瓶颈时,假设其他高可用架构方案(硬件分区、主备读写分离、主主双写双读)也无法解决此压力的时候才需要考虑垂直分库。
例:以上面那个项目举例,当我单个库无法去承载整个系统带过来的压力的时候,就可以考虑垂直分库了。假设此时我们单个MySQL节点的最大连接数为500,但是我们此时数据库来了1400的并发请求,如果不处理好的话,MySQL这个节点80%会被打宕机,所以既然我们一个节点扛不住,我们可以考虑把过来的请求按照不同的业务做分流处理,如下:
高并发大流量情况下带来的海量数据分库分表的正确姿势

原本之前单库的时候无论是查询用户业务相关的SQL还是放款还款之类的SQL全部的打入一个MySQL,全部都由这一个节点提供连接获取数据,但是此时我做了垂直分库之后,用户相关的SQL落入用户库,放款还款之类的SQL落入资金库,这样就能够很好的去解决单库面临的压力问题。

4. 水平分库:结构相同,数据不同(库级别)

场景: 虽然我们通过垂直分库的手段能够去提升MySQL整体的负荷能力,但是如果类似于分布式微服务当中的单节点并发数过高还是能把数据库打宕机,所以此时我们就可以考虑使用水平分库来提升单节点的抗并发能力。
例:假设此时做了垂直分库将压力按照不同的业务分发到不同的库之后还是存在单个业务库并发数过高的话,好比做了分流之后其中还是有1000个请求是分发到资金库的,但是我们单个MySQL节点只能够抗住500左右的并发连接,此时又会出现MySQL瓶颈,我们就可以考虑使用水平分库来分担压力,如下:
高并发大流量情况下带来的海量数据分库分表的正确姿势

这样我们就能很好的去做到单个节点的抗压性也能够很强,类似于分布式微服务中的单个服务做集群保证高可用。

五、分库分表带来的问题及解决方案

1. 垂直切分带来的问题

1.1、join联表查询:
之前在单个库中非常轻松就能进行联表查询筛选数据的操作,但是此时做了垂直分库之后,假设我需要查一个用户的所有还款记录,但是此时因为还款表和用户表分别处于俩个不同的数据库当中,我又该怎么去做联表查询操作?跨库join?的确可以这样去做,但是这样做的代价很高同时还损耗数据库的性能,不推荐。
解决方案:
1)、字段冗余,把常用的字段直接放到要用的表中,尽量避免联表查询。
2)、同步方式,当需要查询他库表时,可以在自己数据库创建一张同步表。
3)、走网络的方式,通过广播表(网络表、全局表)的方式查询。
4)、绑定表(ER表),相同主外键时保证操作落到同一数据库。
5)、系统层组装,在Java代码层全部查询出来之后再组装需要的数据。

1.2、事务控制:
之前在单个库中的事务都是直接由MySQL本地事务来进行管理[begin – commit – rollback],但是现在因为进行分库操作,代表着有多个数据源,那么我们又该怎么样去处理MySQL的事务?一旦没处理好那么带来的就是分布式事务问题。
解决方案:
1)、Best Efforts 1PC模式。
2)、XA协议2PC模式。
3)、XA协议3PC模式。
4)、TTC补偿式事务。
5)、MQ最终一致性事务。

1.3、还是存在单表数据量过大的性能瓶颈:
之前在单个库中的事务都是直接由MySQL本地事务来进行管理[begin – commit – rollback],但是现在因为进行分库操作,代表着有多个数据源,那么我们又该怎么样去处理MySQL的事务?一旦没处理好那么带来的就是分布式事务问题。
解决方案:
水平分库分表。

2. 水平切分带来的问题

1.1、count、order by、gorup by等聚合操作查询:
之前在单个库中我要对于一张表的数据进行聚合操作,哪是非常轻松的,但是现在进行了水平分库之后我们既不是一张表也不是同一个库,那么这样的话就比如我想count统计一下用户表的所有记录,但是现在我难道分别要去三个库上去统计一次之后再全部相加吗?
解决方案:
1)、在系统层(Java service层)进行数据组装,在Java中使用Stream流进行过滤。
2)、提前对数据进行加载,加载的数据放入第三方中间件(NoSQL),之后需要的话直接在NoSQL中拿取。

1.2、分页查询
MySQL举例,在之前的单库环境时,我们可以直接通过Limit index,end 关键字来进行分页的操作,而当现在多数据源的情况下,分页操作又成为了一个难题,比如分页10条数据为1页,那么如果我想要拿到user表的第一页数据就必须通过如下手段拿到:
高并发大流量情况下带来的海量数据分库分表的正确姿势
解决方案
1)、数据提前聚合,按时更新(对数据及时性能接受一定延迟时可以使用)。
2)、分页查询从数据中台(汇聚了所有子库数据)中拿取,Hbase+Flink实现离线大数据计算+实时风控。
3)、尽量在业务层(service层)做文章,在service层中进行数据筛选。

1.3、ID唯一性
之前我们做单库开发时通常ID唯一一般都是通过数据库的自增列来保证的,而数据库的自增列是由数据库本地控制的,此时是多个库的情况,假设还是以数据库自增的方式来做ID,那么绝对会导致出现俩个库中出现相同ID数据不同的情况(ID重复),那么这样在做数据查询会导致查询出多条ID相同的数据。
解决方案
1)、设置数据库步长,假设此时我们有三个库,那么库的自增步长为3,三个库的ID起始值:{DB1:1} {DB2:2} {DB3:3},那么效果如下:
高并发大流量情况下带来的海量数据分库分表的正确姿势
2)、利用算法生成有序ID,比如雪花算法、Snowflake算法等。
3)、利用第三方生产ID,比如redis的incr命令、或者直接使用一个独立的库负责做自增ID工作。
PS:有同学看到这里可能会疑惑,保证ID的唯一性我直接使用UUID不就好了吗?干嘛那么麻烦?使用UUID来做数据库主键的缺点我这里就不说了吧,同学们可以自行了解或者关注我后续文章:《MySQL索引底层剖析》。

1.4、拆分规则的选择
做了水平拆分之后,我的数据该怎么存储,举例:我新增一条ID为987215的数据,到底该落入哪个库?我需要这条数据时又该怎么查询?我怎么知道这条数据落入了哪个库?
解决方案
拆分规则可以按自己的业务来进行选择,但是需要保证的是:数据分布均匀、查询方便、扩容、迁移简单,一般常用的简单分片规则:
随机分片:随便指定请求落入某个节点,但是查询时需要查询全部节点才能拿取数据;
连续分片:每个节点负责ID范围值,比如DB1负责ID在1-10W内的所有数据,DB2负责ID在10W-20W内的所有数据…,单库迁移或者业务增长速度快时使用,否则不推荐,因为只有当DB1库存满了之后才会往DB2插入数据,导致DB2的作用只是做储备库。
一致性哈希:根据某个字段值计算出落入的库,查询时也通过这个字段值来计算,具体实现关注我后续文章:《一线大厂必考之负载均衡算法》。
ID取余:通过ID取余得到数据落入的节点。

1.5、数据迁移,容量规划,扩容等问题
数据迁移: 线上环境从单库切换到分库分表模式,数据怎么迁移能保证线上业务不受影响,这又是一个问题,而且面试这也是最喜欢问的问题,从这个问题可以看出你到底有没有做过分库分表。
解决方案
最简单的,半夜开始公告:服务器维护中,然后后台开程序跑数据,前提工作做好(Java代码从单库到分库分表),数据迁移、程序调试成功,第二天早上切换为分库分表模式,如果没迁移成功那么还是用之前的老库。

容量规划: 关于分库分表首次到底切分多少个合适,一般推荐首次拆分在8个库左右,但是需要保证的是2的倍数,方便后续扩容。

扩容: 当现有的库无法满足业务需求又该如何对库进行的合理的扩容,扩容之后会不会影响之前的业务,比如之前的拆分规则等。
解决方案
前提:水平分库,拆分规则为常用的Hash法(注:扩容是成双的)
1.水平扩容库(升级从库法)
高并发大流量情况下带来的海量数据分库分表的正确姿势
2.水平扩容表(双写迁移法)
高并发大流量情况下带来的海量数据分库分表的正确姿势
第一步:(同步双写)修改应用配置和代码,加上双写,部署;
第二步:(同步双写)将老库中的老数据复制到新库中;
第三步:(同步双写)以老库为准校对新库中的老数据;
第四步:(同步双写)修改应用配置和代码,去掉双写,部署;
PS:双写是常用方案!

六、分库分表之后程序怎么访问数据库

在进行分库分表之后我们程序怎么样访问数据库,代码怎么写?这才是我们更应该关心的问题,之前我们单库开发时非常简单,配置数据源之后直接写SQL执行就好了,那么现在多个数据源又该怎么访问?具体可以分为如下几种方式。
编码层: 如果你项目中使用了Spring框架,那么可以通过Spring提供的AbstractRoutingDataSource类来做数据源的动态切换;
框架层: 一般的ORM框架也是提供了切换数据源的实现类的,也是可以进行数据源的切换;
驱动层: Sharding-JDBC
代理层: MyCat
服务层: 一些特殊的SQL脚本;

成熟的解决方案:

####工程(依赖、Jar、不需要独立部署)
当当网:Sharding-Sphere
蘑菇街:TSharding
淘宝网:TDDL

进程(中间件、独立的进程、需要独立部署)

民间开源(基于阿里Cobar二开):MyCAT
阿里B2B:Cobar
奇虎360:Atlas
58同城:Oceanus
谷歌开源:Vitess
支付宝首席架构方鑫开源:OneProxy
一般用的比较多的前些年都是mycat,包括当初我自己在做架构选型的时候选取的也是mycat,但是最近三年mycat不再更新了,那么另外当当网的sharding-sphere也越来越流行,包括现在已经交由Apache软件基金会来孵化,并且也列入了*项目孵化列表,包括这俩年的使用的企业也越来越多,所以建议考虑分库分表时选用Sharding-Sphere来做应用层的访问。
Sharding-Sphere官网
高并发大流量情况下带来的海量数据分库分表的正确姿势

七、分库分表总结

一、分库分表,首先得知道瓶颈在哪里,然后根据自己的业务以及瓶颈做合理的拆分手段选择(到底是分表还是分库?垂直还是水平?),且不可为了分库分表而拆分,并且如果能够通过其他DB高可用手段来解决的(主备读写分离、主主双写热备、硬件分区)的瓶颈完全没有必要考虑分库分表,虽然能带来很大的好处,但是同时也产生了一系列的问题需要去解决与保障。
二、选择合理的拆分规则和路由key很重要,既要考虑到拆分均匀,也要考虑到非partition key的查询。
三、只要能满足需求,拆分规则越简单越好。
四、在分库分表的同时要为以后考虑,未来的扩容该怎么去做,以现在的拆分手法未来能不能很好的对架构进行升级,一位优秀的架构师必须具备前瞻性。
PS:不要在分库分表的时候想着一劳永逸,这是不可能的,如果能设计一套架构能在互联网行业保证三年运行正常不出现大问题那么就是一套非常优秀的架构,所以在一开始的时候没必要考虑拆分太多的库,根据自己企业的业务拆分出满足现有业务和能够承载当前业务三年内发展即可。