NoSQL数据库(一):Redis - 高性能架构不可替代的王者

1. [核心] Redis 概念与基础

摘要: 本章将为您揭开 Redis 的面纱。我们将从 Redis 的核心定义出发,探讨其为何能在众多数据库中脱颖而出,成为现代应用架构的基石。您将了解到 Redis 凭借其 高性能丰富的数据结构原子性操作 等关键特性,在 缓存分布式锁消息队列 等多种场景下的核心应用。本章是掌握 Redis 技术体系的起点。


1.1. 什么是 Redis

Redis,全称为 Remote Dictionary Server (远程字典服务),是一款使用 C 语言编写的、开源的、基于内存的高性能 Key-Value 存储系统。它并非简单地只能存储字符串,而是内置了对多种数据结构的原生支持,如字符串 (String)、列表 (List)、哈希 (Hash)、集合 (Set) 及有序集合 (ZSet) 等。

凭借其在内存中读写数据的特性,Redis 提供了卓越的性能,使其成为构建高性能、高并发应用的首选解决方案。同时,它还支持持久化,确保了内存中的数据在服务重启后不会丢失。

官方资料速查


1.2. 为什么选择 Redis

当我们评估一项技术时,通常会关注其核心特性与带来的价值。Redis 之所以被广泛采用,源于其一系列强大的内在特性,这些特性共同构成了其解决各种业务痛点的能力基础。

核心特性一览

特性说明
读写性能优异基于内存的操作,官方 Benchmark 显示单机 QPS 可达 10W+,是其成为高速缓存首选的核心原因。
数据类型丰富原生支持多种数据结构,使得复杂业务场景的实现变得简单高效。
原子性Redis 的所有单命令操作都是 原子性 的,确保了并发场景下的数据一致性。
持久化支持支持 RDB 和 AOF 两种持久化机制,实现了数据从内存到硬盘的备份。
发布/订阅模式内置消息发布与订阅功能,可作为轻量级的消息中间件使用。
分布式支持官方提供 Redis Sentinel (哨兵) 和 Redis Cluster (集群) 方案,保障了高可用和高扩展性。

1.3. 核心应用场景剖析

正是基于上述特性,Redis 在实际业务中扮演着多样化的角色。下面我们从理论层面,剖析其最经典的几个应用场景,理解其解决问题的思路。

1.3.1. 热点数据缓存

这是 Redis 最广泛 的应用场景。在绝大多数应用中,用户的请求遵循“二八定律”,即 80% 的请求访问的是 20% 的热点数据。如果这些请求全部直接查询数据库,将给数据库带来巨大压力。Redis 作为内存数据库,其极高的读写性能可以完美解决这个问题,通过缓存热点数据,大幅降低数据库压力,提升应用响应速度。

缓存使用策略: 作为缓存使用时,需要注意缓存与数据库的数据一致性问题,并采取相应策略避免 缓存穿透缓存击穿缓存雪崩 等问题。

1.3.2. 限时业务的运用

在很多业务场景中,数据具有明确的生命周期,例如手机短信验证码 5 分钟内有效、用户登录状态(Session)30 分钟后过期、限时优惠活动到期后自动下线等。Redis 提供了强大的 过期时间(TTL) 设置功能(如 expire 命令),可以为存储的任何键(Key)指定生存时间,一旦超时,Redis 会自动将其删除,无需应用程序手动管理。

1.3.3. 高并发计数器

网站文章的阅读数、视频的播放量、商品的点赞数等,都是典型的高并发计数场景。如果直接在关系型数据库中用 UPDATE 语句来更新计数值,会因行锁机制导致大量请求串行化,性能极差。Redis 提供了原生的 原子性自增命令(如 incrby),它在内存中完成操作,无锁竞争,能够轻松应对每秒数万次的计数请求。

1.3.4. 分布式锁

在分布式架构下,多个服务实例并行运行,当它们需要访问同一个共享资源(如修改同一个文件、执行一个关键任务)时,必须确保同一时刻只有一个实例在操作,这就是分布式锁。Redis 的 `setnx` (SET if Not eXists) 命令具备天然的互斥性,当一个键不存在时才设置成功,正好可以用来作为获取锁的标志,从而实现简单高效的分布式锁。

1.3.5. 延时操作

某些业务需要在事件发生一段时间后才执行,例如“用户下单后 10 分钟未支付则自动取消订单”。这可以通过 Redis 的键空间通知(Keyspace Notifications)功能实现。我们可以在下单时设置一个 10 分钟后过期的 key,然后通过订阅该 key 的过期事件来触发后续的取消订单逻辑。这比传统的定时任务扫描要高效得多。

1.3.6. 排行榜

游戏积分榜、直播贡献榜、热销商品榜等动态排序需求,对数据库来说是极其复杂的查询。而 Redis 的 有序集合(SortedSet) 数据结构为此类场景量身打造。它在集合的基础上为每个成员关联一个分数(score),并始终保持成员按分数排序,获取 Top N 列表的操作速度极快。

1.3.7. 社交关系存储

在微博、微信等社交应用中,需要高效地存储和查询用户间的关系,如“我的关注列表”、“我和 A 的共同好友”、“是否是 A 的粉丝”等。Redis 的 集合(Set) 数据结构支持快速的成员增删查,并且提供了求交集、并集、差集等原生命令,可以极快地实现共同关注、可能认识的人等复杂社交关系的计算。

1.3.8. 简单消息队列

在应用架构中,我们常常需要通过消息队列来解耦服务和削峰填谷。例如,在下单成功后发送短信通知,这个非核心流程就可以异步处理。Redis 的 列表(List) 数据结构是一个双向链表,支持在两端进行压入(push)和弹出(pop)操作,可以作为一个性能优异、先进先出(FIFO)的轻量级消息队列来使用。


2. [核心] 5 种基础数据类型详解

摘要: 数据类型 是 Redis 功能的基石。与许多简单的 Key-Value 存储不同,Redis 在服务器端原生支持了多种复杂的数据结构,这使得开发者可以更高效地解决复杂问题。本章将深入剖析 Redis 中最核心的五种基础数据类型:String (字符串)List (列表)Set (集合)Hash (散列)ZSet (有序集合)。我们将通过图例、核心命令、代码示例和实战场景,带您彻底掌握它们。

我们将使用命令行的方式去连接到我们的Redis数据库,请确保您本地已经下载了Redis并配置环境变量

在命令行输入redis-cli -h 主机地址 -p 端口号,默认主机是 127.0.0.1,端口是 6379,直接redis-cli也可能行。


2.1. Redis 数据类型概览

在深入每种类型之前,我们需要明确一个核心概念:Redis 的所有键 (Key) 都是字符串。我们通常所说的数据类型,指的是与键关联的值 (Value) 的类型。下表是对这五种基础数据类型的简要总结:

结构类型存储的值核心能力
String (字符串)字符串、整数或浮点数对整体或部分字符串操作;原子性的增/减操作。
List (列表)由字符串组成的双向链表在列表两端进行 PUSH/POP;按范围/索引获取元素。
Set (集合)无序、唯一的字符串集合高效的增/删/查;计算交集、并集、差集。
Hash (散列)字段-值 (Field-Value) 对的无序散列表存取单个或多个字段;适合存储对象结构。
ZSet (有序集合)唯一的字符串成员与浮点数分数的映射成员按分数排序;按分数范围或排名获取成员。

2.2. String (字符串)

2.2.1. String - 概念与特性

String 是 Redis 中最基本、最常用的数据类型。它可以存储任何形式的字符串数据,例如普通的文本、序列化后的 JSON、乃至图片或视频的二进制数据。因此,String 类型是 二进制安全 的。当值为整数或浮点数时,Redis 还能将其作为数字进行原子性的增减操作。

2.2.2. String - 核心命令

命令用法示例功能描述
SETSET key value设置指定键的值。
GETGET key获取指定键的值。
DELDEL key删除指定的键。
INCRINCR key将键中储存的数字值原子性地增一。
DECRDECR key将键中储存的数字值原子性地减一。
INCRBYINCRBY key amount将键中储存的数字值原子性地增加指定整数。

2.2.3. String - 实战场景与代码示例

1. 场景:对象缓存

背景: 将用户信息等结构化数据序列化为 JSON 字符串后,存入 Redis 进行缓存,以加速访问。

解决方案: 使用 SET 命令将 JSON 字符串存入,使用 GET 获取。当用户信息更新或删除时,使用 DEL 清除缓存。

1
2
3
4
5
6
7
8
# 缓存用户信息
SET user:1001 '{"name":"Alice","age":25,"city":"New York"}'

# 获取缓存的用户信息
GET user:1001

# 删除缓存
DEL user:1001

2. 场景:高并发计数器

背景: 统计文章的阅读量,需要一个能承受高并发写入的计数器。

解决方案: 利用 INCR 命令的原子性,为每篇文章设置一个计数器键。

1
2
3
4
5
6
7
8
# 初始化文章 "article:123:views" 的阅读量为 0
SET article:123:views 0

# 每次被阅读时,执行 INCR
INCR article:123:views

# 假设有 100 个并发请求
INCRBY article:123:views 100

3. 场景:库存管理

比如电商商品库存,用 DECR减少库存数量

INCRBY一次减多件,如 INCRBY product:100 -5 减 5 件库存。

1
2
3
4
5
6
# 假设初始库存为 50
SET product 50
# 卖出 1 件用 DECR product
DECR product
# 批量卖出 5 件
INCRBY product -5

2.3. List (列表)

2.3.1. List - 概念与特性

Redis 的 List 类型是一个双向链表,它保证了元素的插入顺序。由于其链表结构,在列表的头部和尾部进行元素的推入 (PUSH) 和弹出 (POP) 操作,其性能极高。这使得 List 非常适合用于实现消息队列、任务队列以及动态信息流(如微博的 Timeline)。

2.3.2. List - 核心命令

命令用法示例功能描述
LPUSHLPUSH key element...从列表左侧(头部)推入一个或多个元素。
RPUSHRPUSH key element...从列表右侧(尾部)推入一个或多个元素。
LPOPLPOP key从列表左侧(头部)弹出一个元素。
RPOPRPOP key从列表右侧(尾部)弹出一个元素。
LRANGELRANGE key start stop获取列表指定范围内的所有元素。0 -1 表示所有。
LINDEXLINDEX key index通过索引获取列表中的元素。

重要信息: 在实际使用中,具体怎么选择用左边还是右边操作呀?

比如要实现先进先出的队列,就用 RPUSH 入队,LPOP 出队;要是想后进先出,那就 LPUSH 进,LPOP 出

2.3.3. List - 实战场景与代码示例

1. 场景:信息流(Timeline)

背景: 用户发布了新的动态,需要将其加入到关注者的信息流中。最新的动态应该最先被看到。

解决方案: 使用 LPUSH 将新动态推入用户 Timeline 列表的头部。使用 LRANGE 可以分页获取最新的动态。

1
2
3
4
5
6
7
# 用户 "user:1001" 发布了三条动态
LPUSH user:1001:timeline "post:3"
LPUSH user:1001:timeline "post:2"
LPUSH user:1001:timeline "post:1"

# 获取该用户的最新 10 条动态
LRANGE user:1001:timeline 0 9

2. 场景:简单消息队列

背景: 实现一个简单的先进先出(FIFO)的任务队列。

解决方案: 生产者使用 LPUSH 从左侧放入任务,消费者使用 RPOP 从右侧取出任务。

1
2
3
4
5
6
7
# 生产者放入两个任务
LPUSH task_queue "send_email"
LPUSH task_queue "generate_report"

# 消费者处理任务
RPOP task_queue
RPOP task_queue

3. 场景:歌曲播放列表

1
2
3
4
5
6
# 插入三首歌
LPUSH playlist:1 song1
LPUSH playlist:1 song2
LPUSH playlist:1 song3
# 获取第二首歌
LINDEX playlist:1 1

2.4. Set (集合)

2.4.1. Set - 概念与特性

Redis 的 Set 是一个无序的、元素唯一的字符串集合。由于其底层由哈希表实现,因此添加、删除和查找元素的时间复杂度都是 O(1)。Set 的核心价值在于其 唯一性 和高效的集合运算能力(如求交集、并集、差集),非常适合用于数据去重和关系计算。

2.4.2. Set - 核心命令

命令用法示例功能描述
SADDSADD key member...向集合中添加一个或多个成员。
SREMSREM key member...从集合中移除一个或多个成员。
SMEMBERSSMEMBERS key返回集合中的所有成员。
SISMEMBERSISMEMBER key member判断一个成员是否存在于集合中。
SCARDSCARD key获取集合的成员数量(基数)。
SINTERSINTER key1 key2返回给定所有集合的交集。

2.4.3. Set - 实战场景与代码示例

1. 场景:抽奖系统

背景: 在一个抽奖活动中,需要保证每个用户只能参与一次。

解决方案: 使用 SADD 将参与用户的 ID 添加到集合中。SADD 命令在成员已存在时会返回 0,可以据此判断用户是否重复参与。

1
2
3
4
5
6
7
8
9
# 集合 "lottery:2025" 存储所有参与抽奖的用户 ID
# 用户 1001 第一次参与,成功
SADD lottery:2025 1001

# 用户 1001 再次尝试参与,失败
SADD lottery:2025 1001

# 查看所有参与用户
SMEMBERS lottery:2025

2. 场景:共同关注

背景: 计算两位用户的共同关注列表。

解决方案: 将每个用户的关注列表存储在一个 Set 中,然后使用 SINTER 命令计算交集。

1
2
3
4
5
6
7
8
# Alice 关注了 a, b, c
SADD user:alice:following "a" "b" "c"

# Bob 关注了 b, c, d
SADD user:bob:following "b" "c" "d"

# 计算 Alice 和 Bob 的共同关注
SINTER user:alice:following user:bob:following

2.5. Hash (散列)

2.5.1. Hash - 概念与特性

Redis 的 Hash 类型是一个 字段(Field) - 值(Value) 对的集合,可以看作是程序语言中 Map 或 Dictionary 的实现。它特别适合用来存储对象。相比于使用 String 存储序列化的 JSON 对象,Hash 的优势在于可以对对象中的单个字段进行独立的读写操作,更加灵活和节省网络开销。

2.5.2. Hash - 核心命令

命令用法示例功能描述
HSETHSET key field value将哈希表 key 中的字段 field 的值设为 value。
HGETHGET key field获取存储在哈希表中指定字段的值。
HGETALLHGETALL key获取在哈希表中指定 key 的所有字段和值。
HDELHDEL key field...删除一个或多个哈希表字段。
HINCRBYHINCRBY key field increment为哈希表中的字段值加上指定的增量值。

2.5.3. Hash - 实战场景与代码示例

1. 场景:存储用户信息

背景: 缓存用户的详细信息,如姓名、邮箱、年龄等,并可能需要频繁更新其中某个字段(如年龄)。

解决方案: 使用一个 Hash 键(如 user:1001)来存储该用户的所有信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 存储用户 1001 的信息
HSET user:1001 name "Bob"
HSET user:1001 email "bob@example.com"
HSET user:1001 age 30

# 获取用户的全部信息
HGETALL user:1001

# 单独获取用户的姓名
HGET user:1001 name

# 为用户的年龄增加 1
HINCRBY user:1001 age 1

2.6. ZSet (有序集合)

2.6.1. ZSet - 概念与特性

ZSet (Sorted Set) 与 Set 类似,也是一个不允许重复成员的字符串集合。但不同之处在于,ZSet 的每个成员都会关联一个 double 类型的 分数 (score)。Redis 正是根据这个分数对集合中的成员进行排序。这使得 ZSet 成为实现排行榜等需要动态排序功能的业务的完美选择。

底层实现: ZSet 的实现较为复杂,它结合了哈希表跳跃表 (SkipList)。哈希表用于存储成员到分数的映射,保证了 O(1) 的成员查找复杂度;跳跃表则用于按分数排序,保证了范围查询的高效性。

2.6.2. ZSet - 核心命令

命令用法示例功能描述
ZADDZADD key score member...向有序集合添加一个或多个成员,或者更新已存在成员的分数。
ZREMZREM key member...移除有序集合中的一个或多个成员。
ZRANGEZRANGE key start stop按分数从小到大返回指定排名范围的成员。
ZREVRANGEZREVRANGE key start stop按分数从大到小返回指定排名范围的成员。
ZSCOREZSCORE key member返回有序集合中,成员的分数值。

2.6.3. ZSet - 实战场景与代码示例

1. 场景:游戏排行榜

背景: 实现一个实时更新的游戏积分排行榜,需要随时能查询到排名前列的玩家。

解决方案: 使用 ZADD 更新玩家的最新分数。使用 ZREVRANGE 获取积分从高到低的玩家排名。

1
2
3
4
5
6
7
8
9
10
# 添加三位玩家的得分到排行榜
ZADD leaderboard 3200 "player:1"
ZADD leaderboard 5000 "player:2"
ZADD leaderboard 4100 "player:3"

# 玩家 1 又获得了 1000 分
ZADD leaderboard 4200 "player:1"

# 获取排名前 3 的玩家和他们的分数
ZREVRANGE leaderboard 0 2 WITHSCORES

3. [进阶] 3 种特殊数据类型详解

摘要: 在掌握了五种基础数据类型之后,本章将带您探索 Redis 提供的三种更为特化的数据结构:HyperLogLog (基数统计)Bitmap (位图)Geospatial (地理位置)。它们虽然不像基础类型那样通用,但在各自擅长的领域——海量数据去重统计、大规模用户状态追踪和地理位置服务(LBS)——中,能够以极高的效率和极低的内存消耗解决复杂问题,是构建高性能专业应用的利器。


3.1. HyperLogLog (基数统计)

3.1.1. 概念与优势

HyperLogLog (HLL) 是一种用于进行 基数统计 的概率性数据结构。“基数”指的是一个集合中不重复元素的数量。例如,一个网站一天的独立访客(UV)就是一个基数。

核心解决的问题: 在海量数据中,以极小的内存占用,估算出一个集合的基数。常规方式(如使用 Set)需要存储所有不重复的元素,当元素数量达到亿级别时,内存消耗会非常巨大。而 HLL 只需要固定的、极小的内存(在 Redis 中为 12KB)就能估算接近 2^64 个元素的基数。

概率性与容错: HLL 是一种估算算法,其结果并非 100% 精确,而是存在一定的误差(标准误差为 0.81%)。这个特性决定了它不适用于要求精确计数的场景,但对于像 UV 统计这类可以接受微小误差的场景,则是完美的解决方案。

在 Redis 中 HyperLogLog 相关命令前缀是 PF,可能是因为它基于 “Probabilistic Filtering(概率性过滤)” 原理来实现基数统计,所以取了这两个单词的首字母作为命令前缀。

3.1.2. 核心命令与示例

背景: 我们需要统计页面 A 和页面 B 的单日独立访客数(UV),以及这两个页面的总独立访客数。

解决方案: 使用 PFADD 将每个来访的用户 ID 添加到对应页面的 HLL 中。使用 PFCOUNT 查看估算的 UV。使用 PFMERGE 将两个页面的 HLL 合并,以计算总 UV。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 用户 u1, u2, u3, u4 访问了 page:a
PFADD page:a:uv u1 u2 u3 u4

# 查看 page:a 的 UV
PFCOUNT page:a:uv

# 用户 u3, u4, u5, u6 访问了 page:b
PFADD page:b:uv u3 u4 u5 u6

# 查看 page:b 的 UV
PFCOUNT page:b:uv

# 合并 page:a 和 page:b 的 UV 数据到新的 HLL "total:uv"
PFMERGE total:uv page:a:uv page:b:uv

# 查看合并后的总 UV (u1, u2, u3, u4, u5, u6,共 6 个)
PFCOUNT total:uv

3.2. Bitmap (位图)

3.2.1. 概念与优势

Bitmap (位图) 本身并不是一种独立的数据类型,而是 String 类型上的一组面向二进制位的操作。它允许我们将一个字符串看作是一个由 01 组成的位数组,并能对其中任意一位进行读写。

核心解决的问题: 高效存储只有两种状态的大规模数据。例如,记录一个拥有 1 亿用户的网站每日签到情况,如果用常规方式,每天可能需要上亿条记录。而使用 Bitmap,每个用户只占 1 个 bit,1 亿用户也仅需约 12MB 的内存 (100,000,000 / 8 / 1024 / 1024 ≈ 11.92MB),空间效率极高。

3.2.2. 核心命令与示例

背景: 记录用户 ID 为 888 的用户在一周内的签到打卡情况。我们约定周一对应偏移量 0,周二为 1,以此类推,周日为 6。打卡记为 1,未打卡为 0

解决方案: 使用 SETBIT 将指定偏移量(代表天)的位设置为 1。使用 GETBIT 查询某天的打卡状态。使用 BITCOUNT 统计一周内总的打卡天数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 假设用户 888 在周一(0)、周二(1)、周四(3)、周日(6)打卡
SETBIT user:888:sign 0 1
SETBIT user:888:sign 1 1
SETBIT user:888:sign 3 1
SETBIT user:888:sign 6 1

# 查询用户周四(3)是否打卡
GETBIT user:888:sign 3

# 查询用户周三(2)是否打卡
GETBIT user:888:sign 2

# 统计用户本周的总打卡天数
BITCOUNT user:888:sign

3.3. Geospatial (地理位置)

3.3.1. 概念与核心能力

Geospatial (GEO) 是 Redis 3.2 版本推出的功能,专用于处理地理位置信息。它允许你存储地理坐标点(经度和纬度),并对这些点进行基于距离的计算和查询。

核心解决的问题: 实现 LBS (Location-Based Service) 应用中的常见功能,如“附近的人”、“两点之间的距离”、“某个坐标点半径范围内的所有成员”等。

底层实现揭秘: GEO 功能的底层数据结构是 ZSet。它使用 Geohash 算法将二维的经纬度坐标编码成一个一维的分数(score),并将其作为 ZSet 的分数进行存储,从而巧妙地利用 ZSet 的排序能力来实现地理位置的检索。

GeoSpatial 把经纬度用 GeoHash 算法变成一串字符存进 ZSet。比如找附近的店,就把店的坐标转成字符存起来,要查时通过字符快速比对距离,就像给每个位置编个特殊 “地址码”,方便快速找位置关系。

3.3.2. 核心命令与示例

1. 添加地理位置 (geoadd)

背景: 我们需要录入中国几个主要城市的经纬度信息。

坐标范围: GEOADD 命令要求有效的经度在 -180180 度之间,有效的纬度在 -85.0511287885.05112878 度之间。

1
2
# 将北京、上海、深圳的坐标添加到名为 "china:cities" 的集合中
GEOADD china:cities 116.40 39.90 beijing 121.47 31.23 shanghai 114.05 22.52 shenzhen

2. 计算两地距离 (geodist)

背景: 计算北京到上海的直线距离(单位:公里)。

1
2
# 计算 beijing 和 shanghai 之间的距离,单位为 km
GEODIST china:cities beijing shanghai km

3. 查询“附近的人” (georadiusbymember)

背景: 查找距离“北京”1200 公里范围内的所有城市。

解决方案: 使用 GEORADIUSBYMEMBER,它允许我们以一个已存在的成员(beijing)为中心进行范围查询。

1
2
3
4
5
# 以 beijing 为中心,查询 1200km 半径内的城市
# WITHDIST: 同时返回与中心点的距离
# WITHCOORD: 同时返回成员的经纬度坐标
# COUNT 2: 只返回最近的 2 个结果(包括中心点自己)
GEORADIUSBYMEMBER china:cities beijing 1200 km WITHDIST WITHCOORD COUNT 2

4. [进阶] 数据类型 Stream 详解

摘要: 本章将深入探讨 Redis 5.0 之后最重要的新增数据类型——Stream。它并非对现有类型的简单补充,而是 Redis 官方提供的一个功能完备、支持持久化的消息队列(MQ)解决方案。我们将从 Stream 的设计初衷出发,剖析其核心结构、两种消费模式(独立消费与消费组),并最终通过模拟面试问答的形式,探讨其在消息确认(ACK)、故障转移(Failover)和死信处理等高级场景下的内部机制,帮助您彻底掌握这个强大的新特性。


4.1. 为什么需要 Stream

优点

  • 支持多播:一个消息可被多个订阅者同时接收,符合消息队列的基本模型。

致命缺点

  • 无持久化能力。消息“发后即忘”,若订阅者不在线或网络中断,消息永久丢失,在绝大多数业务场景中不可接受。

优点

  • 通过 LPUSHBRPOP 等命令,可实现持久化的阻塞式 FIFO 队列。

致命缺点

  • 不支持多播:任务被一个消费者 POP 后,其他消费者无法再获取,只适合简单“任务分发”而非“消息广播”。
  • 缺乏分组和确认机制:无法实现复杂的分组消费与消息处理确认。

优点

  • 功能完备的 MQ 模型:借鉴 Kafka 设计思想,支持多播且可持久化。
  • 支持消费组 (Consumer Group):可在多个消费者间实现负载均衡与竞争消费。
  • 支持消息持久化:Redis 重启后数据不丢失。
  • 支持消息确认 (ACK):确保消息被成功处理。
  • 支持阻塞式读取:高性能。

一个健壮的消息队列需要考虑诸多设计要点,而 Stream 正是 Redis 官方为了系统性解决这些问题而推出的。


4.2. Stream 的核心结构

上图展示了 Stream 的几个核心概念:

  • Stream: Redis 中的一个键(Key),作为消息的容器。
  • 消息 (Message): Stream 中的基本单位,由一个唯一的 消息ID (Message ID) 和一个或多个 键值对内容 (Content) 组成。
    • 消息ID: 格式为 timestampInMillis-sequence(如 1527846880572-5),由服务器时间戳和毫秒内序号组成,保证了全局的、单调递增的顺序。
  • 消费组 (Consumer Group): 一组共同消费同一个 Stream 的消费者集合。Stream 中的消息会被分发给组内的所有消费者,但对于一条具体消息,组内只有一个消费者能够接收到。
  • 消费者 (Consumer): 消费组内的一个成员,负责处理消息。
  • last_delivered_id: 每个消费组内部的游标,记录了投递给组内消费者的最后一条消息的 ID。
  • 待处理条目列表 (Pending Entries List, PEL): 每个消费者都有一个独立的 PEL,用于记录那些已被客户端读取、但尚未通过 XACK 命令确认处理完成的消息。这是实现消息可靠性的关键。

4.3. 基础操作 (CRUD)

Stream 的基础操作围绕着消息的添加(XADD)、读取(XRANGE)和删除(XDEL)等展开。

背景: 我们创建一个名为 mystream 的流,并向其中添加几条用户信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 使用 * 号,让 Redis 自动生成消息 ID
# 消息内容是 key-value 对
XADD mystream * name laoqian age 30
XADD mystream * name xiaoyu age 29

# 获取流中的消息总数
XLEN mystream

# 使用 - 和 + 作为起止范围,获取流中的所有消息
XRANGE mystream - +

# 删除指定 ID 的消息
XDEL mystream 1527849609889-0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:6379> XADD mystream * name laoqian age 30
"1723982260001-0"
127.0.0.1:6379> XADD mystream * name xiaoyu age 29
"1723982260002-0"
127.0.0.1:6379> XLEN mystream
(integer) 2
127.0.0.1:6379> XRANGE mystream - +
1) 1) "1723982260001-0"
2) 1) "name"
2) "laoqian"
3) "age"
4) "30"
2) 1) "1723982260002-0"
2) 1) "name"
2) "xiaoyu"
3) "age"
4) "29"
127.0.0.1:6379> XDEL mystream "1723982260001-0"
(integer) 1

4.4. 两种消费模式

4.4.1. 独立消费模式

这是最简单的消费方式,不涉及消费组。你可以像读取一个普通的列表(List)一样,从指定的消息 ID 开始读取 Stream 中的消息。

背景: 顺序读取 mystream 中的所有消息,并阻塞等待新消息的到来。

解决方案: 使用 XREAD 命令。STREAMS 关键字后跟 keyIDID0-0 表示从头开始,$ 表示只接收新消息。BLOCK 0 表示永久阻塞。

1
2
3
4
5
6
7
8
9
10
# 重新准备数据
XADD mystream * name laoqian age 30
XADD mystream * name yurui age 29

# 从头 (ID: 0-0) 开始,最多读取 2 条消息
XREAD COUNT 2 STREAMS mystream 0-0

# 阻塞等待新消息,超时时间为 0 (永不超时)
# 这条命令会挂起,直到有新消息
XREAD BLOCK 0 STREAMS mystream $
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 终端 1
127.0.0.1:6379> XADD mystream * name laoqian age 30
"1723982270001-0"
127.0.0.1:6379> XADD mystream * name yurui age 29
"1723982270002-0"
127.0.0.1:6379> XREAD COUNT 2 STREAMS mystream 0-0
1) 1) "mystream"
2) 1) 1) "1723982270001-0"
2) 1) "name"
2) "laoqian"
3) "age"
4) "30"
2) 1) "1723982270002-0"
2) 1) "name"
2) "yurui"
3) "age"
4) "29"
127.0.0.1:6379> XREAD BLOCK 0 STREAMS mystream $
# (此时终端 1 阻塞)

# 在 终端 2 执行 XADD
# 127.0.0.1:6379> XADD mystream * name youming age 60
# "1723982280001-0"

# 终端 1 的阻塞会立即解除,并返回新消息
1) 1) "mystream"
2) 1) 1) "1723982280001-0"
2) 1) "name"
2) "youming"
3) "age"
4) "60"
(10.00s)

4.4.2. 消费组模式

这是 Stream 最强大、最常用的模式,它允许多个消费者协同处理消息。

1. 创建消费组

背景: 为 mystream 创建一个名为 cg1 的消费组,让它从头开始消费。

1
2
3
4
# XGROUP CREATE <key> <groupname> <id>
# id 为 0-0 表示从头开始
# id 为 $ 表示只消费新消息
XGROUP CREATE mystream cg1 0-0

2. 组内消费与确认

背景: 消费者 c1 加入 cg1 消费组,开始处理消息,并在处理完后进行确认。

解决方案:

  • 使用 XREADGROUP 读取消息。GROUP 关键字后跟组名和消费者名。> 这个特殊的 ID 表示读取尚未投递给组内任何消费者的消息。
  • 使用 XACK 确认消息。
1
2
3
4
5
6
7
8
9
10
11
# 消费者 c1 读取一条消息
XREADGROUP GROUP cg1 c1 COUNT 1 STREAMS mystream >

# 查看消费组状态,会发现 c1 有 1 条 pending 消息
XINFO CONSUMERS mystream cg1

# c1 处理完后,确认该消息 (假设ID为 1723982270001-0)
XACK mystream cg1 1723982270001-0

# 再次查看状态,pending 消息变为 0
XINFO CONSUMERS mystream cg1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
127.0.0.1:6379> XREADGROUP GROUP cg1 c1 COUNT 1 STREAMS mystream >
1) 1) "mystream"
2) 1) 1) "1723982270001-0"
2) 1) "name"
2) "laoqian"
3) "age"
4) "30"
127.0.0.1:6379> XINFO CONSUMERS mystream cg1
1) 1) "name"
2) "c1"
3) "pending"
4) (integer) 1
5) "idle"
6) (integer) 3500
127.0.0.1:6379> XACK mystream cg1 1723982270001-0
(integer) 1
127.0.0.1:6379> XINFO CONSUMERS mystream cg1
1) 1) "name"
2) "c1"
3) "pending"
4) (integer) 0
5) "idle"
6) (integer) 8200

4.5. 深度理解:高级机制与故障处理

为了更深入地理解 Stream 的设计精髓,我们用模拟面试的方式探讨几个关键问题。

面试官深度追问
2025-08-19 15:30
面试官

Stream 的消息 ID 格式是 时间戳-序号,如果服务器时间发生回拨,ID 顺序会乱吗?

M

不会。Redis 的每个 Stream 内部都维护了一个 latest_generated_id 属性,记录了最后生成的消息 ID。如果 XADD 时发现当前服务器时间戳小于这个记录,Redis 会沿用上次的时间戳,只将序号部分加一。这样就保证了消息 ID 始终是单调递增的。

面试官

很好。那如果一个消费者用 XREADGROUP 读取了消息,但在处理时崩溃了,这条消息会丢失吗?

M

不会丢失。这正是 StreamPEL (待处理条目列表) 机制发挥作用的地方。当消费者读取消息后,该消息的 ID 会被放入它自己的 PEL 中。只有当消费者显式调用 XACK 命令,该 ID 才会从 PEL 中移除。如果消费者崩溃而没有 XACK,这条消息会永远留在 PEL 里。

面试官

那如果这个消费者彻底宕机,再也回不来了,它 PEL 里的消息怎么办?总不能一直不处理吧?

M

这种情况,可以通过 XCLAIM 命令实现 消息所有权的转移。另一个健康的消费者可以 XCLAIM 这条长时间未被 ACK 的消息,把它从宕机消费者的 PEL 转移到自己的 PEL 中来继续处理。为了防止消息被误抢,XCLAIM 还需要指定一个最小闲置时间,只有超过这个时间的消息才能被转移。

面试官

非常好。最后一个问题,如果一条消息本身有问题,导致所有消费者都处理失败,反复转移、反复失败怎么办?也就是“死信”问题。

M

Stream 也考虑了这一点。我们可以通过 XPENDING 命令查询每条待处理消息的 delivery counter (已被投递次数)。如果发现某条消息的投递次数超过了一个我们设定的阈值(例如 10 次),就可以认为它是“死信”。此时,我们就可以把它取出来,记录到专门的日志或队列中,然后 XACK 掉,避免它在主流程里无限循环。