资讯详情

资讯详情

缓存击穿、穿透与雪崩:三大痛点排查与解决方案详解

线上业务跑得好好的突然监控面板一片红——数据库慢查询飙升、接口RT翻了好几倍点进详情页发现数据半天刷不出来。这种场景我遇到过不止一次排查到最后大概率是缓存层面出了问题。而在所有缓存问题里击穿、穿透、雪崩这三兄弟是出现频率最高、也最容易被混淆的三类。很多人能把它们的概念倒背如流一遇到真实流量就分不清到底是哪一类更不知道该怎么对症下药。这篇内容我打算把三大缓存痛点从头到尾捋一遍它们本质是什么、触发场景有什么不同、每种问题对应的主流方案怎么做、方案本身有哪些坑。中间会用到一些真实项目里的代码片段和参数配置都是我实际跑过、验证过的做法。如果你正在维护一套有一定并发量的业务系统或者面试前想把缓存这块彻底吃透这篇应该能给你一个相对完整的参考。1. 三个痛点到底差在哪一张表看清击穿、穿透与雪崩先说结论这三个问题虽然都表现为缓存没扛住压力打到数据库但它们在请求特征、数据特征、失效范围三个维度上完全不同。很多线上事故处理不好不是方案不对而是第一步就没判断准——把穿透当成击穿去处理或者把雪崩当成穿透去排查策略自然全拧了。1.1 从一次具体的事故说起某次大促预热期间我负责的一个商品详情页接口突然开始超时。查看监控Redis 的 get 请求量没有明显异常但数据库的 SELECT 次数在短短几十秒内涨了三倍而且集中在几个热门商品 ID 上。当时第一反应是缓存穿透因为表现形式很像大量请求绕过了缓存直接打到数据库。但仔细一看那几个商品 ID 在 Redis 里是有值的只是刚好同时过期了。所以真正的原因是这几个热点 key 在同一秒过期大量请求同时发现缓存不存在然后一起回源数据库。这是典型的缓存击穿而不是穿透。如果当时按照穿透的方案去处理比如加布隆过滤器拦截不了任何请求因为 key 明明存在过过滤器里必然命中问题依旧。所以我一直觉得排查缓存问题之前先把三类问题的判定条件弄清楚比急着上方案重要得多。1.2 用一张表快速定位问题类型我整理了一张对照表平时排查问题时直接对着看维度缓存击穿缓存穿透缓存雪崩失效范围单个热点 key单个或多个不存在的数据大量 key 同时失效查询数据数据存在只是缓存过期数据本身不存在数据存在缓存大面积过期触发场景热点 key 过期瞬间恶意请求、非法参数批量 key 过期、缓存宕机对数据库的影响单点压力集中持续空查可能恶意放大全面流量洪峰缓存重建需要重建难在并发控制永远无法通过缓存命中需要批量重建难在流量分摊这个表是我在实际排查时自己总结的核心区别就三点一个是 key 有但刚过期一个是 key 从来就不存在一个是很多 key 一起过期。后面讲方案的时候你会发现判断对了类型方案的代码结构完全不一样。另外一个容易踩的误区是Redis 宕机算不算雪崩。算但业界通常把缓存不可用导致的流量直冲数据库也归类为雪崩的变种。严格来说大量 key 同时过期是缓存层逻辑失效Redis 宕机是缓存层物理失效两者造成的后果是一样的——所有读请求都落到数据库只是处理思路略有差异后面专门讲。2. 缓存击穿热点 key 失效的瞬间怎么挡住并发风暴缓存击穿的核心矛盾在于热点二字。一个普通的 key 过期了可能只有几十个请求回源数据库毫不在意。但如果是微博热搜、爆款商品、秒杀商品这类 key过期瞬间可能同时涌来几万甚至几十万个查询。这个瞬时量级打在数据库上基本是毁灭性的。2.1 为什么击穿在并发场景下影响巨大很多人只记住了热点 key 过期这个表象没想明白它和普通 key 过期的本质区别。普通 key 过期的公式是瞬时回源请求量 过期后单位时间内的请求数热点 key 过期的公式就变成了瞬时回源请求量 热点 key 关联的全部请求数因为当 key 不存在时所有请求都会走查缓存 → 未命中 → 查数据库 → 回填缓存这条路。即使你的数据库连接池配得再大也扛不住这种瞬时全量冲击。更麻烦的是如果数据库因为这些请求变慢查询时间从 10ms 变成 1000ms那么在这段时间内积累的新请求会更多形成恶性循环。这里有一个最直观的验证经历我曾经用一个模拟的热点 key 做过压力测试QPS 5000 的情况下不做任何保护直接缓存过期数据库连接池在 3 秒内被打满接口错误率飙到 40% 以上。而加了互斥锁之后同等流量下数据库 QPS 被控制在个位数错误率归零。差距就是这么明显。2.2 互斥锁方案让回源请求排好队互斥锁的思路很朴素既然同时有 N 个请求发现缓存没命中那就只让第一个请求去查数据库并重建缓存其他请求在外面等着等缓存重建好了直接读缓存。用 Redis 的 SETNX 命令就能实现伪代码如下// 加锁重建缓存锁的 key 和缓存 key 关联保证同一时刻只有一个回源请求 String lockKey lock:product: productId; String lockValue UUID.randomUUID().toString(); boolean locked redisClient.setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS); if (locked) { try { // 双重检查拿到锁后可能缓存已被其他线程重建 Object cached redisClient.get(cacheKey); if (cached ! null) { return cached; } Object dbData databaseClient.query(productId); redisClient.set(cacheKey, dbData, 30, TimeUnit.MINUTES); return dbData; } finally { // 用 Lua 脚本保证比对和删除是原子的防止误删别人重建时设置的锁 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisClient.eval(script, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); } } else { // 没拿到锁的线程不能直接查库短暂休眠后重试读缓存 Thread.sleep(50); return retryReadCache(cacheKey); }这个方案有几个细节必须注意第一锁的过期时间不能设太短。如果数据库查询和缓存重建耗时超过锁的过期时间锁会自动释放导致另一个线程又去查库锁形同虚设。我一般会结合数据库查询耗时 缓存写入耗时 一些冗余来设置比如业务上查询耗时通常 50ms 以内锁的过期时间可以设到 3 秒留出足够余量。第二拿到锁之后要再做一次缓存检查。这就是上面代码里的双重检查。因为第一个线程可能已经完成了缓存重建并释放了锁第二个线程紧接着拿到了锁如果不检查缓存直接查库就会白白增加一次数据库压力。这个检查和并发编程里的 Double-Checked Locking 是同一种思路。第三释放锁时一定要校验身份。因为可能有这种情况线程 A 拿锁之后执行时间过长锁过期了线程 B 拿到锁开始重建此时线程 A 终于执行完了去删锁。如果不校验A 会把 B 的锁删掉导致线程 C 也能拿锁查库。所以要用上面那段 Lua 脚本先比对 value 一致再删除。2.3 逻辑过期方案不设物理过期时间把过期交给业务逻辑互斥锁能解决问题但会让部分请求阻塞等待对接口响应时间有影响。在追求极致性能的场景下还有一个方案缓存不设置物理过期时间而是在数据里塞一个逻辑过期字段由代码来判断数据是否到期。代码如下public class CacheObjectT { private T data; // 真实数据 private long expireAt; // 逻辑过期时间戳由业务代码写入 } // 读取逻辑 public Object getProduct(String productId) { CacheObjectObject cacheObj redisClient.get(product: productId); if (cacheObj null) { return queryDbAndWriteCache(productId); // 缓存被淘汰了正常回源 } if (cacheObj.getExpireAt() System.currentTimeMillis()) { // 逻辑过期直接返回旧数据同时异步重建缓存 asyncRebuildCache(productId); return cacheObj.getData(); } return cacheObj.getData(); }这个方案的精妙之处在于数据即使逻辑过期了也依然存在 Redis 里请求直接拿到旧数据返回不需要排队等待。重建交给后台异步线程池慢慢做。代价是会有短暂的数据不一致——在重建完成之前用户读到的是旧数据。如果业务对一致性要求高比如支付、库存逻辑过期方案要慎用。但对商品详情、文章内容、配置信息这类场景旧数据多存活几十毫秒完全可接受用户体验几乎无感。我实际用过的一个做法是热点 key 的逻辑过期时间设为 5 分钟异步重建线程池大小 4~8 个重建完成后再把新数据写回缓存。整套下来接口 P99 耗时在逻辑过期期间几乎没有波动。2.4 热点 key 的识别事前配置还是事后自动发现无论是互斥锁还是逻辑过期都有一个前置问题哪些 key 是热点 key如果给所有 key 都加锁普通数据的查询性能会被白白损耗如果热点 key 未被识别问题依旧。实际项目里我主要用两种方式混搭静态配置方式业务比较稳定的热点比如首页推荐位、每日 Top100 商品由运营或开发在配置中心里提前指定 key写死在热点名单里。这种方式可靠但覆盖不了临时冒出来的热点。动态统计方式在访问缓存的公共方法里埋点统计每个 key 的单位时间访问量超过阈值就自动标记为热点触发后续的永不过期策略或逻辑过期策略。阈值需要根据业务压测调整我一般从 QPS 500 起步试。动态方式需要注意统计本身的实现不引入太大开销。我通常用 Redis 的计数器或本地滑动窗口数组来做本地统计的误差范围在可接受范围内控制在一个 JVM 里统计即可不用跨节点合并。热点名单发生变化时延迟几秒没问题因为一旦 key 已经扛住了一波流量短暂的识别延迟不会致命。3. 缓存穿透数据库的压力来自原本就不存在的数据如果击穿是缓存有数据但刚过期那穿透就是缓存压根就不会有数据。典型场景是用户查询一个不存在的商品 ID、一个被封禁的账号、或者恶意构造的非法参数。每一个这样的请求都会直接打到数据库而数据库也查不到任何东西缓存永远不可能被建立于是每一次请求都是穿透。这种情况的可怕之处在于它不像击穿那样一会就过去了而是持续性的如果被脚本恶意放大流量可以做到几何级增长。我遇到过的最夸张的一次有人用脚本遍历一个从 1 开始递增的 ID 段里面大部分 ID 在业务上都已经删除了导致数据库在夜里 2 点被几百万条空查询打满。这种攻击不需要多高的技术含量但效果却很好——因为大多数系统根本没有对不存在的数据做防护。3.1 空值缓存最直观也最容易出问题的解法空值缓存的思路非常简单数据库查不到数据时也往缓存里写一个空值并设置较短的过期时间。这样一来后续相同参数的请求在缓存里就能命中空值而不会穿透到数据库。public Object getProduct(String productId) { Object cached redisClient.get(product: productId); // 注意这里要能区分缓存中没有 与 缓存中存放的就是空值 // 为了简洁仅用 null 表示未命中用一个空对象表示已缓存的空值 if (cached ! null) { return cached instanceof EmptyValue ? null : cached; } Object dbData databaseClient.query(productId); if (dbData null) { redisClient.set(product: productId, new EmptyValue(), 60, TimeUnit.SECONDS); return null; } redisClient.set(product: productId, dbData, 30, TimeUnit.MINUTES); return dbData; }这里有一个很容易被忽视的坑空值本身的过期时间必须远短于正常数据的过期时间。为什么因为如果某个商品只是暂时还没上架但你把它缓存成空值 30 分钟那么上架后用户依然有 30 分钟看不到新数据这对业务是致命打击。我习惯的做法是空值 TTL 设为 60 秒正常数据 TTL 设为 30 分钟或更久。另一个坑是空值 key 可能大量堆积。恶意请求遍历 ID 时每个不存在的 ID 都会在 Redis 里留下一个空值缓存如果遍历了几百万个 IDRedis 内存就会被这些无意义的 key 占满。所以空值缓存方案要搭配key 数量上限控制比如当一个 key 的空值被请求超过一定次数说明是恶意遍历后续请求直接在一层拦截掉不再写缓存。更彻底的方式就是后面要讲的布隆过滤器。3.2 布隆过滤器在查询之前就判定数据是否存在布隆过滤器解决的是另一个视角的问题与其每次查数据库才知道数据是否存在不如在进入缓存之前就快速判断这个 key 是否存在。把所有可能存在的 ID 都放入布隆过滤器请求来了先过过滤器过滤器说没有那这个请求连缓存都不必查直接返回。布隆过滤器的原理可以用一个简单的类比理解它用多个哈希函数把一个 key 映射到 bitmap 的几个位置为 1。查询时判断这几个位是否都是 1有一个不是就说明 key 不存在。因为位可能被不同 key 共同占用所以存在误判——它只会说可能存在不会说绝对不存在但能非常肯定地说一定不存在。在我负责的系统里商品 ID 是数据库自增的数据总量级在百万左右。我把布隆过滤器参数设置成这样expectedInsertions预期数据量100 万falsePositiveProbability误判率1%算出来的结果是需要约 120 万位约 143KB加上哈希函数数组开销也不会超过几百 KB。这个成本对 Redis 来说完全可以忽略。以下是使用 Java 客户端实现的核心逻辑// 初始化布隆过滤器往里面灌一轮已有商品 ID RedisBloomCommands bloom redisClient.bf(); bloom.createFilter(bloom:product, 1_000_000, 0.01); // 批量添加已有 ID for (Long id : productIds) { bloom.insert(bloom:product, id.toString()); } // 查询前先判断 public Object getProduct(String productId) { boolean mightExist bloom.exists(bloom:product, productId); if (!mightExist) { return null; // 一定不存在直接拦截 } Object cached redisClient.get(product: productId); if (cached ! null) { return cached; } Object dbData databaseClient.query(productId); if (dbData null) { return null; // 这里也可以用空值缓存兜底 } redisClient.set(product: productId, dbData, 30, TimeUnit.MINUTES); return dbData; }3.3 布隆过滤器的两个使用前提布隆过滤器很强大但它有一个天然的限制不支持删除元素。因为一个位可能被多个 key 共用你不能在删除某个 key 时把对应位清 0否则其他 key 会受影响。所以当系统的数据允许增量追加但不允许删除时用布隆过滤器很合适如果业务上有删除操作就要考虑定期重建过滤器或者用布隆过滤器的变种比如 Counting Bloom Filter。另外布隆过滤器的数据初始化要安排好。系统启动时要把全量已存在的数据灌入过滤器这一步如果数据量大可能耗时较久。我实际操作时是写了一个异步任务在发布后预热先给过滤器灌数据完成后再打开流量开关。期间如果刚好有新增数据需要额外保证新增 key 也能及时插入过滤器否则会误杀正常请求。4. 缓存雪崩当大量缓存同时失效我们要做的是把损失分摊雪崩的场景大家最熟悉缓存里大量 key 在同一时间过期请求全量落到数据库数据库压力瞬间拉满然后接口超时超时又导致更多重试最终整个服务雪崩。和击穿的区别在于击穿是一个 key 引发的血案雪崩是一批 key 同时失效引发的海啸。很多团队在解决击穿后以为万事大吉结果大促时一个批量刷新操作把所有配置 key 同时置为过期直接触发雪崩这种情况下单 key 的互斥锁根本帮不上忙。4.1 随机过期时间把集中失效变成错峰失效雪崩最直接的诱因是 TTL 设置太整齐。比如初始化缓存时把所有商品的缓存时间都设为 30 分钟那么每过 30 分钟就会出现一次集体过期的高峰。解决思路很简单让每个 key 的 TTL 不同。// 均匀分布随机过期时间区间 [300, 600] 秒 private int randomExpireSeconds() { return ThreadLocalRandom.current().nextInt(300, 601); } Object dbData databaseClient.query(productId); int ttl randomExpireSeconds(); redisClient.set(product: productId, dbData, ttl, TimeUnit.SECONDS);如果原来的 TTL 是 30 分钟那么 5~10 分钟的随机区间就可以把过期时间打散。但这里有个精细的问题随机区间不能设得太大。假设业务要求缓存最多存 10 分钟那么即使随机整体的缓存新鲜度也会波动到 10 分钟边缘还是会有用户感知到数据延迟。所以随机区间一般是原 TTL 的 1/6 到 1/3。另外要注意随机过期时间只能缓解均匀型雪崩解决不了突发型雪崩。比如运维手动清缓存、发布时缓存代码逻辑变更导致全部 key 重写并设置了相同的过期时间这类情况随机 TTL 同样救不了。我建议在缓存写入的公共入口处做一次强制检查如果发现 TTL 几乎相同且 key 数量巨大打一条告警日志人工介入。4.2 多级缓存本地缓存挡下第一波流量随机过期时间降低了概率但没法根治。真正处理雪崩级流量的釜底抽薪方案是把缓存分层。Redis 是第一层缓存JVM 本地内存是第二层缓存。雪崩发生时即使 Redis 里的 key 全失效了本地缓存依然能兜住相当一部分请求。本地缓存我常用 Caffeine配置如下CacheString, Object localCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(60, TimeUnit.SECONDS) .build(); public Object getProduct(String productId) { // 先查本地缓存 Object local localCache.getIfPresent(productId); if (local ! null) { return local; } // 再查 Redis Object cached redisClient.get(product: productId); if (cached ! null) { localCache.put(productId, cached); return cached; } // 最后回源数据库 Object dbData databaseClient.query(productId); redisClient.set(product: productId, dbData, 300 ThreadLocalRandom.current().nextInt(60), TimeUnit.SECONDS); localCache.put(productId, dbData); return dbData; }本地缓存的 TTL 不需要和 Redis 一样长一般 30~60 秒就够了。它的意义不在于缓存命中率多高而在于雪崩发生的瞬间能挡住多少流量。即使只有 20% 的请求被本地缓存命中数据库的压力就能降一个量级。多级缓存有一个全新的问题数据一致性。Redis 更新了本地缓存可能还是旧值。常规做法是容忍短暂不一致因为本地缓存 TTL 本身就很短如果业务对一致性敏感就需要在更新时广播通知所有节点清空本地缓存实现复杂度会明显上升。所以多级缓存适合详情页推荐、资讯列表、配置类这种允许短暂延迟的数据不适合订单、库存这类强一致数据。4.3 Redis 不可用时的降级预案熔断与限流如果雪崩的根源不是大量 key 过期而是 Redis 直接宕机多级缓存只能缓解流量的主力依然会打到数据库。这时候需要的是降级预案。我习惯把降级分成几个等级一级降级轻度异常Redis 读超时自动切换到只读本地缓存数据库写入链路保持正常不进行任何路由变更。二级降级中度异常Redis 完全不可用本地缓存也大面积未命中数据库连接池开启保护模式读请求直接返回默认兜底数据或空数据不进行真实查询。三级降级严重异常核心接口限流非核心接口全部关闭或返回静态页保证最重要的链路不死。这些降级动作必须提前在配置中心里写好开关而不是在故障发生时写代码。我遇到过最尴尬的情况就是 Redis 挂了开发紧急改配置结果配置中心本身也依赖 Redis形成二次依赖。所以降级开关一定要放在无论如何都能 Read的地方本地配置文件是最稳妥的选择。数据库侧的限流措施也要提前设计好。推荐用 Sentinel 或类似方案对数据库查询方法做 QPS 限流超过阈值直接抛异常。你会发现真正雪崩的时候一个精心配置的连接池比如最大 50 个连接就能有效保护数据库前提是应用层不会无限创建新连接——很多数据库被打崩不是请求量真的大到无法承受而是连接池配置不合理瞬间创建了几千个连接。5. 组合防护从请求入口到存储层的完整链路设计前面讲的是每种问题的单独解法。但在真实系统里从来不会只有一种缓存问题同时出现。比如恶意脚本用不存在的 ID 打穿透穿透流量里恰好有热点 key热点 key 又刚好过期……所以一个成熟的系统防护必须是分层的每一层负责挡住一类问题层层设防最后数据库才能安全。5.1 一套完整的请求处理链路我习惯把缓存保护的代码组织成一条链每个环节职责单一、可插拔请求进入 → 参数合法性校验拦截明显非法的请求比如负数 ID、超长字符串 → 布隆过滤器判断拦截不存在的 key解决穿透 → 本地缓存查询Caffeine解决雪崩的第一波冲击 → Redis 缓存查询命中则返回 → 互斥锁/逻辑过期控制解决击穿 → 数据库查询 → 回填 Redis 本地缓存举个具体的处理逻辑public Object getProduct(String productId) { // 第一层参数校验 if (!isValidId(productId)) { return null; } // 第二层布隆过滤器拦截一定不存在的 ID if (!bloom.exists(bloom:product, productId)) { return null; } // 第三层本地缓存 Object local localCache.getIfPresent(productId); if (local ! null) { return local; } // 第四层Redis并应用击穿保护 String cacheKey product: productId; CacheObjectObject cacheObj redisClient.getObject(cacheKey); if (cacheObj ! null) { if (cacheObj.isExpired()) { asyncRebuild(productId); // 逻辑过期异步重建 } localCache.put(productId, cacheObj.getData()); return cacheObj.getData(); } // 第五层互斥锁重建防止极端情况下多个线程同时查库 return rebuildWithLock(productId); }每一层的设计都不是拍脑袋决定的而是对应了一个明确的威胁模型参数校验防的是无脑脚本布隆过滤器防的是不存在数据本地缓存防的是 Redis 不可用/批量失效带来的流量洪峰互斥锁/逻辑过期防的是热点 key 过期的瞬间并发最后数据库那一层依靠的是连接池限流和降级开关兜底。5.2 为什么不能只选一个方案我见过不少团队在介绍方案时喜欢说我们用了布隆过滤器所以缓存安全了或者我们做了随机过期雪崩解决了。这种想法很危险因为每种方案都有自己的盲区互斥锁只解决击穿对穿透无能为力布隆过滤器只解决穿透但无法判断 key 是否过期随机过期时间只解决均匀型雪崩防不住 Redis 宕机多级缓存只减轻压力挡不住无限放大的穿透空值缓存解决了穿透但可能导致关键数据更新延迟这些方案的关系不是互斥的而是互补的。只有组合使用才能真正构造一个即便出现极端流量数据库也不会被打崩的稳健系统。5.3 一致性策略缓存更新和失效要有一套规则组合方案里最容易忽略的是缓存更新策略。很多系统在更新数据库后顺手删除 Redis 里的缓存但删了之后呢如果不配合延迟双删下一次请求正好落在数据库已更新、缓存还没删的间隙读到的就是旧数据。我实际用的策略是这样的强一致场景先更新数据库再删除缓存并在删除后延迟 500ms 再次删除延迟双删。这种方式可以避免并发下旧数据回填缓存的问题。弱一致场景先更新数据库然后同步更新缓存依靠版本号或时间戳来保证新数据覆盖旧数据逻辑过期方案也可以用在这里。最终一致场景数据变更写入消息队列由独立的缓存重建消费者异步刷新缓存。这些策略不会直接解决击穿、穿透、雪崩但它们决定了缓存层的稳定性。一个频繁更新、却又没有更新策略的缓存系统本身就是各种异常问题的温床。6. 踩坑记录与监控预案让方案在线上真正落地代码逻辑写完了、方案选好了不代表系统就稳了。我自己的经验是缓存系统上线后真正难的不是那些高大上的算法而是一些非常琐碎的边界细节和监控预案。这里把我踩过的几个比较典型的坑单独写出来给大家当个参考。6.1 互斥锁的粒度坑锁越粗吞吐越差第一次用互斥锁时我把锁粒度加到了整个 service 类上——意思是同一个商品在重建缓存时所有商品的所有请求都在等待。结果上线后接口吞吐直接掉了一半。后来才意识到锁的粒度应该细化到 key 级别的维度即锁的 key 与缓存 key ——对应product:100 的锁只影响 product:100 的请求不影响 product:101。修改之后吞吐恢复正常数据库压力也没上升。这是一个非常简单但容易犯错的点。6.2 空值缓存的 TTL 设置我踩过的更新延迟坑有一次运营反馈后台修改商品名称后线上详情页十几分钟都没变化。排查后发现商品在上架前有一个待审核状态运营在审核通过前多次查看这个商品每次查询数据库都查不到于是空值缓存被写入TTL 用了默认的 30 分钟。商品真正上架后用户依然命中空值缓存直到 TTL 过期。这个问题的教训是凡是可能被后续状态变更修复的数据空值缓存 TTL 必须短要么就要在业务状态变更时主动删除对应的空值 key。我现在会在所有写入空值缓存的地方强制要求 TTL 不超过 60 秒并且把状态变更清理缓存做成公共逻辑。6.3 监控指标怎么从数据上快速判断缓存问题类型有了方案如果没有监控故障时你依然是盲人摸象。我强烈建议至少在三个维度做监控Redis 层面keyspace hits/misses、平均响应耗时、大 key 扫描、过期 key 统计应用层面缓存命中率、回源数据库 QPS、缓存重建耗时、本地缓存命中率数据库层面慢查询数量、活跃连接数、锁等待状况判断问题类型的方法也很直接如果 Redis miss 率正常、但数据库 QPS 突然飙升并且集中在某些 key优先怀疑击穿如果 miss 率持续偏高且命中率跌到 70% 以下大概率是穿透如果 Redis 的 expired key 数量在某个时间点突然猛增数据库 QPS 同步飙升那就是雪崩。我习惯把这些指标配上告警阈值设为一个相对保守的值比如回源 DB QPS 超过均值 3 倍就告警宁可多告警也不错过一次事故。6.4 上线前的缓存预热与压测最后分享一个很少在文档里看到但在真实项目里非常有用的操作缓存预热。大促或发布前把核心热点数据提前写进 Redis 和本地缓存可以大幅降低突发流量下的回源压力。预热我一般分三步走从数据库把热点 ID 清单捞出来通常是通过历史访问统计比如过去 24 小时 QPS Top 1000。把清单里的数据按逻辑过期方案写入 Redis并给出一个相对较大的 TTL。在流量逐步切过来之前先让一小部分探测流量跑几分钟确认缓存的命中率和回源 QPS 在预期范围内再全量放流量。压测时也可以用上述链路来验证直接压缓存层的读取接口观察数据库 QPS 是否被控制在预期阈值内如果超了就回查是链路哪一环配置没生效。缓存相关的压测比普通接口压测重要得多因为它验证的恰好是极端流量下系统是否仍然安全。踩过这几次坑之后我的感受是缓存三大痛点本质上都是缓存层和存储层之间的信任边界出了问题——你信任缓存一定扛得住所有请求缓存却在某个瞬间失守了。不盲目相信任何单一方案在每一层都做一点防御这大概就是缓存系统所谓的最佳实践最本质的内容。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →