
凌晨两点被告警电话叫醒Redis 的 QPS 曲线掉到接近地板MySQL 的 CPU 却顶到了 90% 以上接口平均耗时从 30ms 涨到 2s——这个场景在我过去几年里遇到过不止一次。爬起来看日志十有八九是缓存出了状况而且几乎都能归进穿透、击穿、雪崩这三个筐里。但真正麻烦的地方不在于修而在于复盘会上大家对这三个词的理解根本不一致有人描述的是查一个根本不存在的 id有人讲的是某个热点 key 刚好过期还有人说的是整片缓存同时失效。三种完全不同的故障模型对应的修复手段差得很远讨论一旦混淆方案就永远对不上号。这篇就把这三件事拆开讲清楚。适合谁看写过缓存读取逻辑但没系统梳理过故障场景的后端同学正在准备技术评审、需要把方案讲明白的人以及线上已经被这几个问题折腾过、想补一补系统性认知的同行。触发条件、典型现象、可选方案、参数怎么算、代码怎么写、监控埋哪些点我都会写出来包括我在实际项目里踩过的坑和后来改过的做法。1. 先把三个词分清楚穿透查的是不存在击穿崩的是热点雪崩塌的是一片1.1 便利店仓库的类比一次性讲透三者的触发条件很多资料一上来就给定义读完还是记不住。我习惯用一个仓库的类比来区分数据库是后仓缓存是前场货架请求就是来买东西的顾客。缓存穿透是顾客反复来问一个店里压根没有的东西比如你们有蓝鳍金枪鱼吗。货架上没有后仓也没有但每次问店员都得跑一趟后仓翻一遍。请求特征很明确查询的 key 在数据库里根本不存在缓存永远不命中每一次请求都会穿透到存储层。缓存击穿是货架上某一款爆品刚好被拿空的那一瞬间门口排着两百个人同时要这一款。单个 key 失效但这个 key 承载的并发量极高重建缓存的那几百毫秒里所有并发请求同时压到数据库上。注意它的关键词是单个和热点。缓存雪崩是两个层面的问题一是大批 key 的过期时间设成了同一个时刻到点集体失效前场货架整排整排地空二是缓存服务本身挂了比如实例宕机、集群抖动这时候不是某个 key 的问题是整个前场没了所有流量直接怼到后仓。用一张表把边界钉死后面讨论方案时对照着看维度缓存穿透缓存击穿缓存雪崩涉及 key 的数量单个或海量伪造 key单个热点 key大批 key 或整个缓存层数据是否存在不存在存在存在触发时机任意时刻可被恶意构造热点 key 过期瞬间批量过期时刻或缓存故障数据库压力形态持续的低效查询短时尖峰持续性洪峰核心应对思路拦住无效查询串行化重建打散失效时间 兜底1.2 面试和复盘会上最容易答混的那一问被问得最多的一个问题是击穿和穿透到底怎么区分我见过太多回答绕来绕去说不清。最简洁的记法是看数据在数据库里到底存不存在穿透是压根没有击穿是有但缓存刚好没了。前者是无效查询后者是有效查询撞上了空窗期。还有一个常见的混淆是把雪崩理解成缓存挂了把批量过期排除在外。实际上批量过期造成的后果和缓存挂掉是同量级的甚至更隐蔽——因为缓存服务本身监控指标一切正常但数据库 QPS 会在某个整点时刻突然拉升而且周期性复现。我以前遇到过每天早上 9 点数据库连接数暴涨的问题查了两天才发现是前一天晚上批量预热时给一批商品缓存统一设了 24 小时过期第二天同一时刻集体失效。顺带纠一个更细的误区有些人把缓存和数据库数据不一致也往这三个筐里塞。那不是一回事那是更新策略的问题先删缓存还是先更新库、延迟双删、订阅 binlog 等和本篇讨论的失效场景属于两个正交的维度。分开讨论方案才不会打架。2. 缓存穿透请求专挑不存在的 key 打数据库就成了挡箭牌2.1 误伤型穿透与攻击型穿透处理思路完全不同穿透要分两种来源看因为应对手段的侧重完全不同。误伤型穿透来自正常的业务边界。比如用户手滑输了个不存在的订单号前端没做校验直接发请求比如爬虫顺着自增 id 从头扫扫到大量已删除的记录比如上游系统传了个空字符串过来。这类穿透的 QPS 通常不高但请求分布随机属于长期慢性失血。攻击型穿透是有人刻意构造大量不存在的 key 打你的接口。id 采用随机 UUID 或者负数、超长字符串缓存永远不命中每个请求都要压到数据库做一次全表扫或者索引扫。这种场景下如果不做任何防护几千 QPS 就能把一台中小规格的数据库打瘫。两类穿透的应对优先级不一样。误伤型主要靠缓存空值和参数校验解决成本低见效快攻击型必须上布隆过滤器或者风控限流光靠空值缓存顶不住——因为攻击者每次换一个随机 key空值缓存也会被撑爆还会挤占正常缓存的内存。我在一个查询类接口上吃过这个亏。当时只做了空值缓存结果被人用随机参数刷Redis 内存两天涨了 3 个 G全是 value 为NULL的垃圾 key。后来加了布隆过滤器前置拦截 按 IP 维度的限流内存才稳下来。2.2 缓存空值过期时间设多长key 怎么命名缓存空值是成本最低的方案逻辑就一句话查数据库返回空也往缓存里写一个特殊标记下次同样的 key 直接返回空。这里有几个细节必须想清楚不然会引入新问题。过期时间不能太长。空值缓存的本质是把当前不存在这个事实固化一段时间。如果业务上这个数据后续可能被创建比如用户先查询订单几秒后下单成功缓存里的空值会导致新数据在过期前一直查不到。我一般把这个时间控制在 30 秒到 2 分钟之间具体看业务对一致性的敏感度。交易类场景我会压到 30 秒以内商品详情这类可以放到 5 分钟。空值的表示要和真实数据严格区分。不要用空字符串或者0这种容易撞车的值。我见过有人用表示空值结果业务里真有字段值为空字符串的合法数据读取逻辑直接判断失误。惯用做法是存一个约定的哨兵字符串序列化后形如__NULL__读取端统一解析成 null 返回给上层。key 的命名要能体现来源。建议把业务前缀、版本号、具体 id 拼进去比如item:detail:v2:10086。加版本号的好处是当缓存结构变更或者需要紧急清空某类缓存时改一下版本号就能整体绕开旧数据不用去 Redis 里扫 key 删除——扫 key 在生产环境是要命的操作。public ItemDetail getDetail(Long id) { String key item:detail:v2: id; String cached redis.get(key); if (cached ! null) { return NULL_SENTINEL.equals(cached) ? null : JSON.parseObject(cached, ItemDetail.class); } ItemDetail detail itemMapper.selectById(id); if (detail null) { // 空值缓存 60 秒加随机抖动避免同一批 id 同时过期 redis.setex(key, 60 ThreadLocalRandom.current().nextInt(30), NULL_SENTINEL); return null; } redis.setex(key, 1800 ThreadLocalRandom.current().nextInt(300), JSON.toJSONString(detail)); return detail; }注意上面空值的过期时间也加了随机抖动原因在雪崩那一节会展开讲。2.3 布隆过滤器的容量与误判率手算一遍布隆过滤器是应对穿透的核武器一个位数组加若干哈希函数能明确回答这个元素一定不存在或者可能存在。判断不存在时百分之百准确判断存在时有一定误判率——但误判的方向是安全的把不存在的误判成存在最多是多查一次数据库不会漏数据。关键参数只有两个位数组长度 m 和哈希函数个数 k。给定预期元素数量 n 和可接受的误判率 p有现成公式m -n × ln(p) / (ln2)²k (m / n) × ln2拿一个具体例子算一遍。假设某商品服务需要拦截约 1000 万个商品 id能接受的误判率是 1%m -10^7 × ln(0.01) / 0.4805 ≈ 10^7 × 4.605 / 0.4805 ≈ 9.58 × 10^7 bit也就是约11.4 MBk (9.58 × 10^7 / 10^7) × 0.693 ≈6.64取 7也就是说11.4 MB 的内存就能用 1% 的误判率挡住 1000 万个 key 的穿透查询。这个性价比极高对比一下空值缓存在被攻击时的内存消耗差距非常明显。落地时有几个坑值得提前说第一容量必须预估不能事后扩。布隆过滤器的位数组一旦确定就很难动态扩容元素数量超过预估容量后误判率会急剧上升。n 的估算要留足余量按未来一年的数据增长量来算别按当前量。如果确实需要扩容得新建一个更大的过滤器双写一段时间后切换麻烦但可控。第二不支持删除。标准布隆过滤器无法删除元素因为一个位可能被多个元素共享。商品下架、账号注销这类场景会带来累积误差。解决办法要么是定期全量重建比如每天凌晨从数据库重新加载要么用计数布隆过滤器每个位换成计数器代价是内存涨几倍。我倾向于定期重建实现简单误差可控。第三数据写入要同步更新过滤器。新增记录后必须把 key 加进布隆过滤器否则新数据会被当成不存在直接拦掉这是最致命的一类 bug。惯用做法是写完数据库后发一条消息由消费者异步更新过滤器同时保留一个短周期的全量重建作为兜底。第四本地布隆过滤器在多实例部署下的一致性。每个应用实例各持有一份过滤器更新时要保证所有实例都收到。简单做法是通过配置中心或者消息广播下发变更复杂一点就用 Redis 的 bitmap 结构集中存储每个实例读取。后者的网络开销大一些但一致性更好。2.4 参数校验和限流为什么这层最容易被跳过很多团队上布隆过滤器上得很积极却忘了最基础的一层参数合法性校验。id 是自增长整型那就校验它必须是正整数且不超过某个上限订单号有固定格式就用正则卡死格式分页参数限制最大页数和每页条数。这些校验写在 Controller 层一个if就能挡掉绝大部分构造出来的垃圾请求成本几乎为零。被跳过的原因通常是反正后面有缓存兜着或者参数校验是前端的事。前者的错误在于缓存本身会被垃圾请求冲击后者的错误在于任何前端校验都可以被绕过。接口是面向公网暴露的就必须假设调用方是恶意的。限流是最后一道闸门。按 IP、按用户、按接口维度分别设阈值超过阈值直接返回 429 或者统一的错误码。这里要注意的是别把正常用户的合理突发流量误伤阈值设定要基于真实压测数据而不是拍脑袋。我一般会先上线观察模式只记录不拦截跑一周拿到真实的分布曲线取 P99 的 1.5 倍作为阈值。3. 缓存击穿热点 key 失效的那几百毫秒流量全灌进数据库3.1 互斥锁重建锁粒度、超时时间与重试策略击穿的核心矛盾是缓存重建需要时间而这段时间的并发请求都想读数据。最直接的思路是加锁只放一个请求去查数据库重建缓存其余请求等待。锁的选型上单机部署用synchronized或者ReentrantLock就够了多实例部署必须用分布式锁。Redis 的SET key value NX PX timeout是最轻量的实现一条命令同时完成加锁和设置过期时间不会出现加锁成功但设置过期失败导致死锁的问题。public ItemDetail getWithMutex(Long id) { String key item:detail:v2: id; ItemDetail cached readFromCache(key); if (cached ! null) return cached; String lockKey lock:item:detail: id; String token UUID.randomUUID().toString(); boolean locked redis.set(lockKey, token, NX, PX, 3000); if (!locked) { // 拿不到锁短暂等待后重试读取缓存 sleepQuietly(50); ItemDetail retry readFromCache(key); if (retry ! null) return retry; // 重试次数用尽降级返回兜底数据或抛业务异常 throw new CacheRebuildBusyException(id); } try { // 二次检查防止在等锁期间别的线程已经重建完成 ItemDetail doubleCheck readFromCache(key); if (doubleCheck ! null) return doubleCheck; ItemDetail detail itemMapper.selectById(id); if (detail ! null) { redis.setex(key, 1800, JSON.toJSONString(detail)); } return detail; } finally { // 用 Lua 脚本比对 token 再删除避免误删别人的锁 releaseLock(lockKey, token); } }几个细节值得展开说。锁的超时时间怎么定。太短会导致业务还没执行完锁就自动释放其他线程进来重复查库等于没锁太长会让拿不到锁的线程等待过久接口响应时间飙升。我的经验值是把超时设为数据库查询 P99 耗时 × 3再留一个下限比如 1 秒。如果业务复杂到超时时间确实不够用就别硬撑改用锁续期机制后台线程定期延长锁的有效期但复杂度和风险都上一个台阶能不用就不用。释放锁必须比对持有者。直接DEL lockKey是经典错误线程 A 的锁超时自动释放了线程 B 拿到锁这时 A 执行完把自己的锁删掉删的其实是 B 的锁后面线程 C 又能拿到锁。用 UUID 作为持有者标识加 Lua 脚本做原子比对删除能规避这个问题。重试策略要克制。拿不到锁的线程如果直接自旋重试会在锁释放瞬间造成二次冲击。我的做法是固定等待 50ms 后重试一次还是失败就降级返回不再无限重试。降级内容可以是兜底数据、默认值或者干脆返回一个系统繁忙的业务错误码让前端做温和提示总比拖垮数据库强。这个方案会牺牲可用性换一致性。极端情况下如果持锁线程挂掉了锁要等到超时才释放这段时间所有请求都在等待或者降级。对一致性要求高、对短暂不可用能容忍的场景适合用它对可用性要求极高的场景看下一种思路。3.2 逻辑过期用旧数据顶住异步刷新的代价逻辑过期的思路是缓存永不设置物理过期时间把过期时间写在 value 里面由业务代码判断。发现逻辑过期后不阻塞当前请求直接返回旧数据同时触发一个异步线程去重建缓存。{ data: { id: 10086, name: 某商品, price: 9900 }, logicalExpireAt: 1735689600000 }读取流程是这样的解析 value判断logicalExpireAt是否小于当前时间。没过期就直接返回过期了则尝试获取重建锁拿到锁的线程提交异步任务去查库并回写当前线程和所有并发线程都拿旧数据返回。这个方案的优点是响应快、可用性高缓存服务正常的情况下不会有任何请求阻塞在锁上。代价有三个必须评估一是数据一致性窗口被拉长。从逻辑过期到异步重建完成这段时间所有请求读到的都是旧数据。如果这个时间窗口内数据库数据变了比如价格调整用户看到的就是过期价格。所以我只在对一致性容忍度较高的场景用这个方案比如商品详情的展示字段、文章的阅读数。涉及价格、库存、余额这类字段老老实实用互斥锁。二是异步线程可能把自己的任务堆满。如果重建逻辑耗时较长而热点 key 的过期又比较频繁异步线程池会被打满进而影响其他异步任务。解决办法是给重建任务单独的线程池做好隔离并且加一个同一 key 短时间内只重建一次的去重控制。三是内存占用。不设物理 TTL 意味着 key 会一直存在内存里依赖淘汰策略清理。要确保 Redis 配置了合理的maxmemory-policy推荐allkeys-lru或volatile-lru并且容量留足余量否则会触发 key 被意外淘汰退化成击穿场景。两种方案的选择其实不难能接受短时不一致、追求高可用选逻辑过期不能接受脏数据、宁可短暂降级选互斥锁。同一个系统里不同业务用不同方案也很正常别强求统一。3.3 哪些 key 值得单独拎出来保护击穿防护是有成本的不管是加锁还是异步线程都会增加代码复杂度和运行时开销。所以真正值得动用这些手段的只有热点 key。识别热点 key 有几个来源一是业务上明确知道的大促商品、首页配置、活动信息这类在发布前就能列出来直接给它们配置更长的基础过期时间或者走永不过期 主动更新的策略二是通过监控发现的Redis 4.0 之后提供hotkeys参数配合redis-cli --hotkeys可以采样统计访问频率或者用MONITOR命令短时观察注意性能和隐私风险别在生产长时间开三是应用层埋点在读取缓存的地方记录 key 的访问计数定期上报聚合。我的做法是所有缓存读取默认走标准的空值缓存 随机过期时间对业务上明确的热点 key在配置中心维护一个名单读取时先判断是否在名单内是的话额外走临界区保护这里可以选用本地锁代替分布式锁因为热点 key 通常分片后落在同一个实例上本地锁更轻量。名单要能动态生效避免大促前临时改代码上线。4. 缓存雪崩成批的 key 在同一秒失效或者整个缓存层直接没了4.1 过期时间打散随机因子的取值范围怎么定批量过期是雪崩最常见也最容易修复的成因。修法本身很简单给过期时间加随机抖动。问题是抖动范围加多大拍脑袋加个 new Random().nextInt(100)意义不大。我的判断依据是缓存重建的总耗时。假设有 10 万个 key 需要在某个时间窗口内完成预热单个 key 重建平均耗时 5ms串行重建要 500 秒并发度 20 的话要 25 秒。那抖动范围至少要覆盖这个重建窗口才能保证失效请求被均匀摊开而不是叠在一起。反过来抖动范围也不能太大否则缓存的定时刷新语义就失效了某些 key 可能比你预期的早很多就过期。实践中我一般按基础 TTL 的 10%~20% 来设定抖动范围。比如基础 TTL 30 分钟抖动用 0~5 分钟int baseTtlSeconds 1800; int jitterSeconds ThreadLocalRandom.current().nextInt(300); redis.setex(key, baseTtlSeconds jitterSeconds, value);对于数据量特别大的场景比如千万级商品的缓存预热光靠随机数还不够因为随机分布在大样本下也会出现局部聚集。这时候要配合分批预热和错峰加载把数据分片每批之间间隔几百毫秒避免在同一秒内集中写缓存。预热任务本身也要做并发控制别把数据库压垮——预热变成了新的雪崩源这种事我见过。还有一个隐蔽的坑批量导入或缓存重建任务使用同一个批量接口。比如一个管理后台功能刷新全部商品缓存点了按钮就把全部 key 删了重写删除瞬间就是一次人造雪崩。这类接口必须改成异步任务 分批处理 限速并且在界面上明确提示预计耗时别让人连着点。4.2 多级缓存的分层设计与本地缓存的更新难题对付雪崩多级缓存是标配。典型结构是本地缓存 分布式缓存 数据库三层本地缓存存最热的一小部分数据命中就不走网络分布式缓存存全量数据库是最终来源。本地缓存选型上Java 生态里 Caffeine 是主流选择它的 W-TinyLFU 淘汰算法在命中率和内存效率上做得比较均衡。Go 生态里有 bigcache、freecache 这类。选型的核心看三个点淘汰策略是否合理、并发读的性能、是否支持过期和容量限制。功能花哨不重要稳定最重要。多级缓存带来的核心难题是数据更新的一致性。数据库改了本地缓存怎么知道要失效几种常见做法更新方式实现方式一致性延迟适用场景消息广播数据变更后发消息各实例收到后清本地缓存秒级数据量中等、一致性要求一般版本号轮询本地缓存记录版本号定期拉取最新版本比对取决于轮询间隔变更不频繁的配置类数据短 TTL本地缓存设很短的过期时间如 1~5 秒最大等于 TTL一致性要求低、读多写少集中式失效通知通过注册中心或者专门的失效通道推送亚秒级对一致性要求较高的热点数据我在一个商品详情场景里用的是消息广播 短 TTL 双保险。短 TTL 设为 3 秒作用是兜底——万一消息丢了或者消费延迟最多 3 秒后数据也会自动刷新。消息广播负责让绝大多数实例在几百毫秒内感知变更。这套组合下来实测数据不一致的窗口基本控制在 1 秒以内业务方可以接受。要提醒的是本地缓存容量必须严格限制。见过有团队给本地缓存配了无上限想着机器内存大结果大促期间单个实例缓存了 3GB 数据GC 时间飙升接口大面积超时。本地缓存的定位就是热数据的一小撮容量按实例内存的 5%~10% 来配超出的靠淘汰。4.3 熔断、降级、限流三件套的配合顺序缓存层整体故障时实例宕机、网络分区、集群切主光靠缓存侧的优化救不了场必须靠应用侧的防护。这三个手段经常被混着说实际职责不同配合顺序也有讲究。限流在最前面。入口处按接口维度限制 QPS超过阈值直接拒绝。目的是保住系统不被打穿让已经进来的请求还有机会正常处理。限流算法用令牌桶或者滑动窗口都可以关键是把阈值定准。阈值应该基于数据库在最坏情况下能承受的 QPS来反推而不是缓存正常时的处理能力。因为缓存挂了之后瓶颈会立刻转移到数据库。熔断在中间。当对数据库的调用错误率或者响应时间超过阈值时熔断器打开后续请求直接返回降级结果不再去尝试数据库。这能防止数据库在已经过载的情况下被持续冲击给它恢复的时间。熔断器要配置半开状态定期放少量请求试探恢复了就关闭。降级是最终手段。返回兜底数据、静态页面、默认值或者提示当前访问人数过多。降级内容最好提前准备好做成可配置的开关出问题时由值班同学一键开启而不是临时改代码紧急发版。我经历过一次半夜紧急发版上线降级逻辑从定位问题到发版花了四十分钟那四十分钟里数据库一直处于半死状态。后来所有核心接口都预置了降级开关通过配置中心控制切换在十秒内完成。再补充一点容易被忽略的缓存集群自身的高可用。Redis 用主从 哨兵或者 Cluster 模式部署主节点故障能自动切换。但切换本身需要时间通常十几秒到几十秒这段时间内客户端会大量报错。所以应用侧的连接池配置、超时时间、重试次数都要调优超时时间设短比如 100ms重试次数设 1 次快速失败比长时间挂着好。另外要确保客户端开启了拓扑自动刷新切换完成后能自动连到新主节点。5. 落到代码上的读取模板与监控埋点5.1 一个能同时扛住击穿和穿透的读取方法把前面几节的措施串起来形成一个可以直接抄的读取模板。这个模板处理了空值缓存、随机过期、互斥重建、异常兜底四件事public T T readThrough(String key, long baseTtlSeconds, SupplierT loader, ClassT clazz) { // 1. 读缓存 String cached null; try { cached redis.get(key); } catch (Exception e) { log.warn(redis get failed, key{}, key, e); // 缓存不可用时直接回源但要限流保护 return loader.get(); } if (cached ! null) { return NULL_SENTINEL.equals(cached) ? null : JSON.parseObject(cached, clazz); } // 2. 缓存未命中尝试获取重建锁 String lockKey lock: key; String token UUID.randomUUID().toString(); boolean locked false; try { locked redis.set(lockKey, token, NX, PX, 3000); } catch (Exception e) { log.warn(acquire lock failed, key{}, key, e); } if (!locked) { // 拿不到锁短暂等待后读一次缓存读不到就回源 sleepQuietly(50); String retry redis.get(key); if (retry ! null) { return NULL_SENTINEL.equals(retry) ? null : JSON.parseObject(retry, clazz); } return loader.get(); } try { // 3. 双重检查 String doubleCheck redis.get(key); if (doubleCheck ! null) { return NULL_SENTINEL.equals(doubleCheck) ? null : JSON.parseObject(doubleCheck, clazz); } // 4. 回源加载 T data loader.get(); int jitter ThreadLocalRandom.current().nextInt((int) (baseTtlSeconds / 5)); if (data null) { redis.setex(key, 60 jitter, NULL_SENTINEL); } else { redis.setex(key, baseTtlSeconds jitter, JSON.toJSONString(data)); } return data; } finally { if (locked) { releaseLock(lockKey, token); } } }用的时候传入 key、基础 TTL、回源函数和类型一行调用搞定。这个模板我在几个项目里复用下来覆盖了大概八成的缓存读取场景。剩下两成需要特殊处理的比如逻辑过期的场景、需要读旧值的场景单独实现不要硬塞进模板里否则模板会越来越臃肿最后谁都不敢改。有一处要特别提醒回源函数里不要嵌套调用缓存读取。见过有同学在 loader 里又调了另一个带缓存的查询方法形成了隐式的依赖链出问题时排查起来极其痛苦还容易出现死锁外层持有锁内层又要拿同一把锁。回源函数应该只做纯粹的数据加载。5.2 该盯哪几个指标告警阈值参考缓存相关的故障如果能提前十分钟发现处理成本能降低一个数量级。我固定会盯这几组指标指标含义参考告警阈值缓存命中率命中次数 / 总请求次数连续 5 分钟低于 90%回源 QPS穿透到数据库的请求量超过基线值 3 倍Redis 命令延迟 P99反映缓存服务健康度超过 10ms 持续 1 分钟Redis 内存使用率是否接近 maxmemory超过 80%连接池活跃连接数是否接近池上限超过上限的 85%锁等待超时次数击穿防护是否在生效每分钟超过 100 次命中率这个指标要分 key 前缀看全局命中率 95% 可能掩盖了某个业务线只有 40% 的事实。我一般会给核心业务的 key 前缀单独配置看板比如商品详情、用户信息、订单查询各一组出问题时能立刻定位到是哪块。回源 QPS 的基线值需要按时间段分别统计。很多业务有明显的峰谷早上的正常流量可能就比凌晨高十倍。用固定阈值告警会误报满天飞建议用同比或者环比的方式当前值相比昨天同一时刻上涨超过 200% 才告警。还有一个我后来加上的指标空值缓存的写入速率。如果这个速率突然飙升说明要么有攻击要么有批量数据被删除两种情况都需要关注。这个指标不太常见但在我处理过一次批量下架导致的大量穿透之后就加上了。6. 几个真实踩过的坑说几个印象比较深的都是文档里不会写、只有实际跑起来才会遇到的情况。第一个坑是空值缓存把正常数据挡住了。某次用户下单后立刻跳转订单详情页页面显示订单不存在用户反复刷新还是不存在客服电话就来了。查下来是几秒前用户查了一次这个订单那时还没生成写入了空值缓存60 秒内所有查询都命中这个空值。修复办法是把空值缓存时间压到 10 秒以内并且在下单成功后主动删除对应的空值 key。教训是空值缓存的过期时间要和业务的数据产生路径对齐凡是先查后建的场景写入成功后必须主动清理空值。第二个坑是布隆过滤器没跟上数据更新。新增的商品查不到日志里显示被过滤器拦了。原因是数据写入走的是另一条链路没触发过滤器的更新逻辑。修复是加了一层兜底被过滤器判定为不存在的 key如果业务上确实可能存在比如刚创建不满 5 分钟的放行一次去查数据库。这个兜底会让误判率稍微上升但换来了正确性。永远不要让过滤器成为数据正确性的唯一防线。第三个坑是锁的超时时间设成了固定值。有个接口的回源逻辑里包含三次远程调用总耗时波动很大从 50ms 到 2s 不等。锁超时设了 1 秒结果在高负载时经常出现锁提前释放、多个线程同时重建的情况数据库压力反而更大。后来改成按接口的历史 P99 动态配置并且把回源逻辑里的串行调用改成并行总耗时降到 300ms 以内才稳定下来。第四个坑是本地缓存和 Redis 双写不一致。更新数据时先删了 Redis 再删本地缓存中间有个极短的时间窗口别的实例读到本地缓存里的旧值又给 Redis 回填了旧数据形成脏数据长期驻留。这个问题排查了很久最后靠给本地缓存也设短 TTL 兜底才缓解。多级缓存的一致性没有银弹兜底策略必须存在。第五个坑是预热任务本身引发的雪崩。大促前跑缓存预热脚本并发开得太大直接把数据库连接池占满影响了正常交易。后来所有预热任务都加了限速器和独立的数据源连接池跑之前还要在群里报备。听起来很土但确实管用。最后分享一个习惯每次处理完缓存相关的线上问题我都会在代码里对应位置留一行注释写清楚当时的现象、原因和修改思路。半年后别人接手或者自己回来看能省下大量重新推导的时间。缓存问题的可怕之处在于它平时完全正常出事就是大面积靠记忆和文档很难完整回溯代码旁边的注释是最可靠的现场记录。