适用场景

商品详情、活动配置、首页聚合数据和权限策略等读取接口,通常会把查询结果缓存在 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

这里还有两个隐患:

  1. 不存在的数据不会写缓存,相同无效 ID 会持续穿透到数据库;
  2. 固定 TTL 会让同时预热的键集中失效。

给 TTL 增加随机抖动可以缓解缓存雪崩,但不能阻止单个热点键击穿。真正的核心是:同一时刻只允许一个执行者重建缓存,其他请求等待、返回旧值或快速失败。

方案一:互斥重建

适合允许短暂等待、数据库查询耗时可控,并且能够接受锁持有期间少量请求重试的场景。

处理流程如下:

  1. 读取业务缓存,命中则直接返回;
  2. 未命中时,用 SET lock_key token NX PX timeout 争抢重建权;
  3. 获得锁后再次读取缓存,避免前一个重建者已经完成写入;
  4. 仍未命中才查询数据库并写入缓存;
  5. 使用随机令牌校验所有权后删除锁;
  6. 未获得锁的请求进行有上限、带抖动的短暂等待,然后重读缓存。

可直接复用的 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 中完全没有该键:进入受保护的首次加载流程,不能无锁回源。

这种方式把用户请求与重建耗时解耦,可以显著稳定尾延迟,但必须解决三个问题:

  1. 明确允许数据陈旧多久,并在数据模型中记录 updated_at;
  2. 为长时间刷新失败设置告警,不能让旧值永久存在;
  3. 发布、删除或权限变更等强一致事件应主动失效或更新缓存,不能只等后台刷新。

涉及库存扣减、余额、授权判定等强一致数据时,不应把逻辑过期缓存当作最终事实来源。

如何选择修复方案

场景 推荐方案 主要代价
允许等待几百毫秒,必须返回较新数据 互斥重建 未获锁请求会短暂等待
读流量极高,可接受短暂旧数据 逻辑过期、旧值兜底 需要异步刷新和陈旧度监控
大量键同时过期 TTL 抖动、分批预热 不能单独解决单个热点键击穿
不存在 ID 被频繁查询 空值缓存或布隆过滤器 需要控制误判和空值 TTL
强一致数据 绕过普通缓存或使用专门一致性设计 延迟和实现成本更高

互斥锁、TTL 抖动和空值缓存解决的是不同问题,实际系统通常需要组合使用。

验证修复是否有效

不要只做一次串行请求。测试应覆盖缓存过期瞬间的并发行为:

  1. 预置一个即将过期的热点键;
  2. 使用屏障让 100 个并发请求在键过期后同时开始;
  3. 给数据库加载函数增加可计数的测试替身;
  4. 断言加载函数只执行一次,其余请求从重建后的缓存读取;
  5. 模拟加载超时、加载异常和进程在获锁后退出;
  6. 验证锁最终自动过期,等待请求在预算内结束;
  7. 验证空结果只缓存短时间,后续新增数据能够被重新读取;
  8. 验证 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 抖动、空值缓存、回源限流和明确的超时预算,才能把一次普通的缓存失效限制在缓存层,而不是演变为数据库和整条调用链的级联故障。