news 2026/9/16 8:30:13

把 Redis 拆开来看:SDS、intset、跳表、渐进式 rehash、epoll 和内存回收,一次讲明白

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
把 Redis 拆开来看:SDS、intset、跳表、渐进式 rehash、epoll 和内存回收,一次讲明白

前三篇我把 Redis 从入门用到了集群:SETGET、缓存三兄弟、主从哨兵分片、多级缓存,一路都是"怎么用"。但有个问题一直挠我——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字段记录当前数组里每个整数占多大,一共三档:

编码单元素大小能存的范围
INT162 字节小的整数
INT324 字节中等整数
INT648 字节大整数

关键规则是:只升不降,而且看的是"最大值说了算"。数组里全塞 100 以内的数,它就用 INT16,每个才占 2 字节;突然你插进来一个 10 万,超出 INT16 范围了,它不会只把这个数特殊处理,而是把整个数组升级成 INT32,所有元素重新排布到正确位置。

为什么要这么大费周章?因为绝大多数场景下你存的都是小整数,用 2 字节存和用 8 字节存,一个上百万元素的 Set 内存差着三四倍。这是一种"能省则省,实在要花再升级"的抠门哲学。我在入门篇学SINTER求共同好友那会儿真没想过,几个小整数背后藏着这么一套编码。

三、ZipList:用"记邻居多长"来代替指针的压缩列表

如果说 intset 是省内存的第一招,那 ZipList(压缩列表)就是 Redis 把"省"字刻进 DNA 的证据。它用来给 Hash、ZSet 这些结构在数据量小的时候兜底。

普通链表每个节点都得存"前驱指针 + 后继指针",两个指针就是 16 字节,比很多实际数据本身还占地方。ZipList 反手把这两个指针砍了——它不存指针,而是让每个 entry 记录"上一个节点有多长"(previous_entry_length)。想知道上一个节点在哪?从当前地址往前退"上一个节点的长度"那么多字节就到了;想知道下一个?往后退"本节点总长"就到了。整块内存是连续的,靠算偏移量来寻址。

我用一张对比图把这个"没有指针的链表"理顺了:

每节点16字节指针开销

用偏移量代替指针

📦 ZipList
连续内存·记长度寻址

zlbytes 总长
zltail 尾偏移
zllen 元素数

entry1
上一节点长度+编码+数据

entry2
上一节点长度+编码+数据

entry3

zlend 0xFF

🔗 普通双向链表
靠指针连接

节点A
prev|next指针

节点B
prev|next指针

节点C
prev|next指针

💸 费内存

✅ 省内存

这张图说的是:普通链表用指针把散落的节点串起来,指针本身很占地方;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 对上)。

数据类型数据量小 / 特殊时数据量大时
Stringint(整数直接存)、embstr(≤44字节连续)raw(SDS)
ListQuickList 内的小 ziplistQuickList 内多段 ziplist
Setintset(全整数)dict(HT)
ZSetziplist(≤128 且元素≤64字节)dict +skiplist(跳表)
Hashziplistdict

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],搬家不一次搬完,而是"每次有人来访问,顺手搬一小批"。我把这个机制画了出来:

没有

搬空了

📊 平时:只用 ht[0]
ht[1] 为空

触发扩容?
LoadFactor≥1且无子进程
或 LoadFactor>5

继续用 ht[0]

🏗️ 建好 ht[1]
rehashidx=0

下次来操作Dict
(增/删/改/查)

新增?只写 ht[1]

查/改/删?
先找 ht[0] 再找 ht[1]

🐢 顺手把 ht[0]
第rehashidx桶搬到ht[1]
rehashidx++

ht[0] 搬完了?

🔄 ht[1]转正为ht[0]
rehashidx=-1·完成

这张图说的是:搬家被摊到了每一次日常访问里,每次只搬一个桶。所以 rehash 期间,新增的 key 直接进 ht[1],而查询要两张表都看。精髓就是 ht[0] 只减不增,随着一次次访问被慢慢掏空,掏空了 ht[1] 就转正。这样任何一次操作都不会引发大规模数据迁移,把开销平摊掉了。收缩也是同一套,只是触发阈值换成了 LoadFactor < 0.1。

六、网络模型:从"一个服务员傻等"到"一个人盯一百桌"

数据结构解决的是"数据怎么存",但 Redis 真正让人惊叹的是"那么多个客户端连接,它一台机器怎么同时伺候"。这块我啃得最久,因为要先补操作系统的课。

先打个地基:为什么 IO 慢?程序在用户态,硬盘网卡归内核态管,两者隔着权限墙(Ring3 对 Ring0)。读写数据得在用户空间和内核空间来回拷贝、来回切换状态。一次网络读要分两个阶段:① 等内核把数据从网卡准备好(等数据就绪),② 把数据从内核缓冲区拷到用户缓冲区(拷贝数据)。五种 IO 模型的全部区别,就是这两个阶段分别怎么对待"等待"。我用一张对比图把这条演进线串了起来:

😴 阻塞IO
两阶段都傻等

🔁 非阻塞IO
阶段①轮询空转
阶段②仍阻塞

🎯 IO多路复用
select盯一批
谁就绪办谁

📢 信号驱动
就绪了发SIGIO通知

⚡ 异步IO
两阶段都不等
内核全包·完事告诉你

这张图说的是:从"派一个服务员站一桌死等",进化到"一个服务员拿着点餐器盯一百桌,谁举手办谁"。讲义用了一个我特别喜欢的比方——服务员给客人点餐分两步:客人想吃什么(等数据就绪)、客人想好了点单(读数据)。要提高效率要么多雇服务员(多线程),要么不排队、谁想好给谁点(多路复用)。Redis 选了后者这条路。

IO 多路复用的关键是要有个"监视器"告诉你这批连接里谁准备好了,这就是select / poll / epoll三个系统调用。它们仨的进化史就是一部"少干重复活"的历史:

selectpollepoll
存储结构固定数组链表红黑树 + 就绪链表
监听上限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全部 keyLRU
allkeys-lfu全部 keyLFU
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-lruallkeys-lfu用得最多。

十、写在最后:掀开盖子之后

这一篇读下来,我把"Redis 为什么快"这个问题,从一句含糊的"因为用内存",拆成了四个具体的答案:SDS 让字符串操作 O(1)、intset/ziplist 把小数据塞进连续内存省到极致、epoll 让一个线程高效盯住上万连接、单线程命令处理天然免去加锁。而"渐进式 rehash"和"惰性+定期删除 + 淘汰策略"这一对,则是我之前完全没概念、现在却觉得最漂亮的工程折中——凡是可能卡顿的大动作,就把它切碎摊平;凡是拿不准的取舍,就用两种笨办法互补。

回想这四篇的脉络:入门篇学"有什么",实战篇学"怎么用",高级篇学"怎么扛",原理篇学"为什么"。到这儿,Redis 在我心里从一个能调 API 的黑盒,慢慢变成了一个能讲清内部齿轮怎么咬合的机器。这种"理解了原理才知道为什么这么选"的踏实感,是光背用法给不了的。

下一步我想把这套"读源码思想"的方法迁到 Kafka 和 MySQL 的 InnoDB 上——毕竟"把大动作摊平、用空间换时间、按数据规模切编码"这三板斧,在哪都是通用的。如果这篇帮你也把 Redis 的盖子掀开了一条缝,那就值了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 8:30:07

01.全链ntdll化_加密本地执行

目录 整体原理分析 思路层 ← 免杀思路分析 先搞清楚:检测方到底在看什么 静态层 ←shellcode编码处理 静态层 —— 多字节 XOR:让文件里没有"载荷"这个东西 取址层 ←Ntapi绕过kernel32 取址层 —— 手写 PE 解析:让导入表里没有可疑名字 弹药层 …

作者头像 李华
网站建设 2026/9/16 8:29:46

2026动环监控系统十大品牌及行业发展前景解析

2026动环监控系统十大品牌及行业发展前景解析不少行业从业者对动环监控系统的了解相对有限&#xff0c;这款设备集成了多项实用功能&#xff0c;适配场景广泛&#xff0c;也正因如此&#xff0c;市面上深耕动环监控系统研发、生产的品牌厂家数量众多。到底哪些品牌实力出众&…

作者头像 李华
网站建设 2026/9/16 8:24:54

【进程】-5-进程优先级

**&#x1f3ac; 博主名称**&#xff1a;迷途之人不知返&#x1f525; 个人专栏: 《C语言》、《数据结构》、《C》、《Linux》 &#x1f5c2;️ Gitee仓库: 《C语言》、《数据结构》、《C》、《Linux》 </> 算法专栏: 《算法精选集》 进程优先级1、进程优先级&#xff…

作者头像 李华
网站建设 2026/9/16 8:24:42

蓝牙协议栈架构-第1章第2题-蓝牙主机层的核心组成有哪些

蓝牙面试题解析:蓝牙主机层的核心组成有哪些?—— HCI、协议层与高层管理模块详解 难度:⭐⭐⭐ 中等 | 场景:社招一面/二面、蓝牙协议栈岗、嵌入式开发岗 | 高频:🔥🔥🔥🔥🔥 适合级别:初中级(1-5 年)| 建议阅读时间:7 分钟 技术基线:Bluetooth Core Spec(…

作者头像 李华
网站建设 2026/9/16 8:24:12

Android去除旋转按钮小图标:三种场景与完整解决方案

做Android开发的&#xff0c;大概率都被这个不起眼的小图标折磨过——测试机上开着自动旋转&#xff0c;把手机横过来看视频&#xff0c;屏幕边上突然弹出一个旋转按钮&#xff1b;或者自己做的播放器页面&#xff0c;控制栏上莫名其妙多了个旋转图标&#xff0c;产品经理截图过…

作者头像 李华
网站建设 2026/9/16 8:23:16

Java开发者转型AI工程师:8周实战经验分享

1. 从传统开发到AI赛道的转型契机去年冬天&#xff0c;我作为一位有着8年Java后端开发经验的"老码农"&#xff0c;正面临着职业发展的瓶颈期。某次技术沙龙上&#xff0c;听到同行讨论大模型应用开发时&#xff0c;突然意识到&#xff1a;这可能是技术人最后的"…

作者头像 李华