Java实现好友关系案例:从数据建模到社交图谱的完整实战
目录导读
- 引言:为什么好友关系系统是社交应用的基石
- 技术选型与核心设计思路
- 数据库表结构设计(MySQL + Redis)
- Java实体类与DAO层实现
- 核心业务逻辑:关注、取关、双向好友判定
- 好友列表查询与性能优化(缓存+分页)
- 案例代码演示(Spring Boot + MyBatis-Plus)
- 常见问题与面试问答(含陷阱分析)
- 总结与扩展方向(分布式场景)
为什么好友关系系统是社交应用的基石
在微信、微博、抖音等社交应用中,好友关系(Follow / Friend)是最核心的领域模型,一个设计不良的好友关系表,在用户量过百万后往往面临查询慢、数据冗余、事务一致性难保证等问题,本文通过一个可运行的 Java + Spring Boot 案例,深入剖析好友关系的存储设计、双向关系判定、以及高性能查询方案,你将学会如何用最小代价实现一套生产级的好友关系模块。

技术选型与核心设计思路
1 技术栈选择
- 后端框架:Spring Boot 2.7.x(简化配置,内置Tomcat)
- ORM框架:MyBatis-Plus(减少SQL编写,支持逻辑删除)
- 数据库:MySQL 8.0(InnoDB引擎,支持事务与行锁)
- 缓存:Redis 6.x(用于热点好友列表缓存)
- 核心算法:基于有向边的存储,配合反向索引实现双向关系
2 设计核心思想(重点)
好友关系不是简单的“用户-用户”对,而是包含 方向性 的。
- 用户A关注了B(A→B),但B不一定关注A。
- 如果A和B互相关注,则成为“双向好友”。
我们采用 两行记录 存储单方向关系(A→B 和 B→A 各一行),或者 一行记录 + 状态字段 区分双向,本文采用更通用的“两行记录”模式,易于扩展(如屏蔽、拉黑)。
数据库表结构设计(MySQL + Redis)
1 核心表 user_relation
CREATE TABLE `user_relation` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `user_id` BIGINT NOT NULL COMMENT '发起人ID', `target_id` BIGINT NOT NULL COMMENT '目标用户ID', `relation_type` TINYINT DEFAULT 1 COMMENT '1=关注 2=拉黑(预留)', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_target` (`user_id`,`target_id`), KEY `idx_target` (`target_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2 辅助缓存结构(Redis)
- Key:
user:{userId}:following(存储所有关注对象ID的Sorted Set,score为时间戳) - Key:
user:{userId}:followers(存储所有粉丝ID的Sorted Set)
注意:Redis缓存仅用于热门用户或近期活跃用户,全量数据以MySQL为准。
Java实体类与DAO层实现
1 实体类 UserRelation.java
@Data
@TableName("user_relation")
public class UserRelation {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long targetId;
private Integer relationType;
private LocalDateTime createdAt;
}
2 Mapper接口(继承BaseMapper)
public interface UserRelationMapper extends BaseMapper<UserRelation> {
// 自定义分页查询我的关注列表
IPage<UserRelation> selectFollowingPage(Page<?> page, @Param("userId") Long userId);
// 自定义查询是否已关注
Integer countRelation(@Param("userId") Long userId, @Param("targetId") Long targetId);
}
核心业务逻辑:关注、取关、双向判定
1 关注操作(含幂等性校验)
@Transactional
public boolean follow(Long userId, Long targetId) {
if (userId.equals(targetId)) {
throw new IllegalArgumentException("不能关注自己");
}
// 1. 查询是否已存在记录(防止重复关注)
int exists = userRelationMapper.selectCount(new LambdaQueryWrapper<UserRelation>()
.eq(UserRelation::getUserId, userId)
.eq(UserRelation::getTargetId, targetId));
if (exists > 0) return false; // 已关注
// 2. 插入一行:userId -> targetId
UserRelation relation = new UserRelation();
relation.setUserId(userId);
relation.setTargetId(targetId);
relation.setRelationType(1);
userRelationMapper.insert(relation);
// 3. 更新Redis缓存(异步或同步)
stringRedisTemplate.opsForZSet().add("user:" + userId + ":following", targetId.toString(), System.currentTimeMillis());
stringRedisTemplate.opsForZSet().add("user:" + targetId + ":followers", userId.toString(), System.currentTimeMillis());
return true;
}
2 判定双向好友关系
public boolean isFriend(Long userA, Long userB) {
// 注意:必须双向都存在记录才是好友
Integer aToB = userRelationMapper.selectCount(...); // A→B
Integer bToA = userRelationMapper.selectCount(...); // B→A
return (aToB > 0 && bToA > 0);
}
优化:当关注量极大时,可以引入“关系中间表”(如 relation_ship 存储一对双向ID),但会增加写复杂度,本文方法简单直观。
好友列表查询与性能优化(缓存+分页)
1 查询关注列表(分页)
public Page<UserVO> getFollowingPage(Long userId, int page, int size) {
// 优先查缓存(仅当Redis中有完整列表时)
Set<String> cachedIds = stringRedisTemplate.opsForZSet()
.reverseRange("user:" + userId + ":following", (page-1)*size, page*size-1);
if (cachedIds != null && !cachedIds.isEmpty()) {
// 根据ID批量查询用户信息(使用IN查询)
List<Long> ids = cachedIds.stream().map(Long::valueOf).collect(Collectors.toList());
return userService.listByIds(ids);
}
// 缓存失效则查MySQL,并重建缓存(略)
}
2 性能优化点(必须掌握)
- 索引覆盖:
(user_id, target_id)联合唯一索引已覆盖关注查询。 - 缓存穿透:使用空值对象缓存(如
Collections.emptyList())防止无结果的查询穿透。 - 缓存雪崩:给缓存设置随机过期时间(如 24h + 随机小时)。
- 大V用户:粉丝超过百万时,使用分页游标(基于Score)而非传统页码。
案例代码演示(Spring Boot + MyBatis-Plus)
1 控制器层接口
@RestController
@RequestMapping("/api/relation")
public class RelationController {
@PostMapping("/follow")
public Result<Void> follow(@RequestParam Long from, @RequestParam Long to) {
relationService.follow(from, to);
return Result.success();
}
@GetMapping("/isfriend")
public Result<Boolean> isFriend(@RequestParam Long a, @RequestParam Long b) {
return Result.success(relationService.isFriend(a, b));
}
}
2 测试用例(JUnit)
@Test
@Transactional
void testFriendCycle() {
relationService.follow(1L, 2L);
relationService.follow(2L, 1L);
assertTrue(relationService.isFriend(1L, 2L));
relationService.unfollow(1L, 2L);
assertFalse(relationService.isFriend(1L, 2L));
}
常见问题与面试问答(含陷阱分析)
Q1:如果用户A拉黑了B,但B仍然关注A,怎么处理?
实操:在
relation_type中增加类型2(拉黑),查询好友列表时,需NOT EXISTS子查询排除被拉黑关系,或通过Redis列表过滤,最保险的是:拉黑操作同时删除对方的好友关系记录。
Q2:使用Redis缓存后,如何保证与MySQL的最终一致性?
方案:引入延迟双删策略(先删缓存,再更新数据库,延迟500ms后再删一次),或者使用Canal订阅MySQL binlog,异步刷新Redis。
Q3:大V用户(千万粉丝)查询粉丝列表为何特别慢?
原因:普通分页查询会
OFFSET过大,导致全表扫描,解法:基于时间戳的游标分页(例如用created_at <= lastTime查询下一批),并定期将大V的粉丝关系同步到专门的宽表或ES。
总结与扩展方向
1 本文总结
通过本文,你已掌握:
- 好友关系的 有向存储模型 与 双向判定逻辑
- 使用Spring Boot + MyBatis-Plus实现完整的CRUD与业务校验
- 基于Redis的优化方案,以及常见缓存问题解法
2 生产级扩展建议
- 分布式存储:当数据量过大时,对
user_id进行哈希取模分库分表。 - 图数据库:如果需要“好友的好友”等深度遍历,可引入Neo4j。
- 异步解耦:关注/取关操作通过MQ(如RabbitMQ)异步写下游报表、消息推送。
延伸思考:如果不使用数据库,而仅用Redis的Set集合(SADD/SISMEMBER)存储好友,会面临哪些可靠性和持久化风险?欢迎在评论区讨论,本文案例代码已全部给出,可在生产环境改造后直接复用。