尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

把 Redis 拆开来看:SDS、intset、跳表、渐进式 rehash、epoll 和内存回收,一次讲明白 前三篇我把 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字段记录当前数组里每个整数占多大一共三档编码单元素大小能存的范围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上一节点长度编码数据entry3zlend 0xFF 普通双向链表靠指针连接节点Aprev|next指针节点Bprev|next指针节点Cprev|next指针 费内存✅ 省内存这张图说的是普通链表用指针把散落的节点串起来指针本身很占地方ZipList 让数据挤在一段连续内存里靠记录邻居长度来定位省下了指针开销。天下没有免费的午餐代价是——它不擅长改动。一旦某个节点变大后面的偏移全得重排。这里还藏着一个经典考点连锁更新。previous_entry_length用 1 个字节记长度前节点 254 字节用 5 个字节记前节点 ≥ 254 字节。设想连续好几个节点都刚好卡在 250~253 字节这个临界区这时候你在头部插入一个 ≥ 254 字节的大节点第二个节点的previous_entry_length就得从 1 字节撑成 5 字节它一变大第三个又得跟着变……像多米诺骨牌一样一路更新下去每次更新又可能触发 realloc。增和删都能引发这连锁反应最坏情况时间复杂度直接从 O(1) 退化到 O(n²)。四、QuickListZipList 太长不好管就把它切片串起来连锁更新 连续内存难申请暴露了 ZipList 的死穴单个列表不能太大。那要存的数据超出上限了怎么办Redis 在 3.2 版本给出了 QuickList——它本身是个双向链表但链表里每个节点装的不是单个元素而是一整个 ZipList。相当于多段小区间用链表串成大社区既有 ZipList 的省内存又不用担心单段过长。管这个小区间大小的配置是list-max-ziplist-size取值挺有意思正数表示每个 ZipList 最多放几个元素负数表示最多占多少内存分五档# -14kb -28kb -316kb -432kb -564kb # 默认 -2即每个ziplist内存不超过8kb list-max-ziplist-size -2这一套SDS intset ZipList QuickList Dict SkipList最终都收拢到一个叫RedisObject的统一外壳里。讲义给了张编码表我盯着看了半天才看透它的用意同一个逻辑类型Redis 会根据数据多少在省内存的编码和快的编码之间自动切换。这也是 String 的 key 为什么有 44 字节那道坎的底层原因下一篇我会专门把它和最佳实践里的 embstr/raw 对上。数据类型数据量小 / 特殊时数据量大时Stringint整数直接存、embstr≤44字节连续rawSDSListQuickList 内的小 ziplistQuickList 内多段 ziplistSetintset全整数dictHTZSetziplist≤128 且元素≤64字节dict skiplist跳表HashziplistdictZSet 那栏的跳表是我觉得最优雅的发明。既要按 score 排序、又要快速按 member 找分数Redis 让它同时挂了两个结构dict 负责member→score的快速查找skiplist 负责按 score 排序。跳表说白了就是给有序链表加了几层快速通道——查一个数不用从 1 一个个走先从最高层大跨步跳接近目标再降层细找跟查字典先翻大目录、再翻小目录一个道理。讲义里那句增删改查效率和红黑树基本一致实现却简单得多点破了它为什么被选中。五、Dict 与渐进式 rehashRedis 怎么边营业边搬家讲完省内存回到那个撑起身家的老伙计——哈希表 Dict。Redis 所有的键值映射、每一个 hash、每一个 db底层都是它。它用数组 链表解决哈希冲突跟 Java 的 HashMap 一脉相承。定位一个 key 靠h sizemask哈希值按位与数组长度-1算出该放哪个槽。问题来了元素越来越多负载因子used/size不断攀升冲突变多、链表变长查询就慢。得扩容。而一旦扩容数组长度变了sizemask 也变了原来每个 key 算出来的位置全作废必须全部重算重放一遍——这就是 rehash。一个百万级的大哈希表要一次性 rehashRedis 单线程卡在那几十秒服务直接停摆。这显然不能忍。Redis 的解法堪称一绝渐进式 rehash。它给 Dict 同时准备了两张哈希表ht[0] 和 ht[1]搬家不一次搬完而是每次有人来访问顺手搬一小批。我把这个机制画了出来否是没有搬空了 平时只用 ht[0]ht[1] 为空触发扩容?LoadFactor≥1且无子进程或 LoadFactor5继续用 ht[0]️ 建好 ht[1]rehashidx0下次来操作Dict增/删/改/查新增只写 ht[1]查/改/删先找 ht[0] 再找 ht[1] 顺手把 ht[0]第rehashidx桶搬到ht[1]rehashidxht[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 数据你只读了 1kbLT默认只要 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 总得约定一套报文格式这就是 RESPRedis 序列化协议。默认用的是 RESP26.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();}// 解析读首字节判断类型intprefixreader.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全部 keyLRUallkeys-lfu全部 keyLFUallkeys-random全部 key随机volatile-lru设了 TTL 的LRUvolatile-lfu设了 TTL 的LFUvolatile-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 的盖子掀开了一条缝那就值了。
返回列表