性能优化?千万别用Shiro+线程池

如果你在用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肯定要修复这个问题。

1.png

"嗯,虽然有效果,但是不够完美,而且每次新开线程确实太浪费了,应该搞个线程池,做到极致优化" 小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规范关于线程池的提示

项目上线不久后,有用户反馈时而能查到别人的数据,这个频率越来越高。后面的用户已经完全查不到自己的数据了

2.png

图中演示用户信息错乱,相同的condition代表一组查询

小L赶快将生产版本回滚,并陷入了沉思... “到底是什么原因呢?”

大坑:Shiro +线程池

小L又默默的一行行看着自己修改的代码,问题表现是“用户信息取错了”,”那么用户信息又是通过dava security(shiro)的ShiroUtil拿到的,那么会不会是这个里面线程间传递有问题呢?“

他打开了ShiroUtil的源码进行追踪查看,突然他(心中)大喊一声 "卧槽,F**k, 这么坑爹"

3.png

原来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
    4.png

估计如果你修改为了InheritedThreadLocalSecurityContextHoldStrategy,也就代表者你知道这里面的风险,如果出现了问题,后果自负! 相比于Shiro默认就使用来说,spring security 确实够良心!

方案四:大神之路 重写Shiro 的 ThreadContext

推荐指数:∞

从根本解决问题,高端玩法,目前只能给出相关参考资料:

  • Alibaba transmittable-thread-local

如果你的业务需要『在使用线程池等会池化复用线程的执行组件情况下传递ThreadLocal』则是TransmittableThreadLocal目标场景。

结束语

我是一个被shiro伤过的Java小学生,欢迎大家吐槽留言。

如果您觉得这篇文章有用,请留下您的小。

你可能感兴趣的:(性能优化?千万别用Shiro+线程池)