|

第八章 - 好友关注模块

本章核心目标:实现好友关注/取关、共同关注、关注推送(Feed 流)三大功能


8.1 关注和取关(p085)

8.1.1 需求分析

关注是单向操作,无需对方同意即可关注或取关。

两个接口:

接口 方法 路径 参数 说明
关注/取关 PUT /follow/{id}/{isFollow} id=被关注用户ID, isFollow=true/false true=关注, false=取关
查询是否关注 GET /follow/or/not/{id} id=被关注用户ID 返回 true/false

页面交互逻辑: - 页面加载时调用”是否关注”接口 → 决定显示”关注”还是”已关注” - 点击按钮调用关注/取关接口 → 切换按钮状态

8.1.2 数据库设计

中间表 tb_follow 记录用户之间的关注关系(多对多)

字段 类型 说明
id bigint (自增) 主键
user_id bigint 关注者(当前用户)
follow_user_id bigint 被关注者
create_time datetime 关注时间

⚠️ 注意: 主键需手动改为自增长,否则开发时需手动指定 ID

为什么取关用删除而非标记字段? - 如果用布尔值标记关注状态,取关后数据仍保留在表中 - 大量取关会产生大量无用数据,浪费空间 - 采用删除方案:关注=INSERT,取关=DELETE

8.1.3 代码实现

关注/取关接口(Controller):

@PutMapping("/follow/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId,
                     @PathVariable("isFollow") Boolean isFollow) {
    return followService.follow(followUserId, isFollow);
}

关注/取关业务(Service):

@Override
public Result follow(Long followUserId, Boolean isFollow) {
    // 1. 获取当前登录用户
    Long userId = UserHolder.getUser().getId();

    if (isFollow) {
        // 2. 关注 → 新增数据
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
    } else {
        // 3. 取关 → 删除数据
        boolean isSuccess = remove(new QueryWrapper<Follow>()
                .eq("user_id", userId)
                .eq("follow_user_id", followUserId));
    }
    return Result.ok();
}

查询是否关注接口:

@Override
public 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);
}

💡 优化提示: 查询是否存在时用 count() 而非 one(),避免查出完整数据,更高效


8.2 共同关注

8.2.1 需求分析

查看自己与目标用户之间共同关注的人(即两个关注列表的交集)。

实现思路: 将用户关注列表存入 Redis Set 集合,利用 Set 的交集(SINTER)功能求共同关注。

8.2.2 Redis Set 交集命令

# 添加关注关系
SADD follows:1 2     # 用户1 关注了 用户2
SADD follows:1 3     # 用户1 关注了 用户3
SADD follows:2 3     # 用户2 关注了 用户3

# 求交集 → 共同关注
SINTER follows:1 follows:2
# 结果: {3} → 用户1 和 用户2 共同关注了用户3

8.2.3 改造关注接口(同步写入 Redis)

在原有的关注/取关业务中,同时维护 Redis 中的关注集合

@Override
public Result follow(Long followUserId, Boolean isFollow) {
    Long userId = UserHolder.getUser().getId();
    String key = FOLLOW_KEY + userId;

    if (isFollow) {
        // 关注:写入数据库
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        // 同时写入 Redis Set
        if (isSuccess) {
            stringRedisTemplate.opsForSet().add(key, followUserId.toString());
        }
    } else {
        // 取关:删除数据库
        boolean isSuccess = remove(new QueryWrapper<Follow>()
                .eq("user_id", userId)
                .eq("follow_user_id", followUserId));
        // 同时从 Redis Set 移除
        if (isSuccess) {
            stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
        }
    }
    return Result.ok();
}

⚠️ 关键: 数据库操作成功后才写入 Redis,保证数据一致性

8.2.4 共同关注接口实现

@Override
public Result followCommons(Long id) {
    // 1. 获取当前用户 ID
    Long userId = UserHolder.getUser().getId();
    String key1 = FOLLOW_KEY + userId;
    String key2 = FOLLOW_KEY + id;

    // 2. 求两个 Set 的交集
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);

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

    // 3. 解析用户 ID,查询用户信息
    List<Long> ids = intersect.stream()
            .map(Long::valueOf)
            .collect(Collectors.toList());

    List<UserDTO> userDTOS = userService.listByIds(ids)
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());

    return Result.ok(userDTOS);
}

8.2.5 补充:进入用户主页的接口

共同关注功能需要进入目标用户主页,需实现两个辅助接口:

接口 路径 说明
查询用户信息 /user/{id} 根据 ID 查询用户
查询用户笔记 /blog/of/user 分页查询指定用户的探店笔记

这两个接口主要是基本 CRUD,可直接使用提供的代码


8.3 关注推送(Feed 流)

8.3.1 Feed 流概念

Feed 流 = 持续为用户推送沉浸式内容的模式,通过无限下拉刷新获取新信息。

两种模式对比:

模式 特点 典型应用 优点 缺点
Timeline 按发布时间排序,不做内容筛选 微信朋友圈 实现简单,信息全面 噪音多,用户不一定感兴趣
智能排序 算法筛选,推送用户感兴趣的内容 抖音、快手 用户粘性高,易沉浸 算法不精准会适得其反

本项目采用 Timeline 模式: 展示关注用户的探店笔记,按时间排序,不过滤。

8.3.2 三种实现方案对比

方案一:拉模式(读扩散)

每个博主有发件箱 → 粉丝读取时拉取所有关注人的发件箱 → 合并排序
优点 缺点
节省内存(收件箱可清理) 每次读取都要拉取+排序,延迟高
数据只存一份 关注人多时性能差

方案二:推模式(写扩散)

博主发消息 → 直接推送到每个粉丝的收件箱 → 粉丝读取时直接读
优点 缺点
读取延迟极低 内存占用高(每个粉丝一份数据)
实现简单 大V发消息会写入海量数据

方案三:推拉结合

普通人发消息 → 推模式(粉丝少)
大V发消息 → 推给活跃粉丝 + 普通粉丝读时拉取发件箱
优点 缺点
兼具两种模式优点 实现复杂
兼顾活跃用户和普通用户 需要区分粉丝类型

本项目选择推模式: 用户量不大 + 没有大V + 实现简单 + 延迟低

选型建议: - 用户量 < 千万 + 无大V → 推模式 - 用户量 > 千万 + 有大V → 推拉结合 - 拉模式不建议单独使用

8.3.3 收件箱数据结构选型

候选: List vs Sorted Set,两者都支持排序和分页

关键差异——滚动分页支持:

数据结构 分页方式 数据变化时 是否适合
List 按角标 角标变化导致数据混乱
Sorted Set 按 score 值范围 不受影响

🎯 结论: Feed 流场景中数据持续变化,必须用 Sorted Set,按时间戳作为 score

8.3.4 推送实现(发布笔记时推送给粉丝)

改造发布笔记的业务,保存成功后推送到粉丝收件箱:

@Override
public Result saveBlog(Blog blog) {
    // 1. 获取登录用户
    blog.setUserId(UserHolder.getUser().getId());

    // 2. 保存探店笔记到数据库
    boolean isSuccess = save(blog);
    if (!isSuccess) {
        return Result.fail("新增笔记失败!");
    }

    // 3. 查询笔记作者的所有粉丝
    List<Follow> follows = followService.query()
            .eq("follow_user_id", blog.getUserId())
            .list();

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

    return Result.ok(blog.getId());
}

💡 优化: 推送到收件箱时只存笔记 ID(score=时间戳),不存完整内容,节省内存

8.3.5 滚动分页查询原理

为什么不能用传统分页?

时间轴 T1: [10,9,8,7,6,5,4,3,2,1]  (score 从大到小)
  第1页(size=5): 返回 [10,9,8,7,6]

时间轴 T2: 新数据 [11] 插入 → [11,10,9,8,7,6,5,4,3,2,1]
  原来脚标为 4 的元素 "6" 现在脚标变成了 5

  第2页: page=2, size=5 → 脚标 5~9 → 返回 [6,5,4,3,2]  ← 出现重复!

滚动分页原理: 不依赖角标,而是记住上一次查询的最小 score,下次从这里继续

第1页: max=∞, offset=0, count=3 → [10,9,8]
第2页: max=8, offset=1, count=3 → [7,6,5]  ← 不受新数据影响

8.3.6 滚动分页参数详解

Sorted Set 滚动分页命令:

ZREVRANGEBYSCORE key max min WITHSCORES LIMIT offset count
参数 第一次查询 后续查询
max 当前时间戳(最大值) 上次查询的最小 score
min 0(固定) 0(固定)
offset 0 上次查询中与最小 score 相同元素的个数
count 固定值(如 2) 固定值

⚠️ 关键细节:offset 的计算 - offset 等于上一次查询结果中,与最小 score 值相同的元素个数 - 当存在相同 score 时,必须跳过所有同值元素,否则会重复

示例:

数据: [m8(8), m7(6), m6(6), m5(5), m4(4), m3(3)]
  第1页: max=1000, offset=0, count=3 → [m8, m7, m6]  minScore=6
  第2页: max=6, offset=2, count=3 → [m5, m4, m3]
         ↑ 6有两个元素(m7,m6),所以 offset=2

8.3.7 滚动分页代码实现

返回值对象 ScrollResult

@Data
public class ScrollResult<T> {
    private List<T> list;       // 当前页数据
    private Long minTime;       // 最小时间戳(下次查询的 max)
    private Integer offset;     // 偏移量(下次查询的 offset)
}

查询收件箱接口:

@GetMapping("/of/follow")
public Result queryBlogOfFollow(
        @RequestParam("lastId") Long max,
        @RequestParam(value = "offset", defaultValue = "0") Integer offset) {
    return blogService.queryBlogOfFollow(max, offset);
}

💡 lastId 是上次查询的最小时间戳,作为本次的 max;第一次查询由前端传当前时间,offset 默认 0

Service 实现:

@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    // 1. 获取当前用户
    Long userId = UserHolder.getUser().getId();

    // 2. 查询收件箱(Sorted Set 滚动分页)
    String key = FEED_KEY + userId;
    Set<ZSetOperations.TypedTuple<String>> tuples = stringRedisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, max, offset, 2);

    if (tuples == null || tuples.isEmpty()) {
        return Result.ok();
    }

    // 3. 解析数据:blogId 集合 + minTime + offset
    List<Long> ids = new ArrayList<>(tuples.size());
    long minTime = 0;
    int os = 1;

    for (ZSetOperations.TypedTuple<String> tuple : tuples) {
        ids.add(Long.valueOf(tuple.getValue()));
        long time = tuple.getScore().longValue();
        if (time == minTime) {
            os++;  // 相同 score 计数 +1
        } else {
            minTime = time;
            os = 1;  // 遇到新的更小值,重置
        }
    }

    // 4. 根据 ID 批量查询 Blog(保证顺序)
    String idStr = StrUtil.join(",", ids);
    List<Blog> blogs = query()
            .in("id", ids)
            .last("ORDER BY FIELD(id," + idStr + ")")
            .list();

    // 5. 为每个 Blog 补充用户信息和点赞状态
    for (Blog blog : blogs) {
        queryBlogUser(blog);
        isBlogLiked(blog);
    }

    // 6. 封装返回结果
    ScrollResult<Blog> result = new ScrollResult<>();
    result.setList(blogs);
    result.setMinTime(minTime);
    result.setOffset(os);

    return Result.ok(result);
}

⚠️ 排序保序问题: 使用 listByIds() 不保证返回顺序,需用 ORDER BY FIELD 强制按传入 ID 顺序返回


📌 核心知识点总结

Redis 数据结构应用

功能 数据结构 Key 设计 命令
共同关注 Set follows:{userId} SADD / SINTER
收件箱 Sorted Set feed:{userId} ZADD / ZREVRANGEBYSCORE

三种 Feed 流方案选型

场景 推荐方案
用户少 + 无大V 推模式(本项目)
用户多 + 有大V 推拉结合
任意场景 ❌ 不推荐单独拉模式

滚动分页四要素

┌──────────────────────────────────────────────┐
│  max     → 上次查询的最小 score              │
│  min     → 固定为 0(时间戳最小值)          │
│  offset  → 上次结果中与最小值相同元素的个数  │
│  count   → 固定值(每页条数)                │
└──────────────────────────────────────────────┘

⚠️ 注意事项

  1. 关注关系存双份: 同时写数据库(持久化)和 Redis(查询性能),数据库成功后才写 Redis
  2. count() vs one(): 判断是否存在时用 count()one() 更高效,避免查出完整数据
  3. Sorted Set 不可用 List 替代: Feed 流数据持续变化,List 的角标分页会导致数据重复或丢失
  4. offset 重置逻辑: 每次遍历遇到新的最小值时,offset 重置为 1;遇到相同值时 offset++
  5. 批量查询顺序: listByIds() 不保证顺序,需用 ORDER BY FIELD 手动排序
  6. 收件箱只存 ID: 推送时只存笔记 ID + 时间戳,查询时再关联完整数据,节省内存
评论交流

文章目录