业务场景:
社区作为一个轻互动论坛,必不可少会涉及很多计数统计,比如个人主页要展示用户相关一些计数(他的粉丝数、发贴数),feed信息流会展示每个贴子查看数、点赞数和评论数。如果每个计数都作为一个独立的个体,独立管理,每次用的时候就要从不同地方去取,维护成本肯定很高。
为了更好的管理,从页面功能角度对这些计数做了分类,分为用户维度、贴子维度、小组维度,而每个维度下又有各自的子项计数。
用户维度(关注数、粉丝数、发贴数、评论数、铜钱数、订阅小组数)
贴子维度(查看数、评论数、点赞数、收藏数)
小组相关(订阅数、贴子数、精华数)
消息相关(回复与&、赞、新粉丝数、私信往来的用户)
以用户维度的计数为例:
数据最终是持久到mysql存储,为了提升性能,中间会有一层redis,使用Hash数据结构
1)写操作
任何一个子项动作都会触发缓存的写操作,比如A关注了B,对于A用户来讲,A的关注数要增加1,其它子项计数不变;对于B用户来讲,B的粉丝数要增加1,其它子项计数不变。
1 2 3 4 5 6 7 8 9 10 11 12 public void updateMemberCount (MemberEditCountParam memberEditCountParam) { MemberCountUpdateParam updateParam = new MemberCountUpdateParam(); updateParam.setUid(memberEditCountParam.getUid()); updateParam.setCopperCount(memberEditCountParam.getCopperCount()); updateParam.setPostCount(memberEditCountParam.getPostCount()); updateParam.setFollowCount(memberEditCountParam.getFollowCount()); updateParam.setFansCount(memberEditCountParam.getFansCount()); updateParam.setReplyCount(memberEditCountParam.getReplyCount()); updateParam.setTags(memberEditCountParam.getTagCount()); memberCountDao.updateMemberCount(updateParam); memberCacheManager.editMemberCount(updateParam); }
先写DB,再写cache。对于DB,通过mysql自身的行锁机制解决数据并发问题,‘posts = posts + #{postCount}’。但对于cache的维护,比较麻烦一些
常规思路:
1 2 3 4 5 6 7 value = hincrBy(String key, String field, long value) ; | | | | if value ==1 , del cache
当缓存为空时,hincrBy会返回1;
如果用户某一个子项的计数为0,即使预热到cache,hincrBy 也会返回1。
所以根据是否等于1决定del cache,在用户量很大的情况下,cache的效率会比较差(只有所有的子项都大于0,且已预热到cache里才能避开这种情况)。另外如果 del cache 操作完成之前有读操作,返回的可能是脏数据
解决思路:
引入一个较大阈值,区分cache为空还是已经预热但值为0的情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 protected long incrCount (String hashName, String member, long by) { long newValue = 0 ; try { newValue = getRedisClient().hincrBy(hashName, member, by); } catch (RedisException e) { logger.error("[AbstractCacheManager.incrCount.hincrBy] invoke error!" , e); } 并发请求干扰带来的脏数据 if (newValue >= COUNT_OFFSET) { return newValue - COUNT_OFFSET; } else { try { getRedisClient().del(hashName); } catch (RedisException e) { logger.error("[AbstractCacheManager.incrCount.del] invoke error!" , e); } return Long.MIN_VALUE; } }
注意:
删除或者初始化cache,将key作为一个整体(非单个子项)来操作,减少复杂性
为了便于维护,cache的预热放在查询阶段,如果cache为空,预热到cache中。
2)读操作
批量查多个用户的用户维度计数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public List<SimpleMemberCountModel> batchGetSimpleMemberCountModel (List<Long> uidList) { if (CollectionUtils.isEmpty(uidList)) { return Collections.emptyList(); } List<String> keyList = uidList.stream() .map(this ::getKeyMemberCount).collect(Collectors.toList()); Map<String, Map<String, Long>> countMaps = super .batchGetCountMap(keyList); List<SimpleMemberCountModel> countModelList = Lists.newArrayListWithCapacity(countMaps.size()); for (int i = 0 ; i < uidList.size(); ++i) { Map<String, Long> countMap = countMaps.get(keyList.get(i)); if (countMap != null ) { final Long fansCount = countMap.get(FIELD_FANS_COUNT); final Long postCount = countMap.get(FIELD_POST_COUNT); final Long followCount = countMap.get(FIELD_FOLLOW_COUNT); final Long replyCount = countMap.get(FIELD_REPLY_COUNT); final Long tags = countMap.get(FIELD_TAGS_COUNT); if (fansCount != null && postCount != null && followCount != null && replyCount != null && tags != null ) { SimpleMemberCountModel countModel = new SimpleMemberCountModel(); Long uid = uidList.get(i); countModel.setUid(uid); countModel.setFansCount(fansCount); countModel.setPostcount(postCount); countModel.setFollowCount(followCount); countModel.setReplyCount(replyCount); countModel.setTagCount(tags); countModelList.add(countModel); } } } return countModelList; }
如果缓存为空,从DB查询数据并预热到缓存中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public void batchSaveSimpleMemberCountModel (List<SimpleMemberCountModel> countModelList) { Map<String , Map<String, Long>> batchCountMap = Maps.newHashMapWithExpectedSize(countModelList.size()); countModelList.forEach(countModel -> { String hash = getKeyMemberCount(countModel.getUid()); Map<String, Long> countMap = Maps.newHashMapWithExpectedSize(6 ); countMap.put(FIELD_UID, countModel.getUid()); countMap.put(FIELD_FANS_COUNT, countModel.getFansCount()); countMap.put(FIELD_POST_COUNT, countModel.getPostcount()); countMap.put(FIELD_FOLLOW_COUNT, countModel.getFollowCount()); countMap.put(FIELD_REPLY_COUNT, countModel.getReplyCount()); countMap.put(FIELD_TAGS_COUNT, countModel.getTagCount()); batchCountMap.put(hash, countMap); }); super .batchSaveCountHash(batchCountMap, HALF_DAY_EXPIRE); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 protected void batchSaveCountHash (Map<String, Map<String, Long> >batchCountMap, int ttl) { if (batchCountMap == null || batchCountMap.size() == 0 ) { return ; } Map<Node, List<String>> nodeMap = getNodeMap(batchCountMap.keySet()); Map<String, Map<String, String>> batchStrMap = Maps.newHashMapWithExpectedSize(batchCountMap.size()); batchCountMap.forEach((key, countMap) -> { Map<String, String> strMap = Maps.newHashMap(); countMap.forEach((field, count) -> strMap.put(field, String.valueOf(count + COUNT_OFFSET))); batchStrMap.put(key, strMap); }); nodeMap.forEach((node, keyList) -> { try { getRedisClient().pipelined(new PipelineBlock() { @Override public void execute () { keyList.forEach(key -> { Map<String, String> strMap = batchStrMap.get(key); try { del(key); hmset(key, strMap); expire(key, ttl); } catch (RedisException e) { logger.error("[AbstractCacheManager.batchSaveCountHash.execute] invoke error!" , e); } }); } @Override public Node getTargetNode () { return node; } }); } catch (RedisException e) { logger.error("[AbstractCacheManager.batchSaveCountHash] invoke error!" , e); } }); }
注意:时间久了,缓存中的数据可能会存在与DB不一致情况,目前设置的缓存有效期是12个小时,然后缓存会失效,需再次从数据库同步数据
3)计数的准确性
时间久了,数据库表里的计数值也未必准确,比如关注数,每天凌晨会有定时任务,借助关注表计算准确的数字更新到数据库和缓存中。