High-Performance QPS Monitoring in Spring Boot: Sliding Window, RingBuffer & LongAdder Deep Dive
This article details a production-ready QPS monitoring implementation for Spring Boot using sliding window algorithm with RingBuffer and LongAdder for high-concurrency safety, integrated with Micrometer for metrics exposure, covering algorithm design, code implementation, performance optimization, and distributed monitoring strategies.
Why Custom QPS Monitoring?
While APM tools like Prometheus and Grafana provide traffic data, custom lightweight QPS monitoring is needed for:
Custom Metrics : Statistics for specific business logic (non-HTTP interfaces, parameter combinations) that generic probes cannot cover.
Local Fast Diagnosis : Instant traffic viewing in application logs or memory during incident troubleshooting without external system dependency.
Rate Limiting Prerequisite : QPS data serves as the foundation for rate limiting algorithms.
Common Pitfalls
Pitfall 1 : Using simple AtomicInteger with per-second reset causes "spikes" in monitoring data, failing to reflect intra-second traffic distribution.
Pitfall 2 : Adding AOP at Controller layer misses requests intercepted at Filter layer (e.g., security validation failures) and static resources.
Core QPS Monitoring Principles
QPS (Queries Per Second) measures requests processed per second. Accurate monitoring hinges on time window partitioning.
Fixed Window vs Sliding Window
Fixed Window
Time divided into fixed intervals (e.g., 1 second) with cumulative counting within each interval.
Drawback : Severe boundary problem . Example: 1000 requests at 00:00:59 and 1000 at 00:01:01 show only 1000 QPS per window, masking the actual 2000 QPS burst.
Sliding Window (Chosen Approach)
A large window (e.g., 10 seconds) split into small time slices (1 second each, 10 slots).
Advantages :
Higher precision controllable via sliding step.
Data smoothing reflects true average traffic over recent N seconds.
Simple eviction: expired slots automatically become invalid as time progresses.
Spring Boot Architecture Design
1. Interception Point: Filter vs Interceptor vs AOP
To capture the most accurate QPS, intercept requests as early as possible. OncePerRequestFilter is optimal because:
It sits before DispatcherServlet, capturing all HTTP requests entering the application (including 404s, error pages).
Guarantees each request is filtered exactly once.
Supports asynchronous request processing.
2. Metrics Framework Integration: Micrometer
Spring Boot 2.x/3.x includes Micrometer by default — a facade library similar to SLF4J.
Unified Facade : Code doesn't need to know underlying backend (Prometheus, JMX, Datadog).
Gauge vs Counter : QPS is a Rate — essentially "increment over a time period". In Micrometer, typically use Gauge to expose the computed sliding window value, or expose a Counter and let Grafana compute via rate() function.
This Solution's Strategy : Implement sliding window logic manually, then expose the calculated QPS via Micrometer's Gauge.
3. High-Concurrency Data Structures
URL Dimension Storage : ConcurrentHashMap<String, WindowCounter> with URI as key and per-URI counter as value.
Time Slice Counter : To avoid concurrent write contention, combine RingBuffer with LongAdder .
Core Source Code Implementation
1. Sliding Window Structure (SlidingWindowCounter)
Maintains time slices with:
Window Size : e.g., 60 seconds.
Slot Size : e.g., 1 second.
Slots : 60 slots forming a ring array.
package com.example.qps.monitor;
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* High-performance sliding window counter based on RingBuffer and LongAdder
*/
public class SlidingWindowCounter {
// Window size (seconds)
private final int windowSize;
// Slot count, default 1 second per slot
private final int slotCount;
// Ring array storing count per time slice
private final LongAdder[] slots;
// Read-write lock for periodic cleanup concurrency control
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// Last cleanup time (nanoseconds)
private volatile long lastClearTime;
public SlidingWindowCounter(int windowSize) {
this.windowSize = windowSize;
this.slotCount = windowSize; // Assume 1s granularity
this.slots = new LongAdder[slotCount];
for (int i = 0; i < slotCount; i++) {
slots[i] = new LongAdder();
}
this.lastClearTime = System.currentTimeMillis();
}
/**
* Record a request
*/
public void record() {
// 1. Check if expired data needs cleanup
checkAndClearExpiredSlots();
// 2. Get current slot and increment
int index = getCurrentSlotIndex();
slots[index].increment();
}
/**
* Get total QPS in current window
*/
public long getQps() {
long total = 0;
try {
// Read lock: allow concurrent reads, block during cleanup
lock.readLock().lock();
checkAndClearExpiredSlots();
for (LongAdder slot : slots) {
total += slot.longValue();
}
} finally {
lock.readLock().unlock();
}
// Note: returns total requests in window.
// For average QPS, divide by valid slot count.
// Simplified: expose "total requests in last N seconds", let Prometheus rate() compute QPS.
// Or compute average QPS = total / validSlotCount directly.
return total;
}
/**
* Get current slot index
*/
private int getCurrentSlotIndex() {
long now = System.currentTimeMillis();
long second = now / 1000;
return (int) (second % slotCount);
}
/**
* Clean expired data (prevent RingBuffer data overlap)
* Simple check: if current time minus last cleanup exceeds a window period, reset array
*/
private void checkAndClearExpiredSlots() {
long now = System.currentTimeMillis();
// If a full window period has passed
if (now - lastClearTime >= windowSize * 1000L) {
lock.writeLock().lock();
try {
// Double-check
if (now - lastClearTime >= windowSize * 1000L) {
// Reset all slots
// Note: in high concurrency, either new LongAdder[] or traverse reset()
// For maximum performance, traverse reset
for (LongAdder slot : slots) {
slot.reset();
}
lastClearTime = now;
}
} finally {
lock.writeLock().unlock();
}
}
}
}2. Core Filter (QpsMonitorFilter)
Intercepts requests and routes to per-URI counters.
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);
// Store counter per URI
private final Map<String, SlidingWindowCounter> counterMap = new ConcurrentHashMap<>();
@Autowired
private MeterRegistry meterRegistry;
// Window size 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. Get or create counter for this URI
// computeIfAbsent ensures thread safety
SlidingWindowCounter counter = counterMap.computeIfAbsent(uri, key -> {
SlidingWindowCounter newCounter = new SlidingWindowCounter(WINDOW_SIZE);
// 2. Register to Micrometer
Gauge.builder(
"app.request.qps.total", newCounter, SlidingWindowCounter::getQps)
.tags("uri", key) // Dimension tag
.description("Total requests in sliding window")
.register(meterRegistry);
return newCounter;
});
// 3. Record request
counter.record();
// 4. Pass through
filterChain.doFilter(request, response);
}
}Deep Analysis & Performance Optimization
1. LongAdder vs AtomicLong
Implementation uses LongAdder instead of AtomicLong.
AtomicLong : Relies on CAS (Compare-And-Swap). Under high contention, CAS failure rate spikes, causing CPU spinning and severe performance degradation.
LongAdder : Uses segmented accumulation (similar to JDK8 ConcurrentHashMap). Distributes accumulated value across multiple Cells; concurrent writes access different Cells, drastically reducing contention.
Conclusion : For QPS statistics — a "read-little, write-heavy" scenario — LongAdder outperforms AtomicLong significantly.
2. RingBuffer Memory Optimization
Why not LinkedList or ArrayList for time slices?
GC Friendly : RingBuffer is a fixed-length array; no new objects created after initialization.
Lock-Free Updates : Index computed via System.currentTimeMillis(), naturally supporting lock-free writes (except periodic cleanup).
3. Memory Leak Defense: Dynamic URL Problem
RESTful endpoints like /api/users/1, /api/users/2 used directly as keys cause ConcurrentHashMap to grow unbounded, leading to OOM.
Solutions :
URL Templating : Leverage Spring's HandlerMapping at interceptor stage to obtain best-match pattern (e.g., /api/users/{id}) as key.
LRU Eviction : If exact URIs must be kept, limit Map capacity and evict cold entries via LRU (Least Recently Used).
// Simple LRU transformation example
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 Monitoring in Distributed Scenarios
The above suits single-node monitoring. In microservice clusters, global QPS is often the concern.
Approach Comparison
Prometheus Aggregation
Principle : Each instance exposes local Gauge; Prometheus scrapes and computes sum(rate(...)).
Pros : Non-invasive, zero code changes, good real-time.
Cons : Depends on external components; instantaneous values may have few seconds latency.
Applicable Scenarios : Recommended for most microservice scenarios.
Redis + Lua
Principle : On each request, Lua script in Redis performs atomic increment and window calculation.
Pros : Absolute precision, supports distributed rate limiting.
Cons : Adds network RTT, impacts business performance (QPS monitoring must not slow business).
Applicable Scenarios : Strong consistency rate limiting scenarios.
Best Practice : Use the local sliding window solution inside Spring Boot to ensure monitoring logic doesn't affect business RTT. Expose data via Micrometer; let Prometheus handle final distributed aggregation.
Summary
This article delivers a production-grade Spring Boot QPS monitoring solution. Key takeaways:
Algorithm Choice : Sliding window solves fixed window's boundary problem.
Performance Design : RingBuffer reduces memory allocation; LongAdder eliminates high-concurrency CAS contention.
Engineering Practice : OncePerRequestFilter captures full traffic; Micrometer integrates seamlessly with mainstream monitoring ecosystem.
With this approach, system traffic pulse is captured precisely at nanosecond-level overhead per request, providing solid data foundation for subsequent rate limiting, circuit breaking, and capacity planning.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
