请求量突增一下,系统有效QPS为何下降很多?

简介

最近我观察到一个现象,当服务的请求量突发的增长一下时,服务的有效QPS会下降很多,有时甚至会降到0,这种现象网上也偶有提到,但少有解释得清楚的,所以这里来分享一下问题成因及解决方案。

队列延迟

目前的Web服务器,如Tomcat,请求处理过程大概都类似如下:

image.png

这是Tomcat请求处理的过程,如下:

  1. Acceptor线程:线程名类似http-nio-8080-Acceptor-0,此线程用于接收新的TCP连接,并将TCP连接注册到NIO事件中。
  2. Poller线程:线程名类似http-nio-8080-ClientPoller-0,此线程一般有CPU核数个,用于轮询已连接的Socket,接收新到来的Socket事件(如调用端发请求数据了),并将活跃Socket放入exec线程池的请求队列中。
  3. exec线程:线程名类似http-nio-8080-exec-0,此线程从请求队列中取出活跃Socket,并读出请求数据,最后执行请求的API逻辑。

这里不用太关心AcceptorPoller线程,这是nio编程时常见的线程模型,我们将重点放在exec线程池上,虽然Tomcat做了一些优化,但它还是从Java原生线程池扩展出来的,即有一个任务队列与一组线程。

当请求量突发增长时,会发生如下的情况:

  1. 当请求量不大时,任务队列基本是空的,每个请求都能得到及时的处理。
  2. 但当请求量突发时,任务队列中就会有很多请求,这时排在队列后面的请求,就会被处理得越晚,因而请求的整体耗时就会变长,甚至非常长。

可是,exec线程们还是在一刻不停歇的处理着请求的呀,按理说服务QPS是不会减少的呀!

简单想想的确如此,但调用端一般是有超时时间设置的,不会无限等待下去,当客户端等待超时的时候,这个请求实际上Tomcat就不用再处理了,因为就算处理了,客户端也不会再去读响应数据的。

image.png

因此,当队列比较长时,队列后面的请求,基本上都是不用再处理的,但exec线程池不知道啊,它还是会一如既往地处理这些请求。

当exec线程执行这些已超时的请求时,若又有新请求进来,它们也会排在队尾,这导致这些新请求也会超时,所以在流量突发的这段时间内,请求的有效QPS会下降很多,甚至会降到0。

这种超时也叫做队列延迟,但队列在软件系统中应用得太广泛了,比如操作系统调度器维护了线程队列,TCP中有backlog连接队列,锁中维护了等待队列等等。

因此,很多系统也会存在这种现象,平时响应时间挺稳定的,但偶尔耗时很高,这种情况有很多都是队列延迟导致的。

优化队列延迟

知道了问题产生的原因,要优化它就比较简单了,我们只需要让队列中那些长时间未处理的请求暂时让路,让线程去执行那些等待时间不长的请求即可,毕竟这些长时间未处理的请求,让它们再等等也无防,因为客户端可能已经超时了而不需要请求结果了,虽然这破坏了队列的公平性,但这是我们需要的。

对于Tomcat,在springboot中,我们可以如下修改:
使用WebServerFactoryCustomizer自定义Tomcat的线程池,如下:

@Component
public class TomcatExecutorCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
    @Resource
    ServerProperties serverProperties;

    @Override
    public void customize(TomcatServletWebServerFactory factory) {
        TomcatConnectorCustomizer tomcatConnectorCustomizer = connector -> {
            ServerProperties.Tomcat.Threads threads = serverProperties.getTomcat().getThreads();
            TaskQueue taskqueue = new SlowDelayTaskQueue(1000);
            ThreadPoolExecutor executor = new org.apache.tomcat.util.threads.ThreadPoolExecutor(
                    threads.getMinSpare(), threads.getMax(), 60L, TimeUnit.SECONDS,
                    taskqueue, new CustomizableThreadFactory("http-nio-8080-exec-"));
            taskqueue.setParent(executor);
            ProtocolHandler handler = connector.getProtocolHandler();
            if (handler instanceof AbstractProtocol) {
                AbstractProtocol<?> protocol = (AbstractProtocol<?>) handler;
                protocol.setExecutor(executor);
            }
        };
        factory.addConnectorCustomizers(tomcatConnectorCustomizer);
    }
}

注意,这里还是使用的Tomcat实现的线程池,只是将任务队列TaskQueue扩展为了SlowDelayTaskQueue,它的作用是将长时间未处理的任务移到另一个慢队列中,待当前队列中无任务时,再把慢队列中的任务移回来。

为了能记录任务入队列的时间,先封装了一个记录时间的任务类RecordTimeTask,如下:

@Getter
public class RecordTimeTask implements Runnable {
    private Runnable run;
    private long createTime;
    private long putQueueTime;

    public RecordTimeTask(Runnable run){
        this.run = run;
        this.createTime = System.currentTimeMillis();
        this.putQueueTime = this.createTime;
    }
    @Override
    public void run() {
        run.run();
    }

    public void resetPutQueueTime() {
        this.putQueueTime = System.currentTimeMillis();
    }

    public long getPutQueueTime() {
        return this.putQueueTime;
    }
}

然后队列的扩展实现如下:

public class SlowDelayTaskQueue extends TaskQueue {
    private long timeout;
    private BlockingQueue<RecordTimeTask> slowQueue;

    public SlowDelayTaskQueue(long timeout) {
        this.timeout = timeout;
        this.slowQueue = new LinkedBlockingQueue<>();
    }

    @Override
    public boolean offer(Runnable o) {
        // 将任务包装一下,目的是为了记录任务放入队列的时间
        if (o instanceof RecordTimeTask) {
            return super.offer(o);
        } else {
            return super.offer(new RecordTimeTask(o));
        }
    }

    public void pullbackIfEmpty() {
        // 如果队列空了,从慢队列中取回来一个
        if (this.isEmpty()) {
            RecordTimeTask r = slowQueue.poll();
            if (r == null) {
                return;
            }
            r.resetPutQueueTime();
            this.add(r);
        }
    }

    @Override
    public Runnable poll(long timeout, TimeUnit unit) throws InterruptedException {
        pullbackIfEmpty();
        while (true) {
            RecordTimeTask task = (RecordTimeTask) super.poll(timeout, unit);
            if (task == null) {
                return null;
            }
            // 请求在队列中长时间等待,移入慢队列中
            if (System.currentTimeMillis() - task.getPutQueueTime() > this.timeout) {
                this.slowQueue.offer(task);
                continue;
            }
            return task;
        }
    }

    @Override
    public Runnable take() throws InterruptedException {
        pullbackIfEmpty();
        while (true) {
            RecordTimeTask task = (RecordTimeTask) super.take();
            // 请求在队列中长时间等待,移入慢队列中
            if (System.currentTimeMillis() - task.getPutQueueTime() > this.timeout) {
                this.slowQueue.offer(task);
                continue;
            }
            return task;
        }
    }
}

逻辑其实挺简单的,如下:

  1. 当任务入队列时,包装一下任务,记录一下入队列的时间。
  2. 然后线程从队列中取出任务时,若发现任务等待时间过长,就将其移入慢队列。
  3. 而pullbackIfEmpty的逻辑,就是当队列为空时,再将慢队列中的任务移回来执行。

为了将请求的队列延迟记录在access.log中,我又修改了一下Task,并加了一个Filter,如下:

  1. 使用ThreadLocal将队列延迟先存起来
@Getter
public class RecordTimeTask implements Runnable {
    private static final ThreadLocal<Long> WAIT_IN_QUEUE_TIME = new ThreadLocal<>();

    private Runnable run;
    private long createTime;
    private long putQueueTime;
    public RecordTimeTask(Runnable run){
        this.run = run;
        this.createTime = System.currentTimeMillis();
        this.putQueueTime = this.createTime;
    }
    @Override
    public void run() {
        try {
            WAIT_IN_QUEUE_TIME.set(System.currentTimeMillis() - this.createTime);
            run.run();
        } finally {
            WAIT_IN_QUEUE_TIME.remove();
        }
    }

    public void resetPutQueueTime() {
        this.putQueueTime = System.currentTimeMillis();
    }

    public long getPutQueueTime() {
        return this.putQueueTime;
    }

    public static long getWaitInQueueTime(){
        return ObjectUtils.defaultIfNull(WAIT_IN_QUEUE_TIME.get(), 0L);
    }
}
  1. 再在Filter中将队列延迟取出来,放入Request对象中
@WebFilter
@Component
public class WaitInQueueTimeFilter extends HttpFilter {

    @Override
    public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws
                                                                                                      IOException,
                                                                                                      ServletException {
        long waitInQueueTime = RecordTimeTask.getWaitInQueueTime();
        // 将等待时间设置到request的attribute中,给access.log使用
        request.setAttribute("waitInQueueTime", waitInQueueTime);

        // 如果请求在队列中等待了太长时间,客户端大概率已超时,就没有必要再执行了
        if (waitInQueueTime > 5000) {
            response.sendError(503, "service is busy");
            return;
        }
        chain.doFilter(request, response);
    }

}
  1. 然后在access.log中配置队列延迟
server:
  tomcat:
    accesslog:
      enabled: true
      directory: /home/work/logs/applogs/java-demo
      file-date-format: .yyyy-MM-dd
      pattern: '%h %l %u %t "%r" %s %b %Dms %{waitInQueueTime}rms "%{Referer}i" "%{User-Agent}i" "%{X-Forwarded-For}i"'

注意,在access.log中配置%{xxx}r表示取请求xxx属性的值,所以,%{waitInQueueTime}r就是队列延迟,后面的ms是毫秒单位。

优化效果

我使用接口压测工具wrk压了一个测试接口,此接口执行时间100ms,使用1000个并发去压,1s的超时时间,如下:

wrk -d 10d -T1s --latency http://localhost:8080/sleep -c 1000

然后,用arthas看一下线程池的队列长度,如下:

[arthas@619]$ vmtool --action getInstances \
    --classLoaderClass org.springframework.boot.loader.LaunchedURLClassLoader \
    --className org.apache.tomcat.util.threads.ThreadPoolExecutor \
    --express 'instances.{ #{"ActiveCount":getActiveCount(),"CorePoolSize":getCorePoolSize(),"MaximumPoolSize":getMaximumPoolSize(),"QueueSize":getQueue().size()} }' \
    -x 2

image.png

可以看到,队列长度远小于1000,这说明队列中积压得不多。

再看看access.log,如下:

image.png

可以发现,虽然队列延迟任然存在,但被控制在了1s以内,这样这些请求就不会超时了,Tomcat的有效QPS保住了。

而最后面那些队列延迟极长的请求,则是被不公平对待的请求,但只能这么做,因为在请求量超出Tomcat处理能力时,只能牺牲掉它们,以保全大局。

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

推荐阅读更多精彩内容