上篇博文里谈到,我最近在做的是组内的一个客服场景下的即时聊天框,前两周的周五赶马上线了。在测试环境测试一切正常,上线后…就翻车了…当时赶着上线,是在周五开完周会之后我和另一个组员一起上线的。那晚数据库还是临时找DBA给申请的,然后上线后我才把之前一条应该刷的SQL给刷了…所以赶着上线真的不是一件好事,在实际开发过程中要尽量避免,这次案例就引以为戒吧。

先来描述一下故障表现:我们上线之后,在用户向服务方发起在线咨询时后台会生成一个群聊记录 os_group,此时问题就出现了,前端调用接口获取到的群聊成员列表都是空的,用户发起群聊后却看不到群聊中的任何成员,数据异常,而且多次测试中还偶然有正常的。

到这里是我们始料未及的,在测试环境我们测试了一周都没有出现这个现象,此时我的第一反应,在确认接口返回的数据确实有问题后,先在脑海里过一遍,有没有可能是代码哪个环节没考虑到导致了这个问题。在确定没有直接能判断到可能的源头后,开始了排查。首先通过跳板机连接到线上的数据查看相关的数据是否插入正常,一查询发现获取群成员列表接口中的 group_id: 0 在数据库表中确实也是0,这基本就锁定了是我们连MySQL出了问题。异常的数据截图如下:
zero_id
接着顺藤摸瓜,找到了代码中插入群聊的位置:
insert_group

这里解释一下:75行之前把要创建群聊的前置条件都准备好,然后调用 dao.insertSelective(groupDO) 进行数据插入,初始化好群聊记录后,接着初始化群成员关系 initGroupUsers(),其中就用到了群聊Id groupDO.getId()。用过ORM框架的朋友们都知道,insert 方法都会将插入那行数据的 id 返回并设置到dao对象中,在此处也即 groupDO 中,然后后续初始化群成员关系的时候就会用到。问题也就是出现在这里,插入后的dao对象的id从效果上看并没有返回设置对应的id,导致初始化群成员关系的时候 group_id 都是0,从而导致了在前端页面数据异常,新建的群聊没有任何成员显示。

到这里问题基本就确定了,是MySQL连接的问题。一开始我们怀疑是临时申请的数据库哪个设置没配(因为在申请数据库的时候DBA跟我说还有一个操作是初始化),不过在和DBA拉群沟通的时候,DBA表示配置都是标准化的并没有遗漏。这时我的另一个组员,也就是这次一起上线的那位,说他之前就遇到过一次DBA申请的数据库在挂了MiProxy之后存在数据同步不一致的问题(MiProxy是MySQL前面的一层代理,是DBA用来做负载均衡的,如果直接连接MySQL后端的话是没有负载均衡的)。这时候我们继续跟DBA沟通,DBA让我们优先排查代码的问题,虽然之前MiProxy出现过主从同步不一致的问题,但遇到客户端不兼容的情况还是极少的。并让我们贴出了有问题的代码:
select_insert

这段代码看起来没有问题,先insert然后再select最新的LAST_INSERT_ID()。这时候我们分别去验证数据库连接的问题:DBA验证MiProxy的问题,我们验证程序的问题,我们在线上把这个组件的数据库切换到staging环境后就好了,插入的id是能查到的不再为空了,同时DBA那边也用脚本验证了MiProxy的连接是没有问题的:
miproxy_validate

到这里我们综合以上信息,脚本使用pymysql.connect()连接线上MiProxy,在同一个打开的connection里面insert/select都是没有问题的;而程序中使用了MyBatis Plus进行连接MiProxy就有问题,但是程序连接staging环境的库没有问题,同时线上库和staging库的区别在于一个是直连的、一个是使用MiProxy连接的;再结合多次测试中还是偶有能正确插入的,这个时候我们就想到可能是MySQL连接多线程的问题,使用单个连接没有问题,可以排除数据库配置、驱动版本的问题,而使用MiProxy连接后就不正常了,MiProxy提供代理能力,其后面屏蔽掉了可能有多个mysql connection线程,而程序代码中的select/inset有2次读写操作,需要先写入然后再去读取,如果这2个操作不在同一个后端MiProxy的connection线程中,则其执行的先后顺序得不到保障,比如select读取的线程总是先返回,那么插入的os_group表的最新的id便是不正确的,从而导致了每次读取到的os_group的id都是0。

到这里问题基本明朗了,是由于数据库代理引发的读写不同步的问题,之前同事就遇到过相关的配置问题,需要开启数据库的配置keep_session来保持会话信息。按理说这里我们也让DBA直接开启keep_session,这样select/insert就能在同一个连接里完成也就不会出现不同步的问题了。但是我的那位同事之前就遇到过开了keep_session之后,经过MiProxy提供的主从库有不同步的问题,所以这里我们并不能通过这个开关配置来最终解决问题。

怎么办呢?我们提议让DBA不要再走MiProxy了,我们想直连后端mysql。DBA不建议这样做,说还是有负载均衡比较好,可是开了keep_session之后又有主从不同步的可能问题,两边都不是。最后的最后,还是我们这边决定先在代码层面对相关的查询位置进行事务处理,明确声明让MiProxy后端放到同一个connection里处理,我们在Service层都加了Transactional注解:
service_transactional

我们在Service层都加了事务开启,这样确保了所有的SQL查询都在事务里,不会出现上述读写不同步的问题。可能到这里你会问所有的查询都开事务,MySQL读写的性能是不是会下降?那是肯定的,但是鉴于我们目前服务的阶段是处于快速上线验证的阶段,所以此次我们先临时fix一下,后面等业务量上来了如果确实性能跟不上,到时候再优化也不迟。

其实这里应该说是基础组件的问题,DBA提供的主从同步数据库有些场景功能满足不了,从而导致了当前的使用现状。  
哎,基础设施不够完善啊,要想跑得快,还得底盘好,这话没毛病。

临时fix掉后我们重新部署,上线后再验证就没有这个问题了,总算是好事多磨,而此时已经是周五晚上接近9点了。不过这次上线也是应该要上的,因为项目Owner在周报里已经写了上线日期就是今天,中间出现的这些小插曲,说明我们项目管理这次做得不够好。

不幸中的万幸是,此次我们组件上线是有灰度的,只有开启了组件可见的服务才能看到我们的入口,而这次上线我们只开启了自己的服务可见,对其他服务来说是无感知的,这也体现了要想持续快速交付,无缝的灰度上线策略是必不可少的。