美团二面:Redis 的 Key 过期时间到了,内存是立马释放的吗?为什么我的主库内存正常,从库却爆了?

104 阅读7分钟

关注我的公众号:【Fox爱分享】,可获取首发内容。

前言

前天跟一个老读者复盘美团二面,他跟我吐槽:“面试官问了个送分题,Redis 的 Key 过期时间到了,内存是立马释放的吗?我回答‘是’,结果他脸色变了,追问了一堆细节,直接给我问挂了。”

说实话,这题看着像送分,其实是送命

很多人的理解还停留在:“TTL 归零 -> 数据消失 -> 内存释放”。

但在高并发生产环境下,如果 Redis 真敢这么做,你的系统早崩了。试想一下,如果大促整点有 500 万个 Key 同时过期,Redis 为了“立刻释放内存”而疯狂扫描删除,CPU 瞬间 100%,主线程卡死,所有线上请求全部超时——这就是灾难。

今天咱们扒开 Redis 的底裤,从源码逻辑生产陷阱,彻底讲透为什么“过期了却还在占用内存”。

一、 别幻想了,Redis 根本没有“准时删除”

首先,把“定时器”这个概念从脑子里扔出去。 Redis 是基于 Reactor 模式的单线程模型(6.0 之前完全单线程,6.0 后网络 IO 多线程,但核心指令执行依然是单线程)。

如果你给几千万个 Key 每个都挂一个定时器,CPU 光是处理回调和上下文切换就得累死,哪还有空处理你的 Get/Set 请求?

Redis 采用的是一种 “懒惰”+“贪婪” 的混合策略:

1. 惰性删除(Lazy Expiration):被动清理

这是 Redis 最“鸡贼”的地方。Key 过期了?它根本不主动管。 只有当你访问这个 Key(执行 GET/TTL 等命令)时,Redis 才会检查:

  • Check:这货过期没?
  • Action:过期了 -> 立刻在主线程执行删除 -> 返回 nil

P7 级陷阱提示: 这就带来一个严重的隐性 OOM 问题——冷数据堆积。 如果一大批数据设了过期时间,但从此再也没人查过它,那它就永远不会触发惰性删除,一直赖在内存里。这就是为什么你的 Redis 经常莫名其妙内存报警。

2. 定期删除(Active Expiration):主动抽查

为了清理冷数据,Redis 必须主动出击。但请注意,这里是面试最大的坑

误区: “Redis 会开一个后台线程去删数据。”**

**

真相: 定期删除是跑在主线程(Main Thread)里的!

源码位置 src/server.c -> serverCron -> activeExpireCycle

// 文件:src/server.c
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    // ... 省略其他杂七杂八的代码 ...
    // 处理数据库相关的后台任务(注意:这里的“后台”是指逻辑上的后台,依然在主线程跑)
    databasesCron(); 
    // ... 省略 ...
    return 1000/server.hz;
}

void databasesCron(void) {
    // 如果是主节点(Master),执行主动过期策略
    if (server.active_expire_enabled && server.masterhost == NULL) {
        // 【关键点在这里】
        // 调用主动过期循环,注意这里没有任何 thread create 的操作
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
    }

    // ... 省略碎片整理等逻辑 ...
}

void activeExpireCycle(int type) {
    // ... 
    // 循环遍历每一个 DB
    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        // ...
        // 【死循环开始】只要过期比例超过阈值,就一直卡在这里删!
        // 这就是为什么我说它会阻塞主线程的原因!
        do {
            // 1. 也就是在这里,从 expires 字典里随机拿 key
            // 2. 检查是否过期
            // 3. 如果过期,调用 activeExpireCycleTryExpire -> delete()
            // 如果执行时间超过了 timelimit(默认 25ms),强制 break
            if ((iteration & 0xf) == 0) { /* check time every 16 
iterations */
                elapsed = ustime()-start;
                if (elapsed > timelimit) {
                    timelimit_exit = 1;
                    server.stat_expired_time_cap_reached_count++;
                    break; // 只有超时了才放过主线程
                }
            }
        } while (expired > config_keys_per_loop * config_cycle_acceptable_stale_cpu_percent / 100);
    }
}

Redis 每 100ms(默认 hz=10)触发一次时间事件,逻辑如下:

  1. 从设置了过期的 Key 字典中,随机抽取 20 个 Key
  2. 检查并删除其中已过期的。
  3. 核心博弈: 如果这 20 个里,过期的超过 5 个(>25%),Redis 会判定“过期数据太多了”,于是立刻重来一次步骤 1
  4. 止损机制: 为了防止主线程卡死,这个循环有一个时间上限(默认 25ms)。一旦超时,强制停止,把 CPU 权交还给正常的读写请求。

**

**

生产事故重现: 如果你在业务代码里写了个循环,让 100 万个 Key 在同一秒过期(比如缓存了今天的热门新闻,TTL 设为今晚 24:00)。 Redis 每一轮抽查,过期率都是 100%,触发“贪婪循环”,虽然有 25ms 限制,但高频的 CPU 占用依然会导致接口响应耗时(RT)出现明显的毛刺(Spike) ,甚至造成短暂的请求阻塞。

二、 致命盲区:主从架构下的“幽灵内存”

如果你面的是 P7/L8 岗位,只讲单机策略是不够的。面试官的必杀技通常是: “为什么我的主库内存正常,从库(Slave)内存却爆了?”

这触及到了 Redis 主从同步的机制:

  1. 从库绝不主动删除过期数据。即便在从库上触发了“定期删除”逻辑(高版本),它也只是标记,不会执行物理删除。
  2. 从库的惰性删除是“逻辑删除” 。你在从库查一个过期 Key,它会回你 nil(骗你没了),但物理内存里它还在!
  3. 从库必须等主库指令。只有主库真正删除了这个 Key,并生成一条 DEL 命令通过 Replication 流同步给从库,从库才会释放内存。

结论: 如果主库压力过大(过期清理跑不过来),或者主从网络延迟高,DEL 命令没及时传过去,从库就会囤积大量已过期的“尸体数据”,导致从库 OOM。

三、 终极拷问:既然都有兜底了,为什么内存还是 OOM?

即使有“惰性+定期”双保,内存依然可能被打爆,原因只有两个:

  1. 写入速度 > 清理速度:你写数据的速度太快,Redis 来不及删。
  2. 大 Key 问题:删一个几百 MB 的 Key,主线程会卡顿,导致清理效率下降。

这时候,Redis 最后的防线就是 内存淘汰策略(Maxmemory Policy)

这里有个极其危险的默认配置:noeviction。 大多数云厂商或默认安装,都是这个策略。意思是:内存满了?我死都不删!谁写我就报错!

P7 级最佳实践(建议背诵):

  • 纯缓存场景(Cache) : 建议配置 allkeys-lru。不管 Key 有没有设置 TTL,只要内存满了,就把最近最少使用的数据踢走。保证热点数据一直可用。
  • 存储场景(Store/DB) : 如果你的 Redis 里混杂了“必须要持久化的数据”(没设 TTL)和“缓存数据”。必须配置 volatile-lru。 意思就是:只杀那些设置了过期时间的数据。千万别用 allkeys-lru,否则你的持久化配置数据可能会被误删!

四、 总结:面试怎么答才像专家?

如果面试官再问“过期释放”问题,按这个逻辑层层递进,降维打击:

  1. 破题(纠正认知) : “面试官,Redis 的过期删除并非‘准时’,而是惰性删除(Lazy) 定期删除(Active) 配合完成的。而且,定期删除是运行在 主线程中的。”
  2. 剖析机制(展示深度) : “定期删除本质是概率抽查。Redis 限制了执行时长(默认 25ms),防止阻塞主线程。 但在主从架构下,从库是被动的,必须等待主库同步 DEL 指令。如果主从延迟高,会出现从库内存不释放的现象。”
  3. 解决方案(实战经验) : “生产环境中,为了避免 OOM 和主线程卡顿,我们一般做三层防御:
    • 业务层:TTL 必须加随机值(如 Random(300s)),打散过期时间,防止‘过期风暴’。
    • 配置层:根据业务场景选择正确的淘汰策略。纯缓存用 allkeys-lru,混合存储用 volatile-lru,坚决不能用默认的 noeviction
    • 版本层:对于大 Key(BigKey)删除,我们使用 Redis 4.0+ 的 Lazy Free(异步删除)特性,将释放内存的耗时操作移到后台线程,避免阻塞主线程。”

写在最后技术没有玄学,全是权衡。 Redis 之所以这么设计,是在CPU 算力(不搞定时器)、内存空间(允许少量残留)和系统稳定性(不阻塞主线程)之间做的极致妥协。

懂了这些,面试官想坑你都难。

觉得有用的兄弟,点个赞,收藏起来,万一下次面试就用上了呢!

想了解更多高频面试题,欢迎关注微信公众号【Fox爱分享】,领取百万字面试宝典。