如果你在用shiro作为底层的安全框架,请一定要阅读此文。
背景
一天,小L接到一个任务,需要优化一个系统接口。小L看了一下原有接口逻辑,代码大致如下:
Controller:
@GetMapping("/bullshitApi")
public Result bullshitApi() {
long start = System.currentTimeMillis();
String condition = IdUtil.simpleUUID();
log.info("username={} 查询条件={}", ShiroUtil.whoAmI(), condition);
String result = testService.getData(condition);
long time = System.currentTimeMillis() - start;
return Result.success(result + "time=" + time + "ms");
}
Service
public String getData(String condition) {
String username1 = longComputation1(condition);
String username2 = longComputation2(condition);
return "username1=" + username1 + ",username2=" + username2 + ",condition=" + condition;
}
private String longComputation1(String condition) {
String username = ShiroUtil.whoAmI();
log.info("longComputation1 username={} 查询条件={}", username, condition);
// 方法很复杂,数据关联查询较多
try {
TimeUnit.MILLISECONDS.sleep(450);
} catch (InterruptedException e) {
e.printStackTrace();
}
return username;
}
private String longComputation2(String condition) {
String username = ShiroUtil.whoAmI();
log.info("longComputation2 username={} 查询条件={}", username, condition);
// 方法很复杂,数据关联查询较多
try {
TimeUnit.MILLISECONDS.sleep(450);
} catch (InterruptedException e) {
e.printStackTrace();
}
return username;
}
初始测试结果 900ms
{
"traceID": "328840403957121024",
"timestamp": 1605166498528,
"language": "zh",
"data": "username1=leven.chen,username2=leven.chen,condition=cdafd8d4556c4fc0b6484648c4eac6e6>>Time=900ms",
"code": "S0000",
"msg": null,
"success": true
}
第一次改造:异步
经过分析,小L发现,这个方法中主要有两个耗时的子方法 longComputation1()
和longComputation2()
,里面的逻辑非常复杂,什么OCP原则,里氏替换等等全都没有,有的只是一大串的代码。
这让小L很是头疼。但是庆幸的是,这两个方法间并没有数据关联关系。那如果使用异步API并行的去处理,那岂不是可以将性能提升很多?!
同步API 与 异步API
同步API其实只是对传统方法调用的另一种称呼,方法间会串行执行,调用方必须等待被调用方执行结束后才会执行下一个方法,这就是阻塞式调用这个名词的由来。与此相反,异步API会直接先返回,或者至少会在被调用方执行结束前,将剩余任务将给另一个线程去处理。简单来说就是方法间会并行的执行,而且调用方和被调用方并不在一个线程内,这就是非阻塞式调用的由来。
于是,小L就开始改造为异步并行处理,代码如下:
public String getDataV2(String condition) {
List list = Lists.newArrayList();
Thread t1 = new Thread(() -> {
String result = longComputation1(condition);
list.add(result);
});
Thread t2 = new Thread(() -> {
String result = longComputation2(condition);
list.add(result);
});
try {
t1.start();
t2.start();
t1.join();
t2.join();
return "username1=" + list.get(0) + ",username2=" + list.get(1) + ",condition=" + condition;
} catch (InterruptedException e) {
log.error("error", e);
}
return null;
}
这里使用了两个异步线程并发的执行方法,经过小L的测试,从原来的900ms,变为了现在的638ms
测试结果: 638ms
{
"traceID": "328840403957121024",
"timestamp": 1605166498528,
"language": "zh",
"data": "username1=leven.chen,username2=leven.chen,condition=cdafd8d4556c4fc0b6484648c4eac6e6>>Time=638ms",
"code": "S0000",
"msg": null,
"success": true
}
而且功能也没有任何问题,非常有效果。
但是IDEA 的 阿里巴巴Java规范提示了一个警告:“不要手工创建线程,请使用线程池”。作为一个有代码洁癖的工程师,小L肯定要修复这个问题。
"嗯,虽然有效果,但是不够完美,而且每次新开线程确实太浪费了,应该搞个线程池,做到极致优化" 小L心中默默的思考着~
第二次改造:异步+线程池
小L在项目中配置了一个线程池,并将异步方法提交到了线程池中进行处理。
线程池配置:
@PostConstruct
public void init() {
executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(10);
executor.setKeepAliveSeconds(5);
executor.setThreadNamePrefix("AYSNC-TEST");
// 线程池对拒绝任务的处理策略
// CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 初始化
executor.initialize();
}
getDataV3:线程池+多线程
这里使用了Java CompletableFuture是为了代码简洁,大致等同于上面的Thread处理
public String getDataV3(String condition) {
CompletableFuture c1 = CompletableFuture.supplyAsync(() -> longComputation1(condition), executor);
CompletableFuture c2 = CompletableFuture.supplyAsync(() -> longComputation2(condition), executor);
try {
return "username1=" + c1.get() + ",username2=" + c2.get() + ",condition=" + condition;
} catch (InterruptedException | ExecutionException e) {
log.error("error", e);
}
return null;
}
测试结果:
{
"traceID": "328840933984616448",
"timestamp": 1605166624897,
"language": "zh",
"data": "username1=leven.chen,username2=leven.chen,condition=6ca569dff23a4f6fb73f932838793173>>Time=452ms",
"code": "S0000",
"msg": null,
"success": true
}
“由上一步的的638ms 到了452ms, 又节省了100ms多毫秒,终于达标小于500ms了。” 小L心中默喜~
单元测试,自测,发布上线,回归都没有问题,小L高兴的以为圆满完成,但是此时他不知道,他已经被坑惨了,坑他的不是其他,正是阿里巴巴Java规范关于线程池的提示。
项目上线不久后,有用户反馈时而能查到别人的数据,这个频率越来越高。后面的用户已经完全查不到自己的数据了。
图中演示用户信息错乱,相同的condition代表一组查询
小L赶快将生产版本回滚,并陷入了沉思... “到底是什么原因呢?”
大坑:Shiro +线程池
小L又默默的一行行看着自己修改的代码,问题表现是“用户信息取错了”,”那么用户信息又是通过dava security(shiro)的ShiroUtil拿到的,那么会不会是这个里面线程间传递有问题呢?“
他打开了ShiroUtil的源码进行追踪查看,突然他(心中)大喊一声 "卧槽,F**k, 这么坑爹"
原来Shiro是用了InheritableThreadLocal来实现存储&线程间隔离用户信息。当创建新的异步线程时,子线程会判断父线程是否存在InheritableThreadLocal,如果存在,就会将InheritableThreadLocal中的信息复制到子线程间,实现线程间传递数据
java.lang.Thead#init()方法部分源码
private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
// 省略。。。
//inheritThreadLocals默认为true
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
// 省略。。。
}
但是小L使用了线程池,线程池最大的作用就是复用线程,也就是说这个init方法只会在线程创建的时候执行,一旦线程初始化,就不会再次执行该方法。其他异步任务就会直接使用该线程,这也就是解释了为什么getDataV2()方法完全没有问题,getDataV3()一开始也没有问题,但是随着用户操作次数增多,线程池中的线程复用情况越来越多,就会出现用户信息取错的问题。
Alibaba官方解释:JDK的InheritableThreadLocal类可以完成父线程到子线程的值传递。但对于使用线程池等会池化复用线程的执行组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的;这时父子线程关系的ThreadLocal值传递已经没有意义,因为不会再走初始化方法,但是应用需要的实际上是把 任务提交给线程池时的ThreadLocal值传递到 任务执行时。
问题修复
其实问题一旦被定位,就很好修复了。
方案一:不要用线程池
推荐指数:
小L心中感悟,当我们在对线程池原理及使用掌握不是非常透彻之前,建议不要使用。最简单的方法,反而是最有效的,固然线程池可以帮助我们提高一点点的效率,但是为了这一点点的性能提升,而导致数据错误,真的是得不偿失!!
方案二:Shiro官方提供的Subject.associateWith(task)方法
推荐指数:
这个没啥可说的,官方出的方案,如果你心中充满执念,可以使用该方法进行处理。
可以使用Shiro官方的TaskExecutor,也可以自定义,小L采用的是自定义了,源码如下:
自定义一个ThreadPoolTaskExecutor 就叫它 ShiroSubjectAwareTaskExecutor:
public class ShiroSubjectAwareTaskExecutor extends ThreadPoolTaskExecutor {
@Override
public boolean prefersShortLivedTasks() {
return false;
}
@Override
public void execute(Runnable task) {
if (task instanceof SubjectRunnable) {
super.execute(task);
return;
}
// not SubjectRunnable and currentSubject not null
Subject currentSubject = ThreadContext.getSubject();
if (Objects.nonNull(currentSubject)) {
super.execute(currentSubject.associateWith(task));
} else {
super.execute(task);
}
}
}
这里重写了线程池的execute方法,在线程被提交执行前用Subject.associateWith(task)进行包装。
然后再创建线程池时使用我们自定义的ShiroSubjectAwareTaskExecutor
@PostConstruct
public void init() {
executor = new ShiroSubjectAwareTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(10);
executor.setKeepAliveSeconds(5);
executor.setThreadNamePrefix("AYSNC-TEST");
// 线程池对拒绝任务的处理策略
// CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 初始化
executor.initialize();
}
注意,如果您的项目中存在多个线程池配置(包含且不限于java 8中的ForkJoinPool,ParallelStream等),都需要使用Subject.associateWith(task)进行处理。而且这个方法是return 包装类,不是对象引用,千万小心。
不得不说,Subject.associateWith(task) 这个API设计的感觉真心一般。应该用get或者rebuild来装饰一下这个方法名,否正会让调用者以为是引用传递呢!!
它的实现原理也非常简单,就是将我们的runnable进行了包装SubjectRunnable,然后在子线程真正执行之前bind() 用户信息,执行结束后进行unbind,源码如下:
org.apache.shiro.subject.support.SubjectRunnable#run()方法源码
public void run() {
try {
threadState.bind();
doRun(this.runnable);
} finally {
threadState.restore();
}
}
方案三:用Srping Security 替换Shiro
推荐指数:
看了一下Spring Security的源码,它默认是避免这个问题的,而且在API设计上,Spring Security 也支持通过策略模式,使用自己的ThreadContext存储策略,您甚至可以用redis来写实现。单从这一个小点来说,不得不承认,Spring Security在设计上确实优于Shiro。
-
Spring Security:
- 默认是ThreadLocalSecurityContextHoldStrategy
- 如果需要线程间传递,可以手工修改配置改为InheritedThreadLocalSecurityContextHoldStrategy
估计如果你修改为了InheritedThreadLocalSecurityContextHoldStrategy,也就代表者你知道这里面的风险,如果出现了问题,后果自负! 相比于Shiro默认就使用来说,spring security 确实够良心!
方案四:大神之路 重写Shiro 的 ThreadContext
推荐指数:∞
从根本解决问题,高端玩法,目前只能给出相关参考资料:
- Alibaba transmittable-thread-local
如果你的业务需要『在使用线程池等会池化复用线程的执行组件情况下传递ThreadLocal』则是TransmittableThreadLocal目标场景。
结束语
我是一个被shiro伤过的Java小学生,欢迎大家吐槽留言。
如果您觉得这篇文章有用,请留下您的小。