如何设计关注/粉丝系统
一、问题描述
1.1 业务背景
关注系统是社交产品的核心功能,用于建立用户之间的关系链。
典型场景:
- 微博:单向关注
- 微信:双向好友
- 知乎:单向关注
- 抖音:单向关注 + 互关好友
1.2 核心功能
- 关注用户:A关注B
- 取消关注:A取关B
- 关注列表:A关注了谁
- 粉丝列表:谁关注了A
- 共同关注:A和B的共同关注
- 推荐关注:推荐可能认识的人
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:
- 分页查询:不要一次性查询全部
- Redis缓存:缓存最近关注/粉丝
- 只显示部分:只显示最近1000个
- 异步加载:前端懒加载
Q2: 如何实现共同关注?
A: Redis Set求交集
redis
SINTER following:A following:BQ3: Feed流如何设计?
A: 推拉结合
- 普通用户:推模式(写扩散)
- 大V:拉模式(读扩散)
- 混合:活跃用户收件箱 + 大V拉取
六、总结
关注系统核心要点:
- 数据库设计:关注关系表 + 统计表
- Redis加速:Set存储关注列表
- 大V优化:推拉结合模式
- 一致性:事务保证 + 异步补偿
相关场景题:
