Administrator
发布于 2018-08-25 / 2618 阅读
31

Redis 内存满了怎么办?过期策略与淘汰策略配置

凌晨两点的告警:Redis used_memory 撞到了 maxmemory

我们的 Redis 是 4.0.2,主从各 4G。某天凌晨收到告警,used_memory 冲到 3.9G,然后业务开始大面积报:

redis.clients.jedis.exceptions.JedisDataException: OOM command not allowed when used memory > 'maxmemory'.

登上机器看:

$ redis-cli info memory
# Memory
used_memory:4089446400
used_memory_human:3.81G
used_memory_rss:4261412864
used_memory_peak:4089446400
maxmemory:4294967296
maxmemory_policy:noeviction
maxmemory_human:4.00G
mem_fragmentation_ratio:1.04

注意到 maxmemory_policy:noeviction,这是默认值:内存满了不淘汰,直接拒绝写命令。业务写不进去,读还能用,所以监控上只看到一片写失败。

我当时的疑问是:我明明给所有 key 都设了过期时间,为什么还会满?

设了过期时间不等于会准时被删

先看实际数据:

$ redis-cli dbsize
(integer) 8421336

$ redis-cli info keyspace
db0:keys=8421336,expires=8421336,avg_ttl=3600000

840 万个 key,全部带过期时间,平均 TTL 一小时。但 avg_ttl 这个数字掩盖了分布问题。我写了个脚本抽样:

$ redis-cli --scan --pattern 'sess:*' --count 1000 | head -5 | xargs -I{} redis-cli ttl {}
(integer) -2
(integer) -2
(integer) -2
(integer) -1
(integer) 3540

TTL 返回 -2 表示 key 已不存在,-1 表示存在但没设过期时间。这里混进了 -1,说明有部分 key 忘了设 TTL(是另一个同事加的永久配置项)。但更主要的问题是那些 -2——它们还占着空间,只是被标记为已过期。

这就得说 Redis 的过期删除策略了。它用了两种配合,而不是定时删除:

惰性删除

客户端访问某个 key 时,Redis 才顺手检查它是否过期,过期就删掉再返回 nil。好处是不浪费 CPU,坏处是如果一个过期 key 再也没人访问,它就永远躺在那儿。源码里这个函数叫 expireIfNeeded,在 lookupKeyRead 里被调用。

定期删除

Redis 每隔一段时间(由 hz 配置决定,默认 10,即每秒 10 次)执行 activeExpireCycle,从设置了过期时间的 key 集合里随机抽一部分检查:

  • 每次随机取 20 个 key(常量 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP);
  • 删掉其中已过期的;
  • 如果这 20 个里过期比例超过 25%,就再抽 20 个,循环继续;
  • 为了避免阻塞主线程,每次执行有时间上限,默认不超过 25ms(Redis 内部按 CPU 时间算)。

因为是随机抽样加时间限制,所以过期 key 的清理是渐进的,一定存在滞后。写入速度超过清理速度时,内存就一路涨上去。我们那次是因为运营做了个活动,写 QPS 从平时的 3000 涨到了 1.2 万。

八种淘汰策略怎么选

maxmemory-policy 一共 8 个可选项。Redis 3.x 只有前 6 个,后两个 LFU 相关的是 4.0 才加的,我们当时正好在 4.0,用得上。

策略淘汰范围淘汰依据3.x 支持
noeviction不淘汰写命令直接报 OOM
volatile-lru只挑设了过期时间的 key最近最少使用
allkeys-lru所有 key最近最少使用
volatile-random只挑设了过期时间的 key随机
allkeys-random所有 key随机
volatile-ttl只挑设了过期时间的 key剩余 TTL 最短的先走
volatile-lfu只挑设了过期时间的 key访问频率最低否(4.0+)
allkeys-lfu所有 key访问频率最低否(4.0+)

几个判断依据:

  • 拿 Redis 当缓存用,数据丢了能从 DB 重建:选 allkeys-lru。这是最常见的场景,也是我最后选的。
  • 同时存缓存和持久化数据(比如有些配置型 key 不能丢):选 volatile-lru,只淘汰带过期时间的那部分。前提是你能保证关键 key 都没设 TTL——这就是前面那个 -1 的来源,得先清理掉误设的永久 key。
  • 希望快过期的先走volatile-ttl。适合那种"越新越有价值"的数据。
  • 有明确的热点,且冷热长期稳定allkeys-lfu。它能避免 LRU 的一个老问题:一个被批量扫描一次的数据会把真热点挤出去。我们的商品详情缓存后来换成了 LFU,命中率从 91.3% 提到 94.7%。

关于 LRU:Redis 用的是近似 LRU

这点我一开始理解错了。Redis 并没有维护一个严格的 LRU 链表(那太占内存),而是在 redisObjectlru 字段里存了 24 位的最近访问时间戳(单位是秒级的 LRU_CLOCK),淘汰时随机抽 maxmemory-samples 个样本,挑其中最久没被访问的删掉。

# redis.conf
maxmemory-samples 5

默认 5 个。样本越大越接近真实 LRU,但 CPU 消耗也越大。Redis 官方有张对比图,samples 设为 10 时已经非常接近理论 LRU,再往上收益就很小了。我调到 10 之后压测了一轮,淘汰精度确实提升,CPU 基本没变化,就留着了。

我的配置和处理过程

改配置不用重启,可以在线改:

$ redis-cli config set maxmemory-policy allkeys-lru
OK
$ redis-cli config set maxmemory-samples 10
OK
$ redis-cli config get maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

别忘了同步改 redis.conf,不然重启后配置会丢:

$ redis-cli config rewrite

然后处理那些历史遗留的永久 key。用 scan(千万别用 keys *,会阻塞)找出没设 TTL 的:

$ redis-cli --scan --pattern 'config:*' --count 1000 | while read key; do
    ttl=$(redis-cli ttl "$key")
    if [ "$ttl" -eq -1 ]; then
        echo "$key"
    fi
done

查出来 3271 个,确认可以重建后补上过期时间:

$ redis-cli expire "config:rate:limit" 86400
(integer) 1

效果:

指标处理前处理后 24h
used_memory3.81G(贴顶)2.6G 稳定
写失败次数 / 天约 4.2 万0
缓存命中率88.1%93.5%
evicted_keys0(noeviction)约 12 万 / 天

evicted_keys 从 0 变成 12 万,看着吓人,其实是正常的:说明淘汰机制在工作,被淘汰的都是冷数据,命中率反而升了,因为腾出来的空间装下了更多热数据。

后来加的几条规矩

  • maxmemory 不要设成机器内存的 100%。我们有台机器因为 used_memory_rssused_memory 高(碎片),加上 fork 子进程做 RDB 时的 copy-on-write,实际占用超过了 maxmemory。一般留 30% 左右余量。
  • 所有写入必须带 TTL,代码 review 时重点看这一条。我写了个小工具定期扫 TTL 为 -1 的 key 发到群里。
  • 监控除了 used_memory,一定要加 evicted_keysexpired_keys 的曲线。淘汰数突然飙升往往是缓存穿透或者热key 变化的前兆。
  • 关注 mem_fragmentation_ratio。我们那台一度到了 1.8,说明碎片严重,后来在低峰期做了次主从切换重启解决的。Redis 4.0 可以用 activedefrag yes 开自动碎片整理。

那次事故之后我才明白一件事:给 key 设过期时间,只是告诉 Redis"这个数据可以删",而不是"这个数据会被立刻删掉"。中间那段滞后,才是内存被打满的原因。

参考