WR Blog 加载中...
返回文章
数据库Redis数据结构缓存

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream封面

数据库基础体系 · 第 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

两个客户端可能都先读到库存充足,然后都执行扣减。

需要使用以下方式之一:

  1. 直接使用提供原子语义的命令,例如 INCRDECRSET NX
  2. 使用 MULTI / EXEC 将多条命令作为事务提交;
  3. 使用 WATCH 实现乐观锁;
  4. 使用 Lua 脚本或 Redis Functions,在服务端完成条件判断和修改。

Redis 事务的基本语义是:

  • MULTI 之后,命令通常先进入队列;
  • EXEC 时按顺序执行;
  • 事务执行期间不会插入其他客户端命令;
  • Redis 事务没有传统关系数据库中的回滚机制;
  • 某条命令运行时出错,其他已排队或可执行命令不会自动回滚;
  • WATCH 监视的 key 在事务提交前被其他客户端修改时,EXEC 会放弃执行并返回空结果。

例如:

MULTI
INCR counter
EXPIRE counter 60
EXEC

这保证 INCREXPIRE 在 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

这三件事经常被混淆:

  1. key 逻辑上到期;
  2. Redis 何时主动删除它;
  3. 数据是否已经从 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;
  • 幂等请求标记;
  • 简单配置;
  • 二进制内容;
  • 通过 GETSETSET 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-1job-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"

alicecarol 分数相同,按 member 字节序排列,因此 alicecarol 之前。

  • 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 认领长时间未处理的消息。

设计重试时需要明确:

  1. 多久未确认才允许认领;
  2. 认领次数如何统计;
  3. 业务操作是否幂等;
  4. 连续失败是否进入死信 Stream;
  5. 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 时产生瞬时延迟。

可采用:

  • SCANHSCANSSCANZSCAN 分批遍历;
  • 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 中按顺序执行,不会被其他客户端命令插入。

但是,下面的事实必须明确:

  1. Redis 事务成功不等于数据库事务成功;
  2. 如果订单主数据在 MySQL,而事件在 Redis,二者之间仍可能出现一边成功、一边失败;
  3. 如果 Redis 主节点故障,事务是否被副本确认取决于复制和故障切换;
  4. 如果客户端在发送后断开,可能无法判断 EXEC 是否已经执行;
  5. 如果命令运行时参数错误,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 哈希槽、事务边界以及客户端重试。数据结构解决的是“如何组织和操作数据”,而不是自动解决持久化、一致性、可靠消息投递和跨系统事务。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
数据库Redis数据结构缓存

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream封面

数据库基础体系 · 第 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

两个客户端可能都先读到库存充足,然后都执行扣减。

需要使用以下方式之一:

  1. 直接使用提供原子语义的命令,例如 INCRDECRSET NX
  2. 使用 MULTI / EXEC 将多条命令作为事务提交;
  3. 使用 WATCH 实现乐观锁;
  4. 使用 Lua 脚本或 Redis Functions,在服务端完成条件判断和修改。

Redis 事务的基本语义是:

  • MULTI 之后,命令通常先进入队列;
  • EXEC 时按顺序执行;
  • 事务执行期间不会插入其他客户端命令;
  • Redis 事务没有传统关系数据库中的回滚机制;
  • 某条命令运行时出错,其他已排队或可执行命令不会自动回滚;
  • WATCH 监视的 key 在事务提交前被其他客户端修改时,EXEC 会放弃执行并返回空结果。

例如:

MULTI
INCR counter
EXPIRE counter 60
EXEC

这保证 INCREXPIRE 在 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

这三件事经常被混淆:

  1. key 逻辑上到期;
  2. Redis 何时主动删除它;
  3. 数据是否已经从 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;
  • 幂等请求标记;
  • 简单配置;
  • 二进制内容;
  • 通过 GETSETSET 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-1job-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"

alicecarol 分数相同,按 member 字节序排列,因此 alicecarol 之前。

  • 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 认领长时间未处理的消息。

设计重试时需要明确:

  1. 多久未确认才允许认领;
  2. 认领次数如何统计;
  3. 业务操作是否幂等;
  4. 连续失败是否进入死信 Stream;
  5. 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 时产生瞬时延迟。

可采用:

  • SCANHSCANSSCANZSCAN 分批遍历;
  • 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 中按顺序执行,不会被其他客户端命令插入。

但是,下面的事实必须明确:

  1. Redis 事务成功不等于数据库事务成功;
  2. 如果订单主数据在 MySQL,而事件在 Redis,二者之间仍可能出现一边成功、一边失败;
  3. 如果 Redis 主节点故障,事务是否被副本确认取决于复制和故障切换;
  4. 如果客户端在发送后断开,可能无法判断 EXEC 是否已经执行;
  5. 如果命令运行时参数错误,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 哈希槽、事务边界以及客户端重试。数据结构解决的是“如何组织和操作数据”,而不是自动解决持久化、一致性、可靠消息投递和跨系统事务。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
表示从当前末尾开始,只等待之后的新消息;它不会返回调用之前已经存在的历史消息。\n\n这一区别非常重要:\n\n- 使用 `0-0`:读取历史;\n- 使用上次处理的 ID:从断点继续;\n- 使用 ` Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream - WR Blog
WR Blog 加载中...
返回文章
数据库Redis数据结构缓存

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream封面

数据库基础体系 · 第 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

两个客户端可能都先读到库存充足,然后都执行扣减。

需要使用以下方式之一:

  1. 直接使用提供原子语义的命令,例如 INCRDECRSET NX
  2. 使用 MULTI / EXEC 将多条命令作为事务提交;
  3. 使用 WATCH 实现乐观锁;
  4. 使用 Lua 脚本或 Redis Functions,在服务端完成条件判断和修改。

Redis 事务的基本语义是:

  • MULTI 之后,命令通常先进入队列;
  • EXEC 时按顺序执行;
  • 事务执行期间不会插入其他客户端命令;
  • Redis 事务没有传统关系数据库中的回滚机制;
  • 某条命令运行时出错,其他已排队或可执行命令不会自动回滚;
  • WATCH 监视的 key 在事务提交前被其他客户端修改时,EXEC 会放弃执行并返回空结果。

例如:

MULTI
INCR counter
EXPIRE counter 60
EXEC

这保证 INCREXPIRE 在 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

这三件事经常被混淆:

  1. key 逻辑上到期;
  2. Redis 何时主动删除它;
  3. 数据是否已经从 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;
  • 幂等请求标记;
  • 简单配置;
  • 二进制内容;
  • 通过 GETSETSET 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-1job-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"

alicecarol 分数相同,按 member 字节序排列,因此 alicecarol 之前。

  • 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 认领长时间未处理的消息。

设计重试时需要明确:

  1. 多久未确认才允许认领;
  2. 认领次数如何统计;
  3. 业务操作是否幂等;
  4. 连续失败是否进入死信 Stream;
  5. 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 时产生瞬时延迟。

可采用:

  • SCANHSCANSSCANZSCAN 分批遍历;
  • 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 中按顺序执行,不会被其他客户端命令插入。

但是,下面的事实必须明确:

  1. Redis 事务成功不等于数据库事务成功;
  2. 如果订单主数据在 MySQL,而事件在 Redis,二者之间仍可能出现一边成功、一边失败;
  3. 如果 Redis 主节点故障,事务是否被副本确认取决于复制和故障切换;
  4. 如果客户端在发送后断开,可能无法判断 EXEC 是否已经执行;
  5. 如果命令运行时参数错误,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 哈希槽、事务边界以及客户端重试。数据结构解决的是“如何组织和操作数据”,而不是自动解决持久化、一致性、可靠消息投递和跨系统事务。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
:只关心调用之后到达的新消息。\n\n如果消费者先获取 ` Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream - WR Blog
WR Blog 加载中...
返回文章
数据库Redis数据结构缓存

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream封面

数据库基础体系 · 第 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

两个客户端可能都先读到库存充足,然后都执行扣减。

需要使用以下方式之一:

  1. 直接使用提供原子语义的命令,例如 INCRDECRSET NX
  2. 使用 MULTI / EXEC 将多条命令作为事务提交;
  3. 使用 WATCH 实现乐观锁;
  4. 使用 Lua 脚本或 Redis Functions,在服务端完成条件判断和修改。

Redis 事务的基本语义是:

  • MULTI 之后,命令通常先进入队列;
  • EXEC 时按顺序执行;
  • 事务执行期间不会插入其他客户端命令;
  • Redis 事务没有传统关系数据库中的回滚机制;
  • 某条命令运行时出错,其他已排队或可执行命令不会自动回滚;
  • WATCH 监视的 key 在事务提交前被其他客户端修改时,EXEC 会放弃执行并返回空结果。

例如:

MULTI
INCR counter
EXPIRE counter 60
EXEC

这保证 INCREXPIRE 在 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

这三件事经常被混淆:

  1. key 逻辑上到期;
  2. Redis 何时主动删除它;
  3. 数据是否已经从 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;
  • 幂等请求标记;
  • 简单配置;
  • 二进制内容;
  • 通过 GETSETSET 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-1job-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"

alicecarol 分数相同,按 member 字节序排列,因此 alicecarol 之前。

  • 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 认领长时间未处理的消息。

设计重试时需要明确:

  1. 多久未确认才允许认领;
  2. 认领次数如何统计;
  3. 业务操作是否幂等;
  4. 连续失败是否进入死信 Stream;
  5. 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 时产生瞬时延迟。

可采用:

  • SCANHSCANSSCANZSCAN 分批遍历;
  • 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 中按顺序执行,不会被其他客户端命令插入。

但是,下面的事实必须明确:

  1. Redis 事务成功不等于数据库事务成功;
  2. 如果订单主数据在 MySQL,而事件在 Redis,二者之间仍可能出现一边成功、一边失败;
  3. 如果 Redis 主节点故障,事务是否被副本确认取决于复制和故障切换;
  4. 如果客户端在发送后断开,可能无法判断 EXEC 是否已经执行;
  5. 如果命令运行时参数错误,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 哈希槽、事务边界以及客户端重试。数据结构解决的是“如何组织和操作数据”,而不是自动解决持久化、一致性、可靠消息投递和跨系统事务。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
,之后因为网络或进程故障没有处理新消息,那么它可能丢失这段期间的消息。需要可靠恢复时,应保存消费位置,或使用消费者组。\n\n---\n\n## 八、Stream 消费者组:投递、Pending 和确认\n\n### 8.1 创建消费者组\n\n假设 Stream 中已有历史消息:\n\n```redis\nXGROUP CREATE stream:orders orders-group 0-0 MKSTREAM\n```\n\n含义:\n\n- 创建名为 `orders-group` 的消费者组;\n- 从 `0-0` 开始,组可以消费已有历史消息;\n- `MKSTREAM` 在 Stream 不存在时创建空 Stream。\n\n若只希望消费创建组之后的新消息,可以把起始 ID 设为 ` Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream - WR Blog
WR Blog 加载中...
返回文章
数据库Redis数据结构缓存

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream

Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream封面

数据库基础体系 · 第 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

两个客户端可能都先读到库存充足,然后都执行扣减。

需要使用以下方式之一:

  1. 直接使用提供原子语义的命令,例如 INCRDECRSET NX
  2. 使用 MULTI / EXEC 将多条命令作为事务提交;
  3. 使用 WATCH 实现乐观锁;
  4. 使用 Lua 脚本或 Redis Functions,在服务端完成条件判断和修改。

Redis 事务的基本语义是:

  • MULTI 之后,命令通常先进入队列;
  • EXEC 时按顺序执行;
  • 事务执行期间不会插入其他客户端命令;
  • Redis 事务没有传统关系数据库中的回滚机制;
  • 某条命令运行时出错,其他已排队或可执行命令不会自动回滚;
  • WATCH 监视的 key 在事务提交前被其他客户端修改时,EXEC 会放弃执行并返回空结果。

例如:

MULTI
INCR counter
EXPIRE counter 60
EXEC

这保证 INCREXPIRE 在 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

这三件事经常被混淆:

  1. key 逻辑上到期;
  2. Redis 何时主动删除它;
  3. 数据是否已经从 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;
  • 幂等请求标记;
  • 简单配置;
  • 二进制内容;
  • 通过 GETSETSET 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-1job-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"

alicecarol 分数相同,按 member 字节序排列,因此 alicecarol 之前。

  • 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 认领长时间未处理的消息。

设计重试时需要明确:

  1. 多久未确认才允许认领;
  2. 认领次数如何统计;
  3. 业务操作是否幂等;
  4. 连续失败是否进入死信 Stream;
  5. 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 时产生瞬时延迟。

可采用:

  • SCANHSCANSSCANZSCAN 分批遍历;
  • 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 中按顺序执行,不会被其他客户端命令插入。

但是,下面的事实必须明确:

  1. Redis 事务成功不等于数据库事务成功;
  2. 如果订单主数据在 MySQL,而事件在 Redis,二者之间仍可能出现一边成功、一边失败;
  3. 如果 Redis 主节点故障,事务是否被副本确认取决于复制和故障切换;
  4. 如果客户端在发送后断开,可能无法判断 EXEC 是否已经执行;
  5. 如果命令运行时参数错误,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 哈希槽、事务边界以及客户端重试。数据结构解决的是“如何组织和操作数据”,而不是自动解决持久化、一致性、可靠消息投递和跨系统事务。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
:\n\n```redis\nXGROUP CREATE stream:orders realtime-group $ MKSTREAM\n```\n\n创建组时使用的起始 ID 决定该组的初始读取位置,不等同于每个消费者当前已处理的位置。\n\n---\n\n### 8.2 消费新消息\n\n```redis\nXREADGROUP GROUP orders-group worker-1 \\\n COUNT 10 BLOCK 5000 STREAMS stream:orders >\n```\n\n`>` 表示读取:\n\n```text\n从未投递给该消费者组的消息\n```\n\n读取后,消息会进入消费者组的 Pending Entries List,简称 PEL。此时“已投递”不等于“已处理成功”。\n\n消费者处理业务成功后确认:\n\n```redis\nXACK stream:orders orders-group 1700000000000-0\n```\n\n`XACK` 的作用是从该消费者组的待确认状态中移除对应消息。它不会删除 Stream 中的原始 entry,因此其他读取方式仍可能读取到历史记录,前提是该 entry 尚未被裁剪或删除。\n\n---\n\n### 8.3 Pending、重试和故障恢复\n\n查看待处理消息:\n\n```redis\nXPENDING stream:orders orders-group\n```\n\n详细查看某个范围:\n\n```redis\nXPENDING stream:orders orders-group - + 10\n```\n\nPending 通常意味着:\n\n- 消息已经投递给某个消费者;\n- 消费者尚未 `XACK`;\n- 消息可能正在处理,也可能消费者已经崩溃。\n\n这使得 Stream 消费者组天然更接近“至少一次投递”,而不是“恰好一次处理”。消费者崩溃后,其他消费者可以通过 `XCLAIM` 或较新版本提供的 `XAUTOCLAIM` 认领长时间未处理的消息。\n\n设计重试时需要明确:\n\n1. 多久未确认才允许认领;\n2. 认领次数如何统计;\n3. 业务操作是否幂等;\n4. 连续失败是否进入死信 Stream;\n5. `XACK` 应在业务提交前还是提交后执行。\n\n一个典型顺序是:\n\n```text\n读取消息\n执行业务操作\n业务操作成功后 XACK\n```\n\n如果业务操作成功但 `XACK` 因网络故障没有被 Redis 确认,消息可能再次投递。因此业务操作必须能够识别同一个订单、事件 ID 或幂等键。\n\n如果先 `XACK` 再执行业务操作,消费者崩溃会导致消息已经确认但业务尚未完成,从而产生消息丢失。因此确认点不是形式问题,而是业务提交与消息状态之间的故障边界。\n\n---\n\n### 8.4 Stream 不等于 Kafka\n\nStream 提供:\n\n- Redis 内部的追加日志;\n- entry ID;\n- 范围读取;\n- 阻塞读取;\n- 消费者组;\n- Pending 和确认;\n- 基本的消息保留控制。\n\n但它不自动提供无限容量、跨集群复制模型、复杂分区协调或完整的消息平台能力。实际吞吐、保留长度、内存压力、AOF/RDB 成本和故障切换行为仍受 Redis 部署方式影响。\n\n在 Redis Cluster 中,单个 Stream key 的数据属于一个槽位。一个消费者组针对的是这个 Stream key;如果需要跨多个分片消费,应在应用层管理多个 Stream 和各自的消费状态。\n\n---\n\n## 九、六种结构的选择逻辑\n\n可以先从数据约束反推结构,而不是从命令名称出发:\n\n| 需求 | 更合适的结构 | 核心原因 |\n|---|---|---|\n| 一个值整体读写、计数、缓存 JSON | String | 读写简单,支持条件写入和原子递增 |\n| 一个对象包含多个可独立更新字段 | Hash | field 级读写 |\n| 先进先出、后进先出、阻塞队列 | List | 两端操作和阻塞弹出 |\n| 成员唯一,只关心是否存在 | Set | 自动去重和集合运算 |\n| 成员唯一,还需要分数、排名、范围 | ZSet | score 排序和排名 |\n| 追加事件、历史回放、确认和重试 | Stream | ID、消费组和 Pending 状态 |\n\n几个常见反例:\n\n### 9.1 用 String JSON 模拟高频字段更新\n\n问题:\n\n- 每次更新都传输整个对象;\n- 并发读改写可能覆盖字段;\n- 过期只能作用于整个对象。\n\n如果字段需要独立更新,优先考虑 Hash;如果需要复杂嵌套 JSON 操作,要明确目标 Redis 版本和相应 JSON 模块语义,不能假设核心 String 自动支持 JSON 查询。\n\n### 9.2 用 List 实现需要确认和重试的消息系统\n\n`LPOP` 成功后,消息已经从队列消失。消费者随后崩溃,消息可能丢失。可以通过“待处理队列 + 处理中队列”改进,但仍要自己维护确认和超时转移;Stream 消费者组已经提供了更直接的 Pending 模型。\n\n### 9.3 用 Set 实现排行榜\n\nSet 没有 score,无法直接表达排名。把分数拼进 member 中会导致更新和排序复杂且容易出错,不能替代 ZSet。\n\n### 9.4 用 ZSet 实现可靠延迟队列却只查询不领取\n\n多个消费者会读到相同任务。必须让“筛选、领取、改变状态”具有原子边界,并处理任务超时和重复执行。\n\n### 9.5 把 Stream 的 `XREADGROUP` 当成自动删除\n\n读取、确认和删除是不同操作:\n\n- 读取:投递消息;\n- `XACK`:移除 Pending 状态;\n- `XDEL` 或裁剪:删除 Stream entry。\n\n确认不会自动删除历史消息;删除 entry 也不等于清理所有消费语义。因此必须单独设计保留和清理策略。\n\n---\n\n## 十、复杂度、内存与大 key 风险\n\nRedis 的命令复杂度不仅影响平均响应时间,也影响事件循环中其他请求的等待时间。\n\n应特别警惕:\n\n```redis\nHGETALL huge-hash\nSMEMBERS huge-set\nLRANGE huge-list 0 -1\nXRANGE huge-stream - +\n```\n\n这些命令可能一次返回大量数据。风险包括:\n\n- Redis 主线程长时间执行;\n- 客户端响应包过大;\n- 网络延迟升高;\n- 主从复制延迟;\n- AOF 写入和重写压力增加;\n- 删除大 key 时产生瞬时延迟。\n\n可采用:\n\n- `SCAN`、`HSCAN`、`SSCAN`、`ZSCAN` 分批遍历;\n- `COUNT` 限制返回规模;\n- 按业务分页;\n- 拆分过大的对象;\n- 监控 key 大小和成员数量;\n- 在合适版本和部署边界下使用异步删除命令,例如 `UNLINK`。\n\n但扫描不是快照。若需要一致性快照,应使用数据库或专门的数据导出机制,而不是假定游标扫描期间数据不会变化。\n\n内部编码也会影响内存和性能。Redis 可能对小对象采用紧凑表示,对较大对象切换为更适合操作的表示。编码属于实现细节,版本升级可能变化;应用只能依赖命令语义、复杂度和配置文档,不能依赖某个内部编码名称或阈值。\n\n---\n\n## 十一、持久化、复制与故障路径中的数据结构\n\nRedis 数据结构存在内存中,但不代表每次命令都已经落盘。\n\n### 11.1 RDB 和 AOF 的关系\n\n- RDB 是某个时间点的数据快照;\n- AOF 记录写操作,具体持久化可靠性取决于同步策略;\n- 重启恢复时,Redis 根据可用持久化文件重建内存数据;\n- 最近写入是否存在、故障时最多丢失多少数据,与持久化策略和故障时序有关。\n\n例如:\n\n```redis\nSET cache:item value EX 60\n```\n\n恢复后该 key 是否存在,不只取决于它的 TTL,还取决于快照或 AOF 中记录的时间和 Redis 恢复时的当前时间。过期时间是绝对时间语义的一部分,不能将“写入过期时间”理解为“从某个持久化文件恢复后重新完整计时”。\n\n### 11.2 Fork 和大数据结构\n\nRDB 保存、AOF 重写等后台操作可能使用 fork。写时复制意味着:\n\n- 子进程读取父进程的内存页;\n- 父进程继续修改时,被修改的内存页可能产生额外副本;\n- 大 Hash、大 List、大 Set、大 ZSet 或大 Stream 在高频更新时可能放大内存峰值。\n\n因此“数据结构的逻辑大小”和“运行时占用内存”不是同一个数。生产诊断应结合:\n\n```redis\nMEMORY USAGE key\nMEMORY STATS\nINFO memory\nINFO persistence\n```\n\n并观察 fork、后台保存、AOF 重写、复制积压和淘汰行为。\n\n### 11.3 复制确认不等于持久化确认\n\n`WAIT` 可以让客户端等待写操作传播到一定数量的副本,但它不等于副本已经将数据持久化到磁盘,也不自动形成跨故障域的一致提交协议。\n\n同样,主节点返回 `OK` 只说明命令在该节点接受并执行成功;如果随后发生进程崩溃、机器故障或网络分区,数据是否能从副本或持久化文件恢复,需要结合部署和持久化配置判断。\n\n---\n\n## 十二、过期、内存淘汰与不同数据结构\n\nRedis 的内存淘汰通常以 key 为单位,而不是自动选择 Hash 中的某个 field 或 ZSet 中的某个 member。即使一个 Hash 中有一百万个 field,淘汰策略也通常决定是否淘汰整个 Hash key。\n\n这会影响建模:\n\n```text\n一个超大 Hash 保存所有用户会话\n```\n\n如果该 key 被淘汰,可能一次失去所有用户会话;若拆成:\n\n```text\nsession:{user_id}\n```\n\n则可以按用户 key 独立过期和淘汰,但 key 数量和管理开销会上升。\n\n缓存系统中还要区分:\n\n- key 不存在;\n- key 存在但值为空;\n- 缓存命中;\n- 缓存命中的是空结果;\n- key 被淘汰;\n- key 到期;\n- 后端查询失败。\n\n例如缓存穿透保护常用 String 保存空值标记:\n\n```redis\nSET cache:user:9999 \"__NULL__\" EX 30 NX\n```\n\n但应用必须保证特殊值不会与真实业务值冲突,或者使用明确的编码格式。\n\n---\n\n## 十三、事务、脚本和多结构协作示例\n\n假设要完成一个“发布订单事件并增加用户订单数”的操作:\n\n```text\nHash:保存订单状态\nString 或 Hash field:增加统计值\nStream:写入订单事件\n```\n\n单机单实例中,可以使用事务:\n\n```redis\nMULTI\nHSET order:1001 status created user_id 7\nHINCRBY user:7 stats_order_count 1\nXADD stream:orders * type order_created order_id 1001 user_id 7\nEXEC\n```\n\n这组命令在 `EXEC` 中按顺序执行,不会被其他客户端命令插入。\n\n但是,下面的事实必须明确:\n\n1. Redis 事务成功不等于数据库事务成功;\n2. 如果订单主数据在 MySQL,而事件在 Redis,二者之间仍可能出现一边成功、一边失败;\n3. 如果 Redis 主节点故障,事务是否被副本确认取决于复制和故障切换;\n4. 如果客户端在发送后断开,可能无法判断 `EXEC` 是否已经执行;\n5. 如果命令运行时参数错误,Redis 不会把其他成功命令自动回滚。\n\n若要实现跨系统可靠事件发布,应考虑事务消息、Outbox、可重试事件和幂等消费,而不是仅靠 Redis 的 `MULTI`。\n\n在 Redis Cluster 中,这个事务涉及多个 key。需要让它们使用同一个哈希标签:\n\n```text\norder:{1001}\nuser:{1001}:stats\nstream:{1001}:orders\n```\n\n但 Stream 按用户标签拆分会改变事件流模型;如果业务需要全局单一事件流,就不能简单地把所有 key 都塞到一个哈希槽而不评估热点。\n\n---\n\n## 十四、诊断数据结构问题的步骤\n\n遇到 Redis 数据异常时,可以按以下顺序定位。\n\n### 14.1 先确认类型和存在性\n\n```redis\nEXISTS key\nTYPE key\nTTL key\n```\n\n如果报 `WRONGTYPE`,优先检查:\n\n- 是否复用了相同 key 命名;\n- 是否有旧版本程序写入了不同类型;\n- 是否清理或迁移不完整;\n- 是否环境配置指向了错误 Redis。\n\n### 14.2 再检查规模\n\n```redis\nSTRLEN string:key\nHLEN hash:key\nLLEN list:key\nSCARD set:key\nZCARD zset:key\nXLEN stream:key\nMEMORY USAGE key\n```\n\n这些命令有助于区分“数据不存在”和“大 key 导致响应缓慢”。\n\n### 14.3 最后检查命令和并发路径\n\n可以使用:\n\n```redis\nSLOWLOG GET 20\nLATENCY DOCTOR\nINFO commandstats\n```\n\n生产环境使用 `MONITOR` 要谨慎,因为它会产生大量输出并增加开销,不应长时间无控制地开启。\n\n对于 Stream,还要额外检查:\n\n```redis\nXINFO STREAM stream:orders\nXINFO GROUPS stream:orders\nXINFO CONSUMERS stream:orders orders-group\nXPENDING stream:orders orders-group\n```\n\n重点观察:\n\n- Stream 是否持续增长;\n- 消费者组是否有 Pending;\n- 某个消费者是否长期不活跃;\n- 消费确认是否停止;\n- 是否发生频繁认领和重复处理。\n\n---\n\n## 十五、总结:从数据约束选择 Redis 结构\n\n六种结构并不是六种互相替代的容器,而是六种不同的数据约束:\n\n- **String**:一个二进制安全值,可整体读写、条件写入和原子计数;\n- **Hash**:一个 key 下的 field-value 映射,适合对象字段级操作;\n- **List**:有序序列,适合两端队列和栈;\n- **Set**:无序去重集合,适合成员关系和集合运算;\n- **ZSet**:带 score 的唯一成员集合,适合排名、范围和时间排序;\n- **Stream**:带 ID 的追加消息流,适合历史读取、消费组、确认和 Pending 管理。\n\n真正的选型结果还取决于故障语义:\n\n- 只需要缓存完整值,String 足够;\n- 需要字段级更新,Hash 更自然;\n- 只需要先进先出,List 更简单;\n- 需要去重和集合运算,Set 更直接;\n- 需要排名或按时间取任务,ZSet 更合适;\n- 需要消息历史、消费确认和故障重试,Stream 提供更完整的模型。\n\n最后应把数据结构和以下机制一起评估:key 级过期、内存淘汰、RDB、AOF、复制、fork、Cluster 哈希槽、事务边界以及客户端重试。数据结构解决的是“如何组织和操作数据”,而不是自动解决持久化、一致性、可靠消息投递和跨系统事务。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[数据库完整学习路线:从关系模型、事务索引到分布式与向量检索](https://wrblog.cn/articles/e04c40d6-ba22-5c0c-8442-2252df05d216)\n- 上一篇:[SQLite 工程实践:嵌入应用、备份、迁移、损坏恢复与安全](https://wrblog.cn/articles/5a1bdce1-22fd-5ede-88f4-287026135ea8)\n- 下一篇:[Redis 持久化与内存:RDB、AOF、过期淘汰、Fork 和恢复](https://wrblog.cn/articles/a6eb6fd9-497d-5bcf-ad84-ee85a4ee4c9b)\n- 延伸:[Redis 缓存体系:一致性、穿透、击穿、雪崩和多级缓存](https://wrblog.cn/articles/02a05981-d76b-5aa8-9e41-fd6af20d978a)\n\n## 官方资料\n\n- [Redis Documentation](https://redis.io/docs/latest/)\n- [Redis Commands](https://redis.io/docs/latest/commands/)\n\n> 本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。\n","tags":["数据库","Redis","数据结构","缓存"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-09-01 14:04:19","updateTime":"2026-09-01 14:04:19"}},"status":200,"locale":"zh-CN","theme":"light"}