緩存監(jiān)控治理在游戲業(yè)務的實踐和探索
通過對 Redis 和 Caffeine 的緩存監(jiān)控快速發(fā)現(xiàn)和定位問題降低故障的影響面。

一、緩存監(jiān)控的背景
- 游戲業(yè)務中存在大量的高頻請求尤其是對熱門游戲而言,而應對高并發(fā)場景緩存是一個常見且有效的手段。
- 游戲業(yè)務中大量的采用遠程緩存(Redis)和本地緩存(Caffeine)組合方式來應對大流量的場景。
- 在整個緩存使用的實踐過程中,基于真實線上案例和日常緩存運維痛點沉淀了一些緩存監(jiān)控治理的有效案例供分享。
二、遠程緩存的監(jiān)控介紹
2.1 監(jiān)控的方案
2.1.1 監(jiān)控目的
- 從宏觀來講監(jiān)控本質(zhì)目的是為了及時發(fā)現(xiàn)定位并解決問題,在成本可控的前提下監(jiān)控維度盡可能豐富。
- 聚焦到 Redis 的維度,除了 Server 本身的監(jiān)控指標(如請求量、連接數(shù)外),還需要監(jiān)控更多偏業(yè)務的指標。
- Redis 目前最常見的問題包括:熱點 Key 問題,大 Key 問題,超負載的大請求量問題。
- 聚焦上述的問題,在基于 Redis 的原生監(jiān)控指標基礎上,補充更多的包含業(yè)務屬性的監(jiān)控。
2.1.2 監(jiān)控方案
- 目前從監(jiān)控的維度進行分析,希望能做到既能針對某個 key 的熱點監(jiān)控,又能針對某一類相同前綴的 key 做聚合趨勢監(jiān)控。前者目的是發(fā)現(xiàn)熱點 key,后者目的是從趨勢維度監(jiān)控緩存的實際訪問量。
- Redis 的具體 key的監(jiān)控由 Redis 自研團隊集成到 redis server 側實現(xiàn)監(jiān)控,分類上歸屬為 Redis Server 側的監(jiān)控,這部分不在本篇分享中具體展開。
- Redis 的某類相同前綴的 key 的聚合監(jiān)控由業(yè)務側通過Aspect 攔截器攔截并上報埋點實現(xiàn),其中 key 的設計需要遵循便于聚合的原則,分類上歸屬為 Redis 的業(yè)務側的監(jiān)控。
2.1.3 監(jiān)控大盤

【Redis Server系統(tǒng)監(jiān)控指標】
說明:
- 上圖監(jiān)控 Redis Server 的原生指標,具體可以參考 Redis 官方文檔
http://doc.redisfans.com/server/info.html。 - 上述指標用來評估 Redis Server 本身的負載情況,并基于此考慮是否需要橫向和縱向擴容。

【Redis 業(yè)務維度前綴監(jiān)控指標】
說明:
- 上圖監(jiān)控的業(yè)務維度按照某類 key 的前綴進行聚合的指標,評估各類的 Redis 的 key 的讀寫指標。
- 上述指標用來評估業(yè)務對 Redis 緩存使用的合理性,如發(fā)現(xiàn)某個前綴 key 的寫入量太大(緩存應該是讀多寫少場景)就需要思考緩存設計的合理性。
2.2 監(jiān)控的實現(xiàn)
- 業(yè)務維度按照某類 key 的前綴進行聚合的功能,關鍵的實現(xiàn)邏輯包括:一類業(yè)務需要統(tǒng)一前綴 key 并在末尾拼接變量;通過切面攔截 redis 的讀寫并上報埋點。
- 統(tǒng)一前綴 key 是指:如果業(yè)務A是按照用戶維度進行緩存 Key 的設計,那么 Key 的形態(tài)應該是 Prefix:UserId,Prefix 是業(yè)務場景的前綴,UserId 是用戶維度的動態(tài)值。
- 切面攔截是指:針對指定的Redis操作(包括常見的 Set 等),進行攔截并匹配前綴進行埋點上報。
2.2.1 前綴 key 設計
Redis Key 的設計
public class RedisKeyConstants {
public static final String REDIS_GAMEGROUP_NEW_KEY = "newgamegroup";
public static final String REDIS_GAMEGROUP_DETAIL_KEY = "gamegroup:detail";
public static final String REDIS_KEY_IUNIT_STRATEGY_COUNT = "activity:ihandler:strategy:count";
public static final String CONTENT_DISTRIBUTE_CURRENT = "content:distribute:current";
public static final String RECOMMEND_NOTE = "recommend:note";
}
public class RedisUtils {
public static final String COMMON_REDIS_KEY_SPLIT = ":";
public static String buildRedisKey(String key, Object... params) {
if (params == null || params.length == 0) {
return key;
}
for (Object param : params) {
key += COMMON_REDIS_KEY_SPLIT + param;
}
return key;
}
}左右滑動查看完整代碼
說明:
- 在常量定義 RedisKeyConstants 中按照不同的業(yè)務區(qū)分了不同的業(yè)務場景的前綴 Key。
- 在 RedisUtils#buildRedisKey 中將業(yè)務的前綴和動態(tài)變化的參數(shù)進行拼接,中間通過分隔符進行連接。
- 分割符的引入是為了后續(xù)切面攔截時候進行逆向切割獲取前綴使用。
2.2.2 監(jiān)控實現(xiàn)
@Slf4j
@Aspect
@Order(0)
@Component
public class RedisMonitorAspect {
private static final String PREFIX_CONFIG = "redis.monitor.prefix";
private static final Set<String> PREFIX_SET = new HashSet<>();
@Resource
private MonitorComponent monitorComponent;
static {
// 更新前綴匹配的名單
String prefixValue = VivoConfigManager.getString(PREFIX_CONFIG, "");
refreshConf(prefixValue);
// 增加配置變更的回調(diào)
VivoConfigManager.addListener(new VivoConfigListener() {
@Override
public void eventReceived(PropertyItem propertyItem, ChangeEventType changeEventType) {
if (StringUtils.equalsIgnoreCase(propertyItem.getName(), PREFIX_CONFIG)) {
refreshConf(propertyItem.getValue());
}
}
});
}
/**
* 更新前綴匹配的名單
* @param prefixValue
*/
private static void refreshConf(String prefixValue) {
if (StringUtils.isNotEmpty(prefixValue)) {
String[] prefixArr = StringUtils.split(prefixValue, ",");
Arrays.stream(prefixArr).forEach(item -> PREFIX_SET.add(item));
}
}
@Pointcut("execution(* com.vivo.joint.dal.common.redis.dao.RedisDao.set*(..))")
public void point() {
}
@Around("point()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
//業(yè)務邏輯異常情況直接拋到業(yè)務層處理
Object result = pjp.proceed();
try {
if (VivoConfigManager.getBoolean("joint.center.redis.monitor.switch", true)) {
Object[] args = pjp.getArgs();
if (null != args && args.length > 0) {
String redisKey = String.valueOf(args[0]);
if (VivoConfigManager.getBoolean("joint.center.redis.monitor.send.log.switch", true)) {
LOGGER.info("更新redis的緩存 {}", redisKey);
}
String monitorKey = null;
// 先指定前綴匹配
if (!PREFIX_SET.isEmpty()) {
for (String prefix : PREFIX_SET) {
if (StringUtils.startsWithIgnoreCase(redisKey, prefix)) {
monitorKey = prefix;
break;
}
}
}
if (StringUtils.isEmpty(monitorKey) && StringUtils.contains(redisKey, ":")) {
// 需要考慮前綴的格式,保證數(shù)據(jù)寫入不能膨脹
monitorKey = StringUtils.substringBeforeLast(redisKey, ":");
}
monitorComponent.sendRedisMonitorData(monitorKey);
}
}
} catch (Exception e) {
}
return result;
}
}printf("hello world!");說明:
- 通過 Aspect 的切面功能對 Redis 的指定操作進行攔截,如上圖中的 Set 操作等,可以按需擴展到其他操作(包括 get 命令等)。
- 針對前綴 key 的提取支持兩個維度,默認場景和自定義場景,其中處理優(yōu)先級為 自定義場景 > 默認場景
- 默認場景是指如 Redis 的 Key 為 A:B:C:UserId,從后往前尋找后向第一個分割符進行分割,A:B:C:UserId 分割后的根據(jù)前綴 A:B:C 進行聚合后數(shù)據(jù)埋點上報。
- 自定義場景如 Redis的 Key 為 A:B:UserId,通過配置自定義的前綴 A:B 來匹配,A:B:C:UserId 根據(jù)自定義的前綴分割后根據(jù)前綴 A:B 進行聚合后數(shù)據(jù)埋點上報。
- 考慮自定義場景的靈活性,相關的自定義前綴通過配置中心實時生效。
2.3 監(jiān)控的案例
public static final String REDISKEY_USER_POPUP_PLAN = "popup:user:plan";
public PopupWindowPlan findPlan(FindPlanParam param) {
String openId = param.getOpenId();
String imei = param.getImei();
String gamePackage = param.getGamePackage();
Integer planType = param.getPlanType();
String appId = param.getAppId();
// 1、獲取緩存的數(shù)據(jù)
PopupWindowPlan cachedPlan = getPlanFromCache(openId, imei, gamePackage, planType);
if (cachedPlan != null) {
monitorPopWinPlan(cachedPlan);
return cachedPlan;
}
// 2、未命中換成后從持久化部分獲取對應的 PopupWindowPlan 對象
// 3、保存到Redis換成
setPlanToCache(openId, imei, gamePackage, plan);
return cachedPlan;
}
// 從緩存中獲取數(shù)據(jù)的邏輯
private PopupWindowPlan getPlanFromCache(String openId, String imei, String gamePackage, Integer planType) {
String key = RedisUtils.buildRedisKey(RedisKeyConstants.REDISKEY_USER_POPUP_PLAN, openId, imei, gamePackage, planType);
String cacheValue = redisDao.get(key);
if (StringUtils.isEmpty(cacheValue)) {
return null;
}
try {
PopupWindowPlan plan = objectMapper.readValue(cacheValue, PopupWindowPlan.class);
return plan;
} catch (Exception e) {
}
return null;
}
// 保存數(shù)據(jù)到緩存當中
private void setPlanToCache(String openId, String imei, String gamePackage, PopupWindowPlan plan, Integer planType) {
String key = RedisUtils.buildRedisKey(RedisKeyConstants.REDISKEY_USER_POPUP_PLAN, openId, imei, gamePackage, planType);
try {
String serializedStr = objectMapper.writeValueAsString(plan);
redisDao.set(key, serializedStr, VivoConfigManager.getInteger(ConfigConstants.POPUP_PLAN_CACHE_EXPIRE_TIME, 300));
} catch (Exception e) {
}
}左右滑動查看完整代碼
說明:
- 如監(jiān)控實現(xiàn)部分所述,通過 Redis Key 的前綴聚合監(jiān)控,能夠發(fā)現(xiàn)某一類業(yè)務場景的 Redis 的寫請求數(shù),進而發(fā)現(xiàn) Redis 的無效使用場景。
- 上述案例是典型的Redis的緩存使用場景:1.訪問 Redis 緩存;2.若命中則直接返回結果;3、如未命中則查詢持久化存儲獲取數(shù)據(jù)并寫入 Redis 緩存。
- 從業(yè)務監(jiān)控的大盤發(fā)現(xiàn)前綴 popup:user:plan 存在大量的 set 操作命令,按照緩存讀多寫少的原則,該場景標明該緩存的設計是無效的。
- 通過業(yè)務分析后,發(fā)現(xiàn)在游戲的業(yè)務場景中 用戶維度+游戲維度 不存在5分鐘重復訪問緩存的場景,確認緩存的無效。
- 確認緩存無效后,刪除相關的緩存邏輯,降低了 Redis Server 的負載后并進一步提升了接口的響應時間。
三、本地緩存的監(jiān)控介紹
3.1 監(jiān)控的方案
3.1.1 監(jiān)控目的
- 從宏觀來講監(jiān)控本質(zhì)目的是為了及時發(fā)現(xiàn)定位并解決問題,在成本可控的前提下監(jiān)控維度盡可能豐富。
- 聚焦到 Caffeine 的維度,監(jiān)控指標包括緩存的請求次數(shù)、命中率,未命中率等指標。
- Caffeine 目前最常見的問題是:緩存設置不合理導致緩存穿透引發(fā)的系統(tǒng)問題。
3.1.2 監(jiān)控方案
- 目前從監(jiān)控的維度進行分析,按照機器維度+緩存實例進行監(jiān)控指標采集,其中監(jiān)控指標的采集基于 Caffeine 的 recordStats 功能開啟。
- 基于 caffeine 的原生能力定制的 vivo-caffeine 集成了單機器維度+單緩存實例的指標數(shù)據(jù)的采集和上報功能。
- vivo-caffeine 上報的數(shù)據(jù)會按照單機器+單緩存實例維度進行大盤展示,支持全量指標的查詢功能。
- vivo-caffeine 的上報的數(shù)據(jù)和公司級的告警功能相結合,例如針對緩存未命中率進行監(jiān)控就能很快發(fā)現(xiàn)緩存穿透的問題。
3.1.3 監(jiān)控大盤


【Caffeine 系統(tǒng)監(jiān)控指標】
說明:
- vivo-caffeine 按照單機器 + 緩存實例維度進行監(jiān)控數(shù)據(jù)的上報并進行展示。
- 所有的系統(tǒng)指標都支持查詢并以圖片的形式進行展示。
3.2 監(jiān)控的實現(xiàn)
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;
}
}
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 的按緩存實例進行指標采集的前提是需要全局維護緩存實例和對應的 instanceName 之間的關聯(lián)關系。
- Caffeine 在緩存創(chuàng)建的時候會設置實例的名稱,通過 applyName 方法設置實例名稱。
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;
}左右滑動查看完整代碼
說明:
- 監(jiān)控指標的采集基于 Caffeine 原生的統(tǒng)計功能 CacheStats。
- 所有采集的指標封裝成一個統(tǒng)計對象 StatsData 進行上報。
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實例對象獲取緩存監(jiān)控數(shù)據(jù)
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、發(fā)送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 數(shù)據(jù)上報成功 URL {} 參數(shù) {} 結果 {}", getReportDataUrl(), JSON.toJSONString(reportData), result);
} catch (Throwable throwable) {
logger.error("Caffeine 數(shù)據(jù)上報失敗 URL {} ", getReportDataUrl(), throwable);
}
}左右滑動查看完整代碼
說明:
- 每個應用單獨的部署的服務作為一個采集點,進行指標的采集和上報。
- 采集過程是獲取當前部署的應用下的所有緩存實例并進行指標的采集封裝。
- 整體上報采用 Http 協(xié)議進行上報并最終展示到監(jiān)控平臺。
3.3 監(jiān)控的案例

說明:
- 某次線上問題發(fā)生時發(fā)現(xiàn)突然多出了大量的 Redis 的請求,但是無法具體定位請求的 Redis 的前綴 key,設想如果接入了 Redis 的業(yè)務監(jiān)控,問題來源就能很快定位。
- 在后續(xù)的問題排查中發(fā)現(xiàn)某個 Caffeine 的本地緩存因為大小設置過小導致大量的本地請求緩存穿透導致 Redis 的請求量突增,最終導致 Redis 的服務接近崩潰。
- 針對本地緩存穿透的場景,如果采用 Caffeine 的本地緩存監(jiān)控方案,能夠從緩存的命中率指標和緩存的未命中率指標突增突降中發(fā)現(xiàn)問題根源。
四、結束語
- 本篇內(nèi)容是基于線上真實案例分享游戲業(yè)務側在緩存監(jiān)控治理方面的有效實踐,監(jiān)控治理本身是一個未雨綢繆的過程。在沒有線上問題發(fā)生時看似不重要,但一旦發(fā)生無法快速定位問題又會導致問題的放大,因此完善的緩存監(jiān)控整理其實是非常有必要的。
- Redis 的前綴 key 的監(jiān)控思路是游戲業(yè)務服務端在優(yōu)化Redis 的使用效率的過程中發(fā)現(xiàn)的一個較好的實踐,逐步延伸后發(fā)現(xiàn)這是一個很好的監(jiān)控手段,能夠通過突增的趨勢快速定位問題。
- 基于 Caffeine 的原生能力定制的監(jiān)控指標采集是游戲業(yè)務服務端在探索 Caffeine 可視化過程中進行的一個探索落地,將整個緩存實例的運行態(tài)進行完整呈現(xiàn),為業(yè)務穩(wěn)定性貢獻力量。
- 相信業(yè)內(nèi)同仁會有更多更好的實踐,相互分享共同進步,共勉。
責任編輯:龐桂玉
來源:
vivo互聯(lián)網(wǎng)技術
































