如何设计一个社交平台的关注/粉丝系统?一位8年Java开发者的架构心路

0 阅读5分钟

如何设计一个社交平台的关注/粉丝系统?—— 一位8年Java开发者的架构心路

当你的社交平台面临百万用户实时互动,如何确保关注操作毫秒级响应?如何保证粉丝列表的实时性和一致性? 这个看似基础的功能背后,隐藏着读写扩散、数据一致性、热点用户等架构难题。本文将带你从业务模型到代码落地,构建一个支撑千万级关系的社交系统。


一、业务场景与核心挑战

典型关注业务流程

graph TD
    A[用户A关注用户B] --> B{关系检查}
    B -->|未关注| C[写入关注关系]
    C --> D[更新粉丝数/关注数]
    D --> E[推送粉丝动态]
    B -->|已关注| F[返回错误]

高并发场景下的核心挑战

  1. 读写扩散:读操作(粉丝列表)远多于写操作(关注)
  2. 数据一致性:如何保证计数与关系列表的强一致?
  3. 热点用户:明星账号百万粉丝列表如何高效存储?
  4. 实时推送:新粉丝动态如何实时触达?

二、架构设计要点

1. 分层架构设计

graph LR
    Client --> API_Gateway
    API_Gateway --> Follow_Service[关注服务]
    Follow_Service --> Cache[Redis集群]
    Follow_Service --> DB[分库分表MySQL]
    Follow_Service --> MQ[Kafka消息队列]
    Follow_Service --> GraphDB[Neo4j图数据库]

2. 关键设计决策

  • 存储策略:组合使用关系型DB + 图数据库 + Redis
  • 计数方案:Redis原子计数 + MySQL异步持久化
  • 列表查询:Redis SortedSet存储热点粉丝列表
  • 实时推送:Kafka + WebSocket双通道推送
  • 冷热分离:ES存储历史粉丝数据

三、核心代码实现(附详细注释)

1. 关注关系服务(写扩散)

@Service
public class FollowService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
    
    private static final String FOLLOWING_KEY = "following:%d";   // 用户关注集合
    private static final String FOLLOWERS_KEY = "followers:%d"; // 粉丝集合
    private static final String FOLLOW_COUNT = "follow_count:%d"; // 计数Key

    /**
     * 关注操作(原子性保证)
     * @param userId   操作者ID
     * @param targetId 被关注用户ID
     * @return 是否成功
     */
    public boolean followUser(long userId, long targetId) {
        // 1. 检查是否已关注
        String followingKey = String.format(FOLLOWING_KEY, userId);
        if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(followingKey, String.valueOf(targetId)))) {
            throw new BusinessException("已关注该用户");
        }

        // 2. 使用Lua脚本保证原子操作
        String luaScript = 
            "local followingKey = KEYS[1] " +
            "local followersKey = KEYS[2] " +
            "local userId = ARGV[1] " +
            "local targetId = ARGV[2] " +
            
            // 添加关注关系
            "redis.call('sadd', followingKey, targetId) " +
            "redis.call('sadd', followersKey, userId) " +
            
            // 更新计数
            "redis.call('hincrby', KEYS[3], 'following', 1) " +
            "redis.call('hincrby', KEYS[4], 'followers', 1) " +
            
            "return 1";

        DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
        String followersCountKey = String.format(FOLLOW_COUNT, targetId);
        String followingCountKey = String.format(FOLLOW_COUNT, userId);
        
        redisTemplate.execute(
            script,
            Arrays.asList(
                followingKey,
                String.format(FOLLOWERS_KEY, targetId),
                followingCountKey,
                followersCountKey
            ),
            String.valueOf(userId),
            String.valueOf(targetId)
        );

        // 3. 异步持久化到数据库
        kafkaTemplate.send("follow-events", 
            new FollowEvent(userId, targetId, System.currentTimeMillis()).toJson()
        );

        // 4. 实时推送新粉丝通知
        pushNewFollowerNotification(targetId, userId);
        
        return true;
    }
    
    // WebSocket实时推送
    private void pushNewFollowerNotification(long targetId, long followerId) {
        String channel = "user:" + targetId + ":followers";
        redisTemplate.convertAndSend(channel, String.valueOf(followerId));
    }
}

2. 粉丝列表查询(读扩散优化)

@Service
public class FollowerQueryService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private ElasticsearchRestTemplate esTemplate;
    
    /**
     * 获取粉丝列表(带分页和缓存)
     * @param userId 用户ID
     * @param page   页码
     * @param size   每页大小
     * @return 粉丝ID列表
     */
    public List<Long> getFollowers(long userId, int page, int size) {
        String redisKey = String.format("followers:%d", userId);
        
        // 1. 尝试从Redis获取
        Set<String> followers = redisTemplate.opsForZSet().reverseRange(
            redisKey, page * size, (page + 1) * size - 1
        );
        
        if (followers != null && !followers.isEmpty()) {
            return followers.stream()
                .map(Long::valueOf)
                .collect(Collectors.toList());
        }
        
        // 2. Redis未命中,查询ES(冷数据)
        return queryFollowersFromES(userId, page, size);
    }
    
    // ES分页查询(用于历史数据)
    private List<Long> queryFollowersFromES(long userId, int page, int size) {
        NativeSearchQuery query = new NativeSearchQueryBuilder()
            .withQuery(QueryBuilders.termQuery("targetId", userId))
            .withPageable(PageRequest.of(page, size))
            .withSort(SortBuilders.fieldSort("followTime").order(SortOrder.DESC))
            .build();
        
        SearchHits<FollowerDoc> hits = esTemplate.search(query, FollowerDoc.class);
        return hits.getSearchHits().stream()
            .map(hit -> hit.getContent().getFollowerId())
            .collect(Collectors.toList());
    }
}

3. 计数服务(缓存+异步持久化)

@Service
public class CountService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    /**
     * 获取粉丝数(优先缓存)
     * @param userId 用户ID
     * @return 粉丝数量
     */
    public long getFollowerCount(long userId) {
        String countKey = String.format("follow_count:%d", userId);
        String count = redisTemplate.opsForHash().get(countKey, "followers");
        
        if (count != null) {
            return Long.parseLong(count);
        }
        
        // 缓存未命中,从DB加载
        long dbCount = jdbcTemplate.queryForObject(
            "SELECT follower_count FROM user_stats WHERE user_id = ?", 
            Long.class, userId
        );
        
        // 回填缓存
        redisTemplate.opsForHash().put(countKey, "followers", String.valueOf(dbCount));
        return dbCount;
    }
    
    /**
     * 异步更新数据库计数
     */
    @KafkaListener(topics = "count-updates")
    public void updateCount(ConsumerRecord<String, String> record) {
        CountUpdateEvent event = CountUpdateEvent.fromJson(record.value());
        jdbcTemplate.update(
            "INSERT INTO user_stats (user_id, follower_count, following_count) " +
            "VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE " +
            "follower_count = follower_count + ?, " +
            "following_count = following_count + ?",
            event.getUserId(), 
            event.getFollowerDelta(), event.getFollowingDelta(),
            event.getFollowerDelta(), event.getFollowingDelta()
        );
    }
}

4. 关系图谱服务(图数据库)

@Service
public class RelationGraphService {
    @Autowired
    private Neo4jTemplate neo4jTemplate;
    
    /**
     * 查询共同关注(二度关系)
     * @param userId1 用户A
     * @param userId2 用户B
     * @return 共同关注的用户列表
     */
    public List<Long> findMutualFollows(long userId1, long userId2) {
        String query = "MATCH (u1:User {id: $id1})-[:FOLLOWS]->(common:User)<-[:FOLLOWS]-(u2:User {id: $id2}) " +
                      "RETURN common.id";
        
        Map<String, Object> params = Map.of("id1", userId1, "id2", userId2);
        return neo4jTemplate.findAll(query, params, Long.class);
    }
    
    /**
     * 推荐可能认识的人(三度关系)
     * @param userId 当前用户
     * @return 推荐用户列表
     */
    public List<Long> recommendUsers(long userId) {
        String query = "MATCH (me:User {id: $userId})-[:FOLLOWS*2..3]->(potential:User) " +
                      "WHERE NOT (me)-[:FOLLOWS]->(potential) " +
                      "RETURN potential.id, COUNT(*) AS commonConnections " +
                      "ORDER BY commonConnections DESC LIMIT 10";
        
        return neo4jTemplate.findAll(query, Map.of("userId", userId), Long.class);
    }
}

四、关键优化点与性能指标

性能优化方案:

  1. 读写分离

    • 写:同步更新Redis,异步持久化DB
    • 读:优先Redis,冷数据走ES
  2. 热点用户特殊处理

    // 对百万粉丝账号使用分片存储
    String shardKey = "followers:" + userId + ":" + (followerId % 32);
    
  3. 缓存策略

    • 粉丝列表:Redis SortedSet(按关注时间排序)
    • 计数数据:Redis Hash持久存储
  4. 数据冷热分离

    • 热数据:最近6个月粉丝存储在Redis
    • 冷数据:历史数据迁移到ES

五、避坑指南(血泪经验)

  1. 粉丝列表分页陷阱

    // 错误:ZRANGE不支持跨分片
    // 正确:使用ZSCAN+游标分页
    Cursor<ZSetOperations.TypedTuple<String>> cursor = redisTemplate.opsForZSet()
        .scan(key, ScanOptions.scanOptions().count(100).build());
    
  2. 缓存穿透解决方案

    // 布隆过滤器防止无效用户查询
    if (!bloomFilter.mightContain(userId)) {
        throw new UserNotFoundException();
    }
    
  3. 数据一致性保障

    • 采用监听Redis Streams的Binlog同步
    • 每日对账任务修复差异数据
    // 对账任务伪代码
    public void reconcileCount(long userId) {
        long redisCount = getFollowerCountFromRedis(userId);
        long dbCount = getFollowerCountFromDB(userId);
        if (redisCount != dbCount) {
            logger.warn("计数不一致: userId={}, redis={}, db={}", userId, redisCount, dbCount);
            // 自动修复逻辑...
        }
    }
    
  4. 突发流量应对

    • 粉丝列表查询降级方案:
    // 降级返回前100粉丝+总数
    public FollowerList getFollowersDegraded(long userId) {
        return new FollowerList(
            redisTemplate.opsForZSet().reverseRange("followers:"+userId, 0, 99),
            getFollowerCount(userId)
        );
    }
    

结语

设计关注/粉丝系统就像构建一座社交关系的大厦:既要保证地基(数据存储)的稳固可靠,又要让电梯(读写性能)高速运行,还要能随时扩建(水平扩展)。通过本文的读写分离、冷热分层、图数据库等方案,我们成功支撑了千万级用户的实时社交关系维护。

记住三条黄金法则

  1. 写操作轻量化:异步化所有非必要操作
  2. 读操作多样化:根据场景选择最佳存储
  3. 数据最终一致:接受短暂延迟换取高性能

架构没有银弹,最好的设计永远是适合业务发展的设计。当你的系统面临下一个量级挑战时,不妨回头看看:当前的瓶颈是否源于昨天的妥协?