緩存監(jiān)控治理在游戲業(yè)務(wù)的實(shí)踐和探索
一、緩存監(jiān)控的背景
- 游戲業(yè)務(wù)中存在大量的高頻請求尤其是對熱門游戲而言,而應(yīng)對高并發(fā)場景緩存是一個常見且有效的手段。
- 游戲業(yè)務(wù)中大量的采用遠(yuǎn)程緩存(Redis)和本地緩存(Caffeine)組合方式來應(yīng)對大流量的場景。
- 在整個緩存使用的實(shí)踐過程中,基于真實(shí)線上案例和日常緩存運(yùn)維痛點(diǎn)沉淀了一些緩存監(jiān)控治理的有效案例供分享。
二、遠(yuǎn)程緩存的監(jiān)控介紹
2.1 監(jiān)控的方案
2.1.1 監(jiān)控目的
- 從宏觀來講監(jiān)控本質(zhì)目的是為了及時發(fā)現(xiàn)定位并解決問題,在成本可控的前提下監(jiān)控維度盡可能豐富。
- 聚焦到 Redis 的維度,除了 Server 本身的監(jiān)控指標(biāo)(如請求量、連接數(shù)外),還需要監(jiān)控更多偏業(yè)務(wù)的指標(biāo)。
- Redis 目前最常見的問題包括:熱點(diǎn) Key 問題,大 Key 問題,超負(fù)載的大請求量問題。
- 聚焦上述的問題,在基于 Redis 的原生監(jiān)控指標(biāo)基礎(chǔ)上,補(bǔ)充更多的包含業(yè)務(wù)屬性的監(jiān)控。
2.1.2 監(jiān)控方案
- 目前從監(jiān)控的維度進(jìn)行分析,希望能做到既能針對某個 key 的熱點(diǎn)監(jiān)控,又能針對某一類相同前綴的 key 做聚合趨勢監(jiān)控。前者目的是發(fā)現(xiàn)熱點(diǎn) key,后者目的是從趨勢維度監(jiān)控緩存的實(shí)際訪問量。
- Redis 的具體 key的監(jiān)控由 Redis 自研團(tuán)隊(duì)集成到 redis server 側(cè)實(shí)現(xiàn)監(jiān)控,分類上歸屬為 Redis Server 側(cè)的監(jiān)控,這部分不在本篇分享中具體展開。
- Redis 的某類相同前綴的 key 的聚合監(jiān)控由業(yè)務(wù)側(cè)通過Aspect 攔截器攔截并上報(bào)埋點(diǎn)實(shí)現(xiàn),其中 key 的設(shè)計(jì)需要遵循便于聚合的原則,分類上歸屬為 Redis 的業(yè)務(wù)側(cè)的監(jiān)控。
2.1.3 監(jiān)控大盤
【Redis Server系統(tǒng)監(jiān)控指標(biāo)】
說明:
- 上圖監(jiān)控 Redis Server 的原生指標(biāo),具體可以參考 Redis 官方文檔
http://doc.redisfans.com/server/info.html。 - 上述指標(biāo)用來評估 Redis Server 本身的負(fù)載情況,并基于此考慮是否需要橫向和縱向擴(kuò)容。
【Redis 業(yè)務(wù)維度前綴監(jiān)控指標(biāo)】
說明:
- 上圖監(jiān)控的業(yè)務(wù)維度按照某類 key 的前綴進(jìn)行聚合的指標(biāo),評估各類的 Redis 的 key 的讀寫指標(biāo)。
- 上述指標(biāo)用來評估業(yè)務(wù)對 Redis 緩存使用的合理性,如發(fā)現(xiàn)某個前綴 key 的寫入量太大(緩存應(yīng)該是讀多寫少場景)就需要思考緩存設(shè)計(jì)的合理性。
2.2 監(jiān)控的實(shí)現(xiàn)
- 業(yè)務(wù)維度按照某類 key 的前綴進(jìn)行聚合的功能,關(guān)鍵的實(shí)現(xiàn)邏輯包括:一類業(yè)務(wù)需要統(tǒng)一前綴 key 并在末尾拼接變量;通過切面攔截 redis 的讀寫并上報(bào)埋點(diǎn)。
- 統(tǒng)一前綴 key 是指:如果業(yè)務(wù)A是按照用戶維度進(jìn)行緩存 Key 的設(shè)計(jì),那么 Key 的形態(tài)應(yīng)該是 Prefix:UserId,Prefix 是業(yè)務(wù)場景的前綴,UserId 是用戶維度的動態(tài)值。
- 切面攔截是指:針對指定的Redis操作(包括常見的 Set 等),進(jìn)行攔截并匹配前綴進(jìn)行埋點(diǎn)上報(bào)。
2.2.1 前綴 key 設(shè)計(jì)
Redis Key 的設(shè)計(jì)
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è)務(wù)區(qū)分了不同的業(yè)務(wù)場景的前綴 Key。
- 在 RedisUtils#buildRedisKey 中將業(yè)務(wù)的前綴和動態(tài)變化的參數(shù)進(jìn)行拼接,中間通過分隔符進(jìn)行連接。
- 分割符的引入是為了后續(xù)切面攔截時候進(jìn)行逆向切割獲取前綴使用。
2.2.2 監(jiān)控實(shí)現(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è)務(wù)邏輯異常情況直接拋到業(yè)務(wù)層處理
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 的指定操作進(jìn)行攔截,如上圖中的 Set 操作等,可以按需擴(kuò)展到其他操作(包括 get 命令等)。
- 針對前綴 key 的提取支持兩個維度,默認(rèn)場景和自定義場景,其中處理優(yōu)先級為 自定義場景 > 默認(rèn)場景
- 默認(rèn)場景是指如 Redis 的 Key 為 A:B:C:UserId,從后往前尋找后向第一個分割符進(jìn)行分割,A:B:C:UserId 分割后的根據(jù)前綴 A:B:C 進(jìn)行聚合后數(shù)據(jù)埋點(diǎn)上報(bào)。
- 自定義場景如 Redis的 Key 為 A:B:UserId,通過配置自定義的前綴 A:B 來匹配,A:B:C:UserId 根據(jù)自定義的前綴分割后根據(jù)前綴 A:B 進(jìn)行聚合后數(shù)據(jù)埋點(diǎn)上報(bào)。
- 考慮自定義場景的靈活性,相關(guān)的自定義前綴通過配置中心實(shí)時生效。
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、未命中換成后從持久化部分獲取對應(yīng)的 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ù)到緩存當(dāng)中
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)控實(shí)現(xiàn)部分所述,通過 Redis Key 的前綴聚合監(jiān)控,能夠發(fā)現(xiàn)某一類業(yè)務(wù)場景的 Redis 的寫請求數(shù),進(jìn)而發(fā)現(xiàn) Redis 的無效使用場景。
- 上述案例是典型的Redis的緩存使用場景:1.訪問 Redis 緩存;2.若命中則直接返回結(jié)果;3、如未命中則查詢持久化存儲獲取數(shù)據(jù)并寫入 Redis 緩存。
- 從業(yè)務(wù)監(jiān)控的大盤發(fā)現(xiàn)前綴 popup:user:plan 存在大量的 set 操作命令,按照緩存讀多寫少的原則,該場景標(biāo)明該緩存的設(shè)計(jì)是無效的。
- 通過業(yè)務(wù)分析后,發(fā)現(xiàn)在游戲的業(yè)務(wù)場景中 用戶維度+游戲維度 不存在5分鐘重復(fù)訪問緩存的場景,確認(rèn)緩存的無效。
- 確認(rèn)緩存無效后,刪除相關(guān)的緩存邏輯,降低了 Redis Server 的負(fù)載后并進(jìn)一步提升了接口的響應(yīng)時間。
三、本地緩存的監(jiān)控介紹
3.1 監(jiān)控的方案
3.1.1 監(jiān)控目的
- 從宏觀來講監(jiān)控本質(zhì)目的是為了及時發(fā)現(xiàn)定位并解決問題,在成本可控的前提下監(jiān)控維度盡可能豐富。
- 聚焦到 Caffeine 的維度,監(jiān)控指標(biāo)包括緩存的請求次數(shù)、命中率,未命中率等指標(biāo)。
- Caffeine 目前最常見的問題是:緩存設(shè)置不合理導(dǎo)致緩存穿透引發(fā)的系統(tǒng)問題。
3.1.2 監(jiān)控方案
- 目前從監(jiān)控的維度進(jìn)行分析,按照機(jī)器維度+緩存實(shí)例進(jìn)行監(jiān)控指標(biāo)采集,其中監(jiān)控指標(biāo)的采集基于 Caffeine 的 recordStats 功能開啟。
- 基于 caffeine 的原生能力定制的 vivo-caffeine 集成了單機(jī)器維度+單緩存實(shí)例的指標(biāo)數(shù)據(jù)的采集和上報(bào)功能。
- vivo-caffeine 上報(bào)的數(shù)據(jù)會按照單機(jī)器+單緩存實(shí)例維度進(jìn)行大盤展示,支持全量指標(biāo)的查詢功能。
- vivo-caffeine 的上報(bào)的數(shù)據(jù)和公司級的告警功能相結(jié)合,例如針對緩存未命中率進(jìn)行監(jiān)控就能很快發(fā)現(xiàn)緩存穿透的問題。
3.1.3 監(jiān)控大盤
【Caffeine 系統(tǒng)監(jiān)控指標(biāo)】
說明:
- vivo-caffeine 按照單機(jī)器 + 緩存實(shí)例維度進(jìn)行監(jiān)控?cái)?shù)據(jù)的上報(bào)并進(jìn)行展示。
- 所有的系統(tǒng)指標(biāo)都支持查詢并以圖片的形式進(jìn)行展示。
3.2 監(jiān)控的實(shí)現(xiàn)
public final class Caffeine<K, V> {
/**
* caffeine的實(shí)例名稱
*/
String instanceName;
/**
* caffeine的實(shí)例維護(hù)的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 的按緩存實(shí)例進(jìn)行指標(biāo)采集的前提是需要全局維護(hù)緩存實(shí)例和對應(yīng)的 instanceName 之間的關(guān)聯(lián)關(guān)系。
- Caffeine 在緩存創(chuàng)建的時候會設(shè)置實(shí)例的名稱,通過 applyName 方法設(shè)置實(shí)例名稱。
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)控指標(biāo)的采集基于 Caffeine 原生的統(tǒng)計(jì)功能 CacheStats。
- 所有采集的指標(biāo)封裝成一個統(tǒng)計(jì)對象 StatsData 進(jìn)行上報(bào)。
public static void sendReportData() {
try {
if (!VivoConfigManager.getBoolean("memory.caffeine.data.report.switch", true)) {
return;
}
// 1、獲取所有的cache實(shí)例對象
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實(shí)例對象獲取緩存監(jiān)控?cái)?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、構(gòu)建上報(bào)對象
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ù)上報(bào)成功 URL {} 參數(shù) {} 結(jié)果 {}", getReportDataUrl(), JSON.toJSONString(reportData), result);
} catch (Throwable throwable) {
logger.error("Caffeine 數(shù)據(jù)上報(bào)失敗 URL {} ", getReportDataUrl(), throwable);
}
}
左右滑動查看完整代碼
說明:
- 每個應(yīng)用單獨(dú)的部署的服務(wù)作為一個采集點(diǎn),進(jìn)行指標(biāo)的采集和上報(bào)。
- 采集過程是獲取當(dāng)前部署的應(yīng)用下的所有緩存實(shí)例并進(jìn)行指標(biāo)的采集封裝。
- 整體上報(bào)采用 Http 協(xié)議進(jìn)行上報(bào)并最終展示到監(jiān)控平臺。
3.3 監(jiān)控的案例
說明:
- 某次線上問題發(fā)生時發(fā)現(xiàn)突然多出了大量的 Redis 的請求,但是無法具體定位請求的 Redis 的前綴 key,設(shè)想如果接入了 Redis 的業(yè)務(wù)監(jiān)控,問題來源就能很快定位。
- 在后續(xù)的問題排查中發(fā)現(xiàn)某個 Caffeine 的本地緩存因?yàn)榇笮≡O(shè)置過小導(dǎo)致大量的本地請求緩存穿透導(dǎo)致 Redis 的請求量突增,最終導(dǎo)致 Redis 的服務(wù)接近崩潰。
- 針對本地緩存穿透的場景,如果采用 Caffeine 的本地緩存監(jiān)控方案,能夠從緩存的命中率指標(biāo)和緩存的未命中率指標(biāo)突增突降中發(fā)現(xiàn)問題根源。
四、結(jié)束語
- 本篇內(nèi)容是基于線上真實(shí)案例分享游戲業(yè)務(wù)側(cè)在緩存監(jiān)控治理方面的有效實(shí)踐,監(jiān)控治理本身是一個未雨綢繆的過程。在沒有線上問題發(fā)生時看似不重要,但一旦發(fā)生無法快速定位問題又會導(dǎo)致問題的放大,因此完善的緩存監(jiān)控整理其實(shí)是非常有必要的。
- Redis 的前綴 key 的監(jiān)控思路是游戲業(yè)務(wù)服務(wù)端在優(yōu)化Redis 的使用效率的過程中發(fā)現(xiàn)的一個較好的實(shí)踐,逐步延伸后發(fā)現(xiàn)這是一個很好的監(jiān)控手段,能夠通過突增的趨勢快速定位問題。
- 基于 Caffeine 的原生能力定制的監(jiān)控指標(biāo)采集是游戲業(yè)務(wù)服務(wù)端在探索 Caffeine 可視化過程中進(jìn)行的一個探索落地,將整個緩存實(shí)例的運(yùn)行態(tài)進(jìn)行完整呈現(xiàn),為業(yè)務(wù)穩(wěn)定性貢獻(xiàn)力量。
- 相信業(yè)內(nèi)同仁會有更多更好的實(shí)踐,相互分享共同進(jìn)步,共勉。