Web服务耗时接口同步请求异步处理解决方案

背景

生产环境存在一些接口,其因为后端服务涉及到大量数据库读写操作,因此接口非常耗时。比如商品导入功能,经过事务拆分、拆分查询并组装数据等手段对功能进行了优化,在不改变业务设计的前提下几乎无任何优化空间。
容器(tomcat)中线程的数量是一定的,容器处理大量耗时请求时,势必会影响其他接口的正常访问。
因此,在不改变业务的前提下,在高并发场景提高商品服务的吞吐率优化是非常必要的。

技术实现方案

采用Spring MVC异步处理方案(适用于耗时同步交易场景)。Spring MVC异步处理实现方案通常支持3种方式:

  • Callable实现
  • WebAsyncTask实现
  • DefferedResult实现

我们使用DefferedResult + 线程池 + 阻塞队列LinkedBlockingQueue 实现请求的异步处理同步响应。

关于DeferredResult

DeferredResult从 Spring 3.2 开始可用,有助于将长时间运行的计算从 http-worker 线程卸载到单独的线程。
尽管另一个线程会占用一些资源进行计算,但工作线程在此期间不会被阻塞并且可以处理传入的其他客户端请求。
异步请求处理模型非常有用,因为它有助于在高负载期间很好地扩展应用程序,尤其是对于 IO 密集型操作。

DeferredResult处理流程

DeferredResult的处理过程与Callback类似,不一样的地方在于它的结果不是DeferredResult直接返回的,而是由其它线程通过同步的方式设置到该对象中。它的执行过程如下所示:

  • 客户端请求服务
    SpringMVC调用Controller,Controller返回一个DeferredResult对象
  • SpringMVC调用ruquest.startAsync
  • DispatcherServlet以及Filters等从应用服务器线程中结束(释放容器线程),但Response仍旧是打开状态,也就是说暂时还不返回给客户端
  • 异步线程处理实际业务并将结果设置到DeferredResult中,SpringMVC将请求发送给应用服务器继续处理
  • DispatcherServlet再次被调用并且继续处理DeferredResult中的结果,最终将其返回给客户端。

重要技术点

线程池

线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

LinkedBlockingQueue

LinkedBlockingQueue实现是线程安全的,实现了先进先出等特性,是作为生产者消费者的首选,LinkedBlockingQueue 可以指定容量,也可以不指定,不指定的话,默认最大是Integer.MAX_VALUE,其中主要用到put和take方法,put方法在队列满的时候会阻塞直到有队列成员被消费,take方法在队列空的时候会阻塞,直到有队列成员被放进来。这里需要注意直接使用LinkedBlockingQueue阻塞队列作为线程池会存在一个问题,当workcount > corePool时优先进入队列排队,因此当请求并发过多会导致请求缓慢,甚至因为队列过多出现内存溢出(JDK是先排队再涨线程池)

网络上基本找得到的DeferredResult相关的技术博客或文章都是直接创建线程或使用LinkedBlockingQueue的线程池,实际在压力测试过程当模拟接口并发到50就出现大量延迟,比不优化时性能还差。有兴趣的可以直接使用LinkedBlockingQueue作为线程池队列压力测试看看效果。

Tomcat的线程池

org.apache.tomcat.util.threads.TaskQueue
org.apache.tomcat.util.threads.ThreadPoolExecutor

因为我们优化的是web接口请求,不能因为LinkedBlockingQueue的排队导致接口出现大量延迟和缓慢,因此我们在实现过程不直接使用LinkedBlockingQueue作为线程池的阻塞队列,而是使用tomcat的线程池TaskQueue,TaskQueue继承了JDK的LinkedBlockingQueue 并扩展了JDK线程池的功能,主要体现在两点:

  • Tomcat的ThreadPoolExecutor使用的TaskQueue,是无界的LinkedBlockingQueue,但是通过taskQueue的offer方法覆盖了LinkedBlockingQueue的offer方法,改写了规则,使得线程池能在任务较多的情况下增长线程池数量——JDK是先排队再涨线程池,Tomcat则是先涨线程池再排队。
  • Tomcat的ThreadPoolExecutor改写了execute方法,当任务被reject时,捕获异常,并强制入队。

代码实现

创建处理耗时任务的线程池

public static ThreadPoolExecutor executor = null;

  private TaskQueue taskqueue;

  protected int maxQueueSize = Integer.MAX_VALUE;

  protected int threadPriority = 5;
  protected boolean daemon = true;
  protected String namePrefix = "testsleep-";
  protected int minSpareThreads = 25;
  protected int maxThreads = 200;
  protected int maxIdleTime = 60000;
  protected long threadRenewalDelay = 1000L;
  protected boolean prestartminSpareThreads = false;


  /**
   * 初始化时启动监听请求队列
   */
  @PostConstruct
  public void init() {
    /*cachedThreadPool = new ThreadPoolExecutor(4,
        50,
        0,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<>(50),
        r -> new Thread(r));*/

    // 任务队列:这里你看到的是一个无界队列,但是队列里面进行了特殊处理
    taskqueue = new TaskQueue(maxQueueSize);
    TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon, threadPriority);
    // 创建线程池,这里的ThreadPoolExecutor是Tomcat继承自JDK的ThreadPoolExecutor
    executor = new ThreadPoolExecutor(
        minSpareThreads, maxThreads, // 核心线程数与最大线程数
        maxIdleTime, TimeUnit.MILLISECONDS, // 默认6万毫秒的超时时间,也就是一分钟
        taskqueue, tf); // 玄机在任务队列的设置
    executor.setThreadRenewalDelay(threadRenewalDelay);
    if (prestartminSpareThreads) {
      executor.prestartAllCoreThreads(); // 预热所有的核心线程
    }
    taskqueue.setParent(executor);


  }

重构请求,使用DeferredResult实现异步处理

这里我们直接使用Thread.sleep模拟一个耗时任务

@GetMapping("/users-anon/test/{testkey}")
  public DeferredResult<Result<String>> testSleep(@PathVariable String testkey) {

    //return service.testSleep(); //直接调用耗时业务处理

    DeferredResult<Result<String>> output = new DeferredResult<>(1000 * 30L);

    output.onTimeout(() ->
        output.setErrorResult(
            ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT)
                .body("Request timeout occurred.")));

    log.info("[ TestSleepController ] 接到请求");

    //转到后台线程
    QueueListener.executor.execute(() -> {
      log.info("开始执行耗时任务:{}", System.currentTimeMillis());
      try {
        Thread.sleep(6000);
      } catch (InterruptedException e) {
      }
      log.info("执行耗时任务结束:{}", System.currentTimeMillis());
      output.setResult(Result.succeed());
    });


    log.info("[ TestSleepController ] 返回DeferredResult,并释放容器线程.");

    return output;


  }

压力测试

机器参数

CentOS Linux release 7.3.1611 (Core) 1核 2.30GHz 8G内存

服务部署

Docker容器

测试结果

单接口测试

在测试DeferredResult前,我们先模拟一个耗时接口,并对接口进行压力测试:
100线程 循环一次

200线程 循环一次

500线程 循环一次

当耗时接口在100、200并发下接口基本正常,当达到500时接口响应出现明显的迟缓。

混合接口测试

我们建立两个线程组,一个是正常的耗时任务A(线程组2),一个是使用DeferredResult优化的耗时任务B(线程组1)。
任务A、B 各50线程 循环一次
压测结果如下:


整体响应都差不多。

任务A 50线程 循环一次 任务B 500线程 循环一次
DeferredResult优化的耗时任务压测结果:

正常任务压测结果:


当任务BDeferredResult优化后的接口并发爆发式增长后,接口的响应仍和优化前一样出现大范围的延迟,但是任务A的接口响应并未收到影响。

结果分析

通过压测结果分析我们可以得出DeferredResult优化的耗时任务虽然不能提示耗时接口本身的响应速度,但是能极大减少耗时任务对服务容器线程的占用,提升应用在高并发场景下本身的吞吐量。

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

推荐阅读更多精彩内容