Redis 数据结构
问题
Redis 支持哪些数据结构?每种数据结构的底层实现是什么?不同场景下如何选择合适的数据结构?
答案
Redis 数据结构全景
Redis 支持 5 种基本数据结构 + 3 种特殊数据结构:
| 数据结构 | 底层编码 | 典型应用场景 |
|---|---|---|
| String | int / embstr / raw (SDS) | 缓存、计数器、分布式锁 |
| Hash | listpack / hashtable | 对象存储、用户信息 |
| List | listpack / quicklist | 消息队列、最新消息列表 |
| Set | intset / hashtable | 标签、共同好友、去重 |
| Sorted Set (ZSet) | listpack / skiplist + hashtable | 排行榜、延迟队列 |
| Stream | Rax 树 + listpack | 消息队列(类 Kafka) |
| Bitmap | String(操作位) | 签到、布隆过滤器 |
| HyperLogLog | 稀疏/密集编码 | UV 统计(基数估算) |
Redis 7.0 将小数据量时的底层编码从 ziplist 改为 listpack,解决了 ziplist 的连锁更新问题。本文基于 Redis 7.0+ 描述。
String
Redis 中最基础的数据结构,底层使用 SDS(Simple Dynamic String)。
SDS vs C 字符串
| 特性 | C 字符串 | SDS |
|---|---|---|
| 获取长度 | O(n) 遍历 | O(1) len 字段 |
| 缓冲区溢出 | 可能溢出 | 自动扩容 |
| 二进制安全 | 否(\0 截断) | 是(用 len 判断结束) |
| 内存分配 | 每次修改都分配 | 预分配 + 惰性释放 |
三种编码
| 编码 | 条件 | 说明 |
|---|---|---|
| int | 值为整数且可用 long 表示 | 直接存储整数值 |
| embstr | 字符串长度 ≤ 44 字节 | 对象头和 SDS 分配在连续内存中 |
| raw | 字符串长度 > 44 字节 | 对象头和 SDS 分别分配内存 |
SET key value [EX seconds] [NX|XX] # 设置值
GET key # 获取值
INCR key # 原子递增
INCRBY key increment # 原子增加指定值
SETNX key value # 不存在时设置(分布式锁基础)
MSET k1 v1 k2 v2 # 批量设置
MGET k1 k2 # 批量获取
应用场景:缓存(JSON 字符串)、计数器(INCR)、分布式锁(SETNX)、Session 共享。
Hash
适合存储对象,比 String 存储 JSON 更节省内存,且支持单字段修改。
编码转换
| 编码 | 条件 | 特点 |
|---|---|---|
| listpack | 元素数 ≤ 128 且 值长度 ≤ 64 字节 | 连续内存,紧凑 |
| hashtable | 超过上述限制 | 哈希表,O(1) 查找 |
HSET user:1 name "张三" age 25 # 设置字段
HGET user:1 name # 获取字段
HMGET user:1 name age # 批量获取
HGETALL user:1 # 获取所有字段和值
HINCRBY user:1 age 1 # 字段递增
HDEL user:1 age # 删除字段
应用场景:用户信息、商品详情、购物车(user_id → {product_id: count})。
List
有序列表,可以从头尾两端操作。
编码
Redis 7.0 中 List 底层使用 quicklist(由多个 listpack 节点组成的双向链表)。
quicklist 兼顾了链表(快速头尾操作)和紧凑存储(listpack 减少内存碎片)的优点。
LPUSH key v1 v2 # 左侧入队
RPUSH key v1 v2 # 右侧入队
LPOP key # 左侧出队
RPOP key # 右侧出队
LRANGE key 0 -1 # 获取所有元素
LLEN key # 获取长度
BLPOP key timeout # 阻塞式左出队
应用场景:消息队列(LPUSH + BRPOP)、最新消息列表(LPUSH + LRANGE)、关注列表。
Set
无序集合,元素不重复,支持交集、并集、差集运算。
SADD key v1 v2 v3 # 添加元素
SMEMBERS key # 获取所有元素
SISMEMBER key value # 判断是否存在
SCARD key # 获取元素数量
SINTER key1 key2 # 交集
SUNION key1 key2 # 并集
SDIFF key1 key2 # 差集
SRANDMEMBER key count # 随机获取
应用场景:标签系统、共同好友(SINTER)、推荐系统(SDIFF)、抽奖(SRANDMEMBER)。
Sorted Set(ZSet)
有序集合,每个元素关联一个 score(分数),按分数排序。
底层实现
| 编码 | 条件 | 结构 |
|---|---|---|
| listpack | 元素数 ≤ 128 且 值长度 ≤ 64 字节 | 紧凑存储 |
| skiplist + hashtable | 超过限制 | 跳表支持范围查询 + 哈希表支持 O(1) 查分数 |
跳表(Skip List)
Level 4: HEAD ──────────────────────────────────────→ 50 ──→ NIL
Level 3: HEAD ──────────→ 20 ──────────────────────→ 50 ──→ NIL
Level 2: HEAD ──→ 10 ──→ 20 ──────────→ 40 ──────→ 50 ──→ NIL
Level 1: HEAD ──→ 10 ──→ 20 ──→ 30 ──→ 40 ──→ 45 → 50 ──→ NIL
跳表的特点:
- 查找:O(log n),从高层开始跳跃查找
- 插入/删除:O(log n)
- 范围查询:O(log n + m),m 为范围内元素数
- 实现比红黑树简单,范围操作更高效
Redis 作者 antirez 给出的理由:
- 跳表实现更简单,维护和调试更容易
- 范围查询和按排名查询性能更好
- 可以通过调整层高参数来平衡时间和空间
ZADD key score member # 添加元素
ZSCORE key member # 获取分数
ZRANK key member # 获取排名(0-based)
ZRANGE key 0 -1 WITHSCORES # 按分数升序获取
ZREVRANGE key 0 9 # 按分数降序取前 10
ZRANGEBYSCORE key min max # 按分数范围获取
ZINCRBY key increment member # 增加分数
应用场景:排行榜(ZINCRBY + ZREVRANGE)、延迟队列(score 为执行时间)、限流(滑动窗口)。
Stream
Redis 5.0 引入的消息队列数据结构,支持消费者组、消息确认等特性。
XADD mystream * name "张三" action "login" # 添加消息
XLEN mystream # 消息数量
XRANGE mystream - + # 读取所有消息
# 消费者组
XGROUP CREATE mystream mygroup $ MKSTREAM # 创建消费者组
XREADGROUP GROUP mygroup consumer1 COUNT 1 BLOCK 0 STREAMS mystream > # 消费消息
XACK mystream mygroup msg-id # 确认消息
应用场景:消息队列(轻量替代 Kafka/RabbitMQ)、事件驱动。
Bitmap 和 HyperLogLog
SETBIT sign:user:1:202401 0 1 # 1 月 1 日签到
GETBIT sign:user:1:202401 0 # 查询是否签到
BITCOUNT sign:user:1:202401 # 统计签到天数
BITOP AND result key1 key2 # 位与运算
PFADD page:uv:20240101 user1 user2 # 添加元素
PFCOUNT page:uv:20240101 # 统计基数(UV)
PFMERGE result key1 key2 # 合并
# 误差率约 0.81%,每个 key 仅占 12 KB
常见面试问题
Q1: Redis 有哪些数据结构?各自适合什么场景?
答案:
| 数据结构 | 核心特点 | 典型场景 |
|---|---|---|
| String | 简单键值对 | 缓存、计数器、分布式锁 |
| Hash | 字段级操作 | 对象存储(用户信息) |
| List | 有序、可重复 | 消息队列、最新 N 条 |
| Set | 无序、不重复、集合运算 | 标签、共同好友、去重 |
| ZSet | 有序、按分数排序 | 排行榜、延迟队列 |
| Stream | 消息队列语义 | 事件流、轻量 MQ |
| Bitmap | 位操作 | 签到、在线状态 |
| HyperLogLog | 基数估算 | UV 统计 |
Q2: ZSet 的底层为什么用跳表?
答案:
ZSet 在数据量大时使用 跳表(skiplist)+ 哈希表 双重结构:
- 跳表:支持 O(log n) 的范围查询和排名查询(
ZRANGE、ZRANGEBYSCORE、ZRANK) - 哈希表:支持 O(1) 的单元素查分数(
ZSCORE)
为什么不用红黑树:跳表实现更简单、范围操作更高效(找到起点后顺序遍历即可)、并发友好(可以更容易地支持并发修改)。
Q3: String 和 Hash 存储对象有什么区别?
答案:
| 对比 | String 存 JSON | Hash |
|---|---|---|
| 存储方式 | 整个 JSON 字符串 | 字段 → 值的映射 |
| 修改部分字段 | 需要先 GET → 解析 → 修改 → SET | 直接 HSET key field value |
| 内存效率 | JSON 有冗余(括号、引号) | 小对象用 listpack 更省内存 |
| 查询灵活性 | 只能整体获取 | 可以获取单个字段 |
| 适用场景 | 整体读取、不常修改 | 频繁修改部分字段 |
Q4: Redis 的 SDS 相比 C 字符串有什么优势?
答案:
SDS(Simple Dynamic String)解决了 C 字符串的四个问题:
- O(1) 获取长度:SDS 结构体中有
len字段,直接返回 - 防止缓冲区溢出:修改前自动检查并扩容
- 减少内存分配:使用空间预分配(扩容时多分配)和惰性释放(缩短时不立即回收)
- 二进制安全:不以
\0判断结束,而是用len确定数据长度,可以存储图片、音频等二进制数据
Q5: Stream 和 List 做消息队列有什么区别?
答案:
| 特性 | List(LPUSH + BRPOP) | Stream |
|---|---|---|
| 消费者组 | ❌ 不支持 | ✅ 支持 |
| 消息确认(ACK) | ❌ 不支持 | ✅ 支持 |
| 消息持久化 | 消费即删除 | 消费后仍保留 |
| 消息回溯 | ❌ 不支持 | ✅ 支持(按 ID 范围读取) |
| 阻塞读取 | ✅ BRPOP | ✅ XREAD BLOCK |
Stream 是更完善的消息队列方案,支持消费者组、消息确认、消息回溯等特性,但对于简单的生产消费场景 List 也很够用。