ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

缓存雪崩、击穿、穿透全解析:后端高并发缓存故障排查与解决方案

缓存雪崩、击穿、穿透全解析:后端高并发缓存故障排查与解决方案 做后端这些年我和缓存三兄弟的“打交道”实录做后端开发这些年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和哈希函数个数km - (n * ln(p)) / (ln(2)^2) k (m / n) * ln(2)我是不建议你手写这个数学公式的实现直接用现成库就行。Google Guava的BloomFilter是Java生态里最常用的Redis 4.0之后也支持了BF命令家族可以直接在Redis里建布隆过滤器。// 预估数据量1万误判率1% BloomFilterLong 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; // 真正的冷数据缓存里完全没有 } CacheItemProduct cacheItem JSON.parseObject(json, new TypeReferenceCacheItemProduct() {}); // 如果逻辑时间未过期直接返回 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); CacheItemProduct 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 数据库。请求先查本地缓存命中就直接返回未命中再查RedisRedis再未命中才查数据库。这样一来即使Redis宕机了本地缓存还能扛住一部分热点请求数据库不至于裸奔。本地缓存的生命周期要设计好不然容易引发一致性问题。我常用的做法是本地缓存过期时间设得非常短比如30到60秒。用户如果有短暂的数据延迟基本感知不到但换来的是Redis崩溃时核心接口的访问体验不至于完全不可用。需要提醒的是本地缓存是个双刃剑。在多实例部署的场景下每个实例都有自己的本地缓存数据更新时没法做到全局一致。如果业务对数据一致性要求高谨慎使用。我一般只给商品详情、配置信息这类“读多写少、容忍短暂不一致”的数据加本地缓存。4.4 核心解法三熔断降级——给数据库加保护罩雪崩场景下的最后一道防线就是熔断降级。说白了就是——数据库已经扛不住了我就不让它扛了直接返回降级结果。比如商品详情接口正常的响应是商品完整信息。数据库压力过高时触发了熔断系统转而从Redis中读取一份精简版缓存或者兜底文案返回给用户。熔断的实现一般用现成框架Java生态里最常用的是Sentinel和HystrixSentinel的使用体验更好。核心配置项有三个滑动窗口大小、触发熔断的阈值、熔断后的降级逻辑。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的重建耗时长到离谱都是提前发现、提前解决的。希望这篇内容能让你在设计和排查缓存问题时更有底气毕竟数据库挂掉这种事经历过一次就再也不想来第二次了。
返回列表