3.1 读写分离(主要是为了数据库读能力的水平扩展)
3.1.1 读写分离概念
单台mysql实例情况下不能支持短时间内大量的对数据库的读操作,所以会将数据库配置成集群,一个master(主库)、多个slave(从库),一般主库负责写,从库负责读,主从之间的同步方式为binlog日志方式。binlog日志可以有Statement(记录修改数据的sql,缺点slave和master执行结果可能不同)、Row(记录每一行数据的修改,缺点日志量大)、Mixed(Statement和Row两种方式的混合)三种方式。
3.1.2 读写分离好处
- 避免单点故障,可以主从切换。
- 负载均衡,读能力水平扩展:通过配置多个slave节点,可以有效避免过大的访问量对单个库造成的压力。
3.1.3 读写分离局限性
- 需要对sql类型进行判断。如果是select等读请求,就走从库,如果是insert、update、delete等写请求,就走主库。
- 主从数据同步延迟问题。因为数据是从master节点通过网络同步给多个slave节点,因此必然存在延迟。因此有可能出现我们在master节点中已经插入了数据,但是从slave节点却读取不到的问题。对于一些强一致性的业务场景,要求插入后必须能读取到,因此对于这种情况,我们需要提供一种方式,让读请求也可以走主库,而主库上的数据必然是最新的。
- 事务问题。如果一个事务中同时包含了读请求(如select)和写请求(如insert),如果读请求走从库,写请求走主库,由于跨了多个库,那么jdbc本地事务已经无法控制,属于分布式事务的范畴。而分布式事务非常复杂且效率较低。因此对于读写分离,目前主流的做法是,事务中的所有sql统一都走主库,由于只涉及到一个库,jdbc本地事务就可以搞定。
- 高可用问题。主要包括:
新增slave节点:如果新增slave节点,应用应该感知到,可以将读请求转发到新的slave节点上。
slave宕机或下线:如果其中某个slave节点挂了/或者下线了,应该对其进行隔离,那么之后的读请求,应用将其转发到正常工作的slave节点上。
master宕机:需要进行主从切换,将其中某个slave提升为master,应用之后将写操作转到新的master节点上。
3.1.4 Zebra与读写分离
zebra团队提供了GroupDataSource来完成读写分离功能,解决了上述所有问题,且对业务方透明。开发人员可以像操作单个库那样,去访问mysql数据库集群,底层细节完全由zebra屏蔽。
- 支持水平扩展从库,灵活的调整各个从库的任意流量比例
- 支持优先就近选择从库进行读取数据,避免跨机房访问
- 灵活的强制走主库策略,支持单条SQL或者整个请求内所有SQL等维度
3.2 分库分表(主要是为了写能力的水平扩展,如果只是为了读,那么读写分离,多几台slave机器,也可以解决)
3.2.1 分库分表概念
一旦业务表中的数据量大了,从维护和性能角度来看,无论是任何的 CRUD 操作,对于数据库而言都是一件极其耗费资源的事情。即便设置了索引,仍然无法掩盖因为数据量过大从而导致的数据库性能下降的事实 ,这个时候就该对数据库进行水平分区 (sharding,即分库分表 ).水平分区从具体表现上来看可以分为:只分表、只分库、分库分表三种。
3.2.1 分库分表好处
分库好处:降低单台机器的负载压力,提高读写性能。
分表好处:比如原来一张表4000w行数据,经过分表后每张表1000w数据,当执行插入操作时,维护索引的时间缩短,提升了写的效率;读时候可以同时从多张表中读,提升了读的效率。所以读写性能都得到了提升。
3.2.3 分库分表局限性
3.2.3.1 增删查改的功能变得复杂
原来的insert语句如下:
insert into user(id,name) values (1,”tianshouzhi”),(2,”huhuamin”), (3,”wanghanao”),(4,”luyang”);
经过分库分表后,需要将sql语句改为如下形式,并分别到每个库去执行:
insert into user_1(id,name) values (1,”tianshouzhi”)
insert into user_2(id,name) values (2,”huhuamin”)
insert into user_3(id,name) values (3,”wanghanao”)
insert into user_0(id,name) values (4,”luyang”)
具体的流程可以用下图描述:
解释如下:
- sql解析:首先对sql进行解析,得到需要插入的四条记录的id字段的值分别为1,2,3,4
- sql路由:sql路由包括库路由和表路由。库路由用于确定这条记录应该插入哪个库,表路由用于确定这条记录应该插入哪个表。
- sql改写:上述批量插入的语法将会在 每个库中都插入四条记录,明显是不合适的,因此需要对sql进行改写,每个库只插入一条记录。
- sql执行:一条sql经过改写后变成了多条sql,为了提升效率应该并发的到不同的库上去执行,而不是按照顺序逐一执行
- 结果集合并:每个sql执行之后,都会有一个执行结果,我们需要对分库分表的结果集进行合并,从而得到一个完整的结果。
3.2.3.2 分布式id问题的解决
分库分表后,不能再使用mysql的自增主键。因为在插入记录的时候,不同库生成的自增id可能会发生冲突,因此要有一个全局的id生成器。
- Snowflake雪花算法
分配一个64bit的long型id,分配如下:
41bit用作时间戳,精确到ms,10bit用于工作机器id(5bit是数据中心id,5bit是机器id),12bit用作序列号,也就是说某一个数据中心的一台机器在1ms时间内可以分配4096个id,序列号的生成可以采用Synchornized加锁的方式,也可以采用AtomicInteger(底层采用CAS)的方式。当在1ms内向某台机器申请多于4096个id时,机器可以推到下一ms生成。这64bit也可以根据自己需要设置不同bit的用途。
缺点: 时间回拨问题、机器id的分配回收问题、机器id的上限问题。这里不再详细阐述。(待补充)
- proxy服务+数据库分段获取id
注: DX、YF等表示数据中心名称、leaf表示该数据中心的某台机器、数据表中的biz_tag表示业务标识,该业务的id要保证唯一,不同业务的id不需要保证、max_id表示该业务当前申请的最大id、step表示该业务每次申请的id数量。申请完成后,该step数量的id被数据中心的某台机器所持有,然后机器可以通过Synchornized/AtomicInteger(底层采用CAS)的方式去分配。
申请sql语句如下(使用事务):
Begin
UPDATE table SET max_id=max_id+step WHERE tag=xxx
SELECT tag, max_id, step FROM table WHERE tag=xxx
Commit
优点:每次分配step数量的id,数据库读写压力很低
缺点:HA高可用性要求极高,采用主从部署+异地灾备的部署方案。
3.2.3.3 分布式事务
分库分表场景下,分布式事务涉及到对不同库、不同表的操作要么同时成功,要么同时失败。
分布式事务解决方案:
XA事务
柔性事务:最大努力通知型、可靠消息最终一致性方案以及TCC两阶段提交
(待补充)
3.2.3.4 动态扩容
动态扩容是指增加分库分表的数量。动态扩容一般情况下伴随数据迁移。但使用everydb的方式进行数据库扩容时,不需要进行数据迁移。思路如下:
在id字段的后面额外加上几位数字。比如加上4位数据,2位表示分库编号,2位表示分表编号。这样一共便拥有了100*100=10000张表。原来的同一个分库分表数据后面加的数字一样即可,这样原来同一分库扩容后还在同一个分库,原来同一分表扩容后还在同一张分表。
3.2.3.5 数据迁移
对于新的应用,如果预估到未来数据量比较大,可以提前进行分库分表。对于老的应用,单表数据量已经比较大了,这时就涉及到数据迁移过程。美团使用Buffalo进行数据迁移。数据迁移流程如下:
- 存量数据迁移。分批次select源数据库,依次将数据插入到目标库中,在该过程中不断有业务写入,通过binlog日志保存增量数据(存量数据迁移过程中的业务写入操作)。
- 增量数据迁移。存量数据迁移完成后,获取增量数据,经过反解析、过滤、封装后同步到目标库,从而实现源库、目标库数据实时同步。也就是在目标库表中执行一遍存量数据迁移过程中的业务写入操作。
- 增量数据迁移追上当前时间后,进行数据校验(比如行数、表大小、CRC校验等),一致后则进行切换。