Java統(tǒng)計接口調(diào)用頻率的五種方案及對比詳解
作為一線開發(fā),你是不是經(jīng)常碰到這樣的場景:
- 運維半夜打電話說某個接口被瘋狂調(diào)用,系統(tǒng)扛不住了
- 產(chǎn)品經(jīng)理抱怨頁面越來越慢,懷疑是接口性能問題
- 老板突然要看各個接口的調(diào)用情況,想了解系統(tǒng)熱點
這時候,如果有一套能精確統(tǒng)計"每個接口每分鐘調(diào)用次數(shù)"的監(jiān)控系統(tǒng),就能快速定位問題了。今天我就把自己在項目中實踐的幾種方案和踩過的坑分享給大家。
為什么要統(tǒng)計接口調(diào)用頻率?
在深入技術(shù)方案前,我們先聊聊為什么需要這樣的統(tǒng)計:
- 性能瓶頸發(fā)現(xiàn) - 哪些接口調(diào)用最頻繁,是不是要優(yōu)化或加緩存
- 容量規(guī)劃 - 根據(jù)調(diào)用趨勢決定啥時候該加機器了
- 安全預(yù)警 - 接口突然被猛調(diào)用,可能是遭受攻擊
- 計費依據(jù) - 對外 API 往往按調(diào)用次數(shù)收費
- 問題排查 - 系統(tǒng)出問題時,先看看哪些接口最忙
方案設(shè)計要考慮的關(guān)鍵因素
實現(xiàn)這種監(jiān)控時,有幾個問題得權(quán)衡:

方案一:固定窗口計數(shù)器
最直觀的方案是用個 Map 記錄每個接口調(diào)用次數(shù),每分鐘清零一次:
public class SimpleCounter {
// 用ConcurrentHashMap保證線程安全
private ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();
// 記錄接口調(diào)用
public void increment(String apiName) {
counters.computeIfAbsent(apiName, k -> new AtomicLong(0)).incrementAndGet();
}
// 獲取接口調(diào)用次數(shù)
public long getCount(String apiName) {
// 用getOrDefault避免頻繁computeIfAbsent
return counters.getOrDefault(apiName, new AtomicLong(0)).get();
}
// 定時任務(wù),每分鐘執(zhí)行一次,打印并清零
@Scheduled(fixedRate = 60000)
public void printAndReset() {
System.out.println("=== 接口分鐘調(diào)用統(tǒng)計 ===");
counters.forEach((api, count) -> {
System.out.println(api + ": " + count.getAndSet(0));
});
}
}
這種方案實現(xiàn)超簡單,但有個明顯問題:假設(shè)定時器在 8:59:59 觸發(fā)清零,9:00:01 有次調(diào)用,這次調(diào)用會被算到 9:01 才清零的那個窗口里,統(tǒng)計就不準了。就像公司打卡,你 8:59 到了,打卡機 9:00 重置,系統(tǒng)硬是把你算成下一個小時的人了。
方案二:滑動窗口計數(shù)器(懶加載優(yōu)化版)
滑動窗口能解決時間邊界問題。我們把一分鐘(60 秒)拆成 6 個 10 秒窗口,像傳送帶一樣滑動:
public class SlidingWindowCounter {
// 記錄每個接口在各個時間片的調(diào)用次數(shù)
privatefinal ConcurrentHashMap<String, CounterEntry> apiCounters = new ConcurrentHashMap<>();
// 每個窗口的時長(秒)
privatefinalint WINDOW_SIZE_SECONDS = 10;
// 窗口數(shù)量(1分鐘=60秒,分成6個窗口,每個10秒)
privatefinalint WINDOW_COUNT = 6;
// 當(dāng)前系統(tǒng)時間片索引
privatevolatileint currentTimeSlice;
public SlidingWindowCounter() {
// 計算初始時間片索引,對齊系統(tǒng)時間
// 時間片索引公式:當(dāng)前時間秒數(shù) / 窗口大小(10秒) = 第幾個時間片
currentTimeSlice = (int)(System.currentTimeMillis() / 1000 / WINDOW_SIZE_SECONDS);
// 啟動定時器,每10秒滑動一次窗口
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// 計算第一次執(zhí)行延遲,讓窗口邊界對齊整10秒
long initialDelay = WINDOW_SIZE_SECONDS - (System.currentTimeMillis() / 1000 % WINDOW_SIZE_SECONDS);
scheduler.scheduleAtFixedRate(this::slideWindow,
initialDelay, WINDOW_SIZE_SECONDS, TimeUnit.SECONDS);
}
// 記錄接口調(diào)用
public void increment(String apiName) {
// 獲取當(dāng)前時間片索引
int timeSlice = currentTimeSlice;
// 獲取或創(chuàng)建接口計數(shù)器,懶加載更新窗口
CounterEntry entry = apiCounters.computeIfAbsent(apiName, k -> new CounterEntry());
entry.increment(timeSlice);
}
// 獲取一分鐘內(nèi)的調(diào)用次數(shù)
public long getMinuteCount(String apiName) {
CounterEntry entry = apiCounters.get(apiName);
if (entry == null) {
return0;
}
// 獲取當(dāng)前時間片之前的6個窗口總和
return entry.getTotal(currentTimeSlice);
}
// 窗口滑動(只更新時間片索引,窗口數(shù)據(jù)懶加載更新)
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;
}
// 更新當(dāng)前時間片索引
currentTimeSlice = newSlice;
// 定期清理長時間未使用的接口統(tǒng)計
cleanupIdleCounters();
} catch (Exception e) {
// 異常處理,避免定時任務(wù)中斷
System.err.println("Error in slideWindow: " + e.getMessage());
}
}
// 計數(shù)器條目內(nèi)部類,支持懶加載窗口更新
privateclass CounterEntry {
// 時間片計數(shù)數(shù)組
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();
}
// 增加當(dāng)前時間片的計數(shù)
public void increment(int timeSlice) {
// 先更新窗口(如果需要)
updateWindowsIfNeeded(timeSlice);
// 增加當(dāng)前時間片的計數(shù)
// 環(huán)形數(shù)組索引 = 時間片索引 % 窗口數(shù)量
// 這里是關(guān)鍵:通過取模運算使得數(shù)組索引在0-5間循環(huán),形成環(huán)形結(jié)構(gòu)
int index = timeSlice % WINDOW_COUNT;
counters[index].incrementAndGet();
// 更新訪問信息
lastAccessedSlice = timeSlice;
lastUpdateTime = System.currentTimeMillis();
}
// 獲取所有窗口的總計數(shù)
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) {
// 如果時間差超過窗口數(shù),直接清零所有窗口
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);
}
}
}
}
// 清理長時間未使用的計數(shù)器,避免內(nèi)存泄漏
private void cleanupIdleCounters() {
finallong IDLE_THRESHOLD_MS = 300000; // 5分鐘無調(diào)用則清理
long now = System.currentTimeMillis();
Iterator<Map.Entry<String, CounterEntry>> it = apiCounters.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, CounterEntry> entry = it.next();
CounterEntry counterEntry = entry.getValue();
if (now - counterEntry.lastUpdateTime > IDLE_THRESHOLD_MS) {
// 如果5分鐘未更新,移除此接口的統(tǒng)計
it.remove();
}
}
}
}
這個懶加載滑動窗口方案的優(yōu)點是時間精度高,邊界平滑,性能也不錯。懶加載是啥意思?就是只有你真來訪問了,我才去更新那個窗口,不像傳統(tǒng)方案每次滑動都要遍歷所有接口。
滑動窗口就像環(huán)形跑道上的 6 個區(qū)域,隨著時間推移,我們只清空前方的區(qū)域,保留最近一分鐘的統(tǒng)計數(shù)據(jù)。

方案三:基于 AOP 的透明統(tǒng)計(異步優(yōu)化版)
前面的方案都要在代碼里手動調(diào)用 increment 方法,太麻煩了。用 Spring AOP,可以實現(xiàn)無侵入的接口調(diào)用統(tǒng)計:
@Aspect
@Component
publicclass ApiMonitorAspect {
privatefinal Logger logger = LoggerFactory.getLogger(ApiMonitorAspect.class);
// 依賴注入單例計數(shù)器
@Autowired
private SlidingWindowCounter counter;
// 創(chuàng)建線程池,配置拒絕策略
privatefinal ThreadPoolExecutor asyncExecutor = new ThreadPoolExecutor(
2, 5, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒絕策略:調(diào)用者執(zhí)行
);
// 定義切點,精確匹配只統(tǒng)計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 {
// 執(zhí)行原方法
result = joinPoint.proceed();
success = true;
return result;
} catch (Exception e) {
// 記錄異常信息
success = false;
throw e;
} finally {
finallong executionTime = System.currentTimeMillis() - startTime;
finalboolean finalSuccess = success;
// 異步記錄統(tǒng)計信息,避免影響主流程性能
asyncExecutor.execute(() -> {
try {
// 記錄總調(diào)用
counter.increment(methodName);
// 成功/失敗分類
counter.increment(methodName + ":" + (finalSuccess ? "success" : "failure"));
// 執(zhí)行時間分類
String speedCategory;
if (executionTime < 100) {
speedCategory = "fast";
} elseif (executionTime < 1000) {
speedCategory = "medium";
} else {
speedCategory = "slow";
}
counter.increment(methodName + ":" + speedCategory);
} catch (Exception ex) {
// 確保統(tǒng)計邏輯異常不影響業(yè)務(wù)
logger.error("Failed to record API metrics", ex);
}
});
}
}
// 提供查詢接口
public long getApiCallCount(String apiName) {
return counter.getMinuteCount(apiName);
}
}
這種 AOP 方案就像在小區(qū)門口裝了個"隱形 攝像頭",進出的人都被記錄,但完全感覺不到它的存在。我優(yōu)化了線程池配置,加了拒絕策略,防止高并發(fā)時隊列爆滿。切點表達式也做了精確匹配,確保只統(tǒng)計真正的 HTTP 接口,不會誤統(tǒng)計內(nèi)部服務(wù)方法。
方案四:使用 Redis 實現(xiàn)分布式統(tǒng)計(時序優(yōu)化版)
前面的方案在單機應(yīng)用里都挺好用,但放到分布式系統(tǒng)里,每臺機器都有自己的計數(shù)器,統(tǒng)計就不全了。用 Redis 可以實現(xiàn)分布式計數(shù):
@Service
publicclass RedisTimeSeriesCounter {
@Autowired
private StringRedisTemplate redisTemplate;
// Redis連接池配置(在應(yīng)用配置文件中設(shè)置)
// spring.redis.jedis.pool.max-active=100
// spring.redis.jedis.pool.max-idle=20
// spring.redis.jedis.pool.min-idle=5
// spring.redis.jedis.pool.max-wait=1000ms
// 重試配置
privatefinalint MAX_RETRIES = 3;
privatefinallong[] RETRY_DELAYS = {10L, 50L, 200L}; // 指數(shù)退避延遲
// 記錄接口調(diào)用
public void increment(String apiName) {
long timestamp = System.currentTimeMillis();
String key = getBaseKey(apiName);
// 使用Lua腳本原子操作:將當(dāng)前分鐘的調(diào)用記錄到有序集合中
String script =
"local minute = math.floor(ARGV[1]/60000)*60000; " + // 取整到分鐘
"redis.call('ZINCRBY', KEYS[1], 1, minute); " + // 增加計數(shù)
"redis.call('EXPIRE', KEYS[1], 86400); " + // 設(shè)置24小時過期
"return 1;";
// 帶重試的執(zhí)行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;
// 重試前等待一段時間(指數(shù)退避)
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);
// 本地計數(shù)器備份可以在這里實現(xiàn)
}
}
// 獲取當(dāng)前分鐘的調(diào)用次數(shù)
public long getCurrentMinuteCount(String apiName) {
long currentMinute = Math.floor(System.currentTimeMillis()/60000)*60000;
return getCountByMinute(apiName, currentMinute);
}
// 獲取指定分鐘的調(diào)用次數(shù)
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();
}
// 獲取一段時間內(nèi)的調(diào)用趨勢
public Map<Long, 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中的時間序列數(shù)據(jù)
Set<ZSetOperations.TypedTuple<String>> results = redisTemplate.opsForZSet()
.rangeByScoreWithScores(key, startMinute, endMinute);
// 構(gòu)建結(jié)果Map
Map<Long, Long> trend = new TreeMap<>();
if (results != null) {
for (ZSetOperations.TypedTuple<String> tuple : results) {
trend.put(Long.parseLong(tuple.getValue()), tuple.getScore().longValue());
}
}
return trend;
}
// 生成Redis基礎(chǔ)key
private String getBaseKey(String apiName) {
return"api:timeseries:" + apiName;
}
}
Redis 時序數(shù)據(jù)方案不僅支持分布式環(huán)境,還能超高效地存儲和查詢歷史趨勢。我加了重試機制,防止網(wǎng)絡(luò)抖動導(dǎo)致計數(shù)失敗,并且提供了 Redis 連接池配置建議。有序集合(ZSET)比簡單計數(shù)器厲害的地方是,它能按時間戳自然排序,一個接口所有時間點的調(diào)用數(shù)據(jù)都在一個結(jié)構(gòu)里,查詢很方便。

方案五:使用 Micrometer + Prometheus 實現(xiàn)監(jiān)控可視化(多維優(yōu)化版)
如果你不只是想統(tǒng)計調(diào)用次數(shù),還想做可視化監(jiān)控和多維度分析,Micrometer + Prometheus 是個好選擇:
@Configuration
publicclass MetricsConfig {
@Bean
public MeterRegistry meterRegistry() {
// 定義通用標簽,如應(yīng)用名、環(huán)境等
returnnew PrometheusMeterRegistry(
PrometheusConfig.DEFAULT,
new CollectorRegistry(),
Clock.SYSTEM,
new CommonTags("application", "my-app", "env", "prod")
);
}
// 添加維度標簽過濾器,防止基數(shù)爆炸
@Bean
public MeterFilter dimensionFilter() {
// 一個指標最多100個uri維度,避免內(nèi)存爆炸
return MeterFilter.maximumAllowableTags("api.calls", "uri", 100);
}
// 基數(shù)控制:限制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) {
// 當(dāng)標簽組合超過5000時,歸入"other"類別
return id.withTag("name", "other");
}
return id;
}
};
}
}
@Component
publicclass ApiMetricsInterceptor implements HandlerInterceptor {
privatefinal MeterRegistry meterRegistry;
privatefinal ThreadLocal<Long> startTimeHolder = new ThreadLocal<>();
// 路徑參數(shù)解析器 - 避免誤判合法數(shù)字
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();
// 路徑參數(shù)標準化
String uri = pathResolver.standardizePath(request.getRequestURI());
// 記錄接口調(diào)用次數(shù),使用標簽而非字符串拼接
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();
// 記錄響應(yīng)狀態(tài)碼
String status = String.valueOf(response.getStatus());
// 記錄執(zhí)行耗時
long executionTime = System.currentTimeMillis() - startTimeHolder.get();
meterRegistry.timer("api.latency",
"name", apiName,
"status", status
).record(executionTime, TimeUnit.MILLISECONDS);
// 清理ThreadLocal避免內(nèi)存泄漏
startTimeHolder.remove();
}
}
// 路徑參數(shù)解析器內(nèi)部類
privatestaticclass PathParameterResolver {
// 路徑參數(shù)模式,如/user/{id}中的{id}
privatefinal Pattern pathParamPattern = Pattern.compile("/\d+(/|$)");
// 需要保留的數(shù)字路徑(避免誤判合法數(shù)字路徑)
privatefinal Set<String> preservedNumberPaths = Set.of(
"/v1", "/v2", "/v3", // API版本
"/2fa", "/oauth2" // 特定路徑
);
public String standardizePath(String uri) {
// 對于需要保留的數(shù)字路徑,直接返回
for (String path : preservedNumberPaths) {
if (uri.contains(path)) {
return uri;
}
}
// 替換疑似ID參數(shù),如/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.include=prometheus,health,info
management.metrics.export.prometheus.enabled=true
management.metrics.tags.application=${spring.application.name}
management.metrics.distribution.percentiles-histogram.http.server.requests=true
# 設(shè)置Prometheus抓取間隔與數(shù)據(jù)保留時間
# prometheus.yml中設(shè)置:
# scrape_interval: 15s
# storage.tsdb.retention.time: 15d
Prometheus 計數(shù)器是單調(diào)遞增的,像汽車里程表一樣只增不減。這設(shè)計很巧妙:即使服務(wù)重啟,也不會丟失統(tǒng)計數(shù)據(jù)。
舉個例子:假設(shè)一個接口 10 分鐘內(nèi)每分鐘調(diào)用 100 次。傳統(tǒng)方式直接記錄"100"這個值,服務(wù)重啟會丟失;
而 Prometheus 記錄的是累計值,從 0 開始不斷增加(100,200,300...)。重啟后從新值開始繼續(xù)累加,通過rate()函數(shù)計算兩次采集間的變化率,依然能得到準確的"每分鐘 100 次"這個結(jié)果。

Prometheus 查詢示例
# 查詢UserService.getUser接口每分鐘調(diào)用率
rate(api_calls_total{name="UserService.getUser"}[1m])
# 查詢接口95分位延遲
histogram_quantile(0.95, sum(rate(api_latency_seconds_bucket{name="UserService.getUser"}[5m])) by (le))
# 按狀態(tài)碼統(tǒng)計接口調(diào)用
sum(rate(api_calls_total{name="UserService.getUser"}[5m])) by (status)
五種方案的數(shù)據(jù)流架構(gòu)對比





混合方案:完整監(jiān)控體系
在實際項目中,我發(fā)現(xiàn)單一方案往往不能滿足所有需求,最佳組合是:
- 滑動窗口 - 服務(wù)內(nèi)部的實時統(tǒng)計(毫秒級響應(yīng))
- Redis 存儲 - 分布式聚合和短期歷史查詢(分鐘級)
- Prometheus - 長期趨勢和多維分析(小時/天級)
具體實現(xiàn):
@Service
publicclass HybridApiMonitor {
@Autowired
private SlidingWindowCounter localCounter;
@Autowired
private RedisTimeSeriesCounter redisCounter;
@Autowired
private MeterRegistry meterRegistry;
// 記錄API調(diào)用
public void recordApiCall(String apiName) {
// 本地滑動窗口統(tǒng)計 - 實時查詢用
localCounter.increment(apiName);
// Redis異步批量寫入 - 本地緩沖后批量寫Redis
batchRedisWriter.add(apiName);
// Prometheus長期趨勢 - 加標簽維度
meterRegistry.counter("api.calls", "name", apiName).increment();
}
// 定時任務(wù):每分鐘將滑動窗口數(shù)據(jù)寫入Redis
@Scheduled(fixedRate = 60000)
public void flushToRedis() {
// 獲取所有接口的分鐘統(tǒng)計,批量寫入Redis
// 實現(xiàn)略
}
// 查詢接口(提供多級統(tǒng)計數(shù)據(jù))
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();
}
}
這種混合方案可以滿足從秒級實時監(jiān)控到月度趨勢分析的全場景需求,各層級數(shù)據(jù)互為補充。
進階:接口調(diào)用監(jiān)控的實戰(zhàn)經(jīng)驗與性能對比
我用 JMeter 對各方案做了次壓測,配置如下:
- 測試環(huán)境: 4 核 8G 云服務(wù)器,JMeter 100 個并發(fā)線程,持續(xù) 10 分鐘
- 測試數(shù)據(jù): 隨機訪問 100 個不同接口,共生成 1000 萬次調(diào)用
- 邊界測試: 在穩(wěn)定運行后突增至 500%流量,維持 30 秒
| 方案 | 內(nèi)存占用(MB) | CPU 使用率 | QPS 上限 | 數(shù)據(jù)持久化 | 跨語言支持 | 監(jiān)控延遲 | 適用場景 |
|---|---|---|---|---|---|---|---|
| 固定窗口計數(shù)器 | ~10 | ~2% | >20000 | 無 | 僅 Java | 實時 | 簡單場景,單機應(yīng)用 |
| 滑動窗口(標準版) | ~15 | ~5% | ~15000 | 無 | 僅 Java | 實時 | 需精確統(tǒng)計的單體應(yīng)用 |
| 滑動窗口(懶加載版) | ~12 | ~3% | >18000 | 無 | 僅 Java | 實時 | 高性能需求場景 |
| AOP 透明統(tǒng)計 | ~20 | ~7% | ~10000 | 無 | Spring 框架 | 實時 | 代碼零侵入需求 |
| Redis 分布式統(tǒng)計 | ~5(客戶端) | ~4% | ~5000 | 24 小時 | 全語言 | <100ms | 分布式系統(tǒng),集群環(huán)境 |
| Micrometer+Prometheus | ~30 | ~10% | ~8000 | 可配置 | 全語言 | 15-30 秒 | 需要可視化和告警場景 |
不同接口數(shù)量下的內(nèi)存增長情況:
| 接口數(shù)量 | 固定窗口 | 標準滑動窗口 | 懶加載滑動窗口 |
|---|---|---|---|
| 1千 | 1MB | 2MB | 2MB |
| 1萬 | 8MB | 20MB | 15MB |
| 10萬 | 70MB | 200MB | 140MB |
| 100萬 | OOM | OOM | 1.3GB |
實戰(zhàn)中的典型問題與解決方案
1.內(nèi)存溢出問題
生產(chǎn)環(huán)境中遇到過一次嚴重 OOM,排查發(fā)現(xiàn)是接口 URL 中包含大量隨機參數(shù)(用戶 ID、訂單號等),導(dǎo)致 Map 鍵爆炸:
// 解決方案:使用Guava Cache限制Map大小
private LoadingCache<String, AtomicLong> counters = CacheBuilder.newBuilder()
.maximumSize(10000) // 最多存儲10000個接口
.expireAfterAccess(30, TimeUnit.MINUTES) // 30分鐘未訪問自動清除
.build(new CacheLoader<String, AtomicLong>() {
@Override
public AtomicLong load(String key) {
return new AtomicLong(0);
}
});
2.分布式環(huán)境中的時鐘漂移
我們在 K8s 環(huán)境中發(fā)現(xiàn),不同 Pod 的時鐘可能相差幾秒,導(dǎo)致窗口邊界不一致:
// 解決方案:時鐘同步方案對比
// 1. NTP同步(物理機最佳):apt install ntp
// 2. Redis時間服務(wù)(混合環(huán)境推薦)
@Service
publicclass RedisClockService implements ClockService {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public long currentTimeMillis() {
return redisTemplate.execute((RedisCallback<Long>) connection ->
connection.time()
);
}
}
// 3. K8s環(huán)境(容器集群最佳):使用PTP協(xié)議和NodeTime DaemonSet
3.高并發(fā)下的 Redis 性能問題
訂單系統(tǒng)高峰期每秒 10 萬+API 調(diào)用,每次都寫 Redis 吃不消:
// 解決方案:本地緩沖+批量寫入
publicclass BufferedRedisCounter {
privatefinal ConcurrentHashMap<String, AtomicLong> buffer = new ConcurrentHashMap<>();
privatefinal ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@Autowired
private RedisTemplate<String, String> redisTemplate;
public BufferedRedisCounter() {
// 每秒批量寫入Redis
scheduler.scheduleAtFixedRate(this::flushToRedis, 1, 1, TimeUnit.SECONDS);
}
public void increment(String apiName) {
// 本地增加計數(shù)
buffer.computeIfAbsent(apiName, k -> new AtomicLong(0)).incrementAndGet();
}
private void flushToRedis() {
if (buffer.isEmpty()) {
return;
}
// 創(chuàng)建Redis管道,批量執(zhí)行命令
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
buffer.forEach((api, count) -> {
long value = count.getAndSet(0); // 重置緩沖區(qū)
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;
});
}
}
容量規(guī)劃建議
基于壓測數(shù)據(jù),以下是各方案的容量規(guī)劃建議:
1.固定/滑動窗口: 每 1 萬個接口約需 15-20MB 內(nèi)存,JVM 堆建議為:
接口數(shù) < 1萬:堆內(nèi)存 >= 512MB
接口數(shù) < 10萬:堆內(nèi)存 >= 2GB
接口數(shù) > 10萬:建議使用Redis方案
2.Redis 分布式統(tǒng)計: 按每個接口每分鐘 100 字節(jié)估算:
1千接口,保留7天:~1GB
1萬接口,保留7天:~10GB
推薦Redis集群配置:3主3從,每節(jié)點16GB
3.Prometheus: 時序數(shù)據(jù)庫容量:
公式:磁盤空間 ≈ 每秒樣本數(shù) × 樣本大小 × 保留時間
1千接口,15秒采集,保留30天:~50GB
高基數(shù)限制:單指標標簽組合不超過5000
總結(jié)
| 方案 | 復(fù)雜度 | 核心優(yōu)點 | 核心缺點 | 適用場景 |
|---|---|---|---|---|
| 固定窗口計數(shù)器 | 低 | 實現(xiàn)極簡,代碼量少,資源消耗最低 | 窗口重置時統(tǒng)計出現(xiàn)邊界誤差,跨窗口調(diào)用統(tǒng)計不準確 | 單機測試、原型驗證場景 |
| 滑動窗口計數(shù)器 | 中 | 時間精度高(秒級分片),無邊界誤差,支持懶加載優(yōu)化 | synchronized 方法在高并發(fā)下形成鎖競爭,內(nèi)存消耗隨窗口數(shù)量增加 | 單體應(yīng)用精確統(tǒng)計需求 |
| AOP 透明統(tǒng)計 | 中 | 無侵入式集成,與業(yè)務(wù)代碼解耦,支持多維度標簽 | 引入 AOP 代理會增加方法調(diào)用開銷,Spring 框架依賴較強 | Spring 生態(tài)中追求開發(fā)便捷性場景 |
| Redis 分布式統(tǒng)計 | 高 | 跨實例聚合數(shù)據(jù),支持集群環(huán)境,可存儲歷史趨勢 | Redis 網(wǎng)絡(luò)延遲導(dǎo)致實時性降低,依賴外部存儲,故障時數(shù)據(jù)可能丟失 | 微服務(wù)架構(gòu)、需要歷史查詢能力場景 |
| Micrometer+Prometheus | 高 | 完整監(jiān)控生態(tài),支持多維標簽、告警、可視化儀表盤,可自定義各種聚合分析 | 配置復(fù)雜,資源消耗較大,需要部署監(jiān)控系統(tǒng),學(xué)習(xí)成本高 | 企業(yè)級生產(chǎn)環(huán)境、需完整監(jiān)控鏈路場景 |
以上就是Java統(tǒng)計接口調(diào)用頻率的五種方案及對比詳解的詳細內(nèi)容,更多關(guān)于Java統(tǒng)計接口調(diào)用頻率的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
SpringBoot?main方法結(jié)束程序不停止的原因分析及解決方法
Spring?Boot啟動內(nèi)嵌Tomcat時,main方法啟動非daemon線程執(zhí)行await()死循環(huán),使JVM保持運行,通過發(fā)送關(guān)機指令可終止程序,下面通過本文給大家介紹SpringBoot?main方法結(jié)束程序不停止的原因分析及解決方法,感興趣的朋友一起看看吧2025-07-07
Spring Cloud使用Feign實現(xiàn)Form表單提交的示例
本篇文章主要介紹了Spring Cloud使用Feign實現(xiàn)Form表單提交的示例,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-03-03
Spring5新功能@Nullable注解及函數(shù)式注冊對象
這篇文章主要為大家介紹了Spring5新功能詳解@Nullable注解及函數(shù)式注冊對象,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-05-05
SpringBoot圖文并茂詳解如何引入mybatis與連接Mysql數(shù)據(jù)庫
這篇文章主要介紹了SpringBoot如何引入mybatis與連接Mysql數(shù)據(jù)庫,本文通過圖文并茂的形式給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-07-07
JavaSwing FlowLayout 流式布局的實現(xiàn)
這篇文章主要介紹了JavaSwing FlowLayout 流式布局的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-12-12

