Java实现好友关系案例

wen java案例 1

Java实现好友关系案例:从数据建模到社交图谱的完整实战

目录导读

  1. 引言:为什么好友关系系统是社交应用的基石
  2. 技术选型与核心设计思路
  3. 数据库表结构设计(MySQL + Redis)
  4. Java实体类与DAO层实现
  5. 核心业务逻辑:关注、取关、双向好友判定
  6. 好友列表查询与性能优化(缓存+分页)
  7. 案例代码演示(Spring Boot + MyBatis-Plus)
  8. 常见问题与面试问答(含陷阱分析)
  9. 总结与扩展方向(分布式场景)

为什么好友关系系统是社交应用的基石

在微信、微博、抖音等社交应用中,好友关系(Follow / Friend)是最核心的领域模型,一个设计不良的好友关系表,在用户量过百万后往往面临查询慢、数据冗余、事务一致性难保证等问题,本文通过一个可运行的 Java + Spring Boot 案例,深入剖析好友关系的存储设计、双向关系判定、以及高性能查询方案,你将学会如何用最小代价实现一套生产级的好友关系模块。

Java实现好友关系案例


技术选型与核心设计思路

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)

  • Keyuser:{userId}:following (存储所有关注对象ID的Sorted Set,score为时间戳)
  • Keyuser:{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)存储好友,会面临哪些可靠性和持久化风险?欢迎在评论区讨论,本文案例代码已全部给出,可在生产环境改造后直接复用。

抱歉,评论功能暂时关闭!