京东面试官:你线上QPS是多少?你是如何监控的呢?
首先,我需要回忆一下QPS的基本概念,QPS是每秒查询率,用来衡量系统处理的请求数量。那么,怎么在Spring Boot应用中统计这个指标呢?
首先,可能的方法有很多种。比如使用拦截器或者过滤器来记录每个请求的时间,然后统计每秒的请求量。或者利用现有的监控工具,比如Spring Boot Actuator,结合Micrometer和Prometheus来收集指标,再通过Grafana展示。不过用户可能想要的是具体的代码实现,而不仅仅是依赖外部工具。
接下来,我需要考虑用户可能的实际应用场景。他们可能有一个运行中的Spring Boot应用,需要实时监控QPS,或者是在压力测试时测量QPS。这时候,简单的拦截器可能更直接,而集成监控系统可能更适合长期监控。用户的问题里提到了“线上QPS”,所以可能需要一个既能在运行时统计,又能方便集成到现有监控体系中的方法。
然后,思考具体的实现步骤。首先,如何统计每个请求的时间戳,并计算每秒的请求数。可能需要一个滑动窗口或者时间窗口来维护最近一秒内的请求次数。这里可以使用一个队列来保存每次请求的时间戳,然后定期清理超过一秒的记录,队列的大小就是当前的QPS。或者使用AtomicLong来计数,每秒钟重置一次计数器。不过这种方法可能不够准确,特别是在高并发时。
另外,考虑线程安全问题。如果多个请求同时更新计数器,必须使用线程安全的数据结构,比如ConcurrentLinkedQueue或者AtomicLong。另外,避免在统计过程中出现性能瓶颈,比如锁竞争,这可能会影响应用的性能。
然后,具体到Spring Boot的实现,可以创建一个拦截器,在preHandle方法中记录请求的时间戳,并维护一个队列。在postHandle或者afterCompletion中,可能不需要做处理,但需要确保统计的准确性。或者,在拦截器中,每次请求进来时,将当前时间戳添加到队列,然后清理掉超过一秒前的记录,队列的大小就是当前的QPS。这种方法可能会在每次请求时都进行清理操作,但可能影响性能,特别是当请求量很大时。可以考虑异步处理或者定时任务来定期清理队列。
另一个方法是使用计数器,每秒钟重置一次。比如,使用一个AtomicLong变量,每次请求时递增,然后每秒获取并重置计数器。这种方法简单,但可能丢失在重置瞬间的请求数,或者在获取QPS时刚好处于两个时间段的交界处,导致数据不准确。不过对于大致估算来说,可能已经足够。
再考虑如何暴露这个QPS数据。可以通过一个自定义的REST端点,比如/qps,返回当前的QPS值。或者集成到Actuator的端点中,这样可以通过监控系统来收集。如果用户希望详细代码,可能需要展示如何创建拦截器、维护计数器,并提供一个控制器来返回当前QPS。
此外,可能还需要考虑时间窗口的精度。比如,滑动窗口可能需要更复杂的数据结构,比如环形缓冲区,来记录最近一秒钟内的请求数。或者使用时间分片,比如每100毫秒一个分片,统计最近10个分片的总请求数,这样计算QPS会更平滑。
现在,综合这些思路,可能需要编写一个拦截器,在请求到达时记录时间戳,并维护一个队列,每次请求时清理掉超过一秒的数据,然后队列的大小即为当前的QPS。为了优化性能,可以使用并发队列,并且可能异步处理清理操作,避免每次请求都遍历整个队列。
或者,使用AtomicLong配合定时任务每秒重置,但这种方法可能不够精确,因为QPS可能在每秒内波动较大。比如,在某一秒的最后时刻突然有大量请求,可能会被统计到下一秒钟的计数中,导致数据不准确。
综合考虑,滑动窗口的方法可能更准确,但实现稍微复杂一些。而计数器方法简单,但可能不够精确。用户可能需要根据实际需求来选择,但作为面试题的答案,可能需要展示更准确的方法,比如滑动窗口。
接下来,具体代码实现。在Spring Boot中创建拦截器,实现HandlerInterceptor接口,在preHandle方法中记录请求时间。维护一个ConcurrentLinkedQueue来保存时间戳。每次请求时,将当前时间戳添加到队列,然后清理掉超过一秒前的记录。队列的大小即为当前QPS。然后,提供一个RestController暴露/qps端点,返回当前队列的大小。
不过,这样每次请求都会触发清理操作,可能在高QPS时成为性能瓶颈。为了优化,可以定期清理队列,比如每秒清理一次,或者在获取QPS的时候再清理。例如,当访问/qps端点时,先清理队列中的过期数据,再返回队列大小。这样,统计的QPS可能不是实时的,但能减少每次请求的开销。
或者,使用AtomicLong,每秒重置计数器,同时另一个变量保存上一秒的QPS。定时任务每秒将当前计数器的值保存到另一个变量,然后重置计数器。这样,获取QPS时直接返回保存的值,不会有并发问题。这种方法简单,但可能丢失当前秒的部分数据。
比如,使用ScheduledExecutorService每秒执行一次任务,将counter.getAndSet(0)存储到lastQps变量中。然后在控制器中返回lastQps的值。这种方法可能在低QPS时更高效,但无法实时反映当前的请求速率。
需要权衡实现复杂度和准确性。在面试中,可能更倾向于展示滑动窗口的方法,尽管实现稍复杂,但更准确。
现在,编写具体的代码。创建一个拦截器:
```java
public class QpsInterceptor implements HandlerInterceptor {
private ConcurrentLinkedQueue
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
long currentTime = System.currentTimeMillis();
requestTimes.add(currentTime);
// 异步清理旧数据,避免阻塞请求线程
// 或者延迟到获取QPS时再清理
return true;
}
public int getCurrentQps() {
long now = System.currentTimeMillis();
// 删除超过1秒前的记录
while (!requestTimes.isEmpty() && now - requestTimes.peek() > 1000) {
requestTimes.poll();
}
return requestTimes.size();
}
}
```
然后,注册拦截器:
```java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public QpsInterceptor qpsInterceptor() {
return new QpsInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(qpsInterceptor());
}
}
```
接着,创建一个RestController来暴露QPS:
```java
@RestController
public class QpsController {
@Autowired
private QpsInterceptor qpsInterceptor;
@GetMapping("/qps")
public int getQps() {
return qpsInterceptor.getCurrentQps();
}
}
```
这种方法存在的问题是,每次调用/qps端点时才会清理队列,这意味着如果长时间不调用该端点,队列中可能会积累大量过时的时间戳,导致内存泄漏。因此,需要定期清理队列中的旧数据,不管有没有人调用/qps。
可以在拦截器中启动一个定时任务,每秒清理一次:
```java
public class QpsInterceptor implements HandlerInterceptor {
private ConcurrentLinkedQueue
public QpsInterceptor() {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
long now = System.currentTimeMillis();
while (!requestTimes.isEmpty() && now - requestTimes.peek() > 1000) {
requestTimes.poll();
}
}, 0, 1, TimeUnit.SECONDS);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
requestTimes.add(System.currentTimeMillis());
return true;
}
public int getCurrentQps() {
return requestTimes.size();
}
}
```
这样,定时任务会每秒清理一次队列,确保队列中只保留最近一秒内的请求时间戳。此时,getCurrentQps()直接返回队列的大小,就是当前的QPS。这种方法解决了内存泄漏的问题,并且清理操作在后台线程执行,不会阻塞请求处理线程。
但需要考虑并发问题,比如ConcurrentLinkedQueue的线程安全性。ConcurrentLinkedQueue是线程安全的,适用于高并发环境,所以这里应该没问题。
不过,ScheduledExecutorService应该在应用关闭时正确关闭,否则可能导致线程泄漏。可以在拦截器中添加@PreDestroy方法来关闭调度程序:
```java
public class QpsInterceptor implements HandlerInterceptor {
private ConcurrentLinkedQueue
private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public QpsInterceptor() {
scheduler.scheduleAtFixedRate(() -> {
long now = System.currentTimeMillis();
while (!requestTimes.isEmpty() && now - requestTimes.peek() > 1000) {
requestTimes.poll();
}
}, 0, 1, TimeUnit.SECONDS);
}
@PreDestroy
public void destroy() {
scheduler.shutdown();
}
// 其他代码
}
```
这样,当Spring容器关闭时,会调用destroy方法关闭调度器,避免线程泄漏。
另一个考虑是性能问题。每次添加时间戳到队列,以及定时清理,是否会影响应用性能。ConcurrentLinkedQueue在高并发下表现良好,但如果QPS非常高,比如几十万,可能会有问题。不过对于大多数应用来说,这个方案是可行的。
另外,使用System.currentTimeMillis()可能会有性能问题,可以考虑使用更高效的时间获取方式,但在Java中这是标准做法。
总结,这个方案通过拦截器记录每个请求的时间戳,定时清理旧数据,队列大小即为QPS,并通过REST端点暴露。这样可以实时获取当前的QPS值。
当然,用户可能需要更详细的步骤,比如如何集成到Spring Boot,如何测试,以及可能的优化点。例如,使用更高效的数据结构,或者结合Micrometer和Actuator来暴露指标,方便集成Prometheus和Grafana。
不过问题中要求详细的代码和实现步骤,所以需要给出完整的示例,包括拦截器、配置、控制器,以及定时清理任务。同时,指出可能的优化和注意事项,比如线程安全、内存泄漏、性能影响等。
另外,用户提到Spring Boot 2,可能需要考虑与Spring Boot 2的特性兼容,比如使用WebMvcConfigurer来添加拦截器,而不是旧式的WebMvcConfigurerAdapter。
综上,最终的答案应该包括代码示例,实现步骤,并解释其工作原理,同时提到其他方法(如使用监控系统)作为补充。
在 Spring Boot 2 中统计并暴露 QPS(每秒查询率)的核心思路是通过拦截器记录请求时间戳,结合滑动窗口算法实时计算。以下是详细实现步骤与代码:
1. 实现请求拦截器
使用 HandlerInterceptor 在请求到达时记录时间戳。
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class QpsInterceptor implements HandlerInterceptor {
// 存储请求时间戳的线程安全队列
private final ConcurrentLinkedQueue requestTimes = new ConcurrentLinkedQueue<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public QpsInterceptor() {
// 定时清理超过1秒的旧数据
scheduler.scheduleAtFixedRate(this::cleanExpiredRequests, 0, 1, TimeUnit.SECONDS);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 记录当前请求时间
requestTimes.add(System.currentTimeMillis());
return true;
}
// 清理过期请求时间戳
private void cleanExpiredRequests() {
long now = System.currentTimeMillis();
while (!requestTimes.isEmpty() && now - requestTimes.peek() > 1000) {
requestTimes.poll();
}
}
// 获取当前QPS(队列大小即最近1秒内的请求数)
public int getCurrentQps() {
return requestTimes.size();
}
// 关闭线程池
@PreDestroy
public void destroy() {
scheduler.shutdown();
}
}
2. 注册拦截器到 Spring Boot
配置拦截器使其生效。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public QpsInterceptor qpsInterceptor() {
return new QpsInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(qpsInterceptor());
}
}
3. 暴露 QPS 指标接口
创建 REST 控制器提供 QPS 查询端点。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class QpsController {
@Autowired
private QpsInterceptor qpsInterceptor;
@GetMapping("/qps")
public int getQps() {
return qpsInterceptor.getCurrentQps();
}
}
4. 验证与测试
- 本地测试:访问 http://localhost:8080/qps 查看实时 QPS。
- 压测工具:使用 Apache Bench (ab) 或 JMeter 发起并发请求:
- bash
- 复制
- ab -n 1000 -c 100 http://localhost:8080/your-api
- 观察 /qps 端点返回值变化。
优化与扩展
- 性能优化:
- 使用 ConcurrentLinkedQueue 保证线程安全。
- 后台定时清理避免阻塞请求线程。
- 集成监控系统(如 Prometheus + Grafana):
- 引入 micrometer-core 依赖:
- xml
- 复制
- <dependency>
io.micrometer micrometer-core - 运行 HTML
- 暴露自定义指标:
- java
- 复制
- @Bean MeterRegistryCustomizer<MeterRegistry> qpsMetrics(QpsInterceptor qpsInterceptor) { return registry -> registry.gauge("app.qps", qpsInterceptor, QpsInterceptor::getCurrentQps); }
- 通过 /actuator/prometheus 端点采集数据。
原理总结
- 滑动窗口算法:队列中仅保留最近1秒内的请求时间戳,队列长度即 QPS。
- 异步清理:定时任务移除过期数据,避免内存泄漏。
- 低侵入性:拦截器对业务代码无侵入,适用于大部分场景。
通过以上步骤,你可以在 Spring Boot 2 中实时监控 QPS,并根据需要扩展集成更强大的监控系统。