数据库基础体系 · 第 35/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream
Redis 是一个以内存为主、通过键(key)访问值(value)的数据存储系统。它的“值”并不是只有一种字符串,而是由多种数据结构组成:String、Hash、List、Set、ZSet 和 Stream。
选择数据结构时,不能只看命令名称。需要同时回答几个问题:
- 数据是否有字段、顺序、唯一性或排名要求?
- 是否需要原子递增、范围查询、阻塞等待或消费确认?
- 读写复杂度如何?
- 过期时间作用于整个 key 还是其中的成员?
- 单条命令是否原子?多个命令之间是否需要事务?
- 在 Redis Cluster 中,相关 key 是否能够放入同一哈希槽?
- 数据持久化、故障恢复和内存淘汰时,是否允许丢失或重复处理?
本文以 Redis 官方稳定版本公开语义为准。命令示例默认使用单机 Redis 和 redis-cli,除非特别说明。示例中的“原子”表示 Redis 服务端对单条命令或事务执行过程的并发语义,不表示已经完成持久化,也不表示客户端一定不会因网络故障而重复提交。
一、先建立 Redis 数据结构的共同语义
1.1 一个 key 对应一种顶层数据类型
Redis 中的 key 映射到一个值,这个值具有确定的顶层类型:
key:user:1001 -> String
user:1001 -> Hash
queue:email -> List
online:users -> Set
rank:score -> ZSet
events -> Stream
同一个 key 不能同时作为不同类型使用。例如:
SET user:1001 "alice"
HSET user:1001 name alice
第二条命令会失败,因为 user:1001 已经是 String,不能当作 Hash 使用。典型错误是:
WRONGTYPE Operation against a key holding the wrong kind of value
可以用 TYPE 查看顶层类型:
TYPE user:1001
返回结果可能是:
string
集合类结构通常只会在仍有成员时存在。删除最后一个 Hash field、List 元素、Set 成员、ZSet 成员或 Stream entry 后,整个 key 可能被删除,因此:
EXISTS some-empty-key
可能返回 0。
1.2 Redis 的原子性边界
Redis 对单条命令的执行是串行的。对于一个命令而言,其他客户端不会在该命令执行到一半时插入操作。例如:
INCR counter
不会出现两个客户端都读取到 10、最后都写回 11 的丢失更新问题。
但是,下面这组客户端逻辑不是原子的:
GET stock
客户端在本地判断 stock > 0
DECR stock
两个客户端可能都先读到库存充足,然后都执行扣减。
需要使用以下方式之一:
- 直接使用提供原子语义的命令,例如
INCR、DECR、SET NX; - 使用
MULTI/EXEC将多条命令作为事务提交; - 使用
WATCH实现乐观锁; - 使用 Lua 脚本或 Redis Functions,在服务端完成条件判断和修改。
Redis 事务的基本语义是:
MULTI之后,命令通常先进入队列;EXEC时按顺序执行;- 事务执行期间不会插入其他客户端命令;
- Redis 事务没有传统关系数据库中的回滚机制;
- 某条命令运行时出错,其他已排队或可执行命令不会自动回滚;
WATCH监视的 key 在事务提交前被其他客户端修改时,EXEC会放弃执行并返回空结果。
例如:
MULTI
INCR counter
EXPIRE counter 60
EXEC
这保证 INCR 和 EXPIRE 在 Redis 命令调度层面连续执行。但如果客户端在 EXEC 返回前断开,客户端可能不知道事务是否已经执行;这属于客户端确认和故障恢复问题,而不是事务内部回滚问题。
1.3 过期时间属于 key,不是默认属于每个成员
Redis 的 EXPIRE 作用于整个 key:
HSET session:1001 user_id 1001 state active
EXPIRE session:1001 1800
1800 秒后,整个 Hash 被删除,而不是只删除某个 field。
常用命令:
EXPIRE key seconds
PEXPIRE key milliseconds
TTL key
PTTL key
PERSIST key
返回值有明确含义:
TTL返回剩余秒数;-1表示 key 存在但没有过期时间;-2表示 key 不存在。
写入命令对 TTL 的影响取决于命令。通常,修改集合内部成员的命令不会自动清除 key 的 TTL;而 SET 默认会覆盖整个 String,并清除原有 TTL。Redis 支持的 SET ... KEEPTTL 可以保留原 TTL:
SET cache:user:1001 '{"name":"Alice"}' EX 300
SET cache:user:1001 '{"name":"Alice","age":20}' KEEPTTL
这三件事经常被混淆:
- key 逻辑上到期;
- Redis 何时主动删除它;
- 数据是否已经从 RDB 或 AOF 中恢复、重写或持久化。
过期键通常通过惰性删除和定期抽样等机制清理,并不保证在逻辑到期的精确瞬间完成物理删除。持久化、恢复、内存淘汰和过期语义结合时,还要考虑 RDB、AOF、后台 fork 和故障切换的影响。
1.4 Redis Cluster 的边界
在 Redis Cluster 中,key 会根据 CRC16 等规则映射到哈希槽。涉及多个 key 的命令、事务或脚本,通常要求这些 key 位于同一哈希槽。
可以使用哈希标签强制相关 key 进入相同槽位:
cart:{user:1001}
coupon:{user:1001}
大括号中的内容 user:1001 作为哈希标签。这样可以让两个 key 适用于同一槽位相关的多 key 操作,但也可能造成槽位热点,不能无条件使用。
因此:
SUNION set:a set:b
在单机中可以直接执行;在 Cluster 中,两个 key 若不在同一槽位,可能返回 CROSSSLOT Keys in request don't hash to the same slot。
二、String:最基础,也最容易被误用的结构
2.1 String 的定义
Redis String 是一个二进制安全的字节序列。它可以保存:
- 文本;
- JSON;
- 序列化对象;
- 图片或其他二进制内容;
- 整数或浮点数的字符串表示。
String 不是只能保存“字符串”。Redis 不理解 JSON 内部字段,也不会自动为 JSON 的某个属性建立索引;在 Redis 看来,整个 JSON 只是一个 String。
单个 String 的大小存在上限,官方命令文档规定的最大值为 512 MB。生产系统通常应设置远小于该上限的应用约束,因为大 value 会增加网络传输、复制、持久化、删除和内存碎片成本。
2.2 基本读写
SET user:name:1001 Alice
GET user:name:1001
预期:
OK
"Alice"
String 可以保存空格和特殊字符,但客户端应正确处理参数边界:
SET greeting "hello redis"
GET greeting
设置多个 key:
MSET user:name:1001 Alice user:name:1002 Bob
MGET user:name:1001 user:name:1002 user:name:9999
MGET 对不存在的 key 返回空值位置,而不是整体失败。应用必须区分:
["Alice", "Bob", nil]
2.3 条件写入、过期和缓存占位
SET 支持条件和过期选项:
SET lock:order:1001 request-abc NX EX 30
含义是:
NX:只有 key 不存在时才写入;EX 30:同时设置 30 秒过期时间;- 写入成功返回
OK; - 如果 key 已存在,返回空值。
这常用于简单的互斥锁或缓存击穿保护,但一个可释放锁的实现不能只执行:
DEL lock:order:1001
否则持锁客户端 A 超时后,客户端 B 获得锁;A 恢复后可能误删 B 的锁。释放锁通常需要比较 value,再删除,比较和删除必须在服务端原子完成,例如使用 Lua 脚本或 Redis Functions:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
这里:
KEYS[1]是锁 key;ARGV[1]是当前客户端生成的随机 token;- 只有 value 匹配时才删除。
这只是单个 Redis 实例上的锁语义。若系统需要跨故障域的严格分布式锁安全性,还必须讨论主从复制、故障切换和客户端重试,不能仅凭 SET NX EX 宣称获得完整的分布式一致性。
2.4 数值操作:String 中保存数字的特殊用法
Redis 将整数和浮点数以 String 形式存储,但提供原子算术命令:
SET counter 10
INCR counter
INCRBY counter 5
DECR counter
GET counter
结果依次相当于:
10
11
16
15
"15"
INCR 要求当前值能解析为整数,否则失败:
SET counter hello
INCR counter
会返回整数解析错误,原值不会被当作合法数字修改。
浮点数使用:
SET price 10.5
INCRBYFLOAT price 2.25
GET price
预期结果是:
"12.75"
浮点计算遵循 Redis 对浮点字符串和双精度计算的语义,不适合直接作为金融金额的精确十进制定点模型。金额通常应转换为最小货币单位的整数,例如分:
SET amount:cents 1050
INCRBY amount:cents 25
2.5 String 适合什么,不适合什么
适合:
- 缓存完整对象;
- 计数器;
- 分布式锁的 token;
- 幂等请求标记;
- 简单配置;
- 二进制内容;
- 通过
GETSET、SET NX等实现状态交换。
不适合:
- 需要独立更新多个字段,却频繁读改写整个 JSON;
- 需要按字段查询、统计或部分过期;
- 需要集合运算、排序或消费确认;
- value 很大且更新频繁。
一个典型反例是:
GET user:1001
客户端解析 JSON
修改 age
SET user:1001 新 JSON
如果两个客户端同时修改不同字段,后写入者可能覆盖先写入者的修改。Hash 或服务端脚本通常更适合这种场景。
三、Hash:将一个 key 组织成字段到值的映射
3.1 Hash 的定义
Hash 是:
一个 Redis key -> 多个 field -> field value
例如:
user:1001
├── name -> Alice
├── age -> 20
└── state -> active
Hash 的 field 和 value 都是字符串或二进制安全的字节序列。Redis 不会把 value 自动解释为嵌套对象。
3.2 基本操作
HSET user:1001 name Alice age 20 state active
HGET user:1001 name
HMGET user:1001 name age missing
HGETALL user:1001
预期结果:
(integer) 3
"Alice"
1) "Alice"
2) "20"
3) (nil)
1) "name"
2) "Alice"
3) "age"
4) "20"
5) "state"
6) "active"
HGETALL 的返回顺序不应被应用当作业务排序。需要遍历时,HSCAN 比一次性获取整个 Hash 更适合大对象:
HSCAN user:1001 0 COUNT 100
游标式扫描的特点是:
- 返回一个新的游标和部分 field;
- 游标回到
0才表示本轮扫描结束; - 在扫描期间发生修改时,可能重复返回,也可能漏掉某些变化中的成员;
- 不能把一次完整扫描当作严格一致的快照。
3.3 Hash 中的原子字段更新
HSET user:1001 age 20
HINCRBY user:1001 age 1
HGET user:1001 age
结果为:
"21"
HINCRBY 只对可解析为整数的 field value 生效。它比“读取整个对象、在客户端修改、重新写回”更安全,因为更新单个 field 是一条原子命令。
Hash 常用于:
- 用户资料;
- 商品属性;
- 设备状态;
- 计数器集合;
- 小型对象的部分更新。
例如:
HSET device:7 temperature 21 humidity 40
HINCRBY device:7 online_seconds 10
3.4 Hash 与 String JSON 的取舍
假设对象是:
{"name":"Alice","age":20,"state":"active"}
用 String 保存 JSON:
SET user:1001 '{"name":"Alice","age":20,"state":"active"}'
优点是对象整体读写简单,适合缓存完整响应。缺点是修改 age 时需要重写整个 JSON,并可能发生并发覆盖。
用 Hash 保存:
HSET user:1001 name Alice age 20 state active
优点是可以独立读写字段,减少无关数据传输。缺点是:
- 读取完整对象需要
HGETALL; - field 没有默认的类型系统;
- 无法像关系数据库那样直接按 field 建立复杂查询索引;
- 同一个对象的字段更新若需要跨字段条件约束,仍要事务或脚本。
一个重要边界是:Hash 的“原子”只覆盖单个命令。下面的逻辑仍不是原子的:
HGET user:1001 state
如果 state == active:
HSET user:1001 state disabled
若要求“只有当前状态为 active 才改为 disabled”,应使用 WATCH 或服务端脚本完成比较与更新。
四、List:有序的字符串序列
4.1 List 的定义与两端操作
List 是按插入顺序组织的字符串序列,支持从左端和右端插入、删除和读取。
左端 <-> [a, b, c, d] <-> 右端
基本操作:
DEL queue:email
LPUSH queue:email job-1 job-2
RPUSH queue:email job-3
LRANGE queue:email 0 -1
结果为:
1) "job-2"
2) "job-1"
3) "job-3"
原因是:
LPUSH job-1 job-2按命令参数从左到右依次插入,最终job-2在最左侧;RPUSH job-3放到最右侧;LRANGE 0 -1表示从第一个元素到最后一个元素。
下标从 0 开始,负数从尾部计算:
LINDEX queue:email 0
LINDEX queue:email -1
分别返回头部和尾部元素。
4.2 List 作为队列和栈
队列通常使用一端写入、另一端读取:
RPUSH queue:email job-1
RPUSH queue:email job-2
LPOP queue:email
读取顺序是 job-1、job-2,即先进先出。
栈可以使用同一端:
LPUSH stack:task task-1
LPUSH stack:task task-2
LPOP stack:task
先取出 task-2,即后进先出。
Redis 提供阻塞式命令。消费者没有任务时可以等待:
BLPOP queue:email 30
返回格式通常为:
1) "queue:email"
2) "job-1"
30 是最多等待 30 秒,0 表示一直等待。阻塞连接应与普通请求连接池分开,否则一个正在等待消息的连接可能占满业务连接池。
Redis 还提供可靠队列相关命令,如 BRPOPLPUSH 的现代替代命令 BLMOVE。典型思路是把任务从待处理队列原子移动到处理中队列:
BLMOVE queue:pending queue:processing LEFT RIGHT 30
这一步可以避免“已经从待处理队列弹出,但消费者在处理前崩溃,任务永久丢失”。不过它不会自动确认任务完成,也不会自动重试。消费者成功处理后还要从处理中队列删除对应任务;失败恢复、重复执行和幂等仍需要应用设计。
4.3 List 的范围与复杂度边界
LRANGE key start stop 返回一个范围。0 -1 获取整个 List:
LRANGE queue:email 0 -1
对小 List 很方便,但对很大的 List 使用 0 -1 会产生:
- 大量网络返回数据;
- 较高客户端内存;
- 较长命令执行时间;
- 可能阻塞其他请求。
典型复杂度可概括为:
- 两端插入和删除:通常为
O(1); LINDEX:按位置访问,通常需要沿序列定位,复杂度随距离增长;LRANGE:与定位起点和返回元素数有关;LREM:需要扫描匹配元素,复杂度与 List 长度相关。
这里的“通常”描述常见实现和命令复杂度边界,不应把底层编码当作永久 API。Redis 内部可能根据元素数量和大小使用不同紧凑表示,但应用应依赖命令语义和官方复杂度说明,而不是依赖内存布局。
4.4 List 与 Stream 的区别
List 适合:
- 简单队列;
- 栈;
- 只需要顺序取出、不需要消息 ID 和消费历史的场景。
List 不天然提供:
- 每条消息的唯一递增 ID;
- 多个消费者组;
- Pending 消息列表;
- 消费确认;
- 按 ID 回放历史。
如果任务系统需要这些能力,Stream 通常比 List 更合适。
五、Set:无序且去重的成员集合
5.1 Set 的定义
Set 是不重复成员的无序集合:
{alice, bob, carol}
同一个成员重复添加不会产生重复项:
DEL online:users
SADD online:users alice bob alice
SCARD online:users
SMEMBERS online:users
SCARD 返回 2,因为 alice 只保留一份。
Set 不承诺成员返回顺序,因此不能把 SMEMBERS 的顺序当作稳定排序。
5.2 成员判断和集合运算
SISMEMBER online:users alice
SREM online:users bob
集合运算:
SADD group:backend alice bob
SADD group:frontend bob carol
SINTER group:backend group:frontend
SUNION group:backend group:frontend
SDIFF group:backend group:frontend
结果的集合意义是:
- 交集:
{bob}; - 并集:
{alice, bob, carol}; - 差集:
{alice}。
Redis 还支持将结果存入新 Set:
SINTERSTORE group:both group:backend group:frontend
这会把交集写入 group:both。它是单条命令,因此命令执行具有原子性;但如果输入集合很大,集合运算本身可能消耗较多 CPU 和执行时间。
Set 常用于:
- 标签;
- 关注关系;
- 在线用户;
- 去重;
- AB 实验分组;
- 集合交并差查询。
5.3 随机取样与随机删除
SPOP online:users
SRANDMEMBER online:users
SRANDMEMBER online:users 3
区别:
SPOP返回成员并将其删除;SRANDMEMBER只读取,不删除;- 带正数 count 时,
SRANDMEMBER返回不重复成员,最多返回集合大小; - 具体随机分布和大集合下的实现成本应以命令文档为准,不能把它当作密码学安全随机数。
需要安全随机令牌时,应使用操作系统或专用密码学随机源,而不是 SRANDMEMBER。
5.4 Set 与 ZSet 的边界
Set 只表达“是否属于集合”:
alice ∈ online:users
它不表达成员之间的顺序和分数。如果需要:
- 按分数排名;
- 按时间范围取成员;
- 取前 100 名;
- 延迟队列;
应使用 ZSet。
六、ZSet:带分数排序的唯一成员集合
6.1 ZSet 的定义
ZSet(Sorted Set)由唯一 member 和 score 组成:
(member, score)
例如:
alice -> 95
bob -> 88
carol -> 95
member 唯一,但 score 可以相同。查询时首先按 score 排序;score 相同时,再按 member 的字典序排列。这里的字典序是 Redis 定义的二进制字节序比较,不应简单理解为当前语言环境下的自然语言排序。
6.2 基本操作与排名
DEL rank:exam
ZADD rank:exam 95 alice 88 bob 95 carol
ZSCORE rank:exam alice
ZRANK rank:exam alice
ZREVRANK rank:exam alice
ZRANGE rank:exam 0 -1 WITHSCORES
升序结果为:
1) "bob"
2) "88"
3) "alice"
4) "95"
5) "carol"
6) "95"
alice 和 carol 分数相同,按 member 字节序排列,因此 alice 在 carol 之前。
ZRANK:升序排名,排名从0开始;ZREVRANK:降序排名;ZRANGE:按排名区间读取;WITHSCORES:同时返回分数。
如果只需要前 10 名:
ZREVRANGE rank:exam 0 9 WITHSCORES
6.3 分数更新和范围查询
ZINCRBY rank:exam 3 alice
ZRANGE rank:exam 90 100 BYSCORE WITHSCORES
ZINCRBY 会原子地增加 score。分数可以是浮点数,但必须符合 Redis 接受的浮点表示。
按分数范围查询时,默认是闭区间:
ZRANGEBYSCORE rank:exam 90 100
排除边界可以使用 (:
ZRANGEBYSCORE rank:exam (90 100
这表示:
90 < score <= 100
按分数删除:
ZREMRANGEBYSCORE rank:exam -inf 60
这会删除所有 score 小于等于 60 的成员。
6.4 ZSet 实现排行榜
排行榜的核心状态是:
用户 -> 分数
每次得分:
ZINCRBY rank:game 10 user:1001
取前 3 名:
ZREVRANGE rank:game 0 2 WITHSCORES
查看某个用户的降序排名:
ZREVRANK rank:game user:1001
要注意排名是从 0 开始。如果业务展示“第几名”,通常需要在应用层加 1:
展示名次 = Redis 返回的 rank + 1
反例是将 ZSet 当成严格的“同分并列排名”系统。Redis 会为同分 member 继续排序,因此每个 member 仍有唯一的序位。若业务要求“同分同名次、后续名次跳跃”,还需要应用层根据分数重新计算名次。
6.5 ZSet 实现延迟队列
可以将任务执行时间戳作为 score:
ZADD delayed:jobs 1710000000 job-1
消费者查询到期任务:
ZRANGEBYSCORE delayed:jobs -inf 1710000000 LIMIT 0 10
但是“读取任务”和“删除任务”不能拆成两个普通客户端命令,否则多个消费者可能拿到同一任务:
客户端 A:查询到 job-1
客户端 B:也查询到 job-1
客户端 A:删除 job-1
客户端 B:删除 job-1
应使用 Lua 脚本或 Redis Functions 将“查询并领取”放入同一服务端原子操作,并设计:
- 领取状态;
- 处理超时;
- 重试次数;
- 幂等键;
- 失败转移。
ZSet 只提供排序,不自动提供可靠消息投递语义。
七、Stream:带 ID、历史和消费状态的追加日志
7.1 Stream 的定义
Stream 是 Redis 的追加式消息流。每条 entry 包含:
entry ID -> field-value 对
例如:
1700000000000-0
├── type -> order_created
├── order_id -> 1001
└── user_id -> 7
Stream 与 List 的关键区别是:Stream entry 有 ID,并且可以保留历史、按 ID 读取、使用消费者组跟踪投递状态。
7.2 写入和读取 Stream
DEL stream:orders
XADD stream:orders * type order_created order_id 1001
XADD stream:orders * type order_paid order_id 1001
XRANGE stream:orders - +
* 表示由 Redis 生成 ID。ID 通常由毫秒时间部分和序列号组成:
milliseconds-sequence
如果同一毫秒写入多条消息,序列号用于区分它们。自动生成的 ID 会严格递增;客户端显式指定 ID 时,必须满足 Redis 对 ID 顺序和格式的要求。
XRANGE stream:orders - + 表示按 ID 读取整个范围。生产系统不应对无限增长的 Stream 永久使用 - +,否则返回数据量会不断增长。
限制读取条数:
XRANGE stream:orders - + COUNT 10
从某个 ID 之后读取时,应注意是否包含边界。常见做法是保存上次处理的 ID,并在下一次读取时使用排除边界的 ID 形式,具体语法应以目标 Redis 版本命令文档为准。
7.3 使用 MAXLEN 控制流长度
写入时限制长度:
XADD stream:orders MAXLEN ~ 10000 * \
type order_created order_id 1001
其中:
MAXLEN表示长度上限;~表示近似裁剪,通常比严格精确裁剪成本低;*表示自动生成 ID。
严格裁剪可以不使用 ~,但在高写入量下可能带来更高开销。Stream 长度限制是保留策略,不是消息消费确认机制。裁剪掉的历史 entry 不再能够通过普通历史读取恢复。
7.4 XREAD:按游标读取
不使用消费者组时,可以使用:
XREAD COUNT 10 STREAMS stream:orders 0-0
含义是从 0-0 开始读取最多 10 条。
实时等待新消息:
XREAD BLOCK 5000 COUNT 10 STREAMS stream:orders $
这里的 $ 表示从当前末尾开始,只等待之后的新消息;它不会返回调用之前已经存在的历史消息。
这一区别非常重要:
- 使用
0-0:读取历史; - 使用上次处理的 ID:从断点继续;
- 使用
$:只关心调用之后到达的新消息。
如果消费者先获取 $,之后因为网络或进程故障没有处理新消息,那么它可能丢失这段期间的消息。需要可靠恢复时,应保存消费位置,或使用消费者组。
八、Stream 消费者组:投递、Pending 和确认
8.1 创建消费者组
假设 Stream 中已有历史消息:
XGROUP CREATE stream:orders orders-group 0-0 MKSTREAM
含义:
- 创建名为
orders-group的消费者组; - 从
0-0开始,组可以消费已有历史消息; MKSTREAM在 Stream 不存在时创建空 Stream。
若只希望消费创建组之后的新消息,可以把起始 ID 设为 $:
XGROUP CREATE stream:orders realtime-group $ MKSTREAM
创建组时使用的起始 ID 决定该组的初始读取位置,不等同于每个消费者当前已处理的位置。
8.2 消费新消息
XREADGROUP GROUP orders-group worker-1 \
COUNT 10 BLOCK 5000 STREAMS stream:orders >
> 表示读取:
从未投递给该消费者组的消息
读取后,消息会进入消费者组的 Pending Entries List,简称 PEL。此时“已投递”不等于“已处理成功”。
消费者处理业务成功后确认:
XACK stream:orders orders-group 1700000000000-0
XACK 的作用是从该消费者组的待确认状态中移除对应消息。它不会删除 Stream 中的原始 entry,因此其他读取方式仍可能读取到历史记录,前提是该 entry 尚未被裁剪或删除。
8.3 Pending、重试和故障恢复
查看待处理消息:
XPENDING stream:orders orders-group
详细查看某个范围:
XPENDING stream:orders orders-group - + 10
Pending 通常意味着:
- 消息已经投递给某个消费者;
- 消费者尚未
XACK; - 消息可能正在处理,也可能消费者已经崩溃。
这使得 Stream 消费者组天然更接近“至少一次投递”,而不是“恰好一次处理”。消费者崩溃后,其他消费者可以通过 XCLAIM 或较新版本提供的 XAUTOCLAIM 认领长时间未处理的消息。
设计重试时需要明确:
- 多久未确认才允许认领;
- 认领次数如何统计;
- 业务操作是否幂等;
- 连续失败是否进入死信 Stream;
XACK应在业务提交前还是提交后执行。
一个典型顺序是:
读取消息
执行业务操作
业务操作成功后 XACK
如果业务操作成功但 XACK 因网络故障没有被 Redis 确认,消息可能再次投递。因此业务操作必须能够识别同一个订单、事件 ID 或幂等键。
如果先 XACK 再执行业务操作,消费者崩溃会导致消息已经确认但业务尚未完成,从而产生消息丢失。因此确认点不是形式问题,而是业务提交与消息状态之间的故障边界。
8.4 Stream 不等于 Kafka
Stream 提供:
- Redis 内部的追加日志;
- entry ID;
- 范围读取;
- 阻塞读取;
- 消费者组;
- Pending 和确认;
- 基本的消息保留控制。
但它不自动提供无限容量、跨集群复制模型、复杂分区协调或完整的消息平台能力。实际吞吐、保留长度、内存压力、AOF/RDB 成本和故障切换行为仍受 Redis 部署方式影响。
在 Redis Cluster 中,单个 Stream key 的数据属于一个槽位。一个消费者组针对的是这个 Stream key;如果需要跨多个分片消费,应在应用层管理多个 Stream 和各自的消费状态。
九、六种结构的选择逻辑
可以先从数据约束反推结构,而不是从命令名称出发:
| 需求 | 更合适的结构 | 核心原因 |
|---|---|---|
| 一个值整体读写、计数、缓存 JSON | String | 读写简单,支持条件写入和原子递增 |
| 一个对象包含多个可独立更新字段 | Hash | field 级读写 |
| 先进先出、后进先出、阻塞队列 | List | 两端操作和阻塞弹出 |
| 成员唯一,只关心是否存在 | Set | 自动去重和集合运算 |
| 成员唯一,还需要分数、排名、范围 | ZSet | score 排序和排名 |
| 追加事件、历史回放、确认和重试 | Stream | ID、消费组和 Pending 状态 |
几个常见反例:
9.1 用 String JSON 模拟高频字段更新
问题:
- 每次更新都传输整个对象;
- 并发读改写可能覆盖字段;
- 过期只能作用于整个对象。
如果字段需要独立更新,优先考虑 Hash;如果需要复杂嵌套 JSON 操作,要明确目标 Redis 版本和相应 JSON 模块语义,不能假设核心 String 自动支持 JSON 查询。
9.2 用 List 实现需要确认和重试的消息系统
LPOP 成功后,消息已经从队列消失。消费者随后崩溃,消息可能丢失。可以通过“待处理队列 + 处理中队列”改进,但仍要自己维护确认和超时转移;Stream 消费者组已经提供了更直接的 Pending 模型。
9.3 用 Set 实现排行榜
Set 没有 score,无法直接表达排名。把分数拼进 member 中会导致更新和排序复杂且容易出错,不能替代 ZSet。
9.4 用 ZSet 实现可靠延迟队列却只查询不领取
多个消费者会读到相同任务。必须让“筛选、领取、改变状态”具有原子边界,并处理任务超时和重复执行。
9.5 把 Stream 的 XREADGROUP 当成自动删除
读取、确认和删除是不同操作:
- 读取:投递消息;
XACK:移除 Pending 状态;XDEL或裁剪:删除 Stream entry。
确认不会自动删除历史消息;删除 entry 也不等于清理所有消费语义。因此必须单独设计保留和清理策略。
十、复杂度、内存与大 key 风险
Redis 的命令复杂度不仅影响平均响应时间,也影响事件循环中其他请求的等待时间。
应特别警惕:
HGETALL huge-hash
SMEMBERS huge-set
LRANGE huge-list 0 -1
XRANGE huge-stream - +
这些命令可能一次返回大量数据。风险包括:
- Redis 主线程长时间执行;
- 客户端响应包过大;
- 网络延迟升高;
- 主从复制延迟;
- AOF 写入和重写压力增加;
- 删除大 key 时产生瞬时延迟。
可采用:
SCAN、HSCAN、SSCAN、ZSCAN分批遍历;COUNT限制返回规模;- 按业务分页;
- 拆分过大的对象;
- 监控 key 大小和成员数量;
- 在合适版本和部署边界下使用异步删除命令,例如
UNLINK。
但扫描不是快照。若需要一致性快照,应使用数据库或专门的数据导出机制,而不是假定游标扫描期间数据不会变化。
内部编码也会影响内存和性能。Redis 可能对小对象采用紧凑表示,对较大对象切换为更适合操作的表示。编码属于实现细节,版本升级可能变化;应用只能依赖命令语义、复杂度和配置文档,不能依赖某个内部编码名称或阈值。
十一、持久化、复制与故障路径中的数据结构
Redis 数据结构存在内存中,但不代表每次命令都已经落盘。
11.1 RDB 和 AOF 的关系
- RDB 是某个时间点的数据快照;
- AOF 记录写操作,具体持久化可靠性取决于同步策略;
- 重启恢复时,Redis 根据可用持久化文件重建内存数据;
- 最近写入是否存在、故障时最多丢失多少数据,与持久化策略和故障时序有关。
例如:
SET cache:item value EX 60
恢复后该 key 是否存在,不只取决于它的 TTL,还取决于快照或 AOF 中记录的时间和 Redis 恢复时的当前时间。过期时间是绝对时间语义的一部分,不能将“写入过期时间”理解为“从某个持久化文件恢复后重新完整计时”。
11.2 Fork 和大数据结构
RDB 保存、AOF 重写等后台操作可能使用 fork。写时复制意味着:
- 子进程读取父进程的内存页;
- 父进程继续修改时,被修改的内存页可能产生额外副本;
- 大 Hash、大 List、大 Set、大 ZSet 或大 Stream 在高频更新时可能放大内存峰值。
因此“数据结构的逻辑大小”和“运行时占用内存”不是同一个数。生产诊断应结合:
MEMORY USAGE key
MEMORY STATS
INFO memory
INFO persistence
并观察 fork、后台保存、AOF 重写、复制积压和淘汰行为。
11.3 复制确认不等于持久化确认
WAIT 可以让客户端等待写操作传播到一定数量的副本,但它不等于副本已经将数据持久化到磁盘,也不自动形成跨故障域的一致提交协议。
同样,主节点返回 OK 只说明命令在该节点接受并执行成功;如果随后发生进程崩溃、机器故障或网络分区,数据是否能从副本或持久化文件恢复,需要结合部署和持久化配置判断。
十二、过期、内存淘汰与不同数据结构
Redis 的内存淘汰通常以 key 为单位,而不是自动选择 Hash 中的某个 field 或 ZSet 中的某个 member。即使一个 Hash 中有一百万个 field,淘汰策略也通常决定是否淘汰整个 Hash key。
这会影响建模:
一个超大 Hash 保存所有用户会话
如果该 key 被淘汰,可能一次失去所有用户会话;若拆成:
session:{user_id}
则可以按用户 key 独立过期和淘汰,但 key 数量和管理开销会上升。
缓存系统中还要区分:
- key 不存在;
- key 存在但值为空;
- 缓存命中;
- 缓存命中的是空结果;
- key 被淘汰;
- key 到期;
- 后端查询失败。
例如缓存穿透保护常用 String 保存空值标记:
SET cache:user:9999 "__NULL__" EX 30 NX
但应用必须保证特殊值不会与真实业务值冲突,或者使用明确的编码格式。
十三、事务、脚本和多结构协作示例
假设要完成一个“发布订单事件并增加用户订单数”的操作:
Hash:保存订单状态
String 或 Hash field:增加统计值
Stream:写入订单事件
单机单实例中,可以使用事务:
MULTI
HSET order:1001 status created user_id 7
HINCRBY user:7 stats_order_count 1
XADD stream:orders * type order_created order_id 1001 user_id 7
EXEC
这组命令在 EXEC 中按顺序执行,不会被其他客户端命令插入。
但是,下面的事实必须明确:
- Redis 事务成功不等于数据库事务成功;
- 如果订单主数据在 MySQL,而事件在 Redis,二者之间仍可能出现一边成功、一边失败;
- 如果 Redis 主节点故障,事务是否被副本确认取决于复制和故障切换;
- 如果客户端在发送后断开,可能无法判断
EXEC是否已经执行; - 如果命令运行时参数错误,Redis 不会把其他成功命令自动回滚。
若要实现跨系统可靠事件发布,应考虑事务消息、Outbox、可重试事件和幂等消费,而不是仅靠 Redis 的 MULTI。
在 Redis Cluster 中,这个事务涉及多个 key。需要让它们使用同一个哈希标签:
order:{1001}
user:{1001}:stats
stream:{1001}:orders
但 Stream 按用户标签拆分会改变事件流模型;如果业务需要全局单一事件流,就不能简单地把所有 key 都塞到一个哈希槽而不评估热点。
十四、诊断数据结构问题的步骤
遇到 Redis 数据异常时,可以按以下顺序定位。
14.1 先确认类型和存在性
EXISTS key
TYPE key
TTL key
如果报 WRONGTYPE,优先检查:
- 是否复用了相同 key 命名;
- 是否有旧版本程序写入了不同类型;
- 是否清理或迁移不完整;
- 是否环境配置指向了错误 Redis。
14.2 再检查规模
STRLEN string:key
HLEN hash:key
LLEN list:key
SCARD set:key
ZCARD zset:key
XLEN stream:key
MEMORY USAGE key
这些命令有助于区分“数据不存在”和“大 key 导致响应缓慢”。
14.3 最后检查命令和并发路径
可以使用:
SLOWLOG GET 20
LATENCY DOCTOR
INFO commandstats
生产环境使用 MONITOR 要谨慎,因为它会产生大量输出并增加开销,不应长时间无控制地开启。
对于 Stream,还要额外检查:
XINFO STREAM stream:orders
XINFO GROUPS stream:orders
XINFO CONSUMERS stream:orders orders-group
XPENDING stream:orders orders-group
重点观察:
- Stream 是否持续增长;
- 消费者组是否有 Pending;
- 某个消费者是否长期不活跃;
- 消费确认是否停止;
- 是否发生频繁认领和重复处理。
十五、总结:从数据约束选择 Redis 结构
六种结构并不是六种互相替代的容器,而是六种不同的数据约束:
- String:一个二进制安全值,可整体读写、条件写入和原子计数;
- Hash:一个 key 下的 field-value 映射,适合对象字段级操作;
- List:有序序列,适合两端队列和栈;
- Set:无序去重集合,适合成员关系和集合运算;
- ZSet:带 score 的唯一成员集合,适合排名、范围和时间排序;
- Stream:带 ID 的追加消息流,适合历史读取、消费组、确认和 Pending 管理。
真正的选型结果还取决于故障语义:
- 只需要缓存完整值,String 足够;
- 需要字段级更新,Hash 更自然;
- 只需要先进先出,List 更简单;
- 需要去重和集合运算,Set 更直接;
- 需要排名或按时间取任务,ZSet 更合适;
- 需要消息历史、消费确认和故障重试,Stream 提供更完整的模型。
最后应把数据结构和以下机制一起评估:key 级过期、内存淘汰、RDB、AOF、复制、fork、Cluster 哈希槽、事务边界以及客户端重试。数据结构解决的是“如何组织和操作数据”,而不是自动解决持久化、一致性、可靠消息投递和跨系统事务。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:SQLite 工程实践:嵌入应用、备份、迁移、损坏恢复与安全
- 下一篇:Redis 持久化与内存:RDB、AOF、过期淘汰、Fork 和恢复
- 延伸:Redis 缓存体系:一致性、穿透、击穿、雪崩和多级缓存
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论