做后端开发这几年,凡是扛过线上流量冲击的人,几乎都被Redis缓存三大问题折磨过:缓存穿透、缓存雪崩、缓存击穿。这三个名字看起来像兄弟,实际成因和应对手段完全不同。搞混了去排查,轻则多加班一晚上,重则数据库被打挂、线上事故一夜回到解放前。
这篇文章从我的实战经验出发,把三类问题的成因、识别方法、解决方案和踩坑细节一次讲透。内容以高频业务场景为主线,附带可直接落地的代码片段和配置思路,适合正在维护缓存体系的后端工程师,也适合刚接触Redis、想搞清楚缓存设计逻辑的初学者。看完你至少能回答三个问题:这三者到底差在哪、线上怎么快速定位、每种方案背后的取舍是什么。
1. 三大问题全景解剖:先分清楚再谈解决
1.1 缓存穿透:打到了不存在的key
缓存穿透指的是请求一个数据库里根本不存在的数据。正常情况下,用户请求先查Redis,查到就直接返回;查不到再查数据库,把结果回填到Redis。可如果这个数据本身不存在,缓存里就一直不会有,于是每次请求都会越过Redis直接打到数据库。
我最早遇到这类情况是在做订单查询功能的时候。用户在客户端输入一个订单号,系统先查缓存,没有,再查数据库,也没有,但没有把空结果缓存下来。结果有人写了个脚本,用随机订单号连续请求,数据库QPS瞬间飙高,好在当时量不大,及时加了一层空值缓存才压住。
穿透的典型场景有两类:一是恶意攻击,攻击者故意构造大量不存在的key来耗尽数据库资源;二是业务误伤,比如用户查询历史数据,而这些数据在归档后已经不在当前库中。穿透的危害不在于单个请求,而在于请求量一旦放大,数据库就变成了裸奔状态,连接池耗尽、主从延迟、接口大面积超时接踵而至。
1.2 缓存雪崩:大量key同时失效
缓存雪崩是一大批key在同一个时间段集中失效,导致这些key对应的请求全部压到数据库上。很多系统初始化缓存时,习惯给所有key设置相同的过期时间,比如统一过期时间600秒。当这批key在同一秒过期,而业务流量又比较高时,数据库就会瞬间收到数倍于平时的流量。
雪崩还有另一种非典型形态:Redis实例直接宕机。缓存层整体不可用,所有请求全部穿透到数据库,这种往往比批量过期更致命,因为连恢复缓存的机会都没有,数据库会在很短时间内被打垮。
我见过一个活动页面的案例:运营在后台一次导入了大量品牌数据,开发统一设置了缓存过期时间为当天24点整。结果一到零点,后台监控里数据库QPS从2000直接跳到2万,接口超时率飙升。这就是典型的缓存雪崩,部分请求直接把源服务打挂了。
1.3 缓存击穿:热点key失效瞬间
缓存击穿和雪崩的区别在于:雪崩是大面积key失效,击穿是单个热点key失效的瞬间,大量并发请求同时穿越缓存打到数据库。
举个例子。某商品正在做秒杀,这个商品的详情数据缓存在Redis里,key只有一个,但请求量每秒几千甚至上万。当这个key在某个瞬间过期,Redis里查不到,所有并发请求会在同一时刻去查数据库,数据库瞬间被热点请求打爆。等第一个请求回填缓存后,后续请求才恢复正常。整个过程可能只有几百毫秒,但就是这几百毫秒,足够让数据库出现大量慢查询和连接堆积。
击穿的关键特征就是“单点热点 + 过期窗口”。它不是持续性的压力,而是过期瞬间的一个脉冲流量,所以用普通的监控告警往往看不出来,要结合缓存命中率和数据库QPS的曲线对比才能定位。
1.4 三大问题对比速查表
| 对比维度 | 缓存穿透 | 缓存雪崩 | 缓存击穿 |
|---|---|---|---|
| 问题对象 | 不存在的数据 | 大量key同时失效 | 单个热点key失效瞬间 |
| 根因 | 缓存与数据库都没有 | 过期时间集中 / 缓存实例不可用 | 热点key过期 + 高并发 |
| 数据库表现 | 持续压力,QPS缓慢爬升 | 瞬时尖峰,QPS暴涨 | 极短脉冲,持续时间短 |
| 持续时间 | 持续,直到加防护 | 数秒到数分钟 | 毫秒到秒级 |
| 解决思路 | 空值缓存、布隆过滤器 | 过期时间随机化、多级缓存、高可用 | 互斥锁、逻辑过期、主动更新 |
把三个问题分清楚是第一步。实战里更常见的情况是它们同时出现,比如热点key失效触发击穿,同时大量普通key批量过期触发雪崩,数据库压力变成“尖峰叠加持续”。所以下面的方案,我会按问题逐一拆解,但最后落地时要组合使用。
2. 问题定位与排查思路
2.1 从监控指标反推问题
线上出了问题,不能靠猜。我通常先拉四组指标:Redis缓存命中率、数据库QPS、接口平均响应时间、Redis慢查询日志。
缓存穿透的典型指标特征是:Redis命中率逐渐下降,数据库QPS同步上升,但Redis本身的请求量并没有大幅变化。因为打过来的请求都是不存在的key,Redis处理这些请求的耗时很低,不会出现慢查询,只是DB在默默扛压。
缓存雪崩的特征是:缓存命中率在某一个时间点出现断崖式下跌,数据库QPS同步出现一个尖峰,Redis的请求量也可能明显上升(因为大量key过期后,瞬时多了很多回源请求)。如果Redis实例直接宕机,监控里会直接看到Redis连接失败和错误率飙升。
缓存击穿的指标特征是最难抓的:持续时间内Redis命中率有一个短暂的“挖坑”,数据库QPS出现一个极窄的尖峰,但Redis整体指标看起来还算正常。这种场景需要把指标粒度细化到秒级,甚至要看具体key的访问频次才能捕捉到。
我的建议是:排查前先给Redis加好INFO命令的周期性采集,至少保留最近24小时的分钟级指标,把keyspace_hits和keyspace_misses算成命中率存下来。没有历史数据,出了问题就只能靠猜,效率太低。
2.2 三个容易误判的现场
第一个误判是把穿透当成普通的缓存未命中。如果只看了Redis的miss计数上涨,而没有进一步确认DB里是否有这些数据,很容易漏掉穿透问题。我一般会抓几条请求的key,直接去DB里查一下,确认这些key对应的数据是否真实存在。如果DB也没有,基本就是穿透。
第二个误判是把雪崩当成了流量突增。有一次线上数据库QPS翻了三倍,业务方第一反应是“是不是有活动流量进来”,查了半天没发现活动入口。后来排查发现是缓存过期时间配置在某一个整点集中生效,纯粹是自己造成的雪崩。判断方法很简单:对比数据库QPS曲线和缓存命中率曲线,如果两者有一个明显的镜像关系,就是雪崩;要是流量从入口就开始涨,那才是真实流量变化。
第三个误判是把击穿当成连接泄漏。热点key失效的瞬间,数据库连接池被打满,应用层报大量连接超时,第一反应往往是去查连接池配置。实际上连接池只是结果,不是原因。抓到这种问题,最直接的办法是回调Redis里那个热点key的过期时间,再配合一分钟粒度的QPS曲线,基本就能确认。
3. 缓存穿透实战方案
3.1 方案一:参数校验加兜底空值缓存
对付穿透,第一道防线是参数校验。很多穿透请求的key根本不符合业务规则,比如负数ID、超长字符串、不存在的用户ID段。在进入缓存查询之前就过滤掉,成本最低、收益最直接。
// 伪代码:参数校验 public Object getProduct(Long productId) { if (productId == null || productId <= 0) { throw new IllegalArgumentException("invalid productId"); } // 后续查缓存逻辑... }第二道防线是空值缓存。也就是当数据库也查不到数据时,把一个空值写入Redis,设置一个较短的过期时间。这样下次同样的key请求就会命中空值缓存,而不会打到数据库。
public Object getProductWithNullCache(Long productId) { String key = "product:" + productId; String cacheValue = redis.get(key); if (cacheValue != null) { if ("NULL".equals(cacheValue)) { return null; // 命中的是空值缓存 } return jsonToObject(cacheValue); } Object dbValue = productDao.selectById(productId); if (dbValue == null) { // 设置空值缓存,TTL建议120~300秒 redis.set(key, "NULL", 180); return null; } redis.set(key, objectToJson(dbValue), 600); return dbValue; }空值缓存的几个细节要注意。第一,空值的TTL不能太长,因为如果这个key对应的数据后来真的被创建了,空值缓存会挡住新数据的可见性,导致一段时间的“数据假死”。我一般控制在3分钟左右,既挡住了大部分穿透流量,又能快速恢复。第二,不要把空值缓存的value设成普通字符串能撞上的值,最好用独立标识,上面例子里的"NULL"只是示意,生产里可以用"__NULL__"这样不容易撞车的占位符。第三,如果空值缓存量太大,会挤占Redis内存,所以要对这类key单独监控,数量异常增长时要及时优化。
3.2 方案二:布隆过滤器挡住大部分穿透请求
布隆过滤器是另一种更优雅的方案。它在缓存之前再加一层判断,用极小的内存代价告诉你“这个key一定不存在”或者“可能存在”。之所以说“可能”,是因为布隆过滤器存在一定误判率,它可能把一个不存在的key判断为存在,但绝不会把一个真实存在的key判断为不存在。
原理不复杂:写入时,把key经过多个哈希函数映射到bit数组里的几个位置并置为1;查询时同样计算这几个位置,只要有一个位置是0,就能确定key不存在。误判的原因是不同key的哈希位置可能叠加冲突,但通过调整bit数组大小和哈希函数个数,可以把误判率控制在很低水平。
// 伪代码:初始化布隆过滤器 // 使用Guava的BloomFilter,也可以基于Redis的bitmap自研 BloomFilter<Long> bloomFilter = BloomFilter.create( Funnels.longFunnel(), expectedInsertions, 0.01); // expectedInsertions: 预估数据量 // 0.01: 希望误判率不超过1% // 数据预热:把存在的ID全部写入 List<Long> allIds = productDao.getAllIds(); for (Long id : allIds) { bloomFilter.put(id); } // 查询流程 public Object getProductWithBloom(Long productId) { if (!bloomFilter.mightContain(productId)) { return null; // 一定不存在,直接返回,不打DB } // 到这里只是“可能存在”,继续走缓存和DB // ... 常规缓存逻辑,包含空值缓存兜底 }BloomFilter上线时有个容易忽略的坑:数据预热。如果服务刚启动,过滤器里还没有数据,那所有key都会被判定为不存在,导致正常的查询也被拦截。所以必须把存量数据先加载进去,通常是在启动时做一次全量加载,或者在每日低峰期做一次过滤器的重建。
另外一个思考是:如果直接用Redis的bitmap实现布隆过滤器,虽然不用预热到本地内存,但每次查询多一次Redis网络交互,在高QPS场景下得不偿失。我建议把小规模的布隆过滤器放在应用本地内存,定期从DB全量重建,简单可靠。
3.3 穿透方案的选择与组合
空值缓存和布隆过滤器不是二选一的关系。实际落地时,我倾向于两层结合:布隆过滤器挡住大部分不存在的key,空值缓存兜住那些“过了布隆过滤器但DB里还是没有”的场景,比如数据刚被删除、或者布隆过滤器误判的case。
| 特点 | 空值缓存 | 布隆过滤器 |
|---|---|---|
| 实现难度 | 低,纯Redis操作 | 中,需要引入额外数据结构 |
| 额外存储 | 每个不存在的key占一条缓存 | bit数组,内存占用极小 |
| 对真实新增数据的影响 | 有,可能延迟可见 | 无,过滤器可以定期重建 |
| 误判风险 | 无 | 有,但可控 |
| 适用场景 | 中小流量、key总量较小 | 大流量、key总量大、恶意攻击明显 |
如果你的业务key空间很大,比如用户ID就有上亿,但真正活跃的可能只有几百万,穿透请求可能大量集中在不存在的ID段上,这种场景空值缓存会产生大量无用key,用布隆过滤器会更合适。
4. 缓存雪崩实战方案
4.1 过期时间随机化是最便宜的方案
雪崩最常见的成因就是大量key使用同一个固定过期时间。解法也很简单:在基础TTL上加一个随机偏移量,让过期时间分散开。
// 伪代码:过期时间随机化 // 基础TTL 600秒,加0~300秒的随机偏移 int baseTtl = 600; int randomOffset = ThreadLocalRandom.current().nextInt(300); redis.set(key, value, baseTtl + randomOffset);这个方案的成本几乎为零,却能非常有效地避免“同秒过期”的集中压力。但要注意,随机化只解决了过期时间集中问题,如果缓存数据本身是由一个后台任务批量更新的,那批量写入时的失效时间也要打散,否则一边写一边同时过期,效果会大打折扣。
我见过一个反面案例:开发确实加了随机偏移,但偏移量只有10秒,而数据量有几十万个key,在10秒窗口内还是形成了可观的过期集中。建议偏移量至少要达到基础TTL的10%~20%以上,数据量特别大时,可以考虑用一个独立的回调任务逐步更新过期时间,而不是一次性批量设置。
4.2 多级缓存设计:本地缓存兜住热点
雪崩场景下,光靠Redis本身兜底是不够的。比较成熟的做法是引入一级本地缓存,比如Caffeine或者Guava Cache。请求先查本地缓存,本地没有再去查Redis,最后才回源数据库。
请求 → 本地缓存(Caffeine) → Redis → 数据库本地缓存的作用有两个:一是挡住了大部分热点请求,让回源到Redis和DB的流量显著降低;二是在Redis不可用期间,本地缓存还能继续服务一段时间,给系统争取恢复时间。
// 伪代码:Caffeine本地缓存配置 Cache<String, Object> localCache = Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(Duration.ofSeconds(30)) .build(); public Object getProductMultiLevel(Long productId) { String key = "product:" + productId; Object localValue = localCache.getIfPresent(key); if (localValue != null) { return localValue; } Object redisValue = redis.get(key); if (redisValue != null) { localCache.put(key, redisValue); return redisValue; } Object dbValue = productDao.selectById(productId); if (dbValue != null) { redis.set(key, dbValue, 600); localCache.put(key, dbValue); } return dbValue; }本地缓存也有一些坑。第一个是内存容量,本地缓存是存在应用进程内的,数据量太大会直接挤占JVM堆内存。所以maximumSize要按单机容量评估,不能按整个集群的数据量评估。第二个是数据一致性问题,本地缓存的过期时间比Redis短,通常30秒到1分钟,宁可让本地缓存快速失效,也要尽量避免修改操作后应用读到旧数据。
4.3 Redis高可用架构与兜底降级
如果雪崩的源头是Redis宕机,那么过期时间随机化再完美也防不住。这种场景要分两层去解决:一是Redis本身的高可用,二是应用层的降级兜底。
Redis高可用常规做法是主从加哨兵,或者直接上集群。主从能在主节点故障时自动切换,哨兵负责监控和通知客户端。集群则把数据分散到多个分片,单节点故障影响面更小。我的建议是:如果业务量已经上了一定的规模,优先用集群,不仅数据容量更大,故障域也更小。
应用层的降级兜底更关键。我给所有核心缓存查询都设置了一个“兜底开关”,当检测到Redis连续异常或者响应时间超过阈值时,自动把缓存查询降级为“直接查DB允许部分流量通过”,或者干脆返回一个默认值。比如商品详情页在Redis故障时,可以先返回一个商品名称和默认价格,而不是直接报错。这种降级方案需要业务方一起确认,但目标是:宁可返回降级数据,不能让整个服务不可用。
# 伪代码:降级开关(配置中心动态切换) cache.enable=true cache.degrade-only=false # 当Redis错误率超过20%时,自动打开degrade-only模式5. 缓存击穿实战方案
5.1 互斥锁方案:只让一个请求回源
缓存击穿的核心矛盾是热点key失效瞬间有大量并发请求同时回源。最直接的解法是加锁,让同一时刻只有一个请求去查数据库,其他请求等待或者直接读取锁释放后的缓存。
// 伪代码:分布式锁防止缓存击穿 public Object getProductWithLock(Long productId) { String key = "product:" + productId; Object cacheValue = redis.get(key); if (cacheValue != null) { return cacheValue; } String lockKey = "lock:product:" + productId; boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (!locked) { // 没拿到锁的请求,短暂等待后重查缓存 Thread.sleep(50); Object retryValue = redis.get(key); if (retryValue == null) { // 降级处理或直接返回默认值 } return retryValue; } try { Object cacheValueAgain = redis.get(key); if (cacheValueAgain != null) { return cacheValueAgain; } Object dbValue = productDao.selectById(productId); redis.set(key, dbValue, 600); return dbValue; } finally { redis.delete(lockKey); // 释放锁 } }互斥锁方案的关键点是二次检查。拿到锁之后不能直接查DB,要先再查一次缓存,因为很可能在你排队等锁的过程中,第一个请求已经把缓存回填了。没做二次检查的话,每拿到锁的请求都会查一次DB,等于锁白加了。
锁的过期时间要设置合理。太长会导致一个请求异常后其他请求长时间拿不到锁,太短又可能锁提前释放,多个请求同时进入。我一般设置为10秒,并加上续期机制,避免业务执行时间超过锁的持有时间。生产环境不建议自己造锁轮子,直接用现成的Redisson等库,它能处理看门狗续期,省心不少。
互斥锁方案的缺点是:在热点key失效瞬间,没抢到锁的请求会有一段等待时间,极端情况下会造成接口延迟上升。所以这个方案更适合并发量可控的场景。
5.2 逻辑过期方案:数据可见性和重建分离
逻辑过期是另一种思路:缓存key永不设置物理过期时间,但在value里放一个逻辑过期时间戳。查询时发现逻辑上已过期,就异步触发一个线程去数据库刷新缓存,当前请求先返回旧数据,新数据更新后由后续请求读取。
// 伪代码:逻辑过期方案 public Object getProductWithLogicalExpire(Long productId) { String key = "product:" + productId; String cacheValue = redis.get(key); if (cacheValue == null) { // 缓存完全没有,走兜底逻辑,一般是直接查DB return productDao.selectById(productId); } CacheDto dto = jsonToObject(cacheValue); if (System.currentTimeMillis() < dto.getExpireAt()) { // 逻辑未过期,直接返回 return dto.getData(); } // 逻辑已过期,异步重建缓存,当前请求先返回旧数据 asyncRebuildCache(productId, key); return dto.getData(); } private void asyncRebuildCache(Long productId, String key) { String lockKey = "lock:rebuild:" + productId; if (redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) { try { Object dbValue = productDao.selectById(productId); CacheDto newDto = new CacheDto(); newDto.setData(dbValue); newDto.setExpireAt(System.currentTimeMillis() + 600_000L); redis.set(key, objectToJson(newDto)); } finally { redis.delete(lockKey); } } }逻辑过期方案的好处很直观:用户几乎无感知,不会因为重建缓存而等待,适合读多写少但数据量很大的场景。风险在于:如果数据库在重建期间挂掉,用户会一直读到过期数据,所以需要业务上能接受短时间的数据延迟。
这个方案我实际用下来,最容易踩坑的是锁的粒度。重建缓存用的是按业务ID加锁,但如果同一个JVM里多个线程都在等待重建,还是会有重复请求。建议加一层本地互斥,比如用一个ConcurrentHashMap做线程级别的标记,避免同一进程内重复触发重建任务。
5.3 热点key永不过期加主动更新
第三种思路是干脆让热点key不过期,由后台任务主动更新。这个方案的适用性偏窄,只针对那些访问量非常集中且数据变更不频繁的key,比如配置信息、商品基本信息、榜单数据。
// 伪代码:后台任务定期刷新热点key // 每5分钟刷新一次,同时将过期时间重置 public void refreshHotKeyCache() { List<Long> hotIds = hotKeyService.getHotIds(); for (Long id : hotIds) { String key = "hot:product:" + id; Object dbValue = productDao.selectById(id); redis.set(key, dbValue, 3600L); // 每次刷新重置TTL } }这个方案的关键在于“怎么定义热点key”。很多团队直接把所有商品都当成热点key,结果后台刷新任务压力巨大。我建议基于访问频次统计来动态圈定热点集,比如通过Redis的keyspace统计或者日志中的访问TopN来确定。热点集要定期调整,不能一成不变,否则业务热点切换后,真正需要保护的数据反而没有被覆盖。
这种方案可以和互斥锁、逻辑过期组合使用:热点集内的key永不过期,靠后台推送;热点集外的key用正常TTL加随机化,兜底穿透和击穿。这里就体现了前面说的“组合打法”的价值。
5.4 击穿方案选型建议
| 方案 | 实现复杂度 | 数据一致性 | 适用于 |
|---|---|---|---|
| 互斥锁 | 中 | 强一致 | 热key失效瞬间并发高,但能接受少量等待 |
| 逻辑过期 | 中高 | 弱一致(短暂旧数据) | 追求用户体验,能接受短暂数据延迟 |
| 永不过期+主动更新 | 低 | 弱一致,依赖更新频率 | 热点明确且稳定、数据变化频率低 |
我自己在多数业务场景下推荐逻辑过期,因为它对用户影响最小,也更容易配合多级缓存使用。但如果你所在团队对数据一致性要求严格,比如金融交易类,那还是互斥锁更稳妥。
6. 全链路落地组合与避坑实录
6.1 一个完整场景的组合打法
用一个最常见的商品详情页举例,把前面的方案串起来。
用户请求进入商品详情接口后,链路是这样的:
- 参数校验:检查商品ID是否合法,非法直接返回,拦截掉基准的攻击流量。
- 本地缓存查询:用Caffeine做一级缓存,TTL 30秒,挡住最热的重复请求。
- 布隆过滤器判断:如果商品ID不存在于过滤器,直接返回“商品不存在”,不再往下走。
- Redis查询:命中就直接返回,同时回填本地缓存。
- 逻辑过期检查:Redis里没有或者逻辑过期,触发重建任务,当前请求返回旧数据或兜底。
- 数据库回源:重建任务里用分布式锁保证只有一个线程在查DB。
这个组合下来,三个问题都有对应的防护层。缓存穿透被参数校验和布隆过滤器挡住;缓存雪崩被本地缓存和过期时间随机化缓冲;缓存击穿被逻辑过期化解。每一层不一定是最优解,但组合起来整体上能够应对大流量冲击。
落地的时候,我建议按这个顺序逐步上线:先加参数校验和空值缓存(成本最低),再加过期时间随机化,然后加本地缓存,最后再上布隆过滤器和逻辑过期。不要一次性全上,一方面不好排查问题,另一方面布隆过滤器和逻辑过期需要数据预热和业务确认,一步到位容易翻车。
6.2 一次真实排查过程的复盘
有一次线上告警:数据库QPS在10分钟内从800涨到了4000,Redis命中率却只下降了5%。一开始怀疑是缓存穿透,因为命中率下降幅度和DB压力不成比例。我随机抓了100个请求key,逐个查数据库,结果发现大多数key是存在的,有的甚至是很久之前的商品。
查到这里,感觉不像是单纯的穿透,更像是有大量key同时失效。我去看了Redis里的key过期时间分布,发现运营在早上10点批量上架了一批新商品,这些商品的缓存过期时间统一设置成了10点后的第600秒,也就是10:10整点过期,正好和告警时间吻合。
到这一步基本锁定了雪崩。处理方案分两步:一是立即把历史key的过期时间随机化,在599到900秒之间随机;二是调整后续批量导入任务的缓存写入逻辑,在代码层强制追加随机偏移。
整个排查过程用了大概40分钟。核心教训是:缓存问题排查不能只看单独一个指标,要同时看命中率、DB QPS和key过期时间的分布。尤其是批量数据写入场景,一定要在写入环节就把过期时间打散,别指望运行后再补救。
6.3 避坑清单
| 隐患 | 后果 | 解决建议 |
|---|---|---|
| 空值缓存TTL过长 | 新数据创建后长时间不可见 | 空值TTL控制在3分钟以内 |
| 布隆过滤器未预热 | 启动后所有请求被误拦截 | 启动时全量加载存量ID |
| 互斥锁未做二次检查 | 锁形同虚设,DB被重复击穿 | 拿到锁后先查一次缓存 |
| 逻辑过期后无锁控制 | 大量线程同时重建缓存 | 用分布式锁加本地标记 |
| 过期时间随机范围太小 | 不能有效分散过期压力 | 偏移量至少到基础TTL的10%~20% |
| 本地缓存容量估算不足 | JVM内存溢出或频繁GC | 按单机流量和堆内存实测评估 |
这里面我再单独说两个容易被忽略的细节。
第一个是锁的续期问题。用Redis原生setnx做分布式锁时,如果业务执行时间超过了锁的过期时间,锁会被自动释放,其他请求就会趁虚而入。要么给锁设置合理的默认过期时间并加上看门狗续期,要么就不要自己实现,直接用成熟库。我在早期踩过这个坑,那次一个慢SQL执行了12秒,锁却只有5秒过期,瞬间被打穿,从那以后我基本都用现成的锁客户端。
第二个是观测指标不能只盯Redis。缓存三大问题的最终受害者都是数据库,所以数据库连接数、活跃会话数、慢查询数量这几个指标一定要有告警。我建议把“DB QPS / 缓存命中率”的比值作为一个自定义指标来看,正常情况下它会维持在一个稳定区间,一旦出现明显背离,基本就是缓存失效类问题在作怪。
6.4 最后一点心得
处理缓存问题这么多年,我最大的感受是:表面上是在解决技术问题,实际上是在解决“请求路径上的失控点”。穿透、雪崩、击穿,本质都是某个环节的请求没有被缓存接住,压力被转移到了下游。方案再多,核心思路只有一个——让每一层都能分担压力,并且知道在什么时候把请求交还出去。
我也经常听到一种论调:Redis都挂了,缓存设计得再好有什么用?这话只对了一半。缓存方案的价值恰恰在于,当Redis真的挂了,系统还能靠本地缓存、降级数据和熔断逻辑撑住,而不是让数据库跟着一起陪葬。
如果你正在做缓存设计,我建议从今天开始做三件事:把现有缓存key的过期时间分布拉出来看一遍,确认是否存在集中过期;给核心接口加上一级本地缓存,哪怕只有几十秒的TTL;把空值和布隆过滤器的方案至少落地上一个。这三件事做完,你再回头看那几个线上问题,会发现大部分都能提前消解在萌芽阶段。