【业务分享】基于Redis的Feed流“读写扩散”方案实现


一、Feed流与主流实现方案

Feed流产品在我们手机APP中几乎无处不在,常见的Feed流比如微信朋友圈、新浪微博、今日头条等。对Feed流的定义,可以简单理解为只要大拇指不停地往下划手机屏幕,就有一条条的信息不断涌现出来。就像给牲畜喂饲料一样,只要它吃光了就要不断再往里加,故此得名Feed(饲养)。

大多数Feed流产品都包含 2 种Feed流:

  1. 基于算法推荐:各种推荐页的实现,比如微博、知乎
  2. 基于关注(好友关系):比如微信朋友圈

目前主流Feed流的实现方案有 3 种:

  1. 读扩散(拉模式):用户将发布的Feed投入自己的发件箱,粉丝读取时,拉取所有关注人的发件箱。阅读者读一次Feed流,后台会扩散为N次读操作(N等于关注的人数)以及一次聚合操作,因此称为读扩散。每次读Feed流相当于去关注者的收件箱主动拉取帖子,因此也得名拉模式。
    在这里插入图片描述

  2. 写扩散(推模式):系统中每个用户除了有发件箱,也会有自己的收件箱。当发布者发表一篇帖子的时候,除了往自己发件箱记录一下之外,还会遍历发布者的所有粉丝,往这些粉丝的收件箱也投放一份相同内容。这样阅读者来读Feed流时,直接从自己的收件箱读取即可。
    在这里插入图片描述

    这种设计,每次发表帖子,都会扩散为M次写操作(M等于自己的粉丝数),因此成为写扩散。每篇帖子都会主动推送到所有粉丝的收件箱,因此也得名推模式。

  3. 读写扩散(推拉模式):对于那些活跃用户登录刷Feed流时,他直接从自己的收件箱读取帖子即可,保证了活跃用户的体验。当一个非活跃的用户突然登录刷Feed流时,我们一方面需要读他的收件箱,另一方面需要遍历他所关注的大V用户的发件箱提取帖子,并且做一下聚合展示。在展示完后,系统还需要有个任务来判断是否有必要将该用户升级为活跃用户。因为有读扩散的场景存在,因此即使是混合模式,每个阅读者所能关注的人数也要设置上限,例如新浪微博限制每个账号最多可以关注2000人。如果不设上限,设想一下有一位用户把微博所有账号全部关注了,那他打开关注列表会读取到微博全站所有帖子,一旦出现读扩散,系统必然崩溃
    在这里插入图片描述

本文主要针对基于关注(好友关系)的Feed读写扩散方案进行设计实现

关于Feed流与 3 种实现方式的对比,可以看这篇公众号文章:https://mp.weixin.qq.com/s/o1WwkVjxzHyub2AdR9ki8w


二、基于Redis的“读写扩散”方案实现

流程图:
在这里插入图片描述

关于是否刷新

  • 刷新:
    1. 构建Feed流:用户许久未登录,登录后即开始Feed流重构建
    2. 进入Feed流页:点击进入该Feed流页面时,此时需要拉取所有新发布的Feed
    3. 刷新Feed流:用户下拉页面,刷新最新发布的Feed
  • 不刷新:用户往下滑Feed流,进行分页查询时

发件箱与收件箱

  • 发件箱:存储于关系型数据库,存放Feed实体数据

  • 收件箱:存储于非关系型数据库,此处使用Redis,使用 Sorted Set 基于创建时间进行排序,score:timestamp * (10^19) + ${user_id},value存储:${feed_id}(timestamp 为秒级时间戳,与 partition_post 表 publish_date 一致)

发布/删除 Feed

  1. 将 Feed 写入该发布者发件箱(DB)

  2. 将 Feed 写入消息队列服务(使用事务消息,保证可靠性投递)

  3. 从消息队列获取 Feed,判断发布者是否为“大V”用户,如果是则结束

  4. 如果为“普通”用户,还需要将 Feed 写入所有粉丝收件箱(提交消息ACK)

读取 Feed 流

  1. 判断此次请求是否为刷新Feed流

  2. 如果不是则跳转到步骤 6

  3. 查询所有关注的大V用户

  4. 查询所有关注的大V用户发件箱,获取所有 Feed(范围查询:feed_id > ?)

  5. 将读取到的 Feed 写入个人收件箱

  6. 读取个人收件箱(分页查询)

  7. 从ES中补充 Feed 特征数据

  8. 如果某些Feed已被删除,需要过滤掉,并同步个人收件箱(如果该页所有Feed都被删除,则回到步骤 6

  9. 清理超过180天的数据(不必每次都去清理,可根据策略调整)

  10. 返回 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)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

自传丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值