Redis 数据结构——String/List/Hash/Set/ZSet 底层编码、内存优化、BigKey
更新时间:2026-09-02。本文是数据库域 NoSQL 第一篇,接 MySQL 查询优化。Redis 是"缓存 + 数据结构服务器",理解数据结构底层编码,才能写出省内存、高性能的 Redis 代码。
本文要回答的问题
- Redis 五种数据结构(String/List/Hash/Set/ZSet)分别适合什么场景?
- 每种结构底层有哪些编码方式?什么时候自动切换?
- 怎么省内存?int 比 string 省多少?ziplist 什么时候用?
- BigKey 是什么?有什么危害?怎么发现和删除?
- 怎么选择合适的数据结构?
一、Redis 数据结构总览
Redis 5 种核心数据结构:
┌─────────────────────────────────────────────────────────────┐
│ String │ List │ Hash │ Set │ ZSet (Sorted Set) │
│ key→str │ [a,b,c]│ {f:v} │ {a,b}│ {a:1, b:2} │
│ 字符串 │ 列表 │ 哈希表 │ 集合 │ 有序集合 │
│ O(1) │ O(1) │ O(1) │ O(1) │ O(log n) │
└─────────────────────────────────────────────────────────────┘底层编码实现在 Redis 源码中的 object.c 和 t_*.c 文件中,不同编码对应不同内存布局。
二、String——字符串
1. 底层编码
| 编码 | 类型 | 条件 | 内存 |
|---|---|---|---|
| int | 整数 | 值能用 64 位有符号整数表示 | 8 字节 |
| embstr | 短字符串 | 长度 ≤ 44 字节(Redis 3.2+) | 一次分配,连续内存 |
| raw | 长字符串 | 长度 > 44 字节 | 两次分配,SDS 结构 |
bash
# String 编码验证
127.0.0.1:6379> SET key1 12345
127.0.0.1:6379> OBJECT ENCODING key1
"int"
127.0.0.1:6379> SET key2 "hello"
127.0.0.1:6379> OBJECT ENCODING key2
"embstr"
127.0.0.1:6379> SET key3 "a" # 长度 45 的字符串
127.0.0.1:6379> OBJECT ENCODING key3
"raw"SDS(Simple Dynamic String)结构:
SDS 结构(Simple Dynamic String):
┌──────────┬──────────┬──────────┬──────────────────────┐
│ len (4B) │ alloc (4B)│ flags (1B)│ buf[] (数据) │
│ 实际长度 │ 分配容量 │ 类型标识 │ 字节数组,以 \0 结尾 │
└──────────┴──────────┴──────────┴──────────────────────┘
优点:
1. O(1) 获取长度(C 字符串需要遍历)
2. 预分配空间,减少内存分配次数
3. 二进制安全(可以存图片、序列化数据)2. 常用命令
bash
# 基本操作
SET key value
GET key
DEL key
# 批量操作(减少网络开销)
MSET key1 val1 key2 val2
MGET key1 key2
# 计数器
INCR counter # 自增 1
INCRBY counter 10 # 自增 10
DECR counter # 自减 1
# 过期时间
SET key value EX 60 # 60 秒后过期
SET key value PX 60000 # 60000 毫秒后过期
EXPIRE key 60
# 不存在时才设置(分布式锁用)
SET key value NX
# 获取并设置
GETSET key new_value3. 内存优化
bash
# 整数比字符串省内存
# ❌ 字符串存数字
SET user_id "123456789" # 占用 10+ 字节
# ✅ 整数存储
SET user_id 123456789 # 占用 8 字节
# 短字符串用 embstr(一次分配)
# 长字符串用 raw(两次分配)
# 临界点在 44 字节(Redis 3.2+)
# 所以尽量把短的 key 值控制在 44 字节以内三、List——列表
1. 底层编码
| 编码 | 条件 | 特点 |
|---|---|---|
| ziplist(压缩列表) | 元素少且值小(默认:元素 < 512 且值 < 64 字节) | 连续内存,省空间 |
| linkedlist(双向链表) | 不满足 ziplist 条件 | 前后指针,分段内存 |
| quicklist(快表,3.2+) | 默认编码 | ziplist + linkedlist 混合 |
Redis 3.2+ 用 quicklist 替代了 ziplist + linkedlist。
quicklist 结构:
┌──────────┬──────────┬──────────┬──────────┐
│ ziplist │ ziplist │ ziplist │ ziplist │ ← 每个节点是一个 ziplist
│ [a,b,c] │ [d,e,f] │ [g,h] │ [i,j,k] │
└──────────┴──────────┴──────────┴──────────┘
↑ 双向链表指针,可双向遍历
quicklist 参数:
list-max-ziplist-size: 每个 ziplist 最大元素数(默认 8KB)
list-compress-depth: 压缩深度(0=不压缩,1=两端不压缩中间压缩)2. 常用命令
bash
# 队列(FIFO):从右进,从左出
LPUSH queue task1 # 从左边插入
LPUSH queue task2
RPOP queue # 从右边弹出 → "task1"
# 栈(LIFO):从左进,从左出
LPUSH stack item1
LPUSH stack item2
LPOP stack # → "item2"
# 阻塞队列(消息队列用)
# 从右弹出,如果列表为空,阻塞 10 秒等待
BRPOP queue 10
# 范围查询
LRANGE list 0 -1 # 所有元素
LRANGE list 0 9 # 前 10 个元素
# 长度
LLEN list3. 使用场景
| 场景 | 操作 | 说明 |
|---|---|---|
| 消息队列 | LPUSH + BRPOP | 生产者消费者模式 |
| 最新列表 | LPUSH + LTRIM | 只保留最近 N 条 |
| 栈 | LPUSH + LPOP | LIFO 结构 |
| 时间线 | LPUSH + LRANGE | 用户时间线等 |
bash
# 最新列表:只保留最近 100 条
LPUSH recent_news news_id
LTRIM recent_news 0 99四、Hash——哈希表
1. 底层编码
| 编码 | 条件 | 特点 |
|---|---|---|
| ziplist(压缩列表) | 元素少且值小(默认:field < 512 且值 < 64 字节) | 连续内存,省空间 |
| hashtable(哈希表) | 不满足 ziplist 条件 | 字典结构,O(1) 读写 |
ziplist 编码的 Hash 在内存中:
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│zlbytes│zltail│zllen │"name"│"Tom" │"age" │"25" │ zlend│
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
key=name value=Tom key=age value=25
hashtable 编码:
┌──────────────────┐
│ dict │
│ ┌────────────┐ │
│ │ 哈希表数组 │ │ ┌──────────────┐
│ │ [0] → ... │──┼────→ │ key: "name" │
│ │ [1] → ... │ │ │ val: "Tom" │
│ │ [2] → ... │ │ │ next: ... │
│ │ ... │ │ └──────────────┘
│ └────────────┘ │
│ rehash 状态 │
└──────────────────┘2. 常用命令
bash
# 基本操作
HSET user:1001 name "Tom"
HSET user:1001 age 25
HGET user:1001 name # → "Tom"
HGETALL user:1001 # → name, Tom, age, 25
# 批量操作
HMSET user:1002 name "Jerry" age 30 city "NY"
HMGET user:1002 name age
# 计数器
HINCRBY user:1001 score 10
# 存在性检查
HEXISTS user:1001 name
# 只取 field 或只取 value
HKEYS user:1001
HVALS user:1001
# 长度
HLEN user:10013. Hash 与 String 存对象的对比
| 方式 | 内存 | 操作灵活性 | 适用场景 |
|---|---|---|---|
| String 存 JSON | 省内存(一次分配) | 改一个字段需全读全写 | 不常改的数据 |
| Hash 存字段 | 多分配(多个 key) | 增删改单字段 O(1) | 频繁改部分字段 |
bash
# String 方式
SET user:1001 '{"name":"Tom","age":25,"city":"NY"}'
# 改 age 需要:GET → 反序列化 → 改 → 序列化 → SET
# Hash 方式(推荐)
HSET user:1001 name "Tom" age 25 city "NY"
# 改 age:HINCRBY user:1001 age 1
# 只取 name:HGET user:1001 name经验: 对象字段经常一起读写时用 String 存 JSON,经常只读写部分字段时用 Hash。从内存角度看,字段少时 ziplist 编码的 Hash 比 String 省内存。
五、Set——集合
1. 底层编码
| 编码 | 条件 | 特点 |
|---|---|---|
| intset(整数集合) | 所有元素都是整数且数量 < 512 | 有序数组,二分查找,省内存 |
| hashtable(哈希表) | 不满足 intset 条件 | 字典结构,O(1) 操作 |
intset 编码:
┌──────────┬──────────┬──────────────────────────────┐
│ encoding │ length │ contents[] │
│ (int32) │ (元素数) │ [1, 5, 10, 20, 100] │
└──────────┴──────────┴──────────────────────────────┘
有序数组,二分查找,省内存
intset 升级:
- 插入 int64 元素时,整个数组从 int32 升级到 int64
- 升级后不会降级(即使删除了大整数)2. 常用命令
bash
# 基本操作
SADD set a b c
SREM set a
SMEMBERS set # 所有元素(小心大数据量)
SISMEMBER set a # 检查是否存在
# 集合运算
SINTER set1 set2 # 交集
SUNION set1 set2 # 并集
SDIFF set1 set2 # 差集
# 随机
SRANDMEMBER set 3 # 随机取 3 个元素(不删除)
SPOP set # 随机弹出一个
# 长度
SCARD set3. 使用场景
| 场景 | 操作 | 说明 |
|---|---|---|
| 标签系统 | SADD + SINTER | 用户标签,交集找共同兴趣 |
| 去重 | SADD + SCARD | 独立访客统计 |
| 抽奖 | SPOP / SRANDMEMBER | 随机抽奖,不重复 |
| 关注关系 | SADD + SINTER | 共同关注,推荐好友 |
bash
# 标签系统:交集找匹配用户
SADD tag:java user1 user2 user3
SADD tag:redis user1 user4 user5
SINTER tag:java tag:redis # → user1(同时懂 Java 和 Redis 的用户)
# 独立访客统计
SADD uv:2026-09-02 ip1 ip2 ip3
SCARD uv:2026-09-02 # → 3六、ZSet(Sorted Set)——有序集合
1. 底层编码
| 编码 | 条件 | 特点 |
|---|---|---|
| ziplist(压缩列表) | 元素少且值小(默认:元素 < 128 且值 < 64 字节) | 连续内存,双有序 |
| skiplist(跳表) | 不满足 ziplist 条件 | 跳表 + 哈希表,O(log n) |
skiplist 编码 = 跳表(按 score 排序)+ 哈希表(按 member 查找)
跳表结构(skiplist):
Level 4: 1 ───────────────────────────────→ 100
Level 3: 1 ─────────────→ 50 ─────────────→ 100
Level 2: 1 ───→ 20 ───→ 50 ───→ 80 ─────→ 100
Level 1: 1 → 10 → 20 → 30 → 50 → 70 → 80 → 100
跳表查找过程(查找 70):
1. Level 4: 1 → 100(比 100 小,下移)
2. Level 3: 1 → 50(比 70 小,继续)→ 100(比 70 大,下移)
3. Level 2: 50 → 80(比 70 大,下移)
4. Level 1: 50 → 70(找到了,O(log n))
为什么用跳表不用平衡树?
- 跳表实现简单,平衡树旋转操作复杂
- 跳表范围查询(ZRANGE)比平衡树方便
- 跳表平均 O(log n),最坏 O(n),平衡树 O(log n)2. 常用命令
bash
# 添加元素(member + score)
ZADD leaderboard 100 user1
ZADD leaderboard 200 user2
ZADD leaderboard 150 user3
# 按 score 排名(从小到大)
ZRANGE leaderboard 0 -1 WITHSCORES
# → user1, 100, user3, 150, user2, 200
# 按 score 倒序(从大到小)
ZREVRANGE leaderboard 0 -1 WITHSCORES
# → user2, 200, user3, 150, user1, 100
# 获取排名
ZRANK leaderboard user1 # 正序排名:0
ZREVRANK leaderboard user1 # 倒序排名:2
# 获取分数
ZSCORE leaderboard user1 # → 100
# 按分数范围查询
ZRANGEBYSCORE leaderboard 100 150
# → user1, user3(分数在 100-150 之间的)
# 增加分数
ZINCRBY leaderboard 50 user1 # user1 分数 +50
# 删除
ZREM leaderboard user1
# 统计分数区间
ZCOUNT leaderboard 100 200 # 分数在 100-200 间的元素数
# 长度
ZCARD leaderboard3. 使用场景
| 场景 | 操作 | 说明 |
|---|---|---|
| 排行榜 | ZADD + ZREVRANGE | 实时 TOP N |
| 延迟队列 | ZADD + ZRANGEBYSCORE | 时间戳做 score,轮询到期任务 |
| 限流 | ZADD + ZREMRANGEBYSCORE | 滑动窗口,时间窗口内计数 |
| 商品热度 | ZINCRBY | 实时更新热度排序 |
bash
# 延迟队列:当前时间戳做 score
ZADD delay_queue 1700000000 task1
ZADD delay_queue 1700000100 task2
# 取出到期任务
ZRANGEBYSCORE delay_queue -inf 1700000050
# → task1(到期任务)
# 限流:滑动窗口
# 每秒一个元素,统计 60 秒内
ZADD rate_limit:user1 1700000000 request_id
ZREMRANGEBYSCORE rate_limit:user1 -inf 1699999400 # 删除旧数据
ZCARD rate_limit:user1 # 60 秒内请求数七、BigKey 问题
1. BigKey 的危害
| 问题 | 原因 |
|---|---|
| 阻塞 Redis | 大 Key 操作(如 SMEMBERS、HGETALL)耗时 O(n),阻塞其他请求 |
| 网络延迟 | 大 Key 传输慢,客户端长时间等待 |
| 内存不均 | 集群中大 Key 节点内存占用高,其他节点低 |
| 数据迁移慢 | 集群扩容缩容时,大 Key 迁移耗时 |
| 删除阻塞 | DEL 大 Key 会阻塞,需要异步删除 |
BigKey 的阈值:
| 类型 | 阈值 |
|---|---|
| String | > 10 KB |
| List | > 10000 个元素 |
| Hash | > 1000 个 field |
| Set | > 5000 个元素 |
| ZSet | > 5000 个元素 |
2. 发现 BigKey
bash
# 方法 1:redis-cli --bigkeys(扫描所有 key)
redis-cli --bigkeys
# 输出示例:
# Biggest string found 'log:2026-09-01' has 12582912 bytes
# Biggest hash found 'user:tags' has 50000 fields
# Biggest set found 'online:users' has 100000 members
# 方法 2:MEMORY USAGE 命令
MEMORY USAGE user:tags
# 方法 3:RDB 分析工具
# redis-rdb-tools 分析 RDB 文件
rdb -c memory /var/lib/redis/dump.rdb --bytes 10000
# 方法 4:MONITOR 命令(线上谨慎使用)
# 看哪些命令执行的耗时大3. 处理 BigKey
bash
# 删除 BigKey(4.0+ 用 UNLINK 异步删除)
UNLINK big_key # 异步删除,不阻塞
# 拆分 BigKey
# 大 Hash → 拆成多个小 Hash
HSET user:tags:0 tag1 tag2 tag3
HSET user:tags:1 tag4 tag5 tag6
# 大 List → 拆成多个小 List
LPUSH queue:0 task1 task2
LPUSH queue:1 task3 task4
# 大 Set → 用多个小 Set
SADD tag:java:0 user1 user2
SADD tag:java:1 user3 user4
# 大 String → 压缩或存文件系统
# 超过 10KB 的字符串考虑压缩
SET compressed_key COMPRESS(data)八、数据结构选型指南
| 需求 | 数据结构 | 原因 |
|---|---|---|
| 存简单值 | String | 最基础,O(1) |
| 计数器 | String INCR | 原子操作,高性能 |
| 消息队列 | List | LPUSH + BRPOP,阻塞 |
| 最新列表 | List | LPUSH + LTRIM |
| 对象存部分字段 | Hash | 单字段操作 O(1) |
| 标签/去重 | Set | 集合运算,自动去重 |
| 排行榜 | ZSet | 按分数排序 O(log n) |
| 延迟队列 | ZSet | 时间戳做 score |
| 限流 | ZSet | 滑动窗口 |
| 分布式锁 | String NX | SET key value NX EX 10 |
| 位统计 | Bitmap | 省内存的布尔统计 |
| 计数 | HyperLogLog | 去重统计,12KB 存 2^64 个 |
相关与延伸
下一篇:Redis 持久化与高可用——RDB、AOF、主从、哨兵、Cluster;Redis 内存管理涉及 内存管理;分布式锁见 分布式一致性。
一句话总结
Redis 数据结构:String 底层用 int/embstr/raw(44 字节分界),List 用 quicklist(ziplist 链表),Hash/Set/ZSet 小数据用 ziplist/intset 省内存,大数据用 hashtable/skiplist;BigKey 通过 redis-cli --bigkeys 发现,UNLINK 异步删除,拆分到多个小 Key;选型口诀:单值 String,字段 Hash,队列 List,去重 Set,排序 ZSet。