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

如何设计一个社交平台的关注/粉丝系统?------ 一位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. 关注关系服务(写扩散)

java 复制代码
@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. 粉丝列表查询(读扩散优化)

java 复制代码
@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. 计数服务(缓存+异步持久化)

java 复制代码
@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. 关系图谱服务(图数据库)

java 复制代码
@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. 热点用户特殊处理

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

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

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

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

  1. 粉丝列表分页陷阱

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

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

    • 采用监听Redis Streams的Binlog同步
    • 每日对账任务修复差异数据
    java 复制代码
    // 对账任务伪代码
    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. 突发流量应对

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

结语

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

记住三条黄金法则

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

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

相关推荐
葫芦和十三6 小时前
图解 MongoDB 21|选举与 failover:Primary 是怎么选出来的
后端·mongodb·agent
GetcharZp7 小时前
26k Star 开源内网穿透神器 NetBird,一分钟实现全球设备互联!
后端
考虑考虑7 小时前
Mybatis实现批量插入
java·后端·mybatis
咖啡八杯8 小时前
GoF设计模式——中介者模式
java·后端·spring·设计模式
lizhongxuan10 小时前
多Agent之间的区别
后端
青石路12 小时前
记一次多JDK版本问题的排查,一坑套一坑,差点没爬上来
java
杨充13 小时前
1.面向对象设计思想
后端
IT_陈寒13 小时前
Java的Date类又坑了我一次,改用时间戳真香
前端·人工智能·后端
systemPro13 小时前
2.6亿条设备数据,历史查询从超时到50ms,我做了什么
后端
要阿尔卑斯吗14 小时前
提示词优化启示:为什么“按顺序输出“比“关键度评分“更有效
后端