Python 缓存策略:Redis 缓存失效、缓存击穿、缓存雪崩解决方案

小飞兽 Python 13 次阅读 2026-07-24

Introduction

缓存是提升 Web 应用性能的杀手锏,但用不好反而会引入新问题。Redis 缓存有三种经典坑:缓存失效(缓存过期后大量请求同时打穿数据库)、缓存击穿(热点 key 过期瞬间大量并发请求直达 DB)、缓存雪崩(大量 key 集中过期导致数据库被打爆)。本文用 Python + Redis 演示三种场景的成因和应对方案。

---

1. 缓存失效(Cache Miss Storm)

场景

高并发下,某个热点数据缓存过期后,大量请求同时发现缓存不存在,全部去查数据库,导致数据库负载瞬间飙升。

演示

import redis, time, random, threading

r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

# ---------- 问题演示:无锁 ----------
def get_user_no_lock(user_id):
    """没有并发保护的查库——大量并发时会产生缓存失效风暴"""
    cache_key = f"user:{user_id}"
    cached = r.get(cache_key)
    if cached:
        return f"缓存命中: {cached}"

    # 模拟数据库查询(100ms)
    time.sleep(0.1)
    data = f"user_data_{user_id}_{random.randint(1000,9999)}"

    # 写入缓存
    r.setex(cache_key, ttl=1, value=data)  # TTL=1秒,故意设短模拟过期
    return f"数据库查询: {data}"

# ---------- 解决方案:分布式锁 ----------
from contextlib import contextmanager

_lock_redis = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

@contextmanager
def distributed_lock(key, timeout=5):
    """Redis SETNX 实现简单分布式锁"""
    lock_key = f"lock:{key}"
    acquired = _lock_redis.set(lock_key, "1", nx=True, ex=timeout)
    if not acquired:
        # 未拿到锁,等待锁释放后重试(最多3次)
        for _ in range(3):
            time.sleep(0.05)
            if _lock_redis.get(lock_key) is None:
                break
        else:
            raise RuntimeError(f"获取锁失败: {key}")
    try:
        yield
    finally:
        _lock_redis.delete(lock_key)

def get_user_with_lock(user_id):
    """加锁后查库,同一时刻只有一个请求能查数据库"""
    cache_key = f"user:{user_id}"
    cached = r.get(cache_key)
    if cached:
        return f"缓存命中: {cached}"

    try:
        with distributed_lock(cache_key):
            # 双重检查(拿到锁后再次确认缓存)
            cached = r.get(cache_key)
            if cached:
                return f"双重检查缓存命中: {cached}"
            time.sleep(0.1)  # 模拟 DB 查询
            data = f"user_data_{user_id}_{random.randint(1000,9999)}"
            r.setex(cache_key, ttl=60, value=data)
            return f"数据库查询并写入缓存: {data}"
    except RuntimeError as e:
        # 锁获取失败,降级为直接查库
        return f"降级查库: {e}"

运行效果

无锁版本:TTL=1秒,过期后多个并发请求同时 miss,全部去查数据库。加锁版本:同一时刻只有一个请求能查库,其他请求等锁释放后直接读缓存。

---

2. 缓存击穿(Hot Key Expiration)

场景

某个 key 是热点数据(比如微博热搜),过期瞬间有 10 万并发请求进来,全部打到数据库,数据库直接挂掉。

解决方案对比

| 方案 | 原理 | 优点 | 缺点 |
|------|------|------|------|
| 分布式锁 | 只允许一个请求查库 | 彻底解决问题 | 有锁开销 |
| 永不过期 + 异步重建 | value 存结构体(含数据+时间戳),异步更新 | 无锁,无失效期 | 实现复杂,空间占用大 |
| 逻辑过期 | 热点 key 过期后返回旧数据,同时异步更新 | 体验好 | 旧数据窗口期 |

演示:逻辑过期方案

import json, time, random
from threading import Thread

r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

# ---------- 预热:写入带逻辑过期时间的数据 ----------
def init_hot_key():
    data = {
        "user_id": 42,
        "username": "alice",
        "votes": 9527,
        "logic_expire_at": time.time() + 5  # 逻辑过期时间:5秒后
    }
    r.set("hot:topic:1", json.dumps(data))
    print(f"预热完成,当前 votes={data['votes']},逻辑过期时间={data['logic_expire_at']}")

# ---------- 异步重建缓存 ----------
def async_rebuild():
    time.sleep(0.2)  # 模拟 DB 查询耗时
    new_data = {
        "user_id": 42,
        "username": "alice",
        "votes": 9999,  # 新数据
        "logic_expire_at": time.time() + 5
    }
    r.set("hot:topic:1", json.dumps(new_data))
    print(f"[异步重建] votes 更新为 {new_data['votes']}")

# ---------- 读数据(逻辑过期) ----------
def get_hot_data():
    raw = r.get("hot:topic:1")
    if not raw:
        return None
    data = json.loads(raw)

    # 逻辑过期:缓存有效但时间戳已过
    if time.time() > data["logic_expire_at"]:
        # 启动异步重建(不等完成直接返回旧数据)
        Thread(target=async_rebuild, daemon=True).start()
        return data  # 仍返回旧数据,体验上只是"稍旧"
    return data

# ---------- 演示 ----------
init_hot_key()
print("\n=== 模拟 3 次请求(5秒内)===")
for i in range(3):
    result = get_hot_data()
    print(f"请求{i+1} 返回: votes={result['votes']}")
    time.sleep(2)

print("\n=== 5秒后再次请求(逻辑已过期)===")
time.sleep(2)
result = get_hot_data()
print(f"请求4 返回: votes={result['votes']}")

运行效果

前 3 次请求(5秒内)直接读缓存,返回 votes=9527。第 4 次请求时逻辑已过期,触发异步重建,返回 votes=9527(旧数据),后台同时更新缓存,更新后 votes=9999。

---

3. 缓存雪崩(Cache Avalanche)

场景

大量 key 同时过期(TTL 设成同一值),导致某一时刻大量请求同时 miss,全部打向数据库。

解决方案

方案 A:TTL 随机化,不让大量 key 同时过期:

import random

def set_with_jitter(key, value, base_ttl=3600):
    """在 base_ttl 基础上加随机抖动(0 ~ 300秒),防止雪崩"""
    ttl = base_ttl + random.randint(0, 300)
    r.setex(key, ttl=ttl, value=value)
    print(f"写入 {key},TTL={ttl}秒")

# 批量写入时加随机抖动
for i in range(100):
    set_with_jitter(f"product:{i}", f"product_data_{i}", base_ttl=3600)

方案 B:二级缓存,本地内存做备份:

from functools import wraps
import redis, json, time, threading

_local_cache = {}  # 本地缓存 dict(生产环境用 LRU Cache)

def get_with_local_cache(redis_conn, key, ttl=60):
    """先查本地内存,再查 Redis,最后查 DB"""
    # 1. 本地缓存
    if key in _local_cache:
        val, expire_at = _local_cache[key]
        if time.time() < expire_at:
            return f"[本地缓存] {val}"

    # 2. Redis
    val = redis_conn.get(key)
    if val:
        _local_cache[key] = (val, time.time() + 10)  # 本地缓存10秒
        return f"[Redis缓存] {val}"

    # 3. 数据库(模拟)
    val = f"db_data_for_{key}"
    redis_conn.setex(key, ttl=ttl, value=val)
    return f"[数据库查询] {val}"

方案 C:服务熔级降级,数据库撑不住时直接返回默认值:

from typing import Optional

def get_or_default(user_id: int, default: str = "数据加载中") -> str:
    cache_key = f"user:{user_id}"
    try:
        val = r.get(cache_key)
        if val:
            return val
        # 数据库查询(省略)
        val = f"user_data_{user_id}"
        r.setex(cache_key, ttl=3600, value=val)
        return val
    except redis.exceptions.ConnectionError:
        # Redis 连不上,降级返回默认值
        print("Redis 连接失败,降级返回默认值")
        return default
    except Exception as e:
        print(f"查询异常: {e},降级返回默认值")
        return default

---

常见问题

Q1: 锁的 key 和数据的 key 能一样吗? 不能。锁的 key 是 lock:user:42,数据的 key 是 user:42,两者分开。如果混了,删除锁时会误删数据。

Q2: 分布式锁用什么 Redis 命令?SET key value NX EX timeout,这是原子操作。不要用 SETNX + EXPIRE 两步(两步之间进程崩溃会导致锁永久不释放)。

Q3: 本地缓存和 Redis 缓存怎么选? 小数据用本地缓存(进程内,无网络开销),大数据用 Redis。本地缓存注意设置 LRU(最近最少使用)上限,防止内存溢出。

Q4: 缓存雪崩和缓存击穿有什么区别? 雪崩是大量 key 同时过期造成批量 DB 压力;击穿是单个热点 key 过期造成瞬时 DB 压力。解决方案不同:雪崩靠 TTL 随机化,击穿靠锁或逻辑过期。

Q5: Redis 内存满了怎么办?maxmemory-policy(淘汰策略)。推荐 allkeys-lru(优先淘汰最近最少使用的 key);对实时性要求高的场景用 volatile-lru(只淘汰设了过期时间的 key)。

---

延伸阅读

  • <a href="/category/12">Python Redis 快速入门:安装、基础命令、Python 操作实战</a> — Redis 基础操作
  • <a href="/category/8">Python 标准库专题</a> — 更多 Python 性能相关教程
  • <a href="/category/12">数据库连接池专题</a> — DB 连接优化实战

---

作者:小马 | 绍大技术网 shaoda.net