基于Redis的Feed流“读写扩散”方案实现
一、Feed流与主流实现方案
Feed流产品在我们手机APP中几乎无处不在,常见的Feed流比如微信朋友圈、新浪微博、今日头条等。对Feed流的定义,可以简单理解为只要大拇指不停地往下划手机屏幕,就有一条条的信息不断涌现出来。就像给牲畜喂饲料一样,只要它吃光了就要不断再往里加,故此得名Feed(饲养)。
大多数Feed流产品都包含 2 种Feed流:
- 基于算法推荐:各种推荐页的实现,比如微博、知乎
- 基于关注(好友关系):比如微信朋友圈
目前主流Feed流的实现方案有 3 种:
-
读扩散(拉模式):用户将发布的Feed投入自己的发件箱,粉丝读取时,拉取所有关注人的发件箱。阅读者读一次Feed流,后台会扩散为N次读操作(N等于关注的人数)以及一次聚合操作,因此称为读扩散。每次读Feed流相当于去关注者的收件箱主动拉取帖子,因此也得名拉模式。
-
写扩散(推模式):系统中每个用户除了有发件箱,也会有自己的收件箱。当发布者发表一篇帖子的时候,除了往自己发件箱记录一下之外,还会遍历发布者的所有粉丝,往这些粉丝的收件箱也投放一份相同内容。这样阅读者来读Feed流时,直接从自己的收件箱读取即可。
这种设计,每次发表帖子,都会扩散为M次写操作(M等于自己的粉丝数),因此成为写扩散。每篇帖子都会主动推送到所有粉丝的收件箱,因此也得名推模式。
-
读写扩散(推拉模式):对于那些活跃用户登录刷Feed流时,他直接从自己的收件箱读取帖子即可,保证了活跃用户的体验。当一个非活跃的用户突然登录刷Feed流时,我们一方面需要读他的收件箱,另一方面需要遍历他所关注的大V用户的发件箱提取帖子,并且做一下聚合展示。在展示完后,系统还需要有个任务来判断是否有必要将该用户升级为活跃用户。因为有读扩散的场景存在,因此即使是混合模式,每个阅读者所能关注的人数也要设置上限,例如新浪微博限制每个账号最多可以关注2000人。如果不设上限,设想一下有一位用户把微博所有账号全部关注了,那他打开关注列表会读取到微博全站所有帖子,一旦出现读扩散,系统必然崩溃
本文主要针对基于关注(好友关系)的Feed读写扩散方案进行设计实现
关于Feed流与 3 种实现方式的对比,可以看这篇公众号文章:https://mp.weixin.qq.com/s/o1WwkVjxzHyub2AdR9ki8w
二、基于Redis的“读写扩散”方案实现
流程图:
关于是否刷新
- 刷新:
- 构建Feed流:用户许久未登录,登录后即开始Feed流重构建
- 进入Feed流页:点击进入该Feed流页面时,此时需要拉取所有新发布的Feed
- 刷新Feed流:用户下拉页面,刷新最新发布的Feed
- 不刷新:用户往下滑Feed流,进行分页查询时
发件箱与收件箱
-
发件箱:存储于关系型数据库,存放Feed实体数据
-
收件箱:存储于非关系型数据库,此处使用Redis,使用 Sorted Set 基于创建时间进行排序,score:timestamp * (10^19) + ${user_id},value存储:${feed_id}(timestamp 为秒级时间戳,与 partition_post 表 publish_date 一致)
发布/删除 Feed
-
将 Feed 写入该发布者发件箱(DB)
-
将 Feed 写入消息队列服务(使用事务消息,保证可靠性投递)
-
从消息队列获取 Feed,判断发布者是否为“大V”用户,如果是则结束
-
如果为“普通”用户,还需要将 Feed 写入所有粉丝收件箱(提交消息ACK)
读取 Feed 流
-
判断此次请求是否为刷新Feed流
-
如果不是则跳转到步骤 6
-
查询所有关注的大V用户
-
查询所有关注的大V用户发件箱,获取所有 Feed(范围查询:feed_id > ?)
-
将读取到的 Feed 写入个人收件箱
-
读取个人收件箱(分页查询)
-
从ES中补充 Feed 特征数据
-
如果某些Feed已被删除,需要过滤掉,并同步个人收件箱(如果该页所有Feed都被删除,则回到步骤 6
-
清理超过180天的数据(不必每次都去清理,可根据策略调整)
-
返回 Feed 流
关注用户
将该用户 180d 内的 Feed 添加到个人收件箱中
- 数据量较大,改为异步分页查库,批量添加(加分布式锁,防止用户频繁操作“关注”/“取关”该用户)
- 关注用户后,回到Feed流页面读取Feed流时需要刷新
取关用户
从个人收件箱中移除该用户的所有Feed(加分布式锁,防止用户频繁操作“关注”/“取关”该用户,必要时需引入风控策略)
数据量评估
Redis 内存占用:
每用户占用:(value[19] + 结构体[16] + score[24]) * Feed长度假设最大值[500] = 59B * 500 = 28.8KB
总内存占用:每用户占用[29KB] * 总用户数 = ?
三、Java代码细节实现
Feed流相关操作
public class FeedService {
private static final long ID_19_MULTIPLIER = (long) Math.pow(10, 19);
private final Jedis jedis;
public FeedService(Jedis jedis) {
this.jedis = jedis;
}
// 添加 Feed 到某用户 Feed流
public void addFeed(String userId, String followeeId, String feedId, long secsTimestamp) {
Map<String, Double> scoreMembers = new HashMap<String, Double>() {
{
put(String.valueOf(feedId), generateFeedScore(secsTimestamp, followeeId));
}
};
jedis.zadd(generateFeedFlowKey(userId), scoreMembers);
}
// 添加 Feed 到某用户 Feed流,如果超过指定长度则删除最小成员
public void addFeedAndKeepSize(String userId, String followeeId, String feedId, long secsTimestamp) {
Object eval = jedis.eval("add_feed_and_keep_size.lua",
Collections.singletonList(generateFeedFlowKey(userId)),
Arrays.asList(feedId, String.valueOf(generateFeedScore(secsTimestamp, followeeId)), "500")
);
}
// 分页读取 Feed流
public Set<Tuple> getFeedWithLimit(String userId, double offsetScore, int limit) {
return jedis.zrevrangeByScoreWithScores(generateFeedFlowKey(userId), Double.POSITIVE_INFINITY, offsetScore + 1, 0, limit);
}
// 根据 feedId 删除(过滤已删除Feed/取消关注)
public int remFeedByFeedIds(String userId, String[] feedIds) {
Long count = jedis.zrem(generateFeedFlowKey(userId), feedIds);
if (count == null) {
return 0;
}
return (int) (long) count;
}
// 根据 userId 获取Feed流缓存key
public String generateFeedFlowKey(String userId) {
return "sorted_set_feed_flow:" + userId;
}
// 生成 score
public double generateFeedScore(long secsTimestamp, String followeeId) {
return generateFeedScore(secsTimestamp, Long.parseLong(followeeId));
}
// 生成 score
public double generateFeedScore(long secsTimestamp, long followeeId) {
return ((double) secsTimestamp) * ID_19_MULTIPLIER + followeeId;
}
}
add_feed_and_keep_size.lua
-- KEYS[1]: Sorted Set 的键名
-- ARGV[1]: 要添加的成员
-- ARGV[2]: 成员对应的分数
-- ARGV[3]: Sorted Set 的最大长度
-- 添加成员及其分数
redis.call('zadd', KEYS[1], ARGV[2], ARGV[1])
-- 获取 Sorted Set 的当前长度
local current_length = redis.call('zcard', KEYS[1])
-- 如果当前长度超过最大长度,则移除分数最小的成员
if current_length > tonumber(ARGV[3]) then
local num_to_remove = current_length - tonumber(ARGV[3])
local members_to_remove = redis.call('zrange', KEYS[1], 0, num_to_remove - 1)
if #members_to_remove > 0 then
redis.call('zrem', KEYS[1], unpack(members_to_remove))
end
end
return redis.call('zcard', KEYS[1])
时间复杂度:(N 为 Feed 流长度,M 为 feedId 数量)
- 添加多个 feed 到 Feed 流:O(M * logN)
- 分页查询 Feed 流:O(logN)
- 根据 feedId 批量删除(取消关注/过滤已删除feed):O(M * logN)