☰
Redis 内存优化与性能调优:编码转换、内存碎片与慢查询的定位链路
2026/10/5 2:26:48 网站建设 项目流程

Redis 内存优化与性能调优:编码转换、内存碎片与慢查询的定位链路

1. 从一次线上告警说起

假设你维护一个订单系统,某天凌晨收到告警:Redis 实例内存使用率 92%,同时接口平均响应时间从 8ms 涨到了 60ms。值班同事的第一反应通常是下面几种:

  • 把maxmemory调大,让实例先“撑过去”;
  • 重启实例,重置 used_memory;
  • 怀疑是流量上涨,直接扩机器。

这些动作可能在短时间内让监控恢复绿线,但一两周后同样的问题会再次出现。原因在于,它们都跳过了最关键的判断:内存到底被谁占用了,是真实业务数据增长,还是编码方式不合理、碎片率过高、过期 Key 没被及时清理?慢又是哪一种慢,是单条命令本身耗时,还是排队、网络、主从同步造成的?

本文不打算把 Redis 调优讲成一份参数清单,而是先给出一条可以复述的定位链路。你可以把它理解成一次体检:先看整体内存形状,再判断数据结构是否用了低效编码,然后检查内存碎片是否过高,最后用慢查询日志锁定具体命令。整条链路串起来,才是一个可复用的工程方法。

为了便于后续展开,先记住一个最小模型:

客户端命令 | v 命令执行线程 --> 读写 Redis 对象 --> 编码可能发生转换 | | | v | 内存分配 / 释放 | | v v 慢查询日志 <-- 耗时超阈值 内存碎片率变化 | | v v 定位具体命令 触发淘汰策略

2. 先建立整体框架:一次写入会经过哪些环节

Redis 的内存问题,很少是单一原因导致的。它更像一个链条:一次写入命令进入后,先执行命令逻辑,然后决定用哪种内部结构保存数据;随着元素增多,编码可能从紧凑结构转换为通用结构,内存占用随之上升;元素删除或过期后,释放的内存不一定能立刻被复用,于是产生碎片;当内存达到上限,淘汰策略开始工作;如果某条命令因为数据量过大而变慢,它就会被 slowlog 记录下来。

把整体拆成四部分更容易理解:

  1. 对象与编码层:Redis 为了兼顾内存和性能,对同一种逻辑类型提供了多种底层编码,例如列表在小数据量时用紧凑列表,变大后转为快速链表。
  2. 内存分配与回收层:内存由分配器管理,频繁增删会造成碎片,碎片率过高会浪费物理内存。
  3. 淘汰与过期层:达到maxmemory后触发淘汰,过期 Key 通过惰性删除和定期删除结合清理。
  4. 可观测层:INFO、MEMORY USAGE、SLOWLOG、LATENCY等命令帮助我们把现象对应到原因。

这四个部分并非独立存在。编码转换会直接影响内存占用和碎片产生,碎片率又会影响淘汰触发时机,而淘汰和过期清理如果执行不当,又可能造成延迟尖刺并进入慢查询日志。理解它们之间的连接,比单独记住某个参数更重要。

3. 编码转换:为什么同样 100 个元素,内存差好几倍

3.1 一句话模型

可以先把它理解成:Redis 对每种数据类型都准备了两套甚至多套“收纳盒”,小数据用紧凑盒,省内存;大数据用性能盒,操作更快。超过阈值时,Redis 自动把数据从紧凑盒搬到性能盒,这个过程叫编码转换。

3.2 触发条件与常见编码

以常见的列表和哈希为例:

数据类型小数据编码大数据编码常见转换触发条件
Listlistpack(早期为 ziplist)quicklist元素个数或单个元素长度超过阈值
Hashlistpack(早期为 ziplist)hashtable字段数或字段值长度超过阈值
Setintsethashtable元素非整数或数量超阈值
ZSetlistpack(早期为 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 性能调优不是先改配置,而是先确认瓶颈位置。可以按照下面的顺序判断:

  1. 命令层面:是否存在 O(N) 大命令、大 Key、频繁全量遍历;
  2. 连接层面:客户端连接池是否过小或过大,是否存在连接泄漏;
  3. 内存层面:编码是否合理、碎片是否过高、淘汰是否频繁;
  4. 持久化层面:是否开启了 AOF 频繁刷盘或 RDB 频繁快照;
  5. 部署层面:主从、哨兵、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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询