高并发场景下的浏览,点赞,收藏

一、什么是高并发

高并发就是短时间内有大量用户同时访问或操作同一个功能,比如一篇热门帖子被上万人同时浏览、点赞或者收藏,服务器需要同时处理这些请求。
在内容社区、电商、在线教育等平台中,浏览量、点赞数、收藏数、评论数、分享数是最常见的互动统计指标。这些功能有一个共同特点:读多写也多、计数操作频繁、要求实时反馈

如果直接对 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 是最基础的数据结构,可以存储字符串和数字。

  • 核心操作:SETGETINCR(原子自增)、支持设置 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

实现流程:

  1. 获取当前登录用户 ID,拼接用户收藏 Key
  2. 如果 Key 不存在,创建空 Set(放入 -1L 占位,因为 Redis 空 Set 会被自动删除)
  3. 判断 Set 中是否包含当前帖子 ID:
    • 包含 → 取消收藏:favornum -1,从 Set 中移除帖子 ID
    • 不包含 → 收藏:favornum +1,向 Set 中添加帖子 ID
  4. 返回最新统计数据
@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

实现流程:

  1. 拼接点赞计数 Key,如果不存在则创建并设置 TTL 为"今天剩余秒数"
  2. INCR 自增当天点赞次数
  3. 如果超过 5 次,返回点赞失败
  4. 未超过则点赞成功,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);
        }
    }
}

八、方案优缺点总结

优点

  1. 高性能:Redis 内存操作,轻松应对高并发读写
  2. 降低数据库压力:Write-Behind 异步写回,批量同步减少数据库写次数
  3. 数据结构灵活:Hash 存多字段统计、Set 存用户收藏列表、String+TTL 做限次计数,各取所长
  4. 冷启动保护:缓存预热保证服务启动后缓存就绪,避免请求穿透
  5. 字段完整性保障:预热加载完整 Hash,避免回写时覆盖丢失数据

缺点与可优化方向

  1. 最终一致性延迟:Redis 与 MySQL 之间存在短暂不一致,依赖定时任务同步。对一致性要求极高的场景(如账户余额)不适用
  2. Redis 宕机风险:Redis 宕机期间的操作数据可能丢失。可通过 Redis 主从、哨兵或集群提高可用性,持久化定时任务缩短同步间隔降低丢失窗口
  3. 全量预热的局限:数据量大时全量预热耗时长,可优化为按热度 Top N 预热 + 懒加载兜底

九、总结

这套方案的核心思路可以概括为三句话:

用 Redis 扛住高频读写,用定时任务保证数据最终落库,用缓存预热解决冷启动问题。

数据结构选型的核心思路是:

判断"有没有"用 Set,计数"有多少次"用 String,多字段统计用 Hash。

该方案不局限于内容社区,电商商品详情页、在线教育课程、新闻资讯文章等任何有"内容主体 + 用户互动 + 多维度统计"的场景都可以直接套用,只需将业务对象从"帖子"替换为对应的实体即可。

posted @ 2026-08-18 13:44  Jennifer_F  阅读(13)  评论(0)    收藏  举报