Redis 缓存基础:缓存没命中时,系统会沿哪条链路失控
缓存最大的诱惑,是它能把一个慢接口瞬间拉快;最大的代价,是你从此不只要面对“查得慢”,还要面对“查不到”“查不一致”“一失效就一起雪崩”。
缓存穿透、击穿、雪崩这些词听起来像一串事故术语,背后其实都在回答同一件事:当 Redis 没命中、热点 key 失效、或者大批 key 同时过期时,请求会怎样重新压回数据库。只有把这条链看明白,才知道缓存到底是在救系统,还是在把风险往后挪。
后面会从一次数据库尖刺对应的缓存事故往下拆,把 Redis 在业务链路里真正承担的角色和它带来的新问题一起讲清楚。
先理清缓存这条链路到底在干嘛
商品详情页背后不是一张表。商品表、店铺表、类目表、销量计数表,一个详情接口背后是四五次 join,不加缓存的话平均响应 800ms,大促高峰能飙到 2s 以上。加了 Redis 之后,p99 掉到 60ms。后端负责人说他当年第一次看到这个数字,一度以为缓存是银弹——直到后来发现,加缓存只是把"查得慢"的问题,换成了"查得不对、查得不一致、某一刻全部失效"的一堆新问题。这次双十一就是"新问题"的集中爆发。
缓存的本质是拿空间换时间,拿一致性换性能。读请求的标准路径是这样的:
1请求数据 2 -> 查 Redis 3 -> 命中(cache hit)则直接返回 4 -> 未命中(cache miss)查数据库 5 -> 把结果写回 Redis 6 -> 返回
这套读模式有个正经名字叫 Cache-Aside(旁路缓存),是用得最多的一种。最朴素的实现长这样(我的 Node 笔记版):
1const Redis = require('ioredis') 2const redis = new Redis() 3 4async function getItem(id) { 5 const cacheKey = `item:${id}` 6 const cached = await redis.get(cacheKey) 7 8 if (cached) { 9 return JSON.parse(cached) 10 } 11 12 const item = await db.findItem(id) 13 await redis.set(cacheKey, JSON.stringify(item), 'EX', 600) 14 15 return item 16}
看着没问题,跑起来也确实快。但后半段重点都落在这版代码藏着的三个坑上:查不到的数据会反复打库(穿透)、热点 key 失效瞬间会被打穿(击穿)、一批 key 同时到期会引发雪崩。下面一个个说,最后是后端负责人他们现在线上真正在用的整合写法。
过期时间:不是随便填的参数
EX 600 表示 600 秒后过期。后端负责人说这个数字最早就是拍脑袋填的,后来吃过亏才认真起来。
过期时间得按数据的"可容忍陈旧度"来定:
- 商品标题、详情图文这类不常变的内容,缓存 10 分钟甚至 1 小时都行;
- 销量、浏览数这种高频变动又不要求精确的,单独缓存,给个短 TTL,比如 30 秒;
- 库存、价格、余额、权限这类一旦读错就出事故的,要么不缓存,要么 TTL 极短并配合主动失效。
他们犯过的错是把商品的所有字段塞进一个大对象一起缓存 10 分钟,结果大促预热期销量数字十分钟才动一次,运营盯着大屏以为活动没效果,直接杀到技术部来问。后来把"静态图文"和"动态计数"拆成两个 key、两套 TTL,才消停。
另一位后端同事补了一句我抄在笔记本第一页:缓存不是越久越好,越久意味着脏数据存活越久。
事故主角:缓存穿透,查一个根本不存在的东西
回到双十一凌晨那根尖刺。穿透指的是请求的数据在数据库里压根不存在,所以缓存永远建立不起来,每次都 miss,每次都打到 DB。
当晚有脚本在循环刷详情接口,商品 id 全是编的:
1/item/-1 2/item/-2 3/item/999999999
这些 id 数据库里都没有,db.findItem(id) 返回 null,于是那版朴素代码连缓存都不写(因为查库结果是空的,没东西可缓存),每个请求都老老实实穿透到 MySQL。几千 QPS 的无效查询压过去,正常用户的下单查询跟着变慢,监控上就是那根尖刺。
后端负责人他们当晚先用限流顶住,事后补的是一套组合拳:
1. 入口参数校验,明显非法的直接挡掉:
1function isValidId(id) { 2 return Number.isInteger(id) && id > 0 3}
2. 缓存空值,查不到也写一个短 TTL 的空标记,避免反复回源:
1async function getItem(id) { 2 if (!isValidId(id)) return null 3 4 const cacheKey = `item:${id}` 5 const cached = await redis.get(cacheKey) 6 7 if (cached !== null) { 8 // 命中空值占位符,说明这条数据确实不存在 9 if (cached === '') return null 10 return JSON.parse(cached) 11 } 12 13 const item = await db.findItem(id) 14 15 if (!item) { 16 // 缓存空值,但 TTL 给短一点(60s),防止商品后来才上架却长时间读不到 17 await redis.set(cacheKey, '', 'EX', 60) 18 return null 19 } 20 21 await redis.set(cacheKey, JSON.stringify(item), 'EX', 600) 22 return item 23}
3. 布隆过滤器,如果 id 空间很大、缓存空值会撑爆内存,就在 Redis 前面加一个布隆过滤器,把"一定不存在"的请求直接拦掉。他们评估的是 RedisBloom 模块:
1BF.RESERVE items 0.001 1000000 # 误判率 0.1%,预估 100 万条 2BF.ADD items 12345 # 商品上架时同步写入 3BF.EXISTS items 999999999 # 查询前先问一下,返回 0 直接拒
布隆过滤器的取舍是:它可能误判"存在"(放进去一个其实没有的),但绝不会误判"不存在"。所以它说没有,就一定没有,可以放心拦截;它说有,再走正常缓存逻辑兜底。
会上后端负责人点名表扬了一句前端——事故当晚刷进来的请求里,从我们自己页面跳转产生的一个都没有,全是直连接口的脚本。因为列表页跳详情前,前端本来就校验过 id,异常的根本不发请求。能在浏览器挡掉的脏流量,没必要让它跑到后端,这道校验平时看着多余,那晚至少让排查少了一层干扰。
缓存击穿:一个热点 key 失效的瞬间
击穿是穿透的"单点高并发"版本:某个非常热的 key(比如首页主推的爆款商品)刚好过期的那一瞬间,成百上千个请求同时发现 miss,于是同时去查数据库、同时写缓存。数据库被这一波瞬时流量打得喘不过气。
后端负责人说他们第一次遇到是去年一个秒杀商品,缓存 TTL 到点的那一秒,监控上 DB 的 QPS 出现一根小尖刺——跟这次穿透的形状像,成因完全不同。解决思路是保证同一时刻只有一个请求去回源,其它请求等它。用 Redis 的 SET NX 做一个简易互斥锁:
1async function getItemWithLock(id) { 2 const cacheKey = `item:${id}` 3 const cached = await redis.get(cacheKey) 4 if (cached !== null) { 5 return cached === '' ? null : JSON.parse(cached) 6 } 7 8 const lockKey = `lock:item:${id}` 9 // NX:只有 key 不存在才设置成功;PX 10000:锁最多持有 10s,防止持锁进程挂了死锁 10 const locked = await redis.set(lockKey, '1', 'PX', 10000, 'NX') 11 12 if (locked) { 13 try { 14 const item = await db.findItem(id) 15 const value = item ? JSON.stringify(item) : '' 16 await redis.set(cacheKey, value, 'EX', item ? 600 : 60) 17 return item 18 } finally { 19 await redis.del(lockKey) 20 } 21 } else { 22 // 没抢到锁,说明别人正在回源,稍等一下再读缓存 23 await new Promise(r => setTimeout(r, 50)) 24 return getItemWithLock(id) 25 } 26}
除了加锁,还有两种思路看场景用:
- 热点 key 不设过期,改成由后台定时任务主动刷新,永远读到的是有效缓存;
- 逻辑过期:value 里存一个
expireAt字段,物理上永不过期,读到逻辑过期就异步触发一次刷新,把旧值返回给当前用户,避免任何人卡住。
首页 banner 位这种超级热点,他们现在用的就是逻辑过期,用户体验最平滑——没有人会因为缓存刷新而等待。
缓存雪崩:一大批 key 在同一秒集体阵亡
雪崩比击穿更吓人:不是一个 key,是一大片 key 在同一时间一起失效,瞬间所有读请求都穿到数据库。
后端负责人讲了个他们的历史事故,原因特别低级——某次大促前批量预热缓存,写了个循环把几万个商品一次性灌进 Redis,全都是 EX 600。结果 10 分钟后,这几万个 key 在同一秒钟集体过期,DB 直接被打挂。
根因是过期时间太整齐。修法是给 TTL 加一个随机扰动,把失效时间打散:
1function ttlWithJitter(base, jitter) { 2 // base 基础过期秒数,jitter 随机抖动上限 3 return base + Math.floor(Math.random() * jitter) 4} 5 6await redis.set(cacheKey, value, 'EX', ttlWithJitter(600, 120))
这样几万个 key 的过期时间被均匀打散在 600~720 秒之间,再也不会扎堆。今年双十一的预热脚本就是带抖动灌的,这一环平安无事。
雪崩的另一个来源是 Redis 整个挂了——这种就不是 TTL 能救的了。运维同事接着讲了他们的两层兜底:一是给 Redis 做主从加哨兵,避免单点;二是在代码里加熔断和限流,当缓存层不可用时,对 DB 的回源做并发上限控制,宁可一部分请求降级返回兜底数据,也不让数据库被瞬时洪峰冲垮。
数据一致性:这才是缓存最难的部分
前面几个问题都有相对标准的解法,唯独一致性,后端负责人的原话是"真没有完美方案"。核心矛盾是:数据库更新了,缓存里还是旧的,怎么办。
更新时常见的几种策略,他们都踩过:
先更新数据库,再删缓存(推荐,Cache-Aside 的标准做法)
1async function updateItem(id, data) { 2 await db.updateItem(id, data) 3 await redis.del(`item:${id}`) // 删除而不是更新,让下次读时重建 4}
为什么是删而不是改?因为如果两个请求并发更新,"写缓存"的顺序可能和"写库"的顺序错乱,导致缓存里留下旧值。删除则是幂等的,下次 miss 自然会读到最新的库数据。
这个方案仍有个极小概率的不一致窗口:读请求 miss 后查到旧库值,还没写缓存,写请求就更新了库并删了缓存,最后读请求把旧值写了回去。概率极低,但存在。
先删缓存,再更新数据库:并发下问题更明显——删完缓存还没更新库,另一个读请求就把旧值又缓存了进来,脏数据一直活到下次过期。一般不单独用。
延迟双删:先删缓存 → 更新库 → 等几百毫秒 → 再删一次缓存,用第二次删除清掉中间可能被读请求写回的脏值。
1async function updateItemDoubleDelete(id, data) { 2 await redis.del(`item:${id}`) 3 await db.updateItem(id, data) 4 setTimeout(() => redis.del(`item:${id}`), 500) 5}
消息/binlog 订阅刷新:用 canal 这类工具订阅 MySQL 的 binlog,库一变就发消息去删或刷缓存。一致性最好、对业务代码侵入最小,但要多维护一套中间件。另一位后端同事说这个明年看规模再上。
后端负责人的取舍原则我也抄下来了:**先问业务能接受多旧的数据。**商品图文晚几秒一致完全无所谓,"更新库 + 删缓存"就够;账户余额这种就别缓存,或者走强一致方案。别一上来就追求强一致,多数业务根本不需要,徒增复杂度。
一版他们现在线上在用的整合写法
把穿透(缓存空值)、击穿(互斥锁)、雪崩(TTL 抖动)合在一起,我照着后端负责人的 Java 版翻译成 Node 大概长这样:
1async function getItemSafe(id) { 2 if (!isValidId(id)) return null 3 4 const cacheKey = `item:${id}` 5 const cached = await redis.get(cacheKey) 6 if (cached !== null) { 7 return cached === '' ? null : JSON.parse(cached) 8 } 9 10 const lockKey = `lock:item:${id}` 11 const locked = await redis.set(lockKey, '1', 'PX', 10000, 'NX') 12 if (!locked) { 13 await new Promise(r => setTimeout(r, 50)) 14 return getItemSafe(id) 15 } 16 17 try { 18 // 拿到锁后再查一次缓存,避免在等锁期间别人已经填好了 19 const recheck = await redis.get(cacheKey) 20 if (recheck !== null) { 21 return recheck === '' ? null : JSON.parse(recheck) 22 } 23 24 const item = await db.findItem(id) 25 if (!item) { 26 await redis.set(cacheKey, '', 'EX', 60) // 空值短缓存防穿透 27 return null 28 } 29 await redis.set(cacheKey, JSON.stringify(item), 'EX', ttlWithJitter(600, 120)) // 抖动防雪崩 30 return item 31 } finally { 32 await redis.del(lockKey) 33 } 34}
后端负责人自己说这版不算完美——简易锁在 Redis 主从切换时仍有极端边界问题,严格场景要上 Redlock;但对商品详情这种读多写少的场景,它把今年双十一除了穿透之外的告警都摁住了,穿透补上空值缓存和布隆过滤器之后,链路就算闭环了。
前端能帮什么忙
最后一节是"各端行动项",前端也领了几条。整理笔记时我把它们和自己的理解合在一起:
第一是入口校验和防连点。id 校验前面说过了;防连点是指保存按钮的请求去重——用户狂点保存,后端就要承受一串写请求,每个写请求又触发一次删缓存,放大出一堆回源。这套去重去年封装 axios 的时候就做进拦截器了,这次算是从后端视角重新理解了它的价值。
第二是理解"保存成功"不等于"处处更新"。用户点保存后,列表接口可能因为缓存还没失效而短时间返回旧数据。我的做法通常两种:要么保存成功后前端做乐观更新,本地先把新值显示出来;要么对刚提交的数据强制带个跳过缓存的参数回源一次。提示语也别写"保存成功,数据已更新"这种把话说死的——上周消息队列那篇里"提交不等于完成"的教训,在缓存这里同样成立。
第三是我自己加的延伸:前端手里其实也有一整套缓存,道理相通。浏览器的 HTTP 缓存就是另一个 Cache-Aside——Cache-Control: max-age 对应 TTL,ETag/304 对应"回源校验",强缓存没过期就不发请求,跟 Redis 命中直接返回一模一样。想通这层对应关系之后,CDN 上静态资源为什么要带 hash 文件名(内容变了就换 key,天然不用操心失效)也就顺理成章了。再小一号的,中后台里那些省市区、类目这种字典接口,我会在 Vuex 里存一份内存缓存,页面切来切去不重复请求——它同样要回答"什么时候失效"这个问题,我的答案是跟着页面刷新走,简单粗暴但和数据的变化频率匹配。
散会之后
另一位后端同事散会时说了句玩笑话:Redis 快是快,但它把 MySQL 的命也攥在手里了。我觉得这话就是这场复盘的核心——缓存不是"让接口变快"这么简单的一行 redis.get,而是主动在系统里引入了一个会过期、会失效、会和数据库不一致的旁路状态,谁引入,谁就得为这个状态的所有异常负责。
穿透、击穿、雪崩、一致性,本质上都是在问同一件事——当缓存不在、缓存集体消失、缓存和真相对不上的时候,系统会怎样。这些词看着像名词解释,背后其实都是监控曲线上的尖刺。前端不直接维护 Redis,但理解了这条链路,就知道哪些数据天然有延迟、哪些"保存成功"其实是"最终会成功",页面状态和提示才能做得诚实又顺手。这种对后端链路的体感,往往比多写两个组件更值钱。