适用场景
商品详情、活动配置、首页聚合数据和权限策略等读取接口,通常会把查询结果缓存在 Redis 中。平时数据库负载很低,一旦某个热点键过期,大量并发请求却可能同时回源,几百毫秒内把连接池占满,最终形成接口超时、重试放大和数据库雪崩。
本文讨论的是单个或少量热点键失效导致的缓存击穿。如果大量键在同一时间过期,属于缓存雪崩;如果请求的键本来就不存在,属于缓存穿透。三者现象相似,但修复手段不同,排查时不能混为一谈。
现象描述
典型故障会同时出现以下信号:
- Redis 命中率在某一秒明显下降,随后很快恢复;
- 数据库 QPS、活跃连接数和慢查询同时出现尖峰;
- 大量请求查询相同的业务 ID,SQL 文本和参数高度集中;
- 应用线程或协程等待数据库连接,接口 P99 延迟突然升高;
- 客户端、网关或服务内部重试让回源流量继续放大;
- Redis 本身延迟正常,说明瓶颈不是缓存服务不可用。
缓存击穿通常持续时间不长,却可能造成很大影响。假设热点接口稳定承受 3000 QPS,数据库重建缓存需要 200 毫秒,那么一个键过期后,理论上可能有约 600 个请求同时回源。连接池即使只有 100 个连接,也会迅速进入排队。
先确认是不是热点键击穿
1. 对齐三条时间线
把应用、Redis 和数据库指标按秒级时间对齐:
- 应用:请求量、P95/P99、超时数、重试次数、连接池等待时间;
- Redis:命中率、
keyspace_hits、keyspace_misses、命令延迟; - 数据库:QPS、活跃连接、锁等待、慢查询和 CPU。
如果缓存 miss、同一查询的数据库调用和接口延迟在同一时刻上升,而 Redis 延迟没有异常,基本可以把方向收敛到缓存失效后的并发回源。
不要只看一分钟平均值。击穿可能在两三秒内完成,一分钟聚合会把尖峰抹平。
2. 检查键的剩余时间和写入方式
在确认键名不包含敏感信息后,可检查热点键:
redis-cli --raw TTL 'product:detail:10086'
redis-cli --raw TYPE 'product:detail:10086'
redis-cli --raw MEMORY USAGE 'product:detail:10086'
TTL 的结果需要这样理解:
- 正数:剩余过期秒数;
-1:键存在但没有过期时间;-2:键不存在。
重点核对写缓存是否使用固定 TTL,以及批量预热时是否让同类键在同一秒写入。如果数万个键都设置为精确的 1800 秒,它们会在半小时后集中失效。
生产环境不要使用 KEYS product:* 枚举键,它会阻塞 Redis 主线程。需要抽样时使用小批次 SCAN,并控制执行频率。
3. 用业务维度确认回源是否集中
应用日志应记录稳定的机器可读字段,例如:
消息:缓存未命中,准备回源
cache_key_hash=8f3c... resource_type=product resource_id=10086
cache_result=miss rebuild_result=lock_busy duration_ms=7 trace_id=...
日志不要包含完整用户信息、Cookie、令牌或未经脱敏的缓存值。指标也不要把 resource_id 或缓存键作为标签,否则会制造高基数;热点 ID 应通过受控日志或采样追踪定位。
常见错误实现
下面的“先读缓存,未命中就查库”在低并发下没有问题,但热点键过期后每个请求都会执行查询:
def get_product(product_id: int) -> dict[str, object] | None:
"""读取商品详情。"""
cache_key = f"product:detail:{product_id}"
cached = redis_client.get(cache_key)
if cached is not None:
return decode_product(cached)
product = product_repository.find_by_id(product_id)
if product is not None:
redis_client.set(cache_key, encode_product(product), ex=1800)
return product
这里还有两个隐患:
- 不存在的数据不会写缓存,相同无效 ID 会持续穿透到数据库;
- 固定 TTL 会让同时预热的键集中失效。
给 TTL 增加随机抖动可以缓解缓存雪崩,但不能阻止单个热点键击穿。真正的核心是:同一时刻只允许一个执行者重建缓存,其他请求等待、返回旧值或快速失败。
方案一:互斥重建
适合允许短暂等待、数据库查询耗时可控,并且能够接受锁持有期间少量请求重试的场景。
处理流程如下:
- 读取业务缓存,命中则直接返回;
- 未命中时,用
SET lock_key token NX PX timeout争抢重建权; - 获得锁后再次读取缓存,避免前一个重建者已经完成写入;
- 仍未命中才查询数据库并写入缓存;
- 使用随机令牌校验所有权后删除锁;
- 未获得锁的请求进行有上限、带抖动的短暂等待,然后重读缓存。
可直接复用的 Python 实现
from __future__ import annotations
import json
import random
import secrets
import time
from collections.abc import Callable
from typing import TypeVar
from redis import Redis
T = TypeVar("T")
NULL_SENTINEL = "__NULL__"
UNLOCK_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
class CacheBusyError(RuntimeError):
"""表示热点缓存正在重建且等待预算已耗尽。"""
def load_with_mutex(
redis_client: Redis,
cache_key: str,
loader: Callable[[], T | None],
*,
ttl_seconds: int = 1800,
wait_budget_seconds: float = 0.8,
) -> T | None:
"""使用短租约锁保护热点缓存重建。"""
cached = redis_client.get(cache_key)
if cached is not None:
return _decode(cached)
lock_key = f"{cache_key}:rebuild-lock"
lock_token = secrets.token_hex(16)
lock_ttl_ms = 5000
acquired = redis_client.set(
lock_key,
lock_token,
nx=True,
px=lock_ttl_ms,
)
if acquired:
try:
cached = redis_client.get(cache_key)
if cached is not None:
return _decode(cached)
value = loader()
if value is None:
redis_client.set(cache_key, NULL_SENTINEL, ex=60)
return None
jitter = random.randint(0, max(1, ttl_seconds // 10))
redis_client.set(
cache_key,
json.dumps(value, ensure_ascii=False),
ex=ttl_seconds + jitter,
)
return value
finally:
redis_client.eval(UNLOCK_SCRIPT, 1, lock_key, lock_token)
deadline = time.monotonic() + wait_budget_seconds
while time.monotonic() < deadline:
time.sleep(random.uniform(0.02, 0.06))
cached = redis_client.get(cache_key)
if cached is not None:
return _decode(cached)
raise CacheBusyError("热点缓存重建超出等待预算")
def _decode(raw_value: bytes | str) -> object | None:
"""解析缓存值并识别空值哨兵。"""
if isinstance(raw_value, bytes):
raw_value = raw_value.decode("utf-8")
if raw_value == NULL_SENTINEL:
return None
return json.loads(raw_value)
实现中的关键点:
NX保证只有键不存在时才能加锁,PX给锁设置毫秒级租约,避免进程崩溃后永久占锁;- 锁值使用不可预测的随机令牌,释放时通过 Lua 原子比较并删除,不能直接
DEL; - 获锁后的二次读取是必要的,否则排队请求可能重复查询数据库;
time.monotonic()不受系统时间回拨影响,适合计算等待预算;- 空值使用短 TTL 哨兵,减少不存在 ID 的重复查询;
- 正常值 TTL 增加最多 10% 的随机抖动,分散批量键的过期时间;
- 等待必须有上限,预算耗尽后由接口层快速失败或返回降级数据,不能无限阻塞。
锁的租约必须大于缓存重建的高分位耗时,并留出网络抖动余量。如果查询可能超过租约,优先缩短查询、拆分数据或采用受控续租;不要简单把锁设成几分钟,否则故障恢复会变慢。
方案二:逻辑过期并返回旧值
对于首页配置、榜单和商品展示等“宁可短暂陈旧,也不能高延迟”的数据,可以让 Redis 键本身不过期,在值中保存业务过期时间:
{
"expire_at": 1790823600,
"value": {
"id": 10086,
"name": "示例商品"
}
}
读取流程是:
expire_at尚未到达:直接返回缓存值;- 已逻辑过期且抢到重建锁:触发后台刷新,本次仍返回旧值;
- 已逻辑过期但没有抢到锁:直接返回旧值,不等待数据库;
- Redis 中完全没有该键:进入受保护的首次加载流程,不能无锁回源。
这种方式把用户请求与重建耗时解耦,可以显著稳定尾延迟,但必须解决三个问题:
- 明确允许数据陈旧多久,并在数据模型中记录
updated_at; - 为长时间刷新失败设置告警,不能让旧值永久存在;
- 发布、删除或权限变更等强一致事件应主动失效或更新缓存,不能只等后台刷新。
涉及库存扣减、余额、授权判定等强一致数据时,不应把逻辑过期缓存当作最终事实来源。
如何选择修复方案
| 场景 | 推荐方案 | 主要代价 |
|---|---|---|
| 允许等待几百毫秒,必须返回较新数据 | 互斥重建 | 未获锁请求会短暂等待 |
| 读流量极高,可接受短暂旧数据 | 逻辑过期、旧值兜底 | 需要异步刷新和陈旧度监控 |
| 大量键同时过期 | TTL 抖动、分批预热 | 不能单独解决单个热点键击穿 |
| 不存在 ID 被频繁查询 | 空值缓存或布隆过滤器 | 需要控制误判和空值 TTL |
| 强一致数据 | 绕过普通缓存或使用专门一致性设计 | 延迟和实现成本更高 |
互斥锁、TTL 抖动和空值缓存解决的是不同问题,实际系统通常需要组合使用。
验证修复是否有效
不要只做一次串行请求。测试应覆盖缓存过期瞬间的并发行为:
- 预置一个即将过期的热点键;
- 使用屏障让 100 个并发请求在键过期后同时开始;
- 给数据库加载函数增加可计数的测试替身;
- 断言加载函数只执行一次,其余请求从重建后的缓存读取;
- 模拟加载超时、加载异常和进程在获锁后退出;
- 验证锁最终自动过期,等待请求在预算内结束;
- 验证空结果只缓存短时间,后续新增数据能够被重新读取;
- 验证 Redis 暂时不可用时,限流和降级不会把全部流量放给数据库。
上线时先对少量热点资源灰度,观察以下指标:
cache_requests_total{result="hit|miss|stale"};cache_rebuild_total{result="success|error|lock_busy"};cache_rebuild_duration_seconds;cache_rebuild_wait_duration_seconds;- 数据库连接池等待时间和同类 SQL 的每秒执行次数。
缓存键、资源 ID 和异常堆栈适合进入采样日志,不适合直接作为指标标签。
常见误区
用 SETNX 加锁后再单独设置过期时间
如果进程在两条命令之间崩溃,锁会永久存在。应使用一条 SET key token NX PX timeout 原子命令。
释放锁时直接执行 DEL
当旧锁租约已过期、其他进程拿到新锁后,旧进程可能误删新锁。释放时必须原子比较随机令牌与当前锁值。
未获锁的请求立即访问数据库
这等于没有做互斥保护。未获锁请求应该重读缓存、返回旧值、受控等待或快速降级。
所有异常都回退到数据库
Redis 故障时让每个请求直接查库,会把局部故障扩散成数据库雪崩。回退路径也必须有并发上限、超时、熔断和整体请求预算。
把分布式锁当成业务一致性保证
这里的锁只用于减少重复计算。涉及库存、账务或状态流转时,最终一致性仍应由数据库事务、唯一约束或版本条件保证。
预防措施
- 热点键的 TTL 增加随机抖动,批量预热按分片错峰执行;
- 在缓存未命中路径设置单键并发保护和全局回源并发上限;
- 为数据库查询、锁等待和整个 HTTP 请求分别设置合理预算;
- 禁止无上限自动重试,重试需采用指数退避并加入随机抖动;
- 对允许陈旧的数据保留最近一次成功值,刷新失败时优先降级;
- 对删除、权限和价格等关键变更建立主动失效机制;
- 压测必须包含缓存冷启动、热点键过期和 Redis 故障场景;
- 告警同时观察缓存 miss、重建失败和数据库连接池等待,避免只盯命中率。
总结
热点缓存击穿的本质不是“Redis 过期了”,而是缓存失效后缺少并发控制,导致同一份数据被同时重建。排查时应把缓存 miss、相同 SQL 的突刺和连接池等待按秒对齐,先证明流量是否集中到同一个业务资源。
互斥重建适合要求数据较新的场景,逻辑过期适合可接受短暂旧值且更关注尾延迟的场景。再配合 TTL 抖动、空值缓存、回源限流和明确的超时预算,才能把一次普通的缓存失效限制在缓存层,而不是演变为数据库和整条调用链的级联故障。
Discussion
评论