Skip to content

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:

sql
SELECT 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

不一致场景:

  1. DB 写成功,Redis 写失败 → DB 有关注记录但 Redis 没有 → 共同关注会漏
  2. 这种不一致是暂时的,可以通过定时任务全量同步修复

更好的方案:

  1. 使用 @Transactional + Redis 事务(但 Redis 事务不支持回滚)
  2. Canal 监听 MySQL binlog 异步同步到 Redis
  3. 发消息到 MQ,异步更新 Redis(最终一致)

Q2: SINTER 的性能如何?大规模场景下怎么优化?

SINTER 的复杂度是 O(N×M),其中 N 和 M 是两个 Set 的大小(Redis 文档标注的最坏情况,实际优化后接近 O(min(N,M)))。

当两个用户各关注 10 万人时,SINTER 可能需要几十毫秒,且阻塞 Redis 主线程

优化方案:

  1. 限制 Set 大小:只存最近 N 个关注(LRU淘汰)
  2. 预计算:对热门用户对预计算共同关注,存入缓存
  3. 使用 SINTERSTORE:将结果存入新 key,设置 TTL 缓存
  4. 迁移到 Bitmap:如果用户 ID 是连续整数,Bitmap 的 BITOP AND 更快

Q3: Feed 推送是同步还是异步?有什么问题?

当前是同步的BlogServiceImpl.saveBlog() 中 for 循环)。

问题:

  1. 阻塞发布接口:如果有 1 万粉丝,1 万次 ZADD 可能需要 100ms+
  2. 部分失败无处理:如果中间某个 ZADD 失败,已推送的无法回滚
  3. 无限扩展:粉丝越多,发布越慢

改进方案:

  1. 发布时只写 DB + 发 Kafka 消息
  2. 消费者分批推送(如每批 1000 个粉丝)
  3. 大 V 走拉模式(粉丝主动拉取,不推送)

追问:取关后 Feed 里还有 ta 的内容怎么办?

当前实现不处理。取关后 feed:{userId} ZSet 中仍然保留了之前推送的博客 ID。

解决方案:

  1. 惰性清理:读取 Feed 时检查作者是否仍被关注,过滤掉已取关的
  2. 即时清理:取关时 ZREM 该作者的所有博客(但需要知道哪些博客是 ta 的)
  3. 不处理:大多数社交产品的做法——取关后时间线不变,未来不再推送新内容

Q4: isFollow 方法为什么查 DB 而不查 Redis Set?

java
public Result isFollow(Long followUserId) &#123;
    Long userId = UserHolder.getUser().getId();
    Integer count = query()
            .eq("user_id", userId)
            .eq("follow_user_id", followUserId)
            .count();
    return Result.ok(count > 0);
&#125;

这是一个可以优化的点。Redis Set 的 SISMEMBER 是 O(1) 操作:

java
Boolean isMember = stringRedisTemplate.opsForSet()
        .isMember("follows:" + userId, followUserId.toString());

比 MySQL 的 COUNT 查询快得多。但前提是 Redis Set 与 DB 保持一致。


踩坑点

踩坑点说明面试官考察意图
双写不一致DB 成功 Redis 失败,或反之分布式一致性
同步推送阻塞发布接口耗时随粉丝数线性增长性能设计
isFollow 查 DB有 Redis Set 但不用缓存利用
取关不清理 Feed旧博客仍在 Feed 中数据清理策略
Redis Set 无 TTLfollows:{userId} 永不过期内存管理

加分回答

  • 对比 Twitter 的关注系统:将关注关系存入 FlockDB(图数据库),专门优化社交图谱查询
  • 分析 SINTER vs SUNION vs SDIFF 的使用场景(共同关注/全部关注/你关注但ta没关注的)
  • 提到可以用 Redis Cluster 的 hash tag {userId} 保证同一用户的 follows Set 在同一节点
  • 讨论"关注推荐"算法:基于共同关注数做推荐(SINTER 结果越多,推荐权重越高)

关联文档