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

NoSQL数据库(一):Redis - 高性能架构不可替代的王者
Prorise1. [核心] Redis 概念与基础
摘要: 本章将为您揭开 Redis 的面纱。我们将从 Redis 的核心定义出发,探讨其为何能在众多数据库中脱颖而出,成为现代应用架构的基石。您将了解到 Redis 凭借其 高性能、丰富的数据结构 和 原子性操作 等关键特性,在 缓存、分布式锁、消息队列 等多种场景下的核心应用。本章是掌握 Redis 技术体系的起点。
1.1. 什么是 Redis
Redis,全称为 Remote Dictionary Server (远程字典服务),是一款使用 C 语言编写的、开源的、基于内存的高性能 Key-Value 存储系统。它并非简单地只能存储字符串,而是内置了对多种数据结构的原生支持,如字符串 (String)、列表 (List)、哈希 (Hash)、集合 (Set) 及有序集合 (ZSet) 等。
凭借其在内存中读写数据的特性,Redis 提供了卓越的性能,使其成为构建高性能、高并发应用的首选解决方案。同时,它还支持持久化,确保了内存中的数据在服务重启后不会丢失。
官方资料速查
- Redis 官网:
https://redis.io/
- Redis 官方文档:
https://redis.io/documentation
- Redis 下载:
https://redis.io/download
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 - 核心命令
命令 | 用法示例 | 功能描述 |
---|---|---|
SET | SET key value | 设置指定键的值。 |
GET | GET key | 获取指定键的值。 |
DEL | DEL key | 删除指定的键。 |
INCR | INCR key | 将键中储存的数字值原子性地增一。 |
DECR | DECR key | 将键中储存的数字值原子性地减一。 |
INCRBY | INCRBY key amount | 将键中储存的数字值原子性地增加指定整数。 |
2.2.3. String - 实战场景与代码示例
1. 场景:对象缓存
背景: 将用户信息等结构化数据序列化为 JSON 字符串后,存入 Redis 进行缓存,以加速访问。
解决方案: 使用 SET
命令将 JSON 字符串存入,使用 GET
获取。当用户信息更新或删除时,使用 DEL
清除缓存。
1 | # 缓存用户信息 |
1
2
3
4
5
6
127.0.0.1:6379> SET user:1001 '{"name":"Alice","age":25,"city":"New York"}'
OK
127.0.0.1:6379> GET user:1001
"{\"name\":\"Alice\",\"age\":25,\"city\":\"New York\"}"
127.0.0.1:6379> DEL user:1001
(integer) 1
2. 场景:高并发计数器
背景: 统计文章的阅读量,需要一个能承受高并发写入的计数器。
解决方案: 利用 INCR
命令的原子性,为每篇文章设置一个计数器键。
1 | # 初始化文章 "article:123:views" 的阅读量为 0 |
1
2
3
4
5
6
127.0.0.1:6379> SET article:123:views 0
OK
127.0.0.1:6379> INCR article:123:views
(integer) 1
127.0.0.1:6379> INCRBY article:123:views 100
(integer) 101
3. 场景:库存管理
比如电商商品库存,用 DECR
减少库存数量
用 INCRBY
一次减多件,如 INCRBY product:100 -5 减 5 件库存。
1 | # 假设初始库存为 50 |
1
2
3
4
5
6
7
127.0.0.1:6379> SET product 50
OK
127.0.0.1:6379> DECR product
(integer) 49
127.0.0.1:6379> INCRBY product -5
(integer) 44
127.0.0.1:6379>
2.3. List (列表)
2.3.1. List - 概念与特性
Redis 的 List
类型是一个双向链表,它保证了元素的插入顺序。由于其链表结构,在列表的头部和尾部进行元素的推入 (PUSH
) 和弹出 (POP
) 操作,其性能极高。这使得 List
非常适合用于实现消息队列、任务队列以及动态信息流(如微博的 Timeline)。
2.3.2. List - 核心命令
命令 | 用法示例 | 功能描述 |
---|---|---|
LPUSH | LPUSH key element... | 从列表左侧(头部)推入一个或多个元素。 |
RPUSH | RPUSH key element... | 从列表右侧(尾部)推入一个或多个元素。 |
LPOP | LPOP key | 从列表左侧(头部)弹出一个元素。 |
RPOP | RPOP key | 从列表右侧(尾部)弹出一个元素。 |
LRANGE | LRANGE key start stop | 获取列表指定范围内的所有元素。0 -1 表示所有。 |
LINDEX | LINDEX key index | 通过索引获取列表中的元素。 |
重要信息: 在实际使用中,具体怎么选择用左边还是右边操作呀?
比如要实现先进先出的队列,就用 RPUSH 入队,LPOP 出队;要是想后进先出,那就 LPUSH 进,LPOP 出
2.3.3. List - 实战场景与代码示例
1. 场景:信息流(Timeline)
背景: 用户发布了新的动态,需要将其加入到关注者的信息流中。最新的动态应该最先被看到。
解决方案: 使用 LPUSH
将新动态推入用户 Timeline 列表的头部。使用 LRANGE
可以分页获取最新的动态。
1 | # 用户 "user:1001" 发布了三条动态 |
1
2
3
4
5
6
127.0.0.1:6379> LPUSH user:1001:timeline "post:3" "post:2" "post:1"
(integer) 3
127.0.0.1:6379> LRANGE user:1001:timeline 0 9
1) "post:1"
2) "post:2"
3) "post:3"
2. 场景:简单消息队列
背景: 实现一个简单的先进先出(FIFO)的任务队列。
解决方案: 生产者使用 LPUSH
从左侧放入任务,消费者使用 RPOP
从右侧取出任务。
1 | # 生产者放入两个任务 |
1
2
3
4
5
6
127.0.0.1:6379> LPUSH task_queue "send_email" "generate_report"
(integer) 2
127.0.0.1:6379> RPOP task_queue
"send_email"
127.0.0.1:6379> RPOP task_queue
"generate_report"
3. 场景:歌曲播放列表
1 | # 插入三首歌 |
1
2
3
4
5
6
7
8
9
127.0.0.1:6379> LPUSH playlist:1 song1
(integer) 1
127.0.0.1:6379> LPUSH playlist:1 song2
(integer) 2
127.0.0.1:6379> LPUSH playlist:1 song3
(integer) 3
127.0.0.1:6379> LINDEX playlist:1 1
"song2"
127.0.0.1:6379>
2.4. Set (集合)
2.4.1. Set - 概念与特性
Redis 的 Set
是一个无序的、元素唯一的字符串集合。由于其底层由哈希表实现,因此添加、删除和查找元素的时间复杂度都是 O(1)。Set
的核心价值在于其 唯一性 和高效的集合运算能力(如求交集、并集、差集),非常适合用于数据去重和关系计算。
2.4.2. Set - 核心命令
命令 | 用法示例 | 功能描述 |
---|---|---|
SADD | SADD key member... | 向集合中添加一个或多个成员。 |
SREM | SREM key member... | 从集合中移除一个或多个成员。 |
SMEMBERS | SMEMBERS key | 返回集合中的所有成员。 |
SISMEMBER | SISMEMBER key member | 判断一个成员是否存在于集合中。 |
SCARD | SCARD key | 获取集合的成员数量(基数)。 |
SINTER | SINTER key1 key2 | 返回给定所有集合的交集。 |
2.4.3. Set - 实战场景与代码示例
1. 场景:抽奖系统
背景: 在一个抽奖活动中,需要保证每个用户只能参与一次。
解决方案: 使用 SADD
将参与用户的 ID 添加到集合中。SADD
命令在成员已存在时会返回 0,可以据此判断用户是否重复参与。
1 | # 集合 "lottery:2025" 存储所有参与抽奖的用户 ID |
1
2
3
4
5
6
127.0.0.1:6379> SADD lottery:2025 1001
(integer) 1
127.0.0.1:6379> SADD lottery:2025 1001
(integer) 0
127.0.0.1:6379> SMEMBERS lottery:2025
1) "1001"
2. 场景:共同关注
背景: 计算两位用户的共同关注列表。
解决方案: 将每个用户的关注列表存储在一个 Set
中,然后使用 SINTER
命令计算交集。
1 | # Alice 关注了 a, b, c |
1
2
3
4
5
6
7
127.0.0.1:6379> SADD user:alice:following "a" "b" "c"
(integer) 3
127.0.0.1:6379> SADD user:bob:following "b" "c" "d"
(integer) 3
127.0.0.1:6379> SINTER user:alice:following user:bob:following
1) "c"
2) "b"
2.5. Hash (散列)
2.5.1. Hash - 概念与特性
Redis 的 Hash
类型是一个 字段(Field) - 值(Value)
对的集合,可以看作是程序语言中 Map 或 Dictionary 的实现。它特别适合用来存储对象。相比于使用 String
存储序列化的 JSON 对象,Hash
的优势在于可以对对象中的单个字段进行独立的读写操作,更加灵活和节省网络开销。
2.5.2. Hash - 核心命令
命令 | 用法示例 | 功能描述 |
---|---|---|
HSET | HSET key field value | 将哈希表 key 中的字段 field 的值设为 value。 |
HGET | HGET key field | 获取存储在哈希表中指定字段的值。 |
HGETALL | HGETALL key | 获取在哈希表中指定 key 的所有字段和值。 |
HDEL | HDEL key field... | 删除一个或多个哈希表字段。 |
HINCRBY | HINCRBY key field increment | 为哈希表中的字段值加上指定的增量值。 |
2.5.3. Hash - 实战场景与代码示例
1. 场景:存储用户信息
背景: 缓存用户的详细信息,如姓名、邮箱、年龄等,并可能需要频繁更新其中某个字段(如年龄)。
解决方案: 使用一个 Hash
键(如 user:1001
)来存储该用户的所有信息。
1 | # 存储用户 1001 的信息 |
1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6379> HSET user:1001 name "Bob" email "bob@example.com" age 30
(integer) 3
127.0.0.1:6379> HGETALL user:1001
1) "name"
2) "Bob"
3) "email"
4) "bob@example.com"
5) "age"
6) "30"
127.0.0.1:6379> HINCRBY user:1001 age 1
(integer) 31
2.6. ZSet (有序集合)
2.6.1. ZSet - 概念与特性
ZSet
(Sorted Set) 与 Set
类似,也是一个不允许重复成员的字符串集合。但不同之处在于,ZSet
的每个成员都会关联一个 double
类型的 分数 (score)。Redis 正是根据这个分数对集合中的成员进行排序。这使得 ZSet
成为实现排行榜等需要动态排序功能的业务的完美选择。
底层实现: ZSet
的实现较为复杂,它结合了哈希表和跳跃表 (SkipList)。哈希表用于存储成员到分数的映射,保证了 O(1) 的成员查找复杂度;跳跃表则用于按分数排序,保证了范围查询的高效性。
2.6.2. ZSet - 核心命令
命令 | 用法示例 | 功能描述 |
---|---|---|
ZADD | ZADD key score member... | 向有序集合添加一个或多个成员,或者更新已存在成员的分数。 |
ZREM | ZREM key member... | 移除有序集合中的一个或多个成员。 |
ZRANGE | ZRANGE key start stop | 按分数从小到大返回指定排名范围的成员。 |
ZREVRANGE | ZREVRANGE key start stop | 按分数从大到小返回指定排名范围的成员。 |
ZSCORE | ZSCORE key member | 返回有序集合中,成员的分数值。 |
2.6.3. ZSet - 实战场景与代码示例
1. 场景:游戏排行榜
背景: 实现一个实时更新的游戏积分排行榜,需要随时能查询到排名前列的玩家。
解决方案: 使用 ZADD
更新玩家的最新分数。使用 ZREVRANGE
获取积分从高到低的玩家排名。
1 | # 添加三位玩家的得分到排行榜 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
127.0.0.1:6379> ZADD leaderboard 3200 "player:1"
(integer) 1
127.0.0.1:6379> ZADD leaderboard 5000 "player:2"
(integer) 1
127.0.0.1:6379> ZADD leaderboard 4100 "player:3"
(integer) 1
127.0.0.1:6379> ZADD leaderboard 4200 "player:1"
(integer) 0
127.0.0.1:6379> ZREVRANGE leaderboard 0 2 WITHSCORES
1) "player:2"
2) "5000"
3) "player:1"
4) "4200"
5) "player:3"
6) "4100"
127.0.0.1:6379>
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 | # 用户 u1, u2, u3, u4 访问了 page:a |
1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> PFADD page:a:uv u1 u2 u3 u4
(integer) 1
127.0.0.1:6379> PFCOUNT page:a:uv
(integer) 4
127.0.0.1:6379> PFADD page:b:uv u3 u4 u5 u6
(integer) 1
127.0.0.1:6379> PFCOUNT page:b:uv
(integer) 4
127.0.0.1:6379> PFMERGE total:uv page:a:uv page:b:uv
OK
127.0.0.1:6379> PFCOUNT total:uv
(integer) 6
3.2. Bitmap (位图)
3.2.1. 概念与优势
Bitmap
(位图) 本身并不是一种独立的数据类型,而是 String
类型上的一组面向二进制位的操作。它允许我们将一个字符串看作是一个由 0
和 1
组成的位数组,并能对其中任意一位进行读写。
核心解决的问题: 高效存储只有两种状态的大规模数据。例如,记录一个拥有 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 | # 假设用户 888 在周一(0)、周二(1)、周四(3)、周日(6)打卡 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
127.0.0.1:6379> SETBIT user:888:sign 0 1
(integer) 0
127.0.0.1:6379> SETBIT user:888:sign 1 1
(integer) 0
127.0.0.1:6379> SETBIT user:888:sign 3 1
(integer) 0
127.0.0.1:6379> SETBIT user:888:sign 6 1
(integer) 0
127.0.0.1:6379> GETBIT user:888:sign 3
(integer) 1
127.0.0.1:6379> GETBIT user:888:sign 2
(integer) 0
127.0.0.1:6379> BITCOUNT user:888:sign
(integer) 4
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
命令要求有效的经度在 -180
到 180
度之间,有效的纬度在 -85.05112878
到 85.05112878
度之间。
1 | # 将北京、上海、深圳的坐标添加到名为 "china:cities" 的集合中 |
1
2
127.0.0.1:6379> GEOADD china:cities 116.40 39.90 beijing 121.47 31.23 shanghai 114.05 22.52 shenzhen
(integer) 3
2. 计算两地距离 (geodist
)
背景: 计算北京到上海的直线距离(单位:公里)。
1 | # 计算 beijing 和 shanghai 之间的距离,单位为 km |
1
2
127.0.0.1:6379> GEODIST china:cities beijing shanghai km
"1067.2023"
3. 查询“附近的人” (georadiusbymember
)
背景: 查找距离“北京”1200 公里范围内的所有城市。
解决方案: 使用 GEORADIUSBYMEMBER
,它允许我们以一个已存在的成员(beijing
)为中心进行范围查询。
1 | # 以 beijing 为中心,查询 1200km 半径内的城市 |
1
2
3
4
5
6
7
8
9
127.0.0.1:6379> GEORADIUSBYMEMBER china:cities beijing 1200 km WITHDIST WITHCOORD COUNT 2
1) 1) "beijing"
2) "0.0000"
3) 1) "116.40000152587890625"
2) "39.90000009167092455"
2) 1) "shanghai"
2) "1067.2023"
3) 1) "121.47000014781951904"
2) "31.2299990397582453"
4. [进阶] 数据类型 Stream 详解
摘要: 本章将深入探讨 Redis 5.0 之后最重要的新增数据类型——Stream。它并非对现有类型的简单补充,而是 Redis 官方提供的一个功能完备、支持持久化的消息队列(MQ)解决方案。我们将从 Stream 的设计初衷出发,剖析其核心结构、两种消费模式(独立消费与消费组),并最终通过模拟面试问答的形式,探讨其在消息确认(ACK)、故障转移(Failover)和死信处理等高级场景下的内部机制,帮助您彻底掌握这个强大的新特性。
4.1. 为什么需要 Stream
优点
- 支持多播:一个消息可被多个订阅者同时接收,符合消息队列的基本模型。
致命缺点
- 无持久化能力。消息“发后即忘”,若订阅者不在线或网络中断,消息永久丢失,在绝大多数业务场景中不可接受。
优点
- 通过
LPUSH
与BRPOP
等命令,可实现持久化的阻塞式 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
),由服务器时间戳和毫秒内序号组成,保证了全局的、单调递增的顺序。
- 消息ID: 格式为
- 消费组 (Consumer Group): 一组共同消费同一个 Stream 的消费者集合。Stream 中的消息会被分发给组内的所有消费者,但对于一条具体消息,组内只有一个消费者能够接收到。
- 消费者 (Consumer): 消费组内的一个成员,负责处理消息。
last_delivered_id
: 每个消费组内部的游标,记录了投递给组内消费者的最后一条消息的 ID。- 待处理条目列表 (Pending Entries List, PEL): 每个消费者都有一个独立的 PEL,用于记录那些已被客户端读取、但尚未通过
XACK
命令确认处理完成的消息。这是实现消息可靠性的关键。
4.3. 基础操作 (CRUD)
Stream
的基础操作围绕着消息的添加(XADD
)、读取(XRANGE
)和删除(XDEL
)等展开。
背景: 我们创建一个名为 mystream
的流,并向其中添加几条用户信息。
1 | # 使用 * 号,让 Redis 自动生成消息 ID |
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
关键字后跟 key
和 ID
。ID
为 0-0
表示从头开始,$
表示只接收新消息。BLOCK 0
表示永久阻塞。
1 | # 重新准备数据 |
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 | # XGROUP CREATE <key> <groupname> <id> |
1
2
127.0.0.1:6379> XGROUP CREATE mystream cg1 0-0
OK
2. 组内消费与确认
背景: 消费者 c1
加入 cg1
消费组,开始处理消息,并在处理完后进行确认。
解决方案:
- 使用
XREADGROUP
读取消息。GROUP
关键字后跟组名和消费者名。>
这个特殊的 ID 表示读取尚未投递给组内任何消费者的消息。 - 使用
XACK
确认消息。
1 | # 消费者 c1 读取一条消息 |
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
的设计精髓,我们用模拟面试的方式探讨几个关键问题。
Stream 的消息 ID 格式是 时间戳-序号
,如果服务器时间发生回拨,ID 顺序会乱吗?
不会。Redis 的每个 Stream 内部都维护了一个 latest_generated_id
属性,记录了最后生成的消息 ID。如果 XADD
时发现当前服务器时间戳小于这个记录,Redis 会沿用上次的时间戳,只将序号部分加一。这样就保证了消息 ID 始终是单调递增的。
很好。那如果一个消费者用 XREADGROUP
读取了消息,但在处理时崩溃了,这条消息会丢失吗?
不会丢失。这正是 Stream
的 PEL
(待处理条目列表) 机制发挥作用的地方。当消费者读取消息后,该消息的 ID 会被放入它自己的 PEL 中。只有当消费者显式调用 XACK
命令,该 ID 才会从 PEL 中移除。如果消费者崩溃而没有 XACK
,这条消息会永远留在 PEL 里。
那如果这个消费者彻底宕机,再也回不来了,它 PEL 里的消息怎么办?总不能一直不处理吧?
这种情况,可以通过 XCLAIM
命令实现 消息所有权的转移。另一个健康的消费者可以 XCLAIM
这条长时间未被 ACK 的消息,把它从宕机消费者的 PEL 转移到自己的 PEL 中来继续处理。为了防止消息被误抢,XCLAIM
还需要指定一个最小闲置时间,只有超过这个时间的消息才能被转移。
非常好。最后一个问题,如果一条消息本身有问题,导致所有消费者都处理失败,反复转移、反复失败怎么办?也就是“死信”问题。
Stream 也考虑了这一点。我们可以通过 XPENDING
命令查询每条待处理消息的 delivery counter (已被投递次数)。如果发现某条消息的投递次数超过了一个我们设定的阈值(例如 10 次),就可以认为它是“死信”。此时,我们就可以把它取出来,记录到专门的日志或队列中,然后 XACK
掉,避免它在主流程里无限循环。