16 KiB
tags, create time, author
| tags | create time | author | |||
|---|---|---|---|---|---|
|
2026-05-15 18:11 | hhs |
Redis 核心数据类型与底层编码
概述
[!tip] 先别急着背编码,想想你在业务里会怎么用 你用 Redis,第一件事不是去记
ziplist和hashtable的区别——而是搞清楚:什么时候该用哪种数据类型?
Redis 提供五种基础数据类型,用一句话总结各自的核心价值:
| 类型 | 一句话定位 | 最典型的场景 |
|---|---|---|
| String | 万能小抽屉 | 缓存、计数器、分布式锁 |
| Hash | 一个对象的多个字段 | 用户信息、配置项 |
| List | 有序队列 | 消息队列、最新动态 |
| Set | 不重复的袋子 | 标签、点赞、共同好友 |
| ZSet | 带权重的排行榜 | 排行榜、延迟任务 |
[!question] 那"底层编码"又是什么? 你在业务层只管用
String、Hash这些类型——但 Redis 在底层会自动选择最省内存的存储方式。比如同样是String,存数字42只占几个字节,存一段长 JSON 可能几百字节。这就是"编码"在起作用。了解编码不是为了手动管理它,而是帮你预判性能和内存消耗,避免踩坑(比如超大 Hash 的
HGETALL阻塞)。
前置概念:底层编码结构速览
[!tip] 这一节是"词典",不用现在全记住 下面六个概念会在后续各节的「深入」部分反复出现。先混个脸熟,遇到时回来查即可。
| 结构 | 一句话定义 | 用在哪里 |
|---|---|---|
| ziplist | 连续内存的紧凑列表,省空间但查找 O(N) | Hash / List(quicklist 节点) / ZSet(小数据量) |
| listpack | ziplist 的继任者,去掉了 prevlen 彻底解决级联更新 | Redis 7.2+ 替代 ziplist |
| hashtable | 标准哈希表,查找 O(1) | Hash / Set / ZSet 的编码之一 |
| intset | 有序整数数组,纯整数集合时使用 | Set(全为整数时) |
| quicklist | 双向链表,每个节点是一个 ziplist/listpack | List 的统一编码 |
| skiplist | 多层索引的有序链表,范围查询 O(logN) | ZSet(大数据量时) |
| SDS | 兼容 C 的动态字符串,O(1) 求长度、二进制安全、自动扩容 | 所有 Redis key、String 值、缓冲区等 |
想深入了解? 02-核心数据类型/02-5-SDS · 02-核心数据类型/02-1-ziplist与listpack · 02-核心数据类型/02-2-skiplist · 02-核心数据类型/02-3-hashtable · 02-核心数据类型/02-4-quicklist
我们先从最简单的 String 开始,逐个击破。
一、String(字符串)
String 是 Redis 最基础的类型——一个 key 对应一个值,就这么简单。
// 存一个 Token,24 小时过期(原子操作)
rdb.Set(ctx, "token:abc", jwtPayload, 24*time.Hour)
// 原子递增:接口调用计数器,天然线程安全
rdb.Incr(ctx, "counter:api")
// MGET 批量获取:一次网络往返取多个 key,比循环 GET 快得多
vals, _ := rdb.MGet(ctx, "user:1", "user:2", "user:3").Result()
[!question] 为什么 String 还能做计数器?
INCR是 Redis 的原子命令,底层直接操作整数值,不需要"先读再改再写"。100 个请求同时INCR也不会丢数据——这是它比你自己维护计数器靠谱得多的原因。
深入:三种内部编码
同样是 String,Redis 底层用了三种不同的存储方式:
| 编码 | 触发条件 | 典型场景 |
|---|---|---|
int |
值可表示为长整型 | 计数器 (INCR) |
embstr |
≤ 44 字节(Redis 7 之前是 39B) | 短文本、小型 JSON |
raw |
> 44 字节 | 大对象 JSON / Base64 |
[!question] embstr 和 raw 有什么区别? 简单理解:
embstr是"打包好的快递"——key 和 value 的内存连在一起分配,创建销毁都只要一次操作,效率高。raw是"分开打包"——支持值变大后扩容。超过 44 字节时,embstr无法原地扩展,只能切换成raw。你不需要手动管理这些——Redis 根据值的大小自动选择。用
MEMORY USAGE key可以验证实际占用。
[!insight] 一个常见的误解
"42"存进去是int编码,只占几个字节;"hello world"是embstr;一段长 JSON 是raw。所以同一个SET命令,不同值的内存差距可以是几十倍——这不是 Bug,是编码策略在起作用。
二、Hash(哈希表)
Hash 就是一个 key 下面存多个字段——和编程语言里的 Map/字典一模一样。
# 存一个用户对象,每个字段独立存取
HSET user:1 name alice age 25 email a@x.com
HGET user:1 name # → "alice"
HSET user:1 age 26 # 只改 age,其他字段不受影响
[!question] 为什么不直接用 String 存 JSON? String 存 JSON,改一个字段要"读出整个 JSON → 在应用层改 → 写回去"。Hash 是结构化的,
HSET user:1 age 26一行搞定,不需要碰其他字段。这是 Hash 最大的优势。
深入:编码切换
flowchart LR
A["ziplist"] -->|"entries > 512 or value >= 64B"| B["hashtable"]
style A fill:#e1f5fe
style B fill:#fff3e0
阈值由两个独立配置控制(注意是两个参数,不是一个):
hash-max-ziplist-entries 512:Hash 中 field 数量超过 512 → 切 hashtablehash-max-ziplist-value 64:任意一个 field-value 超过 64 字节 → 切 hashtable
[!TIP] 调参建议 在生产环境中,如果对象字段数稳定在 1000 以内且单值较短,可以适当调高
entries到 1024 以充分利用 ziplist 的内存优势。反之,如果频繁出现大字段,建议降低entries让它尽早切到 hashtable,避免 ziplist 的级联更新(cascade update)性能问题。
String vs Hash:怎么选?
| 维度 | String(JSON) | Hash(字段级) |
|---|---|---|
| 读取 | ✅ GET user:1 一行搞定 |
HGETALL user:1 需合并 |
| 局部更新 | ❌ 读 → 改 → 写整个 JSON | ✅ HSET user:1 email b@x.com |
| 内存占用 | 较高(序列化开销) | 较低(ziplist 省 ~40%) |
| 适用场景 | 小数据、读多写少 | 中等大小、频繁部分更新 |
[!caution] 警惕大 Hash
HGETALL在 hashtable 编码下是 O(N),字段数很大时会阻塞 Redis。超过几千个字段,考虑拆成多个 key 或改回 String(JSON)。
三、List(列表)
List 就是有序的队列——从一头进,从另一头出。
// 发布新消息到 Feed 流(从左边插入最新消息)
rdb.LPush(ctx, "news-feed", jsonMessage)
// 修剪到最近 1000 条,防止列表无限增长
rdb.LTrim(ctx, "news-feed", 0, 999)
// 取出最近 10 条展示给用户
items, _ := rdb.LRange(ctx, "news-feed", 0, 9).Result()
[!question] LPUSH + LTRIM 为什么是黄金搭档?
LPUSH总是把最新消息插到最前面,LTRIM把超出的旧消息砍掉。两者配合就是一个固定长度的环形缓冲区——Feed 流、日志轮转的标配模式。
典型场景
| 场景 | 命令 | 说明 |
|---|---|---|
| 消息队列 | LPUSH + BRPOP |
单消费者阻塞式消费 |
| 最新 N 条记录 | LPUSH + LTRIM + LRANGE |
Feed 流、操作日志 |
| 延迟队列 | 用 ZSet 更合适 | List 本身不支持按时间排序 |
深入:quicklist 结构
[!question] 为什么不用普通链表? 普通双向链表每个节点都有 16 字节的指针开销。quicklist 把多个元素打包进一个紧凑块(ziplist),大幅减少指针浪费——就像把散装零食装进密封袋,省空间又好管理。
flowchart LR
H["head"] <--> Z1["ziplist node 1, max 4096 items"]
Z1 <--> Z2["ziplist node 2, max 4096 items"]
Z2 <--> Z3["ziplist node N, max 4096 items"]
Z3 <--> T["tail"]
classDef linkPoint fill:#e8f5e9,stroke:#4caf50
classDef znode fill:#fff3e0,stroke:#ff9800
class H,T linkPoint
class Z1,Z2,Z3 znode
List 统一使用 quicklist,每个节点是一个 ziplist:
list-max-ziplist-size 5:默认每个节点最多 4096 个元素list-compress-depth 0:默认不压缩首尾节点(方便头部操作)
想深入了解? → 02-核心数据类型/02-4-quicklist
四、Set(集合)
Set 就是不重复的集合——同一个元素加多少次都只保留一份。它的杀手锏是集合运算:交集、并集、差集。
// 点赞:同一个用户只能赞一次,重复 SAdd 自动去重
rdb.SAdd(ctx, "like:post:42", "user:100")
// 判断是否已点赞
liked, _ := rdb.SIsMember(ctx, "like:post:42", "user:100").Result()
// 两个帖子的共同点赞者——一行代码搞定,不需要在应用层做交集
common, _ := rdb.SInter(ctx, "like:post:42", "like:post:99").Result()
集合运算示例
SADD tags:post1 go golang redis
SADD tags:post2 redis docker golang
SDIFF tags:post1 tags:post2 # → go — post1 独有的标签
SINTER tags:post1 tags:post2 # → golang redis — 两篇帖子共有的标签
SUNION tags:post1 tags:post2 # → go golang redis docker — 所有标签
[!tip] 集合运算是 Set 区别于其他类型的核心能力 "共同好友"、"猜你喜欢"、"去重统计"——凡是涉及两个集合之间的关系,都该想到 Set。
深入:intset 编码
当 Set 里全是整数时,Redis 会用 intset(整数集合)存储。它的本质是一块连续的、有序的整数数组,支持二分查找(O(logN)),内存极其紧凑——没有指针、没有哈希桶,每个元素只占它自身的字节数。
[!question] intset 比 hashtable 省多少? 存 1000 个整数:hashtable 每个
dictEntry至少 24 字节(key 指针 + value 指针 + next),总计 ~24KB+。intset 用int32存储只需 4KB——省了 80%。
intset 内存布局:
flowchart LR
EN["encoding, 4B, 16/32/64 位"] --> LN["length, 4B, 元素个数"]
LN --> C1["contents, 有序排列的整数数组"]
classDef header fill:#e1f5fe,stroke:#2196f3
classDef data fill:#fff3e0,stroke:#ff9800
class EN,LN header
class C1 data
自动升级机制:当插入一个比当前编码更大的整数时,intset 会升级——把所有元素扩展到更宽的类型(如 int16 → int32),然后插入新元素。这是单向的,不会自动降级。
| 配置项 | 默认值 | 说明 |
|---|---|---|
set-max-intset-entries |
512 | 元素数超过 512 → 切换为 hashtable |
[!caution] intset 一旦遇到非整数就"永久"切换 加入一个字符串后,整个 Set 切成 hashtable,即使后来删掉了那个字符串,也不会切回 intset。所以如果你的 Set 明确只存整数(如用户 ID 集合),别手滑加字符串。
flowchart LR
E["Set 添加元素"] --> Q{"全部是整数?"}
Q -->|"是"| Q2{"元素数 <= 512?"}
Q2 -->|"是"| I["intset, 有序数组, 内存极紧凑"]
Q2 -->|"否"| H["hashtable, O(1) 查找"]
Q -->|"否"| H
classDef encoding fill:#e1f5fe,stroke:#2196f3
classDef encoding2 fill:#fff3e0,stroke:#ff9800
classDef check fill:#e8f5e9,stroke:#4caf50
class I encoding
class H encoding2
class Q,Q2 check
[!tip] SSCAN 避免阻塞
SSCAN可以增量迭代大集合,避免单次命令阻塞 Redis——和 Hash 的HSCAN同理。大集合千万别用SMEMBERS一次全捞。
五、ZSet / Sorted Set(有序集合)
ZSet 是 Redis 中最强大的类型——每个元素都有一个分数(score),天然有序。排行榜、延迟任务、带权重的队列,都是它的主场。
# 添加成员和分数
ZADD leaderboard 100 "player1" 200 "player2" 150 "player3"
# 取 Top 10(按分数从高到低)
ZREVRANGE leaderboard 0 9 WITHSCORES
# 查某个玩家的排名和分数
ZREVRANK leaderboard "player3" # → 2(第 3 名)
ZSCORE leaderboard "player2" # → 200
# 按分数范围查询(Redis 6.2+ 新语法,替代 ZRANGEBYSCORE)
ZRANGE leaderboard 0 150 BYSCORE
Go 示例
// 排行榜加分(原子操作)
rdb.ZIncr(ctx, "leaderboard", redis.Z{
Score: 10.5,
Member: "player42",
})
// 获取 Top 10
members, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result()
for _, m := range members {
fmt.Printf("%s: %.1f\n", m.Member, m.Score)
}
[!warning] 命令迁移提示 Redis 6.2 将
ZRANGEBYSCORE/ZREVRANGEBYSCORE/ZREVRANGE统一合并为ZRANGE的参数形式(BYSCORE/BYLEX/REV)。旧命令仍可用但已弃用,新代码直接用ZRANGE ... BYSCORE。
ZSet vs List 做消息队列
| 维度 | List (BRPOP) | ZSet (BZPOPMIN) |
|---|---|---|
| FIFO 顺序 | ✅ 天然保证 | ⚠️ 依赖 score 设置 |
| 延迟执行 | ❌ 不支持 | ✅ score = 执行时间戳 |
| 重排优先级 | ❌ 需重建列表 | ✅ 改 score 即可 |
| 复杂度 | O(log(N/M)) | O(log N) |
[!tip] 什么时候用 List、什么时候用 ZSet? 简单 FIFO 队列用 List;需要延迟执行、优先级排序、按时间范围查询的场景,用 ZSet。
深入:为什么需要 skiplist + hashtable 两套结构?
flowchart LR
Key["key: leaderboard"] --> SL["skiplist, 按 score 排序, 范围查询 O(log N)"]
Key --> HT["hashtable, 按 member 查找, O(1) 定位"]
classDef primary fill:#e8f5e9,stroke:#4caf50
classDef secondary fill:#fff3e0,stroke:#ff9800
class SL secondary
class HT secondary
skiplist 天然有序,擅长范围查询("给我分数在 100~200 之间的所有成员"),但按 member 名字查找要遍历;hashtable 查找 O(1),但无序。两者互补,代价是每个元素存了两份索引——这是 Redis 中内存开销最大的数据结构。
[!warning] ZSet 性能陷阱 不要往同一个 ZSet 里塞超过百万级别的元素——skiplist 虽然 O(log N),但百万级的维护成本不小。可以按分片拆成多个 ZSet。
快速对照表
| 数据类型 | 编码 | 最佳场景 | 时间复杂度 |
|---|---|---|---|
| String | int / embstr / raw | 计数、缓存、分布式锁 | O(1) |
| Hash | ziplist / hashtable | 对象缓存(部分更新) | O(1)~O(N) |
| List | quicklist | FIFO 队列、feed流 | O(1)~O(N) |
| Set | intset / hashtable | 标签、去重、关系运算 | O(1) |
| ZSet | ziplist / skiplist+ht | 排行榜、延迟任务、权重 | O(log N) |
一句话总结
Redis 在底层用不同的编码策略(ziplist、intset、skiplist 等)来自动优化内存和性能——你不需要手动管理,但理解它能帮你预判行为、避免踩坑。
[!summary] 选型速查
- 缓存对象 → Hash(字段多、常部分更新时)或 String(JSON 整体读写时)
- 计数 / 锁 / Token → String(int 编码最省内存)
- Feed 流 / 队列 → List(LPUSH + LTRIM 黄金搭档)
- 标签 / 去重 / 关系运算 → Set(交集并集差集一步到位)
- 排行榜 / 延时任务 / 带权重 → ZSet(score 是一切的基础)
关联笔记
- 02-核心数据类型/02-5-SDS — 底层编码:SDS 结构与空间优化策略
- 02-核心数据类型/02-1-ziplist与listpack — 底层编码:ziplist 内存布局与级联更新
- 02-核心数据类型/02-2-skiplist — 底层编码:skiplist 原理与 ZSet 双索引设计
- 02-核心数据类型/02-3-hashtable — 底层编码:dict 结构与渐进式 rehash
- 02-核心数据类型/02-4-quicklist — 底层编码:quicklist 结构与 LZF 压缩
- hhs/Redis/08-SortedSet精解 — ZSet 的高级玩法
- hhs/Redis/03-基本命令速查 — 常用命令速查表
- hhs/Redis/07-集群方案 — 集群环境下的大 Key 风险