Interview Lab / Redis / Data Structures

Redis 底层核心数据结构:对象、编码与自动转换

先解释标题:Redis 对象是每个值外面的一层统一包装,负责记录“它是什么类型、目前用哪种内部结构”;底层数据结构是数据真正在内存里的组织方式;自动转换是数据不再满足当前结构条件时,Redis 在命令语义不变的前提下把内容搬到另一种结构。

这篇解决一个最容易混淆的问题:字符串(String)、列表(List)、哈希(Hash)、集合(Set)、有序集合(Sorted Set)是用户通过命令看到的数据类型;内存里还会选择不同底层编码。正文先用一份用户资料说明“为什么一类数据需要多种包装”,再逐个引入动态字节串、紧凑序列、分段链表、哈希表、整数数组和跳表。

Redis 7.2+ 主线类型与编码分层6 种核心结构自动转换面试总纲
版本边界:正文以 Redis 7.2+ 的公开编码行为和当前官方源码结构为主。ziplist(压缩列表)是 Redis 6.2 及以前常见的历史实现;当前主线已用 listpack(紧凑列表)承接小型聚合对象。旧版结构不在本文混讲。
一、先建立总模型:命令看到类型,内存保存编码

先看日常用法:保存一份用户资料

下面的 profile:42 是“42 号用户资料”这个 Redis key(键,也就是数据在 Redis 中的名称);name 和 age 是资料字段。HSET 是向 Hash 写字段的命令,业务只关心字段能否正确写入和读取。

HSET profile:42 name Ada age 28
HGET profile:42 name
# 返回 "Ada"

这份资料只有两个字段时,若每个字段都单独创建对象和指针,管理开销可能比数据本身还大;资料增长到几万字段后,若仍把所有内容挤在一段连续内存里,每次查找或中间修改又可能走很久。

小资料的主要矛盾

字段很少,重点是减少对象头、指针和多次内存申请,让数据紧挨着存。

大资料的主要矛盾

字段很多,重点是快速定位和稳定扩展,可以接受更多索引与指针开销。

为什么需要多种编码:对外的 Hash 命令保持不变,Redis 却能根据规模更换内部结构。小对象节省内存,大对象节省查找和修改时间;代价是越过门槛的某次操作可能承担转换成本。

再用包装类比理解“两层”

你在快递面单上写的是“书”“衣服”或“杯子”,这是对外的类别;仓库真正选择纸箱、文件袋还是泡沫盒,这是内部包装。Redis 的数据类型像面单,底层编码像包装。

Redis 从命令类型到对象编码再到底层结构的三层模型客户端命令先确定字符串、列表、哈希、集合或有序集合类型,对象头保存类型和编码,编码再指向动态字节串、紧凑连续序列、分段双向列表、哈希表、有序整数数组或跳表等真实结构。 一次读写要经过三层:类型 → 编码 → 真实结构 第 1 层:命令面对的数据类型 String 字符串List 列表Hash 哈希Set 集合Sorted Set 第 2 层:Redis 对象头type:对外是什么类型  encoding:当前用什么内部编码ptr:指向真实数据  引用与淘汰信息:管理对象生命周期 第 3 层:真实内存结构 动态字节串紧凑序列分段双向列表哈希表有序整数数组跳表 + 哈希表 同一个 type 可以选择不同 encoding;命令语义保持不变,时间与空间成本会变化。
最应记住:Redis 不是“五种类型分别固定对应五种结构”,而是一个对外类型可以在多个内部编码之间选择。

最小观察:类型和编码不是同一个答案

TYPE 是查看对外数据类型的命令;OBJECT ENCODING 是查看当前内部编码的命令。对刚才的 profile:42,前者回答“这是 Hash”,后者回答“这份 Hash 现在用什么结构保存”。

TYPE profile:42
# hash

OBJECT ENCODING profile:42
# 常见结果为 listpack;实际结果由版本、配置和内容决定

只改变观察问题,答案就不同:TYPE 面向命令语义,OBJECT ENCODING 面向内存实现。这是后面所有结构的总入口。

type决定命令是否合法
encoding决定当前如何存
ptr找到真实数据
一句话直觉:小数据优先紧凑连续地放,节省指针和对象开销;数据变大后切换到更适合快速查询、插入或范围访问的通用结构。
二、对象层:为什么 type 和 encoding 必须分开

Redis Object(Redis 对象)是每个值外面的一层统一包装。它让命令只需先看 type 判断“这是不是我能操作的类型”,再按 encoding 分派到具体实现。

LRU(Least Recently Used,最近最少使用)按“多久没访问”辅助淘汰;LFU(Least Frequently Used,最不经常使用)按访问频率辅助淘汰。它们是对象管理信息,不是值内容本身。

struct redisObject {
    type       // 对外类型:String / List / Hash / Set / Sorted Set
    encoding   // 内部编码:listpack / quicklist / hashtable ...
    lru_or_lfu // 淘汰策略使用的时间或频率信息
    refcount   // 有多少地方正在引用这个对象
    ptr        // 指向真实底层数据
}
字段问题白话答案当前流程中的作用
为什么有 type?防止拿 List 命令操作 String先做类型检查
为什么有 encoding?同一业务类型可换不同“包装方式”选择真正执行函数
为什么有 ptr?对象头不直接塞下所有数据定位 SDS、dict 等结构
为什么有管理信息?对象还要参与共享、释放和内存淘汰管理生命周期与缓存策略
可现场验证:TYPE key 看对外类型,OBJECT ENCODING key 看当前内部编码。两条命令回答的不是同一个问题。
三、SDS:Redis 字符串为什么不直接照搬 C 字符串

SDS(Simple Dynamic String,简单动态字符串)是 Redis 的可增长字节串。它在字符内容前保存已使用长度 len、已分配容量 alloc 和头部类型 flags,负责高效取长度、扩容和保存二进制内容。

struct sdshdr8 {
    uint8_t len;    // 已使用的字节数
    uint8_t alloc;  // 已分配的可用容量
    unsigned char flags; // 头部规格
    char buf[];     // 真正的数据,末尾仍保留 \0 方便兼容 C API
}
SDS 头部和数据区的连续内存布局SDS 在 hello 数据之前保存 len、alloc、flags,数据之后保留未使用空间和零结束符;len 可以常数时间得到长度,alloc 减 len 得到剩余空间。 SDS = 带“长度与容量账本”的字节数组 len = 5已用 5 字节alloc = 8容量 8 字节flags头规格hello空\0 buf:二进制安全,内容里可以包含 \0取长度直接读 len;追加前用 alloc - len 判断是否需要重新分配。
最应记住:SDS 自己记录长度,所以不必从头扫描到 \0;它保存的是字节,文本、图片片段和序列化数据都能装。
能力C 字符串的限制SDS 的处理
取长度通常要扫描到 \0,O(n)直接读 len,O(1)
二进制内容内容里的 \0 容易被误认为结尾按 len 读取,二进制安全
追加调用方容易忘记容量边界根据 alloc 检查并扩容
头部开销没有长度头按字符串规模选择 8/16/32/64 位头部

时间复杂度用输入规模增长时操作步骤如何增长来估算成本:O(1) 表示步骤数基本不随数据量增长,O(n) 表示最坏步骤数大致跟数据量 n 一起增长。

别把 SDS 说成 String 的唯一 encoding 名:String 对象还可能直接用整数编码;短字符串也可能采用 embstr(embedded string,嵌入式字符串),把对象头和 SDS 放在一次内存分配中;较长或需要修改的字符串常见为 raw(原始字符串),对象头与 SDS 分开分配。
四、listpack:小数据为什么适合紧挨着放

listpack(紧凑列表)是一段连续内存,把多个字符串或整数逐项编码在一起。每个 entry(条目)就是其中一个被编码的元素;末尾的 EOF(End Of File,结束标记)告诉读取过程“到这里停止”。它没有为每个元素单独保存一组前后指针,因此小型 List、Hash、Set 和 Sorted Set 可以明显减少内存开销。

backlen 是条目尾部记录的“本条前半部分有多长”,负责让读取过程从当前条目向前找到上一条。

listpack 的连续布局和单个 entry 结构listpack 头部保存总字节数与元素数,随后连续保存多个 entry,每个 entry 包含自身编码、数据和反向长度,最后以 255 结束;反向长度帮助从尾部向前移动。 一整段连续内存:头部 + 多个 entry + 结束标记 总字节数4 bytes元素数量2 bytesentry 1"name"entry 2"Ada"entry 342EOF0xFF 单个 entry = 编码与长度 + 数据 + backlenbacklen 记录本项前半部分长度,便于从当前位置反向找到上一项
最应记住:listpack 用“连续紧凑”换内存效率,但查第 N 项通常仍需逐项走,且中间插入可能移动后续字节。

为什么省内存

元素不各自申请对象,不保存一堆指针;整数还能按数值范围使用更短编码,连续数据也更容易被处理器缓存一起读取。

为什么不能无限大

定位中间元素要遍历,插入和删除可能触发内存搬移;对象过大后,这些线性成本会压过节省的空间。

和旧 ziplist 的关键区别:listpack 的每项保存自己的 backlen,不在后一项头部记录前一项长度,避免旧压缩列表修改前项后可能引发的连续重写问题。
五、quicklist:List 如何兼顾两端操作与内存紧凑

quicklist(快速列表)是一条双向链表,每个节点通常不只装一个元素,而是装一段 listpack。它负责让 List 的头尾操作只触碰少数节点,同时避免“一个元素配一个链表节点”的巨大指针开销。

LZF 是算法名称,源自 Lempel–Ziv 压缩家族;这里它是一种轻量无损压缩算法,负责用少量计算换取中间节点更小的内存占用。

quicklist 用双向链表串联多个 listpack 节点quicklist 头和尾指向三枚双向连接的节点,每枚节点内部装一个 listpack;两端节点保持可直接访问,中间节点可按配置压缩,头尾插入只改动边缘节点。 外层负责连接,内层负责紧凑headtail quicklistNode 1listpackA · B · C quicklistNode 2listpack / LZFD · E · F · G quicklistNode 3listpackH · I 中间节点可使用 LZF 压缩,以少量计算换更小的内存占用。LPUSH / RPUSH 主要触碰头尾节点,不需要扫描整条 List。
最应记住:quicklist 不是普通链表,也不是一整块巨大 listpack;它把二者组合,控制单块移动成本和指针开销。
设计选择得到什么付出什么
双向链表连接节点头尾增删快,可局部拆分与合并节点之间仍有指针开销
节点内放多个元素比每元素一个节点更省内存节点内部访问仍可能线性扫描
中间节点可压缩冷数据减少内存访问时可能解压与重新压缩
六、dict:Hash、Set 和全局键空间为何都需要哈希表

dict(dictionary,字典)是 Redis 的通用哈希表。哈希函数把 key 计算成一个数字索引;不同 key 落到同一桶时形成哈希冲突,Redis 沿桶中的链继续比较完整 key。

旧面试资料常把 dict 画成两张哈希表并讲“渐进 rehash”。rehash(重新散列)就是扩容或收缩时重新安排元素位置。当前官方源码仍保证哈希表自动调整容量并处理冲突,但内部字段和增量迁移细节会随版本演进;面试应先回答目标,再说明版本。

哈希表查询和渐进迁移的概念流程key 经 hash 与掩码定位到桶 2,桶内比较完整 key;扩容时旧表和新表暂时并存,每次操作推进少量桶迁移,直到旧表释放。 查询靠 hash 定位,迁移靠分批摊平成本 key = "user:42"计算 hash hash & mask定位 bucket 2 桶内链摘要初筛 → 完整 key 比较命中后返回 value 需要调整容量时旧桶 0 ✓旧桶 1 →旧桶 2旧桶 3 新 0新 1新 2新 3新 4新 5新 6新 7概念上把一次大搬迁拆给多次操作;具体字段与步进策略以目标 Redis 版本源码为准。
最应记住:哈希表让精确查找平均接近 O(1);容量调整若一次搬完会形成长停顿,因此 Redis 尽量分摊工作。
复杂度要说“平均”:哈希表查找平均 O(1),不是无条件永远 O(1)。冲突链、扩容状态和恶意输入都会影响实际成本。
七、intset:全是整数的小 Set 为什么能特别省

intset(integer set,整数集合)是一段有序、连续、无重复的整数数组,负责保存较小且成员全是整数的 Set。它的头部只需记录整数宽度 encoding 和元素数量 length。

typedef struct intset {
    uint32_t encoding; // 每个整数用 16、32 或 64 位
    uint32_t length;   // 元素数量
    int8_t contents[]; // 有序整数数据区
} intset;

查找

因为整数有序,可以用二分查找:每次排除一半范围,查找是 O(log n)。

插入

先定位位置,再移动后半段给新整数腾空间,所以插入最坏是 O(n)。小集合时这笔代价通常可接受。

升级

加入更大整数时,整段数组升级到更宽的 32 或 64 位,旧值按新宽度重新排布。

转型

元素太多或加入非整数成员时,不再适合 intset,Set 会转向 listpack 或 hashtable 等编码。

为什么保持有序:它用“插入时多搬一点”换“无需哈希桶和每元素指针”,非常适合不大、主要做成员判断的整数集合。
八、skiplist:Sorted Set 为什么同时需要跳表和 dict

Sorted Set(有序集合)给每个唯一成员绑定一个分数,并按“分数优先、成员字典序兜底”排序。大型对象常把 skiplist(跳表)与 dict 组合:跳表负责顺序和范围,dict 负责按成员精确找分数。

跳表是在普通有序链表上增加多层“快速通道”的结构。查找时先在高层跨大步,快超过目标时再下降一层,直到最底层命中。

有序集合通过字典和跳表完成两类查询字典从 member 直接找到 score;跳表底层按 10、20、30、40、50 排序,上层用稀疏指针跨越多个节点,查找 40 时从高层跳到 30 再下降命中;span 记录跨过节点数量用于排名。 同一份成员,两套索引各做擅长的事 dict:member → scoreAda40Bob20Chen50ZSCORE / 更新成员平均接近 O(1) 定位 skiplist:按 score 排好顺序 L2L1L010203040501030501030 span 记录跨过多少节点,支持排名ZRANGE / ZRANK / 分数范围查询dict 解答“这个成员多少分”,skiplist 解答“谁排在谁前面”。
最应记住:跳表与 dict 不是重复浪费;它们分别优化精确查找和有序范围,两者共享成员内容以控制额外成本。
操作主要依赖原因
按 member 查 scoredict精确 key 查询平均接近 O(1)
按 score 范围读取skiplist节点天然保持有序
查排名skiplist 的 spanspan 记录每层指针跨过多少底层节点
更新 scoredict + skiplist先找到旧值,再维护新的排序位置
九、五种常用类型到底对应哪些编码
对外数据类型小数据常见编码通用编码选择逻辑
Stringint、embstrraw + SDS整数直接存;短字符串合并分配;更长或需修改时用独立 SDS
Listlistpackquicklist很小时一整块连续存;变大后拆成多个 quicklist 节点
Hashlistpackhashtable小而短的 field/value 连续存;越界后换字典
Setintset、listpackhashtable纯整数优先 intset;小型短成员可 listpack;否则哈希表
Sorted Setlistpackskiplist(实际为 skiplist + dict)小对象顺序扫描可接受;变大后需要范围与精确查询双索引
OBJECT ENCODING 的名字是观察入口:官方文档列出的当前常见返回值包括 int、embstr、raw、listpack、quicklist、hashtable、intset、skiplist。实际结果还会受 Redis 版本与配置影响。

redis-cli 是 Redis 自带的命令行客户端,负责把下面的命令直接发送给本地或远程实例。

# 在 redis-cli 中观察:TYPE 看业务类型,OBJECT ENCODING 看内部编码
SET visits 42
TYPE visits                    # string
OBJECT ENCODING visits         # int

HSET profile name Ada age 28
TYPE profile                   # hash
OBJECT ENCODING profile        # 小对象通常为 listpack
十、自动转换:为什么一个 key 用着用着 encoding 变了

编码转换是 Redis 在对象不再满足紧凑编码条件时,把原数据搬到通用结构的过程。命令结果不变,但内存布局和一次操作的成本会发生变化。

小型紧凑编码超过限制后转换为通用结构小型 Hash 使用 listpack;插入导致成员数或单值长度超过配置阈值后,Redis 遍历原数据并构建 hashtable;转换完成后后续访问走哈希表,命令语义不变。 以 Hash 为例:小而紧凑 → 超过门槛 → 通用哈希表listpackfield · value 紧挨保存name · Ada · age · 28省内存 某次 HSET 越界元素数超过 entries或 field/value 太长本次操作承担转换成本 hashtablefield 经 hash 定位bucket 0 → age:28bucket 1 → name:Ada查询更可扩展转换通常是单向的实现策略;删回很小也不要假设会自动恢复原编码。
最应记住:阈值不是“超过后功能失效”,而是“超过后换结构”;转换动作本身可能让某次写操作更慢。

官方默认配置在 Redis 7.0+ 常见为 hash-max-listpack-entries 512、hash-max-listpack-value 64、zset-max-listpack-entries 128、zset-max-listpack-value 64、set-max-intset-entries 512;Redis 7.2+ 还提供 Set 的 listpack 门槛。单位和精确默认值应以正在运行的实例配置为准;CONFIG GET 是读取实例配置的命令,可用它核对实际门槛。

生产坑点:不要为了省内存盲目把门槛调得很大。紧凑结构越大,扫描、移动和一次性转换的成本越高;官方也建议修改后做基准与延迟测试,同时观察 CPU(Central Processing Unit,中央处理器)消耗。
十一、面试官常见追问
1. Redis 有哪些底层核心数据结构?
常见主线包括 SDS、listpack、quicklist、dict、intset、skiplist。先说明它们是编码层结构,不要直接和 String、List、Hash、Set、Sorted Set 一一硬绑定。
2. 为什么 Redis 要一类数据准备多种编码?
小对象更在意每个指针和对象头的内存开销,适合连续紧凑存储;大对象更在意查询、插入和范围操作,适合哈希表、quicklist 或跳表。多编码是在时间与空间之间动态取舍。
3. SDS 比 C 字符串好在哪里?
它记录长度和容量,取长度 O(1),能保存包含零字节的二进制数据,并由统一 API 管理扩容边界。
4. listpack 为什么省内存,又为什么不适合很大?
它把元素连续编码,减少指针与独立分配;但查中间项要走过前面的 entry,中间增删还可能搬移后续内存。
5. quicklist 为什么不是纯双向链表?
纯链表每个元素都要节点和前后指针。quicklist 让一个节点装多个元素,保留头尾局部操作能力,同时降低指针开销。
6. Sorted Set 为什么跳表旁边还有 dict?
跳表擅长排序、范围和排名;dict 擅长 member 精确定位。两套索引服务不同查询路径。
7. 编码转换会影响业务结果吗?
命令语义不变,但触发转换的那次写入可能承担遍历和重建成本,后续操作的常数、内存与访问路径也会变化。
十二、90 秒面试总结话术
Redis 的数据类型和底层编码是两层概念。
String、List、Hash、Set、Sorted Set 是命令看到的类型,
对象头用 type 标记类型,用 encoding 标记当前内部实现,
ptr 再指向真实数据结构。

核心结构里:
SDS 是带长度和容量的二进制安全字符串;
listpack 把小元素连续编码,重点是节省内存;
quicklist 用双向链表串多个 listpack,兼顾 List 两端操作和内存;
dict 提供平均 O(1) 的精确查找;
intset 用有序连续整数数组保存小型纯整数 Set;
skiplist 用多层索引支持 Sorted Set 的范围和排名,旁边再配 dict 做成员精确查找。

Redis 的总策略是:小对象优先紧凑编码,
超过元素数量或单值长度门槛后转成通用结构。
所以回答复杂度和内存时,既要说数据类型,也要说当前 encoding 和版本。
对象层type + encoding
小对象连续紧凑
大对象通用索引
判断入口OBJECT ENCODING
十三、应用场景:这些结构知识什么时候真正有用

内存优化

什么时候用:大量小 Hash 或 Set 占用超预期。为什么:先看是否仍使用紧凑编码,再评估 field/value 长度和门槛。注意:只调阈值不做延迟测试,可能把内存问题换成 CPU 和长尾延迟问题。

慢命令定位

什么时候用:某次写入偶发尖刺。为什么:检查它是否刚好触发 listpack 到 hashtable、intset 到其他编码的转换。注意:平均复杂度无法描述一次性重建成本。

数据建模

什么时候用:在 Hash、Set、Sorted Set 之间选型。为什么:按查询模式选对外类型,再用编码知识估算规模变化后的成本。注意:不要为了命中某种 encoding 扭曲业务语义。

面试源码题

什么时候用:被追问 ziplist、quicklist、跳表或渐进 rehash。为什么:先声明 Redis 版本,避免把 6.2 以前 ziplist 与 7.x listpack 混成一套。注意:不确定字段时讲稳定的设计目标,不编造源码细节。

业务化表达:“我会先用 TYPE 和 OBJECT ENCODING 确认真实路径,再结合元素数量、最大成员长度和实例配置判断是否发生编码转换;优化时同时观察内存、CPU 与 P99 延迟。”其中 P99(99th percentile,第 99 百分位)表示 99% 请求都不超过的响应时间,用来观察少数慢请求。
附录、官方资料依据

本文以 Redis 7.2+ 的公开行为为版本主线,并用当前官方源码核对对象创建与结构字段。实现细节会演进,线上判断应以实例版本、CONFIG GET 和 OBJECT ENCODING 的实际结果为准。