前言
最近同事在开发的时候,在调用RPC获取用户的积分的时候,发生了返回的用户以及积分信息不是我们请求的uid对应的用户信息的情况,uid变成了另一个人的uid,积分信息也变成了另一个人的,是不是很神奇。同事叫我帮忙查找原因,RPC和调用方的代码都是该同事写的,我在本地用他给的请求参数去请求那个RPC,发现确实会出现那么神奇的情况,返回的信息不是请求参数中的用户的信息。反复请求几次,依旧是这样。
问题确实很奇怪,但是可以肯定是被调用方代码有问题,然后拉下RPC提供方代码。
分析Bug
- 查看源码
获取用户信息部分代码
public UserBizInfo detail(UserBizInfo param) {
// 校验
userBizInfoValidation.detail(param);
String uid = AppUserUtil.getUid();
if (StringUtils.isNotBlank(uid)) {
param.setUid(uid);
} else {
uid = param.getUid();
}
//...
}
public class AppUserUtil {
private static ThreadLocal<String> local = new ThreadLocal<String>();
public static void setUid(String uid) {
local.set(uid);
}
public static String getUid() {
return local.get();
}
}
我们看到他这里的逻辑是先从当前线程的ThreadLocalMap中去获取key为local实例的Entry的value值。如果获取到了uid,就将param参数的uid设置为ThreadLocal中的vlaue。分析到这里其实就已经定位到问题了,就是这个ThreadLocal搞的鬼。我们知道我们的Web服务器在处理请求的时候肯定是要使用到线程池的。
当一个请求进入时,他向当前线程的ThreadLocalMap中添加了key为local这个ThreadLocal实例的Entry,value为一个用户的uid,然后当前请求结束,结束之前并没有remove掉这个Entry。
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// TODO Auto-generated method stub
HandlerMethod handlerMethod = (HandlerMethod) handler;
Object bean = handlerMethod.getBean();
Method method = handlerMethod.getMethod();
AppSecurityAuth bean_auth = bean.getClass().getAnnotation(AppSecurityAuth.class);
AppSecurityAuth method_auth = method.getAnnotation(AppSecurityAuth.class);
if ((bean_auth != null && !bean_auth.login_auth()) || (method_auth != null && !method_auth.login_auth())) {
return true;
}
String uid = apiAuth.getUserId();
if (StringUtils.isEmpty(uid)) {
// 未获取uid,校验是否可直接访问
throw ExceptionUtil.getAuthException("身份校验失败");
}
AppUserUtil.setUid(uid);
return true;
}
项目中是使用拦截器来设置Entry的,前台的接口都需要经过这个拦截器来获取用户的uid。
接着我们的RPC请求来了,注意,我们的RPC请求是不需要经过这个拦截器的,而恰好这个时候Web服务器的线程池中的线程已经满了,RPC请求复用了处理之前的请求的线程,RPC请求进入到detail方法中,String uid = AppUserUtil.getUid();
获取到了之前请求设置的uid,导致了bug的发生。
- 解决Bug
public UserBizInfo detail(UserBizInfo param) {
// 校验
userBizInfoValidation.detail(param);
String uid =param.getUid();
if (StringUtils.isBlank(uid)) {
uid=AppUserUtil.getUid();
}
//...
}
Bug的解决很简单,先判断是否请求参数中带有uid,如果带有的话,证明是RPC请求,如果没有的话,证明是前台请求,从ThreadLocal中拿数据。如果都还没有拿到的话,就需要抛出异常了。
- 为什么会发生这个Bug?
为什么会出现这个Bug呢,从这个Bug的表现来说,还挺唬人的,返回的用户信息居然变成了另一个人的用户信息。出现这个Bug的原因,主要还是对线程池的理解不够,或者说根本就没有考虑到请求线程复用的问题。
detail方法最开始只会被前台请求调用,由于每一个前台请求都会经过拦截器,在到达detail方法之前都会进行AppUserUtil.setUid(uid);
,其实这个操作,就会把之前请求设置的uid给清掉,然后重新设置一个新的,这样才保证了detail方法拿到的uid都是当前请求设置的uid,如果不是在拦截器中每次都会重新设置uid,那么detail方法一开始就会有问题。而后来,另一个项目也需要获取用户积分了,同事打算提供一个RPC供新项目调用获取用户积分,这个时候他在兼容两种调用的时候,写的代码出了问题,先使用AppUserUtil.getUid();
来获取uid,导致如果是RPC请求的话,在复用线程的时候,就会拿到之前的前台请求设置的uid,导致Bug。
- 反思
这个Bug的出现是由于在兼容代码的时候,忽略了ThreadLocal在线程复用时候出现的问题,在有线程池的情况下,使用ThreadLocal是一定要小心的,在每次任务(线程池中的Task,在web服务器中就是一个请求)设置值之前都清掉之前任务设置的值,或者在任务结束前,清掉该任务设置的值。否则就会出现问题。
但是就我而言,我并不觉得这样兼容代码是一个很好的选择。我会新增一个方法专门给RPC调用使用,而不是继续使用detail方法,在这个方法上做兼容。
public UserBizInfo rpcDetail(UserBizInfo param) {
// 校验
userBizInfoValidation.detail(param);
String uid =param.getUid();
if (StringUtils.isBlank(uid)) {
ExceptionUtil.getParamsException("uid不能为空");
}
//获取用户信息逻辑,抽出一个方法,detail方法和rpcDetail方法公用
getUserInfoDetail(param);
//...
}
public UserBizInfo detail(UserBizInfo param) {
// 校验
userBizInfoValidation.detail(param);
param.setUid(AppUserUtil.getUid());
//获取用户信息逻辑,抽出一个方法,detail方法和rpcDetail方法公用
getUserInfoDetail(param);
//...
}