外观
关注模块面经文档
项目:黑马点评(类 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} B2.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 的优势:
- 用时间戳作为 score,
ZREVRANGEBYSCORE基于 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} | ZSet | value=笔记ID, score=时间戳 | 粉丝的关注动态收件箱 |
7. 方案对比与选型理由
7.1 共同关注:Redis SINTER vs MySQL IN 查询
| 维度 | MySQL IN 子查询 | Redis SINTER |
|---|---|---|
| 实现 | 两次 DB 查询 + IN 过滤 | 内存集合求交集 |
| 性能 | O(N log N),涉及磁盘 IO | O(N),纯内存 |
| 数据一致性 | 强一致 | 与 MySQL 可能有短暂延迟 |
| 实现复杂度 | 低 | 中(需维护双写) |
| 适用场景 | 低频查询 | 高频查询、强交互场景 |
7.2 Feed 流分页:ZSet 游标分页 vs 普通 LIMIT/OFFSET
| 维度 | LIMIT/OFFSET | ZSet 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 |