你可能也遇到过这种场景: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用的是概率p=0.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开始底层统一用quicklist,quicklist节点内部是listpack或ziplist,受
list-max-listpack-size控制。 - HASH:元素少且值小时用listpack或ziplist,超过阈值后转为hashtable。常见默认是字段数超过128或512,或某个字段值长度超过64字节时转换。
- SET:全整数且数量不超过512时用intset,否则用hashtable。
- ZSET:元素少且成员长度短时用listpack或ziplist,超过阈值(常见128个元素或成员长度超过64)后转为skiplist+dict复合结构。
这些阈值不是固定的,不同大版本差异不小,具体以你安装版本的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或大Hash,Redis突然出现毫秒级甚至秒级卡顿。原因就是:删除一个底层由大量节点构成的对象时,需要逐个释放内存,这个过程在主线程同步执行。
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的底层结构从来不是孤立的八股知识,它直接决定了你会踩哪些坑、省多少内存、躲过多少阻塞。把这一层想通了,再回头看那些八股面试题,你会发现它们其实都是工程取舍题。