Redis 缓存穿透怎么解决?3 种方案实测 + 踩坑全记录(2026)

上周线上出了事故,凌晨两点被报警电话叫醒------某个查询接口的 QPS 突然飙到平时的 20 倍,MySQL 直接扛不住了,慢查询堆满了。排查下来,是有人拿一堆不存在的 ID 疯狂请求,所有请求全部穿透 Redis 打到了数据库。

缓存穿透这个概念谁都知道,但真正线上被打一次才知道有多疼。事后把三种主流方案都实测了一遍,踩了不少坑,这篇把完整过程分享出来。

先说结论

缓存穿透 = 查询的 key 在 Redis 和数据库中都不存在,每次请求都打穿到 DB。 这和缓存击穿(热 key 过期)、缓存雪崩(大量 key 同时过期)是完全不同的问题。

三种方案的适用场景直接列一下:

  • 缓存空值:最简单,适合偶发性穿透、数据量不大的场景
  • 布隆过滤器:内存占用小,适合海量数据场景,但有误判率
  • 请求校验 + 限流:防恶意攻击,治标不治本但必须做

最终选择是布隆过滤器 + 缓存空值组合拳,下面展开说。

为什么会出现缓存穿透

正常的缓存流程大家都熟:
命中
未命中
存在
不存在
客户端请求
Redis 有缓存?
返回缓存数据
查询 MySQL
数据存在?
写入 Redis + 返回数据
返回空 / 直接返回
下次请求还是打到 DB

问题出在最后那个循环------key 不存在时不会写缓存,导致每次都穿透到 DB。如果有人故意拿 user_id = -1 或者一堆随机 UUID 来查,Redis 形同虚设,DB 直接被打穿。

我那次线上事故,攻击者用的是递增的负数 ID,简单粗暴但很有效。

方案一:缓存空值(Null Caching)

最直觉的方案:查询结果为空也缓存起来,设一个较短的 TTL。

代码实现

python 复制代码
import redis
import json

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

# 空值标记,不要用 None 或空字符串,容易和正常值混淆
EMPTY_CACHE_FLAG = "@@EMPTY@@"
EMPTY_CACHE_TTL = 120 # 空值缓存 2 分钟
NORMAL_CACHE_TTL = 3600 # 正常数据缓存 1 小时


def get_user_by_id(user_id: int) -> dict | None:
 cache_key = f"user:{user_id}"
 
 # 1. 先查 Redis
 cached = r.get(cache_key)
 
 if cached is not None:
 if cached == EMPTY_CACHE_FLAG:
 # 命中空值缓存,直接返回 None,不打 DB
 return None
 return json.loads(cached)
 
 # 2. Redis 没有,查 DB
 user = query_user_from_db(user_id)
 
 if user is None:
 # 3. DB 也没有,缓存空值
 r.setex(cache_key, EMPTY_CACHE_TTL, EMPTY_CACHE_FLAG)
 return None
 
 # 4. DB 有数据,正常缓存
 r.setex(cache_key, NORMAL_CACHE_TTL, json.dumps(user))
 return user


def query_user_from_db(user_id: int) -> dict | None:
 """模拟数据库查询"""
 # 实际项目中这里是 SQL 查询
 # SELECT * FROM users WHERE id = %s
 pass

实测效果

用 wrk 模拟了 1000 个不存在的 ID 并发请求:

  • 不加空值缓存:MySQL QPS 直接拉满到 3000+,响应时间飙到 2s
  • 加了空值缓存:只有第一波请求会打到 DB,之后全部被 Redis 挡住,DB QPS 降到个位数

踩坑点

坑 1:空值标记选错了。 一开始直接缓存空字符串 "",结果有个接口正好会返回空字符串作为合法值,线上出了 bug。后来改成特殊标记字符串,在序列化层统一处理。

坑 2:TTL 设太长导致数据不一致。 如果一个用户刚注册,ID 之前被缓存了空值,那在 TTL 过期前用户查不到自己的数据。解决方案是写入数据库时主动删除对应的空值缓存:

python 复制代码
def create_user(user_data: dict) -> int:
 user_id = insert_user_to_db(user_data)
 # 创建成功后,主动删除可能存在的空值缓存
 r.delete(f"user:{user_id}")
 return user_id

坑 3:内存爆炸。 攻击者用随机 key 来打时,每个 key 都会在 Redis 里存一条空值缓存,Redis 内存会被撑爆。这个方案对随机 key 攻击基本无效。

方案二:布隆过滤器(Bloom Filter)

布隆过滤器的核心思想:用一个 bit 数组 + 多个 hash 函数,快速判断一个元素「一定不存在」或「可能存在」。

注意这个「可能存在」------布隆过滤器有误判率,会把不存在的判断为存在(false positive),但不会把存在的判断为不存在(no false negative)。这个特性刚好适合缓存穿透场景。

架构变化

一定不存在
可能存在
命中
未命中
客户端请求
布隆过滤器判断
直接返回空, 不查 DB
Redis 有缓存?
返回缓存数据
查询 MySQL
写入 Redis + 返回

代码实现

Redis 4.0+ 支持 RedisBloom 模块,用起来很方便。如果 Redis 没装这个模块,也可以用 Python 的 pybloom_live 库在应用层实现,但更推荐用 Redis 原生的,省得维护内存中的状态。

python 复制代码
import redis

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

BLOOM_KEY = "bf:user_ids"


def init_bloom_filter():
 """
 初始化布隆过滤器
 预计元素数量 100万,误判率 0.01(1%)
 实际内存占用约 1.2MB,非常省
 """
 try:
 # BF.RESERVE key error_rate capacity
 r.execute_command("BF.RESERVE", BLOOM_KEY, 0.01, 1000000)
 print("布隆过滤器创建成功")
 except redis.ResponseError as e:
 if "item exists" in str(e):
 print("布隆过滤器已存在,跳过创建")
 else:
 raise


def load_existing_ids():
 """把数据库中已有的 ID 全量灌入布隆过滤器"""
 # 分批加载,别一次性全捞出来
 batch_size = 5000
 offset = 0
 
 while True:
 ids = query_user_ids_batch(offset, batch_size)
 if not ids:
 break
 
 # BF.MADD 批量添加,比循环 BF.ADD 快很多
 r.execute_command("BF.MADD", BLOOM_KEY, *ids)
 offset += batch_size
 print(f"已加载 {offset} 条 ID")


def get_user_by_id(user_id: int) -> dict | None:
 cache_key = f"user:{user_id}"
 
 # 1. 布隆过滤器前置拦截
 exists = r.execute_command("BF.EXISTS", BLOOM_KEY, user_id)
 if not exists:
 # 布隆过滤器说不存在,那就一定不存在
 return None
 
 # 2. 可能存在,走正常缓存逻辑
 cached = r.get(cache_key)
 if cached is not None:
 return json.loads(cached)
 
 # 3. 查 DB
 user = query_user_from_db(user_id)
 if user:
 r.setex(cache_key, 3600, json.dumps(user))
 
 return user


def create_user(user_data: dict) -> int:
 """新增用户时,同步更新布隆过滤器"""
 user_id = insert_user_to_db(user_data)
 # 别忘了把新 ID 加入布隆过滤器!
 r.execute_command("BF.ADD", BLOOM_KEY, user_id)
 return user_id

实测效果

同样的 1000 个不存在的 ID 并发压测:

  • DB 请求数:0。全部被布隆过滤器拦截了
  • Redis 内存增量:1.2MB(100 万数据量下),比方案一的空值缓存动不动几十 MB 省多了
  • 额外延迟:BF.EXISTS 命令耗时约 0.1ms,几乎可以忽略

踩坑点

坑 1:布隆过滤器不支持删除。 标准布隆过滤器只能添加不能删除。删了一条用户数据,布隆过滤器里还是会判断为「可能存在」,结果是多查一次 DB 返回空。删除频繁的业务可以考虑 Cuckoo Filter(CF.RESERVE/CF.ADD/CF.DEL),RedisBloom 也支持。

坑 2:全量初始化太慢。 线上 800 万用户 ID,第一次灌数据花了快 3 分钟。后来改成 pipeline 批量操作 + 后台异步加载,不阻塞主服务启动:

python 复制代码
def load_existing_ids_with_pipeline():
 """用 pipeline 批量加载,速度提升 10 倍+"""
 batch_size = 5000
 offset = 0
 
 while True:
 ids = query_user_ids_batch(offset, batch_size)
 if not ids:
 break
 
 pipe = r.pipeline()
 for uid in ids:
 pipe.execute_command("BF.ADD", BLOOM_KEY, uid)
 pipe.execute()
 
 offset += batch_size

坑 3:误判率的取舍。 误判率设 1% 意味着每 100 个不存在的 key,有 1 个会穿透到 DB。对穿透零容忍的话可以调到 0.001(0.1%),但内存会翻倍。最终选了 0.01,再配合方案一的空值缓存兜底,效果不错。

方案三:请求校验 + 限流

这个方案严格来说不算缓存层的解决方案,但在防恶意攻击场景下是必须做的。

python 复制代码
import re
from functools import wraps
from collections import defaultdict
import time

# 简单的滑动窗口限流
request_counter = defaultdict(list)


def rate_limit(max_requests=100, window_seconds=60):
 """每个 IP 每分钟最多 100 次请求"""
 def decorator(func):
 @wraps(func)
 def wrapper(*args, **kwargs):
 client_ip = get_client_ip()
 now = time.time()
 
 # 清理过期记录
 request_counter[client_ip] = [
 t for t in request_counter[client_ip]
 if now - t < window_seconds
 ]
 
 if len(request_counter[client_ip]) >= max_requests:
 return {"error": "rate limited"}, 429
 
 request_counter[client_ip].append(now)
 return func(*args, **kwargs)
 return wrapper
 return decorator


def validate_user_id(user_id) -> bool:
 """参数校验:在缓存之前就拦截明显非法的请求"""
 if not isinstance(user_id, int):
 return False
 if user_id <= 0: # 我们的 ID 都是正整数
 return False
 if user_id > 10_000_000_000: # 业务上不可能有这么大的 ID
 return False
 return True


@rate_limit(max_requests=100, window_seconds=60)
def api_get_user(user_id):
 # 1. 参数校验
 if not validate_user_id(user_id):
 return {"error": "invalid user_id"}, 400
 
 # 2. 正常走缓存逻辑
 user = get_user_by_id(user_id)
 if user is None:
 return {"error": "user not found"}, 404
 return user, 200

就是在最外层把明显不合法的请求干掉,减少穿透到缓存层和 DB 层的请求量。

最终方案:组合拳

单一方案都有短板,线上实际是这么组合的:
非法请求
合法请求
一定不存在
可能存在
命中
未命中
数据存在
数据不存在
客户端请求
参数校验 + IP 限流
直接拒绝 400/429
布隆过滤器判断
返回 404
Redis 缓存
返回数据
查 MySQL
写缓存 + 返回
缓存空值 2min + 返回 404

三层防线:

  1. 最外层:参数校验 + 限流,拦截恶意流量
  2. 中间层:布隆过滤器,拦截不存在的 key
  3. 兜底层:空值缓存,处理布隆过滤器误判导致的穿透

上线后,同样的攻击流量下,DB QPS 从 3000+ 降到了个位数,Redis 内存增量控制在 2MB 以内。

三种方案对比

维度 缓存空值 布隆过滤器 请求校验+限流
实现复杂度 ⭐ 低 ⭐⭐ 中 ⭐ 低
内存开销 高(随攻击 key 增长) 低(固定大小)
对随机 key 攻击 ❌ 基本无效 ✅ 有效 ⚠️ 部分有效
数据一致性 需处理 TTL 不支持删除 无影响
误判率 有(可控)
推荐场景 穿透量小,key 范围有限 海量数据,key 范围大 所有场景(必选项)

小结

缓存穿透说起来简单,线上真碰到了,排查 + 修复 + 验证一套下来搞了快两天。几个关键经验:

  1. 限流是底线,不管用不用布隆过滤器,请求校验和限流必须做
  2. 布隆过滤器 + 空值缓存组合性价比最高,单用哪个都有明显短板
  3. 布隆过滤器初始化要考虑增量更新的问题,新增数据别忘了同步 BF.ADD
  4. 空值缓存的 TTL 别设太长,2-5 分钟够了,设长了数据一致性问题会让你头疼

面试被问到「缓存穿透和缓存击穿的区别」,别只背概念了------能讲出线上踩坑经历和组合方案的细节,比背八股管用多了。

相关推荐
布谷歌2 小时前
高效查询商户日终余额:一个SQL的优化实践
数据库·sql
m0_738120722 小时前
AI安全——Gandalf靶场 Gandalf Adventure 全关卡绕过详解
服务器·人工智能·安全·web安全·ai·prompt
添柴少年yyds2 小时前
信贷表关联字段
数据库·sql·mysql
一 乐2 小时前
智能农田管理|基于springboot + vue智能农田管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·智能农田管理系统
倔强的石头1062 小时前
kingbase备份与恢复实战(三)—— 表-模式级备份与误删表精准恢复(sys_dump+sys_restore)
数据库·kingbase
苏瞳儿2 小时前
数据库的增删改查-node.js
前端·javascript·数据库
m0_747124532 小时前
LangChain RAG Chain Types 详解
python·ai·langchain
光泽雨2 小时前
数据的DML语句
数据库
小陈工2 小时前
Python Web开发入门(九):权限管理与角色控制实战
服务器·开发语言·前端·数据库·python·安全·sqlite