缓存监控治理在游戏业务的实践和探索

打印 上一主题 下一主题

主题 1003|帖子 1003|积分 3009

作者:来自 vivo 互联网服务器团队- Wang Zhi
 
通过对 Redis 和 Caffeine 的缓存监控快速发现和定位问题降低故障的影响面。
一、缓存监控的背景


  • 游戏业务中存在大量的高频请求尤其是对热门游戏而言,而应对高并发场景缓存是一个常见且有效的本领。
  • 游戏业务中大量的采用远程缓存(Redis)和本地缓存(Caffeine)组合方式来应对大流量的场景。
  • 在整个缓存使用的实践过程中,基于真实线上案例和一样平常缓存运维痛点沉淀了一些缓存监控治理的有效案例供分享。
二、远程缓存的监控介绍

2.1 监控的方案

2.1.1 监控目的


  • 从宏观来讲监控本质目的是为了及时发现定位并办理问题,在本钱可控的前提下监控维度尽大概丰富。
  • 聚焦到 Redis 的维度,除了 Server 本身的监控指标(如请求量、毗连数外),还必要监控更多偏业务的指标。
  • Redis 目前最常见的问题包罗:热门 Key 问题,大 Key 问题,超负载的大请求量问题。
  • 聚焦上述的问题,在基于 Redis 的原生监控指标底子上,补充更多的包罗业务属性的监控。
2.1.2 监控方案


  • 目前从监控的维度进行分析,希望能做到既能针对某个 key 的热门监控,又能针对某一类相同前缀的 key 做聚合趋势监控。前者目的是发现热门 key,后者目的是从趋势维度监控缓存的实际访问量。
  • Redis 的具体 key的监控由 Redis 自研团队集成到 redis server 侧实现监控,分类上归属为 Redis Server 侧的监控,这部分不在本篇分享中具体展开。
  • Redis 的某类相同前缀的 key 的聚合监控由业务侧通过Aspect 拦截器拦截并上报埋点实现,其中 key 的计划必要遵照便于聚合的原则,分类上归属为 Redis 的业务侧的监控。
2.1.3 监控大盘


【Redis Server系统监控指标】
说明:

  • 上图监控 Redis Server 的原生指标,具体可以参考 Redis 官方文档 
http://doc.redisfans.com/server/info.html

  • 上述指标用来评估 Redis Server 本身的负载情况,并基于此考虑是否必要横向和纵向扩容。
 

【Redis 业务维度前缀监控指标】
说明:

  • 上图监控的业务维度按照某类 key 的前缀进行聚合的指标,评估各类的 Redis 的 key 的读写指标。
  • 上述指标用来评估业务对 Redis 缓存使用的公道性,如发现某个前缀 key 的写入量太大(缓存应该是读多写少场景)就必要思考缓存计划的公道性。
2.2 监控的实现

 

  • 业务维度按照某类 key 的前缀进行聚合的功能,关键的实现逻辑包罗:一类业务必要同一前缀 key 并在末尾拼接变量;通过切面拦截 redis 的读写并上报埋点。
  • 同一前缀 key 是指:如果业务A是按照用户维度进行缓存 Key 的计划,那么 Key 的形态应该是 Prefix:UserId,Prefix 是业务场景的前缀,UserId 是用户维度的动态值。
  • 切面拦截是指:针对指定的Redis操作(包罗常见的 Set 等),进行拦截并匹配前缀进行埋点上报。
2.2.1 前缀 key 计划

Redis Key 的计划
 
  1. public class RedisKeyConstants {
  2.     public static final String    REDIS_GAMEGROUP_NEW_KEY              = "newgamegroup";
  3.     public static final String    REDIS_GAMEGROUP_DETAIL_KEY          = "gamegroup:detail";
  4.     public static final String    REDIS_KEY_IUNIT_STRATEGY_COUNT      = "activity:ihandler:strategy:count";
  5.     public static final String    CONTENT_DISTRIBUTE_CURRENT          = "content:distribute:current";
  6.     public static final String    RECOMMEND_NOTE                      = "recommend:note";
  7. }
  8. public class RedisUtils {
  9.     public static final String    COMMON_REDIS_KEY_SPLIT    = ":";
  10.     public static String buildRedisKey(String key, Object... params) {
  11.         if (params == null || params.length == 0) {
  12.             return key;
  13.         }
  14.         for (Object param : params) {
  15.             key += COMMON_REDIS_KEY_SPLIT + param;
  16.         }
  17.         return key;
  18.     }
  19. }
复制代码
 
说明:

  • 在常量界说 RedisKeyConstants 中按照差别的业务区分了差别的业务场景的前缀 Key。
  • 在 RedisUtils#buildRedisKey 中将业务的前缀和动态变化的参数进行拼接,中间通太过隔符进行毗连。
  • 分割符的引入是为了后续切面拦截时候进行逆向切割获取前缀使用。
2.2.2 监控实现

 
  1. @Slf4j
  2. @Aspect
  3. @Order(0)
  4. @Component
  5. public class RedisMonitorAspect {
  6.     private static final String PREFIX_CONFIG = "redis.monitor.prefix";
  7.     private static final Set<String> PREFIX_SET = new HashSet<>();
  8.     @Resource
  9.     private MonitorComponent monitorComponent;
  10.     static {
  11.         // 更新前缀匹配的名单
  12.         String prefixValue = VivoConfigManager.getString(PREFIX_CONFIG, "");
  13.         refreshConf(prefixValue);
  14.         // 增加配置变更的回调
  15.         VivoConfigManager.addListener(new VivoConfigListener() {
  16.             @Override
  17.             public void eventReceived(PropertyItem propertyItem, ChangeEventType changeEventType) {
  18.                 if (StringUtils.equalsIgnoreCase(propertyItem.getName(), PREFIX_CONFIG)) {
  19.                     refreshConf(propertyItem.getValue());
  20.                 }
  21.             }
  22.         });
  23.     }
  24.     /**
  25.      * 更新前缀匹配的名单
  26.      * @param prefixValue
  27.      */
  28.     private static void refreshConf(String prefixValue) {
  29.         if (StringUtils.isNotEmpty(prefixValue)) {
  30.             String[] prefixArr = StringUtils.split(prefixValue, ",");
  31.             Arrays.stream(prefixArr).forEach(item -> PREFIX_SET.add(item));
  32.         }
  33.     }
  34.     @Pointcut("execution(* com.vivo.joint.dal.common.redis.dao.RedisDao.set*(..))")
  35.     public void point() {
  36.     }
  37.     @Around("point()")
  38.     public Object around(ProceedingJoinPoint pjp) throws Throwable {
  39.         //业务逻辑异常情况直接抛到业务层处理
  40.         Object result = pjp.proceed();
  41.         try {
  42.             if (VivoConfigManager.getBoolean("joint.center.redis.monitor.switch", true)) {
  43.                 Object[] args = pjp.getArgs();
  44.                 if (null != args && args.length > 0) {
  45.                     String redisKey = String.valueOf(args[0]);
  46.                     if (VivoConfigManager.getBoolean("joint.center.redis.monitor.send.log.switch", true)) {
  47.                         LOGGER.info("更新redis的缓存 {}", redisKey);
  48.                     }
  49.                     String monitorKey = null;
  50.                     // 先指定前缀匹配
  51.                     if (!PREFIX_SET.isEmpty()) {
  52.                         for (String prefix : PREFIX_SET) {
  53.                             if (StringUtils.startsWithIgnoreCase(redisKey, prefix)) {
  54.                                 monitorKey = prefix;
  55.                                 break;
  56.                             }
  57.                         }
  58.                     }
  59.                     if (StringUtils.isEmpty(monitorKey) && StringUtils.contains(redisKey, ":")) {
  60.                         // 需要考虑前缀的格式,保证数据写入不能膨胀
  61.                         monitorKey = StringUtils.substringBeforeLast(redisKey, ":");
  62.                     }
  63.                     monitorComponent.sendRedisMonitorData(monitorKey);
  64.                 }
  65.             }
  66.         } catch (Exception e) {
  67.         }
  68.         return result;
  69.     }
  70. }
复制代码
 
  1. printf("hello world!");
复制代码
说明:

  • 通过 Aspect 的切面功能对 Redis 的指定操作进行拦截,如上图中的 Set 操作等,可以按需扩展到其他操作(包罗 get 命令等)。
  • 针对前缀 key 的提取支持两个维度,默认场景和自界说场景,其中处置惩罚优先级为 自界说场景 > 默认场景
  • 默认场景是指如 Redis 的 Key 为 A:B:C:UserId,从后往前探求后向第一个分割符进行分割,A:B:C:UserId 分割后的根据前缀 A:B:C 进行聚合后数据埋点上报。
  • 自界说场景如 Redis的 Key 为 A:B:UserId,通过配置自界说的前缀 A:B 来匹配,A:B:C:UserId 根据自界说的前缀分割后根据前缀 A:B 进行聚合后数据埋点上报。
  • 考虑自界说场景的灵活性,相关的自界说前缀通过配置中心实时生效。
2.3 监控的案例
  1. public static final String REDISKEY_USER_POPUP_PLAN = "popup:user:plan";
  2.   
  3.     public PopupWindowPlan findPlan(FindPlanParam param) {
  4.         String openId = param.getOpenId();
  5.         String imei = param.getImei();
  6.         String gamePackage = param.getGamePackage();
  7.         Integer planType = param.getPlanType();
  8.         String appId = param.getAppId();
  9.         // 1、获取缓存的数据
  10.         PopupWindowPlan cachedPlan = getPlanFromCache(openId, imei, gamePackage, planType);
  11.         if (cachedPlan != null) {
  12.             monitorPopWinPlan(cachedPlan);
  13.         
  14.             return cachedPlan;
  15.         }
  16.         // 2、未命中换成后从持久化部分获取对应的 PopupWindowPlan 对象
  17.         // 3、保存到Redis换成
  18.       setPlanToCache(openId, imei, gamePackage, plan);
  19.         return cachedPlan;
  20.     }
  21.     // 从缓存中获取数据的逻辑
  22.     private PopupWindowPlan getPlanFromCache(String openId, String imei, String gamePackage, Integer planType) {
  23.         String key = RedisUtils.buildRedisKey(RedisKeyConstants.REDISKEY_USER_POPUP_PLAN, openId, imei, gamePackage, planType);
  24.         String cacheValue = redisDao.get(key);
  25.         if (StringUtils.isEmpty(cacheValue)) {
  26.             return null;
  27.         }
  28.         try {
  29.             PopupWindowPlan plan = objectMapper.readValue(cacheValue, PopupWindowPlan.class);
  30.             return plan;
  31.         } catch (Exception e) {
  32.         }
  33.         return null;
  34.     }
  35.     // 保存数据到缓存当中
  36.     private void setPlanToCache(String openId, String imei, String gamePackage, PopupWindowPlan plan, Integer planType) {
  37.         String key = RedisUtils.buildRedisKey(RedisKeyConstants.REDISKEY_USER_POPUP_PLAN, openId, imei, gamePackage, planType);
  38.         try {
  39.             String serializedStr = objectMapper.writeValueAsString(plan);
  40.             redisDao.set(key, serializedStr, VivoConfigManager.getInteger(ConfigConstants.POPUP_PLAN_CACHE_EXPIRE_TIME, 300));
  41.         } catch (Exception e) {
  42.         }
  43.     }
复制代码
说明:

  • 如监控实现部分所述,通过 Redis Key 的前缀聚合监控,能够发现某一类业务场景的 Redis 的写请求数,进而发现 Redis 的无效使用场景。
  • 上述案例是典型的Redis的缓存使用场景:1.访问 Redis 缓存;2.若命中则直接返回效果;3、如未命中则查询持久化存储获取数据并写入 Redis 缓存。
  • 从业务监控的大盘发现前缀 popup:user:plan 存在大量的 set 操作命令,按照缓存读多写少的原则,该场景标明该缓存的计划是无效的。
  • 通过业务分析后,发如今游戏的业务场景中 用户维度+游戏维度 不存在5分钟重复访问缓存的场景,确认缓存的无效。
  • 确认缓存无效后,删除相关的缓存逻辑,降低了 Redis Server 的负载后并进一步提升了接口的响应时间。
三、本地缓存的监控介绍

3.1 监控的方案

3.1.1 监控目的


  • 从宏观来讲监控本质目的是为了及时发现定位并办理问题,在本钱可控的前提下监控维度尽大概丰富。
  • 聚焦到 Caffeine 的维度,监控指标包罗缓存的请求次数、命中率,未命中率等指标。
  • Caffeine 目前最常见的问题是:缓存设置不公道导致缓存穿透引发的系统问题。
3.1.2 监控方案


  • 目前从监控的维度进行分析,按照呆板维度+缓存实例进行监控指标采集,其中监控指标的采集基于 Caffeine 的 recordStats 功能开启。
  • 基于 caffeine 的原生本领定制的 vivo-caffeine 集成了单呆板维度+单缓存实例的指标数据的采集和上报功能。
  • vivo-caffeine 上报的数据会按照单呆板+单缓存实例维度进行大盘展示,支持全量指标的查询功能。
  • vivo-caffeine 的上报的数据和公司级的告警功能相结合,比方针对缓存未命中率进行监控就能很快发现缓存穿透的问题。
3.1.3 监控大盘


 

【Caffeine 系统监控指标】
说明:

  • vivo-caffeine 按照单呆板 + 缓存实例维度进行监控数据的上报并进行展示。
  • 所有的系统指标都支持查询并以图片的形式进行展示。
3.2 监控的实现

 
  1. public final class Caffeine<K, V> {
  2.   /**
  3.    * caffeine的实例名称
  4.    */
  5.   String instanceName;
  6.   /**
  7.    * caffeine的实例维护的Map信息
  8.    */
  9.   static Map<String, Cache> cacheInstanceMap = new ConcurrentHashMap<>();
  10.   @NonNull
  11.   public <K1 extends K, V1 extends V> Cache<K1, V1> build() {
  12.     requireWeightWithWeigher();
  13.     requireNonLoadingCache();
  14.     @SuppressWarnings("unchecked")
  15.     Caffeine<K1, V1> self = (Caffeine<K1, V1>) this;
  16.     Cache localCache =  isBounded() ? new BoundedLocalCache.BoundedLocalManualCache<>(self) : new UnboundedLocalCache.UnboundedLocalManualCache<>(self);
  17.     if (null != localCache && StringUtils.isNotEmpty(localCache.getInstanceName())) {
  18.       cacheInstanceMap.put(localCache.getInstanceName(), localCache);
  19.     }
  20.     return localCache;
  21.   }
  22. }
  23. static Cache<String, List<String>> accountWhiteCache = Caffeine.newBuilder().applyName("accountWhiteCache")
  24.             .expireAfterWrite(VivoConfigManager.getInteger("trade.account.white.list.cache.ttl", 10), TimeUnit.MINUTES)
  25.             .recordStats().maximumSize(VivoConfigManager.getInteger("trade.account.white.list.cache.size", 100)).build();
复制代码
左右滑动检察完整代码
 
说明:

  • Caffeine 的按缓存实例进行指标采集的前提是必要全局维护缓存实例和对应的 instanceName 之间的关联关系。
  • Caffeine 在缓存创建的时候会设置实例的名称,通过 applyName 方法设置实例名称。
 
  1. public static StatsData getCacheStats(String instanceName) {
  2.     Cache cache = Caffeine.getCacheByInstanceName(instanceName);
  3.     CacheStats cacheStats = cache.stats();
  4.     StatsData statsData = new StatsData();
  5.     statsData.setInstanceName(instanceName);
  6.     statsData.setTimeStamp(System.currentTimeMillis()/1000);
  7.     statsData.setMemoryUsed(String.valueOf(cache.getMemoryUsed()));
  8.     statsData.setEstimatedSize(String.valueOf(cache.estimatedSize()));
  9.     statsData.setRequestCount(String.valueOf(cacheStats.requestCount()));
  10.     statsData.setHitCount(String.valueOf(cacheStats.hitCount()));
  11.     statsData.setHitRate(String.valueOf(cacheStats.hitRate()));
  12.     statsData.setMissCount(String.valueOf(cacheStats.missCount()));
  13.     statsData.setMissRate(String.valueOf(cacheStats.missRate()));
  14.     statsData.setLoadCount(String.valueOf(cacheStats.loadCount()));
  15.     statsData.setLoadSuccessCount(String.valueOf(cacheStats.loadSuccessCount()));
  16.     statsData.setLoadFailureCount(String.valueOf(cacheStats.loadFailureCount()));
  17.     statsData.setLoadFailureRate(String.valueOf(cacheStats.loadFailureRate()));
  18.     Optional<Eviction> optionalEviction = cache.policy().eviction();
  19.     optionalEviction.ifPresent(eviction -> statsData.setMaximumSize(String.valueOf(eviction.getMaximum())));
  20.     Optional<Expiration> optionalExpiration = cache.policy().expireAfterWrite();
  21.     optionalExpiration.ifPresent(expiration -> statsData.setExpireAfterWrite(String.valueOf(expiration.getExpiresAfter(TimeUnit.SECONDS))));
  22.     optionalExpiration = cache.policy().expireAfterAccess();
  23.     optionalExpiration.ifPresent(expiration -> statsData.setExpireAfterAccess(String.valueOf(expiration.getExpiresAfter(TimeUnit.SECONDS))));
  24.     optionalExpiration = cache.policy().refreshAfterWrite();
  25.     optionalExpiration.ifPresent(expiration -> statsData.setRefreshAfterWrite(String.valueOf(expiration.getExpiresAfter(TimeUnit.SECONDS))));
  26.     return statsData;
  27. }
复制代码
左右滑动检察完整代码
 
说明:

  • 监控指标的采集基于 Caffeine 原生的统计功能 CacheStats。
  • 所有采集的指标封装成一个统计对象 StatsData 进行上报。
 
  1. public static void sendReportData() {
  2.     try {
  3.         if (!VivoConfigManager.getBoolean("memory.caffeine.data.report.switch", true)) {
  4.             return;
  5.         }
  6.         // 1、获取所有的cache实例对象
  7.         Method listCacheInstanceMethod = HANDLER_MANAGER_CLASS.getMethod("listCacheInstance", null);
  8.         List<String> instanceNames = (List)listCacheInstanceMethod.invoke(null, null);
  9.         if (CollectionUtils.isEmpty(instanceNames)) {
  10.             return;
  11.         }
  12.         String appName = System.getProperty("app.name");
  13.         String localIp = getLocalIp();
  14.         String localPort = String.valueOf(NetPortUtils.getWorkPort());
  15.         ReportData reportData = new ReportData();
  16.         InstanceData instanceData = new InstanceData();
  17.         instanceData.setAppName(appName);
  18.         instanceData.setIp(localIp);
  19.         instanceData.setPort(localPort);
  20.         // 2、遍历cache实例对象获取缓存监控数据
  21.         Method getCacheStatsMethod = HANDLER_MANAGER_CLASS.getMethod("getCacheStats", String.class);
  22.         Map<String, StatsData> statsDataMap = new HashMap<>();
  23.         instanceNames.stream().forEach(instanceName -> {
  24.             try {
  25.                 StatsData statsData = (StatsData)getCacheStatsMethod.invoke(null, instanceName);
  26.                 statsDataMap.put(instanceName, statsData);
  27.             } catch (Exception e) {
  28.             }
  29.         });
  30.         // 3、构建上报对象
  31.         reportData.setInstanceData(instanceData);
  32.         reportData.setStatsDataMap(statsDataMap);
  33.         // 4、发送Http的POST请求
  34.         HttpPost httpPost = new HttpPost(getReportDataUrl());
  35.         httpPost.setConfig(requestConfig);
  36.         StringEntity stringEntity = new StringEntity(JSON.toJSONString(reportData));
  37.         stringEntity.setContentType("application/json");
  38.         httpPost.setEntity(stringEntity);
  39.         HttpResponse response = httpClient.execute(httpPost);
  40.         String result = EntityUtils.toString(response.getEntity(),"UTF-8");
  41.         EntityUtils.consume(response.getEntity());
  42.         logger.info("Caffeine 数据上报成功 URL {} 参数 {} 结果 {}", getReportDataUrl(), JSON.toJSONString(reportData), result);
  43.     } catch (Throwable throwable) {
  44.         logger.error("Caffeine 数据上报失败 URL {} ", getReportDataUrl(), throwable);
  45.     }
  46. }
复制代码
左右滑动检察完整代码
 
说明:

  • 每个应用单独的摆设的服务作为一个采集点,进行指标的采集和上报。
  • 采集过程是获取当前摆设的应用下的所有缓存实例并进行指标的采集封装。
  • 整体上报采用 Http 协议进行上报并最终展示到监控平台。
3.3 监控的案例

 

说明:

  • 某次线上问题发生时发现突然多出了大量的 Redis 的请求,但是无法具体定位请求的 Redis 的前缀 key,设想如果接入了 Redis 的业务监控,问题来源就能很快定位。
  • 在后续的问题排查中发现某个 Caffeine 的本地缓存由于大小设置过小导致大量的本地请求缓存穿透导致 Redis 的请求量突增,最终导致 Redis 的服务接近崩溃。
  • 针对本地缓存穿透的场景,如果采用 Caffeine 的本地缓存监控方案,能够从缓存的命中率指标和缓存的未命中率指标突增突降中发现问题根源。
四、竣事语


  • 本篇内容是基于线上真实案例分享游戏业务侧在缓存监控治理方面的有效实践,监控治理本身是一个未雨绸缪的过程。在没有线上问题发生时看似不重要,但一旦发生无法快速定位问题又会导致问题的放大,因此美满的缓存监控整理其实是非常有必要的。
  • Redis 的前缀 key 的监控思路是游戏业务服务端在优化Redis 的使用效率的过程中发现的一个较好的实践,徐徐延伸后发现这是一个很好的监控本领,能够通过突增的趋势快速定位问题。
  • 基于 Caffeine 的原生本领定制的监控指标采集是游戏业务服务端在探索 Caffeine 可视化过程中进行的一个探索落地,将整个缓存实例的运行态进行完整呈现,为业务稳定性贡献力量。
  • 相信业内同仁会有更多更好的实践,相互分享共同进步,共勉。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

汕尾海湾

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表