
一线开发你是不是经常碰到这样的场景运维半夜打电话说某个接口被疯狂调用系统扛不住了产品经理抱怨页面越来越慢怀疑是接口性能问题老板突然要看各个接口的调用情况想了解系统热点这时候如果有一套能精确统计每个接口每分钟调用次数的监控系统就能快速定位问题了。今天我就把自己在项目中实践的几种方案和踩过的坑分享给大家。为什么要统计接口调用频率在深入技术方案前我们先聊聊为什么需要这样的统计性能瓶颈发现- 哪些接口调用最频繁是不是要优化或加缓存容量规划- 根据调用趋势决定啥时候该加机器了安全预警- 接口突然被猛调用可能是遭受攻击计费依据- 对外 API 往往按调用次数收费问题排查- 系统出问题时先看看哪些接口最忙方案设计要考虑的关键因素实现这种监控时有几个问题得权衡方案一固定窗口计数器最直观的方案是用个 Map 记录每个接口调用次数每分钟清零一次public class SimpleCounter { // 用ConcurrentHashMap保证线程安全 private ConcurrentHashMapString, AtomicLong counters new ConcurrentHashMap(); // 记录接口调用 public void increment(String apiName) { counters.computeIfAbsent(apiName, k - new AtomicLong(0)).incrementAndGet(); } // 获取接口调用次数 public long getCount(String apiName) { // 用getOrDefault避免频繁computeIfAbsent return counters.getOrDefault(apiName, new AtomicLong(0)).get(); } // 定时任务每分钟执行一次打印并清零 Scheduled(fixedRate 60000) public void printAndReset() { System.out.println( 接口分钟调用统计 ); counters.forEach((api, count) - { System.out.println(api : count.getAndSet(0)); }); } }这种方案实现超简单但有个明显问题假设定时器在 8:59:59 触发清零9:00:01 有次调用这次调用会被算到 9:01 才清零的那个窗口里统计就不准了。就像公司打卡你 8:59 到了打卡机 9:00 重置系统硬是把你算成下一个小时的人了。方案二滑动窗口计数器懒加载优化版滑动窗口能解决时间边界问题。我们把一分钟60 秒拆成 6 个 10 秒窗口像传送带一样滑动public class SlidingWindowCounter { // 记录每个接口在各个时间片的调用次数 privatefinal ConcurrentHashMapString, CounterEntry apiCounters new ConcurrentHashMap(); // 每个窗口的时长(秒) privatefinalint WINDOW_SIZE_SECONDS 10; // 窗口数量1分钟60秒分成6个窗口每个10秒 privatefinalint WINDOW_COUNT 6; // 当前系统时间片索引 privatevolatileint currentTimeSlice; public SlidingWindowCounter() { // 计算初始时间片索引对齐系统时间 // 时间片索引公式当前时间秒数 / 窗口大小(10秒) 第几个时间片 currentTimeSlice (int)(System.currentTimeMillis() / 1000 / WINDOW_SIZE_SECONDS); // 启动定时器每10秒滑动一次窗口 ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); // 计算第一次执行延迟让窗口边界对齐整10秒 long initialDelay WINDOW_SIZE_SECONDS - (System.currentTimeMillis() / 1000 % WINDOW_SIZE_SECONDS); scheduler.scheduleAtFixedRate(this::slideWindow, initialDelay, WINDOW_SIZE_SECONDS, TimeUnit.SECONDS); } // 记录接口调用 public void increment(String apiName) { // 获取当前时间片索引 int timeSlice currentTimeSlice; // 获取或创建接口计数器懒加载更新窗口 CounterEntry entry apiCounters.computeIfAbsent(apiName, k - new CounterEntry()); entry.increment(timeSlice); } // 获取一分钟内的调用次数 public long getMinuteCount(String apiName) { CounterEntry entry apiCounters.get(apiName); if (entry null) { return0; } // 获取当前时间片之前的6个窗口总和 return entry.getTotal(currentTimeSlice); } // 窗口滑动只更新时间片索引窗口数据懒加载更新 private void slideWindow() { try { // 计算最新的时间片索引 int newSlice (int)(System.currentTimeMillis() / 1000 / WINDOW_SIZE_SECONDS); // 处理时钟回拨情况 if (newSlice currentTimeSlice) { // 时钟回拨了打日志但不更新时间片 System.err.println(Clock skew detected: newSlice currentTimeSlice); return; } // 更新当前时间片索引 currentTimeSlice newSlice; // 定期清理长时间未使用的接口统计 cleanupIdleCounters(); } catch (Exception e) { // 异常处理避免定时任务中断 System.err.println(Error in slideWindow: e.getMessage()); } } // 计数器条目内部类支持懒加载窗口更新 privateclass CounterEntry { // 时间片计数数组 privatefinal AtomicLong[] counters new AtomicLong[WINDOW_COUNT]; // 最后访问的时间片索引 privatevolatileint lastAccessedSlice; // 最后更新时间 privatevolatilelong lastUpdateTime; public CounterEntry() { for (int i 0; i WINDOW_COUNT; i) { counters[i] new AtomicLong(0); } lastAccessedSlice currentTimeSlice; lastUpdateTime System.currentTimeMillis(); } // 增加当前时间片的计数 public void increment(int timeSlice) { // 先更新窗口如果需要 updateWindowsIfNeeded(timeSlice); // 增加当前时间片的计数 // 环形数组索引 时间片索引 % 窗口数量 // 这里是关键通过取模运算使得数组索引在0-5间循环形成环形结构 int index timeSlice % WINDOW_COUNT; counters[index].incrementAndGet(); // 更新访问信息 lastAccessedSlice timeSlice; lastUpdateTime System.currentTimeMillis(); } // 获取所有窗口的总计数 public long getTotal(int currentSlice) { // 先更新窗口如果需要 updateWindowsIfNeeded(currentSlice); // 计算总和 long total 0; for (AtomicLong counter : counters) { total counter.get(); } return total; } // 懒加载更新窗口 - 只在实际访问时更新 private void updateWindowsIfNeeded(int currentSlice) { int sliceDiff currentSlice - lastAccessedSlice; if (sliceDiff 0) { // 时间片未变或异常情况时钟回拨无需更新 return; } if (sliceDiff WINDOW_COUNT) { // 如果时间差超过窗口数直接清零所有窗口 for (AtomicLong counter : counters) { counter.set(0); } } else { // 部分窗口需要清零 for (int i 1; i sliceDiff; i) { int indexToClear (lastAccessedSlice i) % WINDOW_COUNT; counters[indexToClear].set(0); } } } } // 清理长时间未使用的计数器避免内存泄漏 private void cleanupIdleCounters() { finallong IDLE_THRESHOLD_MS 300000; // 5分钟无调用则清理 long now System.currentTimeMillis(); IteratorMap.EntryString, CounterEntry it apiCounters.entrySet().iterator(); while (it.hasNext()) { Map.EntryString, CounterEntry entry it.next(); CounterEntry counterEntry entry.getValue(); if (now - counterEntry.lastUpdateTime IDLE_THRESHOLD_MS) { // 如果5分钟未更新移除此接口的统计 it.remove(); } } } }这个懒加载滑动窗口方案的优点是时间精度高边界平滑性能也不错。懒加载是啥意思就是只有你真来访问了我才去更新那个窗口不像传统方案每次滑动都要遍历所有接口。滑动窗口就像环形跑道上的 6 个区域随着时间推移我们只清空前方的区域保留最近一分钟的统计数据。方案三基于 AOP 的透明统计异步优化版前面的方案都要在代码里手动调用 increment 方法太麻烦了。用 Spring AOP可以实现无侵入的接口调用统计Aspect Component publicclass ApiMonitorAspect { privatefinal Logger logger LoggerFactory.getLogger(ApiMonitorAspect.class); // 依赖注入单例计数器 Autowired private SlidingWindowCounter counter; // 创建线程池配置拒绝策略 privatefinal ThreadPoolExecutor asyncExecutor new ThreadPoolExecutor( 2, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue(1000), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略调用者执行 ); // 定义切点精确匹配只统计HTTP接口 Pointcut(within(org.springframework.web.bind.annotation.RestController) || within(org.springframework.stereotype.Controller)) public void apiPointcut() {} Around(apiPointcut()) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long startTime System.currentTimeMillis(); Object result null; boolean success false; // 获取完整方法签名包含包名避免同名冲突 MethodSignature signature (MethodSignature) joinPoint.getSignature(); String methodName signature.getDeclaringType().getName() . signature.getName(); try { // 执行原方法 result joinPoint.proceed(); success true; return result; } catch (Exception e) { // 记录异常信息 success false; throw e; } finally { finallong executionTime System.currentTimeMillis() - startTime; finalboolean finalSuccess success; // 异步记录统计信息避免影响主流程性能 asyncExecutor.execute(() - { try { // 记录总调用 counter.increment(methodName); // 成功/失败分类 counter.increment(methodName : (finalSuccess ? success : failure)); // 执行时间分类 String speedCategory; if (executionTime 100) { speedCategory fast; } elseif (executionTime 1000) { speedCategory medium; } else { speedCategory slow; } counter.increment(methodName : speedCategory); } catch (Exception ex) { // 确保统计逻辑异常不影响业务 logger.error(Failed to record API metrics, ex); } }); } } // 提供查询接口 public long getApiCallCount(String apiName) { return counter.getMinuteCount(apiName); } }这种 AOP 方案就像在小区门口装了个隐形摄像头进出的人都被记录但完全感觉不到它的存在。我优化了线程池配置加了拒绝策略防止高并发时队列爆满。切点表达式也做了精确匹配确保只统计真正的 HTTP 接口不会误统计内部服务方法。方案四使用 Redis 实现分布式统计时序优化版前面的方案在单机应用里都挺好用但放到分布式系统里每台机器都有自己的计数器统计就不全了。用 Redis 可以实现分布式计数Service publicclass RedisTimeSeriesCounter { Autowired private StringRedisTemplate redisTemplate; // Redis连接池配置在应用配置文件中设置 // spring.redis.jedis.pool.max-active100 // spring.redis.jedis.pool.max-idle20 // spring.redis.jedis.pool.min-idle5 // spring.redis.jedis.pool.max-wait1000ms // 重试配置 privatefinalint MAX_RETRIES 3; privatefinallong[] RETRY_DELAYS {10L, 50L, 200L}; // 指数退避延迟 // 记录接口调用 public void increment(String apiName) { long timestamp System.currentTimeMillis(); String key getBaseKey(apiName); // 使用Lua脚本原子操作将当前分钟的调用记录到有序集合中 String script local minute math.floor(ARGV[1]/60000)*60000; // 取整到分钟 redis.call(ZINCRBY, KEYS[1], 1, minute); // 增加计数 redis.call(EXPIRE, KEYS[1], 86400); // 设置24小时过期 return 1;; // 带重试的执行Lua脚本 Exception lastException null; for (int attempt 0; attempt MAX_RETRIES; attempt) { try { redisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(key), String.valueOf(timestamp) ); return; // 成功则直接返回 } catch (Exception e) { lastException e; // 重试前等待一段时间指数退避 if (attempt MAX_RETRIES - 1) { try { Thread.sleep(RETRY_DELAYS[attempt]); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; } } } } // 所有重试都失败降级处理 try { logger.warn(Failed to execute Redis script after {} retries, falling back to basic operations, MAX_RETRIES, lastException); String minuteKey String.valueOf(Math.floor(timestamp/60000)*60000); redisTemplate.opsForZSet().incrementScore(key, minuteKey, 1); redisTemplate.expire(key, 1, TimeUnit.DAYS); } catch (Exception e) { logger.error(Failed to increment API counter for {}, apiName, e); // 本地计数器备份可以在这里实现 } } // 获取当前分钟的调用次数 public long getCurrentMinuteCount(String apiName) { long currentMinute Math.floor(System.currentTimeMillis()/60000)*60000; return getCountByMinute(apiName, currentMinute); } // 获取指定分钟的调用次数 public long getCountByMinute(String apiName, long minuteTimestamp) { String key getBaseKey(apiName); Double score redisTemplate.opsForZSet().score(key, String.valueOf(minuteTimestamp)); return score null ? 0 : score.longValue(); } // 获取一段时间内的调用趋势 public MapLong, Long getCountTrend(String apiName, long startTime, long endTime) { String key getBaseKey(apiName); // 将时间戳取整到分钟 long startMinute Math.floor(startTime/60000)*60000; long endMinute Math.floor(endTime/60000)*60000; // 查询Redis中的时间序列数据 SetZSetOperations.TypedTupleString results redisTemplate.opsForZSet() .rangeByScoreWithScores(key, startMinute, endMinute); // 构建结果Map MapLong, Long trend new TreeMap(); if (results ! null) { for (ZSetOperations.TypedTupleString tuple : results) { trend.put(Long.parseLong(tuple.getValue()), tuple.getScore().longValue()); } } return trend; } // 生成Redis基础key private String getBaseKey(String apiName) { returnapi:timeseries: apiName; } }Redis 时序数据方案不仅支持分布式环境还能超高效地存储和查询历史趋势。我加了重试机制防止网络抖动导致计数失败并且提供了 Redis 连接池配置建议。有序集合(ZSET)比简单计数器厉害的地方是它能按时间戳自然排序一个接口所有时间点的调用数据都在一个结构里查询很方便。方案五使用 Micrometer Prometheus 实现监控可视化多维优化版如果你不只是想统计调用次数还想做可视化监控和多维度分析Micrometer Prometheus 是个好选择Configuration publicclass MetricsConfig { Bean public MeterRegistry meterRegistry() { // 定义通用标签如应用名、环境等 returnnew PrometheusMeterRegistry( PrometheusConfig.DEFAULT, new CollectorRegistry(), Clock.SYSTEM, new CommonTags(application, my-app, env, prod) ); } // 添加维度标签过滤器防止基数爆炸 Bean public MeterFilter dimensionFilter() { // 一个指标最多100个uri维度避免内存爆炸 return MeterFilter.maximumAllowableTags(api.calls, uri, 100); } // 基数控制限制name标签组合不超过5000个 Bean public MeterFilter cardinalityLimiter() { returnnew MeterFilter() { Override public Meter.Id map(Meter.Id id) { if (id.getName().equals(api.calls) meterRegistry.find(id.getName()).tagKeys().size() 5000) { // 当标签组合超过5000时归入other类别 return id.withTag(name, other); } return id; } }; } } Component publicclass ApiMetricsInterceptor implements HandlerInterceptor { privatefinal MeterRegistry meterRegistry; privatefinal ThreadLocalLong startTimeHolder new ThreadLocal(); // 路径参数解析器 - 避免误判合法数字 privatefinal PathParameterResolver pathResolver new PathParameterResolver(); public ApiMetricsInterceptor(MeterRegistry meterRegistry) { this.meterRegistry meterRegistry; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { startTimeHolder.set(System.currentTimeMillis()); if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; // 使用全限定类名避免冲突 String apiName handlerMethod.getBeanType().getName() . handlerMethod.getMethod().getName(); // 路径参数标准化 String uri pathResolver.standardizePath(request.getRequestURI()); // 记录接口调用次数使用标签而非字符串拼接 meterRegistry.counter(api.calls, name, apiName, method, request.getMethod(), uri, uri ).increment(); } returntrue; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { if (handler instanceof HandlerMethod startTimeHolder.get() ! null) { HandlerMethod handlerMethod (HandlerMethod) handler; String apiName handlerMethod.getBeanType().getName() . handlerMethod.getMethod().getName(); // 记录响应状态码 String status String.valueOf(response.getStatus()); // 记录执行耗时 long executionTime System.currentTimeMillis() - startTimeHolder.get(); meterRegistry.timer(api.latency, name, apiName, status, status ).record(executionTime, TimeUnit.MILLISECONDS); // 清理ThreadLocal避免内存泄漏 startTimeHolder.remove(); } } // 路径参数解析器内部类 privatestaticclass PathParameterResolver { // 路径参数模式如/user/{id}中的{id} privatefinal Pattern pathParamPattern Pattern.compile(/\\d(/|$)); // 需要保留的数字路径避免误判合法数字路径 privatefinal SetString preservedNumberPaths Set.of( /v1, /v2, /v3, // API版本 /2fa, /oauth2 // 特定路径 ); public String standardizePath(String uri) { // 对于需要保留的数字路径直接返回 for (String path : preservedNumberPaths) { if (uri.contains(path)) { return uri; } } // 替换疑似ID参数如/users/123 - /users/{id} Matcher matcher pathParamPattern.matcher(uri); StringBuffer sb new StringBuffer(); while (matcher.find()) { String match matcher.group(0); String replacement match.endsWith(/) ? /{id}/ : /{id}; matcher.appendReplacement(sb, replacement); } matcher.appendTail(sb); return sb.toString(); } } }在application.properties中添加配置management.endpoints.web.exposure.includeprometheus,health,info management.metrics.export.prometheus.enabledtrue management.metrics.tags.application${spring.application.name} management.metrics.distribution.percentiles-histogram.http.server.requeststrue # 设置Prometheus抓取间隔与数据保留时间 # prometheus.yml中设置: # scrape_interval: 15s # storage.tsdb.retention.time: 15dPrometheus 计数器是单调递增的像汽车里程表一样只增不减。这设计很巧妙即使服务重启也不会丢失统计数据。举个例子假设一个接口 10 分钟内每分钟调用 100 次。传统方式直接记录100这个值服务重启会丢失而 Prometheus 记录的是累计值从 0 开始不断增加(100,200,300...)。重启后从新值开始继续累加通过rate()函数计算两次采集间的变化率依然能得到准确的每分钟 100 次这个结果。Prometheus 查询示例# 查询UserService.getUser接口每分钟调用率 rate(api_calls_total{nameUserService.getUser}[1m]) # 查询接口95分位延迟 histogram_quantile(0.95, sum(rate(api_latency_seconds_bucket{nameUserService.getUser}[5m])) by (le)) # 按状态码统计接口调用 sum(rate(api_calls_total{nameUserService.getUser}[5m])) by (status)五种方案的数据流架构对比混合方案完整监控体系在实际项目中我发现单一方案往往不能满足所有需求最佳组合是滑动窗口- 服务内部的实时统计毫秒级响应Redis 存储- 分布式聚合和短期历史查询分钟级Prometheus- 长期趋势和多维分析小时/天级具体实现Service publicclass HybridApiMonitor { Autowired private SlidingWindowCounter localCounter; Autowired private RedisTimeSeriesCounter redisCounter; Autowired private MeterRegistry meterRegistry; // 记录API调用 public void recordApiCall(String apiName) { // 本地滑动窗口统计 - 实时查询用 localCounter.increment(apiName); // Redis异步批量写入 - 本地缓冲后批量写Redis batchRedisWriter.add(apiName); // Prometheus长期趋势 - 加标签维度 meterRegistry.counter(api.calls, name, apiName).increment(); } // 定时任务每分钟将滑动窗口数据写入Redis Scheduled(fixedRate 60000) public void flushToRedis() { // 获取所有接口的分钟统计批量写入Redis // 实现略 } // 查询接口提供多级统计数据 public ApiStats getApiStats(String apiName) { return ApiStats.builder() .realtimeQps(localCounter.getMinuteCount(apiName) / 60.0) // 实时QPS .last5MinutesTrend(redisCounter.getCountTrend(apiName, /* 时间范围 */)) // 分钟级趋势 .prometheusQueryUrl(/grafana/d/apis?var-name apiName) // 长期趋势查询链接 .build(); } }这种混合方案可以满足从秒级实时监控到月度趋势分析的全场景需求各层级数据互为补充。进阶接口调用监控的实战经验与性能对比我用 JMeter 对各方案做了次压测配置如下测试环境4 核 8G 云服务器JMeter 100 个并发线程持续 10 分钟测试数据随机访问 100 个不同接口共生成 1000 万次调用边界测试在稳定运行后突增至 500%流量维持 30 秒不同接口数量下的内存增长情况接口数量固定窗口标准滑动窗口懒加载滑动窗口1千1MB2MB2MB1万8MB20MB15MB10万70MB200MB140MB100万OOMOOM1.3GB实战中的典型问题与解决方案1.内存溢出问题生产环境中遇到过一次严重 OOM排查发现是接口 URL 中包含大量随机参数用户 ID、订单号等导致 Map 键爆炸// 解决方案使用Guava Cache限制Map大小 private LoadingCacheString, AtomicLong counters CacheBuilder.newBuilder() .maximumSize(10000) // 最多存储10000个接口 .expireAfterAccess(30, TimeUnit.MINUTES) // 30分钟未访问自动清除 .build(new CacheLoaderString, AtomicLong() { Override public AtomicLong load(String key) { return new AtomicLong(0); } });2.分布式环境中的时钟漂移我们在 K8s 环境中发现不同 Pod 的时钟可能相差几秒导致窗口边界不一致// 解决方案时钟同步方案对比 // 1. NTP同步物理机最佳apt install ntp // 2. Redis时间服务混合环境推荐 Service publicclass RedisClockService implements ClockService { Autowired private StringRedisTemplate redisTemplate; Override public long currentTimeMillis() { return redisTemplate.execute((RedisCallbackLong) connection - connection.time() ); } } // 3. K8s环境容器集群最佳使用PTP协议和NodeTime DaemonSet3.高并发下的 Redis 性能问题订单系统高峰期每秒 10 万API 调用每次都写 Redis 吃不消// 解决方案本地缓冲批量写入 publicclass BufferedRedisCounter { privatefinal ConcurrentHashMapString, AtomicLong buffer new ConcurrentHashMap(); privatefinal ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); Autowired private RedisTemplateString, String redisTemplate; public BufferedRedisCounter() { // 每秒批量写入Redis scheduler.scheduleAtFixedRate(this::flushToRedis, 1, 1, TimeUnit.SECONDS); } public void increment(String apiName) { // 本地增加计数 buffer.computeIfAbsent(apiName, k - new AtomicLong(0)).incrementAndGet(); } private void flushToRedis() { if (buffer.isEmpty()) { return; } // 创建Redis管道批量执行命令 redisTemplate.executePipelined((RedisCallbackObject) connection - { buffer.forEach((api, count) - { long value count.getAndSet(0); // 重置缓冲区 if (value 0) { String key api:counter: api : ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern(yyyyMMddHHmm)); connection.incrBy(key.getBytes(), value); connection.expire(key.getBytes(), 3600); // 1小时过期 } }); returnnull; }); } }容量规划建议基于压测数据以下是各方案的容量规划建议1.固定/滑动窗口每 1 万个接口约需 15-20MB 内存JVM 堆建议为接口数 1万堆内存 512MB接口数 10万堆内存 2GB接口数 10万建议使用Redis方案2.Redis 分布式统计按每个接口每分钟 100 字节估算1千接口保留7天~1GB1万接口保留7天~10GB推荐Redis集群配置3主3从每节点16GB3.Prometheus时序数据库容量公式磁盘空间 ≈ 每秒样本数 × 样本大小 × 保留时间1千接口15秒采集保留30天~50GB高基数限制单指标标签组合不超过5000总结