缓存击穿热点数据过期
1.1 场景还原:秒杀开始的瞬间发生了什么?
场景:双十一 0 点,某爆款手机降价到 999 元。
23:59:59 → 缓存里有这台手机的数据,一切正常
00:00:00 → 缓存刚好过期!key 被 Redis 删了
00:00:00.001 → 第一波 5000 个用户同时点进商品详情页
每个请求:
→ Redis:key 不存在(刚过期)
→ 5000 个请求全部涌向 MySQL 查同一行数据
→ MySQL:SELECT * FROM products WHERE id = 100
→ 这条 SQL 被同时执行了 5000 次!
→ 数据库 CPU 飙到 100%,其他正常请求全被卡住
这就是击穿——不是不知道怎么做,是缓存刚好在那 0.1 秒没了。
类比——演唱会入场:
检票口本来有 10 个闸机(缓存),10000 人有序入场。
突然 9 个闸机同时坏了(缓存过期)→ 只剩 1 个。
10000 人挤向唯一那个闸机(数据库)→ 闸机被冲垮。
一句话定义:缓存击穿 = 一个热点 key 在过期瞬间,大量并发请求同时打到数据库。
1.2 和穿透的区别——最爱混淆你
穿透:数据"从来就不存在"→ 查多少次都不会有 → 问题出在"数据库里没有这条数据"
击穿:数据"存在但缓存刚好没了"→ 查一次就有了 → 问题出在"缓存过期时间点"
穿透的攻击者是"问不存在的东西"(不管缓存有没有,反正数据库没有)
击穿的攻击者是"同时问同一个存在的东西"(缓存有就没事,缓存刚好没了就出事)
1.3 解法一:互斥锁——“你们等着,我去拿,拿回来大家共享”
核心思路:缓存未命中时,只有第一个请求去查数据库并写回缓存,其他请求排队等着(自旋重试查 Redis),等缓存写好了一起从 Redis 拿。
改造后的流程(互斥锁):
═══════════════════════════════════════════════════════════
请求1 到达 → Redis 没命中 → 尝试拿锁(SETNX lock:product:100 "1")
→ 拿到锁了!✅ → 去查 MySQL → 写回 Redis → 释放锁
请求2 到达 → Redis 没命中 → 尝试拿锁 → 锁被请求1占着 ❌
→ 睡 0.1 秒 → 再查 Redis → 请求1已经写好了!
→ 命中!✅ → 返回(没碰数据库)
请求3 到达 → Redis 没命中 → 尝试拿锁 → 锁被占 ❌
→ 睡 0.1 秒 → 查 Redis → 命中 ✅ → 返回
5000 个请求 → 只有 1 个查了 MySQL!剩下 4999 个都是从 Redis 拿的。
类比——全班同学同时问老师同一个问题:
老师(数据库):"你们别一起问!班长(拿到锁的请求)过来,我告诉他答案。
班长回去写在黑板上(写缓存),其他人看黑板(查 Redis)。
谁再问黑板上有!"
1.3.1 互斥锁代码——Python 完整实现
"""
缓冲击穿保护:互斥锁模式
核心逻辑:
① 查 Redis → 命中 → 返回
② 未命中 → 尝试拿锁(SETNX)
③ 拿到锁 → 查 MySQL → 写 Redis → 释放锁 → 返回
④ 没拿到锁 → 等一小会 → 重试(回到第①步)
"""
import json
import time
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_product_with_lock(product_id: int, retry_times: int = 10) -> dict | None:
"""查商品:带互斥锁的缓存击穿保护"""
cache_key = f"product:{product_id}"
lock_key = f"lock:product:{product_id}"
# ===== 第 1 步:查 Redis =====
for _ in range(retry_times):
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# ===== 第 2 步:没命中 → 尝试拿锁 =====
# SETNX 的特性:只有 key 不存在时才能 set 成功
# 所以 5000 个请求同时 SETNX → 只有 1 个成功(拿到锁)
if r.setnx(lock_key, "1"):
# 拿到锁了!给锁设个过期时间(防死锁——万一程序崩了,锁 10 秒后自动释放)
r.expire(lock_key, 10)
try:
# ===== 第 3 步:查 MySQL =====
# ⚠️ 拿到锁后,再查一次 Redis(双重检查)
# 因为可能在你拿锁的过程中,别人已经查完并写回 Redis 了
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 确认 Redis 真没有 → 查 MySQL
product = db.query(
"SELECT * FROM products WHERE id = %s", product_id
)
if product:
# 写回 Redis(正常过期时间)
r.setex(cache_key, 3600, json.dumps(product))
return product
finally:
# ===== 第 4 步:释放锁 =====
# finally 保证不管前面是否抛异常,锁一定会释放
r.delete(lock_key)
# ===== 没拿到锁 → 等一小会 → 重试 =====
# 这段时间拿锁的人正在查 MySQL 写缓存,等他写好了我们就能从 Redis 拿到
time.sleep(0.1)
# 重试 N 次还是拿不到 → 可能出问题了,返回 None 或降级数据
return None
1.3.2 互斥锁的死锁问题——为什么锁必须有超时时间
没设锁超时时间的惨案:
═══════════════════════════════════════════════════════════
请求1:拿到锁 → 查 MySQL
→ MySQL 查询卡住了(网络抖动 / SQL 太慢)
→ 请求1 被阻塞,锁没释放
请求2~5000:拿不到锁 → 等 0.1 秒 → 重试 → 还是没锁 → 等 0.1 秒 → ...
→ 全卡住了!整个功能废了!
如果设了锁超时(10 秒):
请求1 卡住 10 秒 → Redis 自动把锁 key 过期删除 → 锁释放
→ 请求2 拿到新锁 → 去查 MySQL → 正常流程恢复
但这又引入新问题——请求1 的 MySQL 查询 15 秒后才返回:
锁已经在 10 秒时过期释放 → 请求2 拿到了锁 → 也去查 MySQL
→ 两个人同时在查 MySQL!互斥失败了!
这就是"锁超时太短"和"锁超时太长"的权衡——
太短:挡不住重查询
太长:死锁时恢复太慢
工程上一般设 10~30 秒,够大多数查询完成,同时不会让系统卡太久。
一句话讲清:“互斥锁不是银弹。高并发下锁竞争本身也消耗 Redis 性能。更优雅的解法是逻辑过期——缓存物理上永不过期,用后台异步更新代替主动查库。“
1.4 解法二:逻辑过期——“数据永远在 Redis 里,内容旧了偷偷换”
核心思路:热点 key 不设物理过期时间(不设 EXPIRE)。在 value 里藏一个”逻辑过期时间戳”,读的时候自己判断——数据旧了就先用旧的返回给用户(不阻塞),然后后台开一个任务去更新缓存。
传统方案(物理过期):
═══════════════════════════════════════════════════════════
Redis key: product:100
Value: '{"name":"iPhone","price":6999}'
TTL: 3600 ← 1 小时后这个 key 就被 Redis 删了!
1 小时后 → Redis 删 key → 下一个请求发现 key 没了 → 击穿!
逻辑过期方案:
═══════════════════════════════════════════════════════════
Redis key: product:100
Value: '{
"data": {"name":"iPhone","price":6999},
"expire_at": 1719100000
}'
TTL: -1(永不过期!)← key 永远不会被 Redis 删掉!
读的时候:
① 拿到 value → 检查 expire_at
② 没过期 → 直接返回 data ✅
③ 过期了 → 先返回旧的 data(不阻塞用户!)
→ 尝试获取互斥锁
→ 拿到锁 → 开一个后台任务去查 MySQL 更新缓存
→ 没拿到锁 → 说明别人在更新了,直接用旧数据
关键点:用户永远拿到的是"最快的响应"——哪怕数据旧了几秒。
99% 的业务场景下,用户根本不在意数据是不是 3 秒前的最新版。
"""
逻辑过期时间方案——缓存击穿的工程最佳实践
核心设计:
① key 物理上永不过期(不设 EXPIRE)
② value 里存 data + expire_at(逻辑过期时间)
③ 逻辑过期 → 先返回旧数据 → 后台异步更新
④ 互斥锁保证同时只有一个人在更新
"""
import json
import time
import threading
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_product_logical_expire(product_id: int) -> dict | None:
"""查商品:逻辑过期方案——不会发生击穿"""
cache_key = f"product:{product_id}"
lock_key = f"lock:product:{product_id}"
# ===== ① 读缓存 =====
raw = r.get(cache_key)
if raw is None:
# 缓存里完全没有(冷数据,没人访问过)
# → 走正常的"先查 Redis → 查 MySQL → 写 Redis"流程
return get_product_from_db_with_cache(product_id)
# ===== ② 解析 value → 拿到数据和逻辑过期时间 =====
payload = json.loads(raw)
data = payload["data"]
expire_at = payload["expire_at"]
# ===== ③ 判断是否逻辑过期 =====
if time.time() < expire_at:
# 没过期 → 直接返回
return data
# ===== ④ 逻辑过期了 → 先返回旧数据,异步更新 =====
# 尝试获取互斥锁(和 2.3 的锁一样的作用——保证只有一个人去查 MySQL)
if r.setnx(lock_key, "1"):
r.expire(lock_key, 10)
# 拿到锁 → 开新线程去更新(主线程立刻返回旧数据,不阻塞)
t = threading.Thread(
target=_refresh_cache,
args=(product_id, cache_key, lock_key)
)
t.start()
# 不管拿没拿到锁,都返回旧数据
# 这是这个方案的核心——用户体验不因缓存过期而变差
return data
def _refresh_cache(product_id: int, cache_key: str, lock_key: str):
"""后台线程:查 MySQL → 更新 Redis 缓存"""
try:
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
if product:
payload = {
"data": product, # 真实数据
"expire_at": time.time() + 3600, # 逻辑过期时间 = 当前 + 1 小时
}
# 物理上永不过期!不设 EXPIRE
r.set(cache_key, json.dumps(payload))
finally:
# 释放锁
r.delete(lock_key)
def get_product_from_db_with_cache(product_id: int) -> dict | None:
"""冷数据的普通缓存流程(兜底用)"""
cache_key = f"product:{product_id}"
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
if product:
payload = {
"data": product,
"expire_at": time.time() + 3600,
}
r.set(cache_key, json.dumps(payload)) # 物理永不过期
return product
# ===== 系统启动时的预热——非常重要!=====
def pre_warm_cache():
"""系统启动时:把所有热点数据预加载到 Redis"""
hot_products = db.query(
"SELECT * FROM products WHERE is_hot = 1"
)
for product in hot_products:
cache_key = f"product:{product['id']}"
payload = {
"data": product,
"expire_at": time.time() + 3600,
}
r.set(cache_key, json.dumps(payload)) # 物理永不过期
逻辑过期方案的几个关键问题——会被追问
Q1:为什么逻辑过期 + 逻辑过期时间放在 value 里,而不是用 Redis 的 EXPIRE?
因为 Redis 的 EXPIRE 到期后 key 就真没了——下一个请求发现 key 没了 → 击穿。
逻辑过期的 key 永远在 Redis 里——不会出现"key 突然消失"的情况。
所有请求永远能从 Redis 拿到数据(哪怕是"旧了点"的数据),数据库不会被打。
Q2:逻辑过期返回旧数据——用户不会看到旧数据吗?
会。但 99% 的业务场景下,3 秒前的数据和现在的数据对用户来说没区别——
用户看不出来商品价格 3 秒前是 999 还是 998。
如果业务要求 100% 实时一致性(比如余额查询、库存扣减)→ 这些数据不应该用缓存,直接查 MySQL。
缓存本质是"用一致性换性能"。能接受几秒延迟的业务用缓存,不能接受的别用缓存。
Q3:为什么要开线程异步更新而不是同步等?
同步更新 = 用户请求必须等到 MySQL 返回 + Redis 写完成 → 这一个请求会很慢(几百 ms)
异步更新 = 用户请求直接拿旧数据返回(< 1ms),更新在后台悄悄进行
→ 用户体验完全不受影响
用户的体验是最重要的——不能因为缓存过期就让他等。
1.5 击穿三种解法对比
graph LR subgraph "互斥锁" A1["原理:缓存没了 → 抢锁去查 DB"] A2["用户体验:第一个请求要等 DB(慢)"] A3["实现复杂度:简单,几行代码"] A4["数据实时性:实时"] A5["并发保护:✅ 只有一个人查 DB"] A6["额外组件:不需要"] A7["适用场景:非热点数据、偶尔过期的 key"] end subgraph "逻辑过期" B1["原理:缓存永远在 → 旧了后台换"] B2["用户体验:永远快(返回旧数据)"] B3["实现复杂度:中等,需要异步 + 锁 + 预热"] B4["数据实时性:有延迟(几秒)"] B5["并发保护:✅ 只有一个人查 DB"] B6["额外组件:需要预热逻辑"] B7["适用场景:🔴 热点数据(推荐)"] end subgraph "物理永不过期 + 定时刷新" C1["原理:缓存永远在 → 定时任务批量换"] C2["用户体验:永远快(总是命中)"] C3["实现复杂度:最简单,但需要维护热点列表"] C4["数据实时性:有延迟(秒级)"] C5["并发保护:✅ 不查 DB"] C6["额外组件:需要定时任务"] C7["适用场景:热点固定且量小"] end
一句话讲清:“生产环境一般用逻辑过期方案。互斥锁在高并发下锁竞争激烈——几千个请求同时抢锁,虽然只有一个进 MySQL,但几千个人在 Redis 上反复 SETNX + Sleep + 重试,对 Redis 也是压力。逻辑过期直接从缓存返回,不存在锁竞争。唯一代价是数据有几秒延迟——大多数业务场景完全可接受。“
速记卡(面试闪卡)
Q1:一句话讲清「缓存击穿热点数据过期」到底是什么?
A:热点 key 过期瞬间并发打爆数据库。
Q2:击穿场景:过期秒杀 5000 并发 —— 怎么理解?
A:像演唱会 9 个闸机同时坏:双十一 0 点热点 key 刚好过期,5000 人同时涌向唯一闸机(数据库),SQL 被执行 5000 次、CPU 飙满。本质是 Cache Breakdown(缓存击穿)——数据存在但缓存刚好没了,和「数据从不存在」的穿透不同。
Q3:解法一:互斥锁只放一个查库 —— 怎么理解?
A:像全班问老师:班长(拿到锁的请求)去问答案写黑板,其他人排队看黑板。用 SETNX 抢锁(Mutex Lock),只有 1 个查 MySQL,其余自旋重试查 Redis;锁必须设超时防死锁。代价是高并发下锁竞争也耗 Redis。
Q4:解法二:逻辑过期永不物理删 —— 怎么理解?
A:像永远亮着的小黑板:key 物理永不过期(TTL=-1),value 里藏 expire_at,旧了先返回旧数据(不阻塞),后台开线程 Async Update(异步更新)去刷新。用户永远最快响应,代价是数据有几秒延迟——多数业务无所谓。
Q5:三解法:锁/逻辑过期/定时刷 —— 怎么理解?
A:像三种排队方案:互斥锁(简单、实时、但有锁竞争)、逻辑过期(推荐热点、永远快、有秒级延迟)、物理永不过期+定时刷新(最简单、需维护热点列表)。生产多用逻辑过期,再用系统启动 Pre-warm(预热)把热点提前载入。
Q6:核心速记主线有哪些?
-
击穿=热点过期并发打库
-
互斥锁 SETNX 只放一个
-
逻辑过期后台异步更新
-
生产推荐逻辑过期+预热
口诀
A:热点过期那一秒,5000 并发打数据库
互斥锁 SETNX,只放一个去查库
逻辑过期永不过,旧数据先返后台补
生产用逻辑过期,启动预热最稳妥