SpringBoot 实现 QPS 监控:从原理到高性能实战

发布时间:2026/7/1 1:34:32
SpringBoot 实现 QPS 监控:从原理到高性能实战 摘要在微服务架构中QPSQueries Per Second是衡量系统吞吐量和健康度的核心指标。本文将深入剖析 QPS 监控的核心算法基于 Spring Boot 和 Micrometer 框架设计并实现一套低开销、高并发安全、支持动态维度的 QPS 监控方案。我们将摒弃简单的计数器采用滑动窗口算法Sliding Window结合RingBuffer数据结构深入探讨LongAdder在高并发下的性能优势并提供完整的源码实现。一、 为什么我们需要 QPS 监控在日常开发中我们通常使用 Prometheus、Grafana 等 APM 工具来获取流量数据。但在某些场景下我们需要自研轻量级的 QPS 监控定制化指标需要统计特定业务逻辑如某个非 HTTP 接口、特定参数组合的 QPS通用探针无法覆盖。本地快速诊断在排查线上问题时需要直接在应用日志或内存中查看瞬时流量而不依赖外部监控系统。限流前置判断QPS 数据往往是限流Rate Limiting算法的基础。QPS 监控的常见误区误区 1使用简单的AtomicInteger每秒清零。这会导致监控数据出现“毛刺”无法反映一秒内的流量分布。误区 2在Controller层添加 AOP。这无法统计到Filter层拦截掉的请求如安全校验失败也无法统计到静态资源。二、 QPS 监控核心原理QPS 是指系统每秒处理的请求数量。要实现精准的 QPS 监控核心在于时间窗口的划分。1. 固定窗口 vs 滑动窗口固定窗口 (Fixed Window)将时间划分为固定的区间如 1 秒在区间内累加计数。缺点存在严重的临界点问题。假设 00:00:59 涌入 1000 个请求00:01:01 又涌入 1000 个请求虽然系统承受了 2000 QPS 的压力但两个窗口的统计数据都显示只有 1000 QPS容易掩盖瞬时峰值。滑动窗口 (Sliding Window) - 我们的选择将一个大窗口如 10 秒划分为多个小时间片如 1 秒一个共 10 个格子。优势精度更高可以通过滑动步长控制。数据平滑能够真实反映最近 N 秒的平均流量。淘汰机制简单随着时间推移过期的格子自动失效。三、 SpringBoot 架构设计1. 拦截点选择Filter vs Interceptor vs AOP为了获取最真实的 QPS我们应该尽早捕获请求。OncePerRequestFilter是最佳选择它位于DispatcherServlet之前能捕获所有进入应用的 HTTP 请求包括 404、错误页。保证了每个请求只被过滤一次。支持异步请求处理。2. 指标框架集成MicrometerSpring Boot 2.x/3.x 默认集成了 Micrometer。它是一个“门面”库类似于 SLF4J。统一门面编写代码时无需关心底层是 Prometheus、JMX 还是 Datadog。Gauge vs CounterQPS 是一个速率 (Rate)本质上是“一段时间内的增量”。在 Micrometer 中我们通常使用Gauge暴露当前的滑动窗口计算值或者直接暴露Counter让 Grafana 用rate()函数计算。本方案策略我们将自行实现滑动窗口逻辑然后通过 Micrometer 的Gauge将计算后的 QPS 值暴露出去。3. 高并发数据结构URL 维度存储使用ConcurrentHashMapString, WindowCounterKey 为 URIValue 为该 URI 的计数器。时间片计数器为了避免并发写入导致的竞争我们引入RingBuffer结合LongAdder。四、 核心源码实现1. 定义滑动窗口结构 (WindowCounter)我们需要一个结构来维护时间片。Window Size: 比如 60 秒。Slot Size: 比如 1 秒。Slots: 60 个格子形成一个环形数组。packagecom.example.qps.monitor;importjava.util.concurrent.atomic.LongAdder;importjava.util.concurrent.locks.ReentrantReadWriteLock;/** * 基于 RingBuffer 和 LongAdder 实现的高性能滑动窗口计数器 */publicclassSlidingWindowCounter{// 窗口大小秒privatefinalintwindowSize;// 槽位数量默认 1 秒一个槽位privatefinalintslotCount;// 环形数组存储每个时间片的计数privatefinalLongAdder[]slots;// 读写锁用于周期性清理过期数据时的并发控制privatefinalReentrantReadWriteLocklocknewReentrantReadWriteLock();// 上次清理时间纳秒privatevolatilelonglastClearTime;publicSlidingWindowCounter(intwindowSize){this.windowSizewindowSize;this.slotCountwindowSize;// 假设粒度为 1sthis.slotsnewLongAdder[slotCount];for(inti0;islotCount;i){slots[i]newLongAdder();}this.lastClearTimeSystem.currentTimeMillis();}/** * 记录一次请求 */publicvoidrecord(){// 1. 检查是否需要清理过期数据checkAndClearExpiredSlots();// 2. 获取当前槽位并累加intindexgetCurrentSlotIndex();slots[index].increment();}/** * 获取当前窗口的总 QPS */publiclonggetQps(){longtotal0;try{// 读锁允许并发读取但禁止在清理时读取lock.readLock().lock();checkAndClearExpiredSlots();for(LongAdderslot:slots){totalslot.longValue();}}finally{lock.readLock().unlock();}// 注意这里返回的是窗口内的总请求数。// 如果要算平均 QPS应除以有效时间片数量。// 为了简化此处通常暴露的是“最近 N 秒的总请求数”由 Prometheus rate() 计算 QPS。// 或者我们可以直接算平均 QPS total / validSlotCount。returntotal;}/** * 获取当前槽位索引 */privateintgetCurrentSlotIndex(){longnowSystem.currentTimeMillis();longsecondnow/1000;return(int)(second%slotCount);}/** * 清理过期数据防止 RingBuffer 数据重叠 * 简单判断如果当前时间与上次清理时间跨过了一个窗口周期则重置数组 */privatevoidcheckAndClearExpiredSlots(){longnowSystem.currentTimeMillis();// 如果已经过了一个完整的窗口周期if(now-lastClearTimewindowSize*1000L){lock.writeLock().lock();try{// 双重检查if(now-lastClearTimewindowSize*1000L){// 重置所有槽位// 注意在高并发下直接 new LongAdder[] 或者遍历 reset()// 这里为了极致性能采用遍历 resetfor(LongAdderslot:slots){slot.reset();}lastClearTimenow;}}finally{lock.writeLock().unlock();}}}}2. 核心过滤器 (QpsMonitorFilter)实现请求拦截并根据 URI 路由到不同的计数器。packagecom.example.qps.monitor;importio.micrometer.core.instrument.Gauge;importio.micrometer.core.instrument.MeterRegistry;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.stereotype.Component;importorg.springframework.web.filter.OncePerRequestFilter;importorg.springframework.web.util.UrlPathHelper;importjavax.servlet.FilterChain;importjavax.servlet.ServletException;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;importjava.io.IOException;importjava.util.Map;importjava.util.concurrent.ConcurrentHashMap;ComponentpublicclassQpsMonitorFilterextendsOncePerRequestFilter{privatestaticfinalLoggerlogLoggerFactory.getLogger(QpsMonitorFilter.class);// 存储每个 URI 的计数器privatefinalMapString,SlidingWindowCountercounterMapnewConcurrentHashMap();AutowiredprivateMeterRegistrymeterRegistry;// 窗口大小 60sprivatestaticfinalintWINDOW_SIZE60;OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainfilterChain)throwsServletException,IOException{Stringurirequest.getRequestURI();// 1. 获取或创建该 URI 的计数器// computeIfAbsent 保证线程安全SlidingWindowCountercountercounterMap.computeIfAbsent(uri,key-{SlidingWindowCounternewCounternewSlidingWindowCounter(WINDOW_SIZE);// 2. 注册到 MicrometerGauge.builder(app.request.qps.total,newCounter,SlidingWindowCounter::getQps).tags(uri,key)// 维度标签.description(Total requests in sliding window).register(meterRegistry);returnnewCounter;});// 3. 记录请求counter.record();// 4. 放行filterChain.doFilter(request,response);}}五、 深度解析与性能优化1. LongAdder vs AtomicLong在上述实现中我们使用了LongAdder而不是AtomicLong。AtomicLong底层依赖 CAS (Compare-And-Swap)。在高并发竞争下CAS 失败率高会导致 CPU 空转自旋严重影响性能。LongAdder底层采用分段累加思想类似 JDK8 的ConcurrentHashMap。它将累加值分散到多个 Cell 中多线程写入时访问不同的 Cell极大减少了冲突。结论在统计 QPS 这种“读少写多”的场景下LongAdder的性能远高于AtomicLong。2. RingBuffer 的内存优化为什么不使用LinkedList或ArrayList来存储时间片GC 友好RingBuffer 是一个固定长度的数组初始化后不会产生新的对象。无锁更新通过System.currentTimeMillis()计算索引天然支持无锁写入除了周期性的清理操作。3. 内存泄漏防御动态 URL 问题如果我们的接口是 RESTful 风格的例如/api/users/1,/api/users/2直接以uri作为 Key 会导致ConcurrentHashMap无限膨胀最终 OOM。解决方案URL 模板化利用 Spring 的HandlerMapping在拦截器阶段获取最佳匹配模式Pattern如/api/users/{id}以此作为 Key。LRU 淘汰策略如果必须保留精确 URI可以限制 Map 的最大容量并使用 LRU (Least Recently Used) 算法淘汰冷门数据。// 简单 LRU 改造示例publicclassLruCounterMapK,VextendsLinkedHashMapK,V{privatestaticfinalintMAX_CAPACITY500;publicLruCounterMap(){super(MAX_CAPACITY,0.75f,true);}OverrideprotectedbooleanremoveEldestEntry(Map.EntryK,Veldest){returnsize()MAX_CAPACITY;}}六、 分布式场景下的 QPS 监控以上方案适用于单机监控。但在微服务集群中我们往往关心的是全局 QPS。方案对比方案原理优缺点适用场景Prometheus 聚合每个实例暴露本地 GaugePrometheus 拉取后使用sum(rate(...))计算。优点无侵入零代码改动实时性好。缺点依赖外部组件瞬时值可能存在几秒延迟。推荐大多数微服务场景。Redis Lua请求到来时通过 Lua 脚本在 Redis 中进行原子累加和窗口计算。优点数据绝对精确支持分布式限流。缺点增加网络 RTT影响业务性能QPS 监控不应拖慢业务。强一致性限流场景。最佳实践在 SpringBoot 内部使用本文的本地滑动窗口方案保证监控逻辑不影响业务 RTT。然后通过 Micrometer 暴露数据由 Prometheus 完成最终的分布式聚合计算。七、 总结本文实现了一套生产级的 SpringBoot QPS 监控方案。核心要点如下算法选择滑动窗口算法解决了固定窗口的临界点问题。性能设计利用RingBuffer减少内存分配利用LongAdder解决高并发 CAS 竞争。工程实践通过OncePerRequestFilter拦截全量流量结合 Micrometer 无缝对接主流监控生态。通过这套方案我们可以在极低性能损耗单次请求纳秒级开销的前提下精准掌握系统的流量脉搏为后续的限流、熔断和容量规划提供坚实的数据支撑。 福利时间如果你正在备战面试或者想要学习其他知识给大家推荐一个宝藏知识库作者整理了一些列 Java 程序员需要掌握的核心知识有需要的自取不谢。知识库地址https://farerboy.com/