尧图精选

Redis底层数据结构与编码切换:从内存暴涨到性能优化一次讲透

🕒 发布时间:2026/10/2 18:32:17 📁 来源:尧图网络
你可能也遇到过这种场景Redis命令背得滚瓜烂熟SET、ZADD、LPUSH每天都写但一旦线上Redis内存突增或者大key删除把主线程卡住了就开始犯嘀咕——这货底层到底是什么结构为什么有些List能塞几百万元素还不炸有些Hash字段一多反而从“省内存”变成“吃内存”我从第一次在项目里用Redis做缓存到后来排查慢查询、做集群迁移再到翻源码验证各种猜测前后踩了不计其数的坑。最典型的一次业务方抱怨Redis内存从2G涨到8G我一开始以为是数据量涨了后来用OBJECT ENCODING一看发现一堆小Hash全升级成了Hashtable编码内存直接翻倍。从那之后我就意识到不了解Redis底层数据结构与实现原理你连内存都省不明白。这篇内容不贴大段源码而是把Redis到底怎么存储数据、每个底层结构为什么存在、什么时候会发生编码切换、这些原理又如何影响缓存、分布式锁、自增计数等日常场景一次性讲透。适合正在准备面试的开发者也适合想真正把控线上Redis表现的运维和架构师。1. Redis整体架构先搞清它到底“长什么样”1.1 从RedisServer到RedisObject的存储链路Redis本质上是一个单线程、基于内存的键值数据库。切入底层之前先看数据从命令进入后经过的完整链路RedisServer整个服务实例内部维护着多个数据库默认16个以及其他全局状态。RedisDb每个数据库的核心是两张dict哈希表一张存正式的key-value数据一张存带过期时间的key。dict每个key都是一个SDS字符串每个value则是一个RedisObject对象。也就是说Redis最外层就是一张大哈希表。你执行SET name tom底层实际干的事情是先在dict中查找name这个key的槽位然后把一个字符串对象作为value挂进去。这个外层结构是理解一切的基础。Redis的快离不开这个O(1)级别的键定位而Redis的很多坑比如rehash时产生的短暂性能抖动、大字典扩容导致的内存翻倍也都源自这张大哈希表。1.2 value的“皮囊”RedisObject对象你往Redis里存一个字符串、一个列表、一个哈希它们最终都会被包装成一个统一的RedisObject结构体包含以下几个核心字段type类型也就是STRING、LIST、HASH、SET、ZSET这些对外类型。encoding编码表示这个对象底层到底用什么结构存储。ptr指向底层数据结构的指针。refcount引用计数用于共享对象和回收内存。lru记录访问时间或LFU频率信息供淘汰策略使用。这就是Redis“数据类型”和“底层结构”之间存在映射关系的根源。比如同样是STRING类型encoding可能是INT整数、EMBSTR短字符串内联存储或RAW长字符串普通存储。同样一个HASH类型小数据量时可能是ziplist或listpack数据量大了就变成hashtable。1.3 底层数据结构全家桶Redis在底层实际会用到的核心结构大致是这几类SDS、双向链表、压缩列表、快速列表、跳跃表、整数集合、哈希表、listpack。它们服务于不同的数据类型和不同的数据规模组合逻辑我放到后面的编码转换章节细讲。理解这张结构清单之后你要记住一个核心观点Redis并没有给每种数据类型绑定唯一一种底层结构而是根据数据量、元素大小、访问模式动态选择。这个“动态选择”的设计正是Redis能在内存占用和访问性能之间取得平衡的关键。2. 逐个拆解Redis底层数据结构2.1 SDS简单动态字符串Redis字符串的基石C语言传统字符串用的是以\0结尾的char数组但是Redis没有直接用而是自己封装了一套SDS简单动态字符串。为什么因为C字符串在Redis这种高性能场景里短板太明显。C字符串有三个要命的问题获取长度要遍历时间复杂度O(n)。追加内容时如果忘了分配内存会直接缓冲区溢出。只能存文本遇到二进制数据里的\0会被截断。SDS的结构大致是len记录长度alloc记录已分配内存flags标记SDS头类型后面紧跟buf[]字节数组。长度获取直接读len字段O(1)扩容时由API自己完成内存分配不会溢出判断结束看len而不是\0所以存图片、序列化数据都没问题。这里有个非常值得学习的优化细节空间预分配。每次SDS扩容时如果修改后的长度小于1MB直接多分配一倍空间如果大于等于1MB则多分配1MB。这样连续执行多次字符串追加操作时后续的分配可以复用之前预分配的空间减少系统调用次数。我实测过一个场景往一个key上反复APPEND大量小片段有预分配和没有预分配性能差异非常明显。还有一个“惰性空间释放”策略。在SDS上缩短字符串时并不立即归还内存而是把len改小、保留剩余空间。这样后面再次增长时可以直接复用。这种“先留着说不定以后能用”的思路在Redis里反复出现。2.2 dict哈希表rehash是重头戏前面说过Redis整个键空间就是一张dict。dict内部有两个哈希表数组编号ht[0]和ht[1]正常数据在ht[0]当扩容或缩容时数据会逐步迁移到ht[1]。哈希冲突怎么解决链地址法每个桶挂一个链表。当链表过长时查询性能会退化所以字典需要扩容。扩容和缩容的触发条件扩容没有RDB子进程或AOF重写子进程在跑时负载因子used/size大于等于1就扩容有子进程在跑时阈值提高到5。缩容负载因子小于0.1时收缩。为什么有子进程时阈值要调高因为RDB快照和AOF重写依赖fork出的子进程子进程通过写时复制COW共享父进程内存。如果此时父进程大量扩容会触发大量内存页复制导致内存瞬间翻倍。所以Redis宁可让哈希表稍微拥挤一点也不在持久化期间搞大动作。rehash不是一次性完成的而是渐进式的。原因很简单如果一个字典里有几百万个元素一次性rehash会阻塞主线程Redis直接卡死。渐进式rehash的做法是每次对dict做增删改查时顺便迁移一小部分桶另外还会用后台定时任务在空闲时持续迁移。迁移期间新增数据只会写入ht[1]ht[0]里的数据只减不增。哈希算法方面Redis从3.0开始使用SipHash替代了原来的MurmurHash。SipHash是一种对哈希碰撞攻击免疫更好的算法能防止攻击者故意构造大量相同哈希值的key把哈希表拖成链表。2.3 skiplist跳表为什么偏偏选它跳表主要用来实现有序集合ZSET的有序部分以及集群模式下保存slot与key的映射。跳表是一个多层链表结构。最底层是一个完整的有序链表每往上一层节点数量按概率减少。查找时从最高层开始一路向右、向下最终落到目标位置时间复杂度O(log n)。关键问题是ZSET要实现有序用红黑树或平衡树不香吗为什么选跳表答案有三个层面实现简单。红黑树的旋转、变色逻辑复杂跳表代码量少得多也更容易调试。范围查询方便。ZRANGEBYSCORE这类操作要连续访问一段有序数据跳表在找到起点后沿链表往后遍历就行红黑树找完起点之后还得中序遍历。内存和性能可控。节点层数由随机函数决定期望层数不高整体内存开销可以接受。每个跳表节点会随机生成层数。Redis用的是概率p0.25也就是说每往上一层概率是25%约四分之一的节点是1层十六分之一的节点有3层以上。这种幂律分布让跳表既能做到log级查找又能控制内存开销。这里还有个容易被忽略的细节ZSET其实不止用了跳表同时还用了一张dict来保存member到score的映射。为什么要两份结构因为如果只靠跳表想要更新某个成员的分数还得遍历O(log n)先找到节点。有了dict直接O(1)定位到member再在跳表里删除旧节点、插入新节点效率高很多。2.4 ziplist、listpack和quicklist紧凑存储的进化史面向小数据量Redis设计了一系列连续内存的紧凑结构目的就一个省内存、提高缓存友好性。早期的ziplist把所有元素按紧密排列存储在一块连续内存里每个条目由prevlen、encoding、data三部分构成。从后往前遍历靠prevlen回退。ziplist非常省内存但有个著名的缺陷连锁更新。什么叫连锁更新假设有一串长度都在250字节左右的节点prevlen字段占1个字节。这时在表头插入一个300字节的新节点第二个节点的prevlen就装不下了必须从1字节扩到5字节。这个节点变大之后第三个节点的prevlen也跟着不够用……于是像多米诺骨牌一样扩散。最坏情况下一次插入会导致O(n²)的复杂度。ziplist的连锁更新在极端场景确实会发生Redis官方也承认。于是listpack被设计出来作为替代方案。listpack每个元素保存的是自身的长度信息不依赖前一个节点的大小从后向前遍历时靠元素尾部的backlen字段从根本上消除了连锁更新问题。从Redis 7.0开始listpack已经全面替换ziplist作为List、Hash、ZSET等类型小数据量时的默认紧凑结构。那List类型呢List本身可能是很长的列表不能直接拿一个大ziplist一直塞否则中间插入的成本太高。Redis 3.2之后引入quicklist也就是“双向链表压缩节点”的组合。quicklist的每个节点是一个ziplist或listpack节点之间用指针连接。这样既避免了纯双向链表的节点碎片化和高指针开销又避免了单个紧凑结构过大导致的更新低效。quicklist还有两个有意思的配置list-max-listpack-size控制每个节点最多能存多少元素或多大字节数list-compress-depth控制两端多少个节点不压缩、中间节点用LZF算法压缩。对冷数据多的长列表开启中间压缩能省不少内存。2.5 intset整数集合小巧而精致的专用结构当集合对象全是整数、元素数量又不大的时候Redis用intset来存储。intset本质上就是一个有序的整数数组按从小到大排列查找用二分法时间复杂度O(log n)。intset最核心的机制是升级。底层按当前最大需要的编码宽度来存比如只有1到1000这些数就用int16存储当插入一个需要int32才能装下的大整数时整个数组重新分配内存所有旧元素重编码成int32。这一过程是O(n)但只在升级发生时才有成本。注意intset只支持升级不支持降级。即使之后删掉了那个大整数intset也不会退回int16编码。这其实是Redis的常规操作哲学向上兼容易向下转换难因为降级需要重新遍历整个集合判断可行性得不偿失。3. 对象编码机制什么时候用紧凑结构什么时候切换3.1 各数据类型默认编码与切换阈值理解了底层结构再看数据类型和编码的映射关系就清楚了。不同类型的默认编码和切换条件大致如下STRING小整数用INT编码长度不超过44字节的短字符串用EMBSTR编码字符串对象头和SDS头一起分配减少内存碎片超过44字节用RAW编码。LIST从3.2开始底层统一用quicklistquicklist节点内部是listpack或ziplist受list-max-listpack-size控制。HASH元素少且值小时用listpack或ziplist超过阈值后转为hashtable。常见默认是字段数超过128或512或某个字段值长度超过64字节时转换。SET全整数且数量不超过512时用intset否则用hashtable。ZSET元素少且成员长度短时用listpack或ziplist超过阈值常见128个元素或成员长度超过64后转为skiplistdict复合结构。这些阈值不是固定的不同大版本差异不小具体以你安装版本的redis.conf为准。7.0之后配置名基本都从ziplist改成了listpack比如hash-max-listpack-entries。3.2 用OBJECT ENCODING命令亲眼观察理论知识说得再多不如自己敲一遍。我强烈建议你开个Redis实例一边操作一边看编码# 连接Redis后执行 redis-cli 127.0.0.1:6379 SET num 123 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 HSET h1 f1 v1 (integer) 1 127.0.0.1:6379 OBJECT ENCODING h1 listpack你可以批量往一个Hash里塞字段直到超过阈值再用OBJECT ENCODING看它变成hashtable。这个过程比看一百张原理图都直观。我甚至建议你在测试环境把hash-max-listpack-entries调成3然后用4个HSET触发转换现场感受一下编码切换的临界点。3.3 编码转换的实践影响编码转换最坑的一点是从紧凑结构转向散列结构是单向的不可逆。也就是说一个Hash因为字段太多从listpack升级成了hashtable之后即使删掉大部分字段它也不会自动变回listpack。这个特性在实际线上影响非常大。我曾经处理过一个用户会话存储的场景每个用户的会话字段数稳定在500个左右但偶尔有用户达到几千个。由于阈值设置不合理几乎所有用户都升级成了hashtable内存占用比预想翻了一倍还多。最后只能调整配置、重新构建数据才解决。所以生产环境的经验是先想清楚每种数据类型的最大规模再配合CONFIG GET确认当前版本的实际阈值必要时显式调大紧凑结构的阈值让更多对象留在省内存的编码中。4. 底层结构如何影响你的日常使用4.1 用Hash还是String内存差距的根源很多教程说“对象存储用Hash比用String拼接省内存”但没说原理。现在可以解释清楚了小规模Hash底层是listpack或ziplist多个字段和值连续排列在同一块内存里不用为每个字段单独维护一个RedisObject和SDS省掉了大量指针和对象头开销。而如果用String存比如user:1:name、user:1:age这种key每个key都要在全局dict里占一个entry每个value都是一个独立的SDS和RedisObject光对象头就多出几十字节。数据量一大差距非常惊人。但要注意Hash的优势只在底层还是紧凑结构时成立。一旦字段数太多转入hashtable每个字段都要独立分配内存省内存优势就明显缩水。所以场景评估要基于“会不会触发编码转换”来决策。4.2 自增“不准”和分布式锁的真实情况热词里有“redis incr不准”不少同学在并发计数场景遇到过。这里必须澄清Redis单线程模型下INCR命令本身是原子的不存在命令内部“算错”的问题。那为什么不“准”常见原因在客户端和架构层面客户端超时后重试同一请求执行了两次。使用主从架构时从节点读取的是旧值。key设置了过期时间到期自动清空导致计数归零。多个应用实例各自写了incr之外的补偿逻辑互相覆盖。排查这类问题我建议先看客户端日志里有没有重试或超时再用MONITOR命令观察实际到达Redis的命令序列基本能定位。分布式锁的场景也同样依赖单线程原子性。正确姿势是直接用一条命令完成加锁和过期时间设置SET lock_key unique_value NX PX 30000释放锁时不能用简单的DEL因为可能误删别人的锁。标准做法是用Lua脚本先比较value再删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这套机制和底层结构没有直接关系但它依赖Redis单线程执行命令的特性——判断和删除在同一个脚本中原子完成才不会有并发窗口。4.3 DEL大key为什么会卡UNLINK为什么没事很多人遇到过线上执行DEL一个大List或大HashRedis突然出现毫秒级甚至秒级卡顿。原因就是删除一个底层由大量节点构成的对象时需要逐个释放内存这个过程在主线程同步执行。Redis 4.0之后提供了UNLINK命令它先把对象从键空间中摘除再把回收工作扔给后台线程异步执行。主线程只做指针操作所以几乎不阻塞。哪些key适合用UNLINK只要你能确定是大对象就用。配合SCAN遍历大key并用UNLINK删除是线上治理bigkey的标准操作。5. 面试高频问题快查表把底层结构和实现原理复习完之后顺手整理一份高频面试题和回答要点方便自查问题回答要点Redis为什么快内存访问、单线程避免锁竞争、IO多路复用、高效的底层数据结构SDS比C字符串好在哪O(1)取长度、自动扩容防溢出、二进制安全、预分配和惰性释放dict什么条件下扩容无子进程时负载因子1有持久化子进程时5渐进式rehash期间数据怎么访问两个表一起查新增只写新表旧表数据逐步搬移ZSET为什么用跳表不用红黑树实现简单、范围查询方便、随机层数控制内存红黑树实现复杂ZSET为什么还需要dict用member直接定位score更新分数时不用先遍历跳表intset升级机制插入更大数值时整体重编码O(n)且不降级ziplist连锁更新是什么节点长度变化导致后续节点prevlen字段连锁扩容listpack通过自包含长度解决编码转换能回退吗不能紧凑结构转散列结构是单向的如何查看key底层编码OBJECT ENCODING key大key删除用什么UNLINK异步回收避免阻塞主线程Redis分布式锁怎么实现SET lock value NX PX加锁Lua脚本比较释放注意续期和时钟问题6. 我建议的进阶路径看完这些内容如果你还想往下钻我建议不要直接去读源码而是照着这个顺序来先搭一个本地Redis实例用OBJECT ENCODING把所有数据类型的编码切换触发一遍把阈值参数调小再触发一遍记录内存变化数据。这一步能让“原理”变成“手感”。然后可以挑一个最常用的结构比如SDS或跳表只看它的核心文件重点是扩容路径和查找路径不需要通读所有代码。最后结合生产场景做一次Redis内存分析找出所有编码已经升级但数据量并不大的key思考当初的配置是否合理。这比任何面试题都更能帮你理解Redis的底层哲学一切设计都是为了在有限的CPU和内存里换取最高效的访问路径。我在实际排查线上问题时最大的体会是Redis的底层结构从来不是孤立的八股知识它直接决定了你会踩哪些坑、省多少内存、躲过多少阻塞。把这一层想通了再回头看那些八股面试题你会发现它们其实都是工程取舍题。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →