Redis 清掉过期 key 有两条路。一条被动,命令碰上它才发现已经到期。一条主动,每 100 毫秒跑一轮,从带过期时间的 key 里随机抽 20 个,到期的删掉,抽到的这批超过 25% 都过期才继续下一轮。默认参数下一秒大约清掉 200 个。官方文档留了一句提醒,大量 key 在同一瞬间过期本身就是一类延迟来源。
三个定义大家都会背。穿透是查库里根本没有的数据,击穿是热点 key 到期的那一下,雪崩是大批 key 同时到期。定义讲的是现象,卡在哪个环节没提。
穿透卡在数据存不存在
击穿和雪崩都由 key 到期引起,数据在库里有,要解决的是让缓存别空着。穿透不一样,每个请求都得跑一趟数据库。
布隆过滤器是常见答法,通常会被追问三件事。一问误判朝哪个方向。布隆说没有时一定没有,说有时可能是误判,它挡不完穿透流量,一部分不存在的 ID 照样被放行到数据库。官方文档给了换算,0.1% 误判率要 10 个哈希函数、每元素 14.4 位,100 万个 ID 建出来占 1.8 MB 左右,同一批数据改用 set 判存在要 40 MB 上下。error rate 是建过滤器时定死的,插入量越过 capacity 就再叠一层子过滤器,存在检查一级一级变慢,关掉扩展则误判率一路漂高。
二问删除。标准布隆不支持删除,官方文档给的替代是 Cuckoo filter,检查更快、能删元素,插入更慢。这在穿透场景里很实在,商品下架、用户注销,痕迹清不掉,只能整个重建。
空值缓存是挡穿透最省事的办法,查不到的 ID 也写进 Redis,给个短 TTL。巧的是 Cache-Aside 的权威文档在示例代码里专门写了一句避免缓存 null。两边都没错,前者防重复回源,后者防内存被空壳占满。真要这么干,TTL 压到几十秒,值只放一个短标记。
布隆属于兜底,主防线在入口。ID 自增暴露、列表接口能被人一页页枚举,等流量打进来再上布隆就晚了。
击穿的时候 Redis 没什么压力
热点 key 到期那一瞬,大量请求同时 miss。Redis 自己几乎没受影响,GET 是 O(1),官方延迟文档写它的处理时间在亚微秒级。掉下去的是身后的数据库。回源要毫秒,同一刻几千条请求穿过缓存查同一行,连接池就那么些,后来的全排队。
Redis 用单线程执行命令,官方文档写明所有请求按顺序由一个线程处理,一个请求慢,后面的客户端就得等。6.0 之后多了 I/O 线程,redis.conf 注释写清它们只负责收发 socket 和解析协议,命令执行还是那一条路径。击穿伤不到 Redis。它伤的是容量小几个数量级的那个东西。
想清这一层,互斥重建保的是数据库。
java
String lockKey = "rebuild:" + cacheKey;
String token = UUID.randomUUID().toString();
// 只有抢到锁的人回源, 其余请求走降级分支
if ("OK".equals(redis.set(lockKey, token, SetParams.setParams().nx().ex(10)))) {
try {
return loadFromDbAndFillCache(cacheKey);
} finally {
redis.eval(UNLOCK_SCRIPT, 1, lockKey, token); // 值对得上才删
}
}
return readStaleOrShortWait(cacheKey);
SET key value NX EX 10 是官方文档给的写法。同一页交代了一个坑,固定字符串配 DEL 释放锁,超时以后可能删掉别人刚拿到的锁,所以值里放一个猜不到的 token,释放时先比对再删。Redisson 的 RLock 把这套做成了带续期的版本,不用猜重建要几秒。
锁的粒度常被追。按接口加一把和按 key 加一把,后果差得远。到期的只是某一条详情,把整个业务域的重建串行化,等于自己造出一批人为的慢请求。
nginx 的 proxy_cache_lock 开启后同一个 cache key 只放一个请求去后端,其余请求等响应进缓存或者等锁释放,超时之后等不到的人自己打后端,但响应不再写进缓存。Spring 的 @Cacheable(sync = true) 是同一件事,文档提醒了一句,你用的缓存库未必支持。
宁可给旧值也不让后端同时挨打。nginx 用 proxy_cache_background_update 配 proxy_cache_use_stale updating 干这件事,过期内容先按旧值返回,同时起一个子请求刷新。业务里抄过来就是逻辑过期,值里带一个真实到期时间,Redis 的 key 本身不过期,读到已过期的就交异步任务重建,代价是这期间用户看到旧数据。
一致性先答顺序,再答延迟双删
先更新库还是先删缓存,Azure 的 Cache-Aside Pattern 写得很明确,先更新数据源,再删缓存。它把反过来的后果算了一遍,先删缓存就留下一个窗口,这期间来读的客户端拿到旧值顺手回填,脏值一直活到下一次失效。这个后果比多查一次数据库严重。
删而不是改,依据是并发写。两个线程改同一行,更新缓存的落地顺序和事务提交顺序未必一致,改缓存就给了旧值盖掉新值的机会。删除把决定权交给下一个读请求。
延迟双删属于业界流传的做法,官方文档查不到对应条目。它处理的是回填竞态,删完以后另一个请求从库或者旧快照读到旧值回填缓存,于是再补一次删除。延迟多久才算够,至少要盖过主从复制延迟和读路径耗时,这两个数在生产上会飘。Redis 复制文档写明复制默认异步,WAIT 只确认有几份副本收到了数据,不能把实例变成强一致系统,故障切换时已确认的写仍可能丢。所以别把一致性压在一次定时补删上,挂到 binlog 上让重试和补偿去管。
剩下的看业务能容忍多旧。订单状态这种不如别缓存,详情页晚三十秒就晚三十秒。
雪崩分单机、集群、高可用三处处理
TTL 抖动的理由能从过期机制推出来。Redis 存的是绝对到期时间戳,预热脚本和定时任务容易把几十万条数据写成同一秒到期。一秒只清两百个左右,这一批要连着好几轮才被抽中,比例降不下来时 Redis 会一直卡在清理上。过期以后 value 的内存默认在主线程同步释放,redis.conf 里 lazyfree-lazy-expire 就是 no,默认行为跟调 DEL 一样阻塞。大 key 改用 4.0 就有的 UNLINK 删除,内存回收放到另一个线程。
java
int ttl = baseTtl + ThreadLocalRandom.current().nextInt(300);
redis.set(cacheKey, payload, SetParams.setParams().ex(ttl)); // 把到期时刻摊开
抖动只解决单机的清理成本,解决不了回源风暴。Redis Cluster 给 key 算 CRC16 再对 16384 取模落进某个 hash slot,每个 slot 由一个主节点负责。分片摊开的是 key 的数量,摊不开单个 key 的访问量,更救不了所有分片共用一份数据库。同时到期的 key 散在所有分片上,每个主节点同一秒开始 miss,流量最后收口到一个 MySQL。热点 key 只落在一个 slot 上,它过期时只有那台节点在挨打。
高可用层面还有一种更硬的情况。副本不负责自己的过期,它等主节点下发 DEL,晋升成主节点之后才独立处理过期,所以正常故障切换不会掏空缓存。更麻烦的是关了持久化又开了自动重启,官方复制文档直接列了这个故障模式,主节点带着空数据集重启,副本跟着被清空。缓存整体归零,和雪崩是同一回事。
所以这道题我想听预案。连接池上限多少,超了是拒绝还是排队,热点接口能不能降级到本地缓存。还有一个八股文里不出现的坑,maxmemory-policy 默认是 noeviction,内存到顶之后写命令直接报错,看着像雪崩,查下去是配置事故。
答到哪一步算做到哪一层
把三个定义讲完整是及格线,这里不给分。
能讲清先更库再删缓存的顺序和窗口,知道延迟双删盖不住复制延迟只是安慰剂,知道布隆只会把没有说成有、单条痕迹清不掉,这算能合作,代码交给他我不会担心线上多一份能活一天的脏数据。
能说出锁要按 key 加、抖动只是摊平单机清理成本、加节点救不了共享的那份数据库、副本晋升和空重启是两件事,连 noeviction 和过期释放都记得,我会认为这个人真处理过这类故障。
要给一个判断标准,我看他有没有把 Redis 进程内和数据库两侧分开。这三个词在八股文里长得像,落到系统里位置完全不同。分开之后每一问都能给出代价,谁在等、等多久、旧到什么程度。
文中硬事实的出处。Redis 文档的 Diagnosing latency issues 写了过期采样、亚微秒处理、阻塞释放和单线程执行。io-threads 和 lazyfree 的默认值在 redis.conf,布隆的误判率与 Cuckoo 支持删除在 Bloom filter 一节,分片规则在 Scale with Redis Cluster,复制那几条在 Redis replication,先更库再删缓存出自 Azure 的 Cache-Aside Pattern,锁写法在 SET 命令页。
高频面试题我整理成了一份 PDF,按模块分好,每题后面补了面试官通常会追问的那一步。关注绿泡泡「我要拿offer」,回复 Redis 就能拿。
想看每日面经和 offer 案例的,小红书同名号程序员曜灵。有问题留评论区,出现频率高的我会继续写成下一篇。