尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis缓存击穿全解析:热点Key防护与多级缓存实战

Redis缓存击穿全解析:热点Key防护与多级缓存实战 做后端开发这十来年Redis 相关的线上事故我处理过不少。要说哪种问题最容易让人头皮发麻缓存击穿绝对排得上前三。它不是那种简单的查不到数据报错而是在某个热点 Key 过期的瞬间几万个请求同时涌向数据库直接把核心服务打挂的年度级事故。这篇文章就围绕缓存击穿这个主题把热点 Key 在高并发下的防护策略完整过一遍。全文会覆盖问题识别、热点探测、互斥锁、逻辑过期、主动刷新、多级缓存这几条主流路线也会把我实际踩过的坑和排查过程一并写出来。不管你是刚接触 Redis 的新人还是正在为线上稳定性发愁的中级开发这套思路应该都能直接用得上。1. 先搞清楚缓存击穿到底是怎么回事1.1 穿透、击穿、雪崩三兄弟别再傻傻分不清很多人刚开始接触这几个概念时容易混我建议你把它们放到一条时间线上理解。缓存穿透请求的数据在缓存和数据库里都不存在每次请求都白白打到数据库。典型场景是恶意请求一个不存在的商品 ID。缓存击穿某个热点 Key 在缓存过期的瞬间大量并发请求同时发现缓存没值一起冲到数据库。关键在于这个 Key 是热点请求量极大。缓存雪崩大量 Key 在同一时间窗口内集体失效数据库承受的其实是穿透 击穿的叠加效应。击穿和雪崩的区别不在于多少个 Key而在于请求量集中在一个点还是一大片。击穿是单点引爆雪崩是面状爆发。防护思路完全不同击穿的核心是热点 Key 的重建过程不能被并发放大雪崩的核心是失效时间要错开。这篇文章讲的是击穿但很多手段对雪崩同样有效。1.2 热点 Key 为什么会成为事故源头要理解击穿为什么危险得先看一次普通的缓存失效流程Key 过期第一个请求发现缓存没值去数据库查询拿到结果回填缓存后续请求全部命中。这个流程本来没什么问题坏就坏在第一个请求在极端并发下不是一个而是成百上千个。这些请求同时发现缓存没值同时去查数据库数据库的连接池瞬间被占满。更麻烦的是它们查完之后还会同时写缓存造成无意义的重复写入。数据库的 CPU 和 IO 被打满后不只是这一个接口受影响整个服务的所有查询都会跟着超时。我之前遇到的一次事故里核心订单库就是这么被拖垮的对外表现是大量接口报错排查到最后才发现源头只是某个商品维度的缓存 key 过期了。1.3 最容易发生击穿的三类业务场景不是所有 Key 都值得做防护但下面三类场景一旦出事后果都很严重。秒杀和抢购场景商品库存、活动配置这些 Key 天然是热点活动开始的那一刻并发最高也最容易撞上缓存刷新。热门榜单和排行榜每日热销榜、点击榜通常有固定的过期时间凌晨或整点刷新时全站流量会集体打到重建逻辑上。大促前的配置类数据比如购物车满减规则、运费模板平时流量不大但大促瞬间被高频读取一旦缓存失效影响面会非常大。判断一个 Key 是否值得防护核心标准就一条它会不会在极短时间内被大量请求同时访问。会就按照下面的策略做保护不会普通缓存逻辑就够用。2. 先学会识别热点 Key没有靶子谈什么防护防护策略的前提是知道哪些 Key 是热点。很多人一上来就写互斥锁结果锁住的全是低频 Key反而把简单逻辑搞复杂了。识别热点 Key 可以从 Redis 侧和业务侧两条线同时入手。2.1 用 Redis 自带命令定位热点 KeyRedis 提供了几个基础工具不需要额外引入组件就能用。INFO stats 命令可以查看键空间命中率。如果 keyspace_hits 占比出现明显下降说明有 Key 在集中失效值得深挖。MONITOR 命令实时打印所有命令。在高并发时段跑一两分钟把输出按 key 聚合排序很容易找出被访问次数最多的那几个 Key。注意生产环境开启 MONITOR 会有性能损耗不建议长时间开着我一般是在怀疑有问题的时间窗口开 30 秒左右。SLOWLOG GET慢查询日志。Redis 单线程模型下一旦出现慢命令后续请求会排队。热点 Key 的大 Value 操作很容易出现在慢查询里这既是热点信号也是性能隐患。如果公司有条件建议把 Redis 接入可观测平台按 key 维度统计 qps 和延迟。没有平台的情况下用上面的原生命令也能完成 80% 的定位工作。2.2 业务侧埋点统计与热点预测Redis 侧看到的是结果业务侧更能提前预判热点。我常用的做法是在业务代码上加一个轻量级热点采集器在读取缓存的方法入口用本地内存比如一个带过期时间的 ConcurrentHashMap对 key 做计数窗口设成 10 秒统计窗口内每个 key 的访问次数超过阈值就上报。这样做有两个好处一是能拿到真实的业务热度分布不依赖运维侧的数据二是可以提前把热点清单推送给缓存预热系统。比如电商的大促会场运营会提前给出主推商品清单直接把这些商品 ID 作为预热的候选热点比事后发现再补救要稳得多。2.3 热点分类分级读热点、写热点、混合热点识别出热点 Key 之后先别急着加防护按照读写特征分个类方案选型会更清晰。读热点请求几乎全是 GET比如商品详情。防护重点放在缓存命中率和多级缓存上。写热点请求集中在更新比如库存扣减。这种场景单纯靠缓存策略不够通常需要配合分布式锁和原子操作让写请求串行化。读写混合热点既有大量读又有高频写比如购物车。这种最容易出问题建议优先采用永不过期 数据变更主动刷新的组合屏蔽掉过期瞬间的并发窗口。分级也很重要。我的习惯是把热点分成 P0、P1、P2 三级P0 级使用本地缓存 永不过期 主动刷新全套方案P1 级使用逻辑过期或互斥锁P2 级保持标准缓存逻辑即可。防护手段也是成本用在对的地方才有价值。3. 策略一互斥锁最经典的重建防护互斥锁是缓存击穿防护里最直白的思路既然并发重建是问题那就让重建动作串行化。3.1 互斥锁的原理让重建动作串行化思路很简单。在缓存失效后只有第一个请求能拿到锁并去数据库加载数据其他请求拿不到锁就等待等第一个请求重建完缓存后再从缓存读取。这相当于把并发打到数据库的流量从 N 路削减成 1 路。这里有个容易忽略的点单机环境下用 JVM 的 synchronized 或 ReentrantLock 就能实现但生产环境基本都是多实例部署JVM 锁只能锁住当前进程其他实例的请求照样会冲进数据库。所以必须用跨进程的分布式锁最常见的载体就是 Redis 的 SETNX 命令。3.2 SETNX 分布式锁的标准写法与完整代码用 Redis 做分布式锁需要注意几个细节加锁要设置过期时间防止线程崩溃后锁不释放释放锁时要校验持有者身份防止误删其他线程的锁。下面是我在 Spring Boot 项目里常用的写法直接用 StringRedisTemplate 就能跑通。public String getDataWithLock(String key) { // 第一次读缓存命中直接返回 String value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } String lockKey lock: key; String requestId UUID.randomUUID().toString(); // 尝试加锁5 秒自动过期避免持有锁的线程异常导致死锁 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (!locked) { // 没拿到锁说明其他线程正在重建缓存短暂休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getDataWithLock(key); // 实际项目中要加最大重试次数 } // 拿到锁开始重建 try { // 双重检查可能上一个持锁线程已经重建完了 value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } // 真正查询数据库并回填缓存 value loadFromDatabase(key); redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES); return value; } finally { // 释放锁前校验 requestId防止锁自动过期后误删别人的锁 String currentLock redisTemplate.opsForValue().get(lockKey); if (requestId.equals(currentLock)) { redisTemplate.delete(lockKey); } } }这套代码里有两个关键点值得展开说。第一个是双重检查。很多人加锁后直接查库忽略了锁等待期间前一个线程已经把缓存重建好的可能性。加了双重检查能省掉一次毫无意义的数据库查询。第二个是释放锁时的身份校验。如果线程 A 加的锁在 5 秒后自动过期了线程 B 拿到锁开始重建这时线程 A 才执行完数据库查询走到 finally如果不校验直接 delete就会把线程 B 的锁删掉线程 C 又拿到锁重复重建锁就形同虚设。用 requestId 做身份标识是 Redisson 里也采用的思路。3.3 锁超时时间与重试间隔的合理设定参数怎么定直接影响方案的可靠性。锁超时时间5 秒这个值必须大于数据库查询 缓存回填的最长耗时。我一般先统计业务 SQL 的 P99 耗时然后取它的 3 到 5 倍。如果数据库查询平均 200msP99 到 800ms锁超时设 5 秒是安全的。设太短锁提前过期多个线程还是会同时重建设太长持锁线程异常退出后其他线程会一直拿不到锁白白等很久。重试间隔50ms这个值要平衡空转损耗和响应时间。50ms 是我常用的初始值如果业务对延迟敏感可以降到 20ms。注意重试必须是递归或循环里带上最大次数比如 10 次超过次数直接返回空或者走降级逻辑避免无限递归把线程栈打爆。缓存过期时间30 分钟热点数据的过期时间要结合业务容忍度来定。如果数据变更不频繁千万别把过期时间设成几分钟那等于自己给自己制造击穿窗口。3.4 互斥锁方案的短板和适用边界互斥锁能解决击穿问题但它不是没有代价。最大的短板在于缓存失效后的那段时间里所有请求要么在等待要么在空转重试接口的响应时间会明显变长。如果热点 Key 的重建时间特别长比如数据库查询要 2 秒锁能挡住数据库压力但挡不住用户体验的下降。所以我的经验是互斥锁适合重建耗时不长、对一致性要求相对较高的场景。如果重建动作很重或者业务上允许先返回旧值逻辑过期方案会更合适下面这一章专门讲它。4. 策略二逻辑过期 异步重建把等待变成异步逻辑过期是我个人最喜欢的一套方案它在应对重建时间长、读多写少的热点 Key 时效果比互斥锁好很多。核心思想是缓存里的数据永远不设置物理过期时间而是在 value 里附带一个逻辑过期时间戳。每次读取时判断时间戳是否到期。未到期直接返回已到期先返回旧数据然后异步触发重建。4.1 逻辑过期的设计思路用旧值换时间为什么这个方案能行因为大部分业务场景下返回一条稍旧的数据是可以接受的。比如商品详情页展示的库存数字慢 5 秒更新完全不影响用户体验但因为这 5 秒不让用户访问损失就大了。逻辑过期把缓存重建从请求链路里剥离出去了。请求永远能拿到值不会因为缓存失效而等待数据库压力也被异步线程平摊掉。相当于用数据的短暂延迟换来了系统整体的平稳。4.2 从零实现逻辑过期方案第一步先定义一个缓存对象。value 里不再只存业务数据而是存业务数据加过期时间戳。public class CacheItem { private String data; // 业务数据实际项目里可以是 JSON 字符串 private long expireTime; // 逻辑过期时间戳毫秒 }第二步是读取逻辑。拿到缓存后反序列化成 CacheItem判断 expireTime 是否大于当前时间。这里有个很重要的细节只要逻辑上没过期直接返回逻辑过期了也返回旧值但要把重建任务丢给异步线程。public String getDataWithLogicalExpire(String key) { // 第一次读缓存 String cacheJson redisTemplate.opsForValue().get(key); if (cacheJson null) { // 缓存压根不存在可能是新 Key 或已被淘汰走直接加载并写入 return loadFromDatabase(key); } CacheItem cacheItem JSON.parseObject(cacheJson, CacheItem.class); if (cacheItem.getExpireTime() System.currentTimeMillis()) { // 逻辑过期时间未到正常返回 return cacheItem.getData(); } // 逻辑过期先返回旧值再异步重建 asyncExecutor.execute(() - rebuildCache(key)); return cacheItem.getData(); } private void rebuildCache(String key) { // 这里同样需要防并发重建最简单的是加一个短时锁 String lockKey rebuild_lock: key; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (!locked) { // 已经有线程在重建直接放弃 return; } try { String data loadFromDatabase(key); CacheItem item new CacheItem(data, System.currentTimeMillis() 60_000); redisTemplate.opsForValue().set(key, JSON.toJSONString(item)); } finally { redisTemplate.delete(lockKey); } }注意一个隐蔽的坑如果缓存因为内存淘汰策略被 Redis 主动淘汰了缓存会变成 null走直接加载分支此时可能引发和击穿一样的并发问题。所以在写入时我习惯给 CacheItem 设置一个非常长的物理过期时间比如 24 小时逻辑过期时间设成 60 秒。这样物理层基本不会被淘汰真正的失效完全由逻辑过期控制。4.3 异步线程池参数估算和拒绝策略逻辑过期方案绕不开线程池线程池参数踩坑的比例非常高。我见过很多人直接用默认的 newFixedThreadPool(1)结果热点重建任务全部串行排队数据更新严重滞后。线程池大小应该根据热点 Key 数量和重建耗时来估算。假设你有 50 个热点 Key每个重建耗时 500ms希望在 2 秒内完成一轮全部重建那么需要的线程数大约为 50 × 0.5 / 2也就是 12 到 13 个线程。当然这只是一种粗略估算实际还要留出余量我一般按计算值的 1.5 倍配置。拒绝策略建议用 CallerRunsPolicy。任务满的时候不丢弃而是让提交任务的线程自己执行相当于把重建压力回推到业务线程。这虽然会导致个别请求变慢但能保证重建一定发生比直接 Discard 丢任务要好得多。4.4 并发重建怎么防重建动作再上锁逻辑过期方案里有一个隐藏的并发点在同一时刻可能有一万个请求发现 Key 已过期然后每个请求都往线程池里提交一个重建任务。虽然因为重建锁存在最终只有一个任务会真正执行业务查询但一万个任务同时涌进线程池本身就是一种压力。所以我在提交任务前会先做一个快速判断用 SETNX 抢一个重建锁抢不到就不提交任务。这样线程池收到的任务量会被大幅削减队列不会被打满。这个逻辑在前面代码里的 rebuildCache 方法中体现提交任务的入口也可以提前抢锁双保险。5. 策略三热点 Key 永不过期 主动刷新互斥锁和逻辑过期都是在问题发生时才处理有一种更治本的思路是让热点 Key 压根不进入失效状态。5.1 热点 Key 永不失效为什么不是偷懒很多初学者一听永不过期就觉得这是逃避问题实际上不是。这里的不失效是指缓存不设置真实过期时间而是配合后台任务主动更新。两者的区别在于被动失效是不可控的突发事件主动刷新是可控的定期维护。把不可控变成可控正是高并发系统设计的核心思路。热点 Key 的失效时间如果掌握在定时任务手里你就能精确控制什么时候会有一次数据重建——选在业务低峰期做刷新击穿风险自然趋近于零。5.2 定时预刷新在过期前动手我的做法是为每个热点 Key 维护一个刷新时间点判断依据可以是写入时间加缓存时长。用定时任务每 30 秒扫描一次热点清单如果发现某个 Key 距离刷新时间点不足 60 秒就主动触发重建。更简单一点的做法是让定时任务固定每 30 秒刷新一轮热点 Key不管是否快到过期时间。这个方案适合数据量不大、更新频率可以接受的情况。比如排行榜这种天然需要周期性更新的数据固定刷新反而比过期再重建更贴近业务需求。预刷新的关键在于提前量。从发现快到过期时间到重建完成需要一段耗时定时任务的扫描周期必须小于这中间的缓冲期否则会出现缓存已经过期但刷新还没完成的情况。5.3 数据变更触发主动刷新把缓存更新提前对于写操作频繁的数据光靠定时任务还不够因为定时任务的最小粒度通常秒级无法满足实时性要求。更可靠的方案是在数据源头触发缓存更新数据库数据变更后通过消息中间件或者 binlog 监听把变更事件通知出来业务侧收到事件后主动更新或删除对应的缓存 Key。这个方案最大的好处是缓存的数据永远是接近实时的而且不存在过期瞬间这个概念。实现上要注意防抖同一 Key 在短时间内频繁变更时不要触发大量重复的缓存写操作最好做一次合并或加一个最短刷新间隔。6. 多级缓存与降级兜底真正的双保险任何单一策略都可能在某些极端条件下失效比如 Redis 本身抖动、网络分区、线程池被占满。所以线上高并发系统单靠一个方案远远不够必须组合拳。6.1 本地缓存前置让热点请求尽量不触网在 Redis 前面加一层本地缓存Caffeine 或 Guava Cache是能大幅削减压力的手段。热点 Key 的请求绝大部分可以直接在应用内存里命中根本不需要走到 Redis更不会因为 Redis 的 Key 过期而产生重建问题。本地缓存的时间一般要比 Redis 短。比如 Redis 缓存设 30 分钟本地缓存我建议设 60 到 120 秒。原因是本地缓存是每个实例各存一份如果时间设太长实例之间的数据一致性会很差时间短一点既能有效挡住热点又不会因为数据滞后太严重出问题。配置 Caffeine 的时候注意设置最大容量防止热点数据过多把堆内存打爆。容量按实例的堆内存大小来定我常用的参考是 1GB 堆内存配 10 万个条目的上限。6.2 对重建动作做限流保护数据库的最后一道闸即使有锁锁只能保证同一个 Key 只有一个线程重建但如果同时有多个不同热点 Key 都失效了数据库一样可能被打满。这种场景需要的是对重建总流量做限制。我通常会在查询数据库的那一层加一个限流器可以用 Redisson 的 RRateLimiter也可以简单用本地令牌桶。限流的阈值怎么定看数据库在高负载下的耐受能力。比如数据库连接池配置了 50 个连接那重建流量限制在 20 QPS 以内就比较安全剩下的请求让它返回旧值或者走降级。6.3 降级开关与兜底数据设计最后一个兜底是降级。我是这么设计的每个热点 Key 的读取链路上都埋一个开关当数据库压力持续高位或者依赖的下游服务异常时可以一键切换到降级模式。降级模式下的行为有两种。一种是从内存中返回一份预置的默认数据比如活动未开始时返回活动未开始的静态页面数据。另一种是直接走异步阻塞队列把请求排队流量高峰过去后再处理。这两种方式都比把请求硬打到数据库上要安全得多。降级开关要用配置中心动态下发不能依赖重启。开关的命名和熔断逻辑也要提前预演真到出事的时候再去改代码就晚了。7. 实战实录一次线上缓存击穿事故的排查全过程讲了这么多理论我分享一个我自己遇到过的真实案例。这套排查思路比任何方案都值得参考。7.1 事故现场数据库 CPU 飙升与 Redis 超时事故发生在一次大促预热期的晚上。监控突然报警核心数据库的 CPU 使用率从 20% 直接拉到 90% 以上慢查询数量急剧上升。与此同时服务日志里大量出现Redis command timed out的报错很多接口的响应时间从几十毫秒涨到三秒以上。看一眼业务当晚刚好有一个限量商品的预约活动上线预约按钮的入口挂着一个商品状态 Key过期时间是晚上八点整。活动八点开始晚上七点五十九分这个 Key 还在八点整瞬间过期所有用户同时刷新页面请求全部打到数据库去查这个商品的状态。7.2 排查路径从命中率到慢查询全程定位我当时的排查顺序是这样的。先看 Redis 的命中率。INFO stats 里的 keyspace_hits 和 keyspace_misses 比值在事件发生后迅速下降说明缓存层出现了大规模未命中。然后用 MONITOR 跑了一段时间发现某个商品状态 Key 的 GET 请求量呈指数级增长而这个 Key 已经不在了。接着看数据库侧慢查询日志里面 90% 以上的 SQL 都是同一个查询查商品状态的单条记录。到这里基本可以断定是缓存击穿——一个热点 Key 失效并发集中打到数据库。最后看服务日志确认了 Redis 超时是连锁反应因为 Redis 连接池里堆积了大量等待请求连接被占满后新请求自然超时。7.3 修复动作与效果复盘临时止血动作是把该商品的缓存 Key 手动重建并设置一个小时的过期时间同时把活动状态数据改成永不过期 主动刷新的方式刷新任务放在后台每 10 秒执行一次。后续复盘时我在这个链路上补了完整组合本地缓存前置了 30 秒的 CaffeineRedis 层用逻辑过期方案异步重建的线程池单独隔离。经过改造后这个热点 Key 的读取 P99 从 3 秒降回 50ms数据库的 CPU 高峰再没出现过。这次事故给我的教训很深刻凡是能提前预知的流量高峰缓存方案一定不能只依赖被动过期 自动回填这一条路。大促、秒杀这类活动热点 Key 的缓存策略要提前一两天就定好而不是等活动开始了再观察。8. 避坑手册这些细节不处理好防护形同虚设方案落地时细节决定成败。以下几个坑都是我实际踩过或者帮别人排查过的整理成一份速查手册。8.1 锁的自动过期和误删问题互斥锁方案的锁过期时间是一个两难选择。时间太短任务还没执行完锁就没了第二个线程进来重复重建时间太长持锁线程异常后其他线程要等很久。我的解决办法是在持锁线程里主动实现锁续期每隔 1 秒检查一下任务是否还在执行在执行就重新设置过期时间。这个逻辑 Redisson 的 watchdog 已经内置了如果你不想手写直接用 Redisson 的 lock 也是可以的。误删问题在 3.2 节提过核心是释放锁前比较 requestId。即使是这样如果锁自动过期后旧线程才释放它可能会删掉新线程的锁。最稳妥的方案是使用 Lua 脚本完成比较加删除的原子操作。String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end;8.2 逻辑过期与真实过期的边界管理逻辑过期方案里cacheKey 不设置物理过期时间一旦发生缓存淘汰CacheItem 会直接被删掉逻辑过期的保护就失效了。所以要把物理过期时间设置得足够长同时监控内存淘汰量。另外要注意如果业务数据真的过期了、不能再返回旧值那逻辑过期方案就不适用。比如支付状态的校验坚决不能返回旧数据。这种场景老老实实用互斥锁或直接禁止缓存别为了性能牺牲正确性。8.3 热点预热把事故扼杀在活动开始前热点 Key 的缓存一定要预热这一点无论用哪种防护策略都不能省。预热的时机选择很关键。对于活动类业务至少提前 30 分钟把热点数据写入缓存并且把过期时间设置在活动结束之后。我见过一个项目把预热时间定在活动开始前 1 分钟结果预热任务还没跑完流量就上来了预热线程和业务线程争抢资源反而帮了倒忙。预热的数据源可以用离线导出的数据文件也可以直接从数据库读取。注意预热时要控制并发不要一次性把所有热点 Key 的数据全部并发写入 Redis那会造成 Redis 本身的 CPU 压力。8.4 监控指标与告警阈值参考最后给一套我常用的监控与告警标准供你配置时参考。缓存命中率整体命中率低于 90% 时告警热点 Key 命中率低于 99% 时告警。这个阈值在高并发场景下并不苛刻因为热点 Key 的命中率本来就该极高。Redis 命令耗时P99 超过 10ms 时关注超过 50ms 时告警。数据库慢查询慢查询数量和耗时突然翻倍时优先检查是否存在缓存失效。异步线程池活跃线程数、队列长度超过 80% 容量时告警防止逻辑过期方案在重建任务积压时失效。这套指标体系不需要一开始配得很全先把命中率、慢查询和线程池这三个核心项配好已经能覆盖大部分击穿场景。写到这里我想起第一次处理击穿事故时的手忙脚乱。现在回头看缓存击穿防护并没有多高深核心就四个字错峰重建。不管是互斥锁、逻辑过期还是永不过期主动刷新本质都是在想方设法把缓存重建这件事从高并发请求链路里剥离出去让数据库承受的压力从瞬时峰值变成平滑的常规负载。如果你正在为线上热点数据不稳定发愁我建议不要急着追求复杂的框架先把自己业务里最热的那三五个 Key 挑出来把逻辑过期和本地缓存这两层配上压测验证一下效果再逐步扩展。稳定的系统不是靠一个完美方案堆出来的是靠一层一层兜底兜出来的。
返回列表