美文网首页springboot
性能优化?千万别用Shiro+线程池

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

作者: 我不想成为胖子 | 来源:发表于2020-12-03 10:44 被阅读0次

    如果你在用shiro作为底层的安全框架,请一定要阅读此文。

    背景

    一天,小L接到一个任务,需要优化一个系统接口。小L看了一下原有接口逻辑,代码大致如下:
    Controller:

       @GetMapping("/bullshitApi")
       public Result<String> 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<String> 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<String> c1 = CompletableFuture.supplyAsync(() -> longComputation1(condition), executor);
            CompletableFuture<String> 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

    推荐指数:∞

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

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

    结束语

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

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

    相关文章

      网友评论

        本文标题:性能优化?千万别用Shiro+线程池

        本文链接:https://www.haomeiwen.com/subject/rmqmwktx.html