快取框架 Caffeine 的視覺化探索與實踐

vivo互联网技术發表於2024-07-26

作者:vivo 網際網路伺服器團隊- Wang Zhi

Caffeine 作為一個高效能的快取框架而被大量使用。本文基於Caffeine已有的基礎進行定製化開發實現視覺化功能。

一、背景

Caffeine快取是一個高效能、可擴充套件、記憶體最佳化的 Java 快取庫,基於 Google 的 Guava Cache演進而來並提供了接近最佳的命中率。

Caffeine 快取包含以下特點

  1. 高效快速:Caffeine 快取使用近似演算法和併發雜湊表等最佳化技術,使得快取的訪問速度非常快。

  2. 記憶體友好:Caffeine 快取使用一種記憶體最佳化策略,能夠根據需要動態調整快取的大小,有效地利用記憶體資源。

  3. 多種快取策略:Caffeine 快取支援多種快取策略,如基於容量、時間、權重、手動移除、定時重新整理等,並提供了豐富的配置選項,能夠適應不同的應用場景和需求。

  4. 支援非同步載入和重新整理:Caffeine 快取支援非同步載入和重新整理快取項,可以與 Spring 等框架無縫整合。

  5. 清理策略:Caffeine 使用 Window TinyLFU 清理策略,它提供了接近最佳的命中率。

  6. 支援自動載入和自動過期:Caffeine 快取可以根據配置自動載入和過期快取項,無需手動干預。

  7. 統計功能:Caffeine 快取提供了豐富的統計功能,如快取命中率、快取項數量等,方便評估快取的效能和效果。

正是因為Caffeine具備的上述特性,Caffeine作為專案中本地快取的不二選擇,越來越多的專案整合了Caffeine的功能,進而衍生了一系列的業務視角的需求。

日常使用的需求之一希望能夠實時評估Caffeine例項的記憶體佔用情況並能夠提供動態調整快取引數的能力,但是已有的記憶體分析工具MAT需要基於dump的檔案進行分析無法做到實時,這也是整個事情的起因之一。

二、業務的技術視角

  • 能夠對專案中的Caffeine的快取例項能夠做到近實時統計,實時檢視快取的例項個數。

  • 能夠對Caffeine的每個例項的快取配置引數、記憶體佔用、快取命中率做到實時檢視,同時能夠支援單個例項的快取過期時間,快取條目等引數進行動態配置下發。

  • 能夠對Caffeine的每個例項的快取資料做到實時檢視,並且能夠支援快取資料的立即失效等功能。

基於上述的需求背景,結合caffeine的已有功能和定製的部分原始碼開發,整體作為caffeine視覺化的技術專案進行推進和落地。

三、視覺化能力

Caffeine視覺化專案目前已支援功能包括:

  • 專案維度的全域性快取例項的管控。

  • 單快取例項配置資訊視覺化、記憶體佔用視覺化、命中率視覺化。

  • 單快取例項的資料查詢、配置動態變更、快取資料失效等功能。

3.1 快取例項的全域性管控

圖片

說明:

  • 以應用維度+機器維度展示該應用下包含的快取例項物件,每個例項包含快取設定中的大小、過期策略、過期時間、記憶體佔用、快取命中率等資訊。

  • 單例項維度的記憶體佔用和快取命中率支援以趨勢圖進行展示。

  • 單例項維度支援配置變更操作和快取查詢操作。

3.2 記憶體佔用趨勢

圖片

說明:

  • 記憶體佔用趨勢記錄該快取例項物件近一段時間記憶體佔用的趨勢變化。

  • 時間週期目前支援展示近兩天的資料。

3.3 命中率趨勢

圖片

說明:

  • 命中率趨勢記錄該快取例項物件近一段時間快取命中的變化情況。

  • 時間週期目前支援展示近兩天的資料。

3.4 配置變更

圖片

說明:

  • 配置變更目前支援快取大小和過期時間的動態設定。

  • 目前暫時支援單例項的設定,後續會支援全量生效功能。

3.5 快取查詢

圖片

說明:

  • 單例項維度支援快取資料的查詢。

  • 目前支援常見的快取Key型別包括String型別、Long型別、Int型別。

四、原理實現

4.1 整體設計框架

  • Caffeine框架功能整合

圖片

說明:

  • 沿用Caffeine的基礎功能包括Caffeine的快取功能和Caffeine統計功能。

  • 新增Caffeine記憶體佔用預估功能,該功能主要是預估快取例項物件佔用的記憶體情況。

  • 新增Caffeine例項命名功能,該功能是針對每個例項物件提供命名功能,是全域性管控的基礎。

  • 新增Caffeine例項全域性管控功能,該功能主要維護專案執行中所有的快取例項。

Caffeine視覺化框架

圖片

說明:

  • 【專案工程側】:Caffeine的視覺化框架基於Caffeine框架功能整合的基礎上增加通訊層進行資料資料上報和配置的下發。

  • 【管控平臺側】:負責快取資料上報的接收展示,配置變更命令的下發。

  • 【通訊層支援push和pull兩種模式】,push模式主要用於統計資料的實時上報,pull模式主要用於配置下發和快取資料查詢。

4.2 原始碼實現

業務層-快取物件的管理

static Cache<String, List<String>> accountWhiteCache = Caffeine.newBuilder()
            .expireAfterWrite(VivoConfigManager.getInteger("trade.account.white.list.cache.ttl", 10), TimeUnit.MINUTES)
            .recordStats().maximumSize(VivoConfigManager.getInteger("trade.account.white.list.cache.size", 100)).build();
常規的Caffeine例項的建立方式
 
 
static Cache<String, List<String>> accountWhiteCache = Caffeine.newBuilder().applyName("accountWhiteCache")
            .expireAfterWrite(VivoConfigManager.getInteger("trade.account.white.list.cache.ttl", 10), TimeUnit.MINUTES)
            .recordStats().maximumSize(VivoConfigManager.getInteger("trade.account.white.list.cache.size", 100)).build();
支援例項命名的Caffeine例項的建立方式

說明:

  • 在Caffeine例項建立的基礎上增加了快取例項的命名功能,透過.applyName("accountWhiteCache")來定義快取例項的命名。

public final class Caffeine<K, V> {
 
  /**
   * caffeine的例項名稱
   */
  String instanceName;
 
  /**
   * caffeine的例項維護的Map資訊
   */
  static Map<String, Cache> cacheInstanceMap = new ConcurrentHashMap<>();
 
  @NonNull
  public <K1 extends K, V1 extends V> Cache<K1, V1> build() {
    requireWeightWithWeigher();
    requireNonLoadingCache();
 
    @SuppressWarnings("unchecked")
    Caffeine<K1, V1> self = (Caffeine<K1, V1>) this;
    Cache localCache =  isBounded() ? new BoundedLocalCache.BoundedLocalManualCache<>(self) : new UnboundedLocalCache.UnboundedLocalManualCache<>(self);
 
    if (null != localCache && StringUtils.isNotEmpty(localCache.getInstanceName())) {
      cacheInstanceMap.put(localCache.getInstanceName(), localCache);
    }
 
    return localCache;
  }
}

說明:

  • 每個Caffeine都有一個例項名稱instanceName。

  • 全域性透過cacheInstanceMap來維護Caffeine例項物件的名稱和例項的對映關係。

  • 透過維護對映關係能夠透過例項的名稱查詢到快取例項物件並對快取例項物件進行各類的操作。

  • Caffeine例項的命名功能是其他功能整合的基石。

業務層-記憶體佔用的預估

import jdk.nashorn.internal.ir.debug.ObjectSizeCalculator;
 
public abstract class BoundedLocalCache<K, V> extends BLCHeader.DrainStatusRef<K, V>
    implements LocalCache<K, V> {
 
  final ConcurrentHashMap<Object, Node<K, V>> data;
 
  @Override
  public long getMemoryUsed() {
    // 預估記憶體佔用
    return ObjectSizeCalculator.getObjectSize(data);
  }
}

說明:

  • 透過ObjectSizeCalculator.getObjectSize預估記憶體的快取值。

  • data值是Caffeine例項用來儲存真實資料的物件。

業務層-資料上報機制

public static StatsData getCacheStats(String instanceName) {
 
    Cache cache = Caffeine.getCacheByInstanceName(instanceName);
 
    CacheStats cacheStats = cache.stats();
    StatsData statsData = new StatsData();
 
    statsData.setInstanceName(instanceName);
    statsData.setTimeStamp(System.currentTimeMillis()/1000);
    statsData.setMemoryUsed(String.valueOf(cache.getMemoryUsed()));
    statsData.setEstimatedSize(String.valueOf(cache.estimatedSize()));
    statsData.setRequestCount(String.valueOf(cacheStats.requestCount()));
    statsData.setHitCount(String.valueOf(cacheStats.hitCount()));
    statsData.setHitRate(String.valueOf(cacheStats.hitRate()));
    statsData.setMissCount(String.valueOf(cacheStats.missCount()));
    statsData.setMissRate(String.valueOf(cacheStats.missRate()));
    statsData.setLoadCount(String.valueOf(cacheStats.loadCount()));
    statsData.setLoadSuccessCount(String.valueOf(cacheStats.loadSuccessCount()));
    statsData.setLoadFailureCount(String.valueOf(cacheStats.loadFailureCount()));
    statsData.setLoadFailureRate(String.valueOf(cacheStats.loadFailureRate()));
 
    Optional<Eviction> optionalEviction = cache.policy().eviction();
    optionalEviction.ifPresent(eviction -> statsData.setMaximumSize(String.valueOf(eviction.getMaximum())));
 
    Optional<Expiration> optionalExpiration = cache.policy().expireAfterWrite();
    optionalExpiration.ifPresent(expiration -> statsData.setExpireAfterWrite(String.valueOf(expiration.getExpiresAfter(TimeUnit.SECONDS))));
 
    optionalExpiration = cache.policy().expireAfterAccess();
    optionalExpiration.ifPresent(expiration -> statsData.setExpireAfterAccess(String.valueOf(expiration.getExpiresAfter(TimeUnit.SECONDS))));
 
    optionalExpiration = cache.policy().refreshAfterWrite();
    optionalExpiration.ifPresent(expiration -> statsData.setRefreshAfterWrite(String.valueOf(expiration.getExpiresAfter(TimeUnit.SECONDS))));
 
    return statsData;
}

說明:

  • 透過Caffeine自帶的統計介面來統計相關數值。

  • 統計資料例項維度進行統計。

public static void sendReportData() {
 
    try {
        if (!VivoConfigManager.getBoolean("memory.caffeine.data.report.switch", true)) {
            return;
        }
 
        // 1、獲取所有的cache例項物件
        Method listCacheInstanceMethod = HANDLER_MANAGER_CLASS.getMethod("listCacheInstance", null);
        List<String> instanceNames = (List)listCacheInstanceMethod.invoke(null, null);
        if (CollectionUtils.isEmpty(instanceNames)) {
            return;
        }
 
        String appName = System.getProperty("app.name");
        String localIp = getLocalIp();
        String localPort = String.valueOf(NetPortUtils.getWorkPort());
        ReportData reportData = new ReportData();
        InstanceData instanceData = new InstanceData();
        instanceData.setAppName(appName);
        instanceData.setIp(localIp);
        instanceData.setPort(localPort);
 
        // 2、遍歷cache例項物件獲取快取監控資料
        Method getCacheStatsMethod = HANDLER_MANAGER_CLASS.getMethod("getCacheStats", String.class);
        Map<String, StatsData> statsDataMap = new HashMap<>();
        instanceNames.stream().forEach(instanceName -> {
 
            try {
                StatsData statsData = (StatsData)getCacheStatsMethod.invoke(null, instanceName);
 
                statsDataMap.put(instanceName, statsData);
            } catch (Exception e) {
 
            }
        });
 
        // 3、構建上報物件
        reportData.setInstanceData(instanceData);
        reportData.setStatsDataMap(statsDataMap);
 
        // 4、傳送Http的POST請求
        HttpPost httpPost = new HttpPost(getReportDataUrl());
        httpPost.setConfig(requestConfig);
 
        StringEntity stringEntity = new StringEntity(JSON.toJSONString(reportData));
        stringEntity.setContentType("application/json");
        httpPost.setEntity(stringEntity);
 
        HttpResponse response = httpClient.execute(httpPost);
        String result = EntityUtils.toString(response.getEntity(),"UTF-8");
        EntityUtils.consume(response.getEntity());
 
        logger.info("Caffeine 資料上報成功 URL {} 引數 {} 結果 {}", getReportDataUrl(), JSON.toJSONString(reportData), result);
    } catch (Throwable throwable) {
        logger.error("Caffeine 資料上報失敗 URL {} ", getReportDataUrl(), throwable);
    }
}

說明:

  • 透過獲取專案中執行的所有Caffeine例項並依次遍歷收集統計資料。

  • 透過http協議負責上報對應的統計資料,採用固定間隔週期進行上報。

業務層-配置動態下發

public static ExecutionResponse dispose(ExecutionRequest request) {
    ExecutionResponse executionResponse = new ExecutionResponse();
    executionResponse.setCmdType(CmdTypeEnum.INSTANCE_CONFIGURE.getCmd());
    executionResponse.setInstanceName(request.getInstanceName());
 
    String instanceName = request.getInstanceName();
    Cache cache = Caffeine.getCacheByInstanceName(instanceName);
 
    // 設定快取的最大條目
    if (null != request.getMaximumSize() && request.getMaximumSize() > 0) {
        Optional<Eviction> optionalEviction = cache.policy().eviction();
        optionalEviction.ifPresent(eviction ->eviction.setMaximum(request.getMaximumSize()));
    }
 
    // 設定寫後過期的過期時間
    if (null != request.getExpireAfterWrite() && request.getExpireAfterWrite() > 0) {
        Optional<Expiration> optionalExpiration = cache.policy().expireAfterWrite();
        optionalExpiration.ifPresent(expiration -> expiration.setExpiresAfter(request.getExpireAfterWrite(), TimeUnit.SECONDS));
    }
 
    // 設定訪問過期的過期時間
    if (null != request.getExpireAfterAccess() && request.getExpireAfterAccess() > 0) {
        Optional<Expiration> optionalExpiration = cache.policy().expireAfterAccess();
        optionalExpiration.ifPresent(expiration -> expiration.setExpiresAfter(request.getExpireAfterAccess(), TimeUnit.SECONDS));
    }
 
    // 設定寫更新的過期時間
    if (null != request.getRefreshAfterWrite() && request.getRefreshAfterWrite() > 0) {
 
        Optional<Expiration> optionalExpiration = cache.policy().refreshAfterWrite();
        optionalExpiration.ifPresent(expiration -> expiration.setExpiresAfter(request.getRefreshAfterWrite(), TimeUnit.SECONDS));
    }
 
    executionResponse.setCode(0);
    executionResponse.setMsg("success");
 
    return executionResponse;
}

說明:

  • 透過Caffeine自帶介面進行快取配置的相關設定。

業務層-快取資料清空

/**
     * 失效快取的值
     * @param request
     * @return
     */
    public static ExecutionResponse invalidate(ExecutionRequest request) {
 
        ExecutionResponse executionResponse = new ExecutionResponse();
        executionResponse.setCmdType(CmdTypeEnum.INSTANCE_INVALIDATE.getCmd());
        executionResponse.setInstanceName(request.getInstanceName());
 
        try {
            // 查詢對應的cache例項
            String instanceName = request.getInstanceName();
            Cache cache = Caffeine.getCacheByInstanceName(instanceName);
 
            // 處理清空指定例項的所有快取 或 指定例項的key對應的快取
            Object cacheKeyObj = request.getCacheKey();
 
            // 清除所有快取
            if (Objects.isNull(cacheKeyObj)) {
                cache.invalidateAll();
            } else {
                // 清除指定key對應的快取
                if (Objects.equals(request.getCacheKeyType(), 2)) {
                    cache.invalidate(Long.valueOf(request.getCacheKey().toString()));
                } else if (Objects.equals(request.getCacheKeyType(), 3)) {
                    cache.invalidate(Integer.valueOf(request.getCacheKey().toString()));
                } else {
                    cache.invalidate(request.getCacheKey().toString());
                }
            }
 
            executionResponse.setCode(0);
            executionResponse.setMsg("success");
        } catch (Exception e) {
            executionResponse.setCode(-1);
            executionResponse.setMsg("fail");
        }
 
        return executionResponse;
    }
}

業務層-快取資料查詢

public static ExecutionResponse inspect(ExecutionRequest request) {
 
    ExecutionResponse executionResponse = new ExecutionResponse();
    executionResponse.setCmdType(CmdTypeEnum.INSTANCE_INSPECT.getCmd());
    executionResponse.setInstanceName(request.getInstanceName());
 
    String instanceName = request.getInstanceName();
    Cache cache = Caffeine.getCacheByInstanceName(instanceName);
 
    Object cacheValue = cache.getIfPresent(request.getCacheKey());
    if (Objects.equals(request.getCacheKeyType(), 2)) {
        cacheValue = cache.getIfPresent(Long.valueOf(request.getCacheKey().toString()));
    } else if (Objects.equals(request.getCacheKeyType(), 3)) {
        cacheValue = cache.getIfPresent(Integer.valueOf(request.getCacheKey().toString()));
    } else {
        cacheValue = cache.getIfPresent(request.getCacheKey().toString());
    }
 
    if (Objects.isNull(cacheValue)) {
        executionResponse.setData("");
    } else {
        executionResponse.setData(JSON.toJSONString(cacheValue));
    }
 
    return executionResponse;
}

說明:

  • 透過Caffeine自帶介面進行快取資訊查詢。

通訊層-監聽服務

public class ServerManager {
 
    private Server jetty;
 
    /**
     * 建立jetty物件
     * @throws Exception
     */
    public ServerManager() throws Exception {
 
        int port = NetPortUtils.getAvailablePort();
 
        jetty = new Server(port);
 
        ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
        context.setContextPath("/");
        context.addServlet(ClientServlet.class, "/caffeine");
        jetty.setHandler(context);
    }
 
    /**
     * 啟動jetty物件
     * @throws Exception
     */
    public void start() throws Exception {
        jetty.start();
    }
}
 
 
public class ClientServlet extends HttpServlet {
 
    private static final Logger logger = LoggerFactory.getLogger(ClientServlet.class);
 
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        super.doGet(req, resp);
    }
 
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
 
        ExecutionResponse executionResponse = null;
        String requestJson = null;
        try {
            // 獲取請求的相關的引數
            String contextPath = req.getContextPath();
            String servletPath = req.getServletPath();
            String requestUri = req.getRequestURI();
            requestJson = IOUtils.toString(req.getInputStream(), StandardCharsets.UTF_8);
 
            // 處理不同的命令
            ExecutionRequest executionRequest = JSON.parseObject(requestJson, ExecutionRequest.class);
 
            // 透過反射來來處理類依賴問題
            executionResponse = DisposeCenter.dispatch(executionRequest);
 
        } catch (Exception e) {
            logger.error("vivo-memory 處理請求異常 {} ", requestJson, e);
        }
 
        if (null == executionResponse) {
            executionResponse = new ExecutionResponse();
            executionResponse.setCode(-1);
            executionResponse.setMsg("處理異常");
        }
 
        // 組裝相應報文
        resp.setContentType("application/json; charset=utf-8");
        PrintWriter out = resp.getWriter();
        out.println(JSON.toJSONString(executionResponse));
        out.flush();
    }
}

說明:

  • 通訊層透過jetty啟動http服務進行監聽,安全考慮埠不對外開放。

  • 透過定義ClientServlet來處理相關的請求包括配置下發和快取查詢等功能。

通訊層-心跳設計

/**
 * 傳送心跳資料
 */
public static void sendHeartBeatData() {
 
    try {
 
        if (!VivoConfigManager.getBoolean("memory.caffeine.heart.report.switch", true)) {
            return;
        }
 
        // 1、構建心跳資料
        String appName = System.getProperty("app.name");
        String localIp = getLocalIp();
        String localPort = String.valueOf(NetPortUtils.getWorkPort());
 
        HeartBeatData heartBeatData = new HeartBeatData();
        heartBeatData.setAppName(appName);
        heartBeatData.setIp(localIp);
        heartBeatData.setPort(localPort);
        heartBeatData.setTimeStamp(System.currentTimeMillis()/1000);
 
        // 2、傳送Http的POST請求
        HttpPost httpPost = new HttpPost(getHeartBeatUrl());
        httpPost.setConfig(requestConfig);
 
        StringEntity stringEntity = new StringEntity(JSON.toJSONString(heartBeatData));
        stringEntity.setContentType("application/json");
        httpPost.setEntity(stringEntity);
 
        HttpResponse response = httpClient.execute(httpPost);
        String result = EntityUtils.toString(response.getEntity(),"UTF-8");
        EntityUtils.consume(response.getEntity());
 
        logger.info("Caffeine 心跳上報成功 URL {} 引數 {} 結果 {}", getHeartBeatUrl(), JSON.toJSONString(heartBeatData), result);
    } catch (Throwable throwable) {
        logger.error("Caffeine 心跳上報失敗 URL {} ", getHeartBeatUrl(), throwable);
    }
}

說明:

  • 心跳功能上報專案例項的ip和埠用來通訊,攜帶時間戳用來記錄上報時間戳。

  • 實際專案中因為機器的回收等場景需要透過上報時間戳定時清理下線的服務。

五、總結

vivo技術團隊在Caffeine的使用經驗上曾有過多次分享,可參考公眾號文章《如何把 Caffeine Cache 用得如絲般順滑》,此篇文章在使用的基礎上基於使用痛點進行進一步的定製。

目前Caffeine視覺化的專案已經在相關核心業務場景中落地併發揮作用,整體執行平穩。使用較多的功能包括專案維度的caffeine例項的全域性管控,單例項維度的記憶體佔用評估和快取命中趨勢評估。

如透過單例項的記憶體佔用評估功能能夠合理評估快取條目設定和記憶體佔用之間的關係;透過分析快取命中率的整體趨勢評估快取的引數設定合理性。

期待此篇文章能夠給業界快取使用和監控帶來一些新思路。

相關文章