外观
08 - 关注与社交
知识图谱
关注系统
┌──────────┼──────────┐
▼ ▼ ▼
关注/取关 共同关注 Feed扇出
(双写) (Set交集) (发布推送)
│ │ │
▼ ▼ ▼
DB tb_follow SINTER ZADD feed:
+ Redis Set O(N+M) {粉丝id}核心代码走读
关注/取关(双写一致)
文件: src/main/java/com/hmdp/service/impl/FollowServiceImpl.java
java
@Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
if (isFollow) {
// 关注: 先写 DB, 再写 Redis
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow); // DB INSERT
if (isSuccess) {
// DB 成功后, 同步到 Redis Set
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();
}共同关注(Set 交集)
java
@Override
public Result followCommons(Long id) {
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
String key2 = "follows:" + id;
// Redis SINTER: O(min(N,M)) 复杂度
Set<String> intersect = stringRedisTemplate.opsForSet()
.intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 交集结果 → 查询用户详情
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);
}Feed 扇出(与关注联动)
文件: src/main/java/com/hmdp/service/impl/BlogServiceImpl.java
java
@Override
public Result saveBlog(Blog blog) {
// ...保存博客...
// 查询所有粉丝 (谁关注了我)
List<Follow> follows = followService.query()
.eq("follow_user_id", user.getId()).list();
// 推送到每个粉丝的 Feed 收件箱
for (Follow follow : follows) {
Long userId = follow.getUserId();
String key = FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(
key, blog.getId().toString(),
System.currentTimeMillis());
}
return Result.ok(blog.getId());
}数据模型
tb_follow 表:
┌────┬────────┬──────────────┬────────────┐
│ id │ userId │ followUserId │ createTime │
├────┼────────┼──────────────┼────────────┤
│ 1 │ 1001 │ 1002 │ 2024-03-15 │ ← 用户1001关注了1002
│ 2 │ 1001 │ 1003 │ 2024-03-15 │ ← 用户1001关注了1003
│ 3 │ 1002 │ 1003 │ 2024-03-16 │ ← 用户1002关注了1003
└────┴────────┴──────────────┴────────────┘
Redis Set:
follows:1001 = {1002, 1003}
follows:1002 = {1003}
SINTER follows:1001 follows:1002 = {1003} ← 共同关注面试 Q&A
Q1: 关注数据为什么要双写 DB 和 Redis?
DB 是数据源,Redis 是加速层,各有职责:
存储 职责 操作 MySQL 持久化存储,保证数据不丢 关注列表查询、粉丝列表查询 Redis Set 支持 SINTER 求交集 共同关注计算 如果只用 MySQL,共同关注需要 SQL JOIN:
sqlSELECT f1.follow_user_id FROM tb_follow f1 JOIN tb_follow f2 ON f1.follow_user_id = f2.follow_user_id WHERE f1.user_id = ? AND f2.user_id = ?在大表上这个 JOIN 很慢。Redis Set 的 SINTER 是内存操作,O(min(N,M)),毫秒级返回。
追问:双写的一致性怎么保证?
当前实现的策略是:先写 DB,成功后再写 Redis。
不一致场景:
- DB 写成功,Redis 写失败 → DB 有关注记录但 Redis 没有 → 共同关注会漏
- 这种不一致是暂时的,可以通过定时任务全量同步修复
更好的方案:
- 使用
@Transactional+ Redis 事务(但 Redis 事务不支持回滚)- Canal 监听 MySQL binlog 异步同步到 Redis
- 发消息到 MQ,异步更新 Redis(最终一致)
Q2: SINTER 的性能如何?大规模场景下怎么优化?
SINTER 的复杂度是 O(N×M),其中 N 和 M 是两个 Set 的大小(Redis 文档标注的最坏情况,实际优化后接近 O(min(N,M)))。
当两个用户各关注 10 万人时,SINTER 可能需要几十毫秒,且阻塞 Redis 主线程。
优化方案:
- 限制 Set 大小:只存最近 N 个关注(LRU淘汰)
- 预计算:对热门用户对预计算共同关注,存入缓存
- 使用 SINTERSTORE:将结果存入新 key,设置 TTL 缓存
- 迁移到 Bitmap:如果用户 ID 是连续整数,Bitmap 的 BITOP AND 更快
Q3: Feed 推送是同步还是异步?有什么问题?
当前是同步的(
BlogServiceImpl.saveBlog()中 for 循环)。问题:
- 阻塞发布接口:如果有 1 万粉丝,1 万次 ZADD 可能需要 100ms+
- 部分失败无处理:如果中间某个 ZADD 失败,已推送的无法回滚
- 无限扩展:粉丝越多,发布越慢
改进方案:
- 发布时只写 DB + 发 Kafka 消息
- 消费者分批推送(如每批 1000 个粉丝)
- 大 V 走拉模式(粉丝主动拉取,不推送)
追问:取关后 Feed 里还有 ta 的内容怎么办?
当前实现不处理。取关后
feed:{userId}ZSet 中仍然保留了之前推送的博客 ID。解决方案:
- 惰性清理:读取 Feed 时检查作者是否仍被关注,过滤掉已取关的
- 即时清理:取关时 ZREM 该作者的所有博客(但需要知道哪些博客是 ta 的)
- 不处理:大多数社交产品的做法——取关后时间线不变,未来不再推送新内容
Q4: isFollow 方法为什么查 DB 而不查 Redis Set?
javapublic Result isFollow(Long followUserId) { Long userId = UserHolder.getUser().getId(); Integer count = query() .eq("user_id", userId) .eq("follow_user_id", followUserId) .count(); return Result.ok(count > 0); }这是一个可以优化的点。Redis Set 的
SISMEMBER是 O(1) 操作:javaBoolean isMember = stringRedisTemplate.opsForSet() .isMember("follows:" + userId, followUserId.toString());比 MySQL 的 COUNT 查询快得多。但前提是 Redis Set 与 DB 保持一致。
踩坑点
| 踩坑点 | 说明 | 面试官考察意图 |
|---|---|---|
| 双写不一致 | DB 成功 Redis 失败,或反之 | 分布式一致性 |
| 同步推送阻塞 | 发布接口耗时随粉丝数线性增长 | 性能设计 |
| isFollow 查 DB | 有 Redis Set 但不用 | 缓存利用 |
| 取关不清理 Feed | 旧博客仍在 Feed 中 | 数据清理策略 |
| Redis Set 无 TTL | follows:{userId} 永不过期 | 内存管理 |
加分回答
- 对比 Twitter 的关注系统:将关注关系存入 FlockDB(图数据库),专门优化社交图谱查询
- 分析 SINTER vs SUNION vs SDIFF 的使用场景(共同关注/全部关注/你关注但ta没关注的)
- 提到可以用 Redis Cluster 的 hash tag
{userId}保证同一用户的 follows Set 在同一节点 - 讨论"关注推荐"算法:基于共同关注数做推荐(SINTER 结果越多,推荐权重越高)
关联文档
- 05-Feed流与Timeline — Feed 推送的接收端(游标分页)
- 00-项目总览与架构 — Redis Set 数据结构概览