在做黑马点评项目时,共同关注 是一个很典型的业务功能。
需求不复杂:
用户 A 访问用户 B 的主页时,可以查看自己与用户 B 共同关注了哪些人。
看起来简单,但背后涉及了几个值得深挖的技术点:
- 关系型数据的建模方式
- Redis Set 的天然优势
- 数据库与缓存的双写策略
- 接口的合理设计
本文结合项目代码,系统梳理一下这个功能的后端实现思路,也方便自己后续复习。
一、需求分析
共同关注本质上就是一个集合求交集的问题。
举例:
- 用户 A 关注了:[1、2、3、4]
- 用户 B 关注了:[3、4、5、6]
那么 A 和 B 的共同关注就是:[3、4]
所以这个功能的核心分两步:
- 存储:保存"谁关注了谁"的关系
- 查询:高效地求出两个用户关注集合的交集
二、为什么用 Redis Set,而不只用数据库?
如果纯用数据库,也能实现,比如 SQL 自连接,或者两次查询后在内存里取交集。
但在高并发场景下,这类操作会给数据库带来不小的压力。Redis Set 正好能解决这个问题。
Redis Set 有两个天然的特性特别符合这个需求:
元素唯一性
关注关系本来就不能重复,Set 的唯一性约束和业务语义完全匹配。
原生支持集合运算
Redis 提供了 SINTERSTORE / SINTER 等命令,可以直接在 Redis 层面完成交集运算,效率极高。
所以整体方案是数据库 + Redis 双写:
| 层级 | 职责 |
|---|---|
| 数据库 | 持久化存储,保证数据不丢失 |
| Redis Set | 加速交集查询,提升响应性能 |
一句话总结:数据库是准的,Redis 是快的。
三、Redis 数据结构设计
每个用户维护一个 Set,存储其关注的所有用户 id:
Key: follows:{userId}
Value: Set{ followUserId1, followUserId2, ... }
例如用户 100 关注了 200、201、202:
follows:100 → { "200", "201", "202" }
代码中常量定义:
public static final String FOLLOW_KEY = "follows:";
拼接完整 Key:
String key = FOLLOW_KEY + userId;
四、接口设计
共同关注功能对外提供 3 个接口:
1. 关注 / 取关
PUT /follow/{id}/{isFollow}
| 参数 | 说明 |
|---|---|
| id | 目标用户 id |
| isFollow | true = 关注,false = 取关 |
2. 是否关注
GET /follow/or/not/{id}
用于前端渲染"已关注 / 未关注"的按钮状态。
3. 共同关注列表
GET /follow/common/{id}
id 为目标用户 id,表示查询当前登录用户与目标用户的共同关注列表。
五、数据库实体设计
tb_follow 表对应的实体类:
@Data
@TableName("tb_follow")
public class Follow {
@TableId(type = IdType.AUTO)
private Long id;
/** 当前登录用户 id */
private Long userId;
/** 被关注用户 id */
private Long followUserId;
private LocalDateTime createTime;
}
六、关注 / 取关实现
整体思路
关注:写入数据库 → 写入 Redis Set
取关:删除数据库记录 → 从 Redis Set 移除
为什么先操作数据库,再操作 Redis?
数据库是最终数据来源,Redis 只是加速层。遵循先写库、成功后再更新缓存的原则,可以避免 Redis 中存着脏数据而数据库写失败的情况。
核心代码
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
String key = FOLLOW_KEY + userId;
// 2.判断到底是关注还是取关
if (isFollow) {
// 3.关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess) {
// 把关注的用户id放入Redis的set集合sadd userId followUserId
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 4.取关,删除 delete from tb_follow where userId = ? and follow_user_id = ?
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId)
.eq("follow_user_id", followUserId));
if (isSuccess) {
// 把关注的用户ids从Redis的set集合移除
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
七、是否关注实现
这个接口逻辑简单,直接查数据库记录数即可:
public Result isFollow(Long followUserId) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
// 2.查询是否关注 select count(*) from tb_follow where userId = ? and follow_user_id = ?
Integer count = query().eq("user_id", user.getId()).eq("follow_user_id", followUserId).count();
// 3.判断
return Result.ok(count > 0);
}
八、共同关注实现
这是本模块最核心的部分,完整流程如下:
核心代码
public Result followCommons(Long id) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
String key = FOLLOW_KEY + userId;
// 求交集
String key2 = FOLLOW_KEY + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
// 无交集
return Result.ok(Collections.emptyList());
}
// 3.解析id集合
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 4.查询用户
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
注意:listByIds 底层走的是 WHERE id IN (...) 查询,返回顺序不保证与传入 id 一致。若需要按特定顺序展示共同关注列表,需要在业务层手动排序。
九、Controller 层
/**
* 关注 / 取关
*/
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}
/**
* 是否关注
*/
@GetMapping("/or/not/{id}")
public Result follow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
/**
* 共同关注
*/
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id) {
return followService.followCommons(id);
}
十、完整执行流程梳理
关注流程
取关流程
共同关注流程
十一、小结
| 功能点 | 实现方式 |
|---|---|
| 关注/取关 | 数据库 + Redis Set 双写 |
| 是否关注 | 查数据库 count |
| 共同关注 | Redis SINTER 求交集,再查用户详情 |
Redis Set 在这个场景下是一个非常自然的选择:唯一性天然契合关注关系,SINTER 命令(opsForSet().intersect())让求共同关注变得极其简洁高效。理解了这个模块,对 Redis 在实际业务中的应用会有更直观的感受。