读写分离架构下,发生主从延迟时,可能出现主库已落表而从库因为主从延迟还查不到最新数据的问题;这种"在从库上读到过期数据"的现象,在本文里暂且称之为"过期读";
本篇主要介绍从业务角度和MySQL架构角度处理主从延迟问题的一些方案,包括:读写分离架构、强制路由主库方案、延迟请求从库方案、设计库表时采用分库分表方案、判断是否存在主从延迟方案、GTID的概念,以及判断指定的事务是否已经在从库完成执行的方案(等主库位点/GTID方案);
读写分离的基本结构
大多数的互联网应用场景都是读多写少,因此业务在发展过程中很可能先会遇到读性能的问题;而在数据库层解决读性能问题,常用的架构就是——一主多从/读写分离;读写分离的主要目标就是通过写主库读从库的方式来让从库分摊主库的压力;
读写分离通常有2种实现方案:在客户端主动做负载均衡和使用中间代理层proxy;
客户端主动做负载均衡
图中的结构是客户端(client)主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的DAO层;也就是说,由客户端来选择将当前请求路由到某个数据库;
客户端直连方案,因为少了一层proxy转发,没有额外的逻辑处理和网络传输损耗,所以查询性能稍微好一点儿(现公司proxy损失20%性能),并且整体架构简单,排查问题更方便;
但是如果采用这种方案,需要客户端熟悉MySQL的部署细节,在出现主备切换、库表迁移等操作的时候,客户端都会感知到,至少客户端需要维护数据库连接信息;
为了让服务端专注于业务逻辑开发,往往会使用ORM框架负责维护数据库连接和请求路由,使用类似nacos的配置中心来分发数据库连接信息;
总结下来,优点就是:性能无损、灵活,缺点就是对客户端(业务端)的要求更高;一般使用此方案的系统,是因为公司DBA团队未开发出功能强大且稳定的proxy层;
使用中间代理层proxy
另一种架构是,在MySQL和客户端之间有一个中间代理层proxy,客户端只连接proxy,由proxy根据请求类型和上下文决定请求的分发路由;
带proxy的架构,对客户端比较友好,客户端不需要关注MySQL的部署细节,连接维护、路由转发等工作,都是由proxy完成的;但是采用这种方案,对DB维护团队的要求会更高,proxy不仅需要有丰富的功能,还需要有高可用架构来保证稳定性;因此,带proxy架构的整体就相对比较复杂;
目前看,趋势是往带 proxy 的架构方向发展的;
不论哪种结构,客户端都希望查询从库的数据结果,跟查主库的数据结果是一样的,但是主从延迟还是不能 100% 避免的(主从机器性能、主库长事务、从库并行复制能力等);下面分别从业务角度和MySQL架构角度介绍下处理"过期读"的思路;
强制走主库方案
强制走主库方案其实就是,将查询请求做分类;通常情况下,我们可以将查询请求分为这么两类:
对于必须要拿到最新结果的请求,强制将其发到主库上;比如,在一个交易平台上,用户购买商品成功后立即查看已支付订单信息,这个请求需要拿到最新的订单记录,就必须走主库;
对于可以读到旧数据的请求,才将其发到从库上;在这个交易平台上,用户来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的;那么,这类请求就可以走从库;
实际上,这个方案是用得最多的;
分库分表方案
但是,有时候会碰到"所有查询都不能是过期读"的需求,比如一些订单、金融类的业务;这样的话,你就要放弃读写分离,所有读写压力都在主库,等同于放弃了扩展性;
针对这种场景,可以采用分库分表的方案;将数据量多、读写性能压力大的数据表水平拆分到多个数据库中,这些库被称为分库,分库中的表被称为分表;
拆分后,每个分库负责一份数据的读写操作,从而有效的分散了单库单表的访问压力;在系统扩容时,只需要水平增加分库的数量,并且迁移相关数据,就可以提高数据库的总体容量;
需要注意,如果是业务设计之初,可以在表设计时就考虑好使用此方案,若是业务不断扩展过程中发现性能瓶颈,则还需要做数据迁移(关于数据迁移可参考我的文章《数据迁移方案【建议收藏】》);
延迟请求从库方案
思路就是,主库更新后,读从库之前先delay一下;类似于请查询从库之前先执行一条sleep命令;
这个方案如果在服务端实现,看起来很不靠谱;确实,用户体验很不友好,因此这种方案一般在用户端实现(前端/APP客户端),通过类似丝滑的界面切换动画让用户感知不到这个"故意延迟";
例如,以卖家发布商品为例,商品发布后,用前端使用Ajax(AsynchronousJavaScript+XML,异步JavaScript和XML)直接把客户端输入的内容作为"新的商品"显示在页面上,而不是真正地立即去数据库做查询;这样,对于卖家来说,从界面上看到产品已经发布成功了;等到卖家下次刷新页面,其实已经过了一段时间,也就达到了sleep的目的,进而也就解决了过期读的问题;
类似的做法在用户发弹幕、发评论的场景都在使用,先让用户看到自己的"本地操作结果"而非立即查询从库结果,来避免"过期读"问题;
不过,当主从延迟很大时,超过这个delay,还是会出现"过期读";
上面几种方案是从业务端的视角出发,方案实现依赖业务前端/业务服务端,也是实现简单、很常用的方案;接下来从MySQL架构的角度,介绍一些"更准确"的方案;在此之前,先介绍下GTID的概念;
GTID的概念
GTID的全称是Global Transaction Identifier,也就是全局事务ID,是一个事务在提交的时候生成的,是这个事务的唯一标识;
注意区分与MVCC版本号的区别,MVCC版本号是事务开启时生成的;它跟MySQL的事务id也是不一样的,事务id是在事务执行过程中分配的,尽管事务id递增,但由于事务可以会滚,因此事务id不一定连续,而GTID是事务提交时才生成,因此是连续的;
GTID模式的启动也很简单,我们只需要在启动一个MySQL实例的时候,加上参数gtid_mode=on和enforce_gtid_consistency=on就可以了;
在GTID模式下,每个提交的事务都会跟一个GTID一一对应,因此GTID是唯一的;每个 MySQL 实例都维护了一个 GTID 集合,用来对应"这个实例执行过的所有事务";这个GTID有两种生成方式:
自动分配,设置gtid_next=automatic;这种模式下GTID的数值是连续的;录binlog的时候,先自动生成当前的GTID,再把这个GTID加入本实例的GTID集合;
手动指定,如setgtid_next='current_gtid';current_gtid已经存在于实例的GTID集合中,那么接下来执行的这个事务会直接被忽略;如果current_gtid没有存在于实例的GTID集合中,就将这个current_gtid分配给接下来要执行的事务;
利用上面这个机制,可以保证不会出现主键冲突,并且可以判断在主库已经提交的某个事务在从库是否已经执行过了,而后面将会介绍基于GTID机制的处理"过期读"的方案;
判断主备无延迟方案
这种方案的核心思想就是,如果此时不存在主从延迟,那么从库数据与主库一致,就可以放心的读从库;关键就是判断是否存在主从延迟的方法,有下面几种:
根据sbm(seconds_behind_master)来判断;
每次从库执行查询请求前,先判断seconds_behind_master是否已经等于0;如果还不等于0,那就等到这个参数变为0才能执行查询请求;但是seconds_behind_master的单位是秒,精度太不够;
对比位点确保主备无延迟;
主备的位点信息通过showslavestatus命令查看,上面是部分截图;
Master_Log_File和Read_Master_Log_Pos,表示的是读到的主库的最新位点;Relay_Master_Log_File和Exec_Master_Log_Pos,表示的是备库执行的最新位点;
如果像图中一样,这两组值完全相同,就表示接收到的日志已经同步完成;
对比GTID集合确保主备无延迟:
Auto_Position=1,表示这对主备关系使用了GTID协议;Retrieved_Gtid_Set,是备库收到的所有日志的GTID集合;Executed_Gtid_Set,是备库所有已经执行完成的GTID集合;
如果主备关系使用了GTID协议,并且这两个集合相同,也表示备库接收到的日志都已经同步完成;
上面这个"等到没有主从延迟"的方案有个很明显的问题:当前这条查询为了在从库读到最新的数据,其实并不需要等到主从没有延迟,也就是并不需要主库上最新的事务已经在从库执行,而是只需要跟这条数据相关的最新事务在从库执行就可以了;并且对于主库持续有事务执行的业务,几乎等不到主从完全一致的时机;
判断指定的事务是否已经在从库完成执行的方案
下面介绍这种更合理的"判断指定的事务trx1是否已经在从库完成执行"的方案;
方案1:等主库位点方案
要理解等主库位点方案,要依赖一条命令:
select master_pos_wait(file, pos[, timeout]);
这条命令在从库执行,逻辑:参数file和pos指的是主库上的文件名和位置;timeout可选,设置为正整数N表示这个函数最多等待N秒;
这个命令正常返回的结果是一个正整数M,表示从命令开始执行,到应用完file和pos表示的binlog位置,这期间执行了多少事务;
如果超时返回-1,如果异常返回NULL;
如果执行命令时,从库已经执行过这个位置了,则返回0;
如果此时能拿到主库事务的File和Position,就可以在从库执行这个命令来判断当前事务是否在从库执行完成(返回值>=1);方案示例:
主库trx1事务完成后,马上执行show master status得到当前主库执行到的File和Position;
选定一个从库执行查询语句;在从库上执行select master_pos_wait(File,Position,1);
如果返回值是>=0的正整数,则在这个从库执行查询语句;否则,到主库执行查询语句;
如果此时还超时,要么超时放弃,要么切到主库查询,具体业务具体分析;
方案2:GTID 方案
与等主库位点方案类似,如果开启了GTID模式,则可以尝试下面这个命令:
select wait_for_executed_gtid_set(gtid_set, 1);
这条命令逻辑:
等待,直到这个库执行的事务中包含传入的gtid_set,返回0;
若超时,返回1;
MySQL5.7.6版本开始,允许在执行完更新类事务后,把这个事务的GTID返回给客户端,这样相比等位点方案,就少了一步主库查GTID的步骤;在从库执行这个命令来判断当前事务是否在从库执行完成(返回值=0);方案示例:
开启GTID模式;
trx1事务更新完成后,从执行命令的返回结果中直接获取这个事务的GTID,记为gtid1;
选定一个从库执行查询语句;在从库上执行select wait_for_executed_gtid_set(gtid1,1);
如果返回值是0,则在这个从库执行查询语句;否则,到主库执行查询语句;
跟等主库位点的方案一样,等待超时后是否直接到主库查询,需要业务评估;
下篇文章:《MySQL实战45讲》——学习笔记31 “误删数据的解决方案(删行/删表/删库/删实例)“
本章参考:28 | 读写分离有哪些坑?