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

如果你在用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小学生,欢迎大家吐槽留言。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容