说明:本文记录一次偶然出现的空指针异常,在微服务架构中,一个服务在调用另一个服务时,出现了空指针异常。
业务描述:在做订单超时功能时,大家都知道,可以使用RabbitMQ延迟队列,下单的同时给队列发送一个延迟消息(消息的内容是订单号),比如延迟10分钟。10分钟之后,该消息被消费者监听到,会根据该订单ID查询数据库,看该订单的状态是否为已支付,是则忽略,否则取消该订单,恢复商品库存等等其他操作,然而此时出现了空指针异常,消息未被消费,被路由到死信队列中。
(微服务调用报空指针异常)
(消息被路由到死信队列)
如下图的第三步:
分析
首先排除FeignClient的问题,因为下单减少库存,取消订单恢复库存,我使用的是同一个接口,只是修改了商品数量的正负数,不可能出现下单时可以,取消订单时再使用就报错的情况。
(controller层代码)
/**
* 根据ID更新商品库存
* @param id
* @param num
*/
@PutMapping("/update/{id}/{num}")
public void updateStockById(@PathVariable("id") Long id, @PathVariable("num") Integer num){
itemService.updateStockById(id,num);
}
(service层代码)
@Override
public void updateStockById(Long id, Integer num) {
if (!ObjectUtil.isAllNotEmpty(id, num)) {
System.out.println("参数不能为空");
}
if (id < 0 || num < 0) {
System.out.println("参数非法");
}
update().setSql("stock = stock + " + num).eq("id", id).update();
}
其次,再思考会不会不是因为Feign的调用报错,而是微服务之间有什业务产生的报错。于是,我找到了拦截器。
为了保证用户登录后,经过Gateway(网关)后,信息可以被下游服务获取到,我的代码中是使用MVC拦截器+Feign拦截器实现的,如下图:
每个服务会有两个拦截器,分别把服务接收到的请求,发出的请求拦截到,然后分别解析用户信息,添加用户信息到请求头,以此达到参数透传,用户信息可在微服务之间流传。
MVC拦截器代码(获取请求头中用户的ID,存到ThreadLocal中)
public class AuthorizationInterceptor implements HandlerInterceptor {
/**
* 收到请求会执行的方法
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String id = request.getHeader("authorization");
if (id != null && id != ""){
long l = Long.parseLong(id);
TokenThreadLocal.set(l);
}else {
responseHandler(response);
return false;
}
// 放行
return true;
}
……
}
Feign拦截器(将本服务中的ThreadLocal中的用户ID再设置到请求头上)
/**
* 发送请求拦截器
*/
@Slf4j
public class AuthorizationRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
requestTemplate.header("authorization",TokenThreadLocal.get().toString());
}
}
排查
给这两个地方分别打上断点,等订单超时后进入拦截器的代码,排查一下;
断点来到Feign拦截器,选中这行代码一看,原来是这里报了空指针异常
再一看,原来是TokenThreadLocal.get().toString()
这里是空的;
然后恍然大悟,MQ发送消息是异步请求,ThreadLocal本地线程池对象,自然为空;
解决
很自然的想到一种很简单的解决方法,发送消息的时候把ThreadLocal中的值(用户ID)也给发到延迟队列中,然后在消费者监听的代码里面,再使用ThreadLocal的set()方法,把用户ID设置到线程池中;
把订单ID、用户ID封装成一个Map,转为json格式发送到延迟队列里;
消费者代码这边,使用ThreadLocal的set()方法,把用户ID再设置进去;
启动,测试下单,等待订单超时,清理超时订单,进入断点,问题解决!
总结
这是一个非常隐蔽的异常,因为设置了死信队列,未被成功消费的消息会被路由到死信队列中,程序并不会报错,并且因为订单表的内容大部分是在订单服务中,此异常仅仅会影响订单被取消后,调用商品服务恢复商品库存数量这一个很小的功能未能执行,要排除出来是非常困难的。
而问题原因,概括来说,是因为ThreadLocal的值不能在RabbitMQ的消息中传递,导致在使用拦截器获取ThreadLocal值的时候报了空指针异常。