Redis 用起来是真的顺手,缓存、分布式锁、排行榜、消息队列一套下来都离不开它。不过越是用得顺手,我心里越犯嘀咕:它到底怎么做到又快又稳的?比如 GET 一个 key,背后发生了什么?为什么 zadd 之后,数据还能按分数从小到大给你捞出来?同样是单线程,为什么它跟“一个请求就能卡住整个服务”的应用完全不是一回事?
这些问题想清楚了,面试题能答,线上排障心里也更有底。这篇文章专门把 Redis 底层四个最核心的机制——SDS、跳表、IO 多路复用、单线程模型——串起来讲一遍。通篇不讲虚的,结构、源码片段、命令验证、踩坑经验都会给到。适合这些读者:用过 Redis 但没认真看过底层实现的人、准备系统性梳理 Redis 原理的开发者,以及看了不少 Redis 文章却总觉得没串成体系的技术人。
1. SDS:Redis 的字符串为什么不用 C 字符串
1.1 C 字符串的短板,逼着 Redis 自造了一个结构
很多人学 C 语言时就知道,C 字符串就是一个以\0结尾的字符数组。它本身没有长度信息,所以strlen得从头遍历到尾才能算出长度,时间复杂度是 O(N)。这个在普通程序里可能无所谓,但 Redis 是一个内存数据库,字符串是最基本的数据类型,它需要在很短时间内完成几万次的读写、追加、修改操作。每次 append 一下都要 O(N) 重新算长度,这个开销根本没法接受。
更要命的是安全问题。C 字符串进行strcat之类的拼接时,必须提前确认目标缓冲区够大,否则就会越界覆盖相邻内存。Redis 里用户随时可能对一个 key 做 APPEND,如果底层用 C 字符串,就得在每次操作前做一次额外检查,还要手动重新分配内存。稍微漏掉一个边界条件,线上进程就可能莫名崩溃。这种坑在 C 程序里排查起来相当痛苦。
还有一个很多人容易忽略的点:C 字符串中间不能有\0,因为\0就是结束标志。这意味着你没法安全地存储图片数据、二进制序列化对象这类内容。而 Redis 作为通用缓存,经常要存序列化结果,这类数据中间出现\0再正常不过了。所以基于这三个痛点,Redis 实现了一种动态字符串结构,也就是 SDS(Simple Dynamic String,简单动态字符串)。
注意:Redis 的字符串对象在内存里到底长什么样,取决于你存的内容。int 编码用 long 直接存数字,embstr 和 raw 编码才是真正基于 SDS 的字符串。很多人把“字符串对象”和“SDS”混为一谈,严格来说并不准确,这个我们放到 1.3 节用命令实际验证。
1.2 SDS 的结构设计与三个核心机制
先看一个早期版本中比较经典的结构定义,逻辑最直观:
struct sdshdr { unsigned int len; // 字符串长度 unsigned int free; // 未使用的字节数 char buf[]; // 字节数组,真正存数据的地方 };buf是一个柔性数组,len表示已使用的长度,free表示还没用上的空闲空间。buf实际分配的长度就是len + free + 1,多出来的 1 个字节照例放\0,这样 SDS 可以继续复用一部分 C 字符串的函数,兼作兼容。
后来 Redis 为了节省内存又做了优化,按字符串长度把头部拆成了sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64这几种,长度短就用小字节数的头,长度长才用大字节数的头,不再像老版本那样一刀切固定unsigned int。但不管是哪个变种,核心思路没有变,就是自己维护长度和空闲信息。
围绕这个结构,SDS 提供了几个关键能力:
第一,O(1) 获取长度。直接返回len字段就行,不用遍历。这个带来的性能提升在频繁操作字符串时特别明显。
第二,空间预分配。当对一个 SDS 做 append 操作且空间不够时,Redis 不止申请刚好够用的内存,而是会额外预留一部分。旧版本里的规则是:修改后len小于 1MB,就预分配同样大小的空闲空间,也就是free = len,总长度接近原来的两倍;修改后len大于等于 1MB,则固定多分配 1MB 空闲空间。这样下次继续追加时,大概率不需要再触发内存分配。别小看这个优化,malloc是有系统调用成本的,频繁分配还会让内存碎片增加。类似道理我们在 Java 的ArrayList扩容、Go 的 slice 扩容里也见过。
第三,惰性空间释放。当字符串缩短时,比如执行SETRANGE变短、或者某些内部裁剪操作,SDS 不会立刻把多余内存还给系统,而是用free记录闲置空间。如果用户过一会儿又要对这个 key 做追加,这部分空间就派上用场了,省了一次分配。内存真的紧张时,也可以调用sdsRemoveFreeSpace把多余空间释放掉,把“按需缩容”的控制权握在自己手里。
再提一个 SDS 的典型优点:二进制安全。判断字符串是否结束只看len,不看\0。所以你往 Redis 里存什么都可以,图片、protobuf 序列化结果、压缩数据,都不会被截断。
1.3 用 OBJECT ENCODING 验证一下编码选择
光说结构容易飘,我建议你直接在自己的 Redis 实例上跑几条命令,立刻就有体感:
127.0.0.1:6379> SET num 10086 OK 127.0.0.1:6379> OBJECT ENCODING num "int" 127.0.0.1:6379> SET str hello OK 127.0.0.1:6379> OBJECT ENCODING str "embstr" 127.0.0.1:6379> SET longstr "this is a very long string that definitely exceeds forty four bytes" OK 127.0.0.1:6379> OBJECT ENCODING longstr "raw"这里能看到字符串对象的三种编码:
| 编码 | 适用情况 | 内存布局说明 |
|---|---|---|
| int | 能转成 long 的数字,且长度不超过 20 字节 | 直接存在 redisObject 的指针位置,不额外分配 SDS |
| embstr | 长度不超过 44 字节的短字符串 | 一次性分配 redisObject 和 SDS,内存连续 |
| raw | 超过 44 字节的字符串 | 对象内存与字符串内存分开分配,需要两次 malloc |
其中 embstr 阈值 44 字节也是有讲究的。在常见 64 位 Linux 上,redisObject 本身约 16 字节,sdshdr8 的头部约 3 字节,再加上结尾的\0一个字节,16 + 3 + 44 + 1 = 64,正好对齐到 jemalloc 常用的 64 字节内存桶。也就是说一次分配刚好用一个桶,没有浪费。44 这个数字不是随便拍脑袋定的。
所以你看,哪怕只是存个字符串,Redis 底层都在努力省内存。实际开发中,如果你要缓存的 key 很多、值都很短,尽量控制在 44 字节以内,能享受 embstr 的连续内存优势;如果存的是大量纯数字,Redis 直接用 int 编码存储,连 SDS 头部都省了。这些细节平时不太起眼,但对内存敏感的系统影响不小。
2. 跳表:有序集合的高效组织方式
2.1 从有序链表到“多层索引”,查找怎么跳过中间节点
先理解一个单链表查数据的痛点:链表里的数据是有序的,但你只能从头节点一个个往后走。比如链表里有 1 到 10 十个数字,你要找 7,得从 1 开始走 6 步才能到。数据量小还好,要是有一百万个节点,最坏情况要走一百万步,复杂度 O(N)。
跳表的思路是在原始链表之上,再建多层“快车道”。每层都是一个稀疏的有序链表,下层是稠密数据,上层是指针式的索引。
拿一个极简例子模拟三层结构:
Level 2: 1 -------> 5 -------> 9 Level 1: 1 ---> 3 ---> 5 ---> 7 ---> 9 Level 0: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9现在要找 7。从 Level 2 开始:1 到 5,5 的下一个是 9,9 已经大于 7 了,所以从 5 下探到下一层;Level 1 里 5 的下一个是 7,命中。整个查找只比较了三次,而普通链表需要六次。数据量越大,跳表在层数上的优势越明显,期望查找复杂度能到 O(log N)。
跳表想往上建索引不是靠某种强制规则,而是随机化。每个新节点插入时,先有一个基础层,然后以一定概率继续往上增加一层。Redis 里这个概率是 1/4,同时层高上限是 32 层。你可以理解成:新节点“出生”先在一楼,每往上一层都相当于一次抽奖,只有 25% 的运气能升层。所以绝大多数节点停留在底部两层,极少数节点能爬到很高的层。这种随机化让跳表不需要像平衡树那样做复杂的旋转调整,插入和删除时只要改前后指针就行。
2.2 Redis 跳表节点比教科书多带了三样东西
Redis 源码里的zskiplistNode结构大致长这样:
typedef struct zskiplistNode { sds ele; // 实际存储的元素 double score; // 排序分数 struct zskiplistNode *backward; // 后退指针 struct zskiplistLevel { struct zskiplistNode *forward; // 前进指针 unsigned long span; // 跨度:到下一个节点的步数 } level[]; } zskiplistNode;单看这个结构,有一个很容易忽略的点。跳表一般是正向遍历用的,为什么 Redis 还要维护一个backward后退指针?因为ZREVRANGE和ZREVRANGEBYSCORE需要按分数从大到小倒着查,没有后退指针就得从头部重走一遍,效率很差。加上一个指向前一个节点的backward,倒序遍历就是顺着链表往后走,复杂度线性。
span跨度则是为排名类命令服务的。ZRANK要告诉你某个 member 在有序集合里排第几名,如果你从头节点一层层走过去数,复杂度就退化成了 O(N)。有了span,查找路径上把所有经过节点的跨度加起来,就能在 O(log N) 级别的过程中算出排名。
还有一个常常被忽略的事实:Redis 的有序集合底层并不是只有跳表一种结构。当元素数量很少(默认少于 128 个)且每个元素长度较短(默认小于 64 字节)时,底层用的是紧凑内存编码;一旦超过阈值,才会转换为dict + skiplist的组合。dict 负责按 member 快速查到 score,跳表负责按 score 做有序范围操作。两者各司其职,这也是 ZSET 既支持ZSCORE直接取分数、又支持ZRANGEBYSCORE按范围取元素的根本原因。
2.3 跳表 vs 红黑树:为什么 Redis 选了前者
很多人第一反应是:有序结构为啥不用红黑树?Java 的 TreeMap、C++ 的 std::map 不都是树吗。对比一下 Redis 的实际诉求就明白了。
| 对比项 | 跳表 | 红黑树 |
|---|---|---|
| 实现复杂度 | 逻辑简单,插入删除只改指针 | 需要左旋、右旋、变色,极容易写错 |
| 范围查询 | 定位起点后沿 forward 层直接遍历 | 需要中序遍历,代码更绕 |
| 随机层高带来的内存 | 平均约 1.33 个指针/节点,可控 | 需要存储颜色标记,额外逻辑多 |
| 调试难度 | 打印几层链表即可观察 | 树结构出问题后很难肉眼定位 |
更重要的是范围查询场景。ZRANGEBYSCORE min max这种操作,在跳表里先用 O(log N) 定位到分数起点,然后顺着底层的 forward 指针一路往后读就行,实现起来“所见即所得”。红黑树当然也能做,但需要先找出最小节点,再中序遍历,代码复杂不说,Redis 作者本身就是跳表结构的爱好者,跳表在这种高频范围操作的场景下,属于更务实的选择。
3. IO 多路复用:单线程为什么能同时服务几万个连接
3.1 从阻塞 IO 说起,为什么不能“一个连接一个线程”
先看一个最朴素的服务端模型:每来一个客户端连接,就创建一个线程去处理,这个线程从头到尾阻塞地等着这个连接上的数据。这种做法在小连接数下没问题,但连接数一旦上来,线程数量暴增,CPU 大量时间花在线程上下文切换上。每个线程还要维护自己的栈空间和内核资源,哪怕什么都不干,内存开销也很大。对于 Redis 这种要扛十万级连接的场景,根本不现实。
所以需要一种思路:让一个线程同时监视很多个 socket,谁有数据就处理谁,没数据的连接就先晾着。这个机制就是 IO 多路复用。可以类比成餐厅服务员:一个服务员同时照顾多个餐桌,客人举手(有请求)才过去,而不是每张桌子都站一个人干等。
Redis 在单线程事件循环里做的就是这个事:主线程调用系统提供的多路复用接口,同时等待大量连接的读写事件。事件真正发生时,再去读数据、执行命令、写结果。
3.2 select、poll、epoll,Redis 到底用的什么
Linux 上常用的多路复用方案有三代:select、poll、epoll。三者的核心差异用一个表格就能看明白:
| 方案 | 连接数限制 | 每次调用开销 | 就绪事件获取方式 |
|---|---|---|---|
| select | 受 FD_SETSIZE 限制,通常 1024 | 每次都要把 fd 集合从用户态拷贝到内核态 | 返回后仍需遍历全集才能知道谁就绪,O(N) |
| poll | 没有固定上限,用链表组织 fd | 每次调用仍要拷贝整个 fd 列表 | 返回后仍需遍历,O(N) |
| epoll | 受系统最大文件描述符限制 | 注册时只拷贝一次,后续靠回调通知 | 只返回真正就绪的 fd 列表,O(1) 级 |
epoll 的优势在于:它把“监视哪些 fd”的注册工作放到epoll_ctl里,内核用红黑树把这批 fd 管理起来;一旦某个 fd 上有事件发生,内核通过回调机制把该 fd 加入就绪队列,应用只用epoll_wait取走就绪队列就行。大量空闲连接不会再产生额外拷贝和遍历,非常契合 Redis 这种连接多、活跃连接比例低的场景。
Redis 对操作系统做了抽象,编译时根据平台选择具体的实现:Linux 上走 epoll,macOS/BSD 上用 kqueue,实在不行才会退回 select 方案。在 Linux 生产环境里,你实际跑的就是 epoll 那套代码。另外,Redis 用的是水平触发(Level-Triggered)模式,也就是 socket 缓冲区里还有数据就会持续通知。这种方式写起来更安全,不用像边缘触发(Edge-Triggered)那样必须循环读到EAGAIN,避免了一不留神丢事件的麻烦。
3.3 一条命令在事件循环里怎么走完
用一段极简伪代码表示 Redis 的主事件循环:
void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { aeProcessEvents(eventLoop, AE_ALL_EVENTS); } }aeProcessEvents内部就是调epoll_wait等待事件,然后处理所有就绪的文件事件。一个客户端从发请求到收响应,完整路径大致是:
- 客户端发起 TCP 连接,服务端的监听 socket 变成可读。
epoll_wait返回,主线程accept创建客户端 socket,并把这个新连接注册进 epoll,监听它的可读事件。- 客户端发来命令,对应连接的 socket 可读,
epoll_wait再次返回。 - 主线程读取这块数据,解析命令、查命令表、执行命令,把结果写进客户端的输出缓冲区。
- 如果需要向客户端发送数据,再注册可写事件;等 socket 可写了继续发送,直到全部写出。
除了文件事件,Redis 还有时间事件,最典型的是serverCron,它每隔 100ms 左右跑一次,负责过期键清理、更新统计信息等后台杂活。文件事件和时间事件在同一个线程里轮流处理,所以 Redis 才能做到既响应网络请求,又维持各种后台任务不停摆。
4. 单线程模型:快与慢的边界到底在哪里
4.1 单线程快的原因,其实是一套组合拳
Redis 对外常被叫“单线程”,但它快并不是因为“单线程”本身,而是一套组合拳的结果。
首先是数据在内存。普通数据库一次查询可能涉及磁盘 IO,光寻道就要好几毫秒;Redis 直接读写内存,一次操作的耗时是微妙级别,差了上千倍。
其次是数据结构选得好。SDS 让字符串追加不再反复算长度,跳表让有序集合的范围操作快到接近二分查找。基于哈希表的 dict 让键值查询 O(1)。哪怕有少量慢命令,整体结构设计也在压制最坏情况。
第三就是 IO 多路复用。网络等待不占用 CPU,一个线程就能同时照看几万个连接。这就避开了多线程模型里最头疼的锁竞争和上下文切换,让 CPU 时间几乎都花在真正执行命令上。
最后要提的是,Redis 单线程处理的是“命令执行”这一环,所有命令都是串行执行的。串行听起来像是限制,但在没有锁、没有竞争的条件下反而稳定可预测。一次普通 GET/SET 的耗时通常在微秒级,一台普通机器上的 Redis 单线程跑 benchmark,每秒处理十万级请求并不稀奇。
4.2 为什么早期坚持单线程,不加线程池
很多刚接触 Redis 的人会问:多核不用白不用,为什么不做成多线程?早期 Redis 不这么做的原因很现实:
把命令执行变成多线程,就要面对共享数据结构的竞争问题。Redis 的所有 key、zset、list 都可能被多个线程同时访问,不加锁数据就乱了,加锁又会让操作变慢。锁竞争带来的损耗,可能比多线程提升的那部分收益还大。再加上多线程调试困难、代码复杂度指数级增加,而当时的瓶颈根本不在 CPU 计算上,主要靠在网络 IO 和内存分配。
换句话说,在服务器 CPU 还没到瓶颈的时候,引入多线程只是给自己找麻烦。Redis 把有限的复杂度留给真正需要并行解决的场景,比如持久化就交给子进程去做,而不是去动核心的命令执行路径。
4.3 单线程模型下最容易翻车的四个场景
单线程也有软肋,最典型的问题是某个命令执行时间太长,后面所有命令都得排队等着。我见得最多的翻车场景有四个:
第一,O(N) 命令直接裸奔。KEYS *在 key 较多的实例上会全表扫描,几十毫秒甚至更久;HGETALL一个大哈希、SMEMBERS一个大集合、ZRANGE一个大范围,都有同样的风险。生产环境建议用SCAN、HSCAN、SSCAN分批迭代,或者提前做好数据分片。
第二,删除大 key 引发卡顿。当集合、哈希、列表里塞了几十万甚至几百万个元素时,一次DEL会同步释放所有内存。释放过程对主线程来说也是实打实的工作量,卡个几百毫秒很正常。Redis 4.0 之后提供了UNLINK,它会先断链返回,再用后台线程异步释放内存。线上要删大 key,优先考虑UNLINK,或者用SCAN分批次删除。
第三,FLUSHALL/FLUSHDB同步清空。数据量大时,全量清空会阻塞服务。Redis 支持配置lazyfree-lazy-user-flush yes,把清空操作变成异步执行。
第四,fork 阻塞。BGSAVE、BGREWRITEAOF执行时主进程需要调用 fork 创建子进程。fork 本身要复制页表,内存越大耗时越长;之后如果开启了 AOF rewrite,父进程还要通过写时复制维护内存快照的完整性。所以大内存实例在做持久化时,主线程延迟会明显变高。遇到这种情况,可以调整 fork 时机、错峰执行,或者使用支持多实例的部署方式,把大内存拆开。
排查这类问题,日常建议看两个东西:SLOWLOG GET看慢命令列表,redis-cli --bigkeys扫描大 key。遇到请求抖动,先看慢日志里有没有上述几类命令,比盲目重启实例高效得多。
4.4 Redis 6.0 的多线程 IO 到底改了什么
有人会拿 Redis 6.0 引入多线程来说事,认为 Redis 终于变成“多线程”了。这里要澄清:Redis 6.0 的多线程只发生在网络 IO 阶段,命令执行依然是单线程。
具体来说,主线程仍然负责命令的解析、执行和数据访问。多线程帮忙干的是:把从客户端 socket 里读到的数据拷贝到内存中,以及把命令结果写回多个客户端 socket。这两个环节在连接数巨大时会消耗不少 CPU,把它们拆给多个 IO 线程,网络读写层面就能并行起来。
对应的配置是io-threads,默认是 1,表示不开启。想要开启可以设为 2 到 4,官方建议不要盲目调大,因为线程之间的同步和锁也会带来开销。还有io-threads-do-reads默认 no,默认只把“写回”多线程化,想连“读取”一起并行就需要显式打开。
所以准确的理解是:核心命令执行路径仍是单线程串行,IO 边界用多线程卸力。这个设计很务实,它保留了单线程调试容易、无锁安全的优势,同时把最容易扯后腿的网络收发给并行化了。
最后说一点我自己的实践感受
这篇不做什么总结,就聊一个我反复用到的排障习惯:每次遇到 Redis 抖动,不要先想着加机器、上集群,而是先问自己三个问题——这个 key 到底是什么编码?这条命令是不是 O(N) 操作?主线程现在是不是被 fork、大 key 删除这类任务拖住了?
具体动作也很简单:用OBJECT ENCODING看 key 编码,用SLOWLOG GET看慢命令,用INFO stats关注latest_fork_usec之类的指标。把这几个底层机制串起来之后我发现,绝大多数 Redis 问题在发生之前,底层都已经给了信号,只是你没去读那些信号而已。
还有一个小技巧:给 Redis 设置一个合理的慢日志阈值,比如slowlog-log-slower-than 10000,把超过 10 毫秒的命令全部记录下来。之后再调优时,你有据可依,而不是靠猜。把原理吃透,再拿数据说话,Redis 其实一点都不神秘。