📰 设计一个新闻Feed流系统:朋友圈的架构!

48 阅读11分钟

📖 开场:刷朋友圈

想象你在刷微信朋友圈 📱:

简单实现

打开朋友圈
    ↓
查询我关注的100个好友
    ↓
从每个好友那里拉取最新10条动态
    ↓
合并 + 排序 + 返回

问题:
- 查询100次数据库 → 太慢!❌
- 好友发动态 → 我看不到(需要我打开才能看到)❌

现实场景

微博:
- 用户:5亿
- 关注数:平均200人
- 明星粉丝:5000万

挑战:
- 如何实时推送?⏰
- 如何支持高并发?💥
- 明星发动态 → 5000万人收到?😱

这就是Feed流系统的挑战!


🤔 核心概念

Feed流的两种模式

推模式(Write Fanout)📤

用户A发动态
    ↓
推送给所有粉丝的收件箱
    ↓
粉丝打开Feed流 → 直接读收件箱

例如:
A发动态 → 推送给粉丝[B, C, D]的收件箱
B打开朋友圈 → 读取自己的收件箱(很快)✅

优点:
- 读快(直接读收件箱)✅

缺点:
- 写慢(明星5000万粉丝,要写5000万次)❌
- 存储大(每个人一个收件箱)❌

拉模式(Read Fanout)📥

用户A发动态
    ↓
只保存动态,不推送
    ↓
粉丝B打开Feed流 → 实时拉取关注的人的最新动态

例如:
A发动态 → 保存
B打开朋友圈 → 实时拉取关注的[A, C, D, E]的最新动态
    ↓
合并 + 排序 + 返回

优点:
- 写快(只写一次)✅

缺点:
- 读慢(需要聚合多个人的动态)❌

推拉结合(最佳实践)⭐⭐⭐

普通用户:推模式
- 粉丝少(<1000)
- 发动态 → 推送给粉丝收件箱

明星用户:拉模式
- 粉丝多(>1000)
- 发动态 → 只保存
- 粉丝打开 → 实时拉取

读取Feed流:
1. 从收件箱读取(推模式的动态)
2. 实时拉取明星用户的最新动态(拉模式)
3. 合并 + 排序 + 返回

优点:
- 普通用户读快 ✅
- 明星用户写快 ✅
- 兼顾性能和体验 ✅

🎯 架构设计

整体架构

┌─────────────────────────────────────────┐
│            客户端                        │
│  - 发布动态                              │
│  - 刷新Feed流                            │
│  - 点赞、评论                            │
└─────────────┬───────────────────────────┘
              │
              ↓
┌─────────────────────────────────────────┐
│         应用服务器                       │
│                                         │
│  - Feed发布服务                         │
│  - Feed读取服务                         │
│  - 推拉判断逻辑                         │
└───────┬─────────────┬───────────────────┘
        │             │
        ↓             ↓
┌──────────────┐  ┌──────────────────────┐
│   Redis      │  │   MySQL              │
│              │  │                      │
│ - 收件箱     │  │ - 动态表             │
│ - 关注关系   │  │ - 用户表             │
│ - 热点数据   │  │ - 关注关系表         │
└──────────────┘  └──────────────────────┘

数据模型设计

MySQL表设计

-- ⭐ 动态表
CREATE TABLE feed (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL COMMENT '用户ID',
    content TEXT COMMENT '动态内容',
    images VARCHAR(1000) COMMENT '图片URL(逗号分隔)',
    create_time DATETIME NOT NULL,
    
    INDEX idx_user_id_time (user_id, create_time DESC)  -- 查询某用户的动态
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ⭐ 互动表(点赞、评论)
CREATE TABLE feed_interaction (
    id BIGINT PRIMARY KEY,
    feed_id BIGINT NOT NULL COMMENT '动态ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    type TINYINT NOT NULL COMMENT '类型:1-点赞,2-评论',
    content TEXT COMMENT '评论内容',
    create_time DATETIME NOT NULL,
    
    INDEX idx_feed_id (feed_id),
    INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ⭐ 关注关系表
CREATE TABLE user_follow (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL COMMENT '用户ID',
    follow_id BIGINT NOT NULL COMMENT '关注的用户ID',
    create_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_user_follow (user_id, follow_id),
    INDEX idx_follow_id (follow_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Redis数据结构

1. 收件箱(推模式):
key: inbox:{userId}
value: List<feedId>(按时间倒序)

例如:
inbox:1001 → [10005, 10003, 10001]

2. 用户动态(拉模式):
key: user:feed:{userId}
value: List<feedId>

例如:
user:feed:2001 → [20005, 20003, 20001]

3. 关注列表:
key: follow:{userId}
value: Set<followId>

例如:
follow:1001 → {2001, 2002, 2003}

4. 粉丝数:
key: fans:count:{userId}
value: 粉丝数量

例如:
fans:count:20015000000500万粉丝)

核心功能实现

1️⃣ 发布动态

@Service
@Slf4j
public class FeedPublishService {
    
    @Autowired
    private FeedDao feedDao;
    
    @Autowired
    private UserFollowDao followDao;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private FeedPushService pushService;
    
    private static final int STAR_THRESHOLD = 1000;  // 明星用户阈值
    
    /**
     * ⭐ 发布动态
     */
    @Transactional
    public Feed publishFeed(Long userId, String content, List<String> images) {
        // 1. 保存动态到数据库
        Feed feed = new Feed();
        feed.setId(idGenerator.generateId());
        feed.setUserId(userId);
        feed.setContent(content);
        feed.setImages(String.join(",", images));
        feed.setCreateTime(new Date());
        
        feedDao.insert(feed);
        
        log.info("动态保存成功: feedId={}, userId={}", feed.getId(), userId);
        
        // 2. ⭐ 推送到Redis(用户动态列表)
        String userFeedKey = "user:feed:" + userId;
        redisTemplate.opsForList().leftPush(userFeedKey, feed.getId().toString());
        
        // 只保留最新100条
        redisTemplate.opsForList().trim(userFeedKey, 0, 99);
        
        // 3. ⭐ 判断是否是明星用户
        Long fansCount = getFansCount(userId);
        
        if (fansCount < STAR_THRESHOLD) {
            // ⭐ 普通用户:推模式
            pushToFans(userId, feed.getId());
            log.info("推模式推送: userId={}, fansCount={}", userId, fansCount);
        } else {
            // ⭐ 明星用户:拉模式(不推送)
            log.info("明星用户,使用拉模式: userId={}, fansCount={}", userId, fansCount);
        }
        
        return feed;
    }
    
    /**
     * ⭐ 推送到粉丝收件箱(推模式)
     */
    private void pushToFans(Long userId, Long feedId) {
        // 1. 查询粉丝列表(从Redis)
        Set<String> fans = redisTemplate.opsForSet().members("fans:" + userId);
        
        if (fans == null || fans.isEmpty()) {
            // Redis中没有,从数据库加载
            List<UserFollow> followList = followDao.findFansByUserId(userId);
            fans = followList.stream()
                .map(f -> f.getUserId().toString())
                .collect(Collectors.toSet());
            
            // 缓存到Redis
            if (!fans.isEmpty()) {
                redisTemplate.opsForSet().add("fans:" + userId, fans.toArray(new String[0]));
            }
        }
        
        // 2. ⭐ 推送到每个粉丝的收件箱
        for (String fanId : fans) {
            String inboxKey = "inbox:" + fanId;
            
            // ⭐ 添加到收件箱(List,头部插入)
            redisTemplate.opsForList().leftPush(inboxKey, feedId.toString());
            
            // 只保留最新100条
            redisTemplate.opsForList().trim(inboxKey, 0, 99);
        }
        
        log.info("推送到粉丝收件箱: userId={}, fansCount={}", userId, fans.size());
    }
    
    /**
     * 获取粉丝数
     */
    private Long getFansCount(Long userId) {
        String key = "fans:count:" + userId;
        String count = redisTemplate.opsForValue().get(key);
        
        if (count != null) {
            return Long.parseLong(count);
        }
        
        // 从数据库查询
        Long fansCount = followDao.countFansByUserId(userId);
        
        // 缓存
        redisTemplate.opsForValue().set(key, String.valueOf(fansCount), 1, TimeUnit.HOURS);
        
        return fansCount;
    }
}

2️⃣ 读取Feed流

@Service
@Slf4j
public class FeedReadService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private FeedDao feedDao;
    
    @Autowired
    private UserFollowDao followDao;
    
    private static final int STAR_THRESHOLD = 1000;
    
    /**
     * ⭐ 读取Feed流(推拉结合)
     */
    public List<Feed> getTimeline(Long userId, Long lastFeedId, int size) {
        List<Feed> feeds = new ArrayList<>();
        
        // 1. ⭐ 从收件箱读取(推模式的动态)
        List<String> inboxFeedIds = getInboxFeeds(userId, lastFeedId, size);
        
        if (!inboxFeedIds.isEmpty()) {
            List<Feed> inboxFeeds = feedDao.findByIds(inboxFeedIds);
            feeds.addAll(inboxFeeds);
        }
        
        // 2. ⭐ 拉取明星用户的动态(拉模式)
        List<Long> starFollows = getStarFollows(userId);
        
        if (!starFollows.isEmpty()) {
            List<Feed> starFeeds = pullStarFeeds(starFollows, lastFeedId, size);
            feeds.addAll(starFeeds);
        }
        
        // 3. ⭐ 合并 + 去重 + 排序
        List<Feed> result = feeds.stream()
            .distinct()  // 去重
            .sorted(Comparator.comparing(Feed::getCreateTime).reversed())  // 按时间倒序
            .limit(size)  // 限制数量
            .collect(Collectors.toList());
        
        log.info("读取Feed流: userId={}, count={}", userId, result.size());
        
        return result;
    }
    
    /**
     * ⭐ 从收件箱读取(推模式)
     */
    private List<String> getInboxFeeds(Long userId, Long lastFeedId, int size) {
        String inboxKey = "inbox:" + userId;
        
        if (lastFeedId == null) {
            // 第一页,从头读取
            return redisTemplate.opsForList().range(inboxKey, 0, size - 1);
        } else {
            // 分页,从lastFeedId之后读取
            Long index = redisTemplate.opsForList().indexOf(inboxKey, lastFeedId.toString());
            if (index != null && index >= 0) {
                return redisTemplate.opsForList().range(inboxKey, index + 1, index + size);
            }
        }
        
        return Collections.emptyList();
    }
    
    /**
     * ⭐ 获取明星用户列表
     */
    private List<Long> getStarFollows(Long userId) {
        // 查询我关注的人
        Set<String> follows = redisTemplate.opsForSet().members("follow:" + userId);
        
        if (follows == null || follows.isEmpty()) {
            return Collections.emptyList();
        }
        
        // 过滤出明星用户(粉丝数>1000)
        List<Long> starFollows = new ArrayList<>();
        
        for (String followId : follows) {
            Long fansCount = getFansCount(Long.parseLong(followId));
            if (fansCount >= STAR_THRESHOLD) {
                starFollows.add(Long.parseLong(followId));
            }
        }
        
        return starFollows;
    }
    
    /**
     * ⭐ 拉取明星用户的动态(拉模式)
     */
    private List<Feed> pullStarFeeds(List<Long> starUserIds, Long lastFeedId, int size) {
        List<Feed> starFeeds = new ArrayList<>();
        
        for (Long starUserId : starUserIds) {
            // 从Redis读取明星用户的最新动态
            String userFeedKey = "user:feed:" + starUserId;
            List<String> feedIds = redisTemplate.opsForList().range(userFeedKey, 0, size - 1);
            
            if (feedIds != null && !feedIds.isEmpty()) {
                List<Feed> feeds = feedDao.findByIds(feedIds);
                starFeeds.addAll(feeds);
            }
        }
        
        return starFeeds;
    }
    
    private Long getFansCount(Long userId) {
        String key = "fans:count:" + userId;
        String count = redisTemplate.opsForValue().get(key);
        return count != null ? Long.parseLong(count) : 0L;
    }
}

3️⃣ 点赞和评论

@Service
@Slf4j
public class FeedInteractionService {
    
    @Autowired
    private FeedInteractionDao interactionDao;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⭐ 点赞
     */
    public boolean like(Long feedId, Long userId) {
        // 1. 检查是否已点赞
        String likeKey = "like:" + feedId;
        Boolean isMember = redisTemplate.opsForSet().isMember(likeKey, userId.toString());
        
        if (Boolean.TRUE.equals(isMember)) {
            log.warn("已经点赞过了: feedId={}, userId={}", feedId, userId);
            return false;
        }
        
        // 2. 保存到数据库
        FeedInteraction interaction = new FeedInteraction();
        interaction.setId(idGenerator.generateId());
        interaction.setFeedId(feedId);
        interaction.setUserId(userId);
        interaction.setType(1);  // 1-点赞
        interaction.setCreateTime(new Date());
        
        interactionDao.insert(interaction);
        
        // 3. ⭐ 添加到Redis Set
        redisTemplate.opsForSet().add(likeKey, userId.toString());
        
        // 4. ⭐ 点赞数+1
        String countKey = "like:count:" + feedId;
        redisTemplate.opsForValue().increment(countKey);
        
        log.info("点赞成功: feedId={}, userId={}", feedId, userId);
        
        return true;
    }
    
    /**
     * ⭐ 取消点赞
     */
    public boolean unlike(Long feedId, Long userId) {
        // 1. 检查是否已点赞
        String likeKey = "like:" + feedId;
        Boolean isMember = redisTemplate.opsForSet().isMember(likeKey, userId.toString());
        
        if (Boolean.FALSE.equals(isMember)) {
            log.warn("未点赞: feedId={}, userId={}", feedId, userId);
            return false;
        }
        
        // 2. 删除数据库记录
        interactionDao.deleteByFeedIdAndUserIdAndType(feedId, userId, 1);
        
        // 3. ⭐ 从Redis Set移除
        redisTemplate.opsForSet().remove(likeKey, userId.toString());
        
        // 4. ⭐ 点赞数-1
        String countKey = "like:count:" + feedId;
        redisTemplate.opsForValue().decrement(countKey);
        
        log.info("取消点赞成功: feedId={}, userId={}", feedId, userId);
        
        return true;
    }
    
    /**
     * ⭐ 评论
     */
    public FeedInteraction comment(Long feedId, Long userId, String content) {
        // 1. 保存到数据库
        FeedInteraction interaction = new FeedInteraction();
        interaction.setId(idGenerator.generateId());
        interaction.setFeedId(feedId);
        interaction.setUserId(userId);
        interaction.setType(2);  // 2-评论
        interaction.setContent(content);
        interaction.setCreateTime(new Date());
        
        interactionDao.insert(interaction);
        
        // 2. ⭐ 评论数+1
        String countKey = "comment:count:" + feedId;
        redisTemplate.opsForValue().increment(countKey);
        
        log.info("评论成功: feedId={}, userId={}", feedId, userId);
        
        return interaction;
    }
    
    /**
     * 查询点赞列表
     */
    public List<Long> getLikeUsers(Long feedId) {
        String likeKey = "like:" + feedId;
        Set<String> userIds = redisTemplate.opsForSet().members(likeKey);
        
        if (userIds == null || userIds.isEmpty()) {
            return Collections.emptyList();
        }
        
        return userIds.stream()
            .map(Long::parseLong)
            .collect(Collectors.toList());
    }
    
    /**
     * 查询评论列表
     */
    public List<FeedInteraction> getComments(Long feedId, int page, int size) {
        int offset = (page - 1) * size;
        return interactionDao.findByFeedIdAndType(feedId, 2, offset, size);
    }
}

🎯 高级功能

1️⃣ 热点数据缓存

@Service
public class FeedCacheService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⭐ 缓存热点动态
     */
    public void cacheHotFeed(Feed feed) {
        String key = "feed:hot:" + feed.getId();
        
        // 转换为JSON
        String json = JSON.toJSONString(feed);
        
        // 缓存1小时
        redisTemplate.opsForValue().set(key, json, 1, TimeUnit.HOURS);
    }
    
    /**
     * 获取热点动态
     */
    public Feed getHotFeed(Long feedId) {
        String key = "feed:hot:" + feedId;
        String json = redisTemplate.opsForValue().get(key);
        
        if (json != null) {
            return JSON.parseObject(json, Feed.class);
        }
        
        return null;
    }
}

2️⃣ 消息扇出优化

@Service
public class FeedFanoutService {
    
    @Autowired
    private ThreadPoolExecutor executor;
    
    /**
     * ⭐ 异步扇出(提高性能)
     */
    public void asyncFanout(Long userId, Long feedId, List<Long> fanIds) {
        // 分批处理(每批1000个)
        int batchSize = 1000;
        
        for (int i = 0; i < fanIds.size(); i += batchSize) {
            int end = Math.min(i + batchSize, fanIds.size());
            List<Long> batch = fanIds.subList(i, end);
            
            // ⭐ 异步推送
            executor.execute(() -> {
                pushToBatch(feedId, batch);
            });
        }
    }
    
    private void pushToBatch(Long feedId, List<Long> fanIds) {
        for (Long fanId : fanIds) {
            String inboxKey = "inbox:" + fanId;
            redisTemplate.opsForList().leftPush(inboxKey, feedId.toString());
        }
    }
}

3️⃣ Feed流分页

/**
 * ⭐ 基于lastFeedId的分页
 */
public List<Feed> getTimelinePage(Long userId, Long lastFeedId, int size) {
    List<Feed> feeds = getTimeline(userId, lastFeedId, size);
    
    // 返回结果中最后一个feedId作为下一页的lastFeedId
    if (!feeds.isEmpty()) {
        Feed lastFeed = feeds.get(feeds.size() - 1);
        log.info("下一页lastFeedId: {}", lastFeed.getId());
    }
    
    return feeds;
}

📊 架构总结

        Feed流系统架构(推拉结合)

发布动态:
┌─────────────────────────────────────┐
│ 用户发动态                          │
│  ↓                                  │
│ 判断是否是明星用户                  │
│  ├─ 普通用户(粉丝<1000)→ 推模式  │
│  │   └─ 推送到粉丝收件箱            │
│  └─ 明星用户(粉丝>1000)→ 拉模式  │
│      └─ 只保存,不推送              │
└─────────────────────────────────────┘

读取Feed流:
┌─────────────────────────────────────┐
│ 用户刷Feed流                        │
│  ↓                                  │
│ 1. 从收件箱读取(推模式的动态)    │
│ 2. 实时拉取明星用户的动态(拉模式)│
│ 3. 合并 + 去重 + 排序               │
│ 4. 返回Top N                        │
└─────────────────────────────────────┘

    兼顾性能和体验!✅

🎓 面试题速答

Q1: Feed流的推模式和拉模式有什么区别?

A: 推模式(Write Fanout)

  • 发动态时,推送到所有粉丝收件箱
  • 读快,写慢
  • 适合粉丝少的普通用户

拉模式(Read Fanout)

  • 发动态时,只保存
  • 读取时,实时拉取关注的人的动态
  • 写快,读慢
  • 适合粉丝多的明星用户

推拉结合(最佳):

  • 普通用户推,明星用户拉
  • 兼顾性能和体验

Q2: 如何处理明星用户的动态?

A: 拉模式 + 缓存

1. 发动态:只保存,不推送
2. 缓存到Redis:
   key: user:feed:{userId}
   value: List<feedId>(最新100条)

3. 粉丝读取:
   - 实时拉取明星用户的最新动态
   - 从Redis读取(很快)

优点

  • 发动态快(不需要推送5000万次)
  • 读取也快(Redis缓存)

Q3: 如何实现Feed流的分页?

A: 基于lastFeedId的分页

// 第一页
List<Feed> page1 = getTimeline(userId, null, 10);
Long lastFeedId = page1.get(9).getId();  // 第10条的ID

// 第二页
List<Feed> page2 = getTimeline(userId, lastFeedId, 10);

原理

  • 记录上一页最后一条的feedId
  • 下一页从这个feedId之后开始读取

优点

  • 不会漏数据(有人发新动态)
  • 不会重复(精确定位)

Q4: 如何保证Feed流的一致性?

A: 三种策略

  1. 最终一致性

    • 推送到收件箱可能有延迟
    • 用户刷新后能看到
  2. 双写

    • 同时写MySQL和Redis
    • Redis过期后从MySQL加载
  3. 异步补偿

    • 推送失败的,异步重试
    • 消息队列保证可靠性

Q5: 如何优化Feed流的性能?

A: 五种优化

  1. 推拉结合

    • 普通用户推,明星用户拉
    • 兼顾性能
  2. 异步扇出

    • 推送到粉丝收件箱异步执行
    • 不阻塞发布
  3. 热点缓存

    • 热门动态缓存到Redis
    • 减少数据库查询
  4. 分批推送

    • 每批1000个粉丝
    • 并发推送
  5. CDN加速

    • 图片、视频使用CDN
    • 全球加速

Q6: 微博和朋友圈的Feed流有什么区别?

A: 微博(公开)

  • 任何人都能看到
  • 推拉结合(明星拉,普通用户推)
  • 热点数据缓存

朋友圈(私密)

  • 只有好友能看到
  • 推模式为主(好友少)
  • 三天可见(减少存储)

推荐

  • 根据业务特点选择
  • 大部分场景用推拉结合

🎬 总结

       Feed流系统核心架构

┌────────────────────────────────────┐
│ 推模式(Write Fanout)              │
│ - 发动态 → 推送到粉丝收件箱         │
│ - 读快,写慢                       │
│ - 适合普通用户                     │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 拉模式(Read Fanout)               │
│ - 发动态 → 只保存                  │
│ - 写快,读慢                       │
│ - 适合明星用户                     │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 推拉结合(最佳实践)⭐⭐⭐          │
│ - 普通用户推,明星用户拉           │
│ - 兼顾性能和体验                   │
└────────────────────────────────────┘

    推拉结合是最佳方案!✅

🎉 恭喜你!

你已经完全掌握了Feed流系统的设计!🎊

核心要点

  1. 推拉结合:普通用户推,明星用户拉
  2. 收件箱:Redis List存储
  3. 异步扇出:提高推送性能
  4. 热点缓存:减少数据库压力

下次面试,这样回答

"Feed流系统采用推拉结合的方案。普通用户(粉丝<1000)使用推模式,发动态时推送到粉丝的Redis收件箱;明星用户(粉丝>1000)使用拉模式,发动态时只保存,粉丝读取时实时拉取。

读取Feed流时,先从收件箱读取推模式的动态,再实时拉取明星用户的动态,最后合并、去重、排序后返回。

性能优化方面,推送采用异步扇出,分批处理(每批1000个),并发推送。热门动态缓存到Redis,减少数据库压力。

我们项目的动态系统采用这套方案,支持千万用户,平均响应时间100ms,推送延迟1秒以内。"

面试官:👍 "很好!你对Feed流系统的设计理解很深刻!"


本文完 🎬

上一篇: 203-设计一个搜索引擎系统.md
下一篇: 205-设计一个分布式延迟任务调度系统.md

作者注:写完这篇,我都想去腾讯做微信朋友圈了!📰
如果这篇文章对你有帮助,请给我一个Star⭐!