Redis的缓存雪崩、击穿、穿透和解决方案

一、缓存穿透的深度解析与防护体系

1.1 缓存穿透的本质与危害

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,导致缓存层无法发挥拦截作用,所有请求直接穿透至数据库层。这种场景在恶意攻击或业务异常时尤为突出。

产生原因

当用户发起查询请求时,系统首先查询Redis缓存。若缓存未命中,系统转向数据库查询。若数据库中也不存在该数据,传统实现会直接返回空结果,而不会在缓存中留下任何记录。此时,若恶意用户或异常程序持续发起相同请求,每次请求都会完整执行查缓存 -> 未命中 -> 查数据库 -> 返回空的全链路,缓存层形同虚设,数据库承受全部压力。

典型场景

  • 恶意攻击:攻击者通过脚本批量生成不存在的ID(如负数、超大数值、随机UUID),持续发起查询请求,意图拖垮数据库。
  • 业务异常:前端页面Bug或爬虫程序错误,导致重复查询已删除或从未创建的数据。
  • 数据迁移:系统重构或数据清理后,历史缓存Key对应的数据已被物理删除,但外部仍有旧链接访问。

1.2 解决方案一:缓存空对象

核心思路

对于数据库中不存在的数据,也在Redis中建立缓存记录,值为空值(null、空字符串或特殊标记),并设置较短的TTL时间。下次相同请求到达时,缓存命中空值,直接返回,避免重复查询数据库。

标准实现流程
#mermaid-svg-ynF5JtboeDqL90ze{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ynF5JtboeDqL90ze .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ynF5JtboeDqL90ze .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ynF5JtboeDqL90ze .error-icon{fill:#552222;}#mermaid-svg-ynF5JtboeDqL90ze .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ynF5JtboeDqL90ze .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ynF5JtboeDqL90ze .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ynF5JtboeDqL90ze .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ynF5JtboeDqL90ze .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ynF5JtboeDqL90ze .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ynF5JtboeDqL90ze .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ynF5JtboeDqL90ze .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ynF5JtboeDqL90ze .marker.cross{stroke:#333333;}#mermaid-svg-ynF5JtboeDqL90ze svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ynF5JtboeDqL90ze p{margin:0;}#mermaid-svg-ynF5JtboeDqL90ze .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ynF5JtboeDqL90ze .cluster-label text{fill:#333;}#mermaid-svg-ynF5JtboeDqL90ze .cluster-label span{color:#333;}#mermaid-svg-ynF5JtboeDqL90ze .cluster-label span p{background-color:transparent;}#mermaid-svg-ynF5JtboeDqL90ze .label text,#mermaid-svg-ynF5JtboeDqL90ze span{fill:#333;color:#333;}#mermaid-svg-ynF5JtboeDqL90ze .node rect,#mermaid-svg-ynF5JtboeDqL90ze .node circle,#mermaid-svg-ynF5JtboeDqL90ze .node ellipse,#mermaid-svg-ynF5JtboeDqL90ze .node polygon,#mermaid-svg-ynF5JtboeDqL90ze .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ynF5JtboeDqL90ze .rough-node .label text,#mermaid-svg-ynF5JtboeDqL90ze .node .label text,#mermaid-svg-ynF5JtboeDqL90ze .image-shape .label,#mermaid-svg-ynF5JtboeDqL90ze .icon-shape .label{text-anchor:middle;}#mermaid-svg-ynF5JtboeDqL90ze .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ynF5JtboeDqL90ze .rough-node .label,#mermaid-svg-ynF5JtboeDqL90ze .node .label,#mermaid-svg-ynF5JtboeDqL90ze .image-shape .label,#mermaid-svg-ynF5JtboeDqL90ze .icon-shape .label{text-align:center;}#mermaid-svg-ynF5JtboeDqL90ze .node.clickable{cursor:pointer;}#mermaid-svg-ynF5JtboeDqL90ze .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ynF5JtboeDqL90ze .arrowheadPath{fill:#333333;}#mermaid-svg-ynF5JtboeDqL90ze .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ynF5JtboeDqL90ze .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ynF5JtboeDqL90ze .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ynF5JtboeDqL90ze .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ynF5JtboeDqL90ze .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ynF5JtboeDqL90ze .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ynF5JtboeDqL90ze .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ynF5JtboeDqL90ze .cluster text{fill:#333;}#mermaid-svg-ynF5JtboeDqL90ze .cluster span{color:#333;}#mermaid-svg-ynF5JtboeDqL90ze div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ynF5JtboeDqL90ze .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ynF5JtboeDqL90ze rect.text{fill:none;stroke-width:0;}#mermaid-svg-ynF5JtboeDqL90ze .icon-shape,#mermaid-svg-ynF5JtboeDqL90ze .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ynF5JtboeDqL90ze .icon-shape p,#mermaid-svg-ynF5JtboeDqL90ze .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ynF5JtboeDqL90ze .icon-shape .label rect,#mermaid-svg-ynF5JtboeDqL90ze .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ynF5JtboeDqL90ze .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ynF5JtboeDqL90ze .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ynF5JtboeDqL90ze :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
是空值
非空值
未命中
存在
不存在
开始
提交商铺id
判断缓存

是否命中
判断是否

为空值
返回空结果
返回商铺信息
结束
根据id查询数据库
判断商铺

是否存在
将商铺数据

写入Redis
将空值写入Redis

设置短TTL

代码实现

python 复制代码
from fastapi import FastAPI, HTTPException
import redis.asyncio as aioredis
import json

app = FastAPI()
redis_client = aioredis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True)

CACHE_NULL_TTL = 300  # 空值缓存5分钟
CACHE_SHOP_TTL = 3600  # 正常数据缓存1小时

async def query_shop_from_db(shop_id: int) -> dict | None:
    # 模拟数据库查询
    mock_db = {1: {"id": 1, "name": "星巴克旗舰店", "type": 1}}
    return mock_db.get(shop_id)

@app.get("/shops/{shop_id}")
async def get_shop_with_null_cache(shop_id: int):
    cache_key = f"shop:{shop_id}"
    
    # 1. 查询缓存
    cached_data = await redis_client.get(cache_key)
    if cached_data is not None:
        # 2. 缓存命中,判断是否为空值
        if cached_data == "null":
            raise HTTPException(status_code=404, detail="Shop not found")
        return json.loads(cached_data)
    
    # 3. 缓存未命中,查询数据库
    shop = await query_shop_from_db(shop_id)
    
    if shop is None:
        # 4. 数据库也不存在,缓存空值并设置短TTL
        await redis_client.setex(cache_key, CACHE_NULL_TTL, "null")
        raise HTTPException(status_code=404, detail="Shop not found")
    
    # 5. 数据库存在,缓存正常数据并设置长TTL
    await redis_client.setex(cache_key, CACHE_SHOP_TTL, json.dumps(shop))
    return shop

优势

  • 实现简单:仅需在原有逻辑基础上增加空值判断与写入,代码改动极小。
  • 维护方便:无需引入外部依赖或复杂算法,运维成本低。
  • 即时生效:首次查询后,后续请求立即被缓存拦截,数据库压力迅速下降。

缺陷

  • 额外内存消耗:大量不存在的ID会产生大量空值缓存,占用Redis内存。若攻击者使用海量随机ID,可能导致内存快速增长。
  • 短期数据不一致:空值缓存的TTL窗口期内,若数据库中创建了该ID的数据,缓存仍会返回空值,直到TTL过期。例如,管理员在后台新建商铺ID=999,但前端用户因之前查询过该ID(当时不存在),在TTL窗口期内仍会看到404。
  • TTL设置困难:TTL过短,防护效果差;TTL过长,不一致窗口期长。需根据业务更新频率权衡。

1.3 解决方案二:布隆过滤器

核心思路

利用布隆过滤器(Bloom Filter)算法,在请求进入Redis之前先进行预判断。布隆过滤器是一种概率型数据结构,能够高效判断元素是否一定不存在可能存在。若布隆过滤器判定数据不存在,则直接拒绝请求,不再查询缓存或数据库。

架构设计
#mermaid-svg-OCVsa6viN9jotooG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OCVsa6viN9jotooG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OCVsa6viN9jotooG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OCVsa6viN9jotooG .error-icon{fill:#552222;}#mermaid-svg-OCVsa6viN9jotooG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OCVsa6viN9jotooG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OCVsa6viN9jotooG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OCVsa6viN9jotooG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OCVsa6viN9jotooG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OCVsa6viN9jotooG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OCVsa6viN9jotooG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OCVsa6viN9jotooG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OCVsa6viN9jotooG .marker.cross{stroke:#333333;}#mermaid-svg-OCVsa6viN9jotooG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OCVsa6viN9jotooG p{margin:0;}#mermaid-svg-OCVsa6viN9jotooG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OCVsa6viN9jotooG .cluster-label text{fill:#333;}#mermaid-svg-OCVsa6viN9jotooG .cluster-label span{color:#333;}#mermaid-svg-OCVsa6viN9jotooG .cluster-label span p{background-color:transparent;}#mermaid-svg-OCVsa6viN9jotooG .label text,#mermaid-svg-OCVsa6viN9jotooG span{fill:#333;color:#333;}#mermaid-svg-OCVsa6viN9jotooG .node rect,#mermaid-svg-OCVsa6viN9jotooG .node circle,#mermaid-svg-OCVsa6viN9jotooG .node ellipse,#mermaid-svg-OCVsa6viN9jotooG .node polygon,#mermaid-svg-OCVsa6viN9jotooG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OCVsa6viN9jotooG .rough-node .label text,#mermaid-svg-OCVsa6viN9jotooG .node .label text,#mermaid-svg-OCVsa6viN9jotooG .image-shape .label,#mermaid-svg-OCVsa6viN9jotooG .icon-shape .label{text-anchor:middle;}#mermaid-svg-OCVsa6viN9jotooG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OCVsa6viN9jotooG .rough-node .label,#mermaid-svg-OCVsa6viN9jotooG .node .label,#mermaid-svg-OCVsa6viN9jotooG .image-shape .label,#mermaid-svg-OCVsa6viN9jotooG .icon-shape .label{text-align:center;}#mermaid-svg-OCVsa6viN9jotooG .node.clickable{cursor:pointer;}#mermaid-svg-OCVsa6viN9jotooG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OCVsa6viN9jotooG .arrowheadPath{fill:#333333;}#mermaid-svg-OCVsa6viN9jotooG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OCVsa6viN9jotooG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OCVsa6viN9jotooG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OCVsa6viN9jotooG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OCVsa6viN9jotooG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OCVsa6viN9jotooG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OCVsa6viN9jotooG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OCVsa6viN9jotooG .cluster text{fill:#333;}#mermaid-svg-OCVsa6viN9jotooG .cluster span{color:#333;}#mermaid-svg-OCVsa6viN9jotooG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OCVsa6viN9jotooG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OCVsa6viN9jotooG rect.text{fill:none;stroke-width:0;}#mermaid-svg-OCVsa6viN9jotooG .icon-shape,#mermaid-svg-OCVsa6viN9jotooG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OCVsa6viN9jotooG .icon-shape p,#mermaid-svg-OCVsa6viN9jotooG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OCVsa6viN9jotooG .icon-shape .label rect,#mermaid-svg-OCVsa6viN9jotooG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OCVsa6viN9jotooG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OCVsa6viN9jotooG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OCVsa6viN9jotooG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 一定不存在
可能存在
命中
未命中
存在
不存在
开始
提交商铺id
布隆过滤器

判断是否存在
直接拒绝请求
结束
判断缓存

是否命中
返回商铺信息
查询数据库
数据是否存在
写入缓存
返回404

布隆过滤器原理

布隆过滤器由一个长度为m的位数组(bit array)和k个独立的哈希函数组成。初始时,位数组所有位均为0。

  • 添加元素:对元素执行k次哈希,得到k个位置,将位数组对应位置设为1。
  • 查询元素 :对元素执行k次哈希,检查k个位置是否全为1。若存在任意位置为0,则元素一定不存在 ;若全为1,则元素可能存在(存在误判概率)。

代码实现(使用pybloom-live库)

python 复制代码
from fastapi import FastAPI, HTTPException
import redis.asyncio as aioredis
import json
from pybloom_live import BloomFilter

app = FastAPI()
redis_client = aioredis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True)

# 初始化布隆过滤器
# capacity=1000000: 预计最大元素数量
# error_rate=0.01: 误判率1%
bloom_filter = BloomFilter(capacity=1000000, error_rate=0.01)

# 模拟数据库中所有存在的商铺ID
EXISTING_SHOP_IDS = {1, 2, 3, 100, 200, 500}

# 系统启动时,将所有存在的ID加入布隆过滤器
def init_bloom_filter():
    for shop_id in EXISTING_SHOP_IDS:
        bloom_filter.add(shop_id)

@app.on_event("startup")
async def startup_event():
    init_bloom_filter()

async def query_shop_from_db(shop_id: int) -> dict | None:
    mock_db = {1: {"id": 1, "name": "星巴克旗舰店", "type": 1}}
    return mock_db.get(shop_id)

@app.get("/shops/{shop_id}")
async def get_shop_with_bloom(shop_id: int):
    # 1. 布隆过滤器预判断
    if shop_id not in bloom_filter:
        # 布隆过滤器判定一定不存在,直接拒绝
        raise HTTPException(status_code=404, detail="Shop not found (blocked by bloom filter)")
    
    # 2. 布隆过滤器判定可能存在,继续查询缓存
    cache_key = f"shop:{shop_id}"
    cached_data = await redis_client.get(cache_key)
    if cached_data:
        return json.loads(cached_data)
    
    # 3. 缓存未命中,查询数据库
    shop = await query_shop_from_db(shop_id)
    if not shop:
        raise HTTPException(status_code=404, detail="Shop not found")
    
    # 4. 写入缓存
    await redis_client.setex(cache_key, 3600, json.dumps(shop))
    return shop

优势

  • 内存占用极少:布隆过滤器使用位数组存储,空间效率极高。存储100万个元素,误判率1%,仅需约1.2MB内存。
  • 无多余Key:不会像缓存空值方案那样产生大量Redis Key,避免内存碎片与Key管理开销。
  • 查询性能极高:布隆过滤器的查询时间复杂度为O(k),k为哈希函数个数(通常3-7),远快于Redis网络IO。

缺陷

  • 实现复杂:需引入外部库或自行实现布隆过滤器,增加系统复杂度。需处理布隆过滤器的持久化、重启重建、动态扩容等问题。
  • 存在误判可能:布隆过滤器存在假阳性(False Positive),即元素实际不存在,但被判定为可能存在。误判率与位数组大小、哈希函数个数、元素数量相关。误判会导致部分无效请求穿透至数据库,但不会造成数据错误。
  • 不支持删除:标准布隆过滤器不支持元素删除(删除可能导致其他元素误判)。若业务需频繁删除数据,需使用计数布隆过滤器(Counting Bloom Filter),但内存占用增加。
  • 数据同步困难:新增数据时需同步更新布隆过滤器,若更新失败,会导致正常请求被拒绝。需保证布隆过滤器与数据库的最终一致性。

1.4 综合防护策略

除上述两种主流方案外,企业级系统通常采用多层防护体系。

数据格式校验

在请求入口处对ID进行基础校验,如检查ID是否为正整数、是否在合理范围内(如1-1000000)、是否符合特定格式(如UUID格式)。非法格式请求直接拒绝,不进入后续流程。

权限校验

加强用户身份认证与权限控制。未登录用户或无权限用户禁止访问敏感查询接口。通过Token验证、IP白名单、签名校验等手段,防止恶意爬取。

限流降级

对热点参数或异常IP实施限流。例如,同一IP在1分钟内查询不存在数据的次数超过10次,触发限流,返回429状态码。使用令牌桶或漏桶算法,平滑流量峰值。

企业级最佳实践

  • 低并发场景:采用缓存空对象方案,简单有效。
  • 高并发+数据量可控:采用布隆过滤器+缓存空对象组合。布隆过滤器拦截大部分无效请求,缓存空值兜底。
  • 高并发+数据量大+动态变化:采用布隆过滤器+限流+权限校验多层防护。布隆过滤器定期重建,配合Redis Cluster高可用。

二、缓存雪崩的成因与系统性防御

2.1 缓存雪崩的本质与破坏力

缓存雪崩是指在同一时段大量缓存Key同时失效,或Redis服务宕机,导致原本由缓存承担的海量请求瞬间穿透至数据库,数据库因无法承受突发压力而崩溃,进而导致整个系统瘫痪。

产生原因

缓存雪崩的核心诱因是集中失效。假设系统中有10万个热点Key,均设置为1小时过期。若这些Key在同一时刻(如系统启动时)被创建,则1小时后它们将同时过期。此时若有10万QPS的请求到达,所有请求将直接查询数据库,数据库瞬间负载飙升10倍,响应时间急剧增加,甚至连接池耗尽、服务宕机。

典型场景

  • 集中过期:系统重启、缓存预热、批量更新时,大量Key同时设置相同的TTL,导致未来某一时刻集中失效。
  • Redis宕机:Redis主节点故障,主从切换期间服务不可用,或集群脑裂导致部分数据不可访问。
  • 网络分区:应用服务器与Redis集群之间的网络抖动或中断,导致缓存无法访问,所有请求降级至数据库。

2.2 解决方案一:TTL随机化

核心思路

给不同的Key的TTL添加随机值,避免大量Key在同一时刻过期。例如,基础TTL为3600秒,随机增加0-300秒的偏移量,使实际TTL在3600-3900秒之间随机分布。

代码实现

python 复制代码
import random

async def set_cache_with_random_ttl(key: str, value: str, base_ttl: int = 3600, random_range: int = 300):
    """
    设置缓存并添加随机TTL
    :param key: 缓存Key
    :param value: 缓存Value
    :param base_ttl: 基础TTL(秒)
    :param random_range: 随机范围(秒)
    """
    # 计算随机TTL:base_ttl ± random_range/2
    random_ttl = base_ttl + random.randint(-random_range // 2, random_range // 2)
    # 确保TTL为正数
    random_ttl = max(60, random_ttl)  # 最少60秒
    
    await redis_client.setex(key, random_ttl, value)
    return random_ttl

# 使用示例
@app.get("/shops/{shop_id}")
async def get_shop_with_random_ttl(shop_id: int):
    cache_key = f"shop:{shop_id}"
    
    cached_data = await redis_client.get(cache_key)
    if cached_data:
        return json.loads(cached_data)
    
    shop = await query_shop_from_db(shop_id)
    if not shop:
        raise HTTPException(status_code=404, detail="Shop not found")
    
    # 设置随机TTL,基础3600秒,随机±300秒
    actual_ttl = await set_cache_with_random_ttl(cache_key, json.dumps(shop), 3600, 300)
    print(f"Cache set with TTL: {actual_ttl} seconds")
    
    return shop

方案优势

  • 实现简单:仅需在设置TTL时增加随机数计算,代码改动极小。
  • 效果显著:即使大量Key同时创建,实际过期时间也会分散在时间窗口内,避免集中失效。
  • 无额外成本:不占用额外内存或计算资源。

方案缺陷

  • 无法解决Redis宕机:若Redis服务整体不可用,TTL随机化无效。
  • 随机范围难确定:随机范围过小,分散效果差;随机范围过大,部分Key过早失效,增加数据库压力。需根据业务访问模式调整。

2.3 解决方案二:Redis集群高可用

核心思路

利用Redis Cluster或哨兵模式(Sentinel)构建高可用集群,通过主从复制、自动故障转移、数据分片等机制,确保单点故障不影响整体服务。

架构设计
#mermaid-svg-qNrT33hzzQgmCFCI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qNrT33hzzQgmCFCI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qNrT33hzzQgmCFCI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qNrT33hzzQgmCFCI .error-icon{fill:#552222;}#mermaid-svg-qNrT33hzzQgmCFCI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qNrT33hzzQgmCFCI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qNrT33hzzQgmCFCI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qNrT33hzzQgmCFCI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qNrT33hzzQgmCFCI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qNrT33hzzQgmCFCI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qNrT33hzzQgmCFCI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qNrT33hzzQgmCFCI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qNrT33hzzQgmCFCI .marker.cross{stroke:#333333;}#mermaid-svg-qNrT33hzzQgmCFCI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qNrT33hzzQgmCFCI p{margin:0;}#mermaid-svg-qNrT33hzzQgmCFCI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qNrT33hzzQgmCFCI .cluster-label text{fill:#333;}#mermaid-svg-qNrT33hzzQgmCFCI .cluster-label span{color:#333;}#mermaid-svg-qNrT33hzzQgmCFCI .cluster-label span p{background-color:transparent;}#mermaid-svg-qNrT33hzzQgmCFCI .label text,#mermaid-svg-qNrT33hzzQgmCFCI span{fill:#333;color:#333;}#mermaid-svg-qNrT33hzzQgmCFCI .node rect,#mermaid-svg-qNrT33hzzQgmCFCI .node circle,#mermaid-svg-qNrT33hzzQgmCFCI .node ellipse,#mermaid-svg-qNrT33hzzQgmCFCI .node polygon,#mermaid-svg-qNrT33hzzQgmCFCI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qNrT33hzzQgmCFCI .rough-node .label text,#mermaid-svg-qNrT33hzzQgmCFCI .node .label text,#mermaid-svg-qNrT33hzzQgmCFCI .image-shape .label,#mermaid-svg-qNrT33hzzQgmCFCI .icon-shape .label{text-anchor:middle;}#mermaid-svg-qNrT33hzzQgmCFCI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qNrT33hzzQgmCFCI .rough-node .label,#mermaid-svg-qNrT33hzzQgmCFCI .node .label,#mermaid-svg-qNrT33hzzQgmCFCI .image-shape .label,#mermaid-svg-qNrT33hzzQgmCFCI .icon-shape .label{text-align:center;}#mermaid-svg-qNrT33hzzQgmCFCI .node.clickable{cursor:pointer;}#mermaid-svg-qNrT33hzzQgmCFCI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qNrT33hzzQgmCFCI .arrowheadPath{fill:#333333;}#mermaid-svg-qNrT33hzzQgmCFCI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qNrT33hzzQgmCFCI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qNrT33hzzQgmCFCI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qNrT33hzzQgmCFCI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qNrT33hzzQgmCFCI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qNrT33hzzQgmCFCI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qNrT33hzzQgmCFCI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qNrT33hzzQgmCFCI .cluster text{fill:#333;}#mermaid-svg-qNrT33hzzQgmCFCI .cluster span{color:#333;}#mermaid-svg-qNrT33hzzQgmCFCI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qNrT33hzzQgmCFCI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qNrT33hzzQgmCFCI rect.text{fill:none;stroke-width:0;}#mermaid-svg-qNrT33hzzQgmCFCI .icon-shape,#mermaid-svg-qNrT33hzzQgmCFCI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qNrT33hzzQgmCFCI .icon-shape p,#mermaid-svg-qNrT33hzzQgmCFCI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qNrT33hzzQgmCFCI .icon-shape .label rect,#mermaid-svg-qNrT33hzzQgmCFCI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qNrT33hzzQgmCFCI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qNrT33hzzQgmCFCI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qNrT33hzzQgmCFCI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求
读写
读写
读写
复制
复制
复制
复制
复制
复制
故障转移
故障转移
故障转移
应用服务器
HAProxy/Keepalived
Master 1
Master 2
Master 3
Slave 1-1
Slave 1-2
Slave 2-1
Slave 2-2
Slave 3-1
Slave 3-2

配置要点

  • 主从复制:每个Master节点配置至少2个Slave节点,确保数据冗余。
  • 哨兵监控:部署至少3个哨兵节点,监控Master健康状态,自动选举新Master。
  • 数据分片:使用Redis Cluster将数据分散到多个Master节点,避免单节点内存瓶颈。
  • 持久化:开启AOF(Append Only File)持久化,防止重启数据丢失。

2.4 解决方案三:降级限流与多级缓存

降级限流策略

当缓存层不可用或数据库压力过高时,触发降级机制,返回默认值、缓存旧数据或直接拒绝部分请求。

python 复制代码
from fastapi import HTTPException
import time

# 限流计数器
request_count = {}
RATE_LIMIT = 100  # 每秒最多100个请求
TIME_WINDOW = 1   # 时间窗口1秒

async def check_rate_limit(client_ip: str) -> bool:
    """检查IP限流"""
    current_time = int(time.time())
    key = f"rate_limit:{client_ip}:{current_time}"
    
    count = await redis_client.get(key)
    if count is None:
        await redis_client.setex(key, TIME_WINDOW, 1)
        return True
    
    if int(count) < RATE_LIMIT:
        await redis_client.incr(key)
        return True
    
    return False

@app.get("/shops/{shop_id}")
async def get_shop_with_fallback(shop_id: int):
    client_ip = "127.0.0.1"  # 实际应从请求头获取
    
    # 限流检查
    if not await check_rate_limit(client_ip):
        raise HTTPException(status_code=429, detail="Too many requests, please try later")
    
    try:
        # 尝试查询缓存
        cache_key = f"shop:{shop_id}"
        cached_data = await redis_client.get(cache_key)
        if cached_data:
            return json.loads(cached_data)
        
        # 查询数据库
        shop = await query_shop_from_db(shop_id)
        if not shop:
            raise HTTPException(status_code=404, detail="Shop not found")
        
        await redis_client.setex(cache_key, 3600, json.dumps(shop))
        return shop
        
    except redis.exceptions.ConnectionError:
        # Redis不可用,降级处理
        print("Redis unavailable, falling back to database")
        # 可选择:返回默认值、直接查库、或返回错误
        shop = await query_shop_from_db(shop_id)
        if not shop:
            raise HTTPException(status_code=404, detail="Shop not found")
        return shop  # 不缓存,直接返回

多级缓存架构

构建本地缓存(如Guava、Caffeine)+ 分布式缓存(Redis)的两级缓存体系。本地缓存存储热点数据,TTL极短(如10秒),Redis缓存存储全量数据,TTL较长(如1小时)。Redis宕机时,本地缓存仍可支撑部分流量。

三、缓存击穿的并发控制与重建策略

3.1 缓存击穿的本质与热点Key危机

缓存击穿(Cache Breakdown),又称热点Key问题,是指一个被高并发访问且缓存重建业务较复杂的Key突然失效,无数请求在瞬间穿透至数据库,带来巨大冲击。

产生原因

热点Key具有以下特征:

  • 高并发访问:某一时段内QPS极高(如秒杀商品、热点新闻、明星八卦)。
  • 缓存重建耗时:从数据库查询、业务计算、序列化到写入缓存,整个过程耗时较长(如100ms-1s)。

当热点Key的TTL到期时,缓存失效。此时若有10000个并发请求到达,所有请求同时发现缓存未命中,同时转向数据库查询。数据库瞬间承受10000倍压力,响应时间从1ms飙升至10s,甚至连接池耗尽、服务崩溃。

与缓存穿透的区别

  • 缓存穿透 :请求的数据根本不存在(数据库也无),需通过缓存空值或布隆过滤器拦截。
  • 缓存击穿 :请求的数据真实存在且热点,只是缓存临时失效,需通过并发控制避免重复重建。

3.2 解决方案一:互斥锁(Mutex Lock)

核心思路

给缓存重建过程加分布式锁,确保同一时刻只有一个线程执行数据库查询与缓存写入,其他线程等待锁释放后重新查询缓存。若缓存已重建完成,等待线程直接命中缓存,避免重复查询数据库。

标准实现流程
#mermaid-svg-LMnKJ8oBZF5Qeo1t{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .error-icon{fill:#552222;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .marker.cross{stroke:#333333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LMnKJ8oBZF5Qeo1t p{margin:0;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .cluster-label text{fill:#333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .cluster-label span{color:#333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .cluster-label span p{background-color:transparent;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .label text,#mermaid-svg-LMnKJ8oBZF5Qeo1t span{fill:#333;color:#333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .node rect,#mermaid-svg-LMnKJ8oBZF5Qeo1t .node circle,#mermaid-svg-LMnKJ8oBZF5Qeo1t .node ellipse,#mermaid-svg-LMnKJ8oBZF5Qeo1t .node polygon,#mermaid-svg-LMnKJ8oBZF5Qeo1t .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .rough-node .label text,#mermaid-svg-LMnKJ8oBZF5Qeo1t .node .label text,#mermaid-svg-LMnKJ8oBZF5Qeo1t .image-shape .label,#mermaid-svg-LMnKJ8oBZF5Qeo1t .icon-shape .label{text-anchor:middle;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .rough-node .label,#mermaid-svg-LMnKJ8oBZF5Qeo1t .node .label,#mermaid-svg-LMnKJ8oBZF5Qeo1t .image-shape .label,#mermaid-svg-LMnKJ8oBZF5Qeo1t .icon-shape .label{text-align:center;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .node.clickable{cursor:pointer;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .arrowheadPath{fill:#333333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LMnKJ8oBZF5Qeo1t .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LMnKJ8oBZF5Qeo1t .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LMnKJ8oBZF5Qeo1t .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .cluster text{fill:#333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .cluster span{color:#333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LMnKJ8oBZF5Qeo1t rect.text{fill:none;stroke-width:0;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .icon-shape,#mermaid-svg-LMnKJ8oBZF5Qeo1t .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .icon-shape p,#mermaid-svg-LMnKJ8oBZF5Qeo1t .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .icon-shape .label rect,#mermaid-svg-LMnKJ8oBZF5Qeo1t .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LMnKJ8oBZF5Qeo1t .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LMnKJ8oBZF5Qeo1t .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LMnKJ8oBZF5Qeo1t :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
未命中
成功
失败
开始
提交商铺id
判断缓存

是否命中
返回商铺信息
结束
尝试获取互斥锁
是否获取

锁成功
查询数据库
写入缓存
释放锁
休眠50ms
重试查询缓存

代码实现

python 复制代码
import asyncio
import time

LOCK_PREFIX = "lock:shop:"
LOCK_TTL = 10  # 锁超时时间10秒
RETRY_DELAY = 0.05  # 重试延迟50ms
MAX_RETRIES = 50  # 最大重试次数

async def try_lock(key: str, ttl: int) -> bool:
    """
    尝试获取分布式锁
    使用SETNX(SET if Not eXists)实现
    """
    lock_key = f"{LOCK_PREFIX}{key}"
    # SETNX + EXPIRE 原子操作
    result = await redis_client.set(lock_key, "1", nx=True, ex=ttl)
    return result is not None

async def release_lock(key: str):
    """释放分布式锁"""
    lock_key = f"{LOCK_PREFIX}{key}"
    await redis_client.delete(lock_key)

@app.get("/shops/{shop_id}")
async def get_shop_with_mutex(shop_id: int):
    cache_key = f"shop:{shop_id}"
    
    # 1. 查询缓存
    cached_data = await redis_client.get(cache_key)
    if cached_data:
        return json.loads(cached_data)
    
    # 2. 缓存未命中,尝试获取互斥锁
    retries = 0
    while retries < MAX_RETRIES:
        lock_acquired = await try_lock(shop_id, LOCK_TTL)
        
        if lock_acquired:
            try:
                # 3. 获取锁成功,再次检查缓存(双重检查)
                # 防止在等待锁期间,其他线程已重建缓存
                cached_data = await redis_client.get(cache_key)
                if cached_data:
                    return json.loads(cached_data)
                
                # 4. 缓存仍为空,查询数据库
                print(f"Thread {id(asyncio.current_task())}: Rebuilding cache for shop {shop_id}")
                start_time = time.time()
                
                shop = await query_shop_from_db(shop_id)
                if not shop:
                    raise HTTPException(status_code=404, detail="Shop not found")
                
                # 模拟复杂业务计算(耗时100ms)
                await asyncio.sleep(0.1)
                
                # 5. 写入缓存
                await redis_client.setex(cache_key, 3600, json.dumps(shop))
                
                elapsed = time.time() - start_time
                print(f"Thread {id(asyncio.current_task())}: Cache rebuilt in {elapsed:.3f}s")
                
                return shop
                
            finally:
                # 6. 释放锁(无论成功失败都必须释放)
                await release_lock(shop_id)
        else:
            # 7. 获取锁失败,休眠后重试
            retries += 1
            await asyncio.sleep(RETRY_DELAY)
            
            # 重试前再次检查缓存
            cached_data = await redis_client.get(cache_key)
            if cached_data:
                print(f"Thread {id(asyncio.current_task())}: Cache hit after retry {retries}")
                return json.loads(cached_data)
    
    # 8. 重试次数耗尽,返回错误
    raise HTTPException(status_code=503, detail="Service temporarily unavailable")

并发推演
分布式锁(lock:shop:1) 数据库 Redis缓存 线程2~100 (获取锁失败) 线程1 (成功获取锁) 分布式锁(lock:shop:1) 数据库 Redis缓存 线程2~100 (获取锁失败) 线程1 (成功获取锁) #mermaid-svg-LB3ctaJIevc38JG4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LB3ctaJIevc38JG4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LB3ctaJIevc38JG4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LB3ctaJIevc38JG4 .error-icon{fill:#552222;}#mermaid-svg-LB3ctaJIevc38JG4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LB3ctaJIevc38JG4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LB3ctaJIevc38JG4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LB3ctaJIevc38JG4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LB3ctaJIevc38JG4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LB3ctaJIevc38JG4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LB3ctaJIevc38JG4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LB3ctaJIevc38JG4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LB3ctaJIevc38JG4 .marker.cross{stroke:#333333;}#mermaid-svg-LB3ctaJIevc38JG4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LB3ctaJIevc38JG4 p{margin:0;}#mermaid-svg-LB3ctaJIevc38JG4 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LB3ctaJIevc38JG4 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-LB3ctaJIevc38JG4 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LB3ctaJIevc38JG4 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-LB3ctaJIevc38JG4 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-LB3ctaJIevc38JG4 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-LB3ctaJIevc38JG4 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-LB3ctaJIevc38JG4 .sequenceNumber{fill:white;}#mermaid-svg-LB3ctaJIevc38JG4 #sequencenumber{fill:#333;}#mermaid-svg-LB3ctaJIevc38JG4 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-LB3ctaJIevc38JG4 .messageText{fill:#333;stroke:none;}#mermaid-svg-LB3ctaJIevc38JG4 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LB3ctaJIevc38JG4 .labelText,#mermaid-svg-LB3ctaJIevc38JG4 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-LB3ctaJIevc38JG4 .loopText,#mermaid-svg-LB3ctaJIevc38JG4 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-LB3ctaJIevc38JG4 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LB3ctaJIevc38JG4 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-LB3ctaJIevc38JG4 .noteText,#mermaid-svg-LB3ctaJIevc38JG4 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-LB3ctaJIevc38JG4 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LB3ctaJIevc38JG4 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LB3ctaJIevc38JG4 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LB3ctaJIevc38JG4 .actorPopupMenu{position:absolute;}#mermaid-svg-LB3ctaJIevc38JG4 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-LB3ctaJIevc38JG4 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LB3ctaJIevc38JG4 .actor-man circle,#mermaid-svg-LB3ctaJIevc38JG4 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-LB3ctaJIevc38JG4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} t=0.001s:热点Key过期,100个并发请求瞬间到达 进入休眠重试循环:休眠50ms → 查缓存 → 未命中 → 继续休眠 t=0.002s ~ 0.102s:线程1独占执行缓存重建 t=0.102s:线程1重建完成,释放锁 t=0.102s:重试线程醒来再次检查缓存 结论:100个高并发请求,数据库实际仅被查询 1 次 1. 查询缓存 (未命中)11. 查询缓存 (未命中)22. 尝试获取互斥锁 (SETNX)32. 尝试获取互斥锁 (SETNX)4返回成功 (1)5返回失败 (0)63. 查询数据库 (耗时100ms)7休眠重试中 (无数据库交互)84. 写入缓存 (shop:1)95. 释放互斥锁 (DEL)10锁状态变更为空闲11重试查询缓存12缓存命中,直接返回数据13

假设热点Key shop:1 在t=0时刻过期,100个并发请求在t=0.001s时刻到达。

时刻t=0.001s

  • 100个线程同时查询缓存,均未命中。
  • 100个线程同时尝试获取互斥锁 lock:shop:1
  • 线程1成功获取锁(SETNX返回1),线程2-100获取失败(SETNX返回0)。

时刻t=0.002s - t=0.102s

  • 线程1执行数据库查询与业务计算(耗时100ms)。
  • 线程2-100进入等待循环,每50ms重试一次。
  • 线程2-100在t=0.052s、t=0.102s、t=0.152s...时刻重试查询缓存。

时刻t=0.102s

  • 线程1完成缓存重建,写入 shop:1,释放锁。
  • 线程2-100在t=0.102s重试时,缓存已命中,直接返回数据。
  • 数据库仅被查询1次,而非100次。

方案优势

  • 实现简单:基于Redis的SETNX实现分布式锁,逻辑清晰。
  • 无额外内存消耗:不缓存空值,不维护布隆过滤器。
  • 强一致性:确保缓存重建过程单线程执行,避免并发写冲突。

方案缺陷

  • 性能下降:等待锁的线程需反复重试,增加响应延迟。若重建耗时100ms,重试间隔50ms,最坏情况下线程需等待多次才能命中缓存。
  • 死锁风险:若持有锁的线程在释放锁前崩溃(如进程宕机、网络中断),锁将永久持有,其他线程永远无法获取。解决方案:设置锁的TTL(如10秒),超时自动释放。但TTL设置过短可能导致锁提前释放,设置过长可能导致死锁等待时间过长。
  • 锁竞争开销:高并发下,大量线程频繁尝试获取锁,增加Redis压力。

3.3 解决方案二:逻辑过期(Logical Expiration)

核心思路

热点Key的缓存永不过期(不设置TTL),而是在Value中嵌入一个逻辑过期时间字段。查询时,先判断逻辑过期时间是否已过。若未过期,直接返回数据;若已过期,尝试获取互斥锁,由获得锁的线程异步重建缓存,其他线程直接返回旧数据,无需等待。

数据结构设计

python 复制代码
import time
from dataclasses import dataclass
from typing import Optional

@dataclass
class RedisData:
    """封装缓存数据与逻辑过期时间"""
    data: dict  # 实际业务数据
    expire_time: int  # 逻辑过期时间(Unix时间戳)
    
    def is_expired(self) -> bool:
        """判断是否逻辑过期"""
        return int(time.time()) > self.expire_time
    
    def to_json(self) -> str:
        """序列化为JSON"""
        return json.dumps({
            "data": self.data,
            "expire_time": self.expire_time
        })
    
    @classmethod
    def from_json(cls, json_str: str) -> "RedisData":
        """从JSON反序列化"""
        obj = json.loads(json_str)
        return cls(data=obj["data"], expire_time=obj["expire_time"])

标准实现流程
#mermaid-svg-vJPRYomqAsuy9MES{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vJPRYomqAsuy9MES .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vJPRYomqAsuy9MES .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vJPRYomqAsuy9MES .error-icon{fill:#552222;}#mermaid-svg-vJPRYomqAsuy9MES .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vJPRYomqAsuy9MES .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vJPRYomqAsuy9MES .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vJPRYomqAsuy9MES .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vJPRYomqAsuy9MES .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vJPRYomqAsuy9MES .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vJPRYomqAsuy9MES .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vJPRYomqAsuy9MES .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vJPRYomqAsuy9MES .marker.cross{stroke:#333333;}#mermaid-svg-vJPRYomqAsuy9MES svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vJPRYomqAsuy9MES p{margin:0;}#mermaid-svg-vJPRYomqAsuy9MES .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vJPRYomqAsuy9MES .cluster-label text{fill:#333;}#mermaid-svg-vJPRYomqAsuy9MES .cluster-label span{color:#333;}#mermaid-svg-vJPRYomqAsuy9MES .cluster-label span p{background-color:transparent;}#mermaid-svg-vJPRYomqAsuy9MES .label text,#mermaid-svg-vJPRYomqAsuy9MES span{fill:#333;color:#333;}#mermaid-svg-vJPRYomqAsuy9MES .node rect,#mermaid-svg-vJPRYomqAsuy9MES .node circle,#mermaid-svg-vJPRYomqAsuy9MES .node ellipse,#mermaid-svg-vJPRYomqAsuy9MES .node polygon,#mermaid-svg-vJPRYomqAsuy9MES .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vJPRYomqAsuy9MES .rough-node .label text,#mermaid-svg-vJPRYomqAsuy9MES .node .label text,#mermaid-svg-vJPRYomqAsuy9MES .image-shape .label,#mermaid-svg-vJPRYomqAsuy9MES .icon-shape .label{text-anchor:middle;}#mermaid-svg-vJPRYomqAsuy9MES .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vJPRYomqAsuy9MES .rough-node .label,#mermaid-svg-vJPRYomqAsuy9MES .node .label,#mermaid-svg-vJPRYomqAsuy9MES .image-shape .label,#mermaid-svg-vJPRYomqAsuy9MES .icon-shape .label{text-align:center;}#mermaid-svg-vJPRYomqAsuy9MES .node.clickable{cursor:pointer;}#mermaid-svg-vJPRYomqAsuy9MES .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vJPRYomqAsuy9MES .arrowheadPath{fill:#333333;}#mermaid-svg-vJPRYomqAsuy9MES .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vJPRYomqAsuy9MES .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vJPRYomqAsuy9MES .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vJPRYomqAsuy9MES .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vJPRYomqAsuy9MES .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vJPRYomqAsuy9MES .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vJPRYomqAsuy9MES .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vJPRYomqAsuy9MES .cluster text{fill:#333;}#mermaid-svg-vJPRYomqAsuy9MES .cluster span{color:#333;}#mermaid-svg-vJPRYomqAsuy9MES div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vJPRYomqAsuy9MES .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vJPRYomqAsuy9MES rect.text{fill:none;stroke-width:0;}#mermaid-svg-vJPRYomqAsuy9MES .icon-shape,#mermaid-svg-vJPRYomqAsuy9MES .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vJPRYomqAsuy9MES .icon-shape p,#mermaid-svg-vJPRYomqAsuy9MES .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vJPRYomqAsuy9MES .icon-shape .label rect,#mermaid-svg-vJPRYomqAsuy9MES .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vJPRYomqAsuy9MES .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vJPRYomqAsuy9MES .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vJPRYomqAsuy9MES :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未命中
命中
未过期
已过期
成功
失败
开始
提交商铺id
判断缓存

是否命中
查询数据库
构建缓存对象

设置逻辑过期时间
写入Redis

不设置TTL
返回商铺信息
结束
反序列化RedisData
判断逻辑

是否过期
尝试获取互斥锁
是否获取

锁成功
开启独立线程

异步重建缓存
返回旧数据

代码实现

python 复制代码
import threading
import time

LOGICAL_EXPIRE_SECONDS = 3600  # 逻辑过期时间1小时

async def save_with_logical_expire(key: str, value: dict, logical_expire_seconds: int = LOGICAL_EXPIRE_SECONDS):
    """
    保存数据并设置逻辑过期时间
    不设置Redis TTL,缓存永不过期
    """
    expire_time = int(time.time()) + logical_expire_seconds
    redis_data = RedisData(data=value, expire_time=expire_time)
    await redis_client.set(key, redis_data.to_json())  # 不设TTL

async def get_with_logical_expire(key: str, rebuild_func, *args) -> dict:
    """
    查询缓存,若逻辑过期则异步重建
    :param key: 缓存Key
    :param rebuild_func: 重建缓存的异步函数
    :param args: 重建函数的参数
    """
    # 1. 查询缓存
    cached_json = await redis_client.get(key)
    if not cached_json:
        # 缓存不存在,直接重建
        data = await rebuild_func(*args)
        await save_with_logical_expire(key, data)
        return data
    
    # 2. 反序列化
    redis_data = RedisData.from_json(cached_json)
    
    # 3. 判断逻辑过期
    if not redis_data.is_expired():
        # 未过期,直接返回
        return redis_data.data
    
    # 4. 逻辑已过期,尝试获取互斥锁
    lock_key = key.replace("shop:", "lock:shop:")
    lock_acquired = await redis_client.set(lock_key, "1", nx=True, ex=10)
    
    if lock_acquired:
        # 5. 获取锁成功,开启独立线程异步重建
        def rebuild_task():
            try:
                loop = asyncio.new_event_loop()
                asyncio.set_event_loop(loop)
                
                # 重建缓存
                data = loop.run_until_complete(rebuild_func(*args))
                loop.run_until_complete(save_with_logical_expire(key, data))
                
            except Exception as e:
                print(f"Async rebuild failed: {e}")
            finally:
                # 释放锁
                loop = asyncio.new_event_loop()
                loop.run_until_complete(redis_client.delete(lock_key))
        
        # 启动后台线程
        thread = threading.Thread(target=rebuild_task)
        thread.daemon = True
        thread.start()
        
        print(f"Started async rebuild for {key}")
    
    # 6. 返回旧数据(无论是否获取锁)
    return redis_data.data

@app.get("/shops/{shop_id}")
async def get_shop_with_logical_expire(shop_id: int):
    cache_key = f"shop:{shop_id}"
    
    async def rebuild_shop_data(sid: int) -> dict:
        """重建商铺数据的函数"""
        print(f"Rebuilding shop {sid}...")
        await asyncio.sleep(0.1)  # 模拟耗时查询
        shop = await query_shop_from_db(sid)
        if not shop:
            raise HTTPException(status_code=404, detail="Shop not found")
        return shop
    
    try:
        shop_data = await get_with_logical_expire(cache_key, rebuild_shop_data, shop_id)
        return shop_data
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

并发推演
t=3601.103s后新请求 数据库 独立后台重建线程 分布式锁(lock:shop:1) Redis缓存 线程2~100 (获取锁失败) 线程1 (成功获取锁) t=3601.103s后新请求 数据库 独立后台重建线程 分布式锁(lock:shop:1) Redis缓存 线程2~100 (获取锁失败) 线程1 (成功获取锁) #mermaid-svg-73STUyIwabNpVB0p{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-73STUyIwabNpVB0p .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-73STUyIwabNpVB0p .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-73STUyIwabNpVB0p .error-icon{fill:#552222;}#mermaid-svg-73STUyIwabNpVB0p .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-73STUyIwabNpVB0p .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-73STUyIwabNpVB0p .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-73STUyIwabNpVB0p .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-73STUyIwabNpVB0p .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-73STUyIwabNpVB0p .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-73STUyIwabNpVB0p .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-73STUyIwabNpVB0p .marker{fill:#333333;stroke:#333333;}#mermaid-svg-73STUyIwabNpVB0p .marker.cross{stroke:#333333;}#mermaid-svg-73STUyIwabNpVB0p svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-73STUyIwabNpVB0p p{margin:0;}#mermaid-svg-73STUyIwabNpVB0p .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-73STUyIwabNpVB0p text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-73STUyIwabNpVB0p .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-73STUyIwabNpVB0p .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-73STUyIwabNpVB0p .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-73STUyIwabNpVB0p .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-73STUyIwabNpVB0p #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-73STUyIwabNpVB0p .sequenceNumber{fill:white;}#mermaid-svg-73STUyIwabNpVB0p #sequencenumber{fill:#333;}#mermaid-svg-73STUyIwabNpVB0p #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-73STUyIwabNpVB0p .messageText{fill:#333;stroke:none;}#mermaid-svg-73STUyIwabNpVB0p .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-73STUyIwabNpVB0p .labelText,#mermaid-svg-73STUyIwabNpVB0p .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-73STUyIwabNpVB0p .loopText,#mermaid-svg-73STUyIwabNpVB0p .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-73STUyIwabNpVB0p .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-73STUyIwabNpVB0p .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-73STUyIwabNpVB0p .noteText,#mermaid-svg-73STUyIwabNpVB0p .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-73STUyIwabNpVB0p .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-73STUyIwabNpVB0p .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-73STUyIwabNpVB0p .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-73STUyIwabNpVB0p .actorPopupMenu{position:absolute;}#mermaid-svg-73STUyIwabNpVB0p .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-73STUyIwabNpVB0p .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-73STUyIwabNpVB0p .actor-man circle,#mermaid-svg-73STUyIwabNpVB0p line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-73STUyIwabNpVB0p :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} t=3601.000s:逻辑过期瞬间,100个高并发请求瞬间到达 t=3601.001s:锁竞争结束,主链路立即返回旧数据 100个请求均在1ms内完成,主线程零阻塞 t=3601.002s ~ 0.102s:后台异步执行重建 (耗时100ms) t=3601.103s之后:后续请求命中新数据 结论:数据库仅被查询 1 次,主链路响应时间恒定,实现最终一致性 1. 查询缓存 (命中 shop:1)11. 查询缓存 (命中 shop:1)2返回RedisData {data, expire:3600}3返回RedisData {data, expire:3600}42. 反序列化并判断逻辑时间 (3600 < 3601)52. 反序列化并判断逻辑时间 (3600 < 3601)63. 尝试获取互斥锁 (SETNX)73. 尝试获取互斥锁 (SETNX)8返回成功9返回失败104. 开启独立后台线程执行重建115. 立即返回旧数据 (响应耗时 ~1ms)124. 获取锁失败,直接返回旧数据 (响应耗时 ~1ms)136. 查询数据库并执行复杂业务计算14返回最新数据 v=20157. 写入新缓存,重置逻辑过期时间 (expire:7201)168. 释放互斥锁 (DEL)179. 查询缓存 (命中新数据)18判断逻辑未过期 (7201 > current)19直接返回最新数据 v=2020

假设热点Key shop:1 的逻辑过期时间为t=3600s,当前时刻t=3601s(已过期1秒),100个并发请求到达。

时刻t=3601.000s

  • 100个线程同时查询缓存,均命中 shop:1
  • 100个线程反序列化RedisData,发现 expire_time=3600 < current_time=3601,逻辑已过期。
  • 100个线程同时尝试获取互斥锁 lock:shop:1
  • 线程1成功获取锁,线程2-100获取失败。

时刻t=3601.001s

  • 线程1开启独立后台线程执行重建任务,主线程立即返回旧数据(t=0时刻的缓存值)。
  • 线程2-100获取锁失败,直接返回旧数据。
  • 100个线程均在1ms内返回,无需等待重建。

时刻t=3601.002s - t=3601.102s

  • 后台线程执行数据库查询与业务计算(耗时100ms)。
  • 后台线程在t=3601.102s完成重建,写入新缓存,设置新的逻辑过期时间t=7201s。
  • 后台线程释放锁。

时刻t=3601.103s之后

  • 新到达的请求查询缓存,命中新数据,逻辑未过期,直接返回。
  • 数据库仅被查询1次,且不影响用户响应时间。

方案优势

  • 性能极佳:线程无需等待缓存重建,直接返回旧数据,响应时间稳定在1-2ms。
  • 无雪崩风险:即使热点Key逻辑过期,也不会导致数据库压力突增。
  • 用户体验好:用户始终能快速获得响应,即使数据略有延迟。

方案缺陷

  • 不保证强一致性:缓存重建期间,用户可能读到过期数据(如旧价格、旧库存)。适用于对一致性要求不高的场景(如商品详情、新闻内容),不适用于强一致性场景(如库存扣减、账户余额)。
  • 额外内存消耗:需在Value中存储逻辑过期时间字段,增加约8-16字节/Key。
  • 实现复杂:需封装RedisData数据结构,处理异步线程、事件循环、异常捕获等,代码复杂度高。
  • 资源泄漏风险:异步线程若未正确处理异常或未及时释放锁,可能导致资源泄漏。需使用守护线程(daemon=True)确保主进程退出时线程自动终止。

3.4 方案选型对比

对比维度 互斥锁方案 逻辑过期方案
实现复杂度 简单 复杂
响应延迟 高(需等待重建) 低(立即返回)
数据一致性 强一致 最终一致
内存消耗 无额外消耗 额外8-16字节/Key
适用场景 强一致性需求(库存、余额) 高并发读、弱一致性(详情、新闻)
死锁风险 有(需设置TTL) 无(异步重建)
数据库压力 低(串行重建) 极低(异步重建)

企业级选型建议

  • 核心交易链路(如订单、支付、库存):采用互斥锁方案,保证强一致性,宁可牺牲部分性能。
  • 内容展示链路(如商品详情、新闻、评论):采用逻辑过期方案,保证高可用与低延迟,接受秒级数据延迟。
  • 混合方案:对同一业务的不同字段采用不同策略。如商品详情中的价格、库存采用互斥锁,描述、图片采用逻辑过期。

四、缓存工具类的封装与复用

4.1 工具类设计目标

企业级项目中,缓存操作贯穿各个业务模块。为避免代码重复、统一异常处理、规范序列化方式,需封装通用的缓存工具类。工具类应满足以下需求:

  1. 支持任意对象序列化:将对象自动转换为JSON字符串存储。
  2. 支持TTL过期时间:设置物理过期时间,防止缓存永久驻留。
  3. 支持逻辑过期时间:用于解决缓存击穿问题。
  4. 支持缓存穿透防护:自动缓存空值。
  5. 支持泛型反序列化:查询时自动转换为指定类型。

4.2 工具类实现

python 复制代码
import json
import time
import random
import asyncio
import logging
import uuid
from typing import Optional, TypeVar, Generic, Callable, Awaitable, Any
from dataclasses import dataclass

import redis.asyncio as aioredis
from redis.exceptions import ConnectionError, TimeoutError, RedisError

logger = logging.getLogger(__name__)

T = TypeVar('T')

@dataclass
class RedisData(Generic[T]):
    """封装业务数据与逻辑过期时间戳"""
    data: T
    expire_time: int  # Unix 时间戳

    def is_expired(self) -> bool:
        return int(time.time()) > self.expire_time

    def to_json(self) -> str:
        return json.dumps({"data": self.data, "expire_time": self.expire_time})

    @classmethod
    def from_json(cls, json_str: str) -> "RedisData":
        obj = json.loads(json_str)
        return cls(data=obj["data"], expire_time=obj["expire_time"])


class CacheClient:
    """
    生产级缓存工具类
    严格对应 PDF 要求的 4 个方法,内置防穿透/防击穿/防雪崩机制
    """
    def __init__(self, redis_client: aioredis.Redis, lock_timeout: int = 10):
        self.redis = redis_client
        self.lock_timeout = lock_timeout
        self._lock_prefix = "lock:cache:"

    def _random_ttl(self, base_ttl: int) -> int:
        """生成带 ±25% 随机扰动的 TTL,防止缓存雪崩(集中过期)"""
        if base_ttl <= 0: return 0
        jitter = int(base_ttl * 0.25)
        return max(60, base_ttl + random.randint(-jitter, jitter))

    # ================= 方法1 =================
    async def set(self, key: str, value: Any, ttl: int = 0) -> None:
        """
        将任意对象序列化为 JSON 并存储,可设置物理 TTL
        内置:TTL 随机化防雪崩
        """
        try:
            json_str = json.dumps(value, default=str)
            if ttl > 0:
                await self.redis.setex(key, self._random_ttl(ttl), json_str)
            else:
                await self.redis.set(key, json_str)
        except RedisError as e:
            logger.error(f"[CacheClient] Redis set failed for {key}: {e}")
            # 生产环境:可在此投递 MQ 记录失败任务,由补偿线程重试

    # ================= 方法2 =================
    async def set_with_logical_expire(self, key: str, value: Any, expire_seconds: int) -> None:
        """
        将任意对象序列化为 JSON 并存储,设置逻辑过期时间(不设物理 TTL)
        用于解决缓存击穿问题
        """
        try:
            redis_data = RedisData(data=value, expire_time=int(time.time()) + expire_seconds)
            await self.redis.set(key, redis_data.to_json())
        except RedisError as e:
            logger.error(f"[CacheClient] Redis set_logical failed for {key}: {e}")

    # ================= 方法3 =================
    async def get(self, key: str, type_: type, ttl: int = 0, null_ttl: int = 300,
                  rebuild_func: Optional[Callable[..., Awaitable[Optional[T]]]] = None, *args, **kwargs) -> Optional[T]:
        """
        根据 key 查询缓存并反序列化,利用缓存空值解决缓存穿透
        """
        # 1. 尝试查询缓存(雪崩降级:Redis 不可用时直接走 DB)
        try:
            val = await self.redis.get(key)
        except (ConnectionError, TimeoutError):
            logger.warning("[CacheClient] Redis unavailable, fallback to rebuild_func directly")
            return await rebuild_func(*args,**kwargs) if rebuild_func else None
        except RedisError as e:
            logger.error(f"[CacheClient] Redis get error: {e}")
            return await rebuild_func(*args,**kwargs) if rebuild_func else None

        # 2. 命中空值(防穿透拦截)
        if val == "null":
            return None

        # 3. 命中有效数据
        if val is not None:
            try:
                return json.loads(val)
            except Exception:
                return val

        # 4. 缓存未命中:执行回源
        if rebuild_func is None:
            return None

        data = await rebuild_func(*args,**kwargs)
        if data is None:
            # 数据库中也不存在:缓存空值防穿透(带随机 TTL)
            if null_ttl > 0:
                try:
                    await self.redis.setex(key, self._random_ttl(null_ttl), "null")
                except RedisError:
                    pass
            return None
        else:
            # 数据库存在:正常缓存
            await self.set(key, data, ttl)
            return data

    # ================= 方法4 =================
    async def get_with_logical_expire(
        self, key: str, type_: type, expire_seconds: int,
        rebuild_func: Callable[..., Awaitable[Optional[T]]], *args,
        ttl: int = 0, null_ttl: int = 300,**kwargs
    ) -> Optional[T]:
        """
        根据 key 查询缓存,利用逻辑过期解决缓存击穿
        """
        # 1. 尝试查询缓存
        try:
            val = await self.redis.get(key)
        except (ConnectionError, TimeoutError):
            logger.warning("[CacheClient] Redis unavailable, fallback to rebuild_func")
            return await rebuild_func(*args,**kwargs)
        except RedisError as e:
            logger.error(f"[CacheClient] Redis get error: {e}")
            return await rebuild_func(*args,**kwargs)

        if val is None:
            # 冷启动:无逻辑过期结构,直接重建并转为逻辑过期存储
            data = await rebuild_func(*args,**kwargs)
            if data is None:
                await self.redis.setex(key, self._random_ttl(null_ttl), "null")
                return None
            await self.set_with_logical_expire(key, data, expire_seconds)
            return data

        if val == "null":
            return None

        # 2. 解析逻辑过期数据
        try:
            r_data = RedisData.from_json(val)
        except Exception as e:
            logger.warning(f"[CacheClient] Logical expire parse failed: {e}")
            # 兼容旧数据:直接返回
            try: return json.loads(val)
            except: return val

        # 3. 逻辑未过期:直接返回
        if not r_data.is_expired():
            return r_data.data

        # 4. 逻辑已过期:触发异步重建(防击穿核心)
        await self._try_rebuild_async(key, rebuild_func, *args,
                                      ttl=ttl, expire_seconds=expire_seconds, null_ttl=null_ttl,**kwargs)
        # 主线程不等待,立即返回旧数据保障性能
        return r_data.data

    # ================= 内部核心:带互斥锁的异步重建 =================
    async def _try_rebuild_async(
        self, key: str, rebuild_func: Callable[..., Awaitable[Optional[T]]],
        *args, ttl: int, expire_seconds: int, null_ttl: int,**kwargs
    ):
        lock_key = f"{self._lock_prefix}{key}"
        lock_val = str(uuid.uuid4())

        # 尝试获取分布式互斥锁(SET NX EX 原子操作)
        try:
            acquired = await self.redis.set(lock_key, lock_val, nx=True, ex=self.lock_timeout)
        except RedisError:
            return

        if not acquired:
            return  # 其他协程正在重建,直接退出

        # 提交后台任务,不阻塞主请求
        async def _rebuild_task():
            try:
                # 双重检查:防止锁等待期间已被其他线程重建
                val = await self.redis.get(key)
                if val is not None:
                    try:
                        r_data = RedisData.from_json(val)
                        if not r_data.is_expired():
                            return
                    except Exception:
                        pass

                # 执行耗时重建
                data = await rebuild_func(*args,**kwargs)
                if data is not None:
                    await self.set_with_logical_expire(key, data, expire_seconds)
                else:
                    await self.redis.setex(key, self._random_ttl(null_ttl), "null")
            except Exception as e:
                logger.error(f"[CacheClient] Async rebuild failed for {key}: {e}")
            finally:
                # 参数化 Lua 释放锁
                lua_script = """
                            if redis.call('get', KEYS[1]) == ARGV[1] then
                                return redis.call('del', KEYS[1])
                            else
                                return 0
                            end
                            """
                try:
                    await self.redis.eval(lua_script, 1, lock_key, lock_val)
                except Exception as e:
                    logger.warning(f"[CacheClient] Lock release failed for {key}: {e}")

        asyncio.create_task(_rebuild_task())

使用示例(FastAPI)

python 复制代码
from fastapi import FastAPI, HTTPException
import redis.asyncio as aioredis

app = FastAPI()
redis_client = aioredis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True)
cache_client = CacheClient(redis_client, lock_timeout=10)

async def query_shop_from_db(shop_id: int) -> dict | None:
    # 模拟 ORM 查询
    return {"id": shop_id, "name": "星巴克旗舰店", "type": 1} if shop_id == 1 else None

@app.get("/shops/{shop_id}")
async def get_shop(shop_id: int):
    cache_key = f"shop:{shop_id}"
    
    # 调用工具类方法4:自动处理逻辑过期、互斥锁重建、空值防穿透、异常降级
    shop = await cache_client.get_with_logical_expire(
        key=cache_key,
        type_=dict,
        expire_seconds=3600,      # 逻辑过期 1 小时
        rebuild_func=query_shop_from_db,
        shop_id=shop_id,
        ttl=3600,                 # 物理 TTL(备用)
        null_ttl=300              # 空值 TTL 5 分钟
    )
    
    if shop is None:
        raise HTTPException(status_code=404, detail="Shop not found")
    return shop

4.3 工具类优势

代码复用

业务层无需重复编写缓存查询、序列化、异常处理逻辑,仅需传入Key、重建函数、过期参数,即可自动完成缓存管理。

统一规范

所有缓存操作遵循同一套序列化规则(JSON)、异常处理机制(捕获Redis异常)、空值处理策略(缓存null),避免不同开发者实现差异导致的Bug。

灵活扩展

工具类支持多种缓存策略(TTL、逻辑过期、缓存空值)的组合使用,可根据业务需求灵活配置。后续可轻松扩展支持压缩、加密、分布式锁等功能。

五、总结

5.1 三大问题对比

问题类型 核心特征 根本原因 主流方案
缓存穿透 请求的数据不存在 缓存与数据库均无数据 缓存空值、布隆过滤器
缓存雪崩 大量Key同时失效 TTL集中过期或Redis宕机 TTL随机化、Redis集群、降级限流
缓存击穿 热点Key突然失效 高并发+重建耗时 互斥锁、逻辑过期

5.2 企业级缓存架构设计原则

分层防护

构建布隆过滤器 → 本地缓存 → Redis缓存 → 数据库的多层防护体系,逐层拦截无效请求。

降级预案

缓存层必须具备降级能力。Redis不可用时,自动降级至本地缓存或直接查库,确保核心业务不中断。

监控告警

实时监控缓存命中率、Redis内存使用率、QPS、响应时间等指标。命中率低于80%或响应时间超过100ms时触发告警。

容量规划

根据业务数据量与访问模式,合理估算Redis内存需求。预留30%冗余空间,避免内存打满触发淘汰。

数据预热

系统启动或大促前,提前加载热点数据至缓存,避免冷启动冲击数据库。

相关推荐
QiLinkOS1 小时前
发明人与专利价值共生逻辑
c语言·数据结构·c++·人工智能·单片机·嵌入式硬件·算法
Rick19931 小时前
Redis 高频面试 10 题
数据库·redis·面试
Rick19932 小时前
什么是Redis的 IO 多路复用
redis·缓存
南境十里·墨染春水2 小时前
数据结构 ——BST 树
数据结构
江屿风2 小时前
C++图的基本概念流食般投喂-竞赛编
开发语言·数据结构·c++·笔记·算法·图论
Java 码思客2 小时前
【Redis分布式缓存实战】第2章 Redis核心数据结构与业务实战场景
redis·分布式·缓存
Byte不洛2 小时前
哈希表原理 + 冲突解决 + C++实现
数据结构·c++·算法·哈希算法·散列表
Rick19933 小时前
Redis 分布式锁 + 部署模式
redis·分布式
程序员老邢11 小时前
《技术底稿 43》今日踩坑复盘:Redis 乱码 + MySQL 配置注入失败
redis·技术底稿·redisson 序列化·mysql 配置·项目踩坑·微服务问题排查