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,但理解了这条链路,就知道哪些数据天然有延迟、哪些"保存成功"其实是"最终会成功",页面状态和提示才能做得诚实又顺手。这种对后端链路的体感,往往比多写两个组件更值钱。