npe 生产环境一次诡异的 NPE 问题 反转了 4 次

栏目:科技 2021-12-04 21:01:49
分享到:

作者|因爱坚持编辑|欧阳玉丽

前言

为了保证系统的稳定性,公司增加了很多监控,比如接口响应时间、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问题影响不大,但商家并没有收到一定的通知。

将与核心业务逻辑无关的代码放在事务之外是一个好习惯,这样可以防止出现问题时主流程受到影响。

说实话,有时候网络问题对我们来说不一定是坏事。通过这次线上问题定位,我熟悉了公司更多的新功能,从其他同事那里学到了一些好的想法,总结了一些经验教训,这是一次难得的提升自己的机会。