线上一次诡异的NPE问题,反转了4次
前言
最近缺项目经历想快速提升项目实战能力(包含多个AI项目),或者最近找工作,或者想学习AI的小伙伴,可以看看下面👇🏻的这个链接(或许真的能够帮到你)。
我们公司为了保证系统的稳定性,加了很多监控,比如:接口响应时间、cpu使用率、内存使用率、错误日志等等。如果系统出现异常情况,会邮件通知相关人员,以便于大家能在第一时间解决隐藏的系统问题。此外,我们这边有个不成文的规定,就是线上问题最好能够当日解决,除非遇到那种非常棘手的问题。
1.起因
有个周一的早上,我去公司上班,查看邮件,收到我们老大转发的一封邮件,让我追查线上的一个NPE问题。
邮件是通过sentry发出来的,我们通过点击邮件中的相关链接,可以直接跳转到sentry的详情页面。在这个页面中,展示了很多关键信息,比如:操作时间、请求的接口、出错的代码位置、报错信息、请求经过了哪些链路等等。真是居家旅行,查bug的良药,有了这些,小case一眼就能查到原因。
我当时没费吹灰之力,就访问到了NPE的sentry报错页面(其实只用鼠标双击一下就搞定)。果然上面有很多关键信息,我一眼就看到了NPE的具体代码位置:
notify.setName(CurrentUser.getCurrent().getUserName());剧情发展得如此顺利,我都有点不好意思了。
根据类名和代码行号,我在idea中很快找到那行代码,不像是我写的,这下可以放心不用背锅了。于是接下来看了看那行的代码修改记录,最后修改人是XXX。
什么?是他?
他在一个月前已经离职了,看来这个无头公案已经无从问起,只能自己查原因。
我当时内心的OS是:代码没做兼容处理。
为什么这么说?
这行代码其实很简单,就是从当前用户上下文中获取用户名称,然后设置到notify实体中。
CurrentUser内部包含了一个ThreadLocal对象,它负责保存当前线程的用户上下文信息。当然为了保证在线程池中,也能从用户上下文中获取到正确的用户信息,这里用了阿里的TransmittableThreadLocal。伪代码如下:
@Data
public class CurrentUser {
private static final TransmittableThreadLocal<CurrentUser> THREA_LOCAL = new TransmittableThreadLocal<>();
private String id;
private String userName;
private String password;
private String phone;
...
public statis void set(CurrentUser user) {
THREA_LOCAL.set(user);
}
public static void getCurrent() {
return THREA_LOCAL.get();
}
}然后在项目中定义一个全局的spring mvc拦截器,专门设置用户上下文到ThreadLocal中。伪代码如下:
public class UserInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
CurrentUser user = getUser(request);
if(Objects.nonNull(user)) {
CurrentUser.set(user);
}
}
}用户在请求我们接口时,会先触发该拦截器,它会根据用户cookie中的token,调用调用接口获取redis中的用户信息。如果能获取到,说明用户已经登录,则把用户信息设置到CurrentUser类的ThreadLocal中。
接下来,在api接口的底层(business层)方法中,就能轻松通过CurrentUser.getCurrent();方法获取到想要的用户上下文信息了。
刚开始设计得挺美好的,但有个小问题是:business层被api服务和mq消费者服务都引用了,business层里面的方法两个服务都能调用。我们都知道,api服务用户是需要登录的,而mq消费者服务不需要登录。这样如果某个方法在消费mq时,调用CurrentUser.getCurrent();方法是获取不到用户上下文信息的。
最近缺项目经历想快速提升项目实战能力(包含多个AI项目),或者最近找工作,或者想学习AI的小伙伴,可以看看下面👇🏻的这个链接(或许真的能够帮到你)。
所以我当时的第一个想法是:代码没做兼容处理,因为之前这类问题时有发生。
比如某个方法a刚开始是被api服务调用的,它的底层使用了CurrentUser.getCurrent();获取用户信息。后来,mq消费者服务中有类似的功能,你可能会直接调用已有方法a,但由于该方法又调用了另外的多个方法,调用层级很深,根本没法判断有没有调用CurrentUser.getCurrent();。
所以,我们以前的做法是,先判断一下能否从CurrentUser中获取用户信息,如果不能,则取配置的系统用户信息。伪代码如下:
@Autowired
private BusinessConfig businessConfig;
CurrentUser user = CurrentUser.getCurrent();
if(Objects.nonNull(user)) {
entity.setUserId(user.getUserId());
entity.setUserName(user.getUserName());
} else {
entity.setUserId(businessConfig.getDefaultUserId());
entity.setUserName(businessConfig.getDefaultUserName());
}那段代码没有做这样的兼容处理,导致在mq消费者服务中无法获取用户信息,它那里又没有判空,所以才会出现NPE问题。
表面上,已经有答案了,但我想了想,会不会有什么机关呢?
2.第一次反转
我在多个项目工程中全局搜索CurrentUser.set关键字,还真找到了一个机关。
找到一个mq的AOP拦截器,伪代码如下:
@Aspect
@Component
public class RocketMqAspect {
@Pointcut("execution(* onMessage(..)&&@within(org.apache.rocketmq.spring.annotation.RocketMQMessageListener))")
public void pointcut() {
}
...
@Around(value="pointcut")
public void around(ProceedingJoinPoint point) throws Throwable {
if(point.getArgs().length == 1 && point.getArgs()[0] instanceof MessageExt) {
Message message = (Message)point.getArgs()[0];
String userId = message.getUserProperty("userId");
String userName = message.getUserProperty("userName");
if(StringUtils.notEmpty(userId) && StringUtils.notEmpty(userName)) {
CurrentUser user = new CurrentUser();
user.setUserId(userId);
user.setUserName(userName);
CurrentUser.set(user);
}
}
...
}
}它会拦截所有mq消费者中的onMessage方法,在该方法执行之前,从userProperty中获取用户信息,并且创建用户对象,设置到用户上下文中。
注意,上面的伪代码只给出了设置用户上下文的关键代码,用完后,删除用户上下文的代码没有给出,感兴趣的朋友可以找我私聊。
既然有获取用户信息的地方,必定有设置的地方。我突然发现,有点当侦探的潜力,还真找到了。
有另外一个同事自定义了一个RocketMQTemplate,伪代码如下:
public class MyRocketMQTemplate extends RocketMQTemplate {
public void asyncSend(String destnation, Meassage<?> message, SendCallback sendCallback, long timeout, int delayLevel) {
super.asyncSend(destnation,message,sendCallback,timeout,delayLevel);
}
}3.第二次反转
4.第三次反转
5.第四次反转
6.真相
最近缺项目经历想快速提升项目实战能力(包含多个AI项目),或者最近找工作,或者想学习AI的小伙伴,可以看看下面👇🏻的这个链接(或许真的能够帮到你)。