前三篇我把 Redis 从入门用到了集群:
SET、GET、缓存三兄弟、主从哨兵分片、多级缓存,一路都是"怎么用"。但有个问题一直挠我——SET name 张三这行敲下去,内存里究竟发生了什么?为什么大家总说 Redis"快得离谱",它到底快在哪?这些命令在 Redis 内部到底怎么跑起来,我其实是黑的。这一篇原理篇就是来掀盖子的。我给自己立了个规矩:每个底层结构都先想清楚"它在解决什么问题",再去看它怎么实现。看完最大的感受是——Redis 快,不只是因为用了内存,更是因为它在内存里把每一字节都抠到了极致。
一、先搞懂一件事:Redis 里的"字符串",不是你以为的字符串
我们从没觉得 Redis 的 String 有啥特别的,存个名字、存个 JSON,不就是个字符串嘛。但讲义一上来就告诉我:Redis 压根没用 C 语言原生的字符串,而是自己造了一个叫 SDS(简单动态字符串)的东西。
为什么要自己造?因为 C 字符串有三个致命的坑:
- 取长度得现算:
strlen要从头数到\0,是 O(n)。Redis 天天要判断"这个 key 多长、要不要扩容",每次都从头数,谁也受不了。 - 非二进制安全:C 字符串靠
\0结尾,中间一旦存了个\0就被当成结束了,所以存不了图片、序列化对象这种二进制数据。 - 不可修改:想追加一段,得先算总长、重新 malloc、再拷贝整个。
SDS 在结构体头部就存了len(已用长度)和free(剩余可用空间)两个字段。这一下,取长度从 O(n) 变成了 O(1),判断够不够放也是拿两个数字一比就完事。这就像给字符串随身带了张"身高体重卡",不用每次现量。
更妙的是它的内存预分配策略,这跟我上一篇讲的"空间换时间"是一个套路。给 SDS 追加内容时,如果不够空间:
- 新字符串小于 1M:申请"扩展后长度 × 2 + 1"的空间;
- 新字符串大于 1M:申请"扩展后长度 + 1M + 1"的空间。
多分配出来的这块叫buf的空闲区。好处是下次再追加,很可能直接够用,不用又申请一次内存。代价嘛,就是浪费一点点空间。对内存吃紧的 Redis 来说,这点浪费换来回调次数的大幅减少,划算。
二、intset:一个"会自己升级"的整数数组
Set 集合人人都用,但你可能不知道——当你往 Set 里塞的全是整数、数量还不多时,Redis 底层用的是一个叫 intset 的东西,而不是你以为的哈希表。
intset 本质就是个整数数组,但它有三个讲究:元素保证唯一、保证升序、查询用二分查找。而最有意思的是它的编码升级机制。它用encoding字段记录当前数组里每个整数占多大,一共三档:
| 编码 | 单元素大小 | 能存的范围 |
|---|---|---|
| INT16 | 2 字节 | 小的整数 |
| INT32 | 4 字节 | 中等整数 |
| INT64 | 8 字节 | 大整数 |
关键规则是:只升不降,而且看的是"最大值说了算"。数组里全塞 100 以内的数,它就用 INT16,每个才占 2 字节;突然你插进来一个 10 万,超出 INT16 范围了,它不会只把这个数特殊处理,而是把整个数组升级成 INT32,所有元素重新排布到正确位置。
为什么要这么大费周章?因为绝大多数场景下你存的都是小整数,用 2 字节存和用 8 字节存,一个上百万元素的 Set 内存差着三四倍。这是一种"能省则省,实在要花再升级"的抠门哲学。我在入门篇学SINTER求共同好友那会儿真没想过,几个小整数背后藏着这么一套编码。
三、ZipList:用"记邻居多长"来代替指针的压缩列表
如果说 intset 是省内存的第一招,那 ZipList(压缩列表)就是 Redis 把"省"字刻进 DNA 的证据。它用来给 Hash、ZSet 这些结构在数据量小的时候兜底。
普通链表每个节点都得存"前驱指针 + 后继指针",两个指针就是 16 字节,比很多实际数据本身还占地方。ZipList 反手把这两个指针砍了——它不存指针,而是让每个 entry 记录"上一个节点有多长"(previous_entry_length)。想知道上一个节点在哪?从当前地址往前退"上一个节点的长度"那么多字节就到了;想知道下一个?往后退"本节点总长"就到了。整块内存是连续的,靠算偏移量来寻址。
我用一张对比图把这个"没有指针的链表"理顺了:
这张图说的是:普通链表用指针把散落的节点串起来,指针本身很占地方;ZipList 让数据挤在一段连续内存里,靠"记录邻居长度"来定位,省下了指针开销。天下没有免费的午餐,代价是——它不擅长改动。一旦某个节点变大,后面的偏移全得重排。
这里还藏着一个经典考点:连锁更新。previous_entry_length用 1 个字节记长度(前节点 < 254 字节),用 5 个字节记(前节点 ≥ 254 字节)。设想连续好几个节点都刚好卡在 250~253 字节这个临界区,这时候你在头部插入一个 ≥ 254 字节的大节点,第二个节点的previous_entry_length就得从 1 字节撑成 5 字节;它一变大,第三个又得跟着变……像多米诺骨牌一样一路更新下去,每次更新又可能触发 realloc。增和删都能引发这连锁反应,最坏情况时间复杂度直接从 O(1) 退化到 O(n²)。
四、QuickList:ZipList 太长不好管,就把它切片串起来
连锁更新 + 连续内存难申请,暴露了 ZipList 的死穴:单个列表不能太大。那要存的数据超出上限了怎么办?Redis 在 3.2 版本给出了 QuickList——它本身是个双向链表,但链表里每个节点装的不是单个元素,而是一整个 ZipList。相当于"多段小区间,用链表串成大社区",既有 ZipList 的省内存,又不用担心单段过长。
管这个"小区间"大小的配置是list-max-ziplist-size,取值挺有意思:正数表示每个 ZipList 最多放几个元素;负数表示最多占多少内存,分五档:
# -1=4kb -2=8kb -3=16kb -4=32kb -5=64kb # 默认 -2,即每个ziplist内存不超过8kb list-max-ziplist-size -2这一套(SDS + intset + ZipList + QuickList + Dict + SkipList)最终都收拢到一个叫RedisObject的统一外壳里。讲义给了张编码表,我盯着看了半天才看透它的用意:同一个逻辑类型,Redis 会根据数据多少在"省内存的编码"和"快的编码"之间自动切换。这也是 String 的 key 为什么有 44 字节那道坎的底层原因(下一篇我会专门把它和最佳实践里的 embstr/raw 对上)。
| 数据类型 | 数据量小 / 特殊时 | 数据量大时 |
|---|---|---|
| String | int(整数直接存)、embstr(≤44字节连续) | raw(SDS) |
| List | QuickList 内的小 ziplist | QuickList 内多段 ziplist |
| Set | intset(全整数) | dict(HT) |
| ZSet | ziplist(≤128 且元素≤64字节) | dict +skiplist(跳表) |
| Hash | ziplist | dict |
ZSet 那栏的"跳表"是我觉得最优雅的发明。既要按 score 排序、又要快速按 member 找分数,Redis 让它同时挂了两个结构:dict 负责"member→score"的快速查找,skiplist 负责"按 score 排序"。跳表说白了就是给有序链表加了几层"快速通道"——查一个数,不用从 1 一个个走,先从最高层大跨步跳,接近目标再降层细找,跟查字典先翻大目录、再翻小目录一个道理。讲义里那句"增删改查效率和红黑树基本一致,实现却简单得多"点破了它为什么被选中。
五、Dict 与渐进式 rehash:Redis 怎么"边营业边搬家"
讲完省内存,回到那个撑起身家的老伙计——哈希表 Dict。Redis 所有的键值映射、每一个 hash、每一个 db,底层都是它。它用数组 + 链表解决哈希冲突,跟 Java 的 HashMap 一脉相承。定位一个 key 靠h & sizemask(哈希值按位与"数组长度-1")算出该放哪个槽。
问题来了:元素越来越多,负载因子(used/size)不断攀升,冲突变多、链表变长,查询就慢。得扩容。而一旦扩容,数组长度变了,sizemask 也变了,原来每个 key 算出来的位置全作废,必须全部重算重放一遍——这就是 rehash。一个百万级的大哈希表要一次性 rehash,Redis 单线程卡在那几十秒,服务直接停摆。这显然不能忍。
Redis 的解法堪称一绝:渐进式 rehash。它给 Dict 同时准备了两张哈希表ht[0] 和 ht[1],搬家不一次搬完,而是"每次有人来访问,顺手搬一小批"。我把这个机制画了出来:
这张图说的是:搬家被摊到了每一次日常访问里,每次只搬一个桶。所以 rehash 期间,新增的 key 直接进 ht[1],而查询要两张表都看。精髓就是 ht[0] 只减不增,随着一次次访问被慢慢掏空,掏空了 ht[1] 就转正。这样任何一次操作都不会引发大规模数据迁移,把开销平摊掉了。收缩也是同一套,只是触发阈值换成了 LoadFactor < 0.1。
六、网络模型:从"一个服务员傻等"到"一个人盯一百桌"
数据结构解决的是"数据怎么存",但 Redis 真正让人惊叹的是"那么多个客户端连接,它一台机器怎么同时伺候"。这块我啃得最久,因为要先补操作系统的课。
先打个地基:为什么 IO 慢?程序在用户态,硬盘网卡归内核态管,两者隔着权限墙(Ring3 对 Ring0)。读写数据得在用户空间和内核空间来回拷贝、来回切换状态。一次网络读要分两个阶段:① 等内核把数据从网卡准备好(等数据就绪),② 把数据从内核缓冲区拷到用户缓冲区(拷贝数据)。五种 IO 模型的全部区别,就是这两个阶段分别怎么对待"等待"。我用一张对比图把这条演进线串了起来:
这张图说的是:从"派一个服务员站一桌死等",进化到"一个服务员拿着点餐器盯一百桌,谁举手办谁"。讲义用了一个我特别喜欢的比方——服务员给客人点餐分两步:客人想吃什么(等数据就绪)、客人想好了点单(读数据)。要提高效率要么多雇服务员(多线程),要么不排队、谁想好给谁点(多路复用)。Redis 选了后者这条路。
IO 多路复用的关键是要有个"监视器"告诉你这批连接里谁准备好了,这就是select / poll / epoll三个系统调用。它们仨的进化史就是一部"少干重复活"的历史:
| select | poll | epoll | |
|---|---|---|---|
| 存储结构 | 固定数组 | 链表 | 红黑树 + 就绪链表 |
| 监听上限 | 1024 | 无(理论) | 无 |
| FD 拷贝 | 每次全拷到内核 | 每次全拷 | epoll_ctl 只拷一次 |
| 找就绪 | 遍历所有 FD | 遍历所有 FD | 回调·只拿就绪的 |
| 性能随连接数 | 线性下降 | 线性下降 | 基本不受影响 |
select 每次调用都得把整个 FD 集合从用户态拷到内核态,唤醒后还得自己遍历一遍才知道谁就绪,纯纯的重复劳动。poll 把定长数组换成链表解决了 1024 上限,但拷贝和遍历的老毛病没变。epoll 才是降维打击:内核里用红黑树存着要监听的 FD(增删查都快,加进去就不用重复传了),再用回调机制,谁的 FD 就绪了就把它挂到一个就绪链表上,epoll_wait只需把这条现成的就绪链表返回,彻底告别全量遍历。
epoll 还有个小细节值得记:LT(水平触发)和 ET(边沿触发)。拿一次经典例子——socket 里来了 2kb 数据,你只读了 1kb:
- LT(默认):只要 FD 里还有数据没读完,下次
epoll_wait还会再提醒你一遍。省心,适合新手。 - ET(高速):只在状态"变化"的那一刻提醒一次,读完 1kb 剩下那 1kb 它不再吭声,你得自己一次性读到返回 EAGAIN 为止。更高效,但要写对代码。
这套 epoll 就是 Redis"单线程也能扛住几万并发"的底气所在。
七、Redis 到底是单线程吗?(这题面试必问,我得说准)
这是个容易答错的坑。讲义给了个精确口径,我原样背下来:
- 只聊核心业务(命令处理):是单线程。
- 聊整个 Redis 进程:是多线程。
因为 Redis 在两个版本节点上引入了多线程:v4.0用多线程异步干一些慢活(比如unlink异步删除大 key,这个我在最佳实践篇刚好遇到过);v6.0才在网络模型里引入多线程 IO,专门对付多核 CPU。核心命令执行在 6.0 之前一直是单线程,靠的就是上面那个 epoll 事件循环。
那为什么当年偏要单线程?三个理由我觉得都成立:一是 Redis 是纯内存操作,命令本身执行极快,瓶颈在网络 IO 不在 CPU,多线程省不了多少;二是多线程意味着大量上下文切换,反而拖慢;三是多线程要加锁,实现复杂、性能还打折。一句话:既然卡点在"收发包"而不是"算",那就把"收发包"用 epoll 高效化,"算"干脆单线程串起来,还天然免了并发加锁。这也解释了上一篇我背的那句"Redis 单线程,一个慢命令会阻塞后面一大片"——现在从原理上彻底闭环了。
八、RESP 协议:Redis 说的那门"方言"
客户端和 Redis 总得约定一套报文格式,这就是 RESP(Redis 序列化协议)。默认用的是 RESP2,6.0 出了 RESP3 支持客户端缓存。它最聪明的地方是靠首字节区分类型,五种一目了然:+单行字符串、-错误、:整数、$批量字符串、*数组。比如返回 OK 就是+OK\r\n。
讲义让我自己用 Socket 手撸了一个 mini Redis 客户端来体感这套协议,发送时把命令拼成 RESP 数组格式,解析时靠首字节分派:
// 发送:把命令打包成 RESP 数组 *3\r\n$3\r\nset\r\n...privatestaticvoidsendRequest(String...args){writer.println("*"+args.length);for(Stringarg:args){writer.println("$"+arg.getBytes(StandardCharsets.UTF_8).length);writer.println(arg);}writer.flush();}// 解析:读首字节判断类型intprefix=reader.read();switch(prefix){case'+':returnreader.readLine();// 单行case'-':thrownewRuntimeException(...);// 错误case':':returnLong.parseLong(...);// 整数case'$':/* 先读长度再读内容 */;case'*':returnreadBulkString();// 数组}写完这段我算是真懂了:平时RedisTemplate帮我把这套编码解码全包了,我只管调方法。底层无非是"把命令拼成带长度前缀的字符串、按行收发"。
九、内存回收:过期 key 和内存满了怎么办
Redis 是内存数据库,内存金贵,所以"什么时候释放内存"是个大问题,分两层。
第一层:设了 TTL 的过期 key 怎么清。讲义点出 Redis 用了两个 dict——一个存 key-value,一个存 key-TTL。Redis 判断 key 过不过期,就是查那张 TTL 的表。删除策略是两种配合:
- 惰性删除:到期不立刻删,等下次访问到这个 key 时才检查、才删。省 CPU,但费内存(没人访问就一直占着)。
- 定期删除:后台定时抽样一部分 key 删掉过期的。分 FAST 和 SLOW 两种模式,本质是在"扫描成本"和"内存回收及时性"之间做平衡。
第二层:内存真用满了怎么办——内存淘汰策略。这是缓存场景的重头戏,讲义列了 8 种,我按"淘汰范围 × 淘汰依据"两个维度一梳理就清楚了:
| 策略 | 淘汰范围 | 依据 |
|---|---|---|
| noeviction(默认) | 不淘汰 | 写满直接报错 |
| allkeys-lru | 全部 key | LRU |
| allkeys-lfu | 全部 key | LFU |
| allkeys-random | 全部 key | 随机 |
| volatile-lru | 设了 TTL 的 | LRU |
| volatile-lfu | 设了 TTL 的 | LFU |
| volatile-random | 设了 TTL 的 | 随机 |
| volatile-ttl | 设了 TTL 的 | TTL 最短的先走 |
最容易混的是 LRU 和 LFU,我用一句话钉住区别:LRU 看"多久没被用",越久没用越该淘汰;LFU 看"用得频不频繁",用得越少越该淘汰。前者会被"偶尔来一次的爆红 key"骗到,后者更能识别真正的高频热点。讲义还提到 LFU 的计数不是真存个数(那多占内存),而是用一个概率递增的"逻辑访问次数"+ 时间衰减来近似,省内存的同时保住了统计有效性。做缓存我又一般会让所有 key 都带 TTL,所以生产上allkeys-lru或allkeys-lfu用得最多。
十、写在最后:掀开盖子之后
这一篇读下来,我把"Redis 为什么快"这个问题,从一句含糊的"因为用内存",拆成了四个具体的答案:SDS 让字符串操作 O(1)、intset/ziplist 把小数据塞进连续内存省到极致、epoll 让一个线程高效盯住上万连接、单线程命令处理天然免去加锁。而"渐进式 rehash"和"惰性+定期删除 + 淘汰策略"这一对,则是我之前完全没概念、现在却觉得最漂亮的工程折中——凡是可能卡顿的大动作,就把它切碎摊平;凡是拿不准的取舍,就用两种笨办法互补。
回想这四篇的脉络:入门篇学"有什么",实战篇学"怎么用",高级篇学"怎么扛",原理篇学"为什么"。到这儿,Redis 在我心里从一个能调 API 的黑盒,慢慢变成了一个能讲清内部齿轮怎么咬合的机器。这种"理解了原理才知道为什么这么选"的踏实感,是光背用法给不了的。
下一步我想把这套"读源码思想"的方法迁到 Kafka 和 MySQL 的 InnoDB 上——毕竟"把大动作摊平、用空间换时间、按数据规模切编码"这三板斧,在哪都是通用的。如果这篇帮你也把 Redis 的盖子掀开了一条缝,那就值了。