Skip to content

关注模块面经文档

项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · MySQL · MyBatis-Plus


1. 模块概览

关注模块实现了三个核心功能:

功能技术手段核心价值
关注 / 取关MySQL 持久化 + Redis Set 同步实时维护关注关系
查询共同关注Redis Set 求交集(SINTER)O(N) 内存运算替代多次 DB 查询
关注推送(Feed 流)推模式:发布时写入所有粉丝的 ZSet 收件箱低延迟的关注动态推送

2. 整体流程图

2.1 关注 / 取关

用户 A 关注用户 B

    ├── 写 MySQL:INSERT INTO tb_follow (user_id=A, follow_user_id=B)

    └── 写 Redis:SADD follows:{A} B
         (Set 存储 A 关注的所有人,用于求共同关注)

用户 A 取关用户 B

    ├── 删 MySQL:DELETE FROM tb_follow WHERE user_id=A AND follow_user_id=B

    └── 删 Redis:SREM follows:{A} B

2.2 共同关注查询

用户 A 查询与用户 B 的共同关注


Redis SINTER follows:{A} follows:{B}

    ├── 返回交集用户 ID 列表

    └── 根据 ID 列表批量查询用户信息返回

2.3 Feed 流推送与拉取

【写流程:推模式】

用户发布新笔记


查询该用户的所有粉丝列表(从 MySQL tb_follow 查询)


遍历每个粉丝,将笔记 ID 写入粉丝的收件箱
    ZADD feed:{粉丝ID} {当前时间戳} {笔记ID}

    └── 每个粉丝都有独立的 ZSet 收件箱

【读流程:滚动分页】

用户刷新关注页


ZREVRANGEBYSCORE feed:{userId} {max} 0 LIMIT {offset} 2

    ├── 返回笔记 ID 列表
    ├── 记录本次最小 score(minTime)
    └── 记录与 minTime 相同 score 的个数(offset,用于下次跳过重复)


根据笔记 ID 查询笔记详情返回

3. 关注关系:MySQL + Redis 双写

3.1 表结构

sql
CREATE TABLE tb_follow (
    id             BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id        BIGINT NOT NULL,       -- 关注者(谁在关注)
    follow_user_id BIGINT NOT NULL,       -- 被关注者(关注谁)
    create_time    DATETIME DEFAULT NOW(),
    INDEX idx_user (user_id),
    INDEX idx_follow_user (follow_user_id)
);

3.2 核心代码

java
@Override
public Result follow(Long followUserId, Boolean isFollow) {
    Long userId = UserHolder.getUser().getId();
    String key = "follows:" + userId;  // Redis Set key

    if (isFollow) {
        // 关注:写 DB + 写 Redis
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        if (isSuccess) {
            stringRedisTemplate.opsForSet().add(key, followUserId.toString());
        }
    } else {
        // 取关:删 DB + 删 Redis
        boolean isSuccess = remove(new QueryWrapper<Follow>()
            .eq("user_id", userId)
            .eq("follow_user_id", followUserId));
        if (isSuccess) {
            stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
        }
    }
    return Result.ok();
}

为什么要同时维护 Redis Set?

Redis Set 是专门为「共同关注」准备的。如果只用 MySQL,求两个用户的共同关注需要:

sql
-- 方案A:子查询
SELECT follow_user_id FROM tb_follow WHERE user_id = A
AND follow_user_id IN (
    SELECT follow_user_id FROM tb_follow WHERE user_id = B
)

在用户关注数量大时,这个查询会走两次全表扫描并做 IN 匹配,性能较差。Redis Set 的 SINTER 操作在内存中完成,时间复杂度 O(N·M)(N、M 为两个集合的大小),远快于 DB 查询。


4. 共同关注:Redis Set 求交集

4.1 实现

java
@Override
public Result followCommons(Long targetUserId) {
    Long userId = UserHolder.getUser().getId();
    String key1 = "follows:" + userId;
    String key2 = "follows:" + targetUserId;

    // Redis SINTER 求交集,返回两个用户共同关注的 ID 集合
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);

    if (intersect == null || intersect.isEmpty()) {
        return Result.ok(Collections.emptyList());
    }

    // 解析 ID,批量查询用户信息
    List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
    List<UserDTO> users = userService.listByIds(ids)
        .stream()
        .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
        .collect(Collectors.toList());

    return Result.ok(users);
}

4.2 Redis Set 与其他数据结构对比

数据结构求交集去重有序适用场景
Set✅ SINTER共同关注、共同好友
ZSet间接支持(ZINTERSTORE)带分数的有序集合
List消息队列、时间线

共同关注不需要排序,纯粹的集合交集,Set 是最合适的数据结构。


5. Feed 流推送

5.1 推模式 vs 拉模式 vs 推拉结合

模式写操作读操作优点缺点适用场景
推模式(本项目)发布时写入每个粉丝的收件箱直接读自己的收件箱读快(O(1))写放大(粉丝多时写 N 次)普通用户,粉丝数量有限
拉模式发布时只写一份读时拉取所有关注者的最新内容并合并写轻量读慢(需实时合并)大 V,粉丝极多
推拉结合普通用户推,大 V 拉普通 + 大 V 分别处理均衡实现复杂微博等大型平台

本项目使用推模式,原因是平台上用户的粉丝数量相对有限,不会出现千万级粉丝的大 V 场景,推模式实现简单且读性能好。

5.2 收件箱数据结构选型:为什么用 ZSet 而不用 List?

关注页的 Feed 流需要按发布时间倒序展示,且支持游标分页(无限下拉刷新)。

List 的局限:

  • LRANGE 按索引分页,当内容动态更新时(有人新发布文章),固定索引会产生「跳过」或「重复」的问题
  • 例如:第一页读了索引 0~9,此时有新文章插入到列表头,第二页读索引 10~19 时,原来的第 10 条已经变成了第 11 条,实际上会重复返回第 10 条

ZSet 的优势:

  • 时间戳作为 scoreZREVRANGEBYSCORE 基于 score 范围分页
  • 游标携带「上次最小时间戳」,下次从该时间戳往前拉取,不受新数据插入影响

5.3 滚动分页的 offset 处理

ZREVRANGEBYSCORE 按时间戳范围查询时,若多条笔记的时间戳相同(并发发布),同一时间戳的笔记可能跨页:

时间戳:  100  99  98  98  98  97  96
          └─── 第一页 ──┘  └─────────────────

第一页 max=MAX, offset=0, limit=3 → 返回 [100, 99, 98]
              minTime=98, 与 minTime 相同的有 1 个 → os=1

第二页 max=98, offset=1, limit=3
  ZREVRANGEBYSCORE key 98 0 LIMIT 1 3
  → 跳过1个分数为98的,返回 [98, 98, 97]

offset 计算逻辑

java
long minTime = 0;
int os = 1;

for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
    ids.add(Long.valueOf(tuple.getValue()));
    long time = tuple.getScore().longValue();
    if (time == minTime) {
        os++;           // 累计与 minTime 相同 score 的数量
    } else {
        minTime = time; // 更新 minTime
        os = 1;         // 重置 offset
    }
}
// 下次请求携带 minTime 和 os,跳过已读的相同时间戳记录

5.4 推送流程代码

java
@Override
public Result saveBlog(Blog blog) {
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());
    boolean isSuccess = save(blog);
    if (!isSuccess) {
        return Result.fail("新增笔记失败!");
    }

    // 查询作者的所有粉丝
    List<Follow> follows = followService.query()
        .eq("follow_user_id", user.getId()).list();

    // 推送笔记 ID 到每个粉丝的 ZSet 收件箱
    for (Follow follow : follows) {
        Long fanId = follow.getUserId();
        String key = FEED_KEY + fanId;   // feed:{fanId}
        stringRedisTemplate.opsForZSet()
            .add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    return Result.ok(blog.getId());
}

6. Redis 数据结构汇总

Key 格式数据结构内容用途
follows:{userId}Set该用户关注的所有人的 ID共同关注求交集
feed:{userId}ZSetvalue=笔记ID, score=时间戳粉丝的关注动态收件箱

7. 方案对比与选型理由

7.1 共同关注:Redis SINTER vs MySQL IN 查询

维度MySQL IN 子查询Redis SINTER
实现两次 DB 查询 + IN 过滤内存集合求交集
性能O(N log N),涉及磁盘 IOO(N),纯内存
数据一致性强一致与 MySQL 可能有短暂延迟
实现复杂度中(需维护双写)
适用场景低频查询高频查询、强交互场景

7.2 Feed 流分页:ZSet 游标分页 vs 普通 LIMIT/OFFSET

维度LIMIT/OFFSETZSet ZREVRANGEBYSCORE
实现原理按行号跳过按 score 范围查询
新数据插入后会出现重复/跳过不受影响(按时间范围)
性能OFFSET 大时慢(需扫描跳过的行)稳定 O(log N + M)
适用场景静态数据分页动态更新的时间线

8. 极端场景与容灾

场景一:大 V 用户发帖,粉丝百万,推模式写放大严重

推送 100 万次 Redis ZADD 操作,即使单次操作很快(微秒级),百万次也需要秒级时间,且会阻塞主线程。

应对方案

  • 异步推送:将推送任务提交到线程池或消息队列,用户发帖立即返回,推送在后台完成
  • 推拉结合:对粉丝数超过阈值(如 10 万)的用户改为拉模式,读时合并

场景二:Redis 中的 follows Set 与 MySQL 数据不一致

关注操作写 DB 成功但写 Redis 失败(网络抖动等),导致 Redis Set 中缺少记录,共同关注结果不准确。

应对方案

  • 重试机制:写 Redis 失败时记录日志,定时补偿任务从 MySQL 重建 Redis Set
  • 最终一致性:接受短暂不一致,用户刷新页面后可以得到正确结果
  • 降级:若 Redis 不可用,共同关注直接走 MySQL IN 查询,牺牲性能保证可用性

场景三:feed ZSet 中的旧数据无限积累,内存持续增长

粉丝的收件箱 ZSet 理论上会随关注的人发布文章而无限增长。

应对方案

  • 设置 ZSet 长度上限(如保留最近 500 条),超出则裁剪最老的记录
  • 设置 Key 的 TTL(如 7 天),超过一定时间的动态自动清理
  • 用户主动刷新时,后端检查并裁剪超长的收件箱

场景四:取关操作,MySQL 删除成功但 Redis 未同步

导致 Redis 中仍然有该用户的关注关系,共同关注会出现错误的结果。

应对方案:与场景二类似,定时任务校验 Redis 与 MySQL 的一致性,以 MySQL 为准重建 Redis Set。


9. 面试题清单

9.1 基础实现层

题目考察点
关注模块用了哪些 Redis 数据结构,分别存什么?Redis 数据结构选型
共同关注是怎么实现的?为什么要用 Redis Set 而不直接查 MySQL?Redis SINTER vs DB 查询
Feed 流是推模式还是拉模式?为什么选这种模式?Feed 流架构选型
关注动态的收件箱为什么用 ZSet 而不用 List?ZSet 有序性,游标分页原理

9.2 方案对比层

题目考察点
推模式和拉模式分别适合什么场景?大 V 账号用推模式会有什么问题?写放大问题,推拉结合
普通 LIMIT/OFFSET 分页和 ZSet 游标分页有什么区别?动态数据分页一致性
follows Set 在 Redis 里重复存一份,这不是浪费内存吗?值不值得?空间换时间的权衡

9.3 原理深挖层

题目考察点
Redis Set 的底层数据结构是什么?元素数量多少时会转换?intset vs hashtable
ZREVRANGEBYSCORE 的时间复杂度是多少?ZSet skiplist 查询复杂度
如果同一秒有多篇笔记发布,ZSet 的 score 相同,分页会有问题吗?怎么处理?offset 游标处理

9.4 极端场景层

题目考察点
大 V 发帖要推给 100 万粉丝,推模式怎么处理写放大?异步推送,推拉结合
Redis 和 MySQL 的关注关系不一致了,怎么修复?数据一致性,补偿任务
粉丝收件箱里的旧内容越来越多,怎么控制内存占用?ZSet 裁剪,TTL

10. 一句话总结(面试开场白模板)

关注模块实现了三个功能:关注/取关在写 MySQL 的同时维护 Redis Set,记录每个用户关注的人,DB 负责持久化,Redis 负责快速集合运算;共同关注直接用 Redis 的 SINTER 命令对两个用户的关注 Set 求交集,避免了 MySQL 的两次查询和 IN 过滤;Feed 流采用推模式,用户发帖时遍历粉丝列表,将笔记 ID 以时间戳为 score 写入每个粉丝的 ZSet 收件箱,用户刷新关注页时用 ZREVRANGEBYSCORE 滚动分页,游标携带上次最小时间戳,解决了 LIMIT/OFFSET 在动态数据下分页重复的问题。


附:全模块文档清单

序号模块文档文件名
01登录模块01_登录模块.md
02店铺缓存模块02_店铺缓存模块.md
03优惠券下单模块03_优惠券下单模块.md
04秒杀优惠券模块04_秒杀优惠券模块.md
05文章模块05_文章模块.md
06点赞模块06_点赞模块.md
07关注模块07_关注模块.md