ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis底层数据结构与内存优化:从SDS到Listpack的实战解析

Redis底层数据结构与内存优化:从SDS到Listpack的实战解析 1. Redis的内存魔法数据结构就是它的武器库Redis现在几乎是后端开发绕不开的组件缓存、分布式锁、排行榜、消息队列到处都有它的身影。面试的时候Redis为什么快属于必考题但你观察过没有很多人答到因为基于内存就接不下去了——这么说其实没答到点子上。MySQL把热数据放内存里也快但同样扛不住Redis这种百万级QPS。真正的差异在哪儿在于Redis把内存当成了主力存储并且围绕内存的特性手工设计了一整套高效的数据结构。你可以把Redis想象成一个极度抠门的内存管家每多存一个字节它都心疼。为了省内存它愿意用更复杂的指针结构、更紧凑的编码方式、甚至牺牲一点点CPU来换取空间的节省。这篇文章我想把Redis的数据结构和内存优化这两块硬骨头拆开揉碎讲清楚底层到底是怎么设计的以及你在实际项目中应该怎么利用这些特性把内存开销压下来。这篇文章的内容足够你应付三类场景面试时被问到Redis底层数据结构不卡壳工作中遇到内存暴涨知道从哪儿查做架构设计时能算出Redis大概要用多少内存。全文不涉及环境搭建和客户端使用聚焦在原理和实战优化上把为什么讲透。2. 先从源头说起Redis为什么敢说自己快2.1 单线程模型的底气Redis 6.0以前核心命令执行是单线程的网络IO模块在6.0以后虽然支持了多线程但命令执行依然是单线程。为什么单线程还能这么快一个非常重要的原因是命令执行的过程里没有锁竞争也没有线程切换的开销。多线程程序最大的噩梦就是并发访问共享资源你需要加锁、需要处理死锁、需要应对上下文切换导致的缓存失效。Redis直接绕开了这个问题事件循环里一个个处理命令天然就是线程安全的。当然单线程的前提是所有的操作都足够快。如果你执行一个O(N)的慢命令比如KEYS *扫描几百万个key整个Redis就卡在那里后面所有请求都得排队。这也是为什么Redis的官方文档反复强调生产环境不要用KEYS要用SCAN。说句题外话Redis的作者在早期为什么坚持单线程除了实现简单之外他当时的判断是Redis的性能瓶颈不在CPU而在内存和网络。既然一个线程就能跑满网络带宽何必搞多线程给自己找麻烦这个判断在今天依然基本成立除非你的单条命令特别重、或者数据量特别大否则多线程带来的提升并不明显。2.2 高效数据结构是快的基础如果把Redis比作一辆跑车单线程是发动机的调校方式那么底层的数据结构就是发动机本身的机械素质。Redis最快的部分是它的hash查找和内存拷贝这些操作的时间复杂度都控制在O(1)或者O(logN)但光有时间复杂度还不够还要考虑常数因子也就是每个操作实际执行了多少条CPU指令。举个例子STRLEN命令获取一个字符串的长度在Redis中时间复杂度是O(1)。这个操作如果放在普通C语言里你需要调用strlen()去遍历整个字符串直到遇到\0数据越长耗时越长。Redis为什么能做到O(1)因为它不是用C语言传统的字符串表示方法而是自己封装了一层叫做SDSSimple Dynamic String的结构长度是提前记录好的字段直接读就行。类似的优化贯穿了Redis的每一个角落。所以你会看到Redis里面没有一个数据结构是拍脑门定的每个字段、每个字节都有它的来历。这也是本文想传达的一个核心观念Redis的内存优化本质上是在跟底层数据结构的设计做搏斗。你理解了SDS、Dict、跳表这些底层结构之后再去看那些内存优化的技巧就会有豁然开朗的感觉。3. Redis核心数据结构全景拆解3.1 SDS比C字符串更聪明的字符串Redis没有直接使用C语言的字符串而是定义了一个叫做sdshdr的结构体。它的核心思想是在字符串前面加了一个头部记录长度、剩余空间、以及当前使用的SDS类型。看一个简化版本的样子struct sdshdr { int len; // 已使用的长度 int alloc; // 分配的总容量 char buf[]; // 真正存储字符的字节数组 };这个设计带来了几个实打实的好处第一获取字符串长度是O(1)。因为len字段直接存着长度不用从头遍历到\0。第二杜绝了缓冲区溢出。C语言里执行strcat如果目标空间不够会直接越界写坏内存。Redis里在追加字符串之前会先检查alloc - len是否够用不够就先扩容保证不会写穿。第三空间预分配。当需要扩容时Redis不只是精确地扩到刚刚够用而是会多分配一些余量。如果修改后的长度小于1MB就多分配一倍如果大于等于1MB就多分配1MB。这样的好处是如果你连续做多次APPEND操作只有前面几次需要真正的内存重新分配后续都在预留空间里写性能会稳定很多。第四惰性空间释放。字符串缩短时Redis不会立刻把内存归还给系统而是通过修改len字段把缩短的部分标记为可用空间留着下次追加的时候再使用。这样避免了频繁的malloc和free对性能的冲击。还有一个容易被忽视的点SDS是二进制安全的。C字符串遇到\0就认为字符串结束了但SDS通过len字段来决定字符串边界所以它可以存储二进制数据比如序列化后的对象、图片的字节流甚至包括\0在内的任意字节。3.2 DictRedis的万能基石Redis的key-value映射关系本质上就是一个全局的大字典。除了README和配置文件Redis里几乎所有能按名字找到东西的场景底层都是Dict。Dict的结构核心是两个哈希表数组每个哈希表里是一组桶bucket每个桶是一个单向链表的头指针。当发生哈希冲突时新的键值对会采用头插法挂到链表的头部。这里我要展开讲一讲Dict的渐进式rehash这是Redis面试里一个非常高频的考点而且很有设计巧思。当数据量增长到一定程度哈希冲突会变严重链表越来越长查找性能下降这时候就需要扩容。简单粗暴的做法是申请一个大数组把所有元素重新计算哈希值并插入到新数组然后把旧数组释放掉。但在Redis这种大数据量场景下重新哈希所有元素可能耗时几十毫秒甚至更长这段时间内Redis没法处理其他请求这是不可接受的。Redis给出的方案是把rehash操作分摊到每次增删改查上。具体做法是为哈希表2分配空间空间大小通常是哈希表1的两倍。维护一个rehashidx变量初始为0。每次对Dict执行增删改查命令时除了完成命令本身的操作还会顺带把哈希表1中rehashidx位置上的整条链表重新哈希到哈希表2然后rehashidx加1。当rehashidx递增到哈希表1的长度时说明所有元素都搬完了释放哈希表1交换表1和表2的角色把rehashidx重置为-1表示rehash结束。这个过程妙在没有大块的停顿把一次耗时的大操作细分为N次微小操作每次只花几十个纳秒。代价是rehash期间查找一个key需要同时在两张表里找先查表2查不到再去表1查。这种边用边搬家的思路在系统设计里非常有借鉴意义。3.3 跳表为什么Redis选择它而不是红黑树有序集合ZSET是Redis里功能最丰富的数据结构它同时支持按分数排序、按分数范围查询、按成员查询分数。实现这些能力的数据结构是跳表Skip List而不是更常见的平衡树。跳表本质上是一个多层级的有序链表。最底层是一个完整的链表保存了所有元素往上一层每两个元素抽出一个作为索引节点再往上一层再抽出一半。这样在查找时可以从最高层开始快速跳过一大批节点逐层下降最终落到最底层完成精确查找。为什么Redis不用红黑树而是自己实现了一个跳表我总结有三个核心理由第一实现简单、不易出错。红黑树的插入删除需要左旋右旋、变色逻辑相当复杂任何一个细节没处理好就可能导致树失衡。跳表的实现就是标准的链表操作加随机层级代码量少得多出bug的概率也低。第二范围查询天然高效。ZSET经常要做ZRANGEBYSCORE这种范围操作即给我分数在100到200之间的所有元素。跳表本身就是一个有序链表从头节点开始沿着底层链表顺序遍历就行了非常自然。红黑树虽然也能做范围查询但需要中序遍历还得维护前驱后继指针没有链表来得直接。第三内存占用可接受。跳表每个节点的层级是随机生成的平均每个节点有1.33个额外指针因为层级概率是1/4期望值是1/(1-0.25)1.33和红黑树每个节点500字节左右的开销相比跳表这点额外指针损耗不算大。跳表的插入操作也很优雅新节点通过随机数决定它的层级比如有25%的概率升到第二层有6.25%的概率升到第三层。这种随机性保证了整张表的结构在宏观上是均匀的不会出现红黑树那种需要频繁自平衡的麻烦事。3.4 IntSet纯数字集合的极致紧凑当你的Set集合里全是整数而且元素数量不超过一定阈值时Redis会使用IntSet编码来存储而不是直接用哈希表。IntSet的核心思想是把所有整数按从小到大的顺序排列在一块连续的内存里。它的结构大概是typedef struct intset { uint32_t encoding; // 编码方式int16_t、int32_t、int64_t uint32_t length; // 元素个数 int8_t contents[]; // 数据区连续存放整数 } intset;关键是这个encoding字段。假设一开始集合里全是小整数每个元素只需要2字节int16那么整个contents数据区就用int16数组来管理。如果某天你想插入一个超过32767的整数IntSet会触发一次升级操作把整个数组的元素从int16逐一到int32重新排列一遍。升级意味着什么第一旧数据全部要搬家时间复杂度和元素数量成正比第二升级后整个集合的元素都变成了int32可能包含那些原本只需要2字节的元素内存消耗也上升了。所以如果你能预估集合里元素的取值范围尽量保持较小编码能省不少内存。还有一个面试常问的点IntSet只支持升级不支持降级。即使你把所有大整数都删了编码也不会回到int16。这是为了实现简单避免频繁的内存搬移。3.5 跳表加DictZSET的黄金搭档如果你去查看一个ZSET的底层实现会发现它不是单独用跳表而是跳表Dict组合。这俩的分工很明确跳表按分数排序负责处理ZRANGEBYSCORE、ZRANK、ZREVRANGE这类排序和范围操作。Dict按成员名索引负责处理ZSCORE查某个成员的分数、ZADD时判断成员是否已存在这类精确查找。为什么需要Dict试想如果只有跳表你想查一个成员的分数得从跳表头节点沿着链表一个一个找时间复杂度O(N)这在大数据量下不可接受。有了Dict直接O(1)就找到了成员对应的分数。反过来当你要按分数排名时Dict是做不到的必须依靠跳表的有序特性。所以这两个结构是互补关系一个擅长精确查找一个擅长有序访问。这个结构组合的思路在你设计自己的系统时也值得借鉴——没有一个万能的数据结构但两个结构各司其职就能覆盖复杂的需求。4. 内存优化Redis是怎么做到能省则省的4.1 RedisObject每个对象都有的身份证明Redis存储任何value最外层都是一个叫做RedisObject的结构体。它的定义大致如下typedef struct redisObject { unsigned type:4; // 数据类型string、list、hash... unsigned encoding:4; // 编码方式int、embstr、raw、ziplist... unsigned lru:24; // LRU时间或者LFU的计数 int refcount; // 引用计数 void *ptr; // 指向实际数据存储的指针 } robj;这个结构体的大小是16字节。你可能会说16字节也不大啊。但注意Redis里哪怕存一个只有1字节的字符串也要先花16字节建一个RedisObject再花额外空间存实际数据。如果你有100万个小value光是RedisObject这层就要消耗16MB。所以内存优化的第一层就是想尽一切办法让RedisObject尽可能少地出现或者让它指向一个足够紧凑的存储区域。4.2 三种字符串编码int、embstr、raw字符串类型的value根据内容和长度的不同会采用三种不同的编码如果value是一个可以用long型表示的整数比如12345Redis会直接把数字存在RedisObject的ptr指针位置实际上是把数字强转成指针不分配额外的SDS空间。这就是int编码。如果value是一个字符串长度小于等于44字节采用embstr编码。此时RedisObject和SDS结构体是分配在同一个连续内存块里的一次malloc就能搞定内存碎片少CPU缓存命中率高。如果字符串长度超过44字节采用raw编码。RedisObject和SDS是两块独立的内存需要两次malloc。这里有一个非常经典的问题为什么是44字节很多人背答案背得溜但不知道来龙去脉。原因是现代内存分配器比如jemalloc倾向于按固定大小分箱分配内存常见的有64字节等。Redis假设一次分配64字节的内存块RedisObject占16字节SDS的头部以sdshdr8为例占3字节字符串末尾还要有个\0结束符占1字节剩下还有64 - 16 - 3 - 1 44字节可以用来存数据。所以当字符串内容在44字节以内时可以塞进同一个64字节内存块里这就是embstr的边界。这个数字不是谁拍脑门定的而是通过计算内存对齐之后得出的最优值。了解这一点你在设计的时候就能理解为什么有些小字符串能节省内存有些不行。4.3 共享整数对象池小整数的特殊待遇如果你在Redis里设置几千个字符串value为100难道每次都要新建一个RedisObject吗不用。Redis在启动的时候会预先创建一批从0到9999实际是OBJ_SHARED_INTEGERS默认10000个的整数对象当value恰好落在这个范围内时直接复用已有的共享对象只是把refcount加一。这个机制在节省内存方面很有效因为大量场景下的小整数都是重复的。但它有一个隐蔽的坑共享整数对象默认带有LRU淘汰相关的字段它是全局共享的所以这些共享对象的LRU字段并不会更新。这意味着如果你开启了maxmemory并使用了allkeys-lru淘汰策略这些被共享引用的小整数对象永远不会被淘汰。这一点在极端情况下可能会导致内存无法释放需要特别注意。4.4 Ziplist把小对象压成一行字符串之外哈希、列表、有序集合在元素少且单个元素短的情况下都会使用Ziplist编码。Ziplist的核心思想是把多个元素紧挨着存放在一块连续的内存里每个元素前面加一个小的头信息。Ziplist的整体布局是zlbytes记录整个列表占用的字节数zltail记录最后一个元素的偏移量方便尾部操作zllen记录元素个数若干个entry真正的数据区zlend一个255字节的结束标记每个entry由三部分组成prevlen前一个entry的长度用于从后往前遍历、encoding当前entry的编码和长度、data实际数据。Ziplist最大的优势是空间利用率高没有链表那种每节点两个指针的开销也没有节点之间的内存碎片。缺点也很明显插入和删除元素时如果涉及内存搬移代价是O(N)更麻烦的是可能引发连锁更新。什么叫连锁更新假设Ziplist里有一系列entry它们的长度恰好都在253到254字节之间每个entry的prevlen只需要1字节存储。此时如果在列表头部插入一个特别长的entry超过254字节第二个entry的prevlen就得从1字节变成5字节这可能导致第二个entry的总长度超过254字节于是第三个entry的prevlen也得跟着变……像多米诺骨牌一样连锁更新会持续到列表末尾。连锁更新在最坏情况下的时间复杂度是O(N^2)虽然实际中概率不高需要特定的长度分布但它确实是Ziplist的一个理论缺陷。这也是Redis 7.0用Listpack替代Ziplist的导火索之一。4.5 Listpack修复连锁更新的下一代编码Listpack是Redis 7.0引入的用来逐步替代Ziplist。它和Ziplist最大的区别在于每个entry不再保存前一个节点的长度而是只保存当前节点的长度。这样谁都不依赖谁自然就不存在连锁更新问题。Listpack的entry结构变成了一个可变的长度字段根据元素长度决定用几个字节一个可变的编码字段数据本身由于每个entry都自包含长度信息单向遍历很简单但反向遍历相对于Ziplist会稍微复杂一些需要依赖每个entry末尾的一个特殊backlen字段。不过这个代价比起解决连锁更新问题来说是值得的。如果你在用Redis 7.0及以上版本新创建的list和hash在小数据量下默认使用Listpack内部编码名是listpack这个细节你在OBJECT ENCODING命令里能看到。4.6 Quicklist双向链表与Ziplist的混血儿列表对象在数据量比较大时不会直接用纯双向链表每个节点存一个value两个指针开销太大而是用Quicklist。Quicklist是一个双向链表但链表的每个节点不是存单个值而是存一份Ziplist7.0以后是Listpack。你可以这么理解Quicklist是扁平的数组分块。每个节点是一个压缩块块内部用紧凑的Ziplist/Listpack存放多个元素块与块之间用指针串联。Redis提供了配置项list-max-ziplist-size控制每个节点最多存多少个元素默认是128。如果你把值设置得很大Quicklist就更像一个大数组节省指针开销但插入删除慢设置得小则更像普通链表适合频繁在中间插入删除的场景。实际使用中如果你的List只是拿来做消息队列FIFO那么Quicklist的默认配置就够用了。但如果你的List是高频小元素大量写入的场景可以适当增大list-max-ziplist-size减少节点数量从而减少内存碎片和指针开销。5. 内存治理实战从参数调优到问题排查5.1 maxmemory和三种淘汰策略任何Redis实例都不应该任由内存无限增长否则迟早会把机器内存打爆触发系统OOM Killer那就不是Redis挂掉的问题而是整个宿主机都可能受影响。所以生产环境必须设置maxmemory。当内存达到maxmemory上限后Redis根据maxmemory-policy决定如何处理新写入的请求。8种策略里我挑重点说noeviction不淘汰直接返回错误。适合当缓存不允许丢数据的关键场景但业务要能承受写入失败。allkeys-lru从所有key中挑选最近最少使用的key淘汰。适合标准的LRU缓存模式。volatile-lru只从设置了过期时间的key中挑选LRU淘汰。适合缓存持久化混合的场景。allkeys-lfu按访问频次淘汰LFU能更好地抵御偶发流量冲击。对一些频繁访问但间隔较长的热点LFU比LRU更接近真实场景。volatile-ttl优先淘汰剩余存活时间最短的key其实就是快过期的先淘汰。这里我特别提醒一个容易踩的坑如果业务里大量key都没设过期时间但你把maxmemory-policy设成volatile-lru那么当内存不够时Redis不会去动那些没有过期的key结果就是内存依然暴涨写入失败。策略选择必须和数据模型匹配这是经验之谈。另外maxmemory设置的大小要留有余量。我习惯设置为机器物理内存的60%到70%剩下的内存留给操作系统页缓存、其他进程以及Redis自身的运行开销。别把鸡蛋全放在一个篮子里。5.2 内存碎片的形成与治理很多人在Redis内存飙升时会发现一个奇怪的现象实际使用的内存数据量明明不大但INFO memory里的used_memory_rss进程占用的物理内存却高得吓人。这就是内存碎片在作祟。Redis内存碎片的来源主要有两个第一Redis频繁执行更新操作比如APPEND一个字符串、往list里push再pop内存分配器为了性能会保留一些无法复用的空闲块这些块没有归还会导致碎片率上升。第二大key的增删。创建一个大的字符串对象分配一整块连续内存删除时把这整块内存交还给分配器。分配器可能因为找不到合适大小的块来复用而把大块切成小块或者留着大块给后续的大对象用但中间的空隙就成了碎片。如何判断碎片率看INFO memory里的mem_fragmentation_ratio计算公式是used_memory_rss / used_memory。在4.0以上的Redis里可以直接看allocator_frag_ratio字段。碎片率在1到1.5之间是正常的如果超过1.5说明碎片问题比较严重。你可以通过CONFIG SET activedefrag yes开启自动整理它会在后台把分散的小内存块拷贝合并腾出连续空间。但注意主动整理会占用CPU和内存带宽如果实例本身流量已经很高就不要开启否则反而拖慢性能。碎片问题最有效的根治手段是重启Redis让所有数据重新加载一遍从全新的内存布局开始。但重启意味着缓存全部失效会导致一瞬间的高负载打到数据库上需要结合业务低峰期和持久化策略来做。5.3 key与value的瘦身计划很多时候Redis内存居高不下不是数据量真的大而是key和value的设计太臃肿。这块的优化收益往往是最直接的。key的命名要短且有可读性。我见过有人用user_profile_info_123456这种长度超过20字符的key如果是几十万个用户光key就浪费了几MB。合理的方式是user:profile:123456在保证可读性的前提下尽可能压缩长度。value的序列化方式要慎重。如果你存的是JSON字符串原生的{name:张三,age:18}这种结构在Redis里就是一段字符串数据越长发内存越高。改用MessagePack或Protobuf这类紧凑的二进制序列化同样语义的数据体积能压缩一半以上。尤其是在大value场景下压缩收益非常明显。hash结构实现对象存储比字符串拼接省内存。假设你要存一个用户对象包含id、name、email三个字段。方案一是用三个字符串key每个key都有一个RedisObject和SDS头部方案二是用一个hash字段名加字段值封装在一个Ziplist/Listpack里。当字段数量少、字段值短时hash的紧凑编码远胜于三个独立的字符串。我做过一个实验10万个用户对象用三个字符串key存大约消耗73MB内存用hash统一存每个用户一个hash也有开销最省的是把用户按id分段每1000个用户放一个hash内存能降到原来的35%左右。这种分片hash的思路在生产上非常实用。5.4 用Memory Usage和BigKeys定位元凶当你发现Redis内存异常时先别急着改配置先用命令定位一下是谁占了内存。MEMORY USAGE key命令可以直接查某个key的内存占用包括它的RedisObject、编码结构、以及SDS数据区的总大小。这个方法适合精确定位某几个可疑的key。如果是全局扫描用redis-cli --bigkeys。它会遍历整个实例统计每种数据类型里最大的几个key并报告各类key的总量均值。这个工具是Redis自带的不需要额外安装执行起来效果非常直观。注意--bigkeys在数据量极大的时候也比较耗CPU因为要走全量SCAN建议在低峰期执行。另外它基于SCAN游标不会阻塞Redis主线程比KEYS *安全得多。5.5 大key的删除为什么不能暴力del删除一个几百MB的string key或者一个包含几百万字段的hash如果用DEL命令Redis主线程要遍历对象并逐步释放内存这个过程可能耗时数秒期间整个实例卡住其他请求全部排队。这在生产上是绝对不可接受的。Redis 4.0以后提供了UNLINK命令它的机制是先把key从全局字典中摘除使得这个key立即对客户端不可见然后通过后台线程异步释放内存。UNLINK本身时间复杂度是O(1)即使对象再大也不会阻塞主线程。如果你的Redis版本比较老没有UNLINK也可以手动实现分批删除对大的hash用HSCAN每次取100个字段然后HDEL掉。对大的set用SSCAN每次取100个元素然后SREM掉。对大的zset用ZREMRANGEBYRANK按排名区间删除。对大的list用LTRIM每次截掉一部分。这种方案本质上是在小步快跑把一次大操作拆成多次小操作中间留出间隔让Redis喘口气。6. 生产环境的内存优化实战清单6.1 场景一大量短字符串value如果你要存的关键数据本身就是短字符串比如验证码、短token一条条存字符串确实简单但内存开销有优化空间。验证码这类数据生命周期短适合用hash批量存储。假设你有6位验证码123456对应手机号13800138000可以设计这样一个hashhash key: sms:code:20250115 field: 13800138000 value: 123456这样一个hash可以存成千上万条验证码而底层只用一个Listpack/Listpack小数据量时承载节省了大量独立的RedisObject和SDS头。不过要注意这种集中式存储不适合并发写特别高的场景因为单hash会变成热点而且操作粒度变粗了。6.2 场景二排行榜与ZSETZSET是排行榜需求的天然解决方案但ZSET的内存开销远高于String。每个ZSET成员在跳表里有一个节点在Dict里还有一个节点两个结构各存了一份成员名这就存在数据重复。优化思路有两个一是尽量缩短member的长度。member本身就是集合的标识如果用用户ID尽量用数字ID而不是user_123456这种字符串。数字ID在内部编码中可以作为整数直接存比字符串省很多字节。二是如果排行榜的分数只增不减可以考虑定期归档。把很久不活跃的成员从ZSET中移除放到普通String或者归档存储里减少ZSET的体积和跳表层级。6.3 场景三Session共享与过期用Redis存Session是常见的做法但Session有个特点重复读取多、增量更新少、过期时间固定。我建议把Session做成hash每个用户一个hash字段是session的各个维度登录时间、权限、购物车摘要等过期时间设成和Session生命周期一致。这样做的内存效率远高于把整个Session对象序列化成一个JSON字符串存成String。因为hash在字段数量少时用的是紧凑编码而序列化JSON无论如何都会产生完整字符串副本。另外一个好处是你可以单独更新某个字段而不需要重写整个Session对象。6.4 过期键清理机制需要知道的细节Redis清理过期key有两套机制惰性删除和定期删除。惰性删除是指当客户端访问一个key时Redis先检查它是否过期如果过期就删掉再返回空。这套机制保证了读路径上不会返回过期数据。定期删除是指Redis每隔一段时间默认每秒10次随机抽取一部分设置了过期时间的key检查并删除已过期的。这是为了处理那些从设置后就一直没人访问的过期key防止它们占着内存不放。但定期删除有一个坑如果实例里有过期时间的key非常多定期删除的CPU开销会上升反过来如果每次抽查的数量太少过期key可能长时间堆积内存迟迟释放不了。在redis.conf里有两个参数可以调节hz定期任务的频率和active-expire-effort过期扫描的力度。一般情况下默认配置够用但如果你的key过期时间短且量大可以考虑适当调高hz到20或者把active-expire-effort设为2或3。6.5 监控指标内存优化要量化优化了半天最后当然要能量化效果。我建议至少监控以下这几个指标指标含义预警阈值used_memory逻辑上使用的内存总量达到maxmemory的80%以上used_memory_rss进程实际占用的物理内存与used_memory差距过大mem_fragmentation_ratio内存碎片率持续超过1.5evicted_keys由于淘汰而删除的key数量持续增长说明内存不足expired_keys过期删除的key数量波动大说明过期key集中配合Grafana或者自研监控系统把Redis的INFO memory和INFO stats里的字段定时采集、绘图内存异常就能第一时间发现。我还习惯在变更之后做一个对比试验比如调整某个hash的最大字段数阈值前先记录used_memory调整后观察两天看看内存曲线是否平滑下降。内存优化不是一锤子买卖是持续观察、持续调整的过程。7. 高频问题与避坑速查整理一些我在实际项目中踩过和看到别人踩过的坑。这些点同时也是面试中经常追问的地方。为什么Redis使用单线程却依然能保持极高的吞吐量除了基于内存的高速读写之外单线程模型下没有锁竞争和上下文切换的开销所有操作都在事件循环里串行执行配合高效的IO多路复用机制单实例就能支撑10万以上的QPS。跳表跟平衡树的区别是什么跳表专为范围查询场景而设计链表结构在按分数范围遍历时天然高效红黑树的平衡维护成本高实现复杂跳表通过随机层级实现平衡平均性能出色并且实现简单、便于调试。Ziplist和Listpack的核心区别是什么Ziplist的每个entry会保存前一个entry的长度连锁更新问题可能导致性能退化Listpack每个entry只保存自己的长度从根本上杜绝了连锁更新。为什么String小于44字节用embstr这是因为Redis假设使用jemalloc的64字节分配箱减去RedisObject的16字节、SDS头部3字节和结尾的\01字节恰好剩余44字节可存数据。关闭持久化可以省内存吗严格意义上不会省太多。AOF和RDB对内存的影响主要是子进程fork时的内存页复制。但如果开启了appendfsync always每次写入都要fsync性能损耗很大。生产环境建议用appendfsync everysec或者根据业务容忍度选择关闭。你能把一个大key删掉而不阻塞Redis吗用UNLINK异步删除或者手动分批扫描删除。核心原则是避免在主线程里执行大规模内存释放操作。查看一个key的编码方式是什么命令用OBJECT ENCODING key可以看到int、embstr、raw、listpack、quicklist等编码类型。这个命令也是判断某个key是否采用了紧凑编码的最直接方式。Redis内存碎片率过高怎么办先查INFO memory确认碎片率然后考虑activedefrag yes开启自动整理如果碎片率持续高于1.5且自动整理无效可以直接主从切换后在低峰期重启实例从备份中重新加载数据。set-max-intset-entries参数的含义是什么控制Set使用IntSet编码的最大元素数量默认512。如果你确定集合全是整数且元素数量很大可以把这个参数适当调大让IntSet编码覆盖更多的集合节省内存。但要注意一旦触发升级到哈希表编码后续即使元素减少也不会回到IntSet。为什么Redis的key数量特别多时内存开销会成倍增长因为每个key都要有一个DictEntry包含key指针、value指针和下一个节点指针加上对应的RedisObject和SDS头部一套下来至少几十字节。如果key数量达到千万级别光这些元数据就能吃掉好几个GB内存。所以减少key数量本身就是内存优化的一种思路这也是为什么hash分片存储常常能省大量内存的原因。8. 最后再分享几个压箱底的经验做了这些年Redis的运维和调优我最大的体会是内存优化不是调几个参数就完了而是要理解数据在内存中的真实布局。你调hash-max-listpack-entries和hash-max-listpack-value时如果不清楚Listpack的结构改起来心里没底你设计key时如果不算SDS的头部开销就不知道一个小改动到底能省多少内存。还有一个观点想强调不要过度优化。把1万个小value从String改成hash分片内存确实省了但代码复杂度上来了排查问题也更困难。内存优化的正确姿势是先用MEMORY USAGE和--bigkeys定位大头优先处理那些占比最高的数据结构收益和复杂度一比值不值心里就有数了。最后如果你在面试中被问到Redis别只背为什么快的固定答案试着从数据结构设计、内存编码、淘汰策略这几个维度展开讲清楚每一步设计背后的权衡。面试官真正想听的是你对系统设计的理解而不是几个名词的堆砌。
返回列表