Redis 内存优化与性能调优:编码转换、内存碎片与慢查询的定位链路
1. 从一次线上告警说起
假设你维护一个订单系统,某天凌晨收到告警:Redis 实例内存使用率 92%,同时接口平均响应时间从 8ms 涨到了 60ms。值班同事的第一反应通常是下面几种:
- 把
maxmemory调大,让实例先“撑过去”; - 重启实例,重置 used_memory;
- 怀疑是流量上涨,直接扩机器。
这些动作可能在短时间内让监控恢复绿线,但一两周后同样的问题会再次出现。原因在于,它们都跳过了最关键的判断:内存到底被谁占用了,是真实业务数据增长,还是编码方式不合理、碎片率过高、过期 Key 没被及时清理?慢又是哪一种慢,是单条命令本身耗时,还是排队、网络、主从同步造成的?
本文不打算把 Redis 调优讲成一份参数清单,而是先给出一条可以复述的定位链路。你可以把它理解成一次体检:先看整体内存形状,再判断数据结构是否用了低效编码,然后检查内存碎片是否过高,最后用慢查询日志锁定具体命令。整条链路串起来,才是一个可复用的工程方法。
为了便于后续展开,先记住一个最小模型:
客户端命令 | v 命令执行线程 --> 读写 Redis 对象 --> 编码可能发生转换 | | | v | 内存分配 / 释放 | | v v 慢查询日志 <-- 耗时超阈值 内存碎片率变化 | | v v 定位具体命令 触发淘汰策略2. 先建立整体框架:一次写入会经过哪些环节
Redis 的内存问题,很少是单一原因导致的。它更像一个链条:一次写入命令进入后,先执行命令逻辑,然后决定用哪种内部结构保存数据;随着元素增多,编码可能从紧凑结构转换为通用结构,内存占用随之上升;元素删除或过期后,释放的内存不一定能立刻被复用,于是产生碎片;当内存达到上限,淘汰策略开始工作;如果某条命令因为数据量过大而变慢,它就会被 slowlog 记录下来。
把整体拆成四部分更容易理解:
- 对象与编码层:Redis 为了兼顾内存和性能,对同一种逻辑类型提供了多种底层编码,例如列表在小数据量时用紧凑列表,变大后转为快速链表。
- 内存分配与回收层:内存由分配器管理,频繁增删会造成碎片,碎片率过高会浪费物理内存。
- 淘汰与过期层:达到
maxmemory后触发淘汰,过期 Key 通过惰性删除和定期删除结合清理。 - 可观测层:
INFO、MEMORY USAGE、SLOWLOG、LATENCY等命令帮助我们把现象对应到原因。
这四个部分并非独立存在。编码转换会直接影响内存占用和碎片产生,碎片率又会影响淘汰触发时机,而淘汰和过期清理如果执行不当,又可能造成延迟尖刺并进入慢查询日志。理解它们之间的连接,比单独记住某个参数更重要。
3. 编码转换:为什么同样 100 个元素,内存差好几倍
3.1 一句话模型
可以先把它理解成:Redis 对每种数据类型都准备了两套甚至多套“收纳盒”,小数据用紧凑盒,省内存;大数据用性能盒,操作更快。超过阈值时,Redis 自动把数据从紧凑盒搬到性能盒,这个过程叫编码转换。
3.2 触发条件与常见编码
以常见的列表和哈希为例:
| 数据类型 | 小数据编码 | 大数据编码 | 常见转换触发条件 |
|---|---|---|---|
| List | listpack(早期为 ziplist) | quicklist | 元素个数或单个元素长度超过阈值 |
| Hash | listpack(早期为 ziplist) | hashtable | 字段数或字段值长度超过阈值 |
| Set | intset | hashtable | 元素非整数或数量超阈值 |
| ZSet | listpack(早期为 ziplist) | skiplist | 元素数或成员长度超阈值 |
阈值由配置项控制,例如hash-max-listpack-entries、hash-max-listpack-value、list-max-listpack-size、set-max-intset-entries、zset-max-listpack-entries。不同 Redis 版本默认值和名称略有差异,生产环境必须以当前版本redis.conf为准。
3.3 一个可复现的实验
下面用redis-cli做一个最小实验,观察哈希从紧凑编码转为通用编码后内存的变化。前置条件是本地或测试环境有一个可执行redis-cli的 Redis 实例,版本为 7.x。
# 步骤一:写入少量字段,查看编码和内存redis-cli DEL demo:hash redis-cli HSET demo:hash f1 v1 f2 v2 redis-cli OBJECT ENCODING demo:hash redis-cli MEMORY USAGE demo:hash# 预期输出(示例)# listpack# (integer) 96# 步骤二:写入大量字段,再次查看foriin$(seq1500);doredis-cli HSET demo:hash"field_$i""value_$i">/dev/nulldoneredis-cli OBJECT ENCODING demo:hash redis-cli MEMORY USAGE demo:hash# 预期输出(示例)# hashtable# (integer) 32768关键步骤解释:OBJECT ENCODING用来查看当前编码,MEMORY USAGE用来查看单个 Key 占用内存。第一次输出listpack,说明数据量小、仍然使用紧凑结构;第二次输出hashtable,说明已经发生转换。内存从几十字节涨到几万字节,不完全是数据本身变多,还包括哈希表结构、指针和预分配带来的额外开销。
适用场景:这个实验适合在测试环境验证业务 Key 的编码是否符合预期,尤其适合字段数量接近阈值的场景。容易改错的地方是:直接用MEMORY USAGE比较不同版本可能得到不同结果,因为采样方式和版本实现会变化;另外,转换是单向的,一旦变成hashtable,即使随后删掉大部分字段,编码通常也不会自动退回listpack。
3.4 设计取舍
紧凑编码省内存,但每次修改都可能涉及整块内存搬移,时间复杂度更接近 O(N);通用编码操作快,但内存开销大。Redis 用阈值来平衡这两者。工程上真正要做的,是判断你的业务数据是否长期停留在“接近阈值但未超过”的位置。如果一个 Hash 有 400 个字段,恰好没触发 500 的默认阈值,但字段值较长,内存已经不小,这时可以考虑拆分成多个 Key,或者显式调整阈值并做压测。
4. 内存碎片:used_memory 和 RSS 为什么会差那么多
4.1 碎片是怎么产生的
内存碎片可以理解成一间仓库里堆满了大小不一的箱子,很多小空隙没法再放进新的大箱子。Redis 也类似:不断创建和删除不同大小的对象时,分配器释放的内存块可能无法被后续请求复用,于是操作系统看到的进程 RSS 远大于 Redis 自己统计的 used_memory。
判断碎片程度主要看两个指标:
used_memory:Redis 分配器统计的已用内存;used_memory_rss:操作系统视角的常驻内存;- 碎片率近似为
used_memory_rss / used_memory。
通常碎片率在 1.0 到 1.5 之间可以接受;持续高于 1.5 且业务有明显增删波动时,就需要关注。
4.2 一次诊断流程示例
下面是一个完整的诊断流程,输入是一台内存持续高于预期的 Redis 实例,步骤是读取信息、计算碎片率、查看分配器状态,结果用于决定是否开启主动碎片整理。
# 步骤一:读取内存相关指标redis-cli INFO memory|grep-E"used_memory:|used_memory_rss:|mem_fragmentation_ratio|mem_allocator"# 预期输出(示例)# used_memory:2147483648# used_memory_rss:3758096384# mem_fragmentation_ratio:1.75# mem_allocator:jemalloc-5.2.1# 步骤二:查看碎片整理是否可用及当前状态redis-cli CONFIG GET activedefrag redis-cli MEMORY STATS|grep-E"fragmentation|allocator"# 步骤三:评估后开启主动碎片整理(生产需谨慎,建议在低峰期验证)redis-cli CONFIG SET activedefragyesredis-cli CONFIG SET active-defrag-ignore-bytes 100mb redis-cli CONFIG SET active-defrag-threshold-lower10关键步骤解释:第一步算出碎片率约 1.75,说明 RSS 比已用内存高出很多;第二步确认当前没有开启主动碎片整理;第三步调整参数让 Redis 在碎片超过一定比例时后台整理。active-defrag-ignore-bytes表示碎片浪费低于该值时忽略,active-defrag-threshold-lower表示碎片率超过该百分比才开始整理。
可观察结果:整理期间used_memory_rss会逐步下降,mem_fragmentation_ratio回落。边界和常见错误:主动碎片整理会消耗 CPU,主线程和子线程都可能受影响,不建议在高负载实例上直接大范围开启;另外,重启实例也能消除碎片,但会造成缓存雪崩风险,需要配合预热方案。
4.3 碎片与淘汰的关系
这里最容易误解的是:碎片率高不等于淘汰会提前触发。淘汰判断基于maxmemory和used_memory,而不是 RSS。也就是说,碎片可能让进程被操作系统 OOM Kill,却不触发 Redis 自身的淘汰。生产上如果只盯着碎片率却不看操作系统内存,会出现“Redis 自己觉得还有空间,系统已经把进程杀掉”的情况。因此内存告警应该同时覆盖 used_memory、used_memory_rss 和操作系统剩余内存。
5. 内存淘汰:达到上限后,谁先被牺牲
当used_memory超过maxmemory,Redis 会按照maxmemory-policy的策略淘汰 Key。常见策略如下:
| 策略 | 淘汰范围 | 是否可能淘汰未过期 Key | 适用场景 |
|---|---|---|---|
| noeviction | 不淘汰,写入报错 | 不淘汰 | 不允许丢数据的场景 |
| allkeys-lru | 所有 Key | 会 | 纯缓存,允许按最近使用淘汰 |
| allkeys-lfu | 所有 Key | 会 | 热点差异明显,希望按频率淘汰 |
| volatile-lru | 设置了过期时间的 Key | 不会 | 混合存储,仅缓存部分可淘汰 |
| volatile-lfu | 设置了过期时间的 Key | 不会 | 有过期时间的缓存,热点明显 |
| allkeys-random | 所有 Key | 会 | 对淘汰顺序不敏感 |
| volatile-random | 设置了过期时间的 Key | 不会 | 简单缓存场景 |
| volatile-ttl | 设置了过期时间的 Key | 不会 | 希望优先淘汰快过期的 Key |
5.1 淘汰不是精确算法
LRU 和 LFU 在 Redis 中都是近似实现。Redis 不会维护完整的访问链表,而是通过采样若干个 Key,从中选择淘汰对象。采样数量由maxmemory-samples控制,默认值通常是 5,调大更接近精确但更耗 CPU。
5.2 一次淘汰行为的观察
下面在测试环境复现一次淘汰,前置条件是把实例限制到很小的maxmemory,并设置策略为allkeys-lru。
# 步骤一:设置小内存和淘汰策略(仅测试环境)redis-cli CONFIG SET maxmemory 20mb redis-cli CONFIG SET maxmemory-policy allkeys-lru# 步骤二:持续写入,直到触发淘汰foriin$(seq120000);doredis-cli SET"key:$i""value-$i">/dev/nulldone# 步骤三:查看淘汰统计和内存redis-cli INFO stats|grepevicted_keys redis-cli INFO memory|grepused_memory:# 预期输出(示例)# evicted_keys:5421# used_memory:20961480关键步骤解释:evicted_keys持续增长说明淘汰已经发生,used_memory稳定在maxmemory附近。适用场景:用于验证策略是否生效、观察淘汰速度。容易改错的地方:如果策略设为volatile-*,但 Key 没有设置过期时间,那么淘汰集合为空,写入会直接报 OOM 错误,而不是淘汰数据。
5.3 淘汰与过期的关系
过期 Key 的清理有两种方式:惰性删除在访问时判断是否过期,定期删除由后台周期性抽样清理。淘汰则是在内存达到上限时被动触发。两者都可能造成延迟,但原因不同。如果大量 Key 设置了相同的过期时间,会在同一时刻集中过期,可能引起延迟尖刺;更稳妥的做法是在过期时间上加随机抖动。
6. 慢查询:把“变慢”拆成可定位的命令
6.1 slowlog 记录的是什么
Redis 的慢查询日志记录的是命令执行本身的耗时,不包括网络往返和排队时间。相关配置有两个:slowlog-log-slower-than表示超过多少微秒记录,slowlog-max-len表示最多保留多少条。查看命令为SLOWLOG GET、SLOWLOG RESET、SLOWLOG LEN。
| 命令 | 作用 | 注意事项 |
|---|---|---|
| SLOWLOG GET n | 查看最近 n 条慢查询 | 返回包含命令参数,注意敏感信息 |
| SLOWLOG LEN | 查看当前慢查询条数 | 长期增长说明持续有慢命令 |
| SLOWLOG RESET | 清空慢查询日志 | 排障前先记录再清空 |
| LATENCY RESET | 清空延迟事件 | 用于配合延迟监控 |
| LATENCY HISTORY | 查看延迟历史 | 可定位延迟尖刺类型 |
6.2 一次完整的慢查询定位
下面的流程模拟从发现变慢到定位具体命令,输入是一个响应时间上涨的实例,步骤是先看慢查询,再分析命令涉及的数据规模。
# 步骤一:配置慢查询阈值,例如超过 10ms 记录(10000 微秒)redis-cli CONFIG SET slowlog-log-slower-than10000redis-cli CONFIG SET slowlog-max-len256# 步骤二:查看慢查询redis-cli SLOWLOG GET5# 预期输出(示例,节选)# 1) 1) (integer) 14# 2) (integer) 1712345678# 3) (integer) 45231# 4) 1) "HGETALL"# 2) "big:hash:order"# 5) "127.0.0.1:52341"# 6) ""# 步骤三:确认该 Key 的规模和编码redis-cli HLEN big:hash:order redis-cli OBJECT ENCODING big:hash:order redis-cli MEMORY USAGE big:hash:order# 预期输出(示例)# (integer) 500000# hashtable# (integer) 48234496关键步骤解释:第一条慢查询显示HGETALL耗时约 45ms,Key 名为big:hash:order;第三步确认它有 50 万个字段、占用约 46MB,属于典型大 Key。HGETALL会一次性返回所有字段,数据量大时不仅慢,还会占用网络带宽和客户端内存。
处理方向:业务上改用HGET按需读取、HSCAN分批遍历,或者把大 Hash 拆分成多个小 Key。适用场景:所有出现响应时间尖刺的实例。边界:SLOWLOG不记录网络延迟和执行前的排队时间,如果慢查询日志为空但接口仍然慢,需要排查连接池、网络、主从同步或客户端序列化。
6.3 大 Key 的排查代码
下面给出一个用 Java 客户端扫描大 Key 的完整示例。前置环境是 JDK 8 及以上、Maven 项目引入 Jedis 客户端、本地可连接 Redis。目标是按类型抽样估算 Key 规模,而不是在生产环境全量遍历。
importredis.clients.jedis.Jedis;importredis.clients.jedis.ScanParams;importredis.clients.jedis.ScanResult;importjava.util.List;publicclassBigKeyScanner{publicstaticvoidmain(String[]args){Jedisjedis=newJedis("127.0.0.1",6379);try{Stringcursor=ScanParams.SCAN_POINTER_START;ScanParamsparams=newScanParams().count(100);longchecked=0;longbigKeyCount=0;do{ScanResult<String>result=jedis.scan(cursor,params);List<String>keys=result.getResult();for(Stringkey:keys){checked++;Stringtype=jedis.type(key);longsize=estimateSize(jedis,key,type);if(size>10_000){bigKeyCount++;System.out.println("[BIG KEY] key="+key+", type="+type+", size="+size);}}cursor=result.getCursor();}while(!ScanParams.SCAN_POINTER_START.equals(cursor));System.out.println("checked="+checked+", bigKeyCount="+bigKeyCount);}catch(Exceptione){System.err.println("scan failed: "+e.getMessage());}finally{jedis.close();}}privatestaticlongestimateSize(Jedisjedis,Stringkey,Stringtype){switch(type){case"string":returnjedis.strlen(key);case"hash":returnjedis.hlen(key);case"list":returnjedis.llen(key);case"set":returnjedis.scard(key);case"zset":returnjedis.zcard(key);default:return0;}}}关键步骤解释:使用SCAN而不是KEYS,避免阻塞主线程;每次只取 100 个 Key,逐个按类型统计元素数量,超过 10000 就打印。预期输出会列出大 Key 及其类型和规模,最后打印扫描总数和大 Key 数量。适用场景:测试环境或低峰期对指定实例做抽样检查。容易改错的地方:SCAN只保证遍历期间存在的元素最终被返回,不保证不重复,所以统计是近似值;生产环境不要用KEYS *替代它。
7. 性能调优:从参数到业务结构
7.1 调优的优先级
Redis 性能调优不是先改配置,而是先确认瓶颈位置。可以按照下面的顺序判断:
- 命令层面:是否存在 O(N) 大命令、大 Key、频繁全量遍历;
- 连接层面:客户端连接池是否过小或过大,是否存在连接泄漏;
- 内存层面:编码是否合理、碎片是否过高、淘汰是否频繁;
- 持久化层面:是否开启了 AOF 频繁刷盘或 RDB 频繁快照;
- 部署层面:主从、哨兵、Cluster 是否存在跨机房高延迟或频繁故障转移。
7.2 管道与批量操作
如果业务需要连续写入大量小 Key,逐条命令会产生大量网络往返。可以使用管道批量发送,但要控制每次批量的大小,避免单次响应过大。下面是一个完整示例,前置环境与上一节相同。
importredis.clients.jedis.Jedis;importredis.clients.jedis.Pipeline;publicclassPipelineDemo{publicstaticvoidmain(String[]args){Jedisjedis=newJedis("127.0.0.1",6379);try{Pipelinepipeline=jedis.pipelined();for(inti=0;i<1000;i++){pipeline.set("batch:key:"+i,"value-"+i);}pipeline.sync();System.out.println("pipeline done, sample="+jedis.get("batch:key:999"));}catch(Exceptione){System.err.println("pipeline failed: "+e.getMessage());}finally{jedis.close();}}}关键步骤解释:pipelined()返回管道对象,连续调用set只是把命令放入本地缓冲,sync()才真正发送并读取响应。预期输出会打印value-999。适用场景:批量写入、批量删除等不需要逐条读取结果的场景。边界:管道只节省网络往返,不减少服务端命令执行成本;单次管道过大可能导致客户端内存上涨或服务端输出缓冲超限。
7.3 用 Lua 减少往返的边界
有些场景需要在服务端原子执行多条命令,可以用 Lua 脚本。但脚本本身如果遍历大集合,同样会阻塞主线程。下面是一个简单示例,目标是原子地读取并更新计数。
importredis.clients.jedis.Jedis;publicclassLuaCounter{privatestaticfinalStringSCRIPT="local current = redis.call('GET', KEYS[1]) "+"if current == false then current = 0 end "+"current = tonumber(current) + tonumber(ARGV[1]) "+"redis.call('SET', KEYS[1], current) "+"return current";publicstaticvoidmain(String[]args){Jedisjedis=newJedis("127.0.0.1",6379);try{Objectresult=jedis.eval(SCRIPT,1,"counter:order","5");System.out.println("counter="+result);}catch(Exceptione){System.err.println("lua failed: "+e.getMessage());}finally{jedis.close();}}}关键步骤解释:脚本在服务端原子执行,避免客户端读取和写回之间被其他命令插入。预期输出为counter=5,重复执行会递增。适用场景:计数、限流、简单的原子读改写。边界:脚本执行时间过长会阻塞其他命令,lua-time-limit只控制脚本超时后的行为,不会自动杀死脚本;不要在脚本里做大范围遍历。
8. 常见误区
- 误区一:used_memory 低就说明内存没问题。操作系统看的是 RSS,碎片过高时可能被 OOM Kill。
- 误区二:重启能解决内存问题。重启只能清空碎片和过期数据,如果编码和 Key 结构不合理,问题会很快复发,还可能造成缓存雪崩。
- 误区三:设置 volatile-lru 就一定能淘汰。如果 Key 都没有过期时间,淘汰集合为空,写入会报错。
- 误区四:slowlog 没记录就说明没有慢查询。slowlog 只记录命令执行耗时,不覆盖网络、排队和客户端处理。
- 误区五:编码转换后删除元素就能恢复内存。大多数编码转换是单向的,删除元素不一定让编码退回紧凑结构。
- 误区六:KEYS 用于排查大 Key。
KEYS会阻塞主线程,生产环境应使用SCAN。
9. 生产实践建议
- 给所有缓存 Key 设置带随机抖动的过期时间,避免集中过期。
- 对 Hash、List、Set、ZSet 的业务规模设上限,接近编码阈值时提前拆分或压测。
- 监控同时覆盖 used_memory、used_memory_rss、evicted_keys、expired_keys 和 slowlog 长度。
- 大 Key 和高频 Key 定期抽检,写入链路中避免一次性返回大集合。
- 主动碎片整理只在评估 CPU 余量后开启,优先在低峰期验证。
- 淘汰策略要与业务数据性质匹配:纯缓存用 allkeys-lru 或 allkeys-lfu,混合存储用 volatile-lru 并确保有过期时间。
10. 排障清单与面试复盘
10.1 排障清单
收到 Redis 内存或延迟告警 | v 1. INFO memory:看 used_memory、used_memory_rss、碎片率 | v 2. INFO stats:看 evicted_keys、expired_keys 是否异常增长 | v 3. SLOWLOG GET:确认是否有慢命令,记录参数和时间 | v 4. 对慢命令涉及的 Key 执行 OBJECT ENCODING、MEMORY USAGE、HLEN/LLEN 等 | v 5. 判断是编码不合理、大 Key、碎片过高还是淘汰策略不匹配 | v 6. 制定改动:拆分 Key、调整阈值、开启整理、更换淘汰策略或优化业务访问 | v 7. 低峰期验证并持续观察10.2 面试与复盘问题
- 为什么小 Hash 用 listpack 而大 Hash 用 hashtable,转换后为什么不能自动转回?
- used_memory_rss 远大于 used_memory 时,可能有哪些原因,如何验证?
- allkeys-lru 和 volatile-lru 在 Key 都没有过期时间时分别会发生什么?
- slowlog 记录不到某个慢请求,可能是哪些环节造成的?
- 大 Key 的常见识别方式和拆分思路有哪些?
- 为什么管道不一定能提升服务端处理能力?
11. 总结
回到开头的告警场景,正确的处理顺序不是先调参数,而是先建立定位链路:用INFO memory看内存形状,用OBJECT ENCODING和MEMORY USAGE判断编码和大 Key,用碎片率判断内存是否被浪费,用SLOWLOG找到具体慢命令,最后再决定是拆分 Key、调整编码阈值、开启碎片整理还是更换淘汰策略。
| 现象 | 优先排查方向 | 典型动作 |
|---|---|---|
| 内存高但 used_memory 不高 | 碎片率 | 检查 RSS,评估主动碎片整理 |
| 内存高且 evicted_keys 增长 | 淘汰策略与数据量 | 调整策略或扩容,检查过期时间 |
| 延迟尖刺且 slowlog 有记录 | 大 Key 或 O(N) 命令 | 拆分 Key,改用分批命令 |
| 延迟尖刺但 slowlog 为空 | 网络、连接池、持久化 | 检查客户端、AOF、RDB、主从延迟 |
| 编码从紧凑变为通用 | 业务数据规模 | 控制元素数量,必要时拆分 |
把这张表当作日常排障的入口,比记住一堆参数更有用。
12. 参考资料
- Redis 官方文档:Memory Optimization,https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/
- Redis 官方文档:Keyspace 与过期、淘汰策略说明,https://redis.io/docs/latest/develop/reference/eviction/
- Redis 官方文档:SLOWLOG 命令,https://redis.io/docs/latest/commands/slowlog/
- Redis 官方文档:MEMORY USAGE 命令,https://redis.io/docs/latest/commands/memory-usage/
- Redis 官方文档:OBJECT ENCODING 命令,https://redis.io/docs/latest/commands/object-encoding/
- Redis 官方文档:INFO 命令,https://redis.io/docs/latest/commands/info/
- Jedis 项目文档,https://github.com/redis/jedis