做后端这些年,我和缓存三兄弟的“打交道”实录
做后端开发这些年,Redis缓存几乎成了高并发服务的标配,但很多团队把缓存一接上就以为万事大吉,直到线上流量一来,缓存雪崩、缓存击穿、缓存穿透三兄弟轮番上阵,直接把数据库打到报警,才意识到问题没那么简单。
这三个词看着像孪生兄弟,实际故障场景、危害程度、解决方案完全不同。很多新手(甚至有些工作三五年的老手)张口就把“击穿”说成“穿透”,排查问题时拿着穿透的方案去解决击穿,结果自然事倍功半。今天这篇博文,我把这三兄弟彻底拆开揉碎讲清楚:它们各自是什么、为什么发生、怎么排查、怎么解决,以及我在生产环境里踩过的坑和总结出的实操经验。无论你是刚接触缓存的新人,还是已经在线上和缓存故障搏斗过的老手,这篇文章都能帮你少走弯路。
1. 三个“缓存故障”到底是什么
1.1 缓存雪崩、击穿、穿透的核心定义
先把定义摆清楚,这是所有讨论的基础。
缓存雪崩:大量缓存在同一时间段内集中失效,导致原本应该打到缓存上的请求,瞬间全部涌入数据库,数据库压力陡增甚至被打垮。雪崩的“崩”字,强调是大规模的、整体性的崩溃。
缓存击穿:某个热点key在缓存失效的瞬间,大量并发请求同时发现缓存里没数据,于是一齐冲到数据库上,把单点数据库打爆。击穿的“击”字,强调的是某一个热点被瞬间打穿。
缓存穿透:请求查询的数据在缓存和数据库中都不存在。每次请求都能绕过缓存,直接打到数据库上。穿透的“穿”字,强调的是请求“穿过”了缓存这层防护,而且这种请求往往是可以无限量构造的。
这三个定义背下来容易,真正理解要看它们各自触发的场景和幕后逻辑。
1.2 一张表分清三兄弟的核心区别
| 维度 | 缓存雪崩 | 缓存击穿 | 缓存穿透 |
|---|---|---|---|
| 规模 | 大量key同时失效 | 单个热点key失效 | 单个或多个不存在的key |
| 根因 | 缓存批量过期时间相同 | 热点key过期瞬间并发高 | 数据本身不存在 |
| 数据库压力 | 全部请求涌入,可能打垮 | 并发集中到单key,可能打垮 | 每次都会穿透,持续消耗 |
| 危害范围 | 全局性的 | 局部但可能是核心功能 | 可被恶意利用,持续性攻击 |
| 数据的本质 | 存在但缓存未命中 | 存在但缓存未命中 | 压根不存在 |
这张表看下来,你会发现一个很有趣的点:雪崩和击穿本质上都是“缓存有数据但是刚好过期了”,区别只在规模大小;穿透则完全不同,是“数据根本不存在”。这个本质区别直接决定了解决方案的走向,后面会详细展开。
1.3 理解这三个概念的关键:缓存命中的全流程
要真正理解为什么会有这三种故障,先得把一次带缓存的查询请求从头到尾走一遍。
正常流程是:请求进来,先查Redis,命中就直接返回;未命中,就去数据库查,查到后把结果写回Redis并设置过期时间,然后返回。这套流程本身不复杂,复杂的是它隐含了一个约定:缓存里的数据是按“数据存在且暂时有效”这个假设设计的。
雪崩和击穿的场景里,数据在数据库里是存在的,只是缓存刚好失效了,所以理论上只要数据库扛过这一波,把缓存重建起来,系统就恢复了。穿透则不同,数据压根不存在,你没法重建缓存来挡住后续请求——除非你专门为“不存在”做一层缓存。
另外一个需要理解的底层逻辑是:之所以用缓存,就是把数据库的高成本查询结果临时存起来。一旦缓存失效,请求回到数据库,数据库的基础能力成了唯一防线。所以解决这三兄弟的核心思路,本质上只有一个——怎么让同一时刻到达数据库的请求数量可控。
2. 缓存穿透:恶意流量最喜欢钻的空子
2.1 穿透是怎么发生的,为什么危害那么大
缓存穿透发生的场景非常典型:用户请求一个不存在的商品ID,比如GET /product/123456789,这个ID在数据库里查无此物,那么缓存里自然也不会有。正常的缓存流程是——先查缓存,没命中就去查库,库里也没有,直接返回空结果,不写缓存。
问题就出在这个“不写缓存”上。同样的请求再来一次,系统又得去查一遍库。查一遍库本身不致命,但如果在高并发场景下,有人恶意拿一批不存在的ID疯狂请求,每次请求都会穿过缓存打到底层数据库。
我遇到过一个真实案例:某电商平台的商品详情接口,被刷单程序抓到一个规律——只要商品ID带某个特征,就返回固定错误码。于是对方写了个脚本,批量生成不存在的ID轮番请求,每秒钟几千个请求全部穿透到数据库,数据库的CPU和连接数直接被打满,连带正常用户的请求也超时。
这就是穿透危害大的原因:普通的缓存失效是“一阵子”的事,缓存重建完就好了;穿透是“持续性”的,只要有人不断构造不存在的请求,数据库就会一直被消耗。更麻烦的是,这类请求很难通过常规的限流手段过滤,因为IP可以换、参数可以变、频率可以伪装成正常用户。
2.2 方案一:空值缓存——最朴素但非常管用的手段
解决穿透最直接的办法,就是把“查不到”的结果也缓存起来,只是过期时间设得短一些。
比如商品ID为123456789的查询结果为空,我把它缓存到Redis里,value设为null或者是某个特殊标记(比如-1),过期时间设为60秒。这样一来,60秒内同样的请求再进来,命中缓存直接返回空,根本不会去查数据库。
public Product getProductById(Long id) { // 先从缓存查 Object cached = redis.get("product:" + id); if (cached != null) { // 如果缓存的是空值标记,直接返回null if (EMPTY_MARK.equals(cached)) { return null; } return (Product) cached; } // 缓存未命中,查数据库 Product product = productMapper.selectById(id); if (product == null) { // 数据不存在,缓存空值标记,设置较短的过期时间 redis.set("product:" + id, EMPTY_MARK, 60, TimeUnit.SECONDS); return null; } redis.set("product:" + id, product, 3600, TimeUnit.SECONDS); return product; }空值缓存能挡住绝大多数穿透流量,但它有几个坑需要特别注意。
第一个坑是空值过期时间。如果设得太长,比如10分钟,那么这段时间内如果数据库真的写入了这条数据(比如商品上架),用户刷新看到的依然是空,造成数据不一致。如果设得太短,又挡不住持续的恶意请求。我自己常用的做法是60到120秒,既能有效缓存空结果,又把数据延迟控制在可接受范围。
第二个坑是存储成本。恶意请求往往构造大量不同的不存在的ID,如果这些ID都被缓存成空值,Redis里会积累大量垃圾key,白白占用内存。解决办法有两个方向:一是对空值缓存设置较短TTL让它快速自然淘汰;二是引入布隆过滤器,从源头拦截不存在的key。
2.3 方案二:布隆过滤器——从源头拦截不存在的请求
布隆过滤器的思路和空值缓存完全不同。空值缓存是“查了才知道不存在”,布隆过滤器是“查都不用查,直接告诉你可能存在或者一定不存在”。
原理用一句话讲:用多个哈希函数把数据映射到一个很长的二进制位数组上。某个key过来,先计算它被映射到哪几个位置,如果这几个位置全是1,那这个key可能存在;只要有一个位置是0,那这个key一定不存在。
这就有意思了——布隆过滤器存在误判率,但它只会把“不存在的误判为存在”(假阳性),绝不会把“存在的误判为不存在”(假阴性)。这个特性用在缓存穿透场景里刚刚好:不在过滤器里的key,直接返回,根本不进缓存也不进数据库;在过滤器里的key,放行,走正常的缓存+数据库流程。最多就是让个别不存在的key多查一次数据库,无伤大雅。
使用布隆过滤器时,参数设计是重中之重。核心参数有两个:预估的数据量n和可接受的误判率p。根据这两个参数可以算出位数组长度m和哈希函数个数k:
m = - (n * ln(p)) / (ln(2)^2) k = (m / n) * ln(2)我是不建议你手写这个数学公式的实现,直接用现成库就行。Google Guava的BloomFilter是Java生态里最常用的,Redis 4.0之后也支持了BF命令家族,可以直接在Redis里建布隆过滤器。
// 预估数据量1万,误判率1% BloomFilter<Long> filter = BloomFilter.create( Funnels.longFunnel(), 10000, 0.01 ); // 初始化时把所有合法ID加入 for (Long id : productMapper.selectAllIds()) { filter.put(id); } // 查询前先判断 if (!filter.mightContain(id)) { return null; // 一定不存在,直接返回 }用布隆过滤器有个前置条件:你得能拿到一份完整的合法ID列表,在初始化时全部灌进去。如果ID是动态增长的,你得考虑定期重建过滤器,否则新产生的合法ID可能被误判为“不存在”(注意,这是布隆过滤器本身不会出现的,但如果是重建过滤器的过程中数据没同步好就会出问题)。这也是为什么空值缓存依然是很多团队的首选——它不需要这类前置条件,简单粗暴直接有效。
2.4 穿透方案的选型建议
这两套方案不是互斥的,实际项目中我见过不少团队是组合用:布隆过滤器做第一道拦截,把绝大多数不存在的请求挡在门外;空值缓存做第二道兜底,处理“布隆过滤器误判放行”和“数据确实存在但查询结果为空的边界情况”。
如果业务数据量不大、并发也没那么夸张,直接用空值缓存就够了,简单好维护。如果数据量千万级以上、且存在明显的恶意刷单风险,布隆过滤器的价值就很突出了。
3. 缓存击穿:热点key过期的那一瞬间
3.1 击穿的本质:一个热点key,一场并发风暴
和穿透不同,击穿的根源是“热点”二字。某个商品正在搞秒杀,详情页的QPS可能高达上万,这个key就是典型的热点key。
正常情况下,这个key在缓存里是热的,上万QPS全部打在Redis上,数据库很轻松。但某一天这个key的过期时间到了,Redis里没数据了。这时第一个请求发现缓存未命中,去数据库查到了数据,本打算写回缓存——但就是在这极短的时间内,其他9999个请求也都发现缓存未命中,于是一起涌入数据库。
数据库瞬间收到上万条完全相同的查询,这不就是传说中的“缓存击穿”?雪崩是“一批key集体失效”,击穿是“一个key在极端并发下失效”,规模不同,但底层原理都是“缓存重建的瞬间缺乏互斥保护”。
3.2 方案一:互斥锁——让一个人去重建缓存
解决击穿的第一反应,就是只让一个请求去数据库查数据,其他请求等着。这就是互斥锁方案。
具体实现不复杂:当缓存未命中时,先尝试获取一把锁,拿到锁的请求才允许去查数据库,查完重建缓存后释放锁;没拿到锁的请求,先睡眠一小段时间再重试,重试时往往缓存已经被重建好了。
public Product getProductWithLock(Long id) throws InterruptedException { Object cached = redis.get("product:" + id); if (cached != null) { return (Product) cached; } // 尝试获取分布式锁,设置过期时间防止死锁 String lockKey = "lock:product:" + id; String requestId = UUID.randomUUID().toString(); boolean locked = redis.setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (locked) { try { // 双重检查:拿到锁后可能其他线程已经重建了缓存 cached = redis.get("product:" + id); if (cached != null) { return (Product) cached; } // 查数据库并重建缓存 Product product = productMapper.selectById(id); redis.set("product:" + id, product, 3600, TimeUnit.SECONDS); return product; } finally { // 只有持有锁的线程才能删除锁,防止误删其他人刚获取的锁 if (requestId.equals(redis.get(lockKey))) { redis.delete(lockKey); } } } else { Thread.sleep(50); return getProductWithLock(id); } }这里有两个细节特别重要,都是我在生产环境踩过坑之后总结出来的。一是锁必须设置过期时间,不然重建缓存的过程中线程挂了,锁永远不会释放,其他请求全部死等;二是删除锁时要校验请求标识,防止“线程A超时释放锁,线程B获取锁,线程A才删除锁,结果把B的锁给删了”这种经典的误删问题。
互斥锁方案逻辑清晰,缺点也明显——如果热点key的并发量极大,拿不到锁的请求会短暂阻塞,延迟上涨,但这通常是可以接受的。实际测试中,秒杀场景下99%的请求都能在第二次重试时命中已重建的缓存。
3.3 方案二:逻辑过期——用“过期不删”的思路
互斥锁方案有个潜在瓶颈:如果数据库重建缓存耗时较长(比如复杂SQL查询需要几百毫秒),这段期间所有请求都在等待,用户体验不好。逻辑过期方案就是为了解决这个问题。
逻辑过期的核心思路是:缓存中不设置物理过期时间,而是在value里存一个逻辑过期时间戳。查询时,即使发现逻辑时间已过期,也照样返回旧数据给用户,然后异步地启动一个线程去数据库查询并更新缓存。
public Product getProductWithLogicalExpire(Long id) { String key = "product:" + id; String json = redis.get(key); if (json == null) { return null; // 真正的冷数据,缓存里完全没有 } CacheItem<Product> cacheItem = JSON.parseObject(json, new TypeReference<CacheItem<Product>>() {}); // 如果逻辑时间未过期,直接返回 if (cacheItem.getExpireTime() > System.currentTimeMillis()) { return cacheItem.getData(); } // 逻辑过期,尝试获取互斥锁,异步重建缓存 String lockKey = "lock:product:" + id; if (redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS)) { // 异步线程池执行缓存重建 executor.submit(() -> { try { Product product = productMapper.selectById(id); CacheItem<Product> newItem = new CacheItem<>( product, System.currentTimeMillis() + 3600 * 1000); redis.set(key, JSON.toJSONString(newItem)); } finally { redis.delete(lockKey); } }); } // 先返回旧数据,客户端感知不到延迟 return cacheItem.getData(); }从上面的代码可以看到,逻辑过期方案的最大好处是:没有请求会阻塞等待数据库返回,所有请求要么拿到新鲜数据,要么拿到稍微旧一点但还可用的数据。这个“旧数据可用”的语义很重要,它要求业务上能容忍短暂的数据延迟,比如商品详情展示、资讯列表这类读多写少的场景就非常适合。
3.4 互斥锁 vs 逻辑过期:怎么选
| 维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 数据新鲜度 | 高,一定会读到最新数据 | 低,可能有几秒到几十秒的延迟 |
| 请求延迟 | 可能因等待锁而增加 | 恒定,不会阻塞 |
| 实现复杂度 | 低,逻辑清晰 | 高,需要额外的异步任务管理和时间戳判断 |
| 适用场景 | 数据一致性要求高、缓存重建快的场景 | 数据一致性容忍度高、缓存重建慢的场景 |
我的建议是:大部分场景优先用互斥锁。它简单、直观、可控,不容易出幺蛾子。只有当缓存重建确实很慢(比如某个统计结果要跑好几秒SQL),又不想让用户等待时,再考虑逻辑过期。逻辑过期方案里的异步任务还要考虑线程池监控、丢任务补偿等额外问题,复杂度比看上去高不少。
4. 缓存雪崩:大批key同时失效的“完美风暴”
4.1 雪崩的诱因:不止是“过期时间相同”这么简单
缓存雪崩最经典的诱因是:所有key的过期时间都设置成了同一个值。比如业务启动时批量缓存了一批数据,全部设置expire = 3600秒,那么一个小时后这批key会同时失效。如果这批key正好都是核心数据,那么“整点”时刻就会有一波请求集中打到数据库上,数据库压力陡增。
但雪崩的诱因远不止这一个,我在生产环境见过三种典型的雪崩场景:
- 批量key同一时刻过期:最常见,原因就是过期时间写死了。
- Redis实例宕机:缓存整个不可用,所有请求全部落到数据库。这种是最可怕的雪崩,因为没有“重建缓存”这个过程,数据库直接承受100%流量。
- 缓存服务网络分区:应用访问Redis超时,很多框架在超时后默认“降级”到查数据库,相当于隐形的缓存失效。
区分这三种诱因很重要,因为它们的应对策略完全不同。第一种靠“过期时间加随机值”就能解决;第二种需要做高可用(比如Redis哨兵或集群);第三种要在降级策略上做文章,比如超时后限流降级,而不是无脑查库。
4.2 核心解法一:过期时间打散——加随机值
解决批量key同时失效,最直接的手段就是让过期时间分散开。方法并不复杂,在设置过期时间时加上随机数。
// 基础过期时间1小时,加上0到10分钟的随机值 int baseExpire = 3600; int randomExpire = new Random().nextInt(600); redis.set(key, value, baseExpire + randomExpire, TimeUnit.SECONDS);这样一批key虽然都是“1小时过期”,但实际过期时间在1小时到1小时10分钟之间,分布到10分钟内分批过期,数据库承受的压力就平滑多了。这个方法实现成本极低,我一直建议团队在封装缓存工具类时默认加上随机值,而不是每次单独设置。
随机值的范围怎么定?我的经验是不宜过大。随机范围设定为整体过期时间的10%到20%比较合理。比如1小时过期,随机0到10分钟,既能让过期时间分散到足够大的窗口,又不会出现极端情况下部分数据提前太多过期(增加数据库负载)。
4.3 核心解法二:系统层面加“缓冲垫”——多级缓存
打散过期时间能解决“批量过期”的问题,但解决不了“Redis宕机”这种极端情况。多级缓存就是专门为这种情况兜底的。
多级缓存最经典的组合是本地缓存(如Caffeine或Guava) + Redis + 数据库。请求先查本地缓存,命中就直接返回;未命中再查Redis;Redis再未命中才查数据库。这样一来,即使Redis宕机了,本地缓存还能扛住一部分热点请求,数据库不至于裸奔。
本地缓存的生命周期要设计好,不然容易引发一致性问题。我常用的做法是:本地缓存过期时间设得非常短,比如30到60秒。用户如果有短暂的数据延迟,基本感知不到,但换来的是Redis崩溃时核心接口的访问体验不至于完全不可用。
需要提醒的是,本地缓存是个双刃剑。在多实例部署的场景下,每个实例都有自己的本地缓存,数据更新时没法做到全局一致。如果业务对数据一致性要求高,谨慎使用。我一般只给商品详情、配置信息这类“读多写少、容忍短暂不一致”的数据加本地缓存。
4.4 核心解法三:熔断降级——给数据库加保护罩
雪崩场景下的最后一道防线就是熔断降级。说白了就是——数据库已经扛不住了,我就不让它扛了,直接返回降级结果。
比如商品详情接口,正常的响应是商品完整信息。数据库压力过高时,触发了熔断,系统转而从Redis中读取一份精简版缓存或者兜底文案返回给用户。
熔断的实现一般用现成框架,Java生态里最常用的是Sentinel和Hystrix,Sentinel的使用体验更好。核心配置项有三个:滑动窗口大小、触发熔断的阈值、熔断后的降级逻辑。
@SentinelResource(value = "productDetail", fallback = "productDetailFallback") public Product getProductDetail(Long id) { // 正常的缓存+数据库查询逻辑 } // 降级方法:返回兜底数据 public Product productDetailFallback(Long id, Throwable ex) { Product stub = new Product(); stub.setId(id); stub.setName("商品信息暂时无法加载,请稍后重试"); return stub; }这里想多说一句:降级返回的“兜底数据”是需要提前设计好的,而不是随便返回个null。好的降级设计要让用户明确知道“当前系统压力大”,而不是“这个商品不存在”或“服务出错了”。这两者的用户体验天差地别。
4.5 给缓存加随机过期时间时,业务上要注意什么
打散过期时间听着很容易,但我在实际落地时踩过一个坑。某个核心配置的缓存key,过期时间加随机值之后,因为随机值的加入导致配置更新的时间也跟着漂移了——配置变更后,最长可能需要等“基础过期时间+最大随机值”才能生效。所以对这类“需要及时生效”的配置型数据,建议不要加随机值,而是走显式的主动更新(key变更时直接删除或重设缓存)。
另外,批量加载缓存时,除了过期时间加随机,还可以考虑做一层预热错峰。比如一批数据有100个key,不要在同一时刻全部加载,可以分批在几十秒内慢慢加载完,从源头避免同一时刻缓存里堆着一批“同生共死”的key。
5. 从“搞定单个问题”到“系统性防范”
5.1 三兄弟的本质是同一件事:缓存和数据库之间流量失衡
前面分别聊了三个问题,但把它们放在一起看,你会发现内核其实只有一个:在某个时刻,原本应该被缓存接住的流量,由于某种原因转移到了数据库上,而数据库承受不住。
穿透是“数据不存在导致缓存永远接不住流量”,击穿是“热点key突发失效导致流量瞬间转移”,雪崩是“大批key同时失效导致流量集中转移”。三者只是“流量转移”的规模、时机、对象不同。
想清楚这一层,设计方案时的思路就不一样了。你不是在“分别对付三个问题”,而是在设计一套流量治理机制——尽可能让流量不要瞬间、集中地落到数据库上,万一落到数据库上了,数据库也有能力扛住。
5.2 从架构层面看高并发缓存系统的“组合拳”
我比较推荐的设计方式,是搭配组合方案来处理。虽然前面按“穿透/击穿/雪崩”三个话题分开讲的,但生产环境其实是同时存在风险的:
- 网络层:高防IP、WAF挡掉明显的恶意流量,从源头减少穿透攻击。
- 应用层:布隆过滤器 + 空值缓存,解决穿透问题。
- 缓存层:互斥锁或逻辑过期,解决热点key击穿问题。
- 过期策略:过期时间加随机值、缓存预热错峰,解决雪崩问题。
- 高可用:Redis哨兵或集群部署,解决Redis单点故障。
- 兜底:熔断降级 + 数据库连接池限制 + 读写分离,保证系统在极端情况下不完全瘫痪。
这套组合拳下来,每个层级都在分担压力,即使某一层被突破了,下一层也能兜住,系统整体稳定性会好很多。
5.3 架构设计的第一步:识别“你的系统里有没有热点key”
虽然组合拳很重要,但第一步还是要识别出:你的系统里到底哪些key是热点。方案设计得再花哨,用不到热点上等于白搭。
热点识别的常用手段:
- 分析Redis的
hotkeys命令输出(Redis 4.0之后内置可用)。 - 在缓存访问的代码里埋点,统计每个key的单位时间访问量。
- 结合业务常识判断——比如秒杀商品、热门话题、大V的主页等。
我见过一些团队,看了几篇缓存文章就赶紧给所有key都加上了互斥锁和本地缓存,结果本地缓存一致性出问题,反而引发更多线上事故。热点key才需要击穿保护,普通key用常规方案即可。过度设计带来的复杂度,往往比问题本身更可怕。
以商品系统的长期运营经验来看,真正需要击穿保护的“热点key”在一个业务系统里通常屈指可数,不超过几十个。把这些key管好、监控好,比给每个key都加一套复杂机制更有价值。
6. 生产环境实战:如何快速定位到底是哪个“兄弟”
6.1 从监控指标快速判断故障类型
线上出了问题,第一件事是快速判断属于哪一类,才能对症下药。我的排查习惯是这样:
| 故障特征 | 可能的故障类型 |
|---|---|
| Redis命中率瞬时暴跌,数据库QPS暴涨 | 雪崩或击穿 |
| 某个固定key的访问量极高且缓存未命中 | 击穿 |
| 大量不存在的key请求,缓存命中率正常但数据库慢查询增加 | 穿透 |
| Redis本身不可达,所有请求全部打到数据库 | 雪崩(Redis宕机) |
单靠特征还不够,要结合时间线看。如果数据库压力是“陡峭的尖峰”——很短时间冲上去又快速回落,大概率是击穿或者小规模雪崩;如果是持续的平台型高位,更可能是穿透攻击。第三招是看Redis的keyspace命中数和数据库慢查询日志——如果慢查询集中在某几个相同的SQL上,就是击穿;如果SQL各不相同但全是查无结果,就是穿透。
6.2 一份可复用的排查步骤清单
这里整理了一份我在团队里推广的标准化排查清单,遇到缓存告警按顺序走就行:
- 打开Redis监控大盘,确认缓存服务的存活状态和命中率趋势。
- 打开数据库慢查询日志,看慢SQL是否集中在某几张表或某几个查询条件上。
- 打开应用日志,搜索缓存未命中的key,统计key的分布集中度。
- 看网络入口的流量,确认是否有异常的集中请求或恶意刷接口的行为。
- 结合业务时间线判断:是不是刚上线了版本?是不是常有固定的整点任务?是不是刚好有秒杀活动?
这套清单看起来基础,但真的很管用。大部分线上事故,按这个顺序排查,几分钟内就能定位到问题类型。
6.3 事故复盘时最容易忽略的两个指标
每次事故复盘,大家总是盯着一堆技术细节,但有两个指标我特别关注——缓存重建的平均耗时和热点key的访问分布。
缓存重建耗时直接决定了击穿时数据库要被“按在地上摩擦”多久。如果重建耗时是5毫秒,互斥锁方案的阻塞时间完全可控;如果重建耗时是5秒,用户的超时就是必然的。这个指标还决定了你是否需要引入逻辑过期方案——重建耗时的阈值我认为是100毫秒,超过这个值就值得认真评估逻辑过期了。
热点key的访问分布则决定了你去保护哪个key。我之前在某个客户系统里看到,一个占总量0.01%的key承载了60%的访问量——这种key不做特殊保护,整个系统就永远在击穿边缘徘徊。
6.4 慢SQL之外的另一个盲点:连接池被打满
排查缓存问题时,很多同学只盯着数据库的慢查询,却忽略了一个隐蔽的杀手——连接池被打满。在高并发下,即使每个查询只要1毫秒,但如果瞬间涌入的连接数超过了数据库连接池上限,后续请求会在连接池排队甚至直接报错,表现和“数据库被打垮”几乎一样。
这种问题的排查要从应用层的连接池监控入手。如果看到连接等待数剧增、活跃连接数打满,那就说明不是单条SQL慢的问题,而是并发量超过了连接池的处理能力。解决方案除了前面说的缓存策略,还要考虑调大连接池上限、给核心接口单独配置连接池,以及设置合理的连接等待超时——宁可快速失败,也别让请求无限排队拖垮整个应用。
6.5 加了缓存依然慢:缓存值过大导致的隐藏问题
另一个容易踩的坑是缓存value本身过大。Redis的存取速度极快,但这个“极快”是建立在value是几十KB的前提下。如果你把一个大对象(比如一个包含几千个字段的JSON)塞进Redis,单次读取可能就要十几毫秒甚至更久,且占据大量内存。当并发上来了,这些大key很容易成为性能瓶颈。
我在一个日志分析系统里遇到过:业务方把一个用户的所有操作日志合并成一个JSON存进Redis,单个value高达2MB。查询接口虽然命中了缓存,但反序列化这个超大JSON就花了200多毫秒。这种问题要从缓存设计源头解决——限制单value大小,超长数据拆分成多个小key存储,或者改用单独的对象存储服务。
7. 缓存设计经验谈:从“能用”到“好用”的距离
7.1 过期时间不是拍脑袋定的
很多同学设置过期时间时习惯性写个“3600”完事,但我建议认真想一下设置的依据。过期时间太短,缓存命中率低,数据库压力大;过期时间太长,数据与数据库的一致性窗口变大,业务上可能出错。
我常用的评估方法:统计一个key的正常访问间隔,然后让过期时间覆盖95%以上的访问间隔。比如某数据平均每10分钟被访问一次,那过期时间设为1小时左右就够用了,既可以覆盖绝大多数访问,又不会让已更新数据在缓存里滞留过久。
7.2 缓存更新策略:先更库还是先删缓存
这是老生常谈但必须答对的问题。主流方案有两种:先更新数据库再删除缓存,或者先删除缓存再更新数据库。
我的建议是无脑选“先更新数据库再删除缓存”。原因是:如果先删缓存,还没更新数据库之前,来了一个读请求,它会发现缓存没数据,去数据库查到旧值,把旧值写回缓存。等数据库更新完后,缓存里还是旧数据,脏数据就产生了。先更新库再删缓存,虽然也存在极端的时间窗口,但概率要低很多,配合延迟双删(更新库后等几百毫秒再删一次缓存)基本可以解决。
7.3 能不做的事就不做:缓存设计里的“减法”
分享一个我做技术方案评审时反复强调的观点:缓存的很多故障,其实是缓存加得太随意导致的。有些接口,每次调用数据库也就几十毫秒,并发也不高,压根没必要加缓存——加了反而带来数据一致性问题、缓存故障问题、维护成本问题。
我的评判标准很简单:日请求量低于几千的接口就不要加缓存了,数据库完全扛得住。加缓存的收益必须大于它引入的复杂度,否则就是负优化。
7.4 降级预案不是上线那天才写的
我见过不少团队,Redis没宕过机,于是降级预案只存在于文档里,从没演练过。直到线上Redis真的挂了,才发现降级逻辑里有bug——降级开关没生效、降级返回的数据格式不对、降级后的响应时间反而更慢……
这里有个经验:每季度做一次缓存故障演练,模拟Redis宕机、模拟大批key过期、模拟热key集中访问,用演练去验证降级预案和数据保护机制是否真的有效。演练不一定要在核心环境做,可以先在预发环境跑,保证链路是通的。真到了线上故障来临那一刻,你会发现演练时的从容有多值钱。
7.5 降级要分级,不是一降到底
最后聊一个很多团队做降级时容易犯的错误:把降级当成一个“全有或全无”的开关。压力一来,直接熔断返回错误或者全部走兜底,用户瞬间体验劣化。
更好的做法是分级降级。第一级,关闭非核心功能(比如推荐位、相关商品),保留主流程;第二级,主流程降级为精简数据,只返回名称和价格;第三级,才考虑直接返回兜底提示。每一级降级都对应明确的触发阈值,而不是到了最后一刻一步到位。这套思路实践下来,用户能感知到的降级影响其实很小,大部分用户甚至感觉不到系统正在被冲击。
说回到这三兄弟,我在实际排查和设计缓存方案时最大的体会是:无论方案多花哨,最后起作用的往往是“流量可控、时间分散、多层兜底”这几个朴素的思路。做技术方案时,多问自己一句——这个方案上线后,数据库最坏情况下要扛多少并发?如果这个数字超出了数据库的能力,方案就不合格。
最后再分享一个实践中的小技巧:每次上线缓存相关的改动,我都会在代码里临时加一段日志,打印出缓存key的过期时间和重建耗时,观察几天再撤掉。这比任何监控大盘都更能直观反映缓存的运行状态。这些日志帮我发现了不少潜在风险,比如某些key的过期时间分布太集中、某些key的重建耗时长到离谱,都是提前发现、提前解决的。希望这篇内容能让你在设计和排查缓存问题时更有底气,毕竟数据库挂掉这种事,经历过一次就再也不想来第二次了。