SpringBoot实现QPS监控的原理与高性能实战

 更新时间:2026年07月01日 08:57:45   作者:(farerboy)  
本文深入探讨了SpringBoot中基于滑动窗口算法的高效QPS监控实现,摒弃简单AtomicInteger简单计数器,采用LongAdderer和RingBuffer数据结构,提供低延迟、高并发安全的的方案

摘要:在微服务架构中,QPS(Queries Per Second)是衡量系统吞吐量和健康度的核心指标。本文将深入剖析 QPS 监控的核心算法,基于 Spring Boot 和 Micrometer 框架,设计并实现一套低开销、高并发安全、支持动态维度的 QPS 监控方案。我们将摒弃简单的计数器,采用 滑动窗口算法(Sliding Window) 结合 RingBuffer 数据结构,深入探讨 LongAdder 在高并发下的性能优势,并提供完整的源码实现。

一、 为什么我们需要 QPS 监控?

在日常开发中,我们通常使用 Prometheus、Grafana 等 APM 工具来获取流量数据。但在某些场景下,我们需要自研轻量级的 QPS 监控:

  1. 定制化指标:需要统计特定业务逻辑(如某个非 HTTP 接口、特定参数组合)的 QPS,通用探针无法覆盖。
  2. 本地快速诊断:在排查线上问题时,需要直接在应用日志或内存中查看瞬时流量,而不依赖外部监控系统。
  3. 限流前置判断:QPS 数据往往是限流(Rate Limiting)算法的基础。

QPS 监控的常见误区

  • 误区 1:使用简单的 AtomicInteger 每秒清零。这会导致监控数据出现“毛刺”,无法反映一秒内的流量分布。
  • 误区 2:在 Controller 层添加 AOP。这无法统计到 Filter 层拦截掉的请求(如安全校验失败),也无法统计到静态资源。

二、 QPS 监控核心原理

QPS 是指系统每秒处理的请求数量。要实现精准的 QPS 监控,核心在于时间窗口的划分。

固定窗口 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. 指标框架集成:Micrometer

Spring Boot 2.x/3.x 默认集成了 Micrometer。它是一个“门面”库,类似于 SLF4J。

  • 统一门面:编写代码时无需关心底层是 Prometheus、JMX 还是 Datadog。
  • Gauge vs Counter:QPS 是一个速率 (Rate),本质上是“一段时间内的增量”。在 Micrometer 中,我们通常使用 Gauge 暴露当前的滑动窗口计算值,或者直接暴露 Counter 让 Grafana 用 rate() 函数计算。
  • 本方案策略:我们将自行实现滑动窗口逻辑,然后通过 Micrometer 的 Gauge 将计算后的 QPS 值暴露出去。

3. 高并发数据结构

  • URL 维度存储:使用 ConcurrentHashMap<String, WindowCounter>,Key 为 URI,Value 为该 URI 的计数器。
  • 时间片计数器:为了避免并发写入导致的竞争,我们引入 RingBuffer 结合 LongAdder

四、 核心源码实现

1. 定义滑动窗口结构 (WindowCounter)

我们需要一个结构来维护时间片。

  • Window Size: 比如 60 秒。
  • Slot Size: 比如 1 秒。
  • Slots: 60 个格子,形成一个环形数组。
package com.example.qps.monitor;

import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * 基于 RingBuffer 和 LongAdder 实现的高性能滑动窗口计数器
 */
public class SlidingWindowCounter {

    // 窗口大小(秒)
    private final int windowSize;
    // 槽位数量,默认 1 秒一个槽位
    private final int slotCount;
    // 环形数组,存储每个时间片的计数
    private final LongAdder[] slots;
    // 读写锁,用于周期性清理过期数据时的并发控制
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    // 上次清理时间(纳秒)
    private volatile long lastClearTime;

    public SlidingWindowCounter(int windowSize) {
        this.windowSize = windowSize;
        this.slotCount = windowSize; // 假设粒度为 1s
        this.slots = new LongAdder[slotCount];
        for (int i = 0; i < slotCount; i++) {
            slots[i] = new LongAdder();
        }
        this.lastClearTime = System.currentTimeMillis();
    }

    /**
     * 记录一次请求
     */
    public void record() {
        // 1. 检查是否需要清理过期数据
        checkAndClearExpiredSlots();

        // 2. 获取当前槽位并累加
        int index = getCurrentSlotIndex();
        slots[index].increment();
    }

    /**
     * 获取当前窗口的总 QPS
     */
    public long getQps() {
        long total = 0;
        try {
            // 读锁:允许并发读取,但禁止在清理时读取
            lock.readLock().lock();
            checkAndClearExpiredSlots();
            for (LongAdder slot : slots) {
                total += slot.longValue();
            }
        } finally {
            lock.readLock().unlock();
        }
        // 注意:这里返回的是窗口内的总请求数。
        // 如果要算平均 QPS,应除以有效时间片数量。
        // 为了简化,此处通常暴露的是“最近 N 秒的总请求数”,由 Prometheus rate() 计算 QPS。
        // 或者我们可以直接算平均 QPS = total / validSlotCount。
        return total;
    }

    /**
     * 获取当前槽位索引
     */
    private int getCurrentSlotIndex() {
        long now = System.currentTimeMillis();
        long second = now / 1000;
        return (int) (second % slotCount);
    }

    /**
     * 清理过期数据(防止 RingBuffer 数据重叠)
     * 简单判断:如果当前时间与上次清理时间跨过了一个窗口周期,则重置数组
     */
    private void checkAndClearExpiredSlots() {
        long now = System.currentTimeMillis();
        // 如果已经过了一个完整的窗口周期
        if (now - lastClearTime >= windowSize * 1000L) {
            lock.writeLock().lock();
            try {
                // 双重检查
                if (now - lastClearTime >= windowSize * 1000L) {
                    // 重置所有槽位
                    // 注意:在高并发下直接 new LongAdder[] 或者遍历 reset()
                    // 这里为了极致性能,采用遍历 reset
                    for (LongAdder slot : slots) {
                        slot.reset();
                    }
                    lastClearTime = now;
                }
            } finally {
                lock.writeLock().unlock();
            }
        }
    }
}

2. 核心过滤器 (QpsMonitorFilter)

实现请求拦截,并根据 URI 路由到不同的计数器。

package com.example.qps.monitor;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.UrlPathHelper;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class QpsMonitorFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(QpsMonitorFilter.class);

    // 存储每个 URI 的计数器
    private final Map<String, SlidingWindowCounter> counterMap = new ConcurrentHashMap<>();
    
    @Autowired
    private MeterRegistry meterRegistry;

    // 窗口大小 60s
    private static final int WINDOW_SIZE = 60;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        
        String uri = request.getRequestURI();
        
        // 1. 获取或创建该 URI 的计数器
        // computeIfAbsent 保证线程安全
        SlidingWindowCounter counter = counterMap.computeIfAbsent(uri, key -> {
            SlidingWindowCounter newCounter = new SlidingWindowCounter(WINDOW_SIZE);
            // 2. 注册到 Micrometer
            Gauge.builder("app.request.qps.total", newCounter, SlidingWindowCounter::getQps)
                    .tags("uri", key) // 维度标签
                    .description("Total requests in sliding window")
                    .register(meterRegistry);
            return newCounter;
        });

        // 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 的内存优化

为什么不使用 LinkedListArrayList 来存储时间片?

  • 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 改造示例
public class LruCounterMap<K, V> extends LinkedHashMap<K, V> {
    private static final int MAX_CAPACITY = 500;
    
    public LruCounterMap() {
        super(MAX_CAPACITY, 0.75f, true);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > MAX_CAPACITY;
    }
}

六、 分布式场景下的 QPS 监控

以上方案适用于单机监控。但在微服务集群中,我们往往关心的是 全局 QPS

方案对比

方案原理优缺点适用场景
Prometheus 聚合每个实例暴露本地 Gauge,Prometheus 拉取后使用 sum(rate(...)) 计算。优点:无侵入,零代码改动,实时性好。
缺点:依赖外部组件,瞬时值可能存在几秒延迟。
推荐:大多数微服务场景。
Redis + Lua请求到来时,通过 Lua 脚本在 Redis 中进行原子累加和窗口计算。优点:数据绝对精确,支持分布式限流。
缺点:增加网络 RTT,影响业务性能(QPS 监控不应拖慢业务)。
强一致性限流场景。

最佳实践:在 SpringBoot 内部使用本文的本地滑动窗口方案,保证监控逻辑不影响业务 RTT。然后通过 Micrometer 暴露数据,由 Prometheus 完成最终的分布式聚合计算。

七、 总结

本文实现了一套生产级的 SpringBoot QPS 监控方案。核心要点如下:

  1. 算法选择:滑动窗口算法解决了固定窗口的临界点问题。
  2. 性能设计:利用 RingBuffer 减少内存分配,利用 LongAdder 解决高并发 CAS 竞争。
  3. 工程实践:通过 OncePerRequestFilter 拦截全量流量,结合 Micrometer 无缝对接主流监控生态。

通过这套方案,我们可以在极低性能损耗(单次请求纳秒级开销)的前提下,精准掌握系统的流量脉搏,为后续的限流、熔断和容量规划提供坚实的数据支撑。

以上就是SpringBoot实现QPS监控的原理与高性能实战的详细内容,更多关于SpringBoot QPS监控的资料请关注脚本之家其它相关文章!

相关文章

  • java实现希尔排序算法

    java实现希尔排序算法

    希尔排序(Shell Sort)是插入排序的一种,是针对直接插入排序算法的改进,是将整个无序列分割成若干小的子序列分别进行插入排序,希尔排序并不稳定。该方法又称缩小增量排序,因DL.Shell于1959年提出而得名。
    2015-04-04
  • 如何解决java.lang.IllegalStateException: Target host is null的问题

    如何解决java.lang.IllegalStateException: Target host&n

    文章描述了通过MocoRunner模拟接口,并使用properties文件和ResourceBundle读取配置文件进行get请求的过程,在执行过程中遇到了目标主机为空的错误,通过检查和修正url拼接问题解决了该错误
    2024-12-12
  • java,android,MD5加密算法的实现代码(16位,32位)

    java,android,MD5加密算法的实现代码(16位,32位)

    下面小编就为大家带来一篇java,android,MD5加密算法的实现代码(16位,32位)。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2016-09-09
  • springboot中使用FastJson解决long类型在js中失去精度的问题

    springboot中使用FastJson解决long类型在js中失去精度的问题

    这篇文章主要介绍了springboot中使用FastJson解决long类型在js中失去精度的问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2022-06-06
  • Java 开发的几个注意点总结

    Java 开发的几个注意点总结

    这篇文章主要介绍了Java开发的几个注意点的相关资料,需要的朋友可以参考下
    2016-09-09
  • java 实例化类详解及简单实例

    java 实例化类详解及简单实例

    这篇文章主要介绍了java 实例化类详解及简单实例的相关资料,需要的朋友可以参考下
    2017-03-03
  • JAVA中Spring Boot的AOP切面编程是什么,如何使用?(实例代码)

    JAVA中Spring Boot的AOP切面编程是什么,如何使用?(实例代码)

    本文详细介绍了SpringBoot中面向切面编程(AOP)的基本概念、核心术语、配置方法以及应用场景,涵盖了AOP的五种通知类型、切点表达式、动态代理、切面优先级与执行顺序等核心知识点,并通过实战案例展示了AOP在微服务架构中的应用潜力
    2026-01-01
  • Java案例之HashMap集合存储学生对象并遍历

    Java案例之HashMap集合存储学生对象并遍历

    这篇文章主要介绍了Java案例之HashMap集合存储学生对象并遍历,创建一个HashMap集合,键是学号(String),值是学生对象(Student),存储三个键值对元素并遍历,下文具体操作需要的朋友可以参考一下
    2022-04-04
  • SpringBoot2零基础到精通之数据库专项精讲

    SpringBoot2零基础到精通之数据库专项精讲

    SpringBoot是一种整合Spring技术栈的方式(或者说是框架),同时也是简化Spring的一种快速开发的脚手架,本篇我们来学习如何连接数据库进行操作
    2022-03-03
  • JAVA如何转换树结构数据代码实例

    JAVA如何转换树结构数据代码实例

    这篇文章主要介绍了JAVA如何转换树结构数据代码实例,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-03-03

最新评论