Redis 系列 9. 内存治理:MoreKey、BigKey、过期删除与淘汰策略


1. 先区分 MoreKey 和 BigKey

Redis 内存问题里最容易混在一起的是 MoreKey 和 BigKey。

问题 含义 典型风险
MoreKey key 数量很多 全库扫描慢、字典 rehash 成本高、过期字典膨胀、管理成本高
BigKey 单个 key 对应的 value 很大 阻塞主线程、网络传输慢、删除卡顿、复制抖动

这两个问题经常同时出现,但治理手段不一样。MoreKey 关注 key 的数量和生命周期,BigKey 关注单个 value 的体积和元素个数。

数据量一旦上来,本地环境正常的命令在生产环境可能导致严重问题。

2. KEYS 为什么不能在线上随便用

KEYS * 的问题不是语法错误,而是行为太重。它会遍历当前库里的 key,并一次性返回匹配结果。

redis-cli keys '*'
redis-cli keys 'user:*'

在线上数据量大时,风险主要有三个:

  1. 遍历期间阻塞 Redis 主线程。
  2. 返回结果太大,占用网络和客户端内存。
  3. 误操作时很难控制影响范围。

Redis 命令执行主路径是单线程模型,KEYS 这类全量遍历会阻塞事件循环。阻塞期间,后面的普通 GET、SET 也只能排队等待。

如果确实要排查 key,优先使用 SCAN:

redis-cli scan 0 match 'user:*' count 1000

SCAN 是增量迭代,不会像 KEYS 一样一次性扫完整个库。但它也不是快照扫描,不保证 snapshot consistency。遍历过程中 key 可能新增或删除,而且同一个 key 可能被返回多次。因此业务上使用 SCAN 时要能接受重复和非强一致。

3. 线上禁用危险命令

Redis 可以通过 rename-command 改名或禁用危险命令:

rename-command KEYS ""
rename-command FLUSHDB ""
rename-command FLUSHALL ""

也可以改成一个很难猜的名字:

rename-command KEYS "keys_2024_internal_only"

我更倾向于生产环境直接禁用 KEYS、FLUSHDB、FLUSHALL,管理动作走堡垒机、脚本或专门的运维流程,不把危险入口暴露给普通业务连接。

4. BigKey 怎么定义

BigKey 没有一个全行业统一阈值,要结合业务场景判断。可以先用以下经验线作为预警:

类型 预警参考
String 单 value 超过 10KB 需要关注,超过 100KB 应重点治理
Hash/List/Set/ZSet 元素数量超过 5000 或 10000 需要关注
单 key 网络返回 单次返回超过几百 KB,接口延迟会明显受影响

阈值需结合业务场景判断。比如画像缓存里一个 20KB String 可能还能接受,但秒杀库存、登录态、限流计数这类高频 key 就应该非常小。

BigKey 的问题不只是占内存,还会影响主线程执行、网络传输、主从复制、AOF 重写和集群迁移。大对象写入和删除还会放大 replication backlog 压力,造成 replica replication delay。内存分配和释放频繁时,也要关注 jemalloc allocator fragmentation,避免 used_memory_rss 长期明显高于 used_memory。

5. SCAN 命令补充

前面提到线上排查 key 时优先使用 SCAN,这里补充一下它的语法和注意点。

5.1 SCAN 命令语法

Redis SCAN 命令的基本语法如下:

SCAN cursor [MATCH pattern] [COUNT count]

参数说明:

  • cursor:游标,用于记录本次遍历的位置。
  • MATCH pattern:匹配模式,用于筛选符合条件的 key。
  • COUNT count:指定本次扫描期望返回的元素数量,默认值是 10。

COUNT 只是 hint,不是严格返回数量。Redis 会尽量参考这个值,但每轮返回多少 key 仍取决于字典状态、rehash 进度和匹配条件。

5.2 SCAN 命令说明

SCAN 命令是一个基于游标的迭代器,本质上是对 Redis 字典进行渐进式哈希遍历(incremental hash iteration)。

每次执行 SCAN 之后,Redis 都会返回一个新的游标。用户在下一次迭代时,需要把这个新的游标作为 SCAN 命令的游标参数,继续之前的遍历过程。

简单来说,SCAN 不是一次性返回所有 key,而是分批返回一部分数据。

5.3 SCAN 返回结果

SCAN 命令会返回一个包含两个元素的数组:

  1. 第一个元素是新的游标,用于下一次继续扫描;
  2. 第二个元素是本次扫描返回的 key 列表。

如果返回的新游标为 0,表示本次遍历已经结束。

例如:

1) "新的游标"
2) 1) "key1"
   2) "key2"
   3) "key3"

当游标返回 0 时:

1) "0"
2) ...

说明所有 key 已经遍历完成。

5.4 SCAN 的遍历顺序

SCAN 的遍历顺序比较特殊。

它不是从数组的第一个位置一直顺序遍历到最后一个位置,而是采用了高位进位加法的方式进行遍历。

这样设计主要是为了在 Redis 字典扩容或者缩容时,尽量避免因为哈希表槽位变化导致遍历过程出现大量重复或遗漏。

因此,使用 SCAN 时需要注意:

  • SCAN 是渐进式遍历,不会一次性阻塞 Redis;
  • SCAN 每次返回的数量不一定严格等于 COUNT 指定的数量;
  • SCAN 可能会返回重复 key,业务侧需要根据需要自行去重;
  • SCAN 遍历期间,如果 key 发生新增或删除,结果可能会受到影响;
  • SCAN 不保证快照一致性,不能用于需要精确全量视图的业务事务;
  • 当返回游标为 0 时,表示本轮遍历结束。

5.5 小结

SCAN 适合用于线上环境中分批遍历 key,避免像 KEYS * 那样一次性扫描所有 key,导致 Redis 阻塞。

简单来说:

KEYS:一次性扫描,可能阻塞 Redis,不推荐在线上使用。
SCAN:分批扫描,逐步遍历,更适合线上环境。

6. 怎么发现 BigKey

常见方式有三类。

第一类,用 Redis 自带扫描:

redis-cli --bigkeys
redis-cli --memkeys
redis-cli --hotkeys

–bigkeys 能给出每种类型里较大的 key。它底层也是全量扫描,生产执行要挑低峰期,并控制连接来源,避免在业务高峰制造额外抖动。

–hotkeys 依赖 Redis 的 LFU 访问频率统计,更适合在启用 LFU 淘汰策略时辅助排查热点 key;如果实例没有开启 LFU,不要把它当成完整的热点检测方案。LFU 本身也是近似统计,采样结果可能有偏差,应结合业务日志和客户端指标一起判断。

第二类,抽样查看对象:

type user:1001:profile
strlen user:1001:profile
hlen user:1001:cart
llen queue:task
scard tag:users:java
zcard rank:daily
memory usage user:1001:profile

第三类,从业务侧加约束。比如写入集合前检查元素数量、写入 JSON 前检查序列化后的字节数,把大对象问题拦在应用层。

7. BigKey 怎么拆

治理 BigKey 的原则是:不要让一个 key 承担过多数据。

场景 拆分方式
大 JSON String 拆成 Hash 字段,或者只缓存热点字段
大 Hash 按业务维度拆 key,比如按用户、日期、分页段拆
大 List 控制长度,使用 LTRIM,历史数据转存数据库
大 Set 按 hash slot、业务分组、时间分桶拆
大 ZSet 排行榜按周期和分区拆,只保留 topN

例子:

# 不推荐:一个 key 存所有用户积分
zadd rank:all 100 user:1 90 user:2

# 推荐:按日期或业务维度拆
zadd rank:20241108:gameA 100 user:1
zadd rank:20241108:gameB 90 user:2

拆分以后也要关注查询路径。拆 key 不是为了让读取变复杂,而是为了让每次 Redis 操作都保持可控。

拆 key 会改变原来的 pipeline、事务和多 key 操作边界。在 Cluster 模式下,还要考虑 hash tag 和 slot 分布,避免拆分后产生大量 CROSSSLOT 或热点 slot。

ZSet 排行榜拆分尤其要谨慎。按时间、业务线或分区拆分后,单个 ZSet 会变小,但全局 topN 查询可能需要多路查询再 merge,查询成本会上升。

如果已经有 BigKey,删除时不要直接在高峰期 DEL。

unlink big:hash
unlink big:list

UNLINK 会先把 key 从 keyspace 里摘掉,实际内存释放交给后台线程处理,能降低主线程卡顿。

DEL 是同步删除和同步释放对象内存,BigKey 场景下容易引发 latency spike。UNLINK 能把释放动作后移到 lazyfree 后台线程,但 lazyfree 线程池也不是无限能力。如果短时间删除大量 BigKey,后台释放队列仍可能成为瓶颈。

相关配置:

lazyfree-lazy-user-del yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes

注意:异步释放只是降低删除卡顿,不代表内存马上归还给操作系统。Redis 内存指标、allocator 行为和碎片率仍然需要观察。

9. 过期删除策略

Redis 的 key 可以设置 TTL:

set login:token:1001 abc ex 1800
expire user:cache:1001 600
ttl user:cache:1001

过期 key 的删除不是每个 key 到点都立刻删除。Redis 主要结合两种策略:

策略 说明
惰性删除 访问 key 时发现过期,再删除
定期删除 Redis 周期性近似随机抽样检查过期 key

惰性删除节省 CPU,但过期 key 如果一直没人访问,会继续占内存。定期删除可以主动清理,但不能无限扫全库,否则会影响主线程。

因此,惰性删除依赖访问触发,定期删除依赖 approximate random sampling。TTL 很多且集中到期时,expired cycle 的成本会上升,可能带来短时间 CPU 和延迟抖动。生产上应尽量给 TTL 加随机抖动,避免大量 key 同一秒过期。

10. maxmemory 配置和 OOM 示例

Redis 可以通过 maxmemory 配置项限制最大可使用内存。

配置示例:

# maxmemory <bytes>
maxmemory 1

其中:

maxmemory 1

表示 Redis 最大可使用内存被设置为 1 byte。

这里的 maxmemory 1 只是为了演示 OOM 行为,生产环境不要这样配置。真实实例应该结合机器内存、业务峰值和预留空间设置成 4gb、8gb 这类可用容量。

10.1 查看 maxmemory 配置

127.0.0.1:6379> config get maxmemory
1) "maxmemory"
2) "1"

10.2 写入数据时报错

127.0.0.1:6379> set k1 v11111
(error) OOM command not allowed when used memory > 'maxmemory'.
127.0.0.1:6379>

这个错误表示:当前 Redis 已使用内存已经超过 maxmemory 限制,所以新的写入命令不被允许执行。

10.3 说明

maxmemory 用于限制 Redis 最大内存使用量。

当 Redis 使用的内存达到 maxmemory 限制后,Redis 会根据 maxmemory-policy 配置决定如何处理新写入的数据。

如果没有合适的淘汰策略,或者设置为 noeviction,就可能出现 OOM 写入错误:

OOM command not allowed when used memory > 'maxmemory'.

也就是说,Redis 内存已经超过限制,当前写命令被拒绝执行。noeviction 模式下,读命令通常还能继续执行,但新增、修改、会增加内存的写命令会直接失败。

11. maxmemory 和淘汰策略

Redis 内存上限通过 maxmemory 控制:

maxmemory 4gb
maxmemory-policy allkeys-lru

也可以运行时查看:

config get maxmemory
config get maxmemory-policy
info memory

64 位 Redis 默认 maxmemory 0 表示不主动限制内存上限,但生产环境不建议这么放任。应该结合机器内存、持久化、复制缓冲区、客户端输出缓冲区预留空间。

以 Redis 7 常见配置为主,淘汰策略如下:

策略 说明
noeviction 达到上限后写入报错,不淘汰
allkeys-lru 从所有 key 中近似淘汰最近最少使用
volatile-lru 从设置了过期时间的 key 中按 LRU 淘汰
allkeys-random 从所有 key 中随机淘汰
volatile-random 从设置了过期时间的 key 中随机淘汰
volatile-ttl 从设置了过期时间的 key 中优先淘汰 TTL 更短的
allkeys-lfu 从所有 key 中按 LFU 近似淘汰低频 key
volatile-lfu 从设置了过期时间的 key 中按 LFU 近似淘汰

Redis 的 LRU / LFU 都是近似实现,不是精确全局排序。它会从样本中选择相对合适的 key 淘汰,因此淘汰结果可能和理论上的严格 LRU / LFU 不完全一致。

volatile-* 策略只会从设置了 TTL 的 key 中选择淘汰对象。如果 TTL 设置不完整,Redis 可能找不到合适的淘汰对象,最终仍然出现写入失败。因此缓存型实例通常更适合 allkeys-lru 或 allkeys-lfu。

缓存型 Redis 常见选择是 allkeys-lru 或 allkeys-lfu。其中 LFU 更适合长期稳定热点,LRU 更适合热点随时间快速变化的缓存。如果 Redis 里混放了不能丢的数据,就应该重新审视架构,而不是只靠淘汰策略兜底。

还要注意,eviction 本身也会消耗 CPU。内存逼近上限、写入压力很高时,Redis 可能一边处理写入一边淘汰 key,形成 eviction spike。Redis eviction 不是数据可靠性机制,只是内存上限下的缓存退让策略。

12. 一套上线前检查清单

上线前至少检查这些点:

info memory
info stats
slowlog get 10
config get maxmemory
config get maxmemory-policy

业务侧要补这些约束:

检查项 建议
key 命名 带业务前缀,方便 SCAN MATCH
TTL 临时缓存必须设置过期时间
value 大小 序列化后字节数要有限制
集合长度 写入集合前控制元素数量
删除动作 大对象删除使用 UNLINK
危险命令 生产禁用 KEYS、FLUSHDB、FLUSHALL

Redis 内存治理的核心不是等到内存报警后再清理,而是在写入入口就把 key 数量、value 大小和生命周期管住。

如果运行在 Cluster 模式下,key 前缀还要兼顾 slot 分布。业务前缀方便治理,但 hash tag 使用不当会把大量 key 固定到同一个 slot,导致热点 master 和内存倾斜。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录