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

方案一:固定窗口計(jì)數(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)計(jì) ===");
counters.forEach((api, count) -> {
System.out.println(api + ": " + count.getAndSet(0));
});
}
}
這種方案實(shí)現(xiàn)超簡單,但有個明顯問題:假設(shè)定時器在 8:59:59 觸發(fā)清零,9:00:01 有次調(diào)用,這次調(diào)用會被算到 9:01 才清零的那個窗口里,統(tǒng)計(jì)就不準(zhǔn)了。就像公司打卡,你 8:59 到了,打卡機(jī) 9:00 重置,系統(tǒng)硬是把你算成下一個小時的人了。
方案二:滑動窗口計(jì)數(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() {
// 計(jì)算初始時間片索引,對齊系統(tǒng)時間
// 時間片索引公式:當(dāng)前時間秒數(shù) / 窗口大小(10秒) = 第幾個時間片
currentTimeSlice = (int)(System.currentTimeMillis() / 1000 / WINDOW_SIZE_SECONDS);
// 啟動定時器,每10秒滑動一次窗口
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// 計(jì)算第一次執(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)建接口計(jì)數(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 {
// 計(jì)算最新的時間片索引
int newSlice = (int)(System.currentTimeMillis() / 1000 / WINDOW_SIZE_SECONDS);
// 處理時鐘回?fù)芮闆r
if (newSlice <= currentTimeSlice) {
// 時鐘回?fù)芰?,打日志但不更新時間片
System.err.println("Clock skew detected: " + newSlice + " <= " + currentTimeSlice);
return;
}
// 更新當(dāng)前時間片索引
currentTimeSlice = newSlice;
// 定期清理長時間未使用的接口統(tǒng)計(jì)
cleanupIdleCounters();
} catch (Exception e) {
// 異常處理,避免定時任務(wù)中斷
System.err.println("Error in slideWindow: " + e.getMessage());
}
}
// 計(jì)數(shù)器條目內(nèi)部類,支持懶加載窗口更新
privateclass CounterEntry {
// 時間片計(jì)數(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)前時間片的計(jì)數(shù)
public void increment(int timeSlice) {
// 先更新窗口(如果需要)
updateWindowsIfNeeded(timeSlice);
// 增加當(dāng)前時間片的計(jì)數(shù)
// 環(huán)形數(shù)組索引 = 時間片索引 % 窗口數(shù)量
// 這里是關(guān)鍵:通過取模運(yùn)算使得數(shù)組索引在0-5間循環(huán),形成環(huán)形結(jié)構(gòu)
int index = timeSlice % WINDOW_COUNT;
counters[index].incrementAndGet();
// 更新訪問信息
lastAccessedSlice = timeSlice;
lastUpdateTime = System.currentTimeMillis();
}
// 獲取所有窗口的總計(jì)數(shù)
public long getTotal(int currentSlice) {
// 先更新窗口(如果需要)
updateWindowsIfNeeded(currentSlice);
// 計(jì)算總和
long total = 0;
for (AtomicLong counter : counters) {
total += counter.get();
}
return total;
}
// 懶加載更新窗口 - 只在實(shí)際訪問時更新
private void updateWindowsIfNeeded(int currentSlice) {
int sliceDiff = currentSlice - lastAccessedSlice;
if (sliceDiff <= 0) {
// 時間片未變或異常情況(時鐘回?fù)埽?,無需更新
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);
}
}
}
}
// 清理長時間未使用的計(jì)數(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)計(jì)
it.remove();
}
}
}
}
這個懶加載滑動窗口方案的優(yōu)點(diǎn)是時間精度高,邊界平滑,性能也不錯。懶加載是啥意思?就是只有你真來訪問了,我才去更新那個窗口,不像傳統(tǒng)方案每次滑動都要遍歷所有接口。
滑動窗口就像環(huán)形跑道上的 6 個區(qū)域,隨著時間推移,我們只清空前方的區(qū)域,保留最近一分鐘的統(tǒng)計(jì)數(shù)據(jù)。

方案三:基于 AOP 的透明統(tǒng)計(jì)(異步優(yōu)化版)
前面的方案都要在代碼里手動調(diào)用 increment 方法,太麻煩了。用 Spring AOP,可以實(shí)現(xiàn)無侵入的接口調(diào)用統(tǒng)計(jì):
@Aspect
@Component
publicclass ApiMonitorAspect {
privatefinal Logger logger = LoggerFactory.getLogger(ApiMonitorAspect.class);
// 依賴注入單例計(jì)數(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í)行
);
// 定義切點(diǎn),精確匹配只統(tǒng)計(jì)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)計(jì)信息,避免影響主流程性能
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)計(jì)邏輯異常不影響業(yè)務(wù)
logger.error("Failed to record API metrics", ex);
}
});
}
}
// 提供查詢接口
public long getApiCallCount(String apiName) {
return counter.getMinuteCount(apiName);
}
}
這種 AOP 方案就像在小區(qū)門口裝了個"隱形 攝像頭",進(jìn)出的人都被記錄,但完全感覺不到它的存在。我優(yōu)化了線程池配置,加了拒絕策略,防止高并發(fā)時隊(duì)列爆滿。切點(diǎn)表達(dá)式也做了精確匹配,確保只統(tǒng)計(jì)真正的 HTTP 接口,不會誤統(tǒng)計(jì)內(nèi)部服務(wù)方法。
方案四:使用 Redis 實(shí)現(xiàn)分布式統(tǒng)計(jì)(時序優(yōu)化版)
前面的方案在單機(jī)應(yīng)用里都挺好用,但放到分布式系統(tǒng)里,每臺機(jī)器都有自己的計(jì)數(shù)器,統(tǒng)計(jì)就不全了。用 Redis 可以實(shí)現(xiàn)分布式計(jì)數(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); " + // 增加計(jì)數(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);
// 本地計(jì)數(shù)器備份可以在這里實(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)境,還能超高效地存儲和查詢歷史趨勢。我加了重試機(jī)制,防止網(wǎng)絡(luò)抖動導(dǎo)致計(jì)數(shù)失敗,并且提供了 Redis 連接池配置建議。有序集合(ZSET)比簡單計(jì)數(shù)器厲害的地方是,它能按時間戳自然排序,一個接口所有時間點(diǎn)的調(diào)用數(shù)據(jù)都在一個結(jié)構(gòu)里,查詢很方便。

方案五:使用 Micrometer + Prometheus 實(shí)現(xiàn)監(jiān)控可視化(多維優(yōu)化版)
如果你不只是想統(tǒng)計(jì)調(diào)用次數(shù),還想做可視化監(jiān)控和多維度分析,Micrometer + Prometheus 是個好選擇:
@Configuration
publicclass MetricsConfig {
@Bean
public MeterRegistry meterRegistry() {
// 定義通用標(biāo)簽,如應(yīng)用名、環(huán)境等
returnnew PrometheusMeterRegistry(
PrometheusConfig.DEFAULT,
new CollectorRegistry(),
Clock.SYSTEM,
new CommonTags("application", "my-app", "env", "prod")
);
}
// 添加維度標(biāo)簽過濾器,防止基數(shù)爆炸
@Bean
public MeterFilter dimensionFilter() {
// 一個指標(biāo)最多100個uri維度,避免內(nèi)存爆炸
return MeterFilter.maximumAllowableTags("api.calls", "uri", 100);
}
// 基數(shù)控制:限制name標(biāo)簽組合不超過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)標(biāo)簽組合超過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ù)標(biāo)準(zhǔn)化
String uri = pathResolver.standardizePath(request.getRequestURI());
// 記錄接口調(diào)用次數(shù),使用標(biāo)簽而非字符串拼接
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 計(jì)數(shù)器是單調(diào)遞增的,像汽車?yán)锍瘫硪粯又辉霾粶p。這設(shè)計(jì)很巧妙:即使服務(wù)重啟,也不會丟失統(tǒng)計(jì)數(shù)據(jù)。
舉個例子:假設(shè)一個接口 10 分鐘內(nèi)每分鐘調(diào)用 100 次。傳統(tǒng)方式直接記錄"100"這個值,服務(wù)重啟會丟失;
而 Prometheus 記錄的是累計(jì)值,從 0 開始不斷增加(100,200,300...)。重啟后從新值開始繼續(xù)累加,通過rate()函數(shù)計(jì)算兩次采集間的變化率,依然能得到準(zhǔn)確的"每分鐘 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)計(jì)接口調(diào)用
sum(rate(api_calls_total{name="UserService.getUser"}[5m])) by (status)
五種方案的數(shù)據(jù)流架構(gòu)對比





混合方案:完整監(jiān)控體系
在實(shí)際項(xiàng)目中,我發(fā)現(xiàn)單一方案往往不能滿足所有需求,最佳組合是:
- 滑動窗口 - 服務(wù)內(nèi)部的實(shí)時統(tǒng)計(jì)(毫秒級響應(yīng))
- Redis 存儲 - 分布式聚合和短期歷史查詢(分鐘級)
- Prometheus - 長期趨勢和多維分析(小時/天級)
具體實(shí)現(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)計(jì) - 實(shí)時查詢用
localCounter.increment(apiName);
// Redis異步批量寫入 - 本地緩沖后批量寫Redis
batchRedisWriter.add(apiName);
// Prometheus長期趨勢 - 加標(biāo)簽維度
meterRegistry.counter("api.calls", "name", apiName).increment();
}
// 定時任務(wù):每分鐘將滑動窗口數(shù)據(jù)寫入Redis
@Scheduled(fixedRate = 60000)
public void flushToRedis() {
// 獲取所有接口的分鐘統(tǒng)計(jì),批量寫入Redis
// 實(shí)現(xiàn)略
}
// 查詢接口(提供多級統(tǒng)計(jì)數(shù)據(jù))
public ApiStats getApiStats(String apiName) {
return ApiStats.builder()
.realtimeQps(localCounter.getMinuteCount(apiName) / 60.0) // 實(shí)時QPS
.last5MinutesTrend(redisCounter.getCountTrend(apiName, /* 時間范圍 */)) // 分鐘級趨勢
.prometheusQueryUrl("/grafana/d/apis?var-name=" + apiName) // 長期趨勢查詢鏈接
.build();
}
}
這種混合方案可以滿足從秒級實(shí)時監(jiān)控到月度趨勢分析的全場景需求,各層級數(shù)據(jù)互為補(bǔ)充。
進(jìn)階:接口調(diào)用監(jiān)控的實(shí)戰(zhàn)經(jīng)驗(yàn)與性能對比
我用 JMeter 對各方案做了次壓測,配置如下:
- 測試環(huán)境: 4 核 8G 云服務(wù)器,JMeter 100 個并發(fā)線程,持續(xù) 10 分鐘
- 測試數(shù)據(jù): 隨機(jī)訪問 100 個不同接口,共生成 1000 萬次調(diào)用
- 邊界測試: 在穩(wěn)定運(yùn)行后突增至 500%流量,維持 30 秒
| 方案 | 內(nèi)存占用(MB) | CPU 使用率 | QPS 上限 | 數(shù)據(jù)持久化 | 跨語言支持 | 監(jiān)控延遲 | 適用場景 |
|---|---|---|---|---|---|---|---|
| 固定窗口計(jì)數(shù)器 | ~10 | ~2% | >20000 | 無 | 僅 Java | 實(shí)時 | 簡單場景,單機(jī)應(yīng)用 |
| 滑動窗口(標(biāo)準(zhǔn)版) | ~15 | ~5% | ~15000 | 無 | 僅 Java | 實(shí)時 | 需精確統(tǒng)計(jì)的單體應(yīng)用 |
| 滑動窗口(懶加載版) | ~12 | ~3% | >18000 | 無 | 僅 Java | 實(shí)時 | 高性能需求場景 |
| AOP 透明統(tǒng)計(jì) | ~20 | ~7% | ~10000 | 無 | Spring 框架 | 實(shí)時 | 代碼零侵入需求 |
| Redis 分布式統(tǒng)計(jì) | ~5(客戶端) | ~4% | ~5000 | 24 小時 | 全語言 | <100ms | 分布式系統(tǒng),集群環(huán)境 |
| Micrometer+Prometheus | ~30 | ~10% | ~8000 | 可配置 | 全語言 | 15-30 秒 | 需要可視化和告警場景 |
不同接口數(shù)量下的內(nèi)存增長情況:
| 接口數(shù)量 | 固定窗口 | 標(biāo)準(zhǔn)滑動窗口 | 懶加載滑動窗口 |
|---|---|---|---|
| 1千 | 1MB | 2MB | 2MB |
| 1萬 | 8MB | 20MB | 15MB |
| 10萬 | 70MB | 200MB | 140MB |
| 100萬 | OOM | OOM | 1.3GB |
實(shí)戰(zhàn)中的典型問題與解決方案
1.內(nèi)存溢出問題
生產(chǎn)環(huán)境中遇到過一次嚴(yán)重 OOM,排查發(fā)現(xiàn)是接口 URL 中包含大量隨機(jī)參數(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同步(物理機(jī)最佳):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) {
// 本地增加計(jì)數(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)計(jì): 按每個接口每分鐘 100 字節(jié)估算:
1千接口,保留7天:~1GB
1萬接口,保留7天:~10GB
推薦Redis集群配置:3主3從,每節(jié)點(diǎn)16GB
3.Prometheus: 時序數(shù)據(jù)庫容量:
公式:磁盤空間 ≈ 每秒樣本數(shù) × 樣本大小 × 保留時間
1千接口,15秒采集,保留30天:~50GB
高基數(shù)限制:單指標(biāo)標(biāo)簽組合不超過5000
總結(jié)
| 方案 | 復(fù)雜度 | 核心優(yōu)點(diǎn) | 核心缺點(diǎn) | 適用場景 |
|---|---|---|---|---|
| 固定窗口計(jì)數(shù)器 | 低 | 實(shí)現(xiàn)極簡,代碼量少,資源消耗最低 | 窗口重置時統(tǒng)計(jì)出現(xiàn)邊界誤差,跨窗口調(diào)用統(tǒng)計(jì)不準(zhǔn)確 | 單機(jī)測試、原型驗(yàn)證場景 |
| 滑動窗口計(jì)數(shù)器 | 中 | 時間精度高(秒級分片),無邊界誤差,支持懶加載優(yōu)化 | synchronized 方法在高并發(fā)下形成鎖競爭,內(nèi)存消耗隨窗口數(shù)量增加 | 單體應(yīng)用精確統(tǒng)計(jì)需求 |
| AOP 透明統(tǒng)計(jì) | 中 | 無侵入式集成,與業(yè)務(wù)代碼解耦,支持多維度標(biāo)簽 | 引入 AOP 代理會增加方法調(diào)用開銷,Spring 框架依賴較強(qiáng) | Spring 生態(tài)中追求開發(fā)便捷性場景 |
| Redis 分布式統(tǒng)計(jì) | 高 | 跨實(shí)例聚合數(shù)據(jù),支持集群環(huán)境,可存儲歷史趨勢 | Redis 網(wǎng)絡(luò)延遲導(dǎo)致實(shí)時性降低,依賴外部存儲,故障時數(shù)據(jù)可能丟失 | 微服務(wù)架構(gòu)、需要?dú)v史查詢能力場景 |
| Micrometer+Prometheus | 高 | 完整監(jiān)控生態(tài),支持多維標(biāo)簽、告警、可視化儀表盤,可自定義各種聚合分析 | 配置復(fù)雜,資源消耗較大,需要部署監(jiān)控系統(tǒng),學(xué)習(xí)成本高 | 企業(yè)級生產(chǎn)環(huán)境、需完整監(jiān)控鏈路場景 |
以上就是Java統(tǒng)計(jì)接口調(diào)用頻率的五種方案及對比詳解的詳細(xì)內(nèi)容,更多關(guān)于Java統(tǒng)計(jì)接口調(diào)用頻率的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
SpringBoot2.x 整合Spring-Session實(shí)現(xiàn)Session共享功能
這篇文章主要介紹了SpringBoot2.x 整合Spring-Session實(shí)現(xiàn)Session共享功能,本文給大家介紹的非常詳細(xì),具有一定的參考借鑒價值,需要的朋友可以參考下2019-07-07
java求數(shù)組元素重復(fù)次數(shù)和java字符串比較大小示例
這篇文章主要介紹了java求數(shù)組元素重復(fù)次數(shù)和java字符串比較大小示例,需要的朋友可以參考下2014-04-04
Java中Singleton的3種實(shí)現(xiàn)方式詳解
這篇文章主要給大家介紹了關(guān)于Java中Singleton的3種實(shí)現(xiàn)方式,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-02-02
Spring事件監(jiān)聽機(jī)制ApplicationEvent方式
這篇文章主要介紹了Spring事件監(jiān)聽機(jī)制ApplicationEvent方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-09-09
Java實(shí)現(xiàn)微信公眾平臺朋友圈分享功能詳細(xì)代碼
這篇文章主要介紹了Java實(shí)現(xiàn)微信公眾平臺朋友圈分享功能詳細(xì)代碼,小編覺得挺不錯的,這里分享給大家,供需要的朋友參考。2017-11-11

