最近项目上线,遇到了主从问题。按理说公司基建不至于出现这种问题,但就是出现了。可能因为用的不是原生的MySQL吧。主从延迟会给前端和服务端带来很多问题,需要花费时间用工程手段来解决,我认为这是很不合理的。
举几个因为主从延迟会导致问题场景:
- 创建了一个商品然后立即跳转到详情页
- 在列表页更新了用户的权限,立即刷新
凡是像这种操作后立即获取的,全会有问题。
为什么要有主从
MySQL数据库的主从(Master-Slave)架构主要是为了实现数据的高可用性(High Availability)和读写分离,具体的原因如下:
- 数据备份:主从架构可以实现数据的实时备份,从库可以作为主库的一个镜像存在,当主库出现问题时,可以迅速切换到从库,保证数据的安全性。
- 读写分离:在主从架构中,主库主要负责写操作,从库主要负责读操作,这样可以分担主库的压力,提高系统的处理能力。
- 故障切换:当主库出现故障时,可以迅速切换到从库,保证服务的连续性,提高系统的可用性。
- 负载均衡:通过主从架构,可以将读请求分散到多个从库,实现负载均衡,提高系统的性能。
- 数据一致性:主从复制可以确保数据在主库和从库之间保持一致,提高数据的准确性。 因此,为了保证数据的安全性和系统的高可用性,MySQL通常会采用主从架构。
主从如何同步
下面是一个update 语句在主节点 A 执行,然后同步到从节点 B 的完整流程图
从库 B 跟主库 A 之间维持了一个长连接。主库 A 内部有一个线程,专门用于服务从库 B的这个长连接。一个事务日志同步的完整过程是这样的:
-
在从库 B 上通过 change master 命令,设置主库 A 的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。
-
在从库 B 上执行 start slave 命令,这时候从库会启动两个线程,就是图中的 io_thread 和 sql_thread。其中 io_thread 负责与主库建立连接。
-
主库 A 校验完用户名、密码后,开始按照从库 B 传过来的位置,从本地读取 binlog,发给 B。
-
从库 B 拿到 binlog 后,写到本地文件,称为中转日志(relay log)。
-
sql_thread 读取中转日志,解析出日志里的命令,并执行。
为什么会主从延迟
什么是主从延迟?
-
主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;
-
之后传给从库 B,我们把从库 B 接收完这个 binlog 的时刻记为 T2;
-
从库 B 执行完成这个事务,我们把这个时刻记为 T3。
所谓主从延迟,就是同一个事务,在从库执行完成的时间和主库执行完成的时间之间的差值,也就是 T3-T1。
你可以在从库上执行 show slave status 命令,它的返回结果里面会显示seconds_behind_master,用于表示当前从库延迟了多少秒。
在网络正常的时候,日志从主库传给从库所需的时间是很短的,即 T2-T1的值是非常小的。也就是说,网络正常情况下,主从延迟的主要来源是从库接收完 binlog和执行完这个事务之间的时间差。
主从延迟来源
- 有些部署条件下,从库所在机器的性能要比主库所在的机器性能差。
- 从库的压力大。如从库上的查询耗费了大量的 CPU 资源,影响了同步速度,造成主从延迟。
- 大事务。因为主库上必须等事务执行完成才会写入 binlog,再传给从库。所以,如果一个主库上的语句执行 10 分钟,那这个事务很可能就会导致从库延迟 10分钟。
- 大表 DDL,也是典型的大事务场景。
- 从库的并行复制能力。查看软件版本,在官方的 5.6 版本之前,MySQL (sql_thread)只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主从延迟问题。
如何解决
一般的主从结构如下:
一旦出现主从延迟问题,有如下解决方案
强制走主库方案 - 有点可行
- 将查询请求做分类,必须拿到最新结果的,强制请求到主库
- 优点:能够区分场景,压力可控
- 缺点:
- 前后端都得改动。前端判断是否走主库,服务端判断指定场景走查主库
- 后续维护也比较麻烦
- 有时前端无法判断出场景,如进详情页,前端无法判断是刚创建完跳转的还是打开的是早已创建的
- 全部走主库
- 优点:简单便捷
- 缺点:不区分具体场景,主库压力大
- 先读从库,从库没有读主库
- 优点:相对简单
- 缺点:无法处理所有场景,如list的场景,因为必然有数据,但并不知道是否准确
sleep 方案 - 有点可行
- 前端延迟请求
- 优点:简单便捷
- 缺点:用户体验不好
- 写相关接口,服务端返回结果详情,前端展示详情。如创建商品接口,服务端返回商品的详细信息,前端直接展示详细信息,不请求接口
- 优点:逻辑比较通顺、清晰
- 缺点:
- 前端需要实现多套逻辑。如虽然是详情,但至少可能来自创建和详情接口;在成员列表中删除member成功后就直接不显示,不再调用list接口
- 维护成本高,如对于详情页,如果后续详情也变更,创建和详情接口如何保持一致
- 无法处理所有场景,如创建空间后获取成员列表(至少有自己)
判断主从延迟方案 - 有的可行
- 写操作写redis(过期时间1~2s),读的时候判断是否有redis,有则读主库。如创建商品,则在redis里记录商品id,获取详情的时候先判断该商品id是否在redis存在,如果存在则读主库。
- 优点:
- 前端无需改动,服务端改动相对可控,而且设置为弱依赖,所以问题应该不大
- 能解决大部分问题
- 缺点:
- 服务端需要根据场景记录、读取redis
- 有一定概率增加主库压力,但总体可控
- 判断主从无延迟方案 - 不可行
- 每次从库执行查询请求前,先判断seconds_behind_master 是否已经等于 0。如果还不等于 0 ,那就必须等到这个参数变为0 才能执行查询请求。可以使用 show slave status。
- 对比位点确保主从无延迟
- 对比 GTID 集合确保主从无延迟
这种方式感觉不太现实
- 实现上成本高
- 仍然有过期读问题。因为上面判断主从无延迟的逻辑,是"从库收到的日志都执行完成了"。但是,从 binlog在主从之间状态的分析中,不难看出还有一部分日志,处于客户端已经收到提交确认,而从库还没收到日志的状态。
- 如果在业务更新的高峰期,主库的位点或者 GTID 集合更新很快,那么上面的两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况
- 配合 semi-sync(半同步复制) 方案 - 不可行
主从无延迟方案 + semi-sync 方案 能解决过期读问题。
semi-sync 做了这样的设计:
-
事务提交的时候,主库把 binlog 发给从库;
-
从库收到 binlog 以后,发回给主库一个 ack,表示收到了;
-
主库收到这个 ack 以后,才能给客户端返回"事务完成"的确认。
这样,semi-sync 配合前面关于位点的判断,就能够确定在从库上执行的查询请求,可以避免过期读。
但这种方案也不太现实,而且没有完全解决问题
- semi-sync+ 位点判断的方案,只对一主一从的场景是成立的。如果落到其它从库,还是会出现过期读
- 等主库位点方案 - 不可行
csharp
select master_pos_wait(file, pos[, timeout]);
这条命令的逻辑如下:
-
它是在从库执行的;
-
参数 file 和 pos 指的是主库上的文件名和位置;
-
timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。
返回结果如下:
-
如果执行期间,从库同步线程发生异常,则返回 NULL;
-
如果等待超过 N 秒,就返回 -1;
-
如果刚开始执行的时候,就发现已经执行过这个位置了,则返回 0。
-
正常返回的结果是一个正整数 M,表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务。
使用方法如下
-
trx1 事务在主库更新完成后,马上执行 show master status 得到当前主库执行到的 File 和Position;
-
选定一个从库执行查询语句;
-
在从库上执行 select master_pos_wait(File, Position, 1);
-
如果返回值是 >=0 的正整数,则在这个从库执行查询语句
这种也不太现实,想想实现复杂度有多高。
- 等 GTID 方案 - 不可行
csharp
select wait_for_executed_gtid_set(gtid_set, 1);
这条命令的逻辑是:
-
等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;
-
超时返回 1。
等 GTID 的执行流程为:
-
trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid1;
-
选定一个从库执行查询语句;
-
在从库上执行 select wait_for_executed_gtid_set(gtid1, 1);
-
如果返回值是 0,则在这个从库执行查询语句;
-
否则,到主库执行查询语句。
总结
真的是基建要做好,基建好了大家能把精力放到更重要的事情上。如果真出现主从延迟问题,能选择的方案其实比较少。要么就分场景走主库、要么前端sleep(偏临时方案)、要目服务端自己判断一下主从延迟情况。
这次我们选择redis打点记录,看看效果怎么样吧。
最后
大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)
我的个人博客为:shidawuhen.github.io/
往期文章回顾: