Redis这个内存杀手坑惨了我,排查三小时发现是过期策略搞的鬼

0 阅读1分钟
  • Redis这个内存杀手坑惨了我,排查三小时发现是过期策略搞的鬼*

引言:一个深夜告警引发的"血案"

凌晨2:15,手机突然响起刺耳的告警铃声——线上Redis集群内存使用率突破95%阈值。作为值班工程师的我瞬间清醒,快速登录监控系统查看,发现一个诡异的现象:明明业务量处于低谷期,但Redis内存占用却居高不下,甚至还在缓慢增长。

接下来的三小时排查过程,犹如侦探破案般抽丝剥茧,最终发现"凶手"竟是Redis的过期键删除策略。这个看似简单的机制,在特定场景下竟能引发严重的内存问题。本文将详细还原整个排查过程,深入分析Redis的过期策略机制,并给出防范方案。

第一部分:问题现象与初步排查

1.1 异常现象描述

监控系统显示以下关键指标异常:

  • 内存使用率:95.3%且持续上升
  • key总量:约420万
  • 连接数:正常范围
  • QPS:仅为平日峰值的1/5

最奇怪的是info memory中的expired_keys指标显示过去24小时只有不到1万key过期,而这与业务特性严重不符——我们的业务场景每天应该有百万级key自动过期。

1.2 排查路线图

按照标准排查流程,我依次检查了:

  1. 内存分析redis-cli --bigkeys未发现异常大key
  2. 数据类型type命令抽样显示95%为string类型
  3. TTL分布ttl命令抽样显示大量key已过期(TTL=-1)
  4. 淘汰策略config get maxmemory-policy显示为volatile-lru

此时发现第一个矛盾点:大量已过期key未被清理,但淘汰策略理论上会处理这些key。

第二部分:Redis过期机制深度解析

2.1 过期键的两种删除策略

Redis采用惰性删除+定期删除的组合策略:

  • 1. 惰性删除(被动删除)*
  • 触发时机:访问key时检查是否过期
  • 优点:CPU友好
  • 缺点:内存不友好,不访问的key永远不释放
  • 2. 定期删除(主动删除)*
# 伪代码表示定期删除流程
def activeExpireCycle():
    for db in redisServer.db:
        expired = 0
        for key in random.sample(db.dict, 20):  # 随机采样
            if is_expired(key):
                delete_key(key)
                expired +=1
                if expired > 25%:  # 每次最多删除25%样本
                    break
  • 默认每秒运行10次(每100ms一次)
  • 每次随机检查20个key,删除其中已过期的
  • 如果超过25%的key过期,会继续检查直到低于25%

2.3 关键配置参数

  1. hz:默认10,控制定期删除频率
  2. maxmemory-samples:LRU淘汰时的采样数量(默认5)
  3. 动态调整机制:当发现过期key比例高时,会自动提高删除频率

第三部分:问题根因分析

3.1 完美风暴的形成

结合我们的业务场景,问题根源逐渐清晰:

  1. 批量创建特性:夜间批量作业会集中写入300万临时key,TTL=2小时
  2. 访问模式:这些key后续几乎不会被访问
  3. 删除瓶颈
    • 惰性删除几乎不生效(无访问)
    • 定期删除每秒最多删除200key(10次×20key)
    • 需要清理300万key → 理论最少需要4.17小时

3.2 数学验证

计算实际清理速度:

200 key/s × 3600s = 720,000 key/hour
3,000,000 / 720,000 ≈ 4.17小时

这与我们观察到内存4-5小时才完全释放的现象完全吻合。

第四部分:解决方案与优化实践

4.1 短期应急方案

  1. 手动触发清理
    # 扫描所有db并强制清理
    for i in {0..15}; do
      redis-cli -n $i scan 0 count 100000 | xargs -L 1 redis-cli -n $i ttl | grep -v "-1"
    done
    
  2. 临时调整hz参数
    redis-cli config set hz 100  # 提高10倍清理频率
    

4.2 长期解决方案

  1. 拆分业务key

    • 高频临时key使用独立Redis实例
    • 设置更小的maxmemory确保及时触发淘汰
  2. 调整写入模式

    # 原写法(集中创建)
    for item in items:
        r.set(f"temp:{item.id}", data, ex=7200)
    
    # 优化写法(均匀过期)
    base_ttl = 7200
    spread_ttl = random.randint(-600, 600)  # ±10分钟随机波动
    for item in items:
        r.set(f"temp:{item.id}", data, ex=base_ttl + spread_ttl)
    
  3. 监控增强

    # 监控过期key堆积
    redis-cli info stats | grep expired_stale_perc
    

4.3 配置优化建议

# redis.conf关键配置
hz 50                        # 提高基线频率
maxmemory-policy allkeys-lru # 确保内存不足时能清理所有key
active-expire-effort 2       # 更积极的过期清理(Redis 7.0+)

第五部分:经验总结与最佳实践

  1. 关键认知

    • Redis过期删除不是实时的
    • 批量相同TTL的key是高风险模式
    • 监控不能只看内存使用率,还需关注expired_stale_perc等指标
  2. 设计原则

    • 避免集中过期:通过随机抖动分散TTL
    • 冷热数据分离:不同生命周期的key使用不同实例
    • 压力测试:模拟批量过期场景验证清理速度
  3. 高级技巧

    /* Redis源码中的自适应算法 */
    if (server.stat_expired_stale_perc > 25) {
        server.active_expire_effort = MIN(server.active_expire_effort+1, 10);
    } else {
        server.active_expire_effort = MAX(server.active_expire_effort-1, 1);
    }
    

    理解这种自适应机制有助于合理设置effort参数。

结语:从故障中学到的

这次事故让我深刻体会到:即使像Redis这样成熟的组件,在特定使用模式下也会表现出意料之外的行为。作为工程师,我们不仅要会使用工具,更需要理解其内部机制和适用边界。

最终我们通过三个层面的改进彻底解决了问题:

  1. 架构层面:分离临时数据到专用集群
  2. 代码层面:为批量key添加TTL随机抖动
  3. 配置层面:调优hz和active-expire-effort参数

希望本文的排查思路和解决方案能给遇到类似问题的同行带来启发。记住,在分布式系统中,没有"魔法"——每个异常现象背后都有其技术原理,只有深入理解这些原理,才能真正掌握系统。