本文目录导读:

- 方案一:全量拉取(Pull 模式)——适合小型项目、用户量少
- 方案二:预生成推模式(Push 模式)——适合中等规模、高并发
- 方案三:推拉结合(Fanout-on-write + 读扩散)——大规模系统首选
- 关键技术和性能优化点
- 简易 Demo 代码思路(基于推模式 + Redis)
- 总结推荐
在 PHP 项目中实现信息流(Feed/News Feed),核心思路与社交平台(如微博、朋友圈)类似:拉取用户关注对象或相关实体的最新动态,并按时间倒序或权重排序展示。
根据项目的规模和性能要求,主要有以下 3 种常见实现方案,从简单到复杂排序:
全量拉取(Pull 模式)——适合小型项目、用户量少
原理:每次用户刷新信息流时,直接查询数据库,找出该用户关注的所有人发布的动态,然后按时间排序。
实现步骤:
- 数据表设计:
posts(动态表):id, user_id, content, created_atfollows(关注关系表):user_id, follower_id
- 核心查询:
SELECT p.* FROM posts p JOIN follows f ON f.user_id = p.user_id WHERE f.follower_id = ? ORDER BY p.created_at DESC LIMIT 20 OFFSET 0;
优点:实现简单,数据实时性强。
缺点:当关注人数很多或动态数据量大时,JOIN 查询性能急剧下降,造成“粉丝越多,查询越慢”的问题(即读扩散)。
预生成推模式(Push 模式)——适合中等规模、高并发
原理:当用户发布动态时,系统立即将这条动态“推送到”所有粉丝的收件箱(Redis List 或数据库 Feed 表),用户查看信息流时,直接从自己的收件箱读取。
核心逻辑:写时扩散。
实现步骤:
- 收件箱存储:使用 Redis 的
LPUSH或Sorted Set。- Key 设计:
feed:{user_id}(每个用户一个 List 或 Zset) - Value:动态 ID 或动态内容(建议存 ID 以节省内存)。
- Key 设计:
- 发布流程:
- 用户 A 发布一条动态。
- 从数据库或缓存查询用户 A 的粉丝列表。
- 循环向每个粉丝的
feed:{follower_id}中LPUSH动态 ID。
- 读取流程:
- 用户 B 请求信息流。
- 从 Redis 中
LRANGE feed:B_ID 0 19取出最新 20 条动态 ID。 - 从数据库或缓存中
SELECT * FROM posts WHERE id IN (...)获取完整数据。
优点:用户读取信息流速度极快(只读自己的列表),读性能很好。
缺点:
- 写放大:大 V(如拥有 1000 万粉丝)发布一条动态,系统需要推送 1000 万次,压力极大。
- 存储浪费:每个粉丝的收件箱都存一份数据。
推拉结合(Fanout-on-write + 读扩散)——大规模系统首选
原理:区分普通用户和大 V。
- 对于普通用户(粉丝数少,如 < 1000):使用 推模式。
- 对于明星/大 V(粉丝数多,如 > 1000):使用 拉模式(不推送,粉丝刷新时临时拉取并合并)。
实现流程(典型百度/微博的方案):
- 发布时:
- 判断用户粉丝数,若粉丝数 < 阈值,推送到所有粉丝收件箱。
- 若粉丝数 >= 阈值,将动态直接写入大 V 的发件箱(
timeline:{author_id}的 Redis List)。
- 读取时:
- 获取登录用户关注的所有人列表。
- 将这些关注者分为两组:普通用户列表 + 大 V 列表。
- 从普通用户收件箱中读取动态(已预推送)。
- 从大 V 发件箱中拉取最新动态。
- 在应用层内存中将所有动态合并、排序、去重。
- 特殊处理:
对于大 V 最新发布的动态,在粉丝收件箱中标记一个“拉取指针”,下次读取时只拉取新增部分。
优点:平衡了读写性能和资源消耗,是工业级主流方案。
缺点:实现复杂,需要缓存、队列、异步任务支持。
关键技术和性能优化点
- 缓存为王:信息流一定是缓存密集型。
- 用 Redis 存储收件箱/发件箱(List/Sorted Set)。
- 用 Redis/Memcached 缓存用户对象、动态详情(减少数据库查询)。
- 异步队列:推模式中的“写扩散”操作必须异步。
- 用户发布动态 -> 请求入队(如 RabbitMQ/Redis Stream) -> Worker 消费队列,执行推送任务。
- 这样可以避免用户发布动态后等待过长响应。
- 分页与游标:避免使用传统
OFFSET分页,使用cursor(游标) 分页。- 客户端上次最后一条动态的
id或created_at。 - 查询
WHERE created_at < $cursor ORDER BY created_at DESC LIMIT 20。
- 客户端上次最后一条动态的
- 冷热数据分离:动态数据通常热度集中在最近几天,可将超过 30 天的历史数据归档到冷库(如 MySQL 历史表或 HBase),主库只保留热点数据。
- 数据一致性:推模式中,异步推送可能会失败(如 Redis 宕机),解决方案:
- 用户读取时进行“修复”:如果发现收件箱中缺少某人的最新动态,主动拉取并补入。
- 定时离线计算。
简易 Demo 代码思路(基于推模式 + Redis)
// 类定义
class FeedService {
private $redis;
private $db;
public function publishPost($userId, $content) {
$postId = $this->db->insert('posts', ['user_id' => $userId, 'content' => $content]);
// 获取粉丝列表(异步,这里为了示例同步处理)
$fans = $this->db->select("SELECT follower_id FROM follows WHERE user_id = ?", [$userId]);
// 批量异步推送(最佳实践:使用消息队列)
foreach ($fans as $fan) {
$this->redis->lPush("feed:{$fan['follower_id']}", $postId);
}
// 同时将自己的动态也推给自己(便于查看自己的个人主页)
$this->redis->lPush("feed:{$userId}", $postId);
return $postId;
}
// 获取用户信息流
public function getFeed($userId, $limit = 20) {
// 从 Redis 中取出最新的 $limit 条动态 ID
$postIds = $this->redis->lRange("feed:{$userId}", 0, $limit - 1);
if (empty($postIds)) return [];
// 批量获取动态详情(从缓存或数据库)
// 注意:SQL 中 IN 的顺序可能被打乱,需要在 PHP 中按 $postIds 的顺序重新排序
$placeholders = implode(',', array_fill(0, count($postIds), '?'));
$posts = $this->db->select("SELECT * FROM posts WHERE id IN ($placeholders) ORDER BY FIELD(id, ".implode(',', $postIds).")", $postIds);
return $posts;
}
}
总结推荐
| 方案 | 项目规模 | 优点 | 缺点 |
|---|---|---|---|
| 全量拉取 | < 5万用户 | 实现简单 | 性能差,无法扩展 |
| 推模式 | < 100万用户 | 读取极快 | 写放大,大 V 场景不可用 |
| 推拉结合 | 百万级以上 | 平衡读写,业界标准 | 实现最复杂 |
针对你的项目:
- 如果是博客系统、公司内部系统,全量拉取 加好索引(
user_id, created_at联合索引)足够。 - 如果是社交类 App,建议直接用 推模式(因为用户量通常可控),若出现大 V 问题再升级为推拉结合。