03-Redis 快的秘密藏在这里:数据类型与底层数据结构
开篇:同样存 100 万条数据,内存为什么能差 10 倍
有这么一个真实场景:两个同事都要在 Redis 里存一批用户信息(每个用户有 id、name、age 几个字段)。
- A 用 String,给每个用户存一个 JSON:
SET user:1 '{"name":"Tom","age":18}' - B 用 Hash:
HSET user:1 name Tom age 18
数据量上到百万级后,B 的方案内存占用明显更小、字段更新更方便。再往后,当某个 Hash 的字段特别多时,内存又会突然涨上去——这背后就是「底层编码」在起作用。
这一章我们把 Redis 的五大基础数据类型过一遍,但重点不在「会用命令」,而在于每种类型底层用什么数据结构、什么时候会自动切换编码。搞懂这个,你才能在面对「该用哪种类型」「为什么这么费内存」「这个操作会不会很慢」时心里有数。
本文导航
| 章节 | 你会学到什么 |
|---|---|
| 一、类型与编码 | 分清数据类型、redisObject、编码和观察命令 |
| 二、五大数据类型 | 逐个理解 String、List、Hash、Set、ZSet 的底层结构 |
| 三、编码自动切换 | 理解 Redis 如何根据阈值在省内存和快操作之间取舍 |
| 四、场景实战 | 用电商业务场景练习数据类型选型,并知道什么时候不该用 |
| 五、常见问题与避坑 | 避开 Hash 内存暴涨、大 key 阻塞、ZSet 精度问题 |
一、先分清类型和编码:你用的是接口,Redis 存的是实现
先建立一个最关键的认知:「数据类型」是给用户看的接口,「编码」是底层的实现。 一种类型可以有多种编码,Redis 会根据数据量大小自动选择,在「省内存」和「快操作」之间权衡。
「数据类型」是给你用的接口。 你执行 HSET user:1 name Tom 时,你只知道「我在用 Hash 这种类型,它支持 HSET/HGET/HDEL 这些命令」。这就是接口——你关心的是「它能干什么、怎么调用」,不关心它在内存里到底长什么样。Redis 对外只暴露 5 种类型:String、List、Hash、Set、ZSet。
「编码」是底层真正的存储实现。 同样是 Hash 这个类型,Redis 内部可能用两种完全不同的数据结构来存:数据少时用 listpack(一块紧凑连续的内存,像个小数组),数据多时换成 hashtable(真正的哈希表,带指针)。你用的命令一个字都没变(还是 HSET/HGET),但底层实现悄悄换了——这就是「一种类型可以有多种编码」。
为什么这样设计?为了在「省内存」和「快操作」之间权衡:
- 数据少时:用 listpack 这类紧凑结构,把几个元素挤在一块连续内存里,没有指针开销、省内存;虽然查找要从头扫(O(N)),但才几个元素,无所谓。
- 数据多时:再用紧凑结构,O(N) 扫描就慢了,于是换成 hashtable,查找 O(1) 快,代价是每个元素多了指针等额外开销(费点内存)。
Redis 会根据数据量自动切换编码,不用你操心。你可以用 OBJECT ENCODING 亲眼看到这个切换:
127.0.0.1:6379> HSET user:1 name Tom age 18
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack" # 字段少,用紧凑的 listpack(省内存)
# 当字段数超过 hash-max-listpack-entries,
# 或某个 field/value 超过 hash-max-listpack-value,自动转
127.0.0.1:6379> OBJECT ENCODING user:1
"hashtable" # 字段多了,换成哈希表(操作快)
1.1 先认识 redisObject:类型和编码是怎么连起来的
Redis 不是直接把一个 value 裸放在内存里。每个 value 外面都会包一层对象头,也就是常说的 redisObject。它负责记录「这个 value 是什么类型」「底层用什么编码」「真正的数据在哪里」。
你可以先把它理解成一个快递面单:
| 字段 | 作用 | 新手理解 |
|---|---|---|
type |
记录对外数据类型,比如 String、List、Hash、Set、ZSet | 决定这个 key 能用哪些命令 |
encoding |
记录内部编码,比如 int、listpack、hashtable、skiplist |
决定底层怎么存、内存高不高、操作快不快 |
ptr |
指向真正的数据结构 | 可能指向 SDS、listpack、quicklist、dict、skiplist 等 |
lru/lfu |
记录访问时间或访问频率 | 内存淘汰策略会用到 |
refcount |
引用计数 | Redis 内部管理对象生命周期会用到 |
1.2 必须会的 3 个观察命令
底层编码不是靠猜的,最好在本地 Redis 里自己敲命令验证。下面 3 个命令会贯穿本章所有实验:
127.0.0.1:6379> TYPE user:1
hash
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack"
127.0.0.1:6379> MEMORY USAGE user:1
(integer) 120
| 命令 | 看什么 | 什么时候用 |
|---|---|---|
TYPE key |
这个 key 对外是什么数据类型 | 先确认能用哪些 Redis 命令 |
OBJECT ENCODING key |
这个 key 内部用了什么编码 | 判断是否触发编码升级 |
MEMORY USAGE key |
这个 key 大约占多少字节 | 对比不同建模方式的内存差异 |
先看一张总览表,建议你读正文前扫一眼,读完后再回来对照:
| 数据类型 | 可能的底层编码 | 切换时机(常见规则,具体以配置为准) |
|---|---|---|
| String | int / embstr / raw |
整数→int;短字符串→embstr;更长→raw |
| List | quicklist(节点内部使用 listpack) |
外层负责双端操作,内部节点用 listpack 节省内存 |
| Hash | listpack / hashtable |
字段数和值长度未超过阈值→listpack;否则→hashtable |
| Set | intset / listpack / hashtable |
全整数且少→intset;少量非整数→listpack;否则→hashtable |
| ZSet | listpack / skiplist |
元素数和成员长度未超过阈值→listpack;否则→skiplist |
1.3 本章底层结构速查表
如果你第一次接触 Redis 源码里的结构名,先不用急着背细节。下面这张表帮你建立第一印象,后面再逐个展开。
| 底层结构 | 一句话理解 | 优点 | 代价 | 常见在哪些类型里 |
|---|---|---|---|---|
| SDS | Redis 自己实现的动态字符串 | O(1) 获取长度、二进制安全 | 比裸 C 字符串多一些元数据 | String |
| ziplist | 老版本使用的紧凑连续内存结构 | 省内存,适合小集合 | 可能发生连锁更新,Redis 7.x 已基本被 listpack 替代 | 老版本 Hash、List、ZSet |
| linkedlist | 老版本使用的普通双向链表 | 两端增删快,结构直观 | 每个节点都有前后指针,内存开销大,缓存不友好 | 老版本大 List |
| listpack | 紧凑连续内存块 | 非常省内存,适合小集合 | 查找和插入通常要顺序扫描 | Hash、ZSet、quicklist 节点内部、小 Set |
| quicklist | 多个 listpack 串起来的双向链表 | 兼顾双端操作和内存紧凑 | 结构比单纯数组复杂 | List |
| hashtable | 哈希表 | 按 key 查找接近 O(1) | 指针和扩容会带来额外内存 | Hash、Set、ZSet 的 member 索引 |
| intset | 整数集合的紧凑编码 | 存少量整数很省内存 | 只能存整数,元素多了会升级 | Set |
| skiplist | 跳表,多层有序链表 | 支持范围查询和排名,O(log N) | 比紧凑结构更占内存 | ZSet |
二、五大数据类型:从命令接口看到底层编码
这一节每种类型都按同一个节奏展开:先讲它能解决什么问题,再讲底层编码,然后给出可复现实验、典型用途和常用命令。你可以把它当成一张选型地图来读,而不是把所有命令一次性背完。
2.1 String:不只是字符串,核心是 SDS
它能存什么:字符串、整数、浮点数、甚至二进制数据(图片、序列化对象),单个 value 最大 512MB。
底层结构 SDS(Simple Dynamic String,简单动态字符串):Redis 没用 C 语言原生的 char*,而是自己造了 SDS。原因:
- O(1) 获取长度:SDS 头部记录了
len,不用像 C 字符串那样遍历到\0。 - 二进制安全:靠
len判断结尾,而不是\0,所以能存任意二进制数据。 - 避免缓冲区溢出 + 减少内存重分配:预分配和惰性释放空间,
APPEND时不用每次都 realloc。
2.1.1 String 的三种编码:int、embstr、raw
String 对外都叫字符串,但 Redis 内部会按内容大小选择不同编码:
| 编码 | 适合什么值 | 内存结构 | 优点 | 代价 |
|---|---|---|---|---|
int |
能用整数表示的字符串,比如 "123" |
整数值直接保存在对象里 | 最省内存,数值自增很快 | 只适合整数;一旦变成非整数会转为字符串编码 |
embstr |
短字符串,常见阈值约 44 字节以内 | redisObject 和 SDS 一次连续分配 |
分配次数少,内存局部性好 | 只读优化,字符串被修改后通常会转成 raw |
raw |
长字符串,或被修改过的字符串 | redisObject 和 SDS 分开分配 |
适合大字符串,扩容更灵活 | 比 embstr 多一次内存分配 |
int:整数直接存,省掉 SDS
当 value 看起来是字符串,但内容能被 Redis 解析成整数时,Redis 可能使用 int 编码。
key: counter
value: "100"
redisObject
type = string
encoding = int
ptr = 100
这时 Redis 不需要额外分配一块 SDS 来保存 "100" 这几个字符,而是直接保存整数值。比如计数器、库存、浏览量这类值,很适合走这个编码。
要注意:int 只是底层编码,对外还是 String 类型,所以你仍然用 GET、SET、INCR 这些 String 命令。
embstr:短字符串一次分配,适合小 value
embstr 可以理解为“短字符串专用布局”。它把 redisObject 和 SDS 放在一块连续内存里,一次内存分配就能完成。
一块连续内存
┌─────────────┬────────────────────┐
│ redisObject │ SDS header + buf │
└─────────────┴────────────────────┘
这样做有两个好处:
- 少一次内存分配:创建对象更快。
- 内存更连续:CPU 访问时更容易命中缓存。
但 embstr 更偏向只读优化。如果你对这个字符串执行 APPEND、SETRANGE 等修改操作,Redis 往往会把它转成 raw,方便后续扩容。
raw:长字符串分开存,扩容更灵活
当字符串比较长,或者短字符串被修改后,Redis 会使用 raw 编码。
redisObject
ptr ───────────────► SDS header + buf
raw 的 redisObject 和 SDS 是分开分配的。它没有 embstr 那么紧凑,但更适合长字符串,因为长字符串后续扩容、释放、重新分配都更灵活。
小结:String 的底层编码不是为了改变命令语义,而是为了让“整数、小字符串、大字符串”分别用最合适的内存布局。
可复现实验:
127.0.0.1:6379> SET n 12345
OK
127.0.0.1:6379> OBJECT ENCODING n
"int" # 纯整数,用 int 编码
127.0.0.1:6379> SET s "hello"
OK
127.0.0.1:6379> OBJECT ENCODING s
"embstr" # 短字符串,embstr
127.0.0.1:6379> SET s2 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
OK
127.0.0.1:6379> OBJECT ENCODING s2
"raw" # 超过 44 字节,转 raw
典型用途:缓存对象(JSON)、计数器(INCR/DECR 是原子操作)、分布式锁(SET key val NX EX)。
# 计数器:原子自增,天然并发安全
127.0.0.1:6379> SET article:1001:views 0
OK
127.0.0.1:6379> INCR article:1001:views
(integer) 1
127.0.0.1:6379> INCRBY article:1001:views 10
(integer) 11
常用命令速览:
127.0.0.1:6379> SET k v # 设值(可加 EX 60 设过期、NX 不存在才设)
127.0.0.1:6379> GET k # 取值,不存在返回 (nil)
127.0.0.1:6379> SETEX k 60 v # 设值同时设 60 秒过期
127.0.0.1:6379> SETNX lock 1 # 不存在才设,返回 1/0(分布式锁雏形)
127.0.0.1:6379> MSET a 1 b 2 # 批量设(一次网络往返)
127.0.0.1:6379> MGET a b # 批量取
127.0.0.1:6379> APPEND k "tail" # 追加,返回追加后总长度
127.0.0.1:6379> STRLEN k # 长度
127.0.0.1:6379> GETRANGE k 0 3 # 取子串 [0,3]
127.0.0.1:6379> INCR pv # 原子 +1,返回结果
127.0.0.1:6379> INCRBY pv 10 # 原子 +10
127.0.0.1:6379> DECR stock # 原子 -1
127.0.0.1:6379> INCRBYFLOAT bal 1.5 # 加浮点
2.2 List:双端列表,底层是 quicklist
它是什么:有序、可重复的元素列表,支持两端进出,适合做队列、栈、最新消息列表。
底层结构演进:早期 List 是 ziplist + linkedlist 双编码;Redis 3.2 引入 quicklist(快速列表)统一;Redis 7.0 起 quicklist 的每个节点内部用 listpack 存放。
先不要急着记版本,先把这几个结构讲清楚:
| 结构 | 它长什么样 | 优点 | 问题 |
|---|---|---|---|
linkedlist |
一个元素一个节点,节点之间用 prev / next 指针串起来 |
两端插入、删除很快,适合队列 | 每个节点都有指针和分配开销,元素很多时很费内存;节点分散在内存里,CPU 缓存命中差 |
ziplist |
一整块连续内存,多个 entry 挨着放 | 非常省内存,适合小 List / 小 Hash / 小 ZSet | entry 里保存“前一个 entry 的长度”,某些插入/修改可能引发连锁更新 |
listpack |
一整块连续内存,多个 entry 挨着放,但 entry 元信息设计更简单 | 继承 ziplist 的省内存优势,同时避免 ziplist 的连锁更新问题 | 本质还是连续内存,按值查找仍然需要顺序扫描 |
quicklist |
外层是双向链表,链表每个节点内部放一个 listpack | 两端操作快,同时每个节点内部又省内存 | 比单一结构复杂,需要控制每个 listpack 节点大小 |
2.2.1 linkedlist:普通双向链表,快但费内存
linkedlist 很好理解:每个元素都是一个独立节点,节点里除了数据,还要保存前驱指针 prev 和后继指针 next。
它的好处是两端插入、删除很快:LPUSH、RPUSH、LPOP、RPOP 都不需要搬动整块数据。但它的问题也明显:如果只存一个很短的字符串,比如 "a",节点里的指针和对象元数据可能比数据本身还大。数据量一多,内存浪费就明显了。
2.2.2 ziplist:连续内存,省内存但怕连锁更新
先抓住一句话:ziplist 是一整块连续内存,每个 entry 都要记录“前一个 entry 有多长”。
它不是链表,不是一个元素一个节点,而是把多个元素紧紧挨在一块内存里:
ziplist 整体结构
┌────────────┬─────────────┬─────────────┬─────────────┬───────┐
│ ziplist 头 │ entry A │ entry B │ entry C │ 结束 │
└────────────┴─────────────┴─────────────┴─────────────┴───────┘
entry 的内部结构
┌─────────┬──────────┬──────┐
│ prevlen │ encoding │ data │
└─────────┴──────────┴──────┘
这 3 个字段分别表示:
| 字段 | 含义 | 为什么需要 |
|---|---|---|
prevlen |
前一个 entry 的总长度 | 支持从后往前遍历 |
encoding |
当前 data 的编码方式和长度信息 | 告诉 Redis 当前值怎么解析 |
data |
当前真正存的数据 | 例如 "a"、"Tom"、100 |
prevlen 是 ziplist 最关键、也最容易出问题的字段。
比如 entry B 前面是 entry A:
entry A 的长度 = 20 字节
entry B.prevlen = 20
从 entry B 往前找:
entry B 的起始位置 - prevlen
= entry B 的起始位置 - 20
= entry A 的起始位置
这样 Redis 就能倒着遍历 ziplist。
问题出在 prevlen 自己也要占空间。
ziplist 为了省内存,对 prevlen 做了变长编码:
| 前一个 entry 的长度 | prevlen 占用 |
|---|---|
| 小于 254 字节 | 1 字节 |
| 大于等于 254 字节 | 5 字节 |
这就带来一个麻烦:如果 entry A 原来很短,后来变长了,entry B 的 prevlen 可能要从 1 字节扩成 5 字节。
原来:
entry A 长度 250 字节
entry B.prevlen 只需要 1 字节
后来:
entry A 变成 260 字节
entry B.prevlen 必须从 1 字节扩成 5 字节
entry B 的 prevlen 变大后,entry B 自己的总长度也变大了。于是 entry C 记录的“前一个 entry 长度”也可能要变大。这样一节影响一节,就叫连锁更新。
1. entry A 变长
↓
2. entry B.prevlen 从 1 字节扩成 5 字节
↓
3. entry B 自己变长
↓
4. entry C.prevlen 也可能要扩
↓
5. 后面的 entry 继续被影响
所以 ziplist 的特点可以这样记:
| 优点 | 缺点 |
|---|---|
| 连续内存,没有大量指针,省内存 | 插入、修改可能触发连锁更新 |
| 小数据量下扫描成本低 | 数据变大后性能可能抖动 |
2.2.3 listpack:替代 ziplist,核心是避免连锁更新
再抓住一句话:listpack 也是连续内存,但每个 entry 记录的是“自己有多长”,不再让后一个 entry 记录前一个 entry 的长度。
listpack 的整体布局还是一整块连续内存:
listpack 整体结构
┌──────────────┬─────────────┬─────────────┬─────────────┬───────┐
│ listpack 头 │ entry A │ entry B │ entry C │ 结束 │
└──────────────┴─────────────┴─────────────┴─────────────┴───────┘
entry 的内部结构
┌──────────┬──────┬─────────┐
│ encoding │ data │ backlen │
└──────────┴──────┴─────────┘
这里的 backlen 可以理解为“当前 entry 自己的总长度”。它放在 entry 的尾部。有些资料会把它写成 element-tot-len,你可以先把它们理解成同一个作用:帮助 Redis 从后往前找到当前 entry 的起始位置。
| 字段 | 含义 | 和 ziplist 的差别 |
|---|---|---|
encoding |
当前 data 的编码方式和长度信息 | 类似 ziplist |
data |
当前真正存的数据 | 类似 ziplist |
backlen |
当前 entry 自己的总长度 | 不再记录前一个 entry 的长度 |
那 listpack 怎么倒着遍历呢?
关键前提是:Redis 在遍历 listpack 时,手里拿着当前 entry 的起始地址指针。假设当前指针叫 B_start,指向 entry B 的第一个字节。
因为 listpack 是连续内存,entry A 和 entry B 是紧挨着的:
内存地址从左到右递增
B_start
│
▼
┌────────────────────┬────────────────────┐
│ entry A │ entry B │
├──────────┬──────┬──┼──────────┬──────┬──┤
│ encoding │ data │backlen │encoding│...│
└──────────┴──────┴──┴──────────┴──────┴──┘
▲
│
B_start - 1
所以 Redis 想从 entry B 往前找 entry A 时,不是“猜”前面那一小段,而是直接从 B_start - 1 开始读。B_start - 1 这个位置,正好落在 entry A 尾部的 backlen 上。
backlen 是变长编码,可能占 1 到多个字节。它的编码方式支持从右往左解析:Redis 从 B_start - 1 开始,往左一个字节一个字节读,直到把完整的 backlen 解析出来。
entry A
┌──────────┬──────┬─────────┐
│ encoding │ data │ backlen │
└──────────┴──────┴─────────┘
▲
│
B_start - 1 先碰到 backlen 的最后一个字节
然后 Redis 按 backlen 的变长编码规则继续向左读
解析出 entry A 的 backlen 后,Redis 就知道 entry A 的总长度。于是 entry A 的起始位置可以直接算出来:
A_total_len = 解析出来的 entry A.backlen
A_start = B_start - A_total_len
所以 listpack 的反向遍历可以拆成 3 步:
- 当前在 entry B,已知
B_start。 - 从
B_start - 1向左解析 entry A 尾部的backlen。 - 用
A_start = B_start - A_total_len找到 entry A 的起始位置。
也就是说,listpack 仍然支持从后往前遍历,但它不需要让 entry B 保存 entry A 的长度;entry A 的长度信息放在 entry A 自己尾部。
这就是它避免连锁更新的关键:
ziplist:
entry A 变长
-> entry B.prevlen 要改
-> entry B 可能变长
-> entry C.prevlen 可能也要改
listpack:
entry A 变长
-> 修改 entry A 自己尾部的 backlen
-> entry B 不保存 A 的长度,所以 B 的元信息不用跟着改
要注意:listpack 仍然是连续内存。如果在中间插入一个更大的 entry,后面的内存仍然可能需要整体移动。它解决的是 ziplist 那种“后面很多 entry 的长度字段都要被迫修改”的问题,而不是把中间插入变成 O(1)。
最后用一张表收住:
| 对比项 | ziplist | listpack |
|---|---|---|
| 是否连续内存 | 是 | 是 |
| entry 记录谁的长度 | 记录前一个 entry 的长度 prevlen |
记录当前 entry 自己的长度 backlen |
| 是否支持反向遍历 | 支持 | 支持 |
| 是否可能连锁更新 | 可能 | 避免了 ziplist 式连锁更新 |
| Redis 7.x 中的地位 | 老结构,基本被替代 | Hash、ZSet、quicklist 节点内部常用 |
2.2.4 quicklist:把链表和 listpack 组合起来
如果只用 linkedlist,内存浪费;如果只用一整块超大的 listpack,中间插入、删除又要移动大量内存。quicklist 的思路是折中:
quicklist = 双向链表 + 每个链表节点内部放一个 listpack
也就是说,List 外层仍然能像链表一样做两端快速操作;但每个节点内部不是只放一个元素,而是放一小段连续的 listpack,减少指针数量和内存碎片。
注意:在 Redis 7.x 里,你用
OBJECT ENCODING查看 List,通常看到的是quicklist。这不代表没有 listpack,而是因为 listpack 藏在 quicklist 节点内部,属于内部实现细节。
图 2:quicklist = 双向链表,每个节点内部是一个紧凑的 listpack。
quicklist 是一个双向链表,链表的每个节点不是单个元素,而是一个 listpack(里面紧凑地存了多个元素)。这样既有链表两端操作快的优点,又有 listpack 内存紧凑的优点,还避免了「一个超长 ziplist 增删元素时连锁更新」的问题。元素少时,quicklist 可能只有一个节点;元素变多后,再拆成多个 quicklist 节点,每个节点内部仍然是 listpack。
可复现实验:
127.0.0.1:6379> RPUSH queue a b c
(integer) 3
127.0.0.1:6379> OBJECT ENCODING queue
"quicklist" # List 外层编码是 quicklist,节点内部用 listpack
127.0.0.1:6379> LRANGE queue 0 -1
1) "a"
2) "b"
3) "c"
# 元素变多后仍然是 quicklist,只是内部 quicklistNode 会变多
127.0.0.1:6379> RPUSH queue $(python3 -c "print(' '.join(str(i) for i in range(200)))")
127.0.0.1:6379> OBJECT ENCODING queue
"quicklist"
典型用途:消息队列(LPUSH + BRPOP 阻塞消费)、最新 N 条记录(LPUSH + LTRIM)。
# 只保留最新 100 条动态
127.0.0.1:6379> LPUSH feed:user:1 "post:9999"
127.0.0.1:6379> LTRIM feed:user:1 0 99 # 裁剪,只留索引 0~99
常用命令速览(这里先列教学中最常用的一组):
127.0.0.1:6379> LPUSH q a b # 左侧入队,返回入队后长度
127.0.0.1:6379> RPUSH q c # 右侧入队
127.0.0.1:6379> LPOP q # 左侧出队,返回元素 / (nil)
127.0.0.1:6379> RPOP q # 右侧出队
127.0.0.1:6379> LLEN q # 长度
127.0.0.1:6379> LRANGE q 0 -1 # 取区间(0 -1 全部,大列表慎用)
127.0.0.1:6379> LINDEX q 0 # 按下标取
127.0.0.1:6379> LREM q 1 a # 删 1 个值为 a 的元素
127.0.0.1:6379> BRPOP q 5 # 阻塞右出队,最多等 5 秒(做消息队列)
2.3 Hash:字段-值映射,小对象更省内存
它是什么:一个 key 下面挂多个 field → value,天然适合存对象。
两种编码:字段少且值短时用 listpack(紧凑省内存);超过阈值转 hashtable(标准哈希表,操作 O(1) 但有指针开销)。
| 编码 | 适合什么 Hash | 存储方式 | 查询复杂度 | 主要特点 |
|---|---|---|---|---|
listpack |
字段少、field/value 都比较短 | field、value 交替放在连续内存里 | HGET 需要顺序扫描,O(N) |
极省内存,适合小对象 |
hashtable |
字段多,或某个 field/value 很长 | 用哈希表保存 field -> value | 平均 O(1) | 查询快,但指针和元数据更多 |
图 3:Hash 的两种编码。少量字段用紧凑的 listpack,连续内存更省;超过阈值后升级为 hashtable,查找更快,但会增加指针和元数据开销。
2.3.1 listpack 编码:小对象为什么省内存
Hash 使用 listpack 时,不是每个 field 都单独分配一个对象,而是把 field 和 value 交替塞进一块连续内存:
Hash key: user:1
listpack:
┌──────┬─────┬─────┬────┬──────┬─────────┐
│ name │ Tom │ age │ 18 │ city │ Beijing │
└──────┴─────┴─────┴────┴──────┴─────────┘
field value field value field value
这样存的好处是:字段少的时候,没有哈希桶、entry 指针、next 指针这些额外开销,内存非常紧凑。比如用户资料、商品详情这类“小对象”,用 Hash 往往比用 String 存 JSON 更省内存,而且还能单独更新某个字段。
但它的代价也很清楚:查找 HGET user:1 city 时,Redis 要从 listpack 头部开始顺序扫描:
找 city
-> 看 name,不是
-> 跳过 Tom
-> 看 age,不是
-> 跳过 18
-> 看 city,命中
-> 返回 Beijing
字段少时这点扫描成本可以忽略;字段很多时,继续顺序扫就不划算了。
2.3.2 hashtable 编码:字段多时为什么查询更快
当 Hash 字段数超过阈值,或者某个 field/value 太长,Redis 会把 Hash 升级为 hashtable。
dict buckets
┌───────┬───────┬───────┬───────┐
│ bucket│ bucket│ bucket│ bucket│
└───┬───┴───┬───┴───┬───┴───┬───┘
│ │ │ │
▼ ▼ ▼ ▼
name age city ...
│ │ │
▼ ▼ ▼
Tom 18 Beijing
这时 Redis 会根据 field 计算哈希值,定位到对应 bucket,再找到 field 对应的 value。大多数情况下,HGET、HSET、HDEL 都接近 O(1)。
hashtable 的代价是内存:它需要桶数组、dictEntry、指针等额外结构。字段越多,这些元数据越明显。所以你会看到一个现象:Hash 小的时候很省内存;超过阈值升级后,内存可能突然上涨。
小结:Hash 的核心取舍是“字段少时用连续内存省空间,字段多时用哈希表换查询速度”。
可复现实验:
127.0.0.1:6379> HSET user:1 name Tom age 18 city Beijing
(integer) 3
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack"
127.0.0.1:6379> HGET user:1 name
"Tom"
127.0.0.1:6379> HGETALL user:1
1) "name"
2) "Tom"
3) "age"
4) "18"
5) "city"
6) "Beijing"
如果你想亲眼看到 Hash 从 listpack 升级到 hashtable,可以先查看当前阈值,再批量写入超过阈值的字段:
redis-cli CONFIG GET hash-max-listpack-entries
# 示例:如果本机阈值是 512,就写入 600 个字段。
for i in $(seq 1 600); do
redis-cli HSET user:big "f$i" "v$i" > /dev/null
done
redis-cli OBJECT ENCODING user:big
# 可能返回:"hashtable"
典型用途:存对象(比 String 存 JSON 更省、可单字段更新)、购物车(key=用户,field=商品 id,value=数量)。
常用命令速览(这里先列教学中最常用的一组):
127.0.0.1:6379> HSET user:1 name Tom age 18 # 设字段,返回新增字段数
127.0.0.1:6379> HGET user:1 name # 取一个字段
127.0.0.1:6379> HMGET user:1 name age # 取多个字段
127.0.0.1:6379> HDEL user:1 age # 删字段
127.0.0.1:6379> HEXISTS user:1 name # 字段是否存在,返回 1/0
127.0.0.1:6379> HLEN user:1 # 字段数量
127.0.0.1:6379> HKEYS user:1 # 所有字段名
127.0.0.1:6379> HVALS user:1 # 所有字段值
127.0.0.1:6379> HINCRBY user:1 age 1 # 字段原子 +1(购物车改数量常用)
127.0.0.1:6379> HSCAN user:1 0 # 渐进遍历字段(替代 HGETALL)
2.4 Set:无序去重集合,重点看编码升级
它是什么:无序、元素唯一的集合,支持交集、并集、差集运算。
三种编码:全是整数且数量少→intset(整数集合,有序数组,省内存);少量非整数→listpack(7.2 起);超阈值→hashtable。
| 编码 | 适合什么 Set | 存储方式 | 优点 | 触发升级的常见原因 |
|---|---|---|---|---|
intset |
少量整数元素 | 有序整数数组 | 极省内存,整数会按大小排序 | 加入非整数,或整数数量超过阈值 |
listpack |
少量非整数元素(Redis 7.2+ 常见) | 连续内存顺序保存成员 | 比 hashtable 更省内存 | 成员数量或成员长度超过阈值 |
hashtable |
大集合 | 用哈希表保存 member | SISMEMBER 平均 O(1) |
大多数大 Set 最终都会使用它 |
版本提示:老版本 Redis 里,Set 从
intset遇到非整数后可能直接升级到hashtable。Redis 7.2+ 引入小 Set 的 listpack 编码后,少量非整数 Set 也可以先用 listpack。实际以OBJECT ENCODING观察结果为准。
图 4:Set 的三种编码升级路径。纯整数小集合用 intset;出现少量非整数时用 listpack;集合变大后升级为 hashtable。
2.4.1 intset 编码:整数小集合为什么这么省
intset 专门用来存少量整数集合。它本质上是一块连续数组,而且数组里的整数会按从小到大排序。
SADD ids 3 1 2
intset:
┌───┬───┬───┐
│ 1 │ 2 │ 3 │
└───┴───┴───┘
它有两个关键点:
- 只存整数:比如
1、2、10086。 - 自动选择整数宽度:刚开始可能用较小的整数宽度;如果加入更大的整数,会整体升级为更大的整数宽度。
例如原来都是小整数,后来加入一个很大的整数:
[1, 2, 3]
-> 加入 5000000000
-> 整个 intset 的整数编码宽度升级
这个升级只是整数宽度变化,仍然是 intset。但如果加入 "redis" 这种非整数成员,就不能再用 intset 了。
2.4.2 listpack 编码:少量非整数成员也可以先省内存
Set 的成员如果不是纯整数,比如 "redis"、"mysql",在支持 listpack 小 Set 的 Redis 版本里,Redis 可以把少量成员放进 listpack:
Set key: tags
listpack:
┌───────┬───────┬──────┐
│ redis │ mysql │ java │
└───────┴───────┴──────┘
这种方式仍然是连续内存,没有哈希表那么多指针开销,适合“小而短”的集合。但它判断成员是否存在时,需要顺序扫描,所以集合变大后就不适合了。
2.4.3 hashtable 编码:大 Set 为什么查得快
当 Set 元素很多,Redis 会使用 hashtable。Set 的 hashtable 只关心 member 是否存在,不需要保存 value,所以可以把 member 放进哈希表里做快速判断。
Set key: tags
hashtable:
bucket[0] -> redis
bucket[1] -> mysql
bucket[2] -> java
这时 SISMEMBER tags redis 这类判断成员是否存在的命令,平均复杂度接近 O(1)。代价同样是内存更多:哈希桶、entry、指针都会占空间。
小结:Set 的编码选择取决于两个问题:是不是全整数?元素多不多?全整数小集合用
intset,小非整数集合可能用listpack,大集合最终用hashtable。
可复现实验:
127.0.0.1:6379> SADD ids 1 2 3
(integer) 3
127.0.0.1:6379> OBJECT ENCODING ids
"intset" # 全整数,intset
127.0.0.1:6379> SADD tags redis mysql
(integer) 2
127.0.0.1:6379> OBJECT ENCODING tags
"listpack" # Redis 7.2+ 常见;老版本可能直接是 hashtable
# 集合运算:共同关注
127.0.0.1:6379> SADD user:1:follow 100 200 300
127.0.0.1:6379> SADD user:2:follow 200 300 400
127.0.0.1:6379> SINTER user:1:follow user:2:follow # 交集 = 共同关注
1) "200"
2) "300"
典型用途:去重(UV 统计、抽奖去重)、共同好友/关注(集合运算)、标签。
常用命令速览(这里先列教学中最常用的一组):
127.0.0.1:6379> SADD s a b c # 添加成员,返回新增数
127.0.0.1:6379> SREM s a # 删除成员
127.0.0.1:6379> SISMEMBER s b # 是否是成员,返回 1/0(点赞判重常用)
127.0.0.1:6379> SCARD s # 成员数量
127.0.0.1:6379> SMEMBERS s # 取所有成员(大 Set 慎用,改用 SSCAN)
127.0.0.1:6379> SPOP s 1 # 随机弹出并删除(抽奖)
127.0.0.1:6379> SRANDMEMBER s 2 # 随机返回但不删除
127.0.0.1:6379> SINTER s1 s2 # 交集(共同关注)
127.0.0.1:6379> SUNION s1 s2 # 并集
127.0.0.1:6379> SDIFF s1 s2 # 差集(s1 有 s2 没有)
2.5 ZSet:带分数的有序集合,排行榜的核心结构
它是什么:ZSet 可以理解成“带分数的 Set”。每个 member 都有一个 score,Redis 会按 score 维护顺序,所以它特别适合排行榜、热搜榜、延迟队列这类场景。
图 5:ZSet 的第一层理解。它不是普通 Set,而是每个成员都带一个分数,Redis 根据分数维护顺序。
两种编码:ZSet 小的时候用 listpack 省内存;变大后用 skiplist + hashtable 提升查询和排序效率。
| 编码 | 适合什么 | 怎么存 | 优点 | 代价 |
|---|---|---|---|---|
listpack |
元素少、member 短 | member, score 连续紧凑存放 |
省内存 | 查找和插入要顺序扫描/移动 |
skiplist |
元素多、频繁排行/范围查询 | hashtable 查 member,skiplist 管排序 |
查分数快,范围查询快 | 维护两套结构,更占内存 |
图 6:ZSet 的底层编码。小数据用 listpack 省空间;大数据用 hashtable + skiplist,用更多内存换更快查询。
2.5.1 小 ZSet:listpack 为什么省内存
当 ZSet 元素少、member 也短时,Redis 会把 member 和 score 紧凑地放在一块连续内存里:
listpack:
┌───────┬─────┬───────┬────┬──────┬────┐
│ Jerry │ 95 │ Tom │100 │ Lucy │120 │
└───────┴─────┴───────┴────┴──────┴────┘
member score member score member score
这种方式没有跳表节点、层级指针和哈希表 entry,所以很省内存。缺点是元素多了以后,查找某个 member 或插入新分数需要顺序扫描,性能会下降。
2.5.2 大 ZSet:为什么需要 hashtable + skiplist
大 ZSet 要同时支持两类高频操作:
| 操作 | 需要什么能力 | 由谁负责 |
|---|---|---|
ZSCORE rank Tom |
按 member 快速查 score | hashtable |
ZREVRANGE rank 0 9 |
按 score 排序取 TopN | skiplist |
ZRANGEBYSCORE rank 60 100 |
按分数范围查询 | skiplist |
ZADD rank 110 Jack |
判断是否已存在,并插入到正确排序位置 | hashtable + skiplist |
所以大 ZSet 会同时维护两套结构:
hashtable:负责按 member 查
Tom -> 100
Jerry -> 95
Lucy -> 120
skiplist:负责按 score 排序
95 Jerry -> 100 Tom -> 120 Lucy
一句话记住:hashtable 负责“按人查分”,skiplist 负责“按分排序”。
2.5.3 跳表怎么查得快:能向右就向右,不能向右就下沉
跳表可以理解成“普通有序链表 + 多层快捷索引”。底层保存所有节点,上层只保留一部分节点作为索引。
图 7:跳表查找
21的过程。先在高层快速跳到17,发现下一步25会超过目标,就下沉到底层,再走到21。
查找规则只有一句:能向右就向右,不能向右就下沉。
以查找 21 为例:
1. 从最高层 head 开始
2. 右边是 1,没超过 21,向右
3. 右边是 17,没超过 21,继续向右
4. 再往右会到 25,超过 21,不能向右,向下沉
5. 到底层后,从 17 向右一步找到 21
跳表不是每次都从头扫完整链表,而是借助高层索引跳过大量节点,所以查询、插入、删除的平均复杂度都是 O(log N)。Redis 用随机方式决定新节点有几层,常见晋升概率约为 1/4,因此高层更稀疏、底层最完整。
小结:ZSet 小的时候用 listpack 省内存;大了以后用 hashtable + skiplist。hashtable 让
ZSCORE快,skiplist 让排行榜和范围查询快。
可复现实验——实时排行榜:
127.0.0.1:6379> ZADD rank 100 Tom 95 Jerry 88 Spike
(integer) 3
127.0.0.1:6379> OBJECT ENCODING rank
"listpack"
# 查排名前 3(从高到低):WITHSCORES 带出分数
127.0.0.1:6379> ZREVRANGE rank 0 2 WITHSCORES
1) "Tom"
2) "100"
3) "Jerry"
4) "95"
5) "Spike"
6) "88"
# 给 Tom 加 5 分
127.0.0.1:6379> ZINCRBY rank 5 Tom
"105"
# 查 Jerry 的排名(0 开始,从高到低用 ZREVRANK)
127.0.0.1:6379> ZREVRANK rank Jerry
(integer) 1
典型用途:排行榜、延迟队列(score 存执行时间戳)、范围检索。
常用命令速览:
127.0.0.1:6379> ZADD rank 100 Tom # 添加/更新成员及分数
127.0.0.1:6379> ZSCORE rank Tom # 查成员分数
127.0.0.1:6379> ZINCRBY rank 5 Tom # 分数 +5,返回新分数
127.0.0.1:6379> ZCARD rank # 成员数量
127.0.0.1:6379> ZRANK rank Tom # 升序排名(最低分第 0)
127.0.0.1:6379> ZREVRANK rank Tom # 降序排名(最高分第 0,排行榜常用)
127.0.0.1:6379> ZREVRANGE rank 0 9 WITHSCORES # Top10(降序,带分数)
127.0.0.1:6379> ZRANGEBYSCORE rank 60 100 # 按分数区间取 [60,100]
127.0.0.1:6379> ZCOUNT rank 60 100 # 分数区间内的成员数
127.0.0.1:6379> ZREM rank Spike # 删除成员
127.0.0.1:6379> ZPOPMAX rank # 弹出最高分成员
三、编码自动切换:Redis 如何在省内存和快操作之间取舍
Redis 在「内存效率」和「操作效率」之间做权衡:
- 数据少时,用 listpack / intset 这类紧凑连续的结构。它们把数据塞在一块连续内存里,没有指针开销,内存利用率高;虽然增删是 O(N),但 N 很小,无所谓。
- 数据多时,切换成 hashtable / skiplist / quicklist 这类带指针的结构。它们操作复杂度低(O(1) 或 O(log N)),代价是每个元素有额外的指针和元数据开销。
切换是单向的、不可逆的:一旦从 listpack 升级到 hashtable,即使后来删到只剩几个元素,也不会降回 listpack。原因是来回切换有性能成本,且实际场景里数据通常只增不减。
3.1 常见编码切换阈值:到底是什么触发了升级
编码升级通常由两个因素触发:
- 元素数量变多:比如 Hash 的 field 数、ZSet 的 member 数超过阈值。
- 单个元素太大:比如 Hash 的某个 field/value 太长,ZSet 的某个 member 太长。
Redis 的判断逻辑可以简化成一句话:
只要“数量”或“单个元素长度”有一个超过阈值,
就不再适合继续用紧凑编码,
Redis 会升级成 hashtable / skiplist / 多节点 quicklist 这类结构。
下面这张表按 Redis 7.2 常见默认值整理。不同小版本可能略有差异,生产环境以你机器上的 redis.conf 和 CONFIG GET 为准。
| 数据类型 | 省内存编码 | 升级后编码 | 默认阈值(Redis 7.2 常见) | 触发升级的意思 |
|---|---|---|---|---|
| String | int / embstr |
raw |
embstr 常见约 44 字节;不是配置项 |
纯整数可用 int;短字符串可用 embstr;字符串变长或被修改后可能转 raw |
| List | quicklist 节点内部的 listpack | 仍是 quicklist,只是节点变多或节点被压缩 | list-max-listpack-size -2,约表示单个 listpack 节点最大 8KB;list-compress-depth 0 默认不压缩 |
List 外层一直是 quicklist;阈值控制每个 quicklistNode 里 listpack 放多大 |
| Hash | listpack |
hashtable |
hash-max-listpack-entries 512;hash-max-listpack-value 64 |
field 数超过 512,或任意 field/value 超过 64 字节,就升级为 hashtable |
| Set(整数) | intset |
hashtable |
set-max-intset-entries 512 |
全是整数且数量不超过 512,用 intset;整数数量超过 512 后升级 |
| Set(非整数小集合) | listpack |
hashtable |
set-max-listpack-entries 128;set-max-listpack-value 64 |
非整数成员数量超过 128,或任意 member 超过 64 字节,就升级为 hashtable |
| ZSet | listpack |
skiplist + hashtable |
zset-max-listpack-entries 128;zset-max-listpack-value 64 |
member 数超过 128,或任意 member 超过 64 字节,就升级为 skiplist + hashtable |
注意:这些阈值不是“业务建议上限”,而是“底层编码切换线”。比如 Hash 不是说最多只能放 512 个字段,而是超过这个数量后,底层会从 listpack 变成 hashtable。
怎么查看你本机的真实默认值
最稳的方式不是背表,而是在 Redis 里查配置:
127.0.0.1:6379> CONFIG GET hash-max-listpack-*
1) "hash-max-listpack-value"
2) "64"
3) "hash-max-listpack-entries"
4) "512"
127.0.0.1:6379> CONFIG GET zset-max-listpack-*
1) "zset-max-listpack-value"
2) "64"
3) "zset-max-listpack-entries"
4) "128"
127.0.0.1:6379> CONFIG GET set-max-*
1) "set-max-intset-entries"
2) "512"
3) "set-max-listpack-entries"
4) "128"
5) "set-max-listpack-value"
6) "64"
127.0.0.1:6379> CONFIG GET list-*
1) "list-max-listpack-size"
2) "-2"
3) "list-compress-depth"
4) "0"
几个具体例子
例子 1:Hash 为什么突然从 listpack 变成 hashtable
hash-max-listpack-entries = 512
hash-max-listpack-value = 64
情况 A:Hash 有 100 个字段,每个 value 都很短
-> 没超过 entries,也没超过 value
-> 继续用 listpack
情况 B:Hash 有 513 个字段,每个 value 都很短
-> 字段数超过 512
-> 升级为 hashtable
情况 C:Hash 只有 3 个字段,但某个 value 长度是 100 字节
-> 单个 value 超过 64 字节
-> 升级为 hashtable
例子 2:ZSet 为什么排行榜大了会变成 skiplist
zset-max-listpack-entries = 128
zset-max-listpack-value = 64
情况 A:排行榜只有 50 人,member 都很短
-> listpack
情况 B:排行榜有 200 人
-> member 数超过 128
-> 升级为 skiplist + hashtable
情况 C:排行榜只有 10 人,但某个 member 特别长
-> member 长度超过 64 字节
-> 升级为 skiplist + hashtable
例子 3:List 的阈值不是“listpack 变 quicklist”
List 这里最容易误解。Redis 7.x 里,List 外层通常就是 quicklist;list-max-listpack-size 控制的是每个 quicklistNode 内部的 listpack 可以有多大。
list-max-listpack-size = -2
含义:
-2 不是负数个元素,
而是一个特殊配置值,
通常表示单个 listpack 节点大小控制在 8KB 左右。
常见特殊值可以这样记:
| 配置值 | 含义 |
|---|---|
-1 |
每个 listpack 节点约 4KB |
-2 |
每个 listpack 节点约 8KB,默认值 |
-3 |
每个 listpack 节点约 16KB |
-4 |
每个 listpack 节点约 32KB |
-5 |
每个 listpack 节点约 64KB |
所以 List 的变化更准确地说是:
元素变多
-> quicklist 里的 quicklistNode 变多
-> 每个 quicklistNode 内部仍然用 listpack 紧凑存储
你可能会在不同资料里看到不同的默认数字,比如 Hash entries 有人写 512,有人写 128。这里不要死记数字:理解触发条件比背阈值更重要,真正上线前用
CONFIG GET查自己的 Redis 版本。
图 8:编码切换是「省内存 → 快操作」的单向升级。超过阈值后会自动升级,但删除数据后不会自动降回原来的紧凑编码。
为什么 7.0 要用 listpack 替代 ziplist:ziplist 每个元素记录了「前一个元素的长度」,当中间插入/删除一个元素导致长度跨越编码边界时,可能引发连锁更新(cascade update)——后面所有元素都要重新调整,最坏 O(N²)。listpack 改了元数据设计,每个元素只记录自身长度,从根本上消除了连锁更新问题。
3.2 重点:编码切换会影响什么
编码切换不是冷知识,它会直接影响 3 件事:
- 内存占用:listpack、intset 这类紧凑结构更省内存;hashtable、skiplist 这类结构会多出指针和元数据。
- 命令耗时:小数据量顺序扫描没问题;数据量变大后,需要 hashtable 或 skiplist 降低查询、插入、范围检索的复杂度。
- 故障排查方向:当你发现某个 key 内存突然变大,第一反应应该是用
OBJECT ENCODING看看是不是从紧凑编码升级了。
一个常见排查流程是:
发现 Redis 内存上涨
-> 用 redis-cli --bigkeys 找大 key
-> 用 TYPE 看数据类型
-> 用 OBJECT ENCODING 看是否发生编码升级
-> 用 MEMORY USAGE 对比单 key 占用
-> 决定拆 key、换类型,或调整 listpack 阈值
四、场景实战:电商系统该怎么选数据类型
场景背景:假设你要给一个电商系统设计 Redis 缓存和实时数据模型。这里不只看“能不能实现”,还要看数据量上来后会不会变成大 key、会不会阻塞、能不能按业务维度拆分。
4.1 先给结论:电商常见场景选型表
下面这张表建议按三列重点看:先看典型数据量,再看推荐类型,最后看风险点 / 拆分建议。真实项目里,选型往往不是因为某个类型“能实现”,而是因为它在当前数据量下更稳。
| 业务场景 | 典型数据量 | 推荐类型 | Key 设计示例 | 为什么这样选 | 风险点 / 拆分建议 |
|---|---|---|---|---|---|
| 商品详情缓存 | 单商品 10~100 个字段;全站百万级商品 | Hash | product:{skuId} |
可单字段更新,适合商品标题、价格、库存展示字段 | 单个商品字段不要无限扩;长描述、富文本建议单独存 String 或走对象存储 |
| 商品详情整包缓存 | value 较大,但整体读写 | String | product:json:{skuId} |
一次 GET 就能拿完整 JSON,适合读多写少 | 局部字段更新麻烦;大 JSON 会造成网络和内存压力 |
| 商品浏览量 | 单商品高频自增 | String | product:pv:{skuId} |
INCR 原子自增,简单高效 |
超高并发可先本地聚合或分片计数,如 product:pv:{skuId}:{shard} |
| 库存扣减 | 单 SKU 高频扣减 | String | stock:{skuId} |
DECR / Lua 脚本可做原子校验扣减 |
秒杀场景必须防止超卖,建议 Lua 保证判断和扣减原子性 |
| 购物车 | 单用户几十到几百个商品 | Hash | cart:{userId} |
field=skuId,value=数量,天然适合改数量 | 超大购物车要限制上限;长期不活跃用户要设置过期或落库 |
| 用户最近浏览 | 每用户保留最近 50~200 条 | List | history:{userId} |
LPUSH + LTRIM 保留顺序并控制长度 |
不要 LRANGE 0 -1 拉超长列表;每次写入后裁剪 |
| 用户收藏商品 | 每用户几十到几千个商品 | Set | fav:{userId} |
自动去重,SISMEMBER 判断是否收藏很快 |
收藏过多时避免 SMEMBERS 全量拉取,改用 SSCAN |
| 商品标签 | 单商品少量标签;单标签大量商品 | Set | product:tags:{skuId} / tag:products:{tag} |
去重、交并差方便 | 标签下商品很多时,分页不要用 SMEMBERS 全量 |
| 热销榜 / 销量榜 | 每个榜单 Top100~Top10000 | ZSet | rank:sales:{categoryId} |
score=销量,member=skuId,天然支持 TopN | 大榜单要按类目、时间拆分,不要全站一个无限大 ZSet |
| 搜索热词榜 | 每日/每小时几千到几十万词 | ZSet | rank:search:{yyyyMMdd} |
ZINCRBY 累加热度,ZREVRANGE 查 TopN |
按天或小时拆 key,避免长期无限增长 |
| 优惠券领取去重 | 单券几万到几百万用户 | Set | coupon:users:{couponId} |
SADD 返回值可判断是否首次领取 |
超大 Set 可能很占内存;只判断是否领过可考虑 Bitmap 或布隆过滤器 |
| 用户签到 | 每用户一年 365 天 | Bitmap(基于 String) | sign:{userId}:{yyyy} |
一天一位,极省内存 | 适合二值状态;不适合存复杂签到信息 |
| 秒杀资格名单 | 几万到几百万用户 ID | Set / Bitmap | seckill:allow:{activityId} |
Set 适合精确名单;Bitmap 适合数字型用户 ID | Set 内存高;Bitmap 要求 ID 范围可控 |
| 延迟订单关闭 | 待关闭订单数从几千到百万 | ZSet | delay:order:close |
score=执行时间戳,按时间范围拉取到期订单 | 消费端要分批 ZRANGEBYSCORE,处理后及时 ZREM |
| 用户 Session / Token | 每用户 1~多个 token | String / Hash | token:{token} / session:{userId} |
String 适合 token->userId;Hash 适合 session 多字段 | 必须设置过期时间;敏感信息不要直接明文存 |
经验规则:单个 key 的元素数量不要无脑增长。Redis 很快,但大 key 会让
HGETALL、SMEMBERS、LRANGE 0 -1、ZRANGE 0 -1这类命令变成阻塞风险。
4.2 按数据量调整方案:小数据、中数据、大数据不是一种设计
同一个业务,数据量不同,选型也会变。
| 场景 | 小数据量 | 中等数据量 | 大数据量 |
|---|---|---|---|
| 商品详情 | Hash product:{skuId} |
仍用 Hash,但控制字段数量和字段长度 | 拆冷热字段:基础字段 Hash,长描述 String/对象存储 |
| 浏览历史 | List history:{userId} 保留 50 条 |
保留 100~200 条,写入后 LTRIM |
历史全量落库,Redis 只保留最近 N 条 |
| 收藏商品 | Set fav:{userId} |
SSCAN 分页,避免全量取 |
按用户维度拆分或落库分页,Redis 只做是否收藏判断 |
| 销量榜 | 一个 ZSet rank:sales |
按类目拆:rank:sales:{categoryId} |
按类目 + 时间拆:rank:sales:{categoryId}:{yyyyMMdd} |
| 优惠券领取 | Set coupon:users:{couponId} |
大券使用 Set,但只做判重不全量读 | 只判重可用 Bitmap;精确明细落库 |
| 秒杀库存 | String stock:{skuId} |
Lua 原子扣减 | 分片库存 + Lua + 异步落库,避免单 key 热点 |
4.3 典型场景怎么写:几个可直接套用的模型
商品详情:字段经常局部更新,用 Hash
127.0.0.1:6379> HSET product:1001 title "iPhone" price 5999 stock 100 status on
(integer) 4
127.0.0.1:6379> HGET product:1001 price
"5999"
127.0.0.1:6379> HINCRBY product:1001 stock -1
(integer) 99
适合:商品标题、价格、库存展示值、上下架状态等字段。
不适合:商品详情富文本、超长图片列表,这类字段会让 Hash 更容易超过 listpack 阈值。
购物车:一个用户一个 Hash
127.0.0.1:6379> HSET cart:10086 sku:1 2 sku:2 1
(integer) 2
127.0.0.1:6379> HINCRBY cart:10086 sku:1 1
(integer) 3
127.0.0.1:6379> HDEL cart:10086 sku:2
(integer) 1
这里 field=skuId,value=购买数量。购物车天然适合 Hash,因为经常改某个商品的数量。
最近浏览:List 保留最近 N 条
127.0.0.1:6379> LPUSH history:10086 sku:9
(integer) 1
127.0.0.1:6379> LTRIM history:10086 0 49
OK
127.0.0.1:6379> LRANGE history:10086 0 9
注意:浏览历史不要无限增长。Redis 只放最近 50 或 100 条,全量历史交给数据库。
热销榜:ZSet 按销量排序
127.0.0.1:6379> ZINCRBY rank:sales:phone 1 sku:1001
"1"
127.0.0.1:6379> ZINCRBY rank:sales:phone 10 sku:1002
"10"
127.0.0.1:6379> ZREVRANGE rank:sales:phone 0 9 WITHSCORES
如果是全站榜,key 可以是 rank:sales:all:{yyyyMMdd};如果是类目榜,key 可以是 rank:sales:{categoryId}:{yyyyMMdd}。不要把所有年份、所有类目的销量都塞进一个 ZSet。
延迟订单关闭:ZSet 的 score 存时间戳
# 订单 30 分钟后关闭,score 存关闭时间戳
127.0.0.1:6379> ZADD delay:order:close 1735689600 order:1001
(integer) 1
# 消费端每次取到期订单,分批处理
127.0.0.1:6379> ZRANGEBYSCORE delay:order:close 0 1735689600 LIMIT 0 100
处理完成后要 ZREM delay:order:close order:1001,否则会重复扫描。
结果验证:用 OBJECT ENCODING 和 MEMORY USAGE key 观察实际编码与内存占用,对照预期:
127.0.0.1:6379> HSET product:1 title "iPhone" price 5999 stock 100
(integer) 3
127.0.0.1:6379> MEMORY USAGE product:1 # 查看这个 key 占多少字节
(integer) 120
4.4 选型时不要只问“能不能实现”,还要问“会不会踩坑”
很多 Redis 建模问题,第一版都能实现,差别在数据量上来后会不会变慢、变贵、难维护。下面这张表更适合做日常选型:
| 数据类型 | 适合什么 | 不适合什么 / 风险点 |
|---|---|---|
| String | 简单缓存、计数器、分布式锁值、Bitmap 位图 | 大对象 JSON 频繁改局部字段时不方便;单 value 最大 512MB,但不建议接近这个上限 |
| List | 消息列表、最近浏览、简单队列、按插入顺序保留数据 | 不适合按中间元素随机查询;超大 List 做 LRANGE 0 -1 容易阻塞 |
| Hash | 对象缓存、购物车、用户资料、商品详情 | 单个 Hash 字段太多会变成大 key;字段很多时可能从 listpack 升级到 hashtable |
| Set | 去重集合、标签集合、共同好友、交并差 | SMEMBERS 一次取全量有风险;无序,不适合排行榜 |
| ZSet | 排行榜、延迟队列、按时间/分数范围查询 | score 是 double,超大整数有精度风险;成员过多时要注意内存 |
4.5 选型口诀
- 只存一个值:优先 String。
- 存一个对象,并且要改局部字段:优先 Hash。
- 保留插入顺序,两端进出:优先 List。
- 只关心去重和集合运算:优先 Set。
- 既要去重,又要排序或范围查询:优先 ZSet。
- 数据量很大时:先问会不会形成大 key,再决定拆分策略。
五、常见问题与避坑:类型选错会发生什么
问题 1:Hash 字段太多,内存为什么会暴涨
- 现象:本以为 Hash 省内存,结果某些 key 内存翻倍。
- 原因:字段数超过
hash-max-listpack-entries,或字段/值长度超过hash-max-listpack-value后转成 hashtable,每个字段多出指针开销。 - 解决:若单个 Hash 字段过多,考虑拆分(如按 field 取模分到多个 Hash),让每个 Hash 维持在 listpack 范围内。
问题 2:大 key 为什么会引发阻塞
- 现象:某个操作偶发性卡顿。
- 原因:一个 List/Hash/Set 里塞了几十万元素,
HGETALL、LRANGE 0 -1、SMEMBERS这类 O(N) 命令会长时间占用单线程。 - 解决:避免大 key;用
HSCAN/SSCAN/ZSCAN分批取;用redis-cli --bigkeys扫描大 key。
问题 3:为什么不能把 ZSet 的 score 当成普通整数
- 现象:排序结果跟预期不一致。
- 原因:score 是 double 浮点数,存大整数(如毫秒时间戳)超过 2^53 后会丢精度。
- 解决:需要高精度排序时,把精度信息编进 member,或缩小 score 量级。
总结
这一章的核心只有一句话:数据类型是接口,编码是实现,Redis 会根据数据量在「省内存的紧凑结构」和「快操作的指针结构」之间自动切换。 我们过了 String(SDS)、List(quicklist+listpack)、Hash(listpack/hashtable)、Set(intset/listpack/hashtable)、ZSet(listpack/skiplist),每种都配了实验和选型建议。
记住几个习惯:用 OBJECT ENCODING 看编码、用 MEMORY USAGE 看占用、用 --bigkeys 揪大 key。这些会贯穿你后面所有的 Redis 调优工作。
更多推荐




所有评论(0)