Redis 数据结构与单线程模型深度解析:对外接口与底层编码的双重智慧
前言
上一篇我们梳理了 Redis 的通用命令与过期机制,打下了基础操作的底子。而 Redis 之所以能碾压同类内存数据库,核心竞争力有两个:一是丰富且高效的数据结构,二是经典的单线程事件驱动模型。
很多人学 Redis 停留在"会用 set/get、知道有五种数据类型"的层面,但对底层编码优化、单线程为什么快这些核心原理一知半解,一到面试深挖就卡壳。本文从「对外数据类型」与「底层编码实现」的分离设计讲起,逐个拆解五大基础类型的底层实现思路,再深入 Redis 最经典的单线程模型,从工作过程到性能本质,结合系统编程视角一次性讲透。
一、Redis 数据结构:对外接口与底层编码的分离设计
1.1 先理清两个核心概念
我们常说 Redis 有 string、list、hash、set、zset 五种数据结构,这其实是对外暴露的数据类型,也就是 Redis 向使用者承诺的 API 与时间复杂度。比如 hash 类型承诺你查询、插入、删除都是 O(1),list 承诺两端操作 O(1)。
但在源码底层,同一种数据类型可能对应多种编码实现方式。Redis 会根据数据量大小、数据类型,自动选择最合适的底层编码,在时间和空间之间做权衡——既保证对外的时间复杂度承诺不变,又尽可能节省内存。
打个通俗的比方:餐厅承诺你 10 分钟上一份炒面,这是对外的服务标准。至于后厨是老师傅现炒、还是预制菜加热,只要最终口味和时效达标,都可以。Redis 的编码优化就是这个逻辑:对外接口语义不变,内部实现按需优化。
1.2 五大基础类型的底层编码实现
我们逐个来看每种数据类型对应的底层编码,以及各自的适用场景。
string 字符串
string 是最基础的类型,底层有三种编码方式:
- int 编码:当 value 是整数时,Redis 不会存成字符串,而是直接用整型存储,既省空间又方便计算。比如计数器场景,天然适配 int 编码。
- embstr 编码:针对短字符串的优化编码,将 RedisObject 和字符串数据在内存上连续分配,只需一次内存分配,缓存友好性更好,空间效率更高。
- raw 编码:最通用的字符串编码,底层就是一个字节数组(对应 C/C++ 的 char 数组、Java 的 byte 数组),长字符串会使用这种方式。
补充一个细节:C/C++ 里的 char 占 1 字节,等价于 Java 的 byte;而 Java 的 char 占 2 字节,用来存 Unicode 字符,两者不要搞混。Redis 是 C 语言写的,字符串本质就是字节流,不关心内容编码。
hash 哈希
hash 类型对应两种底层编码:
- ziplist(压缩列表):当 hash 中元素数量少、字段和值都比较短时,使用压缩列表存储。它是一块连续的内存,通过紧凑排列节省空间,虽然查询是遍历,但元素少的时候性能差异可以忽略,整体视为 O(1)。
- hashtable(哈希表):当元素数量超过阈值后,自动转成标准哈希表实现,保证查询、插入的 O(1) 复杂度。
核心设计思想:小数据量时优先省内存,大数据量时优先保性能。Redis 里有大量的 key,如果每个小 hash 都用完整哈希表,内存碎片和开销会非常大;统一用压缩列表紧凑存储,整体内存收益非常可观。
list 列表
list 类型的编码经历了一次演进:
- 早期版本:元素少用 ziplist,元素多用 linkedlist(双向链表)。
- Redis 3.2 之后:引入 quicklist 作为默认实现,相当于"链表 + 压缩列表"的结合体——整个 list 是一个双向链表,链表的每个节点又是一个 ziplist。
这种设计同时兼顾了两端操作的效率和空间利用率,和 C++ 里的 std::deque 设计思路非常像:分段连续存储,既避免链表的内存碎片化,又保证两端插入删除的高效性。
set 集合
set 类型有两种编码:
- intset(整数集合):当集合里全是整数、且元素数量少时,使用整数集合紧凑存储,非常省内存。
- hashtable(哈希表):当元素不满足整数条件、或数量超过阈值时,转成哈希表实现,保证去重和查询的 O(1) 复杂度。
zset 有序集合
zset 是 Redis 非常有特色的数据结构,同样有两种编码:
- ziplist(压缩列表):元素数量少时用压缩列表存储,按 score 顺序排列,遍历即可实现有序性。
- skiplist(跳表):元素数量多时,使用跳表作为核心实现。跳表在普通链表的基础上,给每个节点增加了多层指针,通过"跳层"的方式将查询时间复杂度降到 O(logN),实现简单、并发友好,是 Redis 非常经典的底层设计。
1.3 查看底层编码:object encoding 命令
我们可以通过 OBJECT encoding key 命令,查看某个 key 实际使用的底层编码:
127.0.0.1:6379> set num 111
OK
127.0.0.1:6379> type num
string
127.0.0.1:6379> OBJECT encoding num
"int"
127.0.0.1:6379> hset user name zhangsan age 20
(integer) 2
127.0.0.1:6379> OBJECT encoding user
"ziplist"
可以看到,同样是 string 类型,底层可能是 int 编码;同样是 hash 类型,小数据量下默认是 ziplist。整个切换过程 Redis 自动完成,使用者完全无感知。
1.4 学习理念:记思想,不记数字
很多同学学这里特别喜欢背阈值:比如字符串小于 39 字节用 embstr,hash 元素超过 512 个转 hashtable。这里我特别强调一句:只记思想,不记数字。
原因很简单:
- 这些阈值都是可配置的,不同版本 Redis 默认值也不一样,死记硬背毫无意义;
- 面试真正考察的,是你"空间换时间、时间换空间"的权衡思路,而不是具体数字;
- 实际工作中,最优阈值永远要靠压测得出,而不是背来的。
就像 HashMap 的红黑树转换阈值、线程池的核心线程数,这些参数本质都是"可调的工程选择",不是"必须遵守的定理"。理解背后的设计思想,比记住数字重要一万倍。
二、深入 Redis 单线程模型
单线程模型是 Redis 最经典的设计,也是面试必考题。很多人对它有误解,我们从工作过程到性能本质,一步步拆解。
2.1 单线程模型的工作过程
首先澄清一个误区:Redis 单线程,指的是「核心命令的执行是单线程」,不是说整个 Redis 进程只有一个线程。高版本 Redis 里,网络 IO、持久化等工作是由额外的后台线程做的,真正执行数据操作、处理命令逻辑的,只有一个主线程。
它的工作模式很简单:所有客户端发来的命令请求,都会先进入一个队列排队;主线程按顺序逐个取出命令、执行、返回结果。宏观上看是多个客户端并发访问,微观上所有命令都是串行执行的。
这带来一个最直接的好处:天然线程安全。比如两个客户端同时对同一个 key 执行 incr 自增操作,在多线程模型下会出现并发安全问题,自增两次实际只加 1;但在 Redis 单线程模型下,两个命令一定会排队先后执行,最终结果一定是自增两次,完全不需要加锁,也不会有竞态问题。
这个逻辑就像学校食堂只有一个打饭窗口:放学时大家一窝蜂跑过来,宏观上是并发的;但到了窗口前都得排队,阿姨一个一个打饭,微观上是串行的。不会出现两个人同时抢一份菜的情况。
当然,单线程能成立是有前提的:Redis 的核心操作都是内存操作,短平快,不消耗 CPU。如果是 CPU 密集型的业务,单线程肯定扛不住,必须上多线程。而 Redis 的瓶颈从来不在 CPU,而在内存和网络 IO。
单线程的弊端也非常明显:一旦有一个慢命令占用主线程太久,所有其他命令都会被阻塞。这也是为什么 keys *、大 key 删除、复杂聚合计算在生产环境是高危操作——它们会卡住整个 Redis 服务。
2.2 经典面试题:单线程为什么这么快?
这道题面试出场率接近 100%,很多人只能答出"内存存储、单线程无锁",答得完整且有深度的人很少。我们从四个层面完整拆解:
1. 内存存储是根本前提
Redis 所有数据都在内存里,操作的是内存数据结构;而 MySQL 这类关系型数据库操作的是磁盘。内存访问是纳秒级,磁盘寻道是毫秒级,差了好几个数量级。这是最本质的差距,也是一切高性能的基础。
2. 功能简洁,执行逻辑轻量
数据库要做的事情太多了:SQL 解析、查询优化、事务处理、约束校验、索引维护……每一步都有额外开销。而 Redis 的核心逻辑非常纯粹:根据 key 找到 value,执行简单的数据结构操作,没有复杂的语义和约束,干的活少,自然就快。
3. 单线程避免了线程竞争开销
多线程不是银弹,它带来收益的同时,也会引入上下文切换、锁竞争、缓存失效等额外开销。Redis 的操作都是短平快的内存操作,本身不耗 CPU,就算开多线程,也很难利用好多核,反而会因为锁和切换降低效率。单线程模型彻底规避了这些问题,实现简单且高效。
补充:CPU 密集型任务适合多线程,可以充分利用多核;IO 密集型 + 轻计算任务,单线程 + IO 多路复用往往是更优的选择。Redis 就是典型的后者。
4. IO 多路复用:单线程搞定海量连接
这是 Redis 能支撑高并发的核心技术。很多人会疑惑:单线程怎么同时处理成百上千个客户端连接?答案就是 epoll 实现的 IO 多路复用机制。
我们用一个通俗的例子理解:假设你要去买三份小吃:蛋炒饭、肉夹馍、饺子。
- 方案一:你挨个排队买,等完第一家再等第二家,最后等第三家。这就是同步阻塞 IO,串行处理,效率最低。
- 方案二:找三个人各买各的,同时等。这就是每连接一线程模型,效率高,但线程多了系统开销大,连接上万之后根本扛不住。
- 方案三:你一个人去买,挨个下单后就在旁边等着,哪家做好了老板就喊你一声,你去取哪家的。这就是 IO 多路复用:一个线程管理多个 socket,哪个连接有数据就绪了,就去处理哪个。
Redis 面对的场景,正好符合第三种方案的前提:大部分连接大部分时间是空闲的,同一时刻只有少数连接是活跃的。IO 多路复用充分利用了等待时间,用一个线程就扛住了海量并发连接。
Linux 下的 IO 多路复用有三套经典 API:select、poll、epoll。Redis 使用的是性能最高的 epoll,它采用事件通知机制,不需要遍历所有连接,连接数再多也不会导致性能线性下降。
C/C++ 开发可以直接使用 Linux 原生的 epoll API,Java 则是通过 NIO 封装了底层的 epoll 机制。做后端开发,IO 多路复用是必须吃透的底层知识。
三、源码视角:设计背后的工程考量
站在源码实现的角度看,Redis 的设计处处体现着"实用主义"的工程智慧,这里挑两个点做解读。
3.1 quicklist:空间与时间的经典折中
在 quicklist 出现之前,list 用 ziplist 和 linkedlist 切换,要么空间好但插入性能差,要么插入好但空间碎片多。quicklist 的设计非常巧妙:
- 宏观上是双向链表,保证两端 O(1) 插入删除;
- 微观上每个链表节点是一个 ziplist,用连续内存减少碎片、提升缓存命中率。
你可以调整每个 ziplist 的长度,来适配不同场景:ziplist 设得短,性能接近链表;设得长,空间更紧凑。这就是典型的"把选择权交给使用者"的设计,提供机制,不提供标准答案。
3.2 ae 事件循环:单线程的驱动核心
Redis 的单线程,本质是跑在一个事件循环(aeEventLoop)里。这个事件循环同时处理两类事件:
- 文件事件:客户端的网络 IO 请求,底层封装 epoll;
- 时间事件:定时任务,比如定期删除过期 key、持久化触发等。
整个事件循环就是一个 while 循环,每次计算最近的时间事件还有多久,然后阻塞等待文件事件,超时后就去处理时间事件。整个过程没有锁、没有线程切换,逻辑清晰且高效,是非常经典的 Reactor 模式单线程实现。
核心考点总结
设计思想:对外数据类型与底层编码分离,接口语义不变,内部按需优化,在空间和时间之间做权衡。
编码对应:
- string:int、embstr、raw
- hash/set/zset:小数据量用 ziplist/intset,大数据量转 hashtable/skiplist
- list:3.2 之后默认 quicklist,兼顾链表与压缩列表的优势
学习原则:记思想不记数字,理解"小数据省空间、大数据保性能"的核心逻辑。
单线程本质:命令执行单线程串行,天然线程安全;网络 IO 等辅助工作由后台线程完成。
单线程快的四大原因:内存存储、功能简洁、无线程竞争开销、epoll IO 多路复用。
IO 多路复用:select/poll/epoll 的区别,epoll 事件驱动的优势,适用场景。
单线程弊端:慢命令会阻塞全局,生产环境禁止执行耗时未知的重操作。
结语
本文从对外数据结构到底层编码实现,再到单线程模型的工作原理与性能本质,把 Redis 最核心的两个基础知识点讲透了。理解这些底层设计,你再去学具体命令、排查线上问题,就不会只停留在表面。接下来我们会逐个深入每一种数据结构,拆解常用命令、典型业务场景与最佳实践,带你从"会用"走向"用好"。





