最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

Java統(tǒng)計(jì)接口調(diào)用頻率的五種方案及對比詳解

 更新時間:2025年10月31日 08:51:11   作者:皮皮林551  
這篇文章主要介紹了五種接口調(diào)用頻率監(jiān)控方案,包括固定窗口計(jì)數(shù)器、滑動窗口計(jì)數(shù)器、AOP透明統(tǒng)計(jì)、Redis分布式統(tǒng)計(jì)和Micrometer+Prometheus,每種方案都有其優(yōu)缺點(diǎn)和適用場景,作者還分享了在實(shí)際項(xiàng)目中遇到的問題和解決方案,并提供了容量規(guī)劃建議,需要的朋友可以參考下

作為一線開發(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%~10000Spring 框架實(shí)時代碼零侵入需求
Redis 分布式統(tǒng)計(jì)~5(客戶端)~4%~500024 小時全語言<100ms分布式系統(tǒng),集群環(huán)境
Micrometer+Prometheus~30~10%~8000可配置全語言15-30 秒需要可視化和告警場景

不同接口數(shù)量下的內(nèi)存增長情況:

接口數(shù)量固定窗口標(biāo)準(zhǔn)滑動窗口懶加載滑動窗口
1千1MB2MB2MB
1萬8MB20MB15MB
10萬70MB200MB140MB
100萬OOMOOM1.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)文章

最新評論

剑阁县| 崇仁县| 安塞县| 新巴尔虎左旗| 淮北市| 高台县| 凤庆县| 浮山县| 淮北市| 黄浦区| 饶平县| 徐闻县| 七台河市| 丰县| 苍南县| 隆回县| 高雄市| 夏津县| 夏河县| 盐源县| 盘山县| 遂溪县| 和硕县| 五寨县| 潞西市| 长白| 古田县| 手游| 镶黄旗| 海宁市| 砀山县| 武隆县| 扎囊县| 资溪县| 九江市| 新兴县| 黄平县| 康定县| 伊通| 邮箱| 聂拉木县|