开篇:同样存 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 记录内部编码,比如 intlistpackhashtableskiplist 决定底层怎么存、内存高不高、操作快不快
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。原因:

  1. O(1) 获取长度:SDS 头部记录了 len,不用像 C 字符串那样遍历到 \0
  2. 二进制安全:靠 len 判断结尾,而不是 \0,所以能存任意二进制数据。
  3. 避免缓冲区溢出 + 减少内存重分配:预分配和惰性释放空间,APPEND 时不用每次都 realloc。

存储 / 数据层:真实内存布局

Redis 服务层:String 对象

客户端 / 用户侧

关键路径:写入 String

数据流:指向 SDS

数据流:len 判断内容边界

对比关系:依赖 NUL 结尾

优化点:O(1) 长度 + 预分配

1. SET / APPEND / STRLEN
用户看到的是 String 类型

2. redisObject
type=String / encoding=int、embstr、raw

3. SDS 元信息
len=5 / alloc=10

4. buf 数据区
h e l l o _ _ _ _ _

5. C 原生字符串
h e l l o NUL

6. 风险点
C 字符串求长度 O(N),且不适合二进制数据

2.1.1 String 的三种编码:intembstrraw

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 类型,所以你仍然用 GETSETINCR 这些 String 命令。

embstr:短字符串一次分配,适合小 value

embstr 可以理解为“短字符串专用布局”。它把 redisObject 和 SDS 放在一块连续内存里,一次内存分配就能完成。

一块连续内存
┌─────────────┬────────────────────┐
│ redisObject │ SDS header + buf   │
└─────────────┴────────────────────┘

这样做有两个好处:

  1. 少一次内存分配:创建对象更快。
  2. 内存更连续:CPU 访问时更容易命中缓存。

embstr 更偏向只读优化。如果你对这个字符串执行 APPENDSETRANGE 等修改操作,Redis 往往会把它转成 raw,方便后续扩容。

raw:长字符串分开存,扩容更灵活

当字符串比较长,或者短字符串被修改后,Redis 会使用 raw 编码。

redisObject
  ptr ───────────────► SDS header + buf

rawredisObject 和 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

存储 / 数据层:linkedlist 双向链表

prev / next 指针

prev / next 指针

1. node A
prev=null
value=a
next=B

2. node B
prev=A
value=b
next=C

3. node C
prev=B
value=c
next=null

它的好处是两端插入、删除很快:LPUSHRPUSHLPOPRPOP 都不需要搬动整块数据。但它的问题也明显:如果只存一个很短的字符串,比如 "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

存储 / 数据层:ziplist 连续内存

连续内存

紧挨着

紧挨着

紧挨着

1. ziplist 头
总字节数 / 尾部偏移 / 元素个数

2. entry A
prevlen / encoding / data

3. entry B
prevlen / encoding / data

4. entry C
prevlen / encoding / data

5. 结束标记

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 连续内存

连续内存

紧挨着

紧挨着

紧挨着

1. listpack 头
总字节数 / 元素个数

2. entry A
encoding / data / backlen

3. entry B
encoding / data / backlen

4. entry C
encoding / data / backlen

5. 结束标记

那 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 步:

  1. 当前在 entry B,已知 B_start
  2. B_start - 1 向左解析 entry A 尾部的 backlen
  3. 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 节点内部,属于内部实现细节。

存储 / 数据层:quicklist + listpack

Redis 服务层:List 对象

客户端 / 用户侧

关键路径:双端读写

数据流:定位 quicklist

关键路径:访问头尾节点

链表指针:next / prev

链表指针:next / prev

内部紧凑存储

内部紧凑存储

1. LPUSH / RPUSH / LPOP / RPOP
用户看到的是 List 类型

2. redisObject
type=List

3. quicklist 头
维护首尾节点和长度

4. quicklistNode 1
内部 listpack:e1 e2 e3

5. quicklistNode 2
内部 listpack:e4 e5 e6

6. quicklistNode 3
内部 listpack:e7 e8

7. 优化点
链表两端操作快,listpack 又节省内存

图 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) 查询快,但指针和元数据更多

存储边界:大 Hash / 快查询

存储边界:小 Hash / 省内存

Redis 服务层:Hash 对象

客户端 / 用户侧

关键路径:执行 Hash 命令

控制流:检查编码阈值

数据流:字段少且值短

关键路径:超过阈值自动升级

异常/成本:内存开销变大

1. HSET / HGET / HDEL
用户看到的是 Hash 类型

2. redisObject
type=Hash

3. 编码判断
字段数与 value 长度

4. listpack
name -> Tom -> age -> 18 -> city -> BJ
连续内存,查找 O(N)

5. hashtable
桶数组 + entry 指针
HGET 平均 O(1)

6. 风险点
升级后指针和元数据开销增加,且不会自动降级

图 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。大多数情况下,HGETHSETHDEL 都接近 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 观察结果为准。

存储 / 数据层:三种编码

Redis 服务层:Set 对象

客户端 / 用户侧

关键路径:执行 Set 命令

控制流:选择编码

数据流:全整数且少

控制流:出现非整数元素

关键路径:数量超阈值

关键路径:数量超阈值

异常/成本:指针开销

1. SADD / SREM / SISMEMBER
用户看到的是 Set 类型

2. redisObject
type=Set

3. 编码判断
是否全整数 + 元素数量

4. intset
全整数且少:[1,2,3]
有序数组,省内存

5. listpack
少量非整数
连续内存,查找 O(N)

6. hashtable
大集合
SISMEMBER 平均 O(1)

7. 风险点
变大后升级为 hashtable,内存开销增加

图 4:Set 的三种编码升级路径。纯整数小集合用 intset;出现少量非整数时用 listpack;集合变大后升级为 hashtable。

2.4.1 intset 编码:整数小集合为什么这么省

intset 专门用来存少量整数集合。它本质上是一块连续数组,而且数组里的整数会按从小到大排序。

SADD ids 3 1 2

intset:
┌───┬───┬───┐
│ 1 │ 2 │ 3 │
└───┴───┴───┘

它有两个关键点:

  1. 只存整数:比如 1210086
  2. 自动选择整数宽度:刚开始可能用较小的整数宽度;如果加入更大的整数,会整体升级为更大的整数宽度。

例如原来都是小整数,后来加入一个很大的整数:

[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 维护顺序,所以它特别适合排行榜、热搜榜、延迟队列这类场景。

按 score 排序后的排行榜

ZSet 逻辑视图:member + score

客户端 / 用户侧

写入 member + score

写入 member + score

写入 member + score

score 最大

score 第二

score 第三

1. ZADD rank
Jerry=95
Tom=100
Lucy=120

2. Jerry / 95

3. Tom / 100

4. Lucy / 120

5. 第 1 名
Lucy / 120

6. 第 2 名
Tom / 100

7. 第 3 名
Jerry / 95

图 5:ZSet 的第一层理解。它不是普通 Set,而是每个成员都带一个分数,Redis 根据分数维护顺序。

两种编码:ZSet 小的时候用 listpack 省内存;变大后用 skiplist + hashtable 提升查询和排序效率。

编码 适合什么 怎么存 优点 代价
listpack 元素少、member 短 member, score 连续紧凑存放 省内存 查找和插入要顺序扫描/移动
skiplist 元素多、频繁排行/范围查询 hashtable 查 member,skiplist 管排序 查分数快,范围查询快 维护两套结构,更占内存

数据多:skiplist 编码

数据少:listpack 编码

元素少且 member 短

超过阈值

超过阈值

同一批数据
保持一致

1. 同一份 ZSet 数据
Jerry=95 / Tom=100 / Lucy=120

2. 连续内存
Jerry | 95 | Tom | 100 | Lucy | 120
省内存

3. hashtable
Tom -> 100
负责 ZSCORE

4. skiplist
95 Jerry -> 100 Tom -> 120 Lucy
负责排序、范围、排名

图 6:ZSet 的底层编码。小数据用 listpack 省空间;大数据用 hashtable + skiplist,用更多内存换更快查询。

2.5.1 小 ZSet:listpack 为什么省内存

当 ZSet 元素少、member 也短时,Redis 会把 memberscore 紧凑地放在一块连续内存里:

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 跳表怎么查得快:能向右就向右,不能向右就下沉

跳表可以理解成“普通有序链表 + 多层快捷索引”。底层保存所有节点,上层只保留一部分节点作为索引。

第 1 层:完整链表

第 2 层:中间索引

第 3 层:最高层索引

下沉

下沉

下沉

下沉

下沉

下沉

下沉

下沉

1. 向右

2. 向右

3. 下沉

25 > 21,不能向右

4. 下沉

5. 命中 21

head

1

17

head

1

9

17

25

head

1

5

9

13

17

21

25

图 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 常见编码切换阈值:到底是什么触发了升级

编码升级通常由两个因素触发:

  1. 元素数量变多:比如 Hash 的 field 数、ZSet 的 member 数超过阈值。
  2. 单个元素太大:比如 Hash 的某个 field/value 太长,ZSet 的某个 member 太长。

Redis 的判断逻辑可以简化成一句话:

只要“数量”或“单个元素长度”有一个超过阈值,
就不再适合继续用紧凑编码,
Redis 会升级成 hashtable / skiplist / 多节点 quicklist 这类结构。

下面这张表按 Redis 7.2 常见默认值整理。不同小版本可能略有差异,生产环境以你机器上的 redis.confCONFIG 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 512hash-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 128set-max-listpack-value 64 非整数成员数量超过 128,或任意 member 超过 64 字节,就升级为 hashtable
ZSet listpack skiplist + hashtable zset-max-listpack-entries 128zset-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 版本。

存储边界:数据多 / 快操作

存储边界:数据少 / 省内存

Redis 服务层:编码选择与切换

客户端 / 用户侧

关键路径:写入导致变大

控制流:未超过阈值

关键路径:超过阈值

数据流:重建为新编码

依赖关系:即使变小

异常/成本:不会回到紧凑结构

1. 写入更多元素
HSET / SADD / ZADD / RPUSH

2. 后续删除元素
HDEL / SREM / ZREM

3. 检查阈值
entries / value 长度

4. 执行编码升级
省内存结构 -> 快操作结构

5. listpack / intset
连续内存,无指针
小 N 下 O(N) 可接受

6. hashtable / skiplist / quicklist
O(1) 或 O(log N)
但有指针和元数据开销

7. 风险点
升级单向不可逆,删除后不会自动降级

图 8:编码切换是「省内存 → 快操作」的单向升级。超过阈值后会自动升级,但删除数据后不会自动降回原来的紧凑编码。

为什么 7.0 要用 listpack 替代 ziplist:ziplist 每个元素记录了「前一个元素的长度」,当中间插入/删除一个元素导致长度跨越编码边界时,可能引发连锁更新(cascade update)——后面所有元素都要重新调整,最坏 O(N²)。listpack 改了元数据设计,每个元素只记录自身长度,从根本上消除了连锁更新问题。

3.2 重点:编码切换会影响什么

编码切换不是冷知识,它会直接影响 3 件事:

  1. 内存占用:listpack、intset 这类紧凑结构更省内存;hashtable、skiplist 这类结构会多出指针和元数据。
  2. 命令耗时:小数据量顺序扫描没问题;数据量变大后,需要 hashtable 或 skiplist 降低查询、插入、范围检索的复杂度。
  3. 故障排查方向:当你发现某个 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 会让 HGETALLSMEMBERSLRANGE 0 -1ZRANGE 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=skuIdvalue=购买数量。购物车天然适合 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 ENCODINGMEMORY 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 里塞了几十万元素,HGETALLLRANGE 0 -1SMEMBERS 这类 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 调优工作。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐