在互联网项目中,计数器有着广泛的应用场景。以技术派项目为例,诸如文章点赞数、收藏数、评论数以及用户粉丝数等都离不开计数器的支持。在技术派源码中,提供了基于数据库操作记录实时更新和基于 Redis 的 incr
特性实现计数器这两种方案,本文将重点探讨基于 Redis 的实现方式。
1 计数的业务场景
技术派中使用计数器的场景主要分为两大类(业务计数 + PV/UV),三个细分领域(用户、文章、站点):
- 用户的相关统计信息:包括文章数、文章总阅读数、粉丝数、关注作者数、文章被收藏数、被点赞数量等。
- 文章的相关统计信息:如文章点赞数、阅读数、收藏数、评论数等。
- 站点的 PV/UV 等统计信息 :涵盖网站的总
PV/UV
、某一天的PV/UV
以及某个 URI 的PV/UV
等。
2 Redis 计数器
Redis 计数器主要借助原生的 incr
指令来实现原子的 +1/-1 操作,且不仅 string
类型支持 incr
,hash
、zset
数据类型同样也支持。
2.1 incr
指令
Redis 的 Incr
命令用于将 key
中存储的数值增一。若 key
不存在,会先初始化为 0 再执行 INCR
操作;若值的类型错误或不能表示为数字,则返回错误;并且该操作的值限制在 64 位有符号数字表示之内。
-
技术派的封装实现 :在
RedisClient
中对hIncr
方法进行了封装,用于实现hash
类型数据的自增操作。代码如下:javapublic static Long hIncr ( String key , String filed , Integer cnt ) { return template . execute ( ( RedisCallback <Long >) con ->con . hIncrBy ( keyBytes ( key ) , valBytes ( filed ) , cnt ) ) ; }
2.2 用户计数统计
在技术派项目中,每个用户的相关计数都存储在一个hash数据结构中。具体结构如下:
key
为user_statistic_${userId}
field
包含followCount
(关注数)、fansCount
(粉丝数)、articleCount
(已发布文章数)、praiseCount
(文章点赞数)、readCount
(文章被阅读数)、collectionCount
(文章被收藏数)等。
在业务场景中,为避免计数器与业务代码强耦合,技术派采用消息机制。在 com.github.paicoding.forum.service.statistics.listener.UserStatisticEventListener
中,通过监听不同的消息事件(如 NotifyMsgEvent
、ArticleMsgEvent
)来实现用户和文章的计数变更。例如,当收到点赞消息(PRAISE
)时,会对用户和文章的点赞数进行相应的 +1 操作;取消点赞时则进行 -1 操作。
java
@EventListener(classes = NotifyMsgEvent.class)
@Async
public void notifyMsgListener(NotifyMsgEvent msgEvent) {
switch (msgEvent.getNotifyType()) {
case COMMENT:
case REPLY:
// 评论/回复
CommentDO comment = (CommentDO) msgEvent.getContent();
RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + comment.getArticleId(), CountConstants.COMMENT_COUNT, 1);
break;
case DELETE_COMMENT:
case DELETE_REPLY:
// 删除评论/回复
comment = (CommentDO) msgEvent.getContent();
RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + comment.getArticleId(), CountConstants.COMMENT_COUNT, -1);
break;
case COLLECT:
// 收藏
UserFootDO foot = (UserFootDO) msgEvent.getContent();
RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.COLLECTION_COUNT, 1);
RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.COLLECTION_COUNT, 1);
break;
case CANCEL_COLLECT:
// 取消收藏
foot = (UserFootDO) msgEvent.getContent();
RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.COLLECTION_COUNT, -1);
RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.COLLECTION_COUNT, -1);
break;
case PRAISE:
// 点赞
foot = (UserFootDO) msgEvent.getContent();
RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.PRAISE_COUNT, 1);
RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.PRAISE_COUNT, 1);
break;
case CANCEL_PRAISE:
// 取消点赞
foot = (UserFootDO) msgEvent.getContent();
RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.PRAISE_COUNT, -1);
RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.PRAISE_COUNT, -1);
break;
case FOLLOW:
UserRelationDO relation = (UserRelationDO) msgEvent.getContent();
// 主用户粉丝数 + 1
RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getUserId(), CountConstants.FANS_COUNT, 1);
// 粉丝的关注数 + 1
RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getFollowUserId(), CountConstants.FOLLOW_COUNT, 1);
break;
case CANCEL_FOLLOW:
relation = (UserRelationDO) msgEvent.getContent();
// 主用户粉丝数 - 1
RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getUserId(), CountConstants.FANS_COUNT, -1);
// 粉丝的关注数 - 1
RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getFollowUserId(), CountConstants.FOLLOW_COUNT, -1);
break;
default:
}
}
@Async
@EventListener(ArticleMsgEvent.class)
public void publishArticleListener(ArticleMsgEvent<ArticleDO> event) {
ArticleEventEnum type = event.getType();
if (type == ArticleEventEnum.ONLINE || type == ArticleEventEnum.OFFLINE || type == ArticleEventEnum.DELETE) {
Long userId = event.getContent().getUserId();
int count = articleDao.countArticleByUser(userId);
RedisClient.hSet(CountConstants.USER_STATISTIC_INFO + userId, CountConstants.READ_COUNT, count);
}
}
2.3 用户统计信息查询
查询用户的统计信息时,直接使用 hgetall
命令即可获取用户对应的所有统计数据,源码路径:com.github.paicoding.forum.service.statistics.service.impl.CountServiceImpl#queryUserStatisticInfo
。
java
@Override
public UserStatisticInfoDTO queryUserStatisticInfo(Long userId) {
Map<String, Integer> ans = RedisClient.hGetAll(CountConstants.USER_STATISTIC_INFO + userId, Integer.class);
UserStatisticInfoDTO info = new UserStatisticInfoDTO();
info.setFollowCount(ans.getOrDefault(CountConstants.FOLLOW_COUNT, 0));
info.setArticleCount(ans.getOrDefault(CountConstants.ARTICLE_COUNT, 0));
info.setPraiseCount(ans.getOrDefault(CountConstants.PRAISE_COUNT, 0));
info.setCollectionCount(ans.getOrDefault(CountConstants.COLLECTION_COUNT, 0));
info.setReadCount(ans.getOrDefault(CountConstants.READ_COUNT, 0));
info.setFansCount(ans.getOrDefault(CountConstants.FANS_COUNT, 0));
return info;
}
2.4 缓存一致性
为保证缓存与实际数据的一致性,技术派采用简单的定时同步方案,每天对用户统计信息和文章统计信息进行全量同步。
-
用户统计信息每天全量同步 :
com.github.paicoding.forum.service.statistics.service.impl.CountServiceImpl#autoRefreshAllUserStatisticInfo
java/** * 每天4:15分执行定时任务,全量刷新用户的统计信息 */ @Scheduled(cron = "0 15 4 * * ?") public void autoRefreshAllUserStatisticInfo() { Long now = System.currentTimeMillis(); log.info("开始自动刷新用户统计信息"); Long userId = 0L; int batchSize = 20; while (true) { List<Long> userIds = userDao.scanUserId(userId, batchSize); userIds.forEach(this::refreshUserStatisticInfo); if (userIds.size() < batchSize) { userId = userIds.get(userIds.size() - 1); break; } else { userId = userIds.get(batchSize - 1); } } log.info("结束自动刷新用户统计信息,共耗时: {}ms, maxUserId: {}", System.currentTimeMillis() - now, userId); }
-
文章统计信息每天全量同步 :
com.github.paicoding.forum.service.sitemap.service.impl.SitemapServiceImpl#initSiteMap
以及com.github.paicoding.forum.service.statistics.service.impl.CountServiceImpl#refreshArticleStatisticInfo
java/** * fixme: 加锁初始化,更推荐的是采用分布式锁 */ private synchronized void initSiteMap() { long lastId = 0L; RedisClient.del(SITE_MAP_CACHE_KEY); while (true) { List<SimpleArticleDTO> list = articleDao.getBaseMapper().listArticlesOrderById(lastId, SCAN_SIZE); // 刷新文章的统计信息 list.forEach(s -> countService.refreshArticleStatisticInfo(s.getId())); // 刷新站点地图信息 Map<String, Long> map = list.stream().collect(Collectors.toMap(s -> String.valueOf(s.getId()), s -> s.getCreateTime().getTime(), (a, b) -> a)); RedisClient.hMSet(SITE_MAP_CACHE_KEY, map); if (list.size() < SCAN_SIZE) { break; } lastId = list.get(list.size() - 1).getId(); } }
java/** *刷新文章的统计信息 */ public void refreshArticleStatisticInfo(Long articleId) { ArticleFootCountDTO res = userFootDao.countArticleByArticleId(articleId); if (res == null) { res = new ArticleFootCountDTO(); } else { res.setCommentCount(commentReadService.queryCommentCount(articleId)); } RedisClient.hMSet(CountConstants.ARTICLE_STATISTIC_INFO + articleId, MapUtils.create(CountConstants.COLLECTION_COUNT, res.getCollectionCount(), CountConstants.PRAISE_COUNT, res.getPraiseCount(), CountConstants.READ_COUNT, res.getReadCount(), CountConstants.COMMENT_COUNT, res.getCommentCount() ) ); }
3 小结
基于 Redis 的 incr
特性能够轻松实现计数相关的需求。使用 Redis 实现计数器,相较于直接使用数据库原始数据进行统计,在项目发展到一定阶段、面临高并发访问时,性能更强,能直接在展示层获取最终结果。但数据库统计方式在项目初期或简单项目中也有其优势,如实现简单、迅速且不易出问题。实际选型时,应根据项目具体情况,优先选择实现代价最小的方案,同时也可预留重构的可能性。
4 思维导图
