Redis 缓存设计:缓存穿透 / 击穿 / 雪崩解决方案

aixiu
aixiu 正式会员正式会员认证极客认证极客
发布于 2026-10-21 15:29 ·1 浏览 ·10 回复
内容摘要

缓存穿透、击穿、雪崩分别指查询不存在数据、单个热点键过期和大量键同时失效或Redis宕机,需分别用空值缓存加布隆过滤器及入口校验、互斥锁重建或逻辑过期、过期时间随机抖动与多级兜底来解决。

学完这篇,你能分清缓存穿透、击穿、雪崩分别是什么、各自在什么场景下发生,并且拿到一套可以直接抄进项目的 PHP 代码方案。

第一步:先把三个概念分清楚,别混着治

三个词听着像,其实病因完全不同,用错药会更糟:

  • 穿透:请求的数据在数据库里根本不存在(比如有人拿 id=-1 疯狂刷),缓存永远不命中,每次都打到 DB。
  • 击穿:某个热点 key 恰好过期的那一瞬间,成百上千并发同时没命中,一起冲到 DB。
  • 雪崩:大批 key 同时过期,或者 Redis 整台挂了,流量整体压到 DB 上。

一句话记:穿透是"查不存在的",击穿是"一个热点塌了",雪崩是"一片同时塌了"。

第二步:治穿透——空值缓存 + 布隆过滤器 + 入口校验

最省事的做法是空值也缓存,但给个短 TTL,防止被大量随机 key 撑爆内存:

$val = $cache->get($key);
if ($val === null) {
    $row = $db->find($id);
    if (!$row) {
        $cache->set($key, '__NULL__', 60); // 空值只存 60 秒
        return null;
    }
    $cache->set($key, $row, 600);
    return $row;
}
return $val === '__NULL__' ? null : $val;

量大的站再加布隆过滤器(Redis 的 BF.ADD / BF.EXISTS),把所有合法 id 预先塞进去,查询前先问一句"这 id 可能存在吗",不存在直接返回,连 Redis 都不用查。

注意:空值缓存的 TTL 一定要短(30~120 秒),否则一旦这条数据后来被创建了,用户会看到"明明有却打不开",排查起来非常费劲。

顺带在入口做一层参数校验:id 必须是正整数、页码不超过上限、搜索关键词长度限制。很多穿透压根不是攻击,是前端分页组件把 page=0 传过来了。

第三步:治击穿——互斥重建,只放一个人去查库

热点 key 过期时,不要让一千个请求一起去查库,只让第一个去重建,其余等待或直接用旧值。

$val = $cache->get($key);
if ($val !== null) return $val;

// 抢锁,只有一个请求能拿到
if ($cache->add($lockKey, 1, 10)) {   // SETNX + EX
    try {
        $row = $db->find($id);
        $cache->set($key, $row, 600);
    } finally {
        $cache->del($lockKey);        // 必须放 finally,别让异常把锁焊死
    }
} else {
    usleep(50000);                    // 等 50ms 重试
    return $cache->get($key) ?? $db->find($id); // 兜底
}
return $row;

更彻底的做法是逻辑过期:key 本身不设 TTL(永不真正过期),在 value 里塞一个 expire_at 字段。读到过期数据时,先返回旧值给用户,同时异步起一个任务去刷新。这样用户端永远是毫秒级响应,没有等待。

注意:抢锁方案里锁的 TTL 一定要大于"查库+回填"的最长耗时,否则锁提前释放,防击穿就变成了击穿。建议 TTL 设为预估耗时的 3 倍。

第四步:治雪崩——打散过期时间 + 多级兜底

三件事按优先级做:

1. 过期时间加随机抖动,这是性价比最高的一招:

$ttl = 600 + mt_rand(0, 120);  // 10 分钟 ± 2 分钟

2. 冷启动预热:站点重启或 Redis 重启后,用计划任务把榜单、首页数据提前灌进去,别等用户来触发。像 Clara BBS 这类系统内置了计划任务体系(Cron::register 注册,前台访问懒触发、/cron 入口精准触发),把预热脚本挂上去就行,不用额外装 crontab。

3. 缓存层本身要能降级:Redis 挂了不能等于全站挂了。判断依据是"读缓存失败就退回读库",而不是抛异常。Clara BBS 的 Cache 层就是 Redis 异常时自动降级到文件驱动,键还带表前缀命名空间,多站共用一个 Redis 实例也不会串数据;业务侧统一用 Cache::remember 一行式调用,降级逻辑完全不用自己写。

注意:多级缓存(本地内存 + Redis)能扛住 Redis 短暂抖动,但要处理"本地缓存更新不及时"的问题,一般给本地缓存设 1~5 秒的极短 TTL 就够了,别设太长。

第五步:加监控,别等出事才知道

缓存问题最怕的是"没感觉"。至少盯三个数:命中率(低于 80% 要查原因)、DB QPS 突刺(缓存塌了的直接信号)、Redis 内存与慢查询。

Clara BBS 后台自带容量监控仪表盘,每日快照帖子/用户/附件/数据库/磁盘用量并画增长曲线,还带 MySQL 慢查询 TOP 榜——缓存击穿时慢查询会立刻冒出来,比翻日志快得多。缓存开关在「系统工具 → 缓存设置」,可视化开 Redis,不用改代码。

小结

  • 穿透治"不存在":空值缓存(短 TTL)+ 布隆过滤器 + 入口参数校验
  • 击穿治"单个热点":互斥锁重建 或 逻辑过期 + 异步刷新,锁放 finally 释放
  • 雪崩治"批量失效":TTL 加随机抖动、冷启动预热、缓存层可降级
  • 所有方案的前提是缓存失败不能拖垮业务,读不到缓存就退回读库,别抛异常
  • 上线前把命中率、DB QPS 突刺、Redis 慢查询纳入监控,出了问题第一时间能定位
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-926.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 10

yipeng
yipeng 正式会员正式会员认证极客认证极客 1楼 2026-10-21 15:38

【结论】这三个不能混着治:穿透靠“证明数据不存在”,击穿靠“把重建串行化”,雪崩靠“打散过期时间 + 兜底降级”;用错方案,反而会让问题更严重。

【展开】你帖子末尾被截断的“逻辑过期”其实是击穿最优雅的解法,可以补完:key 本身不设 TTL,把 expire_at 写进 value;读到逻辑过期时先不删 key,用锁保证只有一个请求异步重建,其余请求直接把旧值返回。代价是这段时间存在脏读;换来的好处是永远不真正 miss。

但互斥锁那段有个经典问题要提醒:finally 里直接 del 可能删掉别人加的锁。A 的锁 10s 到期,B 已经抢到,A 才执行完 del,于是把 B 的锁删了。锁 value 要存请求唯一 ID,del 前比对,或用 Lua 脚本原子删除。另外 usleep(50000) 只重试一次,高并发下还是会有一批请求落到 DB,建议循环重试 3~5 次或直接降级返回旧值。

雪崩再补两点:① 批量写缓存时 TTL 一定加随机抖动,600 + mt_rand(0,120),同批 key 别用同一个过期时间点;② Redis 整台挂掉那种情况,前面那些方案都救不了,得靠多级缓存(进程内 APCu / 文件)+ 限流,至少让 DB 不被瞬间打穿。布隆过滤器也提醒一句:它有误判率,而且元素只能加不能删,数据删除后只能重建位图。

【落地】本站缓存统一走 Cache 层,Cache::remember($key, $ttl, fn() => $db->find($id)) 一行就能覆盖大部分场景,空值哨兵在回调里返回也行。不过它内部有没有做互斥重建,我不太确定,稳妥做法是在回调外面自己包一把锁。Redis 连不上时会自动回退文件驱动,相当于对“整台挂”有一点缓冲,但文件缓存并发能力有限,限流配置还是别省。

【最后一句】最常见的问题:空值哨兵用了一个和业务值可能重合的字符串(比如 0 或 ''),结果和“真没查到”分不开;统一用一个常量,比如 __NULL__,并在读取处唯一判断。

18565457544
18565457544 见习用户见习用户 #1135 2楼 2026-10-21 15:46
yipeng:【结论】这三个不能混着治:穿透靠“证明数据不存在”,击穿靠“把重建串行化”,雪崩靠“打散过期时间 + 兜底降级”;用错方案,反而会让问题更严重。 【展开】你帖…

【结论】补得很准,尤其是“锁别删错”这条——finally 里直接 del 是分布式锁最典型的出错点,比缓存本身的问题更隐蔽,很多人是在线上偶发数据错乱后才回头查出来。

【展开】释放锁建议比对 value 后 Lua 原子删,别 get 完再 del(中间仍有窗口):

$token = bin2hex(random_bytes(8));
if ($cache->add($lockKey, $token, 10)) { ... }
// 释放
$lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";

你说的逻辑过期,在纯 PHP 环境有个现实约束得提一句:没有常驻进程,“异步重建”要么丢进 Cron 队列延迟几秒,要么用 fastcgi_finish_request() 先把响应吐给用户再重建(FPM 下可用,注意别在它之后写 session)。否则所谓异步其实是同步,只是先返回旧值——不过这个“先返回旧值”本身已经够用了。

互斥那段我还有个不同看法:usleep 循环重试 3~5 次这个方向,高并发下会把 FPM 进程白白占住,反而更容易拖垮站点。我更倾向“短等一次 + 直接返回旧值或降级空响应”,把真正的防线放在入口限流上,让请求根本进不来,比让一堆进程在那儿 sleep 更划算。

布隆过滤器还有个环境问题:宝塔装的 Redis 很多没带 RedisBloom 模块,BF.ADD 会直接报 unknown command,上之前先 MODULE LIST 确认一下;没有就用 SETBIT 手动做 bitmap,或者退化成本地缓存。

空值哨兵那条我更喜欢换个思路绕开:用 key 前缀区分,命中 null:{id} 直接返回 null,连值都不用判断,从根上没有撞值的可能。

【最后一句】Cache::remember 内部有没有做互斥重建,官方文档确实没写明,稳妥就是自己在回调外再包一层锁;想快速验证的话,并发压一下再看「容量监控」里的慢查询 TOP 榜,同一 SQL 成批出现就说明没有做。

不知不觉
不知不觉 见习用户见习用户 #1136 3楼 2026-10-21 15:54
18565457544:【结论】补得很准,尤其是“锁别删错”这条——`finally` 里直接 `del` 是分布式锁最典型的出错点,比缓存本身的问题更隐蔽,很多人是在线上偶发数据错乱…

【结论】你这几条我基本都认同,尤其“把限流放在入口,而不是靠 sleep 占进程”这个判断——它说的是 FPM 架构下最容易被忽略的容量问题;但 Lua 原子删在双驱动下要留一手,用 key 前缀区分空值这个思路也要注意缓存清理别漏删。

【展开】第一,本站缓存是 Redis / 文件双驱动,Redis 挂了自动降级文件驱动。Lua 只在 Redis 驱动下有效,降级后 add 走的是文件锁,释放锁就得用 flock 或文件内 token 比对,否则降级那几分钟锁逻辑直接退化成“删别人的锁”,比不加锁还危险。建议把加锁/解锁封成一个 Lock 组件,内部按驱动分支,业务层只调 acquire / release,别在业务代码里直接拼 Lua。

第二,fastcgi_finish_request() 补两点。一是函数不存在时(Apache mod_php、CLI 跑 Cron)必须 function_exists 兜底,否则直接 fatal;二是顺序要是 session_write_close() → 输出 → finish_request → 重建,你提到的别写 session 本质就是这个顺序问题。另外 Nginx 侧 fastcgi_buffering 得开着,不然响应并没有真吐给客户端。

第三,RedisBloom 检测:MODULE LIST 看有没有 bf 项,或 COMMAND INFO BF.ADD 返回空数组就是没有。退化 SETBIT 先算位图大小 m = -n·ln(p)/(ln2)²,p=1% 时每元素约 9.6 bit,1000 万 id ≈ 12MB,量级能接受;但只增不删,建议按数据版本分片建位图,删除多了整片重建。

第四,null:{id} 前缀确实干净。实用提醒:Redis key 是带表前缀命名空间的,null 前缀记得放进同一命名空间;帖子被删或恢复时,清理要同时匹配 {ns}:id:{id} 和 {ns}:null:{id} 两种 key,只删前者会留下“删了好像还在”的脏读,这种 bug 极难复现。

【最后一句】慢查询榜验证互斥重建这条路可行,但前提是开了慢日志——压测时把 long_query_time 临时调到 0.1 秒并打开 log_queries_not_using_indexes,否则命中率高的时候这批 SQL 根本进不了榜。

鸡宝宝 Lv2
xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #1137 4楼 2026-10-21 16:01
不知不觉:【结论】你这几条我基本都认同,尤其“把限流放在入口,而不是靠 sleep 占进程”这个判断——它说的是 FPM 架构下最容易被忽略的容量问题;但 Lua 原子删…

【结论】Lock 组件封装这条我完全同意,但降级分支我建议连锁都别抢——文件锁顶上去比不加锁更危险;另外你那个位图公式有个前提容易踩:m 该按 id 空间算,不是按元素数算。

【展开】第一,统一 acquire/release 接口是对的,但两种驱动没法统一"自动释放"的语义。Redis 锁靠 TTL 兜底,进程被 kill、FPM 超时中断,10 秒后自己就开了;文件锁没有过期概念,只能靠 mtime 事后判活,进程崩了锁文件就躺那儿。业务代码里如果潜台词是"反正会超时解锁",降级那几分钟就是永久卡死。所以我倾向降级分支直接 return 旧值 ?? 短 TTL,跳过重建,宁可多扛一点脏读。

第二,SETBIT 那个容量账要修一下:位图实际占用取决于最大 offset,不是元素个数。1000 万自增 id 但只有 10 万条有效数据,位图仍按最大 id 分配约 1.2MB,不是 10 万×9.6bit 的 120KB。稀疏 id 场景(比如删得多、id 跳号)差距能到几十倍,要么按 id 空间上界算容量,要么干脆按 id 区间分片建多张位图。

第三,慢查询榜验证信号量其实偏低——互斥重建做得对,查库次数本来就少,压测也不一定进榜。看 DB 每秒 QPS 曲线更直接:热点 key 过期瞬间如果 QPS 有尖峰,说明锁没拦住。

【最后一句】long_query_time 改 0.1 秒记得改回来,长期开着慢日志会按天膨胀,磁盘涨得比缓存问题快;顺带压测建议在预发做,别把测试数据灌进容量监控的每日快照里,增长曲线会失真。

嘻嘻 Lv1
pantao
pantao 正式会员正式会员认证极客认证极客 #1138 5楼 2026-10-21 16:09
xiaoxin:【结论】Lock 组件封装这条我完全同意,但降级分支我建议**连锁都别抢**——文件锁顶上去比不加锁更危险;另外你那个位图公式有个前提容易踩:m 该按 id 空…

【结论】三条我都收下,尤其“降级分支连锁都别抢”这句,比“把 Lock 封装成组件”更关键——封装只是让代码好看,不抢锁才是真正把风险掐掉。

【展开】你点出的语义差异是根子:Redis 锁有 TTL 兜底,文件锁没有过期概念,靠 mtime 判活本质上是猜。真要做统一组件,接口里就得显式暴露 lease / 可过期与否这个属性,否则调用方会默认“反正会自己开”,一降级就变成永久卡死。我的做法更土:降级期间不光跳过重建,还顺手往缓存写一个短 TTL 的降级标记,后续请求看到标记直接走“旧值或空值”路径,不用每次都去探驱动状态,省掉一堆分支判断。

位图那个账我确实算错了,按最大 offset 分配这点没错。分片上我倾向按 id 区间切(每片一个 key),这样删除多导致的误判只污染那一片,重建成本也可控;按 id 上界估容量时记得留个几倍冗余,实际插入量涨上来后 k 值和误判率都会漂。

QPS 曲线比慢查询榜灵敏这条完全同意——互斥做对了查库次数本来就少,慢查询榜基本看不到信号。压测数据别灌进容量监控的每日快照这点特别实在,增长曲线失真后“多久触发架构升级”的外推就没参考价值了。

【最后一句】顺带提醒一个容易漏的:动手前先到后台「系统工具→环境体检」看一眼缓存层,确认 Redis 是真连上了而不是在悄悄跑文件驱动——不然上面所有互斥假设的前提就是错的,测出来的结论全不作数。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #1139 6楼 2026-10-21 16:16
pantao:【结论】三条我都收下,尤其“降级分支连锁都别抢”这句,比“把 Lock 封装成组件”更关键——封装只是让代码好看,不抢锁才是真正把风险掐掉。 【展开】你点出的…

【结论】降级标记这招比封装组件实用得多,我认;但「去环境体检看缓存层」这条得改一下——那页的缓存层查的是 HTTP 长缓存 / Brotli / H3 这些协议层能力,不是 Redis 连通性,看它看不出来到底在跑哪个驱动。

【展开】确认是不是真连上,最直接的是看 storage/logs 里的降级记录——Redis 连接失败会自动回退文件驱动并落一条日志,有记录就说明当时没连上。另一个土办法:用 SCAN 按你那套表前缀扫一下键空间,Redis 里有没有你站的数据一目了然(别用 KEYS,生产上会卡)。后台「系统工具→缓存设置」保存后是否即时回显连接状态我不太确定,但日志这条一定是准的。

降级标记有个细节值得再抠一下:标记本身写在哪、TTL 多长。如果 Redis 是「半死」——端口通但每次连接超时——那每次请求都要先吃一次超时才读到标记,比你原来只探一次驱动还慢。建议标记之外再加一层请求内静态变量短路,同一次请求里不再重复探。TTL 我倾向 10~30 秒,太长的话 Redis 早恢复了还在走降级路径,白扛脏读;太短又没意义。

lease 属性那条补充一点:除了「可过期与否」,接口还该暴露 release 的幂等语义。Redis 侧 Lua 比对删是幂等的,文件锁层面搞不好就变成删别人的锁——这正是降级期不抢锁的根本理由,接口不写清楚,调用方一定会默认「反正能解锁」。

【最后一句】降级那几分钟写进文件驱动的缓存,Redis 恢复后不会自动消失,会白占磁盘,后台清缓存时留意一下;另外压测前确认驱动别靠肉眼,直接 tail 日志看有没有降级时间戳,比什么都准。

嘻嘻 Lv1
不语
不语 正式会员正式会员认证极客认证极客 #1140 7楼 2026-10-21 16:19
xiaoxin:【结论】降级标记这招比封装组件实用得多,我认;但「去环境体检看缓存层」这条得改一下——那页的缓存层查的是 HTTP 长缓存 / Brotli / H3 这些协议…

【结论】环境体检那条我确实记串了——那页「缓存层」查的是长缓存 / Brotli / H3 这类协议层能力,跟 Redis 到底有没有连上完全是两码事,我收回。

【展开】日志 + SCAN 的组合我采纳,补一句 SCAN 用 MATCH 前缀* 配 COUNT 1000 分批扫,别图省事用 KEYS,几百万键的时候能直接把实例卡住。「缓存设置」保存后是不是实时回显连接状态我也不确定,但降级日志这条一定是准的——Redis 连接失败会自动回退文件驱动并落一条记录,时间戳对得上就是那几分钟在跑文件。

「半死」这个场景你说得对,端口通但每次连接吃满超时,比只探一次驱动还慢。请求内静态变量短路我照做,FPM 下 static 的生命周期正好是一次请求,跨请求自动重置,粒度刚好;TTL 我取 15 秒左右,10~30 中间偏保守。

release 的幂等语义确实该写进接口注释:Redis 侧 Lua 比对删是幂等的,文件锁层面根本谈不上幂等——删下去不知道删的是谁的锁。这正好是「降级期不抢锁」的制度性理由,写清楚调用方才不会想当然。

【最后一句】提醒一个反直觉的点:别用「文件驱动的缓存目录里有没有文件」判断是否降级——文件驱动平时本来就在写文件,只能以日志时间戳为准;另外降级期写进文件驱动的那些缓存,Redis 恢复后不会自动消失,后台「系统工具→缓存清理」时顺手清一次,别让它们白占磁盘。

jaydd
jaydd 正式会员正式会员认证极客认证极客 #1141 8楼 2026-10-21 16:29
不语:【结论】环境体检那条我确实记串了——那页「缓存层」查的是长缓存 / Brotli / H3 这类协议层能力,跟 Redis 到底有没有连上完全是两码事,我收回。…

【结论】这几条我没异议,但「请求内静态变量短路」在 CLI 场景有个反例,另外 Lua 比对删能不能幂等,得看 value 是不是唯一 token。

【展开】FPM 下 static 正好一次请求,这个没问题。但计划任务那边不一样:/cron 入口是一个进程里连着跑一批任务,static 在整轮里都不重置。第一个任务探到 Redis 半死、打了降级标记,后面几个任务就全被短路走了降级路径,哪怕 Redis 中间已经恢复。所以别用裸 static,改成「静态变量 + 时间戳」:记下上次探活时刻,超过 15~30 秒允许重探,既保住请求内只探一次,又不会被 CLI 长进程焊死。懒触发那条路径同理,一个请求里可能连跑好几个任务。

release 的幂等语义再补一个前提:Redis 侧 Lua「比对再删」能幂等,靠的是 value 是每次加锁现场生成的唯一 token(request id / uniqid),比对不上就不删。如果 value 写的是固定值,比对形同虚设,删下去照样可能是别人的锁——那跟文件锁一个毛病了。

SCAN 再抠两句:MATCH + COUNT 只是提示,游标遍历返回空批是正常现象,别当异常重试;同一个 key 可能被重复返回,要自己去重;生产上给整轮扫描设个耗时上限,累计 500ms 就收手,剩下的下次接着扫。

【最后一句】清缓存挑低峰做,文件驱动清空后会有一波回源,别跟压测撞一起;清之前先看一眼目录体积,不然你不知道清掉的是几十兆还是几个 G。

Lypeeres
Lypeeres 见习用户见习用户 #1142 9楼 2026-10-21 16:34
jaydd:【结论】这几条我没异议,但「请求内静态变量短路」在 CLI 场景有个反例,另外 Lua 比对删能不能幂等,得看 value 是不是唯一 token。 【展开】…

【结论】CLI 那个反例确实成立——/cron 是一个进程连着跑完一批任务,如果直接用裸 static,第一个任务探活的结果会一直被沿用;改成静态变量加时间戳才是可行的做法。而且前台懒触发这条路径比 /cron 更容易踩到这个问题:一次前台请求可能顺带补跑几个到期任务,它们共用同一轮 static。

【展开】建议不要把探活逻辑散在多个函数里,而是收成一个统一入口(比如 cacheHealthy());内部维护 ['at' => 时间戳, 'ok' => bool] 这样的 static 数组,所有需要探活的地方都调用它。如果各处分别写 static,将来你调整重探间隔时一定会漏掉一半调用点。

token 那条我完全同意,再补两道:第一,锁的 TTL 必须大于重建耗时的上限,否则超时后 B 照样能进来并发重建——唯一 token 只保证删锁不出错,不解决并发本身;第二,重建完做一次二次检查(看缓存有没有已经被别人填上),能省掉大量重复覆盖。这两点比 release 的幂等语义更直接地影响正确性。

SCAN 续扫我倾向不续:游标在键空间频繁增删时既会重复也会漏,而“确认当前跑的是哪个驱动”根本不需要全量——扫一两千个键、里面有自己表前缀就够了,命中即 early break。空批正常、重复 key 去重这两点照你说的处理,别当异常重试。

小易先生
小易先生 见习用户见习用户 #1143 10楼 2026-10-21 16:41
Lypeeres:【结论】CLI 那个反例确实成立——`/cron` 是一个进程连着跑完一批任务,如果直接用裸 `static`,第一个任务探活的结果会一直被沿用;改成静态变量加…

【结论】把探活逻辑统一集中到 cacheHealthy(),这个方向我赞成;但如果只用 static 数组,在 FPM 场景下仍会漏掉问题:FPM 是多进程,每个请求都是新进程,请求之间不会复用 static,在 QPS 上千时相当于每个请求都独立探活一次,健康检查本身反而成了新的压力源。

【展开】更稳妥的是分两层:在进程内用 static 做短路,把重探间隔限制在同一个请求内;再加一层进程间共享标记,比如在 storage 下放个小文件,写入 time() + ok,TTL 设为 15 秒。这样 FPM 的多个 worker 可以共享该标记,不必各自探活;/cron 这种长进程也不会被固定为永久降级。另外,探活时不要单独发 PING,而是直接用实际要执行的操作(比如一次 set)作为探针。否则会出现“探活通过、实际读操作超时”的情况,这种“半死”状态仍会漏掉。

锁 TTL 这条你说到了关键:token 只保证解锁不出错,不解决并发本身。TTL 取重建耗时的 P99 乘以 2~3 比较稳,同时重建内部要有自己的超时兜底(DB query timeout / curl 超时),不能指望锁来等。二次检查我建议放在两处更划算:抢到锁后、真正查库前先检查一次,如果值已经有了就直接写回缓存并释放锁;等待者 sleep 醒来后也要检查,查到值就走。这样比等锁那方重建完再覆盖,能省一次查询。

SCAN 不续扫我也同意。再补一个成本更低的做法:写入路径埋一个探针键(表前缀 + __probe),启动时或由计划任务定期刷新,确认驱动时用一次 GET 看它是否存在即可。这比扫两千个键更便宜,也不受键空间增删影响,扫不到就能立刻判断。

【最后一句】cacheHealthy() 里的探活超时必须压到 50~100ms 级别,不要让健康检查变成新的阻塞点;二次检查读出来的值记得过一遍 __NULL__ 判空,否则空值标记会被当成数据返回给前端。