上周被拉去处理一个线上事故Redis写入大面积超时错误日志里全是OOM command not allowed when used memory maxmemory。翻配置一看maxmemory设成了物理内存的90%淘汰策略还是默认的noeviction——数据量一上来内存打满所有写命令直接被拒。这种配置在开发环境永远体会不到问题上了生产必炸。那次之后我复盘了很久发现“Redis内存管理”这事绝大多数人要么只停留在面试题层面要么就是踩坑之后才开始研究。今天想把Redis在“有限内存”下那套精细操作完整拆一遍内存花在哪、怎么省、满了丢谁、碎了怎么收拾、线上怎么监控。不逐行读源码我从实际运维和开发的视角把整个机制捋成一套能直接用的方法论。1. 先搞清楚Redis的内存都由什么构成1.1 为什么Redis敢把所有数据都放在内存里Redis的设计前提就是“全内存计算”。内存随机访问延迟是几十到一百纳秒级别SSD是几十微秒机械盘是几毫秒差距有三到四个数量级。Redis要在单线程模型下做到单实例十万级QPS唯一的办法就是所有数据都在内存里命令执行路径上不碰磁盘。这个前提带来一个直接后果内存是贵的容量是有限的但Redis必须在这个有限内存里尽量多塞数据并且塞满了还不能崩、不能把延迟拖垮。为了做到这一点Redis把这套账算到了字节级。很多人不知道的是Redis很早期曾经尝试过把冷数据交换到磁盘的VM功能把不常用的key换出去要用的时候再从磁盘读回来。这个方案听起来很美实际跑起来却不行Redis是单线程事件循环一旦某个被换出的key被访问主线程就得等磁盘I/O延迟直接不可控而且还要维护一套swap索引内存没省多少复杂度反而上去了。后来这个功能被砍掉Redis从此坚定走“数据全内存”路线。理解了这段历史你就能明白Redis后来在内存上做的所有优化本质上都是在“必须全内存”这个约束下死磕出来的。1.2 一份数据在Redis里到底占了多大先看一个最常见的误区很多人估算Redis内存只看value有多大。比如一个value是3字节的字符串他以为就占3字节。完全不对。Redis里任何一个kv对内存开销由三部分组成redisObject对象头、SDS字符串头、dictEntry哈希表节点外加底层哈希表数组的分摊空间。64位系统下一个redisObject是16字节type、encoding、lru、refcount、ptr指针SDS还有一个headerdictEntry算上key指针、value指针和next指针大概30字节左右。算下来一个很小的key比如key1 - abc实际RSS占用大概在80到100字节之间其中真正业务数据只有3字节。这就是为什么有些人存一亿个key没事有些人存两千万就崩了——问题是出在key设计上。如果你的key是user:12345:order:67890:detail光key字符串就四五十字节加上各种结构开销单条记录轻松破150字节。十个亿的key光结构开销就是十个GB级别。所以做内存管理第一步不是调参数而是先搞清楚你的内存到底被谁吃了。Redis 4.0之后提供的MEMORY USAGE key命令可以查看单个key的实际内存占用算的是包含结构开销的完整大小不是value长度。这个命令应该是你做任何内存评估的起点。1.3 “省、换、收、清”四字心法Redis的内存管理说白了就是四件事省——数据结构层面尽量压缩存储换——内存满了按策略淘汰不重要的数据收——过期键及时回收清——内存碎片清理归还系统。这四个字背后有一个总约束Redis主线程是单线程的任何内存操作都不能有大规模O(n)遍历或一次性大复制否则一次操作就可能阻塞所有命令。所以你看Redis每一个机制的设计都带着“小步快跑”的味道rehash是渐进式的LRU是采样式的过期删除是抽样分批的碎片整理是后台慢慢搬的。理解了这条总纲后面所有细节都能串起来。2. 数据结构层面的“省内存”设计把每个字节都塞进该用的地方2.1 小集合的压缩编码listpack是怎么省内存的Redis对Hash、ZSet、List这三种类型都有一个“小数据时用紧凑编码”的设计。数据量小的时候不用哈希表链表跳表那套重型结构而是把所有元素紧凑地排在一起。Redis 7.0之前这个小数据编码叫ziplist7.0开始全面换成了listpack。为什么换ziplist有个著名的“级联更新”问题它的每个entry都要记录前一个entry的长度如果中间某个entry的长度变了后面所有entry的prevlen都可能要跟着调整最坏情况下一次更新会引发O(n)次重分配。对小数据集合来说概率不高但Redis里数据规模是不可控的一旦触发主线程就要卡在那做一连串内存移动。listpack去掉了prevlen这个设计反向遍历靠尾部记录的总长度定位彻底消除了级联更新问题。这是Redis 7.0把ziplist全部替换掉的核心原因。对你来说这几个参数值得关注hash-max-listpack-entries默认128、hash-max-listpack-value默认64。意思是Hash的field数量少于128个、且每个field的key和value长度都小于64字节时用listpack紧凑存储超过阈值就升级成真正的哈希表。实际项目中如果key的field很小但数量多比如用户标签、商品属性这种可以把entries调大到256甚至512内存能省下一大截。代价是读写复杂度从O(1)变成近似O(n)但在几百上千个field的规模下完全可忽略。超过五千就要慎重了listpack线性扫描的耗时开始明显。2.2 intset、共享对象与embstr小对象优化三板斧除了listpackRedis还有三个不起眼但很关键的小对象优化。第一个是intset。Set集合如果全是整数且元素数量小于set-max-intset-entries默认512就用有序整数数组存储二分查找定位。每个整数直接存int64省掉了redisObject、SDS、哈希表指针那一整套。举个例子一个500个元素的整数Set用intset存和用哈希表存内存差距能有五到十倍。第二个是共享整数对象。Redis启动时会预创建0到9999这10000个整数对象所有用到这些数字的地方直接复用同一个redisObject靠refcount计数。这就是为什么你用整数做计数器哪怕有几百个key实际内存开销非常小。注意共享只发生在整数上字符串不共享——字符串共享需要维护一个巨大的字符串池查找成本比省下的内存还高得不偿失。第三个是embstr编码。Redis存字符串时如果字符串长度小于等于44字节会把redisObject和SDS头、字符串数据分配在一块连续内存里一次malloc搞定超过44字节就变成raw编码要两次分配。连续分配的embstr还有额外好处内存局部性好碎片少cache命中率高。所以一个实在的建议是key和value如果能控制在44字节以内对内存和性能都有正向影响。2.3 渐进式rehash为什么扩容不会卡死哈希表扩容最直接的做法是申请一块更大的内存然后把所有旧数据重新哈希搬过去。这个操作对存了上亿key的Redis来说可能要移动几GB数据主线程如果一口气干完期间所有请求全部卡死。Redis的方案是渐进式rehash一个dict里同时维护ht[0]和ht[1]两张哈希表扩容开始后每次增删改查操作顺带从旧表迁移一个bucket到新表把搬迁动作分散到无数次操作里。查询的时候两张表都查插入只插新表等旧表搬空就释放掉。整个扩容过程对请求延迟的影响被压到很小。触发时机也有讲究负载因子used/size超过1时如果当前没有在执行持久化就会触发扩容超过5时强制扩容哪怕正在做RDB持久化。缩容则发生在哈希表使用量降到size的10%以下时把表缩小到合适大小这才是真正把内存“还回去”的动作。实践层面要知道一件事rehash期间两张表同时存在内存会有一个短暂的峰值这是正常的。但如果写入量暴涨迁移没完成又触发新一轮扩容峰值可能被放大得很厉害。所以对业务流量会突然翻倍的场景maxmemory设计时要留缓冲不能贴顶设置。3. 内存超过上限后的淘汰机制由谁来决定丢哪个key3.1 maxmemory与淘汰的完整触发流程默认情况下64位系统的Redismaxmemory是0表示不限制内存。这是Redis最危险的默认值没有之一。实例内存涨到系统物理内存耗尽Redis不会自己停止接收写入而是被操作系统OOM Killer直接杀掉或者开始swap导致延迟雪崩。设置了maxmemory之后Redis在每次执行写命令前都会检查当前内存使用量。一旦超过上限就按照maxmemory-policy逐出key一直逐到内存降到上限以下然后才真正执行这条命令。注意这个检查是“每次写命令都可能触发”逐出过程本身也是主线程执行的所以如果淘汰很频繁、每次要删很多key写命令的延迟一样会上去。如果策略是noeviction情况更直接内存超限后所有写命令直接返回OOM错误读命令和DEL这种删除命令不受影响。这就是我开头说的事故场景。很多人以为noeviction安全实际上它等于把“内存满了怎么办”这个问题甩给了业务写入直接失败而且是批量失败。还有一个隐藏很深的点即使配置了淘汰策略也可能报OOM。比如你配的是volatile-lru但整个实例里没有任何一个key带TTL那Redis找不到任何可淘汰的对象写命令照样失败。这个坑在业务上线初期、TTL还没铺开的时候特别容易踩。3.2 近似LRU和LFU为了省内存算法都做了折中既然要淘汰key最理想的做法是维护一个全局LRU链表按访问时间排序淘汰时精确找出最久未访问的key。但精确LRU意味着每个redisObject都要额外存prev和next两个指针对几千万上亿key的实例来说光是这个链表的管理就是巨大的内存浪费和操作开销。Redis的答案是近似LRU。它不维护全局访问序而是在每次淘汰时随机采样maxmemory-samples默认5个key从中淘汰闲置时间最长的一个。Redis 3.0之后又加了一个淘汰池把多轮采样到的候选key放进池子按idle时间排序每轮从池子里挑最老的几个淘汰。这样一来近似程度已经非常接近全局LRU但内存和CPU开销都小得多。Redis 4.0之后又引入了LFU专门解决“LRU可能误杀热点key”的问题——一个key被大量访问后隔一段时间没被访问LRU可能把它当成冷数据淘汰掉但它的真实热度其实很高。LFU的实现在内存上极度抠门它只用了24bit里的8bit做计数器而且用的是Morris计数器这种概率计数法——每次访问不是必然1而是按概率加8bit就能表示从0到几十万的访问频率区间。另外还有衰减因子避免一个key在冷门之后又被当成历史热点。LFU适合访问热度差异明显的业务比如新闻热点、秒杀商品这种少数key扛了大部分流量。可以调节的参数有三个maxmemory-samples采样数默认5可以调到10减少误差代价是每次淘汰时多花一点CPUlfu-log-factor控制计数器增长速度lfu-decay-time控制热度衰减速度。3.3 淘汰策略到底怎么选一个直接能抄的表格Redis 4.0之后一共有8种淘汰策略实际选型没那么多纠结。我把每种策略的适用范围和风险整理成了一张表策略作用范围适用场景主要风险noeviction不淘汰数据绝不能丢内存写满后写命令全部报错allkeys-lru全部key最通用的缓存场景未设置TTL的数据也可能被淘汰allkeys-lfu全部key热点非常集中的读缓存偶发访问的冷数据可能被误淘汰allkeys-random全部key访问模式完全无规律淘汰命中率低容易误伤volatile-lru带TTL的key只想淘汰可过期的数据无TTL的key永不腾位置volatile-lfu带TTL的key只淘汰带TTL且热度低的key同上volatile-random带TTL的key带TTL的key随机淘汰同上volatile-ttl带TTL的key优先淘汰剩余寿命最短的key依赖业务TTL设置是否合理我的建议很简单缓存场景绝大多数选allkeys-lru热点访问极度不均匀的选allkeys-lfu。不太推荐volatile一族。原因很直接volatile策略只淘汰带TTL的key如果业务有部分key不设过期时间即使这些key已经成了垃圾数据占着大量内存Redis也动不了它们。长期跑下来内存会被这类“不死key”慢慢填满。而allkeys-lru就没有这个问题它一视同仁只看谁最久没被访问。volatile-ttl这个策略更要谨慎用。它优先淘汰剩余时间最短的key听起来很合理但前提是业务设置的TTL能真实反映数据价值。现实中很多业务的TTL都是拍脑袋设的有些很重要的数据TTL很短反而先被淘汰了。不如交给LRU用访问模式来判断。3.4 一个必须躲开的配置组合淘汰策略本身不会让你翻车翻车的是“maxmemory设得不合理 淘汰策略不匹配”。最强的一种配置事故maxmemory设成物理内存的90%淘汰策略用noeviction。数据量缓慢上涨某天晚上流量上来内存打满所有写命令OOM缓存全部变相失效请求打到数据库数据库被压垮整个系统雪崩。这个模式我在不止一个项目里见到过。正确姿势是maxmemory给当前实际使用量留出30%以上的缓冲额度淘汰策略根据业务风险设定然后用监控在内存到达70%时就开始告警而不是等到85%再处理。4. 过期数据的回收与内存碎片治理4.1 过期删除机制惰性、周期和淘汰是怎么配合的设置TTL的key过期之后Redis并不是立刻把它删掉。这里有两套机制配合。第一套是惰性删除当客户端访问一个key时Redis先检查这个key是否已经过期过期就当场删除再返回nil。好处是删除动作只发生在被访问的key上不浪费CPU坏处是如果key过期后再也没人访问它就赖在内存里不走了。第二套是周期删除Redis的主循环每100ms执行一次expireCycle随机抽取一批带TTL的key检查是否过期过期就删。注意是“抽样”不是全量扫描——全量扫描几千万个key会卡死主线程。每次周期删除还会限制执行时间慢速模式下尽量执行快速模式下最多跑1ms就刹住。通过“抽一批、限时间”的方式把过期清理的CPU开销控制在可接受范围。这两套配合的结果是一个已过期的key最迟在下一次周期删除被抽中时才会被清掉中间这段时间它仍然占着内存。所以如果你对内存回收的实时性要求高单纯依赖TTL是不够的还要靠淘汰策略兜底。实际运维中更要命的问题是过期风暴。假设你在某个整点一次性给几百万个key设置了相同的过期时间这些key会在同一秒集体到期。周期删除一次忙不过来主线程卡在过期清理上请求延迟瞬间飙高同时内存释放不及时写命令还会触发淘汰。我处理过的案例里最典型的就是缓存key的TTL固定写成24 * 3600每天凌晨零点集体过期凌晨的慢查询和延迟尖刺比白天还多。解法很土但很有效TTL加一个随机偏移比如5%到10%的抖动让过期时间散开。4.2 内存碎片从哪里来RSS与used_memory之间的差用INFO memory看内存有两个关键数字used_memory是Redis自己统计的逻辑内存占用used_memory_rss是操作系统视角下进程实际占用的物理内存。这两个数字的比值就是mem_fragmentation_ratio内存碎片率。碎片率为什么会大于1三个原因。一是频繁增删key内存释放后会留下空洞新数据大小不匹配就填不上二是不同大小的value交错分配allocator很难复用碎片空间三是内存分配器按固定bin粒度分配比如jemalloc按8字节、16字节、32字节的规格切分内存你申请一个30字节的对象它可能给你分配32字节的槽位。怎么判断碎片严不严重经验值供参考碎片率在1到1.5之间是正常水平超过1.5说明碎片偏多可以看看mem_fragmentation_bytes具体差了多少超过2.0说明碎片已经很严重需要处理。但注意一个干扰项RDB持久化fork子进程时子进程和主进程共享内存页期间RSS会升高碎片率也会波动。所以看碎片率要结合时间线别在fork期间下结论。碎片严重的实例最直观的表现是used_memory不高但RSS很高系统内存压力极大可Redis自己还觉得内存充裕。这种状态下内存管理已经完全失真了。4.3 碎片整理与大key的异步删除想清理碎片重启Redis是最快的办法但业务不允许。Redis 4.0之后提供了在线碎片整理功能开启activedefrag后后台会定期把散落在不同页的小对象搬到一起腾出连续的页归还操作系统。相关参数有active-defrag-ignore-bytes碎片超过多少字节才启动、active-defrag-threshold-lower和active-defrag-threshold-upper碎片率达到某区间才整理。但要提醒一句碎片整理本质是搬数据它要占用CPU和内存带宽。线上开启后务必盯一下实例的延迟和CPU如果发现整理期间请求延迟明显上升就调高触发阈值或者错峰开启。我见过一个实例开了activedefrag之后CPU从10%飙到60%最后只能关掉等低峰期重启。跟碎片问题经常一起出现的还有大key删除阻塞。一个value有几百MB的hash或者list用DEL删除时主线程要释放一大块内存这个过程可能阻塞几百毫秒甚至几秒期间所有命令排队。Redis 4.0之后提供了UNLINK命令它把释放内存的动作交给后台线程主线程只负责把key从哈希表里摘掉立刻返回。线上删除大key一律用UNLINK没有例外。5. 落到实际项目里的内存管理方案5.1 maxmemory到底设多少合适这个问题没有标准答案但有个基本思路先想清楚这个Redis实例的角色。如果是纯缓存节点不开启AOF和RDB持久化数据源可以随时回源那maxmemory可以设到物理内存的70%到80%。剩下的内存留给操作系统page cache、连接缓冲、以及各种临时的内存峰值。如果开启了RDB或AOF持久化情况就不一样了。fork子进程做持久化时依赖操作系统的Copy-On-Write机制子进程和主进程共享内存页期间主进程只要修改一个页内核就得复制一份。如果写入量大fork期间的内存消耗可能达到正常使用的两倍。这种场景maxmemory建议压在物理内存的50%到60%宁可少缓存一点也别让fork期内存爆掉。如果这个Redis还承担主从复制的角色从节点加载RDB文件时也需要额外的内存内存预留要更充足。一个原则maxmemory永远不能顶满物理内存。顶满意味着Redis在kill掉自己之前操作系统先把你杀了。容器场景还有一个容易踩的坑如果Redis跑在Docker或者K8s里maxmemory应该按cgroup限制的内存配额来算而不是宿主机内存。否则Redis以为内存还很宽裕cgroup已经把它杀了。5.2 用三个工具给Redis做内存体检我每两周会给核心Redis实例做一次“体检”三个工具组合用。第一个是INFO memory。重点看used_memory、used_memory_rss、mem_fragmentation_ratio、maxmemory四个字段。增长的曲线比绝对值更重要如果used_memory稳步上涨是数据正常增长如果RSS涨但used_memory不涨是碎片问题。第二个是MEMORY USAGE key。对热门key抽样看单个key的真实占用。很多“内存没用多少却突然爆炸”的诡异现象一查这个命令就现原形——往往是一个超大hash或者一个超长字符串占了几百MB。第三个是redis-cli --bigkeys。它会扫描整个实例按类型统计最大的key。新版redis-cli还有--memkeys选项按内存占用排序。注意这个命令本身就是一次全量scan对实例有额外负载务必在低峰期跑。跑完拿到大key清单该拆分的拆分该设定TTL的设定TTL。如果还想看RDB文件里的详细内存构成可以用rdb-tools这类离线分析工具把dump.rdb解析成内存报告能精确到每个db、每种数据类型占用的内存比例。定期跑一份比对趋势比临时抱佛脚强得多。5.3 三种内存打满的事故复盘把之前遇到的内存事故归归类基本逃不出下面三种。第一种maxmemory没设内存一路涨到系统OOM。等发现的时候进程已经被OS杀了重启后数据全丢只能回源重建缓存。复盘结论不管是三台机器的小项目还是几十节点的集群maxmemory和合适的淘汰策略必须第一天就配好哪怕当时的数据量离上限还远得很。第二种淘汰策略配成noeviction内存打满后写命令全部OOM。这种最坑因为Redis本身没挂但业务表现是“缓存全部失效”流量穿透到数据库数据库扛不住才是真正的灾难。复盘结论缓存节点几乎不应该用noeviction。真担心数据丢失应该用持久化和主从复制来解决而不是堵住淘汰这条路。第三种大量key同一时刻过期主线程删除忙不过来延迟尖刺。解决之前说的TTL加随机偏移。另外如果过期key里躺着几个大key那延迟尖刺会非常明显这时候UNLINK异步删除就派上用场了。这三种事故有一个共同点都不是Redis“内存管理能力不够”而是配置和使用方式触发了它的保护机制的负面效果。Redis已经把一个内存系统能做到的精细管理都做了剩下的全靠使用者的配置贴合业务。我现在的习惯是给每台Redis实例做三件固定动作maxmemory按场景显式设置淘汰策略写清楚再用配置管理工具固话内存使用率超过70%进告警池超过85%直接电话通知每两周扫一次bigkeys配合rdb-tools分析内存账单。踩过几次坑后你会发现Redis的内存管理其实不复杂核心就是尊重它的几个设计前提单线程、全内存、有限内存。顺着这个前提去配置Redis能帮你扛的事远比你想的多。