Redis实现Feed流

260 阅读5分钟

Feed流

  • Feed 流:是提供给用户的内容流,为用户持续的提供 “沉浸式” 的体验,通过无限下拉刷新获取新的信息。比如微博的关注页,抖音的关注页视频都叫Feed流
  • Feed:Feed流中的一条信息,比如朋友发布的一条朋友圈

redis实现Feed推送

相关数据结构

  • SET:redis中的set可以将元素去重
  • ZSET:与set一样将元素去重,但是支持排序,新增数据时要指定一个score,默认按照score排序。

Feed 流产品有两种常见模式

  • Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注产生的内容。

    例如朋友圈

    优点:信息全面,不会有缺失。并且实现也相对简单 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

  • 智能排序:利用算法推送用户感兴趣信息来吸引用户

    例如抖音,快手

    优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷 缺点:如果算法不精准,可能起到反作用

Feed流的实现方案

feed流程实现的方案主要有三种模式:推模式、拉模式、推拉结合

  1. 拉模式:也叫做读扩散 这种模式下只有每次读的时候才获取消息,内存消耗小。缺点是读操作过于频繁,若用户关注了许多博主,一次要读的消息也是十分多,造成延迟较高 在这里插入图片描述

  2. 推模式:也叫做写扩散。 这种模式下在发消息时写入粉丝收件箱,内存占用更高。缺点是写操作频繁,如果有大v,需要给很多粉丝写消息 在这里插入图片描述

  3. 推拉结合模式:也叫做读写混合 这种模式兼具推和拉两种模式的优点。对于不同博主采取不同的策略,缺点是实现复杂 普通博主:可以采用推模式,写操作并不是很繁重 大v:其粉丝分两种粉丝,一种是活跃用户,一种是普通用户;一般而言活跃用户数量少,可以采用推模式;普通用户数量多,但它们查看消息少、频率低,采用拉模式。 在这里插入图片描述 在本文中采取写扩散作为案例。redis中实现feed流需要使用zset,当博主发布一条动态时往粉丝的收件箱(redis的zset)写一条数据。数据格式为:

{
score:一般为时间戳,
member:消息内容
}

具体实现代码 将消息写入粉丝收信箱

	fans, err := mysql.GetFansByID(h.Context, u)
	if err != nil {
		return nil, err
	}
	for _, fan := range fans {
		key := constants.FEED_KEY + strconv.FormatInt(fan.ID, 10)
		err = redis.RedisClient.ZAdd(h.Context, key, &redis2.Z{
			Score:  float64(time.Now().Unix()),
			Member: req.ID,
		}).Err()
	}

粉丝读取收信箱

	u := utils.GetUser(h.Context).GetID()
	key := constants.FEED_KEY + strconv.FormatInt(u, 10)
	zSet, err := redis.GetBlogsByKey(h.Context, key, req.LastId, req.Offset)
	var bids []string
	for _, z := range zSet {
		bids = append(bids, z.Member.(string))
	}
	var blogs []*blog.Blog
	err = mysql.DB.Where("id in ?", bids).Find(&blogs).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, errors.New("没有更多数据")
	}
	if err != nil {
		return nil, err
	}
	//fmt.Printf("blogs: %v\n", blogs)
	var res blog.FollowBlogRresp
	res.List = blogs
	res.MinTime = "0"
	if len(zSet) > 0 {
		res.MinTime = strconv.FormatInt(int64(zSet[len(zSet)-1].Score), 10)
	}
	// 取最小分数的记录数
	var offset int64 = 0
	minScore := zSet[len(zSet)-1].Score
	for _, element := range zSet {
		if element.Score == minScore {
			offset++
		}
	}
	res.Offset = offset

滚动分页

为什么要用滚动分页

由于Feed流中的数据是随时间变化不断更新的,传统的分页方式为根据每页几条pageSize和当前第几页页Page来计算查询范围,这对于Feed流中的动态列表而言会有重复读的问题: 在这里插入图片描述 在T1时刻查询到6、5、4三条数据后,在下一次查询(T3)时刻前有人往数据库写入一条记录7,在T3时刻查询时候根据pageSize和page计算从第四条记录开始查3条,就会得到重复的记录:4。

而滚动分页在查询时采用游标形,会记住上次查询的最后位置,可以避免这种情况 在这里插入图片描述 第一次查询后记住lastId为4,即上一次查询的最小值,在第二次查询时从lastId往后查找,即使中间有人插入数据也不会影响查询结果。

滚动分页实现

在redis中读取zset的数据使用zrange、zrevenge命令,一般使用zrevenge将查询结果按照score从大到小反转。读取时要关注4个参数:

  • max:最大的分数,上一次查询中最小的时间戳

  • min:最小分数,默认0

  • offset:偏移量,小于等于max的第几个元素,如写0,则查询结果包含socre=max的

  • count:查询的数据条数

==分页规则==

假设有如下数据:

memberscore
item88
item7-17
item7-27
item66
item55
item44

使用如下命令查询

zrevrangebyscore key max min withscores limit offset count

第一次查询时,offset应当被设置成为0,例如

zrevrangebyscore key 100 0 withscores limit 0 3
结果是:
item8:8
item7-1:7
item7-2:7

后续查询时,max应当设置成上一次查询的最小分数,再此处设置为7.如果此时将offset设置为1,查询结果会变成

zrevrangebyscore key 100 0 withscores limit 0 3
结果是:
item7-2:7
item6:6
item5:5

==显然:得到一条重复的item7-2记录,这是错误的== 为了解决这个问题,后续查询时候,应当将offset设置成上一次查询的最小分数对应的记录条数。比如在本案例中第二次查询应该为

zrevrangebyscore key 100 0 withscores limit 1 3
结果是:
item6:6
item5:5