Skip to content

如何设计关注/粉丝系统

一、问题描述

1.1 业务背景

关注系统是社交产品的核心功能,用于建立用户之间的关系链。

典型场景

  • 微博:单向关注
  • 微信:双向好友
  • 知乎:单向关注
  • 抖音:单向关注 + 互关好友

1.2 核心功能

  1. 关注用户:A关注B
  2. 取消关注:A取关B
  3. 关注列表:A关注了谁
  4. 粉丝列表:谁关注了A
  5. 共同关注:A和B的共同关注
  6. 推荐关注:推荐可能认识的人

1.3 技术挑战

大V问题

  • 热门用户有千万级粉丝
  • 关注列表/粉丝列表查询性能

一致性问题

  • 关注数、粉丝数统计准确性
  • 双向关系的一致性

实时性问题

  • Feed流推送时效性
  • 关注关系变更通知

二、数据库设计

2.1 关注关系表

sql
CREATE TABLE user_follow (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL COMMENT '关注者ID',
    follow_user_id BIGINT NOT NULL COMMENT '被关注者ID',
    status TINYINT DEFAULT 1 COMMENT '状态 1关注 0取消',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_user_follow (user_id, follow_user_id),
    KEY idx_follow_user (follow_user_id, create_time),
    KEY idx_user (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='关注关系表';

-- 分表:按user_id取模分16张表

2.2 关注统计表

sql
CREATE TABLE user_follow_count (
    user_id BIGINT PRIMARY KEY,
    following_count INT DEFAULT 0 COMMENT '关注数',
    follower_count INT DEFAULT 0 COMMENT '粉丝数',
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='关注统计表';

2.3 Redis数据结构

redis
# 1. 关注列表(Set)
Key: following:{user_id}
Value: {follow_user_id1, follow_user_id2, ...}
命令: SADD, SREM, SISMEMBER, SMEMBERS

# 2. 粉丝列表(Set)
Key: follower:{user_id}
Value: {follower_id1, follower_id2, ...}

# 3. 关注数/粉丝数(String)
Key: follow:count:{user_id}
Value: {"following": 123, "follower": 456}

# 4. 关注状态(Bitmap)
Key: follow:status:{user_id}
Bit位: follow_user_id对应的位
值: 1已关注 0未关注

三、核心实现

3.1 Go实现

点击查看完整实现
go
package follow

import (
    "context"
    "fmt"
    "time"
    
    "github.com/go-redis/redis/v8"
    "gorm.io/gorm"
)

type FollowService struct {
    db    *gorm.DB
    redis *redis.Client
    mq    MessageQueue
}

// Follow 关注
func (s *FollowService) Follow(ctx context.Context, userID, followUserID int64) error {
    // 1. 检查是否已关注
    isFollowing, _ := s.IsFollowing(ctx, userID, followUserID)
    if isFollowing {
        return fmt.Errorf("already following")
    }
    
    // 2. 写数据库
    follow := &UserFollow{
        UserID:       userID,
        FollowUserID: followUserID,
        Status:       1,
        CreateTime:   time.Now(),
    }
    
    err := s.db.Transaction(func(tx *gorm.DB) error {
        // 插入关注记录
        if err := tx.Create(follow).Error; err != nil {
            return err
        }
        
        // 更新统计
        tx.Exec("INSERT INTO user_follow_count (user_id, following_count) VALUES (?, 1) "+
            "ON DUPLICATE KEY UPDATE following_count = following_count + 1", userID)
        
        tx.Exec("INSERT INTO user_follow_count (user_id, follower_count) VALUES (?, 1) "+
            "ON DUPLICATE KEY UPDATE follower_count = follower_count + 1", followUserID)
        
        return nil
    })
    
    if err != nil {
        return err
    }
    
    // 3. 更新Redis
    pipe := s.redis.Pipeline()
    
    // 关注列表
    followingKey := fmt.Sprintf("following:%d", userID)
    pipe.SAdd(ctx, followingKey, followUserID)
    
    // 粉丝列表
    followerKey := fmt.Sprintf("follower:%d", followUserID)
    pipe.SAdd(ctx, followerKey, userID)
    
    // 统计数
    pipe.HIncrBy(ctx, fmt.Sprintf("follow:count:%d", userID), "following", 1)
    pipe.HIncrBy(ctx, fmt.Sprintf("follow:count:%d", followUserID), "follower", 1)
    
    _, err = pipe.Exec(ctx)
    
    // 4. 发送消息(异步处理)
    s.mq.Send("follow_topic", FollowMessage{
        Action:       "follow",
        UserID:       userID,
        FollowUserID: followUserID,
        Timestamp:    time.Now().Unix(),
    })
    
    return err
}

// Unfollow 取消关注
func (s *FollowService) Unfollow(ctx context.Context, userID, followUserID int64) error {
    // 1. 检查是否已关注
    isFollowing, _ := s.IsFollowing(ctx, userID, followUserID)
    if !isFollowing {
        return fmt.Errorf("not following")
    }
    
    // 2. 更新数据库
    err := s.db.Transaction(func(tx *gorm.DB) error {
        // 软删除
        if err := tx.Model(&UserFollow{}).
            Where("user_id = ? AND follow_user_id = ?", userID, followUserID).
            Update("status", 0).Error; err != nil {
            return err
        }
        
        // 更新统计
        tx.Exec("UPDATE user_follow_count SET following_count = GREATEST(following_count - 1, 0) WHERE user_id = ?", userID)
        tx.Exec("UPDATE user_follow_count SET follower_count = GREATEST(follower_count - 1, 0) WHERE user_id = ?", followUserID)
        
        return nil
    })
    
    if err != nil {
        return err
    }
    
    // 3. 更新Redis
    pipe := s.redis.Pipeline()
    
    followingKey := fmt.Sprintf("following:%d", userID)
    pipe.SRem(ctx, followingKey, followUserID)
    
    followerKey := fmt.Sprintf("follower:%d", followUserID)
    pipe.SRem(ctx, followerKey, userID)
    
    pipe.HIncrBy(ctx, fmt.Sprintf("follow:count:%d", userID), "following", -1)
    pipe.HIncrBy(ctx, fmt.Sprintf("follow:count:%d", followUserID), "follower", -1)
    
    _, err = pipe.Exec(ctx)
    
    return err
}

// IsFollowing 判断是否已关注
func (s *FollowService) IsFollowing(ctx context.Context, userID, followUserID int64) (bool, error) {
    // 1. 从Redis查询
    followingKey := fmt.Sprintf("following:%d", userID)
    isMember, err := s.redis.SIsMember(ctx, followingKey, followUserID).Result()
    
    if err == nil {
        return isMember, nil
    }
    
    // 2. Redis miss,从数据库查询
    var count int64
    err = s.db.Model(&UserFollow{}).
        Where("user_id = ? AND follow_user_id = ? AND status = 1", userID, followUserID).
        Count(&count).Error
    
    if err != nil {
        return false, err
    }
    
    return count > 0, nil
}

// GetFollowingList 获取关注列表
func (s *FollowService) GetFollowingList(ctx context.Context, userID int64, page, pageSize int) ([]int64, error) {
    // 1. 从Redis获取
    followingKey := fmt.Sprintf("following:%d", userID)
    members, err := s.redis.SMembers(ctx, followingKey).Result()
    
    if err == nil && len(members) > 0 {
        // 转换为int64
        userIDs := make([]int64, len(members))
        for i, member := range members {
            fmt.Sscanf(member, "%d", &userIDs[i])
        }
        
        // 分页
        start := (page - 1) * pageSize
        end := start + pageSize
        if start >= len(userIDs) {
            return []int64{}, nil
        }
        if end > len(userIDs) {
            end = len(userIDs)
        }
        
        return userIDs[start:end], nil
    }
    
    // 2. 从数据库查询
    var follows []UserFollow
    offset := (page - 1) * pageSize
    err = s.db.Where("user_id = ? AND status = 1", userID).
        Order("create_time DESC").
        Offset(offset).
        Limit(pageSize).
        Find(&follows).Error
    
    if err != nil {
        return nil, err
    }
    
    userIDs := make([]int64, len(follows))
    for i, follow := range follows {
        userIDs[i] = follow.FollowUserID
    }
    
    return userIDs, nil
}

// GetCommonFollowing 获取共同关注
func (s *FollowService) GetCommonFollowing(ctx context.Context, userID1, userID2 int64) ([]int64, error) {
    key1 := fmt.Sprintf("following:%d", userID1)
    key2 := fmt.Sprintf("following:%d", userID2)
    
    // Redis Set求交集
    members, err := s.redis.SInter(ctx, key1, key2).Result()
    if err != nil {
        return nil, err
    }
    
    userIDs := make([]int64, len(members))
    for i, member := range members {
        fmt.Sscanf(member, "%d", &userIDs[i])
    }
    
    return userIDs, nil
}

// GetFollowCount 获取关注数和粉丝数
func (s *FollowService) GetFollowCount(ctx context.Context, userID int64) (*FollowCount, error) {
    // 1. 从Redis获取
    countKey := fmt.Sprintf("follow:count:%d", userID)
    result, err := s.redis.HGetAll(ctx, countKey).Result()
    
    if err == nil && len(result) > 0 {
        var following, follower int64
        fmt.Sscanf(result["following"], "%d", &following)
        fmt.Sscanf(result["follower"], "%d", &follower)
        
        return &FollowCount{
            Following: following,
            Follower:  follower,
        }, nil
    }
    
    // 2. 从数据库获取
    var count UserFollowCount
    err = s.db.Where("user_id = ?", userID).First(&count).Error
    if err != nil {
        if err == gorm.ErrRecordNotFound {
            return &FollowCount{Following: 0, Follower: 0}, nil
        }
        return nil, err
    }
    
    // 写回Redis
    s.redis.HSet(ctx, countKey, "following", count.FollowingCount, "follower", count.FollowerCount)
    
    return &FollowCount{
        Following: int64(count.FollowingCount),
        Follower:  int64(count.FollowerCount),
    }, nil
}

// BatchCheckFollowing 批量检查关注状态
func (s *FollowService) BatchCheckFollowing(ctx context.Context, userID int64, targetUserIDs []int64) (map[int64]bool, error) {
    followingKey := fmt.Sprintf("following:%d", userID)
    
    result := make(map[int64]bool)
    
    // Pipeline批量查询
    pipe := s.redis.Pipeline()
    cmds := make(map[int64]*redis.BoolCmd)
    
    for _, targetID := range targetUserIDs {
        cmds[targetID] = pipe.SIsMember(ctx, followingKey, targetID)
    }
    
    _, err := pipe.Exec(ctx)
    if err != nil {
        return nil, err
    }
    
    for targetID, cmd := range cmds {
        result[targetID], _ = cmd.Result()
    }
    
    return result, nil
}

type UserFollow struct {
    ID           int64
    UserID       int64
    FollowUserID int64
    Status       int8
    CreateTime   time.Time
    UpdateTime   time.Time
}

type UserFollowCount struct {
    UserID         int64
    FollowingCount int
    FollowerCount  int
    UpdateTime     time.Time
}

type FollowCount struct {
    Following int64
    Follower  int64
}

type FollowMessage struct {
    Action       string
    UserID       int64
    FollowUserID int64
    Timestamp    int64
}

四、Feed流推送

4.1 推拉结合模式

推模式(写扩散)

  • 发布时推送给所有粉丝
  • 适合粉丝少的用户

拉模式(读扩散)

  • 查看时实时拉取关注的动态
  • 适合大V

推拉结合

  • 普通用户:推模式
  • 大V:拉模式
  • 活跃用户收件箱 + 大V动态拉取

4.2 实现

go
// 发布动态
func (s *FeedService) PublishFeed(ctx context.Context, userID int64, content string) error {
    // 1. 保存动态
    feed := &Feed{
        UserID:  userID,
        Content: content,
        CreateTime: time.Now(),
    }
    s.db.Create(feed)
    
    // 2. 获取粉丝数
    followerCount := s.followService.GetFollowerCount(userID)
    
    if followerCount < 10000 {
        // 推模式:推送给所有粉丝
        s.pushToFollowers(userID, feed)
    } else {
        // 拉模式:只存储,用户查看时拉取
        // 不做推送
    }
    
    return nil
}

// 获取Feed流
func (s *FeedService) GetFeedList(ctx context.Context, userID int64, page int) ([]*Feed, error) {
    var feeds []*Feed
    
    // 1. 从收件箱获取(推模式的动态)
    inboxFeeds := s.getInboxFeeds(userID, page)
    feeds = append(feeds, inboxFeeds...)
    
    // 2. 拉取大V的动态(拉模式)
    bigVFeeds := s.pullBigVFeeds(userID, page)
    feeds = append(feeds, bigVFeeds...)
    
    // 3. 按时间排序
    sort.Slice(feeds, func(i, j int) bool {
        return feeds[i].CreateTime.After(feeds[j].CreateTime)
    })
    
    return feeds, nil
}

五、面试要点

5.1 常见追问

Q1: 如何优化大V的粉丝列表查询?

A:

  1. 分页查询:不要一次性查询全部
  2. Redis缓存:缓存最近关注/粉丝
  3. 只显示部分:只显示最近1000个
  4. 异步加载:前端懒加载

Q2: 如何实现共同关注?

A: Redis Set求交集

redis
SINTER following:A following:B

Q3: Feed流如何设计?

A: 推拉结合

  • 普通用户:推模式(写扩散)
  • 大V:拉模式(读扩散)
  • 混合:活跃用户收件箱 + 大V拉取

六、总结

关注系统核心要点:

  1. 数据库设计:关注关系表 + 统计表
  2. Redis加速:Set存储关注列表
  3. 大V优化:推拉结合模式
  4. 一致性:事务保证 + 异步补偿

相关场景题

正在精进