分布式下的数据拆分,读写分离,分库分离相关问题
一、为什么要用分库分表
当不使用分库分表的情况下,系统的性能瓶颈主要体现在:
- 当面临高并发场景的时候,为了避免Mysql崩溃(MySql性能一般的服务器建议2000/s读写并发以下),只能使用消息队列来削峰。
- 受制于单机限制。数据库磁盘容量吃紧。
- 数据库单表数据量太大,sql越跑越慢
而分库分表正是为了解决这些问题,提高数据库读写并发量,磁盘容量大大提高,单表数据量降低,提高查询效率。
二、垂直拆分和水平拆分
以表的维度来说:
- 垂直拆分 指根据表的字段进行拆分,其实很常见,有时候在数据库设计的时候就完成了,属于数据库设计范式,如订单表、订单支付表、商品表。
- 水平拆分 表结构一样,数据进行拆分。如原本的t_order表变为t_order_0,t_order_1,t_order_3
以库的维度来说:
- 垂直拆分 指把原本的大库,按业务不同拆到不同的库(微服务一般都是这么设计的,即专库专用)
- 水平拆分 一个服务对应多个库,每个库有相同的业务表,如库1有t_order表,库2也有t_order表。业务系统通过数据库中间件或中间层操作t_order表,分库操作对于业务代码透明。
所以,我们平常说的分库分表,一般都是指的水平拆分
三、分库分表工具
主要关注MyCat和sharding-jdbc。
两者对比:
- MyCat:基于中间件的形式,提供读写分离、分库、分表功能。只不过好久都没更新了,我使用的是1.6版本,并不支持一个逻辑表的分库、分表同时存在。
- sharding-jdbc:基于jar包中间层的形式,提供读写分离、分库、分表功能。社区较活跃。支持功能强大。
四、分库分表的两种策略
- hash分法,按一个键进行hash取模,然后分发到某张表或库。优点是可以平摊每张表的压力,缺点是扩容时会存在数据迁移问题。
- range分法,按范围或时间分发,比如按某个键的值区间、或创建时间进行分发,优点是可以很方便的进行扩容,缺点是会造成数据热点问题。从分表上说还好,如果是分库,将导致某一个库节点压力过大,节点间负载不均。
这里,我认为最好的分法是hash分库、range分表。因为对库来说重要得是负载要均衡,对表来说重要的是可以动态扩容。
五、分库分表数据迁移方案
停机分库分表
方案很简单,就是停机维护时,用后台临时程序基于数据库中间件将老库数据直接分发到需要迁移的数据库
此方案的主要缺点是一定会出现几个小时的停机,如果没搞定要回滚,第二天继续搞,开发人员心是慌得。
不停机双写方案
主要步骤:
- 修改系统中的所有写库的代码,同时写老库和数据库中间件(包括新增、更新、删除操作)
- 然后后台用工具将老库之前的老库数据迁移到新的数据库中间件,注意比对修改时间,若id一样,按修改时间决定是否覆盖。
- 使用后台工具比对一次数据,看是否完全一样,不一样,则后台再用工具进行一次迁移。(避免有些数据因为网络问题没有迁移成功,或业务上的bug导致),这个过程通常需要好几天。
- 以此循环几次,数据完全一样时,就切换为只读写新库
该方案解决了停机造成的服务不可用。
六、分库分表下的动态扩容问题
在分库分表的情况下,如何在已经分库分表的基础上进一步分库分表提高系统效率,是一个麻烦的问题。特别是基于hash分片的服务器,再次分库分表,一般只能对服务器进行停机,然后将所有数据又基于新的规则插入到不同的库与表。为了避免这种问题,可以在第一次分库分表的时候就将库切分的较细,避免二次扩容。比如:
- 最开始就将库分为32个库,最开始业务量没那么大,可以将多个库放在同一台机器上,以后按照2-4-8-16-32来进行扩容,如最开始2台机器能够满足数据库读写并发,此时一台服务器上有16个库,后来不够了,就扩容为4台机器,每台机器8个库。。。依次类推,这样做的好处是只需要迁移需要迁移的库,并且是按照整个库进行迁移,不需要重新进行分发,同时分库分表的分片机制也不用修改,只需要修改其数据源就行了。
- 对于分表来说,也可以分的多一些,推荐分为32张表。
- 以上,分为了32个库32张表,总共数据量可以达到32 * 32 = 1024张表,按每张表500万正常的容量来算,可以容纳约50亿数据,足以满足大部分过扩容需求。
- 另外这种方案推荐扩容方案为2-4-8-16-32倍数进行扩容,深层原因是32是这些数的公倍数,按照约数进行扩容更容易让每个机器负载的库都一样。
- 需要注意的是如果按照同一分片键进行同样的分片策略分库分表,会导致数据只会达到某库的某表比如1库的1表,2库的2表,(因为库和表的数量也是一样)所以分库我们可以按32取模策略,分表的话我们可以按整除之后的余数再对32取模进行分表。
七、全局id的生成策略
几种生成id的方式对比:
- 通过数据库自增
往公用的一张表(这张表是自增主键)插入一条数据,获取id的返回值,用这个id再去插入中间件当中去。oracle可以通过自增序列。
缺点:不适合并发高的场景,毕竟不管是自增序列还是采取自增键的方式来生成,会并发竞争写锁,效率太低。
- UUID
缺点:uuid太长了,不规则
- 时间戳
一般联合其他业务字段拼接作为一个Id,如时间戳+用户id+业务含义编码
缺点:并发高容易重复
- 雪花算法
原理:前面1位为定值0+41位为时间戳+5位机房id+5位为机器id+12位为序号,唯一需要保证同步的地方是生成一个序号,锁粒度较低。另外这个算法可用于分布式环境中。最大的优点是不需要依赖任何中间件,核心原理是用5位机房id,5位机器id标志了唯一一台机器,所以不需要分布式锁去保证不同机器生成id的同步性,只需要在当前机器保证生成的序号不一样就行了。
- redis中间件生成
原理:利用redis单线程工作线程属性去维护一个自增变量。
八、读写分离
为什么要读写分离
- 理论上来说读写请求不要超过2000/s,如果加了缓存之后,到数据库请求还是超过2000以上考虑读写分离
- 使得读请求可以在不同机器并发,用了读写分离之后可以通过动态扩展读服务器增加读效率,这与redis中的主从架构读写分离、copyOnWrite机制的并发容器、以及数据库MVCC机制有点相识,都是通过读请求的数据备份增加读写并发效率。
- 适用于业务场景中,读请求大于写请求的情况,读写分离使得系统能够更多的容纳读请求并发。
读写分离的实现方式
一般来说是基于mysql自带的主从复制功能。mysql主从复制的流程图如下:
总结mysql的主从复制过程大体是主库有一个进程专门是将将记录的Binlog日志发送到从库,从库有一个io线程(5.6.x之后IO线程可以多线程写入relay日志)将收到的数据写入relay日志当中,另外还有一个SQL进程专门读取relay日志,根据relay日志重做命令(5.7版本之后,从可以并行读取relay log重放命令(按库并行,每个库一个线程))。
主从同步的三种模式:
- 异步模式(mysql async-mode)
异步模式如下图所示,这种模式下,主节点不会主动push bin log到从节点,这样有可能导致failover的情况下,也许从节点没有即时地将最新的bin log同步到本地。
- 半同步模式(mysql semi-sync)
这种模式下主节点只需要接收到其中一台从节点的返回信息,就会commit;否则需要等待直到超时时间然后切换成异步模式再提交;这样做的目的可以使主从数据库的数据延迟缩小,可以提高数据安全性,确保了事务提交后,binlog至少传输到了一个从节点上,不能保证从节点将此事务更新到db中。性能上会有一定的降低,响应时间会变长。如下图所示:
- 同步模式(mysql semi-sync)
全同步模式是指主节点和从节点全部执行了commit并确认才会向客户端返回成功。
读写分离场景下主从延迟可能导致的问题
在代码中插入之后,又查询这样的操作是不可靠,可能导致插入之后,查出来的时候还没有同步到从库,所以查出来为null。如何应对这种情况了?其实并不能从根本上解决这种情况的方案。只能一定程度通过降低主从延迟来尽量避免。
降低主从延迟的方法有:
- 拆主库,降低主库并发,降低主库并发,此时主从延迟可以忽略不计,但并不能保证一定不会出现上述情况。
- 打开并行复制-但这个效果一般不大,因为写入数据可能只针对某个库并发高,而mysql的并行粒度并不小,是以库为粒度的。
但这并不能根本性解决这个问题,其实面对这种情况最好的处理方式是:
- 重写代码,插入之后不要更新
- 如果确实是存在先插入,立马就能查询到,然后立马执行一些操作,那么可以对这个查询设置直连主库(通过中间件可以办到)
举例:
讲个创业故事,某一天老总想做一个HR的Saas系统,其中员工信息表,但不同员工信息表有一部分是相同的,也有一部分不同的。我们就把不同的放在一个json字段(Mysql 8.0以后支持)中,Dao用的Mybatis,以及MybatisGenerator生成的基础包访问DB,大家知道这个查询就会查询一条完整的数据,没法查几个字段。刚开始没啥,为了赶进度查询一条就一条吧,不用就行。后来试用开始,量上来就慢了,尤其一些扩展的字段都是在更多详情中展示,但基础信息获取也很慢。原因就是查询用户基础信息也用的是按照行全量查询。
为了解决读的问题,我们做了读写分离,后来干脆多高几个Slave从库进行读,可随着业务发展,尤其有些公司扩展字段很多,读取速度就更慢了。
垂直分离,将基本信息和扩展信息拆分为两个表,ID是一致的。这样,基础信息查询用户基础基础信息表,点击更多的时候查询用户的扩展信息表。
因为是Saas多租户架构,用户表数据量也上来了,于是开始分片。这里的分片不同于数据库的那个分片,也指业务上的分片。一般方案有两个,按照某种业务规则拆分,如三个月内+历史数据(时间维度);另一种是根据ID进行Hash,平均分配到不同DB,缺点是当然你查询自己的订单时候就不得不从两个表查询了。Saas多租户,租户ID自然是比较好的拆分依据,就把不同租户的用户拆分到不同的DB中,关于是分库还是分表,建议是分库,实现简单,且两个租户不争夺IO资源,当然也不绝对,看业务。
分库后,刚开始没有进行读写分离,运维成本的考虑。但对于大公司还是比较慢的,于是就添加了从库,进行读写分离,尤其对于报表生成都迁移从库来完成。
业务发展到现在,对于一个租户来说,主库还是单点。畅想一下,一般情况,业务复杂后,数据量再上来,优先考虑纵向根据业务拆分为不同微服务,这样原来一个DB 100张表,可能就拆分为40+60的两个库,能缓解一段时间。
但如果对于一些业务表,单表都能超过kw级别,分表一定不行了,原因是:能达到kw规模存量数据,增速一定很高,写性能和IO性能是瓶颈,即使分表也会争抢IO资源,所以这里就必须进行分库了,这里的分库指的是:同一个表的分库,类似根据ID去Hash分库一样。以此来解决数据量大,单点扛不住问题。
演化到这里,已经很少有业务能到这个程度,如果到了,也只是系统中少有的几个模块,就需要特殊设计了,也不存在什么通用的方案。
数据库架构分解
说起“数据库架构”,包括:数据库表设计和数据持久层架构。下面主要将持久层架构,但也会涉及数据库表设计。
创业初期:一个服务一个库
读瓶颈:读写分离
架构:一主多从,主从同步,读写分离
这么一套就是一组,这个架构也成为:分组架构
技术实现:在Service层使用AOP注解标记;多数据源路由的时候根据标记路由;或者配置规则进行路由。
架构特点:
主从表结构和数据完全一致
通过binlog同步数据,会有一定的时差
解决读多写少的场景,或者读费时的问题
表中冷热数据纵向拆分
如下表单,先展示基本信息,点击“更多”后,展开其他详细信息。
刚开始数据库表设计:
前面的“基本信息”比“扩展信息”更常用,所以可以拆分为两个表:
单表总数据库大:分片架构
根据租户ID进行拆分,即:一个公司的数据独立的放在一个库里边。
路由的技术实现:Spring的动态数据源;Mycat中间件
分库还是分表:建议分库,拆分数据是为了解决单表数据量问题,数据量大的两个瓶颈是:数据库单表瓶颈和服务器IO,如果分表意味着IO瓶颈会突出出来。
水平分配规则:
按照业务key分,如:不同租户的数据;不同地点订单;不同类别等
根据ID按照Hash算法平均分配,这类只是为了解决数据量大的问题
架构特点:
每个数据库的Schema一样
数据每个库数据是不同的,区别于主从复制
解决问题:此架构解决单库成为瓶颈问题,通过拆分为多个库存储数据,一次来降低单库的压力,代价就是统计,查询我的历史等功能,需要通过合并数据才可以。
分片后读写分离
拆分为不同的库后,也有读多写少的问题,于是再对每台DB进行分组架构,添加从库进行读写分离。
如果业务读写并发量很高,数据量也很大,通常需要实施分组+分片的数据库架构:
1.通过分片来降低单库的数据量,线性提升数据库的写性能
2.通过分组来线性提升数据库的读性能,保证读库的高可用
主库单点问题扩展
此案例先进行根据租户ID分库,但如果一个租户的数据也过大,就需要对这个租户进行分库存储。图跟上面类似,路由的规则却变为根据ID去hash。这个只是扩展,实际业务需要看具体业务,必须分库会带来其他额外的工作,比如获取全部员工信息,需要从多个数据库查询查询。类似这样的功能需要全部重构。
数据库架构总结
业务初期用单库
数据量和并发量增加开始可以选择更好好一点的机器
读写比例差异,可以进行读写分离
类似报表等查询和统计类功能,又比较好性能的模块就可以迁移到从库
单库写量上来后,可以进行分片,将压力拆分到多个库中
高频的信息查询和低频信息可以拆分为两个表