高并发场景下的浏览,点赞,收藏
一、什么是高并发
高并发就是短时间内有大量用户同时访问或操作同一个功能,比如一篇热门帖子被上万人同时浏览、点赞或者收藏,服务器需要同时处理这些请求。
在内容社区、电商、在线教育等平台中,浏览量、点赞数、收藏数、评论数、分享数是最常见的互动统计指标。这些功能有一个共同特点:读多写也多、计数操作频繁、要求实时反馈。
如果直接对 MySQL 进行读写,每次操作都要走磁盘 IO,并发量上来后响应变慢。
本文分享一套基于 Redis 的高并发互动统计缓存方案,核心思路是:用 Redis 进行高频读写,用定时任务做数据持久化,用缓存预热解决冷启动问题。
二、为什么选择 Redis
2.1 Redis 相比 MySQL 的优势
| 对比维度 | MySQL | Redis |
|---|---|---|
| 存储介质 | 磁盘 | 内存 |
| 读写速度 | 毫秒级(磁盘 IO) | 微秒级(内存操作) |
| 单节点 QPS | 千级 | 十万级 |
| 数据结构 | 表结构 | String / Hash / Set / List / ZSet 等丰富结构 |
| 原子操作 | 需要加锁或事务 | INCR / HINCRBY 原生原子命令 |
2.2 Redis 的缺点
- 内存成本高, 数据放内存,内存资源昂贵,不适合海量冷数据;
- 持久化也不能保证绝对零丢失
- 作为缓存层时, 需要处理与数据库的一致性问题
三、Redis 数据结构选型
针对互动统计场景,我们用到了以下三种数据结构:
3.1 Hash(哈希)—— 存储多字段统计数据
Hash 是键值对集合,适合存储一个对象的多个属性。
- 核心操作:
HSET(设值)、HGET(取值)、HINCRBY(字段原子增减)、HGETALL(取全部) - 适用场景:一篇帖子的浏览量、点赞数、收藏数等多个统计维度
注意:Hash 可以单独修改某一个字段,
HINCRBY是原子操作,Redis 单线程执行,A 执行完 B 才能执行,没有并发问题。
3.2 Set(集合)—— 判断"有没有"
Set 是无序、不重复的元素集合。
- 核心操作:
SADD(添加)、SREM(删除)、SISMEMBER(判断是否存在,O(1))、SCARD(获取数量) - 适用场景:用户收藏列表、点赞用户列表等需要判断"是否已操作"的场景
3.3 String(字符串)—— 简单计数 + 过期时间
String 是最基础的数据结构,可以存储字符串和数字。
- 核心操作:
SET、GET、INCR(原子自增)、支持设置 TTL 过期时间 - 适用场景:限次计数器、带过期时间的临时数据
四、核心功能实现
4.1 统计数据 Hash 设计
每篇帖子对应一个 Hash Key,存储该帖子的全部互动统计数据:
Key: STRATEGY_STATIS_HASH:{帖子ID}
Value: {
"id": 帖子ID,
"viewnum": 浏览量,
"replynum": 评论数,
"favornum": 收藏数,
"sharenum": 分享数,
"thumbsupnum": 点赞数
}
六个字段集中在一个 Hash 中,修改任意统计维度只需操作对应字段,互不干扰。
4.2 浏览量与评论数
浏览量和评论数的实现最简单,直接对 Hash 对应字段执行 HINCRBY 原子自增:
@Override
public Map<String, Object> viewnumIncr(Long sid) {
String key = statisHashInit(sid); // 确保 Key 存在
redisService.incrementCacheMapValue(key, "viewnum", 1); // HINCRBY +1
return redisService.getCacheMap(key); // 返回最新统计数据
}
statisHashInit 是一个懒加载兜底方法:如果 Redis 中不存在该帖子的统计 Key,则从数据库加载并创建。
private String statisHashInit(Long sid) {
String key = RedisKeys.STATEGY_STATIS_HASH.join(sid.toString());
if (!redisService.hasKey(key)) {
//redis里没有这个key,没有这个文章的缓存
Strategy strategy = baseMapper.selectById(sid);
Map<String, Object> map = new HashMap<>();
map.put("viewnum", strategy.getViewnum().intValue());
map.put("favornum", strategy.getFavornum().intValue());
map.put("replynum", strategy.getReplynum().intValue());
map.put("sharenum", strategy.getSharenum().intValue());
map.put("thumbsupnum", strategy.getThumbsupnum().intValue());
map.put("id", strategy.getId());
redisService.setCacheMap(key, map);
}
return key;
}
4.3 收藏功能(Set + Hash)
收藏是一个"收藏 / 取消收藏"的切换场景,需要两个 Key 配合:
- 用户收藏列表:
USER_STRATEGY_FAVOR:{用户ID},Set 结构,存储该用户收藏过的所有帖子 ID - 帖子收藏数:
STRATEGY_STATIS_HASH:{帖子ID}中的favornum字段
![image]()
![image]()
实现流程:
- 获取当前登录用户 ID,拼接用户收藏 Key
- 如果 Key 不存在,创建空 Set(放入
-1L占位,因为 Redis 空 Set 会被自动删除) - 判断 Set 中是否包含当前帖子 ID:
- 包含 → 取消收藏:
favornum-1,从 Set 中移除帖子 ID - 不包含 → 收藏:
favornum+1,向 Set 中添加帖子 ID
- 包含 → 取消收藏:
- 返回最新统计数据
@Override
public Map<String, Object> favor(Long sid) {
Long userId = SecurityContextHolder.getUserId();
String key = RedisKeys.USER_STRATEGY_FAVOR.join(userId.toString());
// 空 Set 会被 Redis 自动删除,放入 -1L 占位保持 Key 存在
if (!redisService.hasKey(key)) {
Set<Long> set = new HashSet<>();
set.add(-1L);
redisService.setCacheSet(key, set);
}
// 拼接当前帖子的统计key
String statisKey = RedisKeys.STRATEGY_STATIS_HASH.join(sid.toString());
// 告诉前端到底是收藏还是取消收藏操作
Boolean result;
if (redisService.isCacheSetContains(key, sid)) {
// 已收藏 → 取消收藏
redisService.incrementCacheMapValue(statisKey, "favornum", -1);
result = false;
// 把这篇帖子从用户收藏中删掉
redisService.deleteCacheSetValue(key, sid);
} else {
// 未收藏 → 收藏
redisService.incrementCacheMapValue(statisKey, "favornum", 1);
result = true;
// 把这篇帖子加到用户收藏中
redisService.addCacheSetValue(key, sid);
}
//更新最新数据
Map<String, Object> cacheMap = redisService.getCacheMap(statisKey);
cacheMap.put("result", result);
return cacheMap;
}
判断用户是否收藏过某篇帖子,直接用 SISMEMBER,O(1) 时间复杂度:
@Override
public Boolean isUserFavor(Long sid, Long uid) {
return redisService.isCacheSetContains(
RedisKeys.USER_STRATEGY_FAVOR.join(uid.toString()), sid
);
}
4.4 点赞功能(String + Hash,每日限次)
点赞和收藏不同,我们增加了每日限次机制:每个用户每天最多给同一篇帖子点赞 5 次,防止刷赞。
- 点赞计数器:
USER_STRATEGY_THUMBSUP:{帖子ID}:{用户ID},String 结构,存储当天点赞次数,设置当天结束自动过期 - 帖子点赞数:
STRATEGY_STATIS_HASH:{帖子ID}中的thumbsupnum字段
![image]()
![image]()
实现流程:
- 拼接点赞计数 Key,如果不存在则创建并设置 TTL 为"今天剩余秒数"
INCR自增当天点赞次数- 如果超过 5 次,返回点赞失败
- 未超过则点赞成功,
thumbsupnum+1
@Override
public Map<String, Object> thumbsup(Long sid) {
Long uid = SecurityContextHolder.getUserId();
String key = RedisKeys.USER_STRATEGY_THUMBSUP.join(sid.toString(), uid.toString());
if (!redisService.hasKey(key)) {
Date now = new Date();
Date endDate = DateUtil.getEndDate(now);
Long time = DateUtil.getDateBetween(now, endDate);
time = time == 0 ? 1 : time;
redisService.setCacheObject(key, 0, time, TimeUnit.SECONDS); // 当天结束自动过期
}
Long ret = redisService.incrementCacheObjectValue(key, 1); // INCR +1
Boolean result;
String statisKey = RedisKeys.STRATEGY_STATIS_HASH.join(sid.toString());
if (ret > 5) {
result = false; // 今日点赞超过5次,失败
} else {
result = true;
redisService.incrementCacheMapValue(statisKey, "thumbsupnum", 1);
}
Map<String, Object> cacheMap = redisService.getCacheMap(statisKey);
cacheMap.put("result", result);
return cacheMap;
}
对比:收藏用 Set(判断"有没有"),点赞用 String(计数"有多少次"),统计数据用 Hash(多字段集中管理)。业务场景不同,数据结构的选择也不同,这就是选型的核心思路。
五、问题:Redis 数据丢失
解决方案分为两个方向:
- 运行中:数据持久化(Redis → MySQL)
- 启动时:缓存预热(MySQL → Redis)
六、数据持久化方案
6.1 模式选择:Write-Behind 异步写回
我们采用 Write-Behind(异步写回) 模式:
- 用户操作只写 Redis,立即返回
- 通过定时任务每隔几分钟将 Redis 中的统计数据批量同步到 MySQL
为什么不用同步写(每次操作都写 MySQL)?因为点赞、收藏、浏览量这类场景对一致性要求不高,用户不会在意统计数据晚几分钟同步到数据库。批量写入可以大幅减少数据库的写次数,降低压力。
这是一种最终一致性设计:短时间内 Redis 和 MySQL 可能不一致,定时任务执行后数据达成一致。
6.2 实现代码
@Override
public void statisHashPersistence() {
// 模糊匹配所有帖子的统计 Key
String keyPattern = RedisKeys.STRATEGY_STATIS_HASH.join("*");
Collection<String> keys = redisService.keys(keyPattern);
if (keys != null && keys.size() > 0) {
for (String k : keys) {
Map<String, Object> map = redisService.getCacheMap(k);
Long id = (Long) map.get("id");
Integer viewnum = (Integer) map.get("viewnum");
Integer replynum = (Integer) map.get("replynum");
Integer favornum = (Integer) map.get("favornum");
Integer sharenum = (Integer) map.get("sharenum");
Integer thumbsupnum = (Integer) map.get("thumbsupnum");
// 批量更新到 MySQL
lambdaUpdate().eq(Post::getId, id)
.set(Post::getViewnum, viewnum)
.set(Post::getReplynum, replynum)
.set(Post::getFavornum, favornum)
.set(Post::getSharenum, sharenum)
.set(Post::getThumbsupnum, thumbsupnum)
.update();
}
}
}
通过 keys("STRATEGY_STATIS_HASH*") 模糊匹配出所有统计 Key,遍历取出 Hash 中的六个字段,使用 MyBatis-Plus 的 lambdaUpdate 逐条更新到 MySQL。
七、缓存预热方案
7.1 为什么需要预热
解决冷启动:服务刚启动时 Redis 为空,如果不预热,所有请求直接到数据库
7.2 实现方式
在服务启动完成后,将数据库中所有帖子的统计数据全量加载到 Redis:
@Component
public class StatisHashInitListener implements ApplicationListener<ApplicationReadyEvent>
{
@Autowired
private IStrategyService strategyService;
@Autowired
private RedisService redisService;
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
//从mysql里拿数据
List<Strategy> list = strategyService.list();
//循环每个攻略
for (Strategy strategy : list) {
//获取攻略id
Long id = strategy.getId();
//拼接key
String key = RedisKeys.STATEGY_STATIS_HASH.join(id.toString());
//如果key存在,跳过,不用覆盖
if(redisService.hasKey(key)){
continue;
}
Map<String, Object> map = new HashMap<>();
map.put("viewnum", strategy.getViewnum().intValue());
map.put("favornum", strategy.getFavornum().intValue());
map.put("replynum", strategy.getReplynum().intValue());
map.put("sharenum", strategy.getSharenum().intValue());
map.put("thumbsupnum", strategy.getThumbsupnum().intValue());
map.put("id", strategy.getId());
//将key,map放到redis里
redisService.setCacheMap(key, map);
}
}
}
八、方案优缺点总结
优点
- 高性能:Redis 内存操作,轻松应对高并发读写
- 降低数据库压力:Write-Behind 异步写回,批量同步减少数据库写次数
- 数据结构灵活:Hash 存多字段统计、Set 存用户收藏列表、String+TTL 做限次计数,各取所长
- 冷启动保护:缓存预热保证服务启动后缓存就绪,避免请求穿透
- 字段完整性保障:预热加载完整 Hash,避免回写时覆盖丢失数据
缺点与可优化方向
- 最终一致性延迟:Redis 与 MySQL 之间存在短暂不一致,依赖定时任务同步。对一致性要求极高的场景(如账户余额)不适用
- Redis 宕机风险:Redis 宕机期间的操作数据可能丢失。可通过 Redis 主从、哨兵或集群提高可用性,持久化定时任务缩短同步间隔降低丢失窗口
- 全量预热的局限:数据量大时全量预热耗时长,可优化为按热度 Top N 预热 + 懒加载兜底
九、总结
这套方案的核心思路可以概括为三句话:
用 Redis 扛住高频读写,用定时任务保证数据最终落库,用缓存预热解决冷启动问题。
数据结构选型的核心思路是:
判断"有没有"用 Set,计数"有多少次"用 String,多字段统计用 Hash。
该方案不局限于内容社区,电商商品详情页、在线教育课程、新闻资讯文章等任何有"内容主体 + 用户互动 + 多维度统计"的场景都可以直接套用,只需将业务对象从"帖子"替换为对应的实体即可。





浙公网安备 33010602011771号