请求量突增一下,系统有效QPS为何下降很多?
原创:扣钉日记(微信公众号ID:codelogs),欢迎分享,转载请保留出处。
简介
最近我观察到一个现象,当服务的请求量突发的增长一下时,服务的有效QPS会下降很多,有时甚至会降到0,这种现象网上也偶有提到,但少有解释得清楚的,所以这里来分享一下问题成因及解决方案。
队列延迟
目前的Web服务器,如Tomcat,请求处理过程大概都类似如下:

这是Tomcat请求处理的过程,如下:
- Acceptor线程:线程名类似http-nio-8080-Acceptor-0,此线程用于接收新的TCP连接,并将TCP连接注册到NIO事件中。
 - Poller线程:线程名类似http-nio-8080-ClientPoller-0,此线程一般有CPU核数个,用于轮询已连接的Socket,接收新到来的Socket事件(如调用端发请求数据了),并将活跃Socket放入exec线程池的请求队列中。
 - exec线程:线程名类似http-nio-8080-exec-0,此线程从请求队列中取出活跃Socket,并读出请求数据,最后执行请求的API逻辑。
 
这里不用太关心Acceptor与Poller线程,这是nio编程时常见的线程模型,我们将重点放在exec线程池上,虽然Tomcat做了一些优化,但它还是从Java原生线程池扩展出来的,即有一个任务队列与一组线程。
当请求量突发增长时,会发生如下的情况:
- 当请求量不大时,任务队列基本是空的,每个请求都能得到及时的处理。
 - 但当请求量突发时,任务队列中就会有很多请求,这时排在队列后面的请求,就会被处理得越晚,因而请求的整体耗时就会变长,甚至非常长。
 
可是,exec线程们还是在一刻不停歇的处理着请求的呀,按理说服务QPS是不会减少的呀!
简单想想的确如此,但调用端一般是有超时时间设置的,不会无限等待下去,当客户端等待超时的时候,这个请求实际上Tomcat就不用再处理了,因为就算处理了,客户端也不会再去读响应数据的。

因此,当队列比较长时,队列后面的请求,基本上都是不用再处理的,但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;
        }
    }
}
逻辑其实挺简单的,如下:
- 当任务入队列时,包装一下任务,记录一下入队列的时间。
 - 然后线程从队列中取出任务时,若发现任务等待时间过长,就将其移入慢队列。
 - 而pullbackIfEmpty的逻辑,就是当队列为空时,再将慢队列中的任务移回来执行。
 
为了将请求的队列延迟记录在access.log中,我又修改了一下Task,并加了一个Filter,如下:
- 使用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);
    }
}
- 再在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);
    }
}
- 然后在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

可以看到,队列长度远小于1000,这说明队列中积压得不多。
再看看access.log,如下:

可以发现,虽然队列延迟任然存在,但被控制在了1s以内,这样这些请求就不会超时了,Tomcat的有效QPS保住了。
而最后面那些队列延迟极长的请求,则是被不公平对待的请求,但只能这么做,因为在请求量超出Tomcat处理能力时,只能牺牲掉它们,以保全大局。
请求量突增一下,系统有效QPS为何下降很多?的更多相关文章
- 案例实战:每日上亿请求量的电商系统,JVM年轻代垃圾回收参数如何优化?
		
出自:http://1t.click/7TJ 目录: 案例背景引入 特殊的电商大促场景 抗住大促的瞬时压力需要几台机器? 大促高峰期订单系统的内存使用模型估算 内存到底该如何分配? 新生代垃圾回收优化 ...
 - 每日上亿请求量的电商系统,JVM年轻代垃圾回收参数如何优化?    ----实战教会你如何配置
		
目录: 案例背景引入 特殊的电商大促场景 抗住大促的瞬时压力需要几台机器? 大促高峰期订单系统的内存使用模型估算 内存到底该如何分配? 新生代垃圾回收优化之一:Survivor空间够不够 新生代对象躲 ...
 - Sentinel基本使用--基于QPS流量控制(二), 采用Warm Up预热/冷启动方式控制突增流量
		
Sentinel基本使用--基于QPS流量控制(二), 采用Warm Up预热/冷启动方式控制突增流量 2019年02月18日 23:52:37 xiongxianze 阅读数 398更多 分类专栏: ...
 - 请求量限制方法-使用本地Cache记录当前请求量[坑]
		
有个需求:需要限制每个账户请求服务器的次数(该次数可以配置在DB,xml文件或其他).单位:X次/分钟.若1分钟内次数<=X 则允许访问,1分钟内次数>X则不再允许访问. 这类需求很常 ...
 - [故障公告] 13:52-14:03,访问量突增,博客web服务器CPU 100%
		
13:52-14:03,由于访问量突增,博客web服务器全线CPU 100%,造成博客站点不正常访问,由此给您带来麻烦,请您谅解. 为了迎接访问量的增长给web服务器CPU带来的巨大压力,上周我们已经 ...
 - 近期业务大量突增微服务性能优化总结-3.针对 x86 云环境改进异步日志等待策略
		
最近,业务增长的很迅猛,对于我们后台这块也是一个不小的挑战,这次遇到的核心业务接口的性能瓶颈,并不是单独的一个问题导致的,而是几个问题揉在一起:我们解决一个之后,发上线,之后发现还有另一个的性能瓶颈问 ...
 - 近期业务大量突增微服务性能优化总结-4.增加对于同步微服务的 HTTP 请求等待队列的监控
		
最近,业务增长的很迅猛,对于我们后台这块也是一个不小的挑战,这次遇到的核心业务接口的性能瓶颈,并不是单独的一个问题导致的,而是几个问题揉在一起:我们解决一个之后,发上线,之后发现还有另一个的性能瓶颈问 ...
 - 【故障公告】龙卷风来袭:突增的并发请求,撑不住的CPU
		
(上图是数据库连接数监控图) 非常抱歉,今天下午 16:50-17:40 期间,一场龙卷风突袭园子,突增的并发请求狂卷博客站点的 pod,由于风力巨大(70%左右的增量),pod 的 cpu 不堪重负 ...
 - JVM菜鸟进阶高手之路二(JVM的重要性,Xmn是跟请求量有关。)
		
转载请注明原创出处,谢谢! 今天看群聊jvm,通常会问ygc合适吗? 阿飞总结,可能需要2个维度,1.单位时间执行次数,2.执行时间 ps -p pid -o etime 查看下进程的运行时间, 17 ...
 - 云主机被拿去挖矿,cpu暴涨,tcp连接突增
		
1.云主机被拿去挖矿,cpu暴涨,tcp连接突增 2.现象:top -c 3.然后我再查看pstree进程树 4.查找文件来源 ind / -name '*suppoie*' 5. 然后删除 sup ...
 
随机推荐
- 2022最新版超详细的Maven下载配置教程、IDEA中集成maven(包含图解过程)、以及导入项目时jar包下载不成功的问题解决
			
文章目录 1.maven下载 2.maven环境变量的配置 3.查看maven是否配置成功 4.配置文件的修改 5.IDEA集成maven 6.导入项目时jar包下载不成功的问题解决 maven教程: ...
 - .net core-利用PdfSharpCore和SkiaSharp.QrCode 添加PDF二维码页眉
			
前序 由于去年的一个项目需要在PDF 添加公司二维码 ,当时在网上找了很多操作PDF方案,第一种Aspose.PDF,很遗憾 Aspose.PDF 有添加版权的背景还是页脚我忘记了,不适合公司项目,最 ...
 - 9.异步redis
			
在使用Python代码操作redis时候,连接.操作.断开都是网络IO #安装支持异步redis的模块 pip3 install aioredis async def execute(address, ...
 - Java单例模式,看这一篇就够了
			
在创建型设计模式中,我们第一个学习的是单例模式(Singleton Pattern),这是设计模式中最简单的模式之一. 单例是什么意思呢? 单例就是单实例的意思,即在系统全局,一个类只创建一个对象,并 ...
 - 【第5篇】AI语音简介
			
1.3 AI语音简介 AI语音既人工智能语音技术,以语音识别技术为开端,实现人机语言的通信,包括语音识别技术(ASR).自然语言处理技术(NLP)和语音合成技术(TTS).通俗点说就是通过语音这个媒 ...
 - clang在编译时指定目标文件所需的最低macOS版本
			
调研这个的原因,是因为有个同事在macOS 12.2上打包好的程序,放在macOS 10.15上运行时报错: Dyld Error Message: Symbol not found: __ZNKS ...
 - Java计算文件或文件夹大小
			
导入此类后,直接调用FilesUtil.returnSizi(String path),即可获取文件或者文件夹大小. 代码: 1 /** 2 * 路人甲操作文件的工具类 3 * returnSizi( ...
 - Https Webservice接口的免证书调用
			
目录 前言 思路 方案 Axis调用 HttpClient调用 参考链接 前言 在调用https协议的Webservice接口时,如果没有做证书验证,一般会报javax.net.ssl.SSLHand ...
 - .NET深入了解哈希表和Dictionary
			
引子 问题:给定一串数字{1,2,5,7,15,24,33,52},如何在时间复杂度为O(1)下,对数据进行CURD? 数组:我创建一个Length为53的数组,将元素插入相同下标处,是不是就可以实现 ...
 - 重新认识下JVM级别的本地缓存框架Guava Cache——优秀从何而来
			
大家好,又见面了. 本文是笔者作为掘金技术社区签约作者的身份输出的缓存专栏系列内容,将会通过系列专题,讲清楚缓存的方方面面.如果感兴趣,欢迎关注以获取后续更新. 不知不觉,这已经是<深入理解缓存 ...