提示

返回博客列表

加了缓存反而更慢了:Redis 缓存的穿透、击穿、雪崩与一致性

给列表页加缓存本来是为了提速。加上之后确实快了——QPS 从 60 涨到 400,然后我就去干别的了。

两周后连续出问题:

  1. 有个页面的缓存命中率一直是 0,加了等于没加(穿透);
  2. 某个热点数据过期的一瞬间,数据库 QPS 冲到平时的 20 倍,差点打挂(击穿);
  3. 有天下午缓存集体失效,数据库直接被冲垮,全站 502(雪崩);
  4. 运营改了内容,页面上还是旧的,改完半小时才生效(一致性)。

这四个问题在教科书里都有名字,但真到自己系统里出的时候,表现跟书上写的不太一样。这篇把我实际遇到的现象、排查过程和最后的代码写下来。

TL;DR:缓存问题的四个经典坑——穿透(查不存在的 key,永远回源)用空值缓存或布隆过滤器挡;击穿(热点 key 过期瞬间大量回源)用互斥锁(cache.add 做 SETNX)或逻辑过期;雪崩(大批 key 同时过期)用过期时间加随机抖动;一致性用 Cache Aside(先更新数据库、再删缓存),不要"先删缓存再更新库"。另外:Redis 不要和 Celery broker 共用一个库,大 key 和热 key 要定期扫描,纯缓存场景 maxmemory-policy 用 allkeys-lru。

目录

一、先想清楚:什么值得缓存

不是所有东西都该缓存。我的判断标准(三条全中才缓存):

条件 说明 反例
读多写少 读的次数远大于写 每请求都变的计数器不该缓存
计算/查询代价高 数据库查询慢或者计算贵 主键查询单条记录,加缓存收益极小
能容忍短暂不一致 几十秒到几分钟的延迟可接受 库存、余额、支付状态不能缓存

不适合缓存的典型场景:

  • 实时性要求高的数据(库存、订单状态);
  • 每个用户都不一样且访问频次低的数据(缓存命中率低,白白占内存);
  • 数据量极小、查询本身很快的东西(比如查一条配置,0.5ms,缓存反而多一次网络往返)。

有个简单的判断方法:看命中率。低于 50% 的缓存基本是浪费内存,要么调整 key 粒度,要么干脆去掉。

我们的命中率监控(利用 django-redis 的统计,或者自己埋点):

# 简单埋点:在统一的缓存工具里统计
import logging
from django.core.cache import cache

logger = logging.getLogger('cache')
_stats = {'hit': 0, 'miss': 0}


def cached_get(key):
    val = cache.get(key)
    _stats['hit' if val is not None else 'miss'] += 1
    return val

上线一周后看 hit / (hit + miss),低于 0.6 的我都会复查。

二、Django 里怎么用缓存

配置(我们用的是 django-redis):

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': 'redis://:密码@127.0.0.1:6379/2',   # 用独立的 db,别跟 Celery 混
        'OPTIONS': {
            'client_class': 'django_redis.client.DefaultClient',
            'CONNECTION_POOL_KWARGS': {'max_connections': 50},
        },
        'KEY_PREFIX': 'viddown',
        'TIMEOUT': 300,
    }
}

KEY_PREFIX 一定要设。它的作用是把所有 key 加一个前缀,好处是:

  • 多个应用共用一个 Redis 时不打架;
  • 需要整体失效的时候知道该删哪些(cache.delete_pattern('viddown:*'));
  • 部署多套环境(测试/生产)共用一个 Redis 时不会串(虽然不推荐共用)。

常用写法:

from django.core.cache import cache

# 基础
cache.set('key', value, timeout=300)
value = cache.get('key')                    # 不存在返回 None
cache.delete('key')

# 不存在时才设置(原子,可当锁用)
added = cache.add('lock:xxx', '1', timeout=10)     # 返回 True/False

# 拿不到就用默认值算一个并写回
value = cache.get_or_set('key', lambda: expensive_query(), timeout=300)

# 批量
cache.set_many({'a': 1, 'b': 2}, timeout=300)
cache.get_many(['a', 'b'])

# 自增(计数场景)
cache.incr('counter:xxx')

get_or_set 很好用但它不防击穿(并发下多个线程都会执行那个 expensive 函数)。防击穿见第四节。

视图级缓存:

from django.views.decorators.cache import cache_page

@cache_page(60 * 5)          # 缓存 5 分钟,按 URL 做 key
def index(request):
    ...

@cache_page 对登录用户要小心:它会给同一个 URL 的所有用户返回同一份内容。如果页面上有用户名之类的个性化内容就串了。解决办法是用 vary_on_cookie:

from django.views.decorators.vary import vary_on_cookie

@cache_page(300)
@vary_on_cookie
def index(request):
    ...

或者干脆不用 cache_page,自己按用户维度做 key 更可控。我一般只在"完全公开且不变的页面"上用 cache_page。

三、穿透:一个永远不命中的 key

现象:监控发现某个接口的缓存命中率长期是 0,数据库 QPS 没降。

原因:这个接口查的是"某个 ID 的资源详情",而攻击者(或者爬虫)不断用不存在的 ID 来请求。逻辑是:

def get_detail(pk):
    data = cache.get(f'detail:{pk}')
    if data is None:
        data = query_db(pk)          # 查不到,返回 None
        if data:                      # ← 只有查到才写缓存
            cache.set(f'detail:{pk}', data, 300)
    return data

不存在的数据永远不会写缓存,于是每次请求都打到数据库。如果有人用脚本循环请求不存在的 ID,数据库就一直被刷。

解法 1:空值缓存(最实用)

NULL = object()          # 用一个哨兵对象表示"查过了,确实没有"

def get_detail(pk):
    key = f'detail:{pk}'
    data = cache.get(key)
    if data is None:
        data = query_db(pk) or NULL
        # 空值缓存时间要短(60 秒),避免真的新增了却长时间查不到
        cache.set(key, data, 60 if data is NULL else 300)
    return None if data is NULL else data

关键点:

  • 用哨兵对象而不是 None,因为 cache.get 返回 None 既可能是"没缓存"也可能是"缓存了空值",无法区分;
  • 空值的过期时间要短(30~60 秒),否则新增的数据要等很久才能查到。

解法 2:布隆过滤器(数据量极大时)

把所有存在的 ID 放进布隆过滤器,查询前先过一遍,不存在的直接返回。Redis 有 RedisBloom 模块,Python 侧可以用 pybloom-live。

我的建议:先用解法 1。布隆过滤器引入了额外的一致性问题(新增数据要同步加入过滤器),除非数据量到了千万级、空值缓存撑不住,否则不值得。

解法 3:参数校验——最容易被忽略但最有效。如果 ID 是正整数,请求 ?id=abc 或 ?id=-1 直接在入口层挡掉。

四、击穿:热点 key 过期的那一瞬间

现象:某个 key 过期后的 1~2 秒内,数据库 QPS 出现尖峰(我们观察到过 20 倍),随后恢复。

原因:这个 key 是热点(比如首页的推荐列表),QPS 很高。它过期的瞬间,所有并发请求同时发现缓存没了,同时去查数据库。

# 有问题的写法:并发下 N 个请求全部执行 expensive_query()
value = cache.get(key)
if value is None:
    value = expensive_query()      # ← 100 个请求同时执行
    cache.set(key, value, 300)

解法 1:互斥锁(推荐)

import time
from django.core.cache import cache

def get_with_lock(key, loader, ttl=300, lock_ttl=10, wait=0.05, max_wait=3.0):
    """缓存击穿防护:只有一个请求去加载,其他等待并复用结果。"""
    value = cache.get(key)
    if value is not None:
        return value

    lock_key = f'lock:{key}'
    # cache.add 是原子的(SETNX),只有第一个能拿到锁
    if cache.add(lock_key, '1', timeout=lock_ttl):
        try:
            value = loader()
            cache.set(key, value if value is not None else '', ttl)
            return value
        finally:
            cache.delete(lock_key)
    else:
        # 没拿到锁:短暂等待后重试读缓存
        waited = 0.0
        while waited < max_wait:
            time.sleep(wait)
            waited += wait
            value = cache.get(key)
            if value is not None:
                return value
        # 兜底:实在等不到就自己查一次(避免死锁导致永久不可用)
        return loader()

要点:

  1. cache.add 对应 Redis 的 SETNX,是原子的,不会两个请求同时拿到锁;
  2. 锁必须有过期时间(lock_ttl),否则持锁进程挂了就是死锁;
  3. finally 里释放锁;
  4. 等待有上限,超时后自己查一次兜底(可用性优先于一致性)。

解法 2:逻辑过期(热点数据永不过期)

不给 key 设 Redis 过期,而是把过期时间存进 value 里:

import json
import time

def get_logical(key, loader, ttl=300):
    raw = cache.get(key)
    now = time.time()
    if raw:
        payload = json.loads(raw)
        if payload['expire_at'] > now:
            return payload['data']          # 没过期,直接返回
        # 逻辑过期:返回旧值,同时异步刷新
        if cache.add(f'lock:{key}', '1', timeout=10):
            try:
                data = loader()
                cache.set(key, json.dumps({'data': data, 'expire_at': now + ttl}))
            finally:
                cache.delete(f'lock:{key}')
        return payload['data']              # 返回旧数据,不阻塞
    data = loader()
    cache.set(key, json.dumps({'data': data, 'expire_at': now + ttl}))
    return data

好处:用户永远不会被阻塞(拿不到锁就直接返回旧值),代价是可能短暂看到过期数据。适合"热点 + 对实时性要求不高"的场景,比如首页推荐、排行榜。

我们最后的做法:热点数据(首页、热门列表)走逻辑过期,普通数据走互斥锁。

五、雪崩:一批 key 集体失效

现象:某天下午 14:00 整,数据库 QPS 瞬间冲到 8000(平时 400),持续 3 分钟后全站 502。

原因:我们有一批数据是"每天凌晨 2 点预热 + 缓存 12 小时",还有一批是"发布时批量写入"。加上有一次批量导入时,脚本给 3000 个 key 设了完全相同的过期时间——它们在同一秒集体失效。

解法:

1. 过期时间加随机抖动(最简单有效)

import random

def set_with_jitter(key, value, base_ttl=300):
    ttl = base_ttl + random.randint(0, base_ttl // 5)   # ±20% 抖动
    cache.set(key, value, ttl)

300 秒的 key,实际过期时间在 300~360 秒之间随机分布,3000 个 key 就散开在 60 秒的窗口里,数据库压力被摊平。

我们的规范要求:所有批量写入缓存的地方,必须加抖动。 现在我写 cache.set 几乎都走封装函数,不直接写死 timeout。

2. 多级缓存(本地内存 + Redis)

from django.core.cache import cache
from django.core.cache.backends.locmem import LocMemCache

local = LocMemCache('local', {'OPTIONS': {'MAX_ENTRIES': 1000}})

def get_two_level(key, loader, ttl=300):
    v = local.get(key)                     # 一级:进程内,无网络开销
    if v is None:
        v = cache.get(key)                 # 二级:Redis
        if v is None:
            v = loader()
            cache.set(key, v, ttl + random.randint(0, 60))
        local.set(key, v, min(60, ttl))    # 本地短一点,避免太旧
    return v

即使 Redis 全部失效,本地缓存还能扛一波。代价是本地缓存不一致窗口更长、多进程之间不一致。只在"能容忍不一致"的数据上用。

3. 缓存预热

在业务低峰(凌晨)主动把热点数据加载进缓存,而不是等用户访问时才加载。我们有个 beat 任务做这件事(并且预热时也加抖动)。

4. 兜底:数据库侧的保护

即使雪崩发生了,也要保证数据库不被打死:

  • 前面那篇讲的 statement_timeout;
  • 数据库的 max_connections 留余量;
  • 应用层的并发闸门(限制同时回源的线程数)。

六、一致性:更新了数据库,缓存怎么办

这是最容易被忽略、也最容易出线上问题的一节。

先说结论:用 Cache Aside,且顺序是"先更新数据库,再删缓存"

def update_resource(pk, **kwargs):
    # 1. 更新数据库
    obj = Resource.objects.get(pk=pk)
    for k, v in kwargs.items():
        setattr(obj, k, v)
    obj.save()
    # 2. 删除缓存(不是更新缓存!)
    cache.delete(f'detail:{pk}')

为什么是"删"而不是"更新"缓存?

因为更新缓存的代价更高且更容易出错:你可能在缓存里写了一个和数据库不完全一致的对象(比如漏了某个字段),或者并发写入导致顺序错乱。删掉最简单,下次读的时候自然重建。

为什么是"先数据库,后缓存"?

看反过来的顺序会出什么事:

请求 A(更新):删除缓存  →  更新数据库(还没完成)
请求 B(读取):缓存没了 → 查数据库(拿到旧值!)→ 写入缓存(旧值)
请求 A(更新):更新数据库完成
结果:数据库是新值,缓存是旧值 → 永久不一致,直到缓存过期

而"先数据库后删缓存":

请求 A(更新):更新数据库 → 删除缓存
请求 B(读取):缓存有旧值 → 返回旧值(短暂不一致)
请求 C(读取):缓存没了 → 查数据库(新值)→ 写缓存(新值)
结果:只有一个很短的窗口会返回旧值,之后自动恢复

"先库后删"只在一种情况下出问题:删除缓存失败(网络抖动)。所以生产上我会加一次延迟双删:

import threading
import time

def update_resource(pk, **kwargs):
    obj = Resource.objects.get(pk=pk)
    for k, v in kwargs.items():
        setattr(obj, k, v)
    obj.save()
    cache.delete(f'detail:{pk}')
    # 延迟双删:500ms 后再删一次,清掉"删缓存期间被写进去的旧值"
    threading.Timer(0.5, lambda: cache.delete(f'detail:{pk}')).start()

用一个后台线程(或者扔给 Celery 的 apply_async(countdown=1))延迟几百毫秒再删一次。这是个低成本的保险,能覆盖掉大部分并发窗口。

批量更新怎么办

如果一个操作影响了很多 key(比如改了一个分类,下面 500 篇文章的缓存都要失效),逐个删会很慢。用模式删除:

from django.core.cache import cache

# django-redis 提供(需要 DefaultClient)
cache.delete_pattern('viddown:detail:*')

delete_pattern 内部是 SCAN + 删除,不会阻塞 Redis(不像 KEYS)。但数据量大时依然有开销,别在请求里做——扔给异步任务。

另一个思路:版本号前缀。改分类不删 key,而是把版本号 +1,key 变成 v2:detail:123,旧的自然过期。彻底避免批量删除。

def cache_key(kind, pk):
    version = cache.get(f'version:{kind}', 1)
    return f'v{version}:{kind}:{pk}'

适合"整类数据一起失效"的场景,比 delete_pattern 优雅得多。

七、key 怎么设计

几条规范(踩过坑之后的):

<前缀>:<业务>:<标识>[:<子标识>]
viddown:detail:12345
viddown:list:page:2:tag:ffmpeg
viddown:user:99:quota
  1. 统一前缀(KEY_PREFIX 或手动),便于整体管理和删除;
  2. 不要有空格和特殊字符(Redis 无所谓,但调试和 delete_pattern 时麻烦);
  3. key 不要太长(占内存,且 MEMORY USAGE 会变大)。用 ID 而不是完整标题;
  4. value 不要太大(见下节大 key);
  5. 版本化:结构变更时换 key 前缀,避免旧数据解析失败。

序列化方面,django-redis 默认用 pickle:

'OPTIONS': {
    'client_class': 'django_redis.client.DefaultClient',
    'SERIALIZER': 'django_redis.serializers.json.JSONSerializer',   # 可选
}

pickle 能存任意 Python 对象,但有安全风险(反序列化可执行代码,如果 Redis 被入侵);json 更安全、跨语言可读,但只能存基础类型。我们用的默认 pickle,因为要缓存一些模型对象和 datetime;如果你的 Redis 暴露在不太可信的网络里,换成 json。

八、Redis 侧:内存、大 key、热 key

内存与淘汰策略

maxmemory 2gb
maxmemory-policy allkeys-lru

纯缓存场景用 allkeys-lru:内存满了淘汰最久没用的 key,不管它有没有设过期时间。

但如果这个 Redis 还存了别的东西(比如 Celery 的 broker、或者持久化的业务数据),绝不能用 allkeys-lru——它会把你的任务消息或者持久数据淘汰掉。这时候用 volatile-lru(只淘汰设了过期时间的 key)。

我们的做法:缓存和 Celery broker 用不同的 db index,但同一个 Redis 进程。这样 maxmemory-policy 只能设一个,所以要保守(用 volatile-lru,并且给缓存 key 都设过期时间)。更稳妥是分开两个 Redis 实例——量不大的时候一个实例两个 db 够用,但要知道风险。

绝对不要做的事:在存了 Celery 队列的 db 上执行 FLUSHDB。 我见过有人为了清缓存 redis-cli -n 0 FLUSHDB,结果把几千个待处理任务清掉了。

大 key

单个 key 的 value 太大(比如缓存了一个 10MB 的列表)会导致:

  • 读取时网络传输慢,阻塞其他请求;
  • 删除时(尤其是集合类)卡顿;
  • 内存碎片。

扫描:

redis-cli --bigkeys
redis-cli MEMORY USAGE viddown:list:xxx

我们的规范:单个 value 不超过 1MB,集合元素不超过 1 万个。 超了就拆(分页缓存)或者改存数据库。

热 key

某个 key 被极高频率访问(比如首页 banner),会打满单个 Redis 实例的 CPU(Redis 单线程)。

发现办法:

  • redis-cli --hotkeys(需要 maxmemory-policy 是 LFU 类);
  • 客户端埋点统计。

缓解:

  • 本地缓存(前面讲的多级缓存);
  • 拆成多个 key(比如 banner:1 ~ banner:10,随机读一个),把压力分散(Redis Cluster 下能分散到不同节点)。

九、实测数据

优化前后的对比(列表页接口):

指标 无缓存 朴素缓存 加防护后
QPS 62 410 405
P95 延迟 780 ms 45 ms 48 ms
数据库 QPS 62 15 8
缓存命中率 — 87% 96%
击穿尖峰(过期瞬间 DB QPS) — 1200+ 22
雪崩事故 — 2 次 0

朴素缓存(不加任何防护)的时候,虽然平均性能很好,但过期瞬间有 1200 QPS 的尖峰打向数据库。加了互斥锁和抖动之后,尖峰降到 22。

这个对比说明一件事:缓存的"平均效果"好看不代表系统稳。真正的风险在那些瞬间的尖峰。

十、坑清单

  1. cache.get 返回 None 分不清"没缓存"和"缓存了空值" → 用哨兵对象。
  2. 查不到就不写缓存 → 穿透,一直打到数据库。空值缓存(短 TTL)。
  3. get_or_set 以为能防击穿 → 不能,并发下都会执行 loader。要加锁。
  4. 锁没有过期时间 → 持锁进程挂了就死锁。
  5. 批量写缓存用相同 TTL → 雪崩。加随机抖动。
  6. 先删缓存再更新数据库 → 并发下写入旧值,永久不一致。改成先库后删。
  7. 删除缓存失败没有补偿 → 延迟双删。
  8. 批量失效用 KEYS 命令 → 阻塞 Redis。用 SCAN(delete_pattern)。
  9. 缓存和 Celery 共用一个 db,然后 FLUSHDB → 任务全没了。
  10. 纯缓存场景没设 maxmemory → Redis 内存无限增长直到被 OOM kill。
  11. 混用场景用了 allkeys-lru → 淘汰掉不该淘汰的持久数据/队列消息。
  12. 缓存了大对象(几 MB) → 网络阻塞、删除卡顿。拆分页。
  13. @cache_page 用在个性化页面 → 所有用户看到同一份内容。加 vary_on_cookie 或自己控制 key。
  14. 不监控命中率 → 加了等于没加也不知道。
  15. 缓存了不该缓存的(库存、余额、权限) → 数据错误,比慢更严重。
  16. key 没前缀 → 多应用冲突、无法批量清理。

最后说说我对缓存的整体看法。

缓存是"用一致性换性能"的交易,所以每次加缓存都应该明确回答两个问题:

  1. 这个数据能容忍多久的延迟?(决定 TTL)
  2. 如果缓存挂了/被清空,系统会怎样?(决定要不要熔断保护)

第一个问题决定 TTL 设多长——我见过 TTL 设 24 小时的"配置缓存",改个配置要等一天才生效,这种就是没想清楚。

第二个问题更重要:很多系统加缓存之后,就变成了"依赖缓存才能扛住"。一旦 Redis 挂了或者被清空,数据库瞬间被打垮。所以要么保证数据库能扛住全量(哪怕慢一点),要么在缓存失效时有限流/熔断保护。别让缓存成为系统的单点。

我们现在的做法是:所有回源路径都经过限流闸门(下一节会讲),即使缓存全丢,数据库收到的请求也是被限制过的,最多是变慢,不会挂。这个设计让我们在两次 Redis 重启中都没有出事故。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部