作者|因爱坚持编辑|欧阳玉丽
前言
为了保证系统的稳定性,公司增加了很多监控,比如接口响应时间、cpu利用率、内存利用率、错误日志等等。如果系统出现异常,会通过邮件通知相关人员,让大家第一时间解决隐藏的系统问题。另外,我们这里有一个不成文的规定,就是网上的问题要当天解决,除非有非常棘手的问题。
如果业务中的一个方法最初是为api开发的,那么在方法的深度使用CurrentUser.getCurrent获取用户上下文。但是后来,一个新帅哥也在mq consumers中调用了那个方法,如果他没有意识到这个小机制,就会被抓住,找不到用户上下文。
所以我当时的第一个想法就是代码不兼容,因为这种问题以前偶尔会发生。
解决这个问题其实很简单。只需要先判断是否可以从CurrentUser获取用户信息,如果不能,则获取配置的系统用户信息。伪代码如下:
这个简单无污染的代码,只加一两个地方就可以了。
但是,如果有很多地方正在获取用户信息,是否需要在每个地方都写同样的判断逻辑?对于有抱负的程序员来说,这种简单的重复是编写代码的禁忌。如何更优雅地解决问题?
答案将在文章后面揭晓。
表面上,这个NPE问题是有答案的。根据以往的经验,由于代码中没有兼容性处理,在mq consumer service中获取的用户信息为空。对于空对象,如果调用其方法,将出现NPE。
第一次逆转
但是这个回答似乎有点草率,会不会有什么机关?
所以我在项目中全局搜索了关键字CurrentUser.set,实际上找到了一个器官。
剧情第一次反转。
有一个地方可以写一个rocketmq的AOP拦截器。伪代码如下:
它拦截所有mq使用者中的onMessage方法。在执行方法之前,它从userProperty获取用户信息,并创建用户对象并将其设置在用户上下文中。
温馨提醒,免得有些朋友踩坑照葫芦画。上面的伪代码只给出了设置用户上下文的关键代码。使用后,删除用户上下文的代码没有给出。有兴趣的朋友可以私下和我聊聊。
既然有获取用户信息的地方,我猜肯定有设置的地方。就在这个时候,我突然发现自己有一点做侦探的潜质,因为我后来真的发现了。
不惊讶,不惊讶?
另一位同事自己定制了一个RocketMQTemplate。伪代码如下:
这段代码的主要功能是在mq生产者发送异步消息之前,将当前用户上下文信息设置到mq消息的头中,这样就可以由mq消费者中的userProperty获取,其本质也是从头中获取的。
这个设计很巧妙,通过CurrentUser.getCurrent完美解决了mq消费者的问题;无法获取用户信息。
这时线索突然断了,没有进展。
我再次查看了服务器日志。已确认mq消息有问题,并且其标头信息没有userId和userName字段。
是因为mq生产者没有将用户信息插入到header中吗?这是需要认真怀疑的地方。
因为mq生产者是由另一个团队编写的代码,当EOA回调他们的系统时,他们会向我们发送mq消息,通知我们签名状态。
EOA是第三方系统,用户系统还没有和我们连接。因此,在另一个团队的回调接口中,无法获取当前登录用户的信息,AOP的拦截器也无法自动将用户信息插入到头部,这在mq消费者中自然无法获取。
这样想真的很有道理。
第二次逆转
但真的是这样吗?
怀着极大的希望,我们给他们发了一封邮件,请他们帮忙检查问题。
很快,他们回复了邮件。
但是他们说:已经在当地测试过了,功能正常。
就这样,故事第二次反转了。
这个时候我有点好奇,他们是怎么把用户信息塞进表头的?带着“学习心态”,我让他们一起查相关代码。
他们调用一个UserUtil工具,在发送mq消息之前注入用户。这个工具类的伪代码如下:
嗯,我不得不承认,这确实可以解决头传用户信息的问题,比之前需要手动判断用户信息是否为空要优雅得多,因为注入的用户信息肯定不是空。
折腾了很久,NPE问题还是没有解决。
我回头看了一下定制的RocketMQTemplate类,发现了被覆盖的方法:asyncSend,它包含五个参数。当他们向我们推送消息时,被称为asyncSend的只传递了三个参数。
突然,问题有了新的进展。有没有可能他们调整错了界面?
他们应该用5个参数调用方法,但实际上他们用3个参数调用了方法。
这就解释清楚了。
第三次逆转
最后,我有了一些想法,我准备用喜悦来证明我的猜想。
但事实证明,我真的高兴得太早了,我立刻被扇了耳光。
这是最快的逆转。
这是怎么回事?
本来我以为是另一个团队的人在发送mq消息的时候调整了错误的方法,应该用五个参数调用asyncSend方法,但是他们的代码实际上用三个参数调用了同名的方法。
为了防止错误的同事发生。尽职调查后,我仔细查看了RocketMQTemplate类的所有方法,它是由rocketmq框架提供的。
我偶然发现了一些断开的关系,伪代码如下:
这背后有一个大秘密。这些同名的方法有着相同的目标,最后都调用了带有五个参数的asyncSend方法。
这样,如果在一个子类中重写五个asyncSend方法,就相当于重写了所有的asyncSend方法。
再次证明他们是对的。
温馨提醒大家,有些类的重载方法会互相调用。如果重写子类底部的重载方法,将重写所有重载方法。
头疼,该回到原点了。
第四次逆转
在这一点上,我有点困惑。
不过有一个好习惯:当你遇到网上的问题,不知道该怎么办的时候,会多查看日志。
我没有报告任何希望,但我没想到会再次查看日志。
还有第四个反转。
这次带着尝试的心态,根据messageID查看了mq生产者的日志,找到了一条消息的发送日志。
这一次,我擦了擦眼睛,发现了一个小细节:时间不对。
该日志显示消息是2021年5月21日发送的,但实际上mq消费者是在2021年5月28日处理的。
这消息一周内就被消化了?
显然不是。
我开始有点尊重了。返回并检查带有该messageID的mq消费者的日志,发现实际上有六条消息被消费。前五次结果是同一天,都是2021-05-21,都没有处理。另一次是2021-05-28,处理成功。
为什么同一条消息在同一天被消费五次?
如果你熟悉rocketmq,你一定知道它支持重试机制。
如果mq使用者消息处理失败,可能会在业务代码中引发异常。然后,框架层捕获异常并返回consumeconcurrentlystatus。稍后,rocketmq会自动将消息放入重试队列。
流程图如下:
通过这种方式,mq消费者可以在下一次重新消费该消息,直到一定次数,rocketmq会将该消息发送到死信队列。
流程图如下:
以后不再消耗。
为什么最后花的更多?
最后一条消息不能由其他mq生产者发送,因为messageID是唯一的,其他生产者不能生成相同的messageID。
那么,只有一种可能,那就是人为地发出一个信息。
在搜索在线日志时,时间、消息标识、跟踪标识和记录数量的维度非常重要。
真实性
后来才发现,真的是人为留言。
一周前有用户上线,因EOA页面回拨界面失败导致审核状态变更失败。批准文件在EOA系统中被批准,但是mq消费者在处理批准文件时发现状态仍然是待批准,所以直接退回,没有完成后续流程,导致批准文件数据异常。
为了解决这个问题,我们首先修改了在线审批单的状态。然后,在rocketmq的后台手动发送了一条消息。当时在rocketmq的后台看不到标题信息,所以消息直接发送到指定的主题,没有照顾到标题。
请注意,手动发送mq消息时,一定要注意是否需要在报头中设置相关参数,尤其是rocketmq,否则可能会出现问题。
mq消费者消费完消息后,审核列表通常会经历这个过程,并与测试一起进行测试,数据库的状态也是正常的。
大家都认为没有问题,但是大家都忽略了一个小细节:正常的业务逻辑处理完后,会给指定的用户发送websocket通知。但是这个功能是已经离职的同事添加的新逻辑,其他人都不知道。从手动发送消息的人的角度来看,他是对的,因为他根本不知道新功能的存在。
因为这一行代码是最后一行代码,和前面的代码不在一个东西里面,所以即使出了问题,也不会影响正常的业务逻辑。
因此,NPE问题影响不大,但商家并没有收到一定的通知。
将与核心业务逻辑无关的代码放在事务之外是一个好习惯,这样可以防止出现问题时主流程受到影响。
说实话,有时候网络问题对我们来说不一定是坏事。通过这次线上问题定位,我熟悉了公司更多的新功能,从其他同事那里学到了一些好的想法,总结了一些经验教训,这是一次难得的提升自己的机会。