Redis点评实战篇-关注推送

目录

[1. Feed流业务场景与模式选择](#1. Feed流业务场景与模式选择)

[1.1 Timeline(基于时间线)](#1.1 Timeline(基于时间线))

[1.2 智能排序(基于算法推荐)](#1.2 智能排序(基于算法推荐))

[2. Timeline模式的核心挑战](#2. Timeline模式的核心挑战)

[3. 三种实现方案](#3. 三种实现方案)

[3.1 拉模式(读扩散)](#3.1 拉模式(读扩散))

基本原理

工作流程

优点

缺点

适用场景

技术要点

[3.2 推模式(写扩散)​编辑](#3.2 推模式(写扩散)编辑)

基本原理

工作流程

优点

缺点

适用场景

技术要点

[3.3 推拉结合模式(混合读写)](#3.3 推拉结合模式(混合读写))

基本原理

工作流程

优点

缺点

适用场景

技术要点

[4. 方案对比总结](#4. 方案对比总结)

5.案例实现(推模式)

[Redis 滚动分页查询](#Redis 滚动分页查询)

[1.初识有序集合与 ZREVRANGEBYSCORE](#1.初识有序集合与 ZREVRANGEBYSCORE)

2.滚动分页的需求与挑战

[2.1 传统分页的局限](#2.1 传统分页的局限)

[2.2 滚动分页(Cursor-based Pagination)的优势](#2.2 滚动分页(Cursor-based Pagination)的优势)

3.滚动分页的参数设计解析

[3.1 为什么用时间戳作为分数?](#3.1 为什么用时间戳作为分数?)

[3.2 处理分数重复的关键:offset 的妙用](#3.2 处理分数重复的关键:offset 的妙用)

[3.3 参数变化举例](#3.3 参数变化举例)

4.深入理解命令的执行细节

[4.1 分数范围与 LIMIT 的协作](#4.1 分数范围与 LIMIT 的协作)

[4.2 为何 min 固定为 0?](#4.2 为何 min 固定为 0?)

[4.3 处理空结果和结束条件](#4.3 处理空结果和结束条件)

5.实际应用中的注意事项


1. Feed流业务场景与模式选择

在社交类应用中,Feed流(信息流)是核心功能。用户通过关注关系获取动态,系统需要高效地将发布的内容分发到粉丝的收件箱。常见的Feed流模式有两种:

1.1 Timeline(基于时间线)

  • 原理:严格按内容发布时间排序,不做智能筛选,常见于朋友圈、关注页。

  • 优点:数据全面,无信息遗漏;实现逻辑简单,用户可掌控信息获取节奏。

  • 缺点:信息噪音大,用户可能错过感兴趣内容;随着关注量增加,阅读效率降低。

1.2 智能排序(基于算法推荐)

  • 原理:通过算法(如协同过滤、CTR预估)过滤不感兴趣内容,推送高价值信息,如抖音、快手推荐页。

  • 优点:用户粘性高,容易沉浸;可挖掘长尾内容。

  • 缺点:算法不精准会引发用户反感;技术复杂度高,需大量数据训练。

本例场景:个人页面的关注动态流,基于用户主动关注的博主,需确保内容完整性,因此采用Timeline模式。下面重点剖析Timeline的三种实现方案。


2. Timeline模式的核心挑战

在Timeline模式下,核心挑战是数据的高效分发与读取。假设用户A关注了用户B,当B发布一条动态时,如何让A快速看到?系统需要处理两个核心操作:

  • 写扩散:发布时,将内容推送给粉丝。

  • 读扩散:读取时,实时聚合关注列表的内容。

不同的实现方案,本质是权衡写操作与读操作的复杂度,以及数据一致性、存储成本等因素。


3. 三种实现方案

3.1 拉模式(读扩散)

基本原理
  • 每个用户维护自己的发件箱(Outbox),发布的内容写入自己的发件箱。

  • 粉丝读取Feed时,实时拉取所有关注用户的发件箱,合并后按时间排序返回。

工作流程
  1. 发布:用户B发布动态 → 写入B的发件箱(如Redis的ZSet,score为时间戳)。

  2. 读取:用户A刷新Feed → 查询A关注的所有用户ID列表 → 并行拉取这些用户的发件箱数据 → 内存合并排序 → 返回给A。

优点
  • 存储成本低:每条内容只存一份,无需为每个粉丝复制。

  • 写操作轻量:发布时仅需写入自己的发件箱,复杂度O(1)。

缺点
  • 读操作重:关注越多,拉取、合并开销越大(N次网络IO + 归并排序),延迟随关注数线性增长。

  • 热点问题:大V发布时,大量粉丝同时读请求会打爆大V的发件箱(虽然读的是缓存,但并发极高)。

适用场景
  • 用户关注数有限(如朋友圈,好友上限5000)。

  • 读请求量不大,对实时性要求不那么极致(可接受秒级延迟)。

技术要点
  • 使用Redis ZSet存储发件箱,按时间戳排序。

  • 合并排序可采用多路归并算法,避免全量排序内存溢出。

  • 可配合本地缓存降低重复合并开销(但需注意一致性)。


3.2 推模式(写扩散)

基本原理
  • 每个用户维护自己的收件箱(Inbox)。

  • 发布时,系统将内容推送给所有粉丝,写入粉丝的收件箱。

  • 粉丝读取时,直接从自己的收件箱拉取,无需合并。

工作流程
  1. 发布:用户B发布动态 → 查询B的所有粉丝列表 → 将动态写入每个粉丝的收件箱(如Redis List/ZSet)。

  2. 读取:用户A刷新Feed → 直接从A的收件箱获取内容(按时间倒序)。

优点
  • 读操作极轻:O(1)读取,完美应对高频刷新。

  • 实时性好:内容预先聚合,读取无延迟。

缺点
  • 写操作重:粉丝越多,写放大越严重(大V发布需写入百万级收件箱),可能导致发布延迟甚至失败。

  • 存储成本高:每一条内容需要为每个粉丝存储一份(粉丝数 * 内容数),浪费空间。

  • 删除/更新复杂:若内容被删除,需遍历所有粉丝的收件箱删除,代价巨大。

适用场景
  • 粉丝数有限(如企业内部系统)。

  • 读多写少,且发布者粉丝数可控。

技术要点
  • 异步写扩散:发布请求先写发件箱,再通过消息队列异步推送给粉丝,降低发布延迟。

  • 收件箱可选用Redis的Sorted Set,按时间戳排序,并设置过期时间或容量限制(如只保留最近1000条)。

  • 对于大V,需特殊处理(如限流、只推活跃粉丝)。


3.3 推拉结合模式(混合读写)

基本原理
  • 结合推和拉的优势,将用户分为两类:普通用户(推模式)和大V用户(拉模式)。

  • 普通用户发布时,推送给所有粉丝;大V发布时,只推送给在线/活跃粉丝,其余粉丝读取时采用拉模式。

  • 或者更通用的做法:推送给最近活跃的粉丝,对不活跃粉丝采用拉模式。

工作流程
  1. 发布:用户B发布动态。

    1. 若B是普通用户 → 推送给所有粉丝。

    2. 若B是大V → 推送给活跃粉丝(近期登录的粉丝),不活跃粉丝则采用拉模式。

  2. 读取:用户A刷新Feed。

    1. 先读取自己的收件箱(推模式内容)。

    2. 再读取关注列表中的大V发件箱,合并拉取的内容。

    3. 两部分合并排序后返回。

优点
  • 平衡读写压力:减轻大V写扩散的压力,同时保证大部分用户读性能。

  • 灵活性高:可动态调整策略(如根据粉丝活跃度、网络状况)。

  • 存储与成本折中:相比纯推模式节省大量存储,相比纯拉模式提升读性能。

缺点
  • 实现复杂:需要区分用户类型、维护粉丝活跃状态、处理合并逻辑。

  • 一致性挑战:推和拉的数据可能重复或遗漏,需设计去重和补偿机制。

适用场景
  • 大型社交平台(如微博、Twitter),存在大量普通用户和少量大V。

  • 对实时性要求高,且需要控制成本。

技术要点
  • 活跃度判定:基于用户最近登录时间、在线状态等,可定期更新。

  • 收件箱容量限制:为防止无限膨胀,可设置收件箱上限(如只存最近500条),超限则淘汰。

  • 合并拉取优化:使用多线程并发拉取大V发件箱,配合本地缓存减少重复拉取。

  • 推拉比例调优:根据业务数据动态调整推拉阈值(例如粉丝数超过10万则采用拉模式)。


4. 方案对比总结

|------|--------|--------|------------|
| 维度 | 拉模式 | 推模式 | 推拉结合 |
| 写复杂度 | O(1) | O(粉丝数) | O(活跃粉丝数) |
| 读复杂度 | O(关注数) | O(1) | O(1 + 大V数) |
| 存储成本 | 低 | 高 | 中 |
| 实时性 | 依赖合并性能 | 高 | 高 |
| 适用场景 | 好友少、读少 | 粉丝少、读多 | 大V存在、读写均衡 |
| 实现难度 | 易 | 中 | 高 |

5.案例实现(推模式)

在博主发布博客后,将把博客推送给他的粉丝,而他的粉丝有多个关注博主,在粉丝读收件箱的推送时要进行分页读取。

java 复制代码
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    Long userId = UserHolder.getUser().getId();
    //查询收件箱 ZREVRANGEBYSCORE key max min LIMIT offset count
    String key = RedisConstants.FEED_KEY + userId;
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
    if (typedTuples==null||typedTuples.isEmpty()){
        return Result.ok();
    }
    List<Long> ids = new ArrayList<>(typedTuples.size());
    long minTime = 0;
    int os=1;
    //解析数据:blogId,minTime(时间戳),offset
    for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples){
        ids.add(Long.valueOf(typedTuple.getValue()));
        //获取分数(时间戳)
        long Time = typedTuple.getScore().longValue();
        if (Time == minTime){
            os++;
        }else {
            minTime = Time;
            os = 1;
        }
    }
    String idstr= StrUtil.join(",", ids);
    List<Blog> blogs = query().in("id", ids)
            .last("ORDER BY FIELD(id,"+idstr+")").list();
    for (Blog blog : blogs){
        isBlogLiked(blog);
    }
    ScrollResult scrollResult = new ScrollResult();
    scrollResult.setList(blogs);
    scrollResult.setOffset(os);
    scrollResult.setMinTime(minTime);
    return Result.ok(scrollResult);
}

Redis 滚动分页查询

1.初识有序集合与 ZREVRANGEBYSCORE

Redis 的 有序集合(Sorted Set) 是一种既存储元素(member)又为每个元素关联一个分数(score)的数据结构,集合内的元素按分数从小到大的顺序排列。常用场景包括排行榜、时间轴、带权重的任务队列等。

ZREVRANGEBYSCORE 命令用于按分数从大到小(降序)返回有序集合中指定分数范围内的元素。其基本语法如下:

ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]

  • key:有序集合的键名。

  • maxmin:分数范围,返回分数在 [min, max] 之间的元素(注意 ZREVRANGEBYSCORE 是先写 max 后写 min,表示从高分到低分)。

  • WITHSCORES:可选,返回元素时同时返回分数。

  • LIMIT offset count:可选,分页参数,从结果中跳过 offset 个元素,返回最多 count 个元素。

例如,获取分数在 100 到 200 之间的前 3 个最高分元素:

java 复制代码
ZREVRANGEBYSCORE myzset 200 100 LIMIT 0 3

2.滚动分页的需求与挑战

2.1 传统分页的局限

在 Web 应用中,常用 LIMIT offset count 进行分页。但对于有序集合,如果使用 ZREVRANGEBYSCORE 配合固定的 offset 分页,会存在两个问题:

  • 性能问题:offset 越大,Redis 需要跳过的元素越多,复杂度为 O(log(N) + offset),当 offset 很大时效率降低。

  • 数据不一致:如果在两次查询之间集合发生了变化(新增或删除了元素),传统分页可能导致数据重复或遗漏。

2.2 滚动分页(Cursor-based Pagination)的优势

滚动分页不依赖固定的 offset,而是基于上一页最后一条记录的分数作为下一页的查询起点,从而避免重复和遗漏,且查询效率稳定。这种方法特别适合按时间倒序排列的场景(例如新闻列表、消息流),通常用时间戳作为分数。

3.滚动分页的参数设计解析

3.1 为什么用时间戳作为分数?

将元素的发布时间或更新时间作为分数,按时间倒序排列,最新的元素分数最高。这样:

  • 第一页查询:max = 当前时间戳min = 0,取所有时间 ≤ 现在的最新记录。

  • 后续页查询:以上一页的最后一条记录的时间戳作为新的 max,保证只取比该时间更早的记录。

3.2 处理分数重复的关键:offset 的妙用

如果多个元素具有相同的分数(例如同一秒发布的文章),仅仅用分数作为游标会带来问题:

  • 如果下一页直接用上一页的最小分数作为 max,那么这些同分数的元素会在下一页被重复返回(因为分数相同,它们既属于上一页,也符合下一页的范围)。

  • 如果直接跳过整个分数,则可能漏掉一部分同分数的元素。

解决方案:记录上一页中与最小分数相同的元素个数,并作为下一页查询的 offset 参数。这样,下一页会跳过这些已取过的同分数元素,从下一个不同分数的元素开始返回。

3.3 参数变化举例

假设有序集合 news:timeline 中有以下数据(按时间倒序排列):

|--------|------------|--------|
| member | score(时间戳) | |
| A | 1700000003 | |
| B | 1700000002 | |
| C | 1700000002 | ← 分数重复 |
| D | 1700000001 | |
| E | 1700000000 | |

第一页(每页 2 条):

  • max = 当前时间戳(假设为 1700000003),min = 0offset = 0count = 2

  • 结果:[A, B](按分数降序,同分数内按字典序)

  • 此时最小分数为 1700000002,且这一页中该分数出现了 1 次(只有 B,注意 B 和 C 分数相同但 B 先被返回)。

第二页:

  • max = 上一页的最小分数 = 1700000002

  • offset = 上一页中最小分数出现的次数 = 1

  • count = 2

  • 命令:ZREVRANGEBYSCORE news:timeline 1700000002 0 LIMIT 1 2

  • 执行逻辑:在分数 [0, 1700000002] 范围内,按降序取,跳过 1 个元素,然后取 2 个。

  • 跳过谁?分数 1700000002 的第一个元素(即 B)被跳过,从该分数的下一个元素(C)开始取。

  • 结果:[C, D](C 是 1700000002,D 是 1700000001)

  • 记录这一页的最小分数 1700000001,且该分数出现次数为 1(只有 D)。

第三页:

  • max = 1700000001offset = 1count = 2

  • 结果:[E](因为后面只有 E 了),可能不足 count。

4.深入理解命令的执行细节

4.1 分数范围与 LIMIT 的协作

ZREVRANGEBYSCORE 先根据分数范围筛选出一个有序子集(降序),然后应用 LIMIT offset count 进行截断。整个过程都在 Redis 服务端完成,高效稳定。

4.2 为何 min 固定为 0?

通常时间戳为正整数,且不会小于 0,所以 min = 0 可以涵盖所有历史数据。如果你的数据中分数可能为负,可以调整,但一般场景 0 足够。

4.3 处理空结果和结束条件

  • 当某次查询结果不足 count 条时,说明已到末尾(没有更早的数据)。

  • 如果某次查询结果为空,则应停止滚动。

5.实际应用中的注意事项

1.命令版本:ZREVRANGEBYSCORE 在 Redis 6.2.0 之后被标记为废弃,官方推荐使用 ZRANGEBYSCORE 并加上 REV 参数,例如:

ZRANGEBYSCORE key min max REV LIMIT offset count

原理完全相同,只是参数顺序和关键字略有调整。

2.并发修改:如果在滚动过程中集合发生变化,可能会导致某一页的 offset 失效。例如,上一页的最小分数在下一页查询前被删除了,此时 offset 的意义会变化。但通常滚动分页用于静态或只追加的数据集(如新闻流),问题不大。

3.性能分析:每次查询的时间复杂度为 O(log(N) + M),其中 M 为跳过和返回的元素总数,无论翻多少页都能保持稳定,不会随着页数增加而变慢。

4.WITHSCORES 选项:在实现滚动分页时,通常需要返回分数,以便客户端记录下一页所需的 maxoffset。因此建议加上 WITHSCORES 参数。

相关推荐
季明洵2 小时前
Java实现循环队列、栈实现队列、队列实现栈
java·数据结构·算法··队列
+VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue社区智慧消防管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
山岚的运维笔记2 小时前
SQL Server笔记 -- 第74章:权限或许可 第75章:SQLCMD 第76章:资源调控器
数据库·笔记·sql·microsoft·oracle·sqlserver
舟舟亢亢2 小时前
Redis知识复习笔记(上)
数据库·redis·笔记
青春:一叶知秋2 小时前
【Redis存储】持久化
数据库·redis·缓存
一 乐2 小时前
英语学习平台系统|基于springboot + vue英语学习平台系统(源码+数据库+文档)
java·vue.js·spring boot·学习·论文·毕设·英语学习平台系统
+VX:Fegn08953 小时前
计算机毕业设计|基于springboot + vue物业管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
无心水10 小时前
【任务调度:数据库锁 + 线程池实战】3、 从 SELECT 到 UPDATE:深入理解 SKIP LOCKED 的锁机制与隔离级别
java·分布式·科技·spring·架构
xcLeigh10 小时前
IoTDB 数据导入全攻略:工具、自动加载与 Load SQL 详解
数据库·sql·工具·iotdb·数据导入·loadsql