缓存空对象
问题引入
在了解了什么是缓存穿透之后,我们需要找到一种方法来防护这种攻击。
核心问题:
- 查询不存在的数据时,无法将结果写入缓存
- 每次请求都会调用外部 API
- 恶意攻击者可以构造大量不存在的 key
解决思路:即使数据不存在,也把”空结果”缓存起来
这就是缓存空对象方案的核心思想。
什么是缓存空对象?
缓存空对象是指:当查询的数据不存在时,在缓存中存储一个特殊的标记,表示”该数据不存在”。
核心原理
正常情况:
- 数据存在 → 缓存真实数据
- 数据不存在 → 无法缓存 → 每次都查询外部 API
缓存空对象方案:
- 数据存在 → 缓存真实数据
- 数据不存在 → 缓存空标记 → 避免重复查询与其他方案对比
| 方案 | 内存占用 | 判断准确率 | 适用场景 | 实现难度 |
|---|---|---|---|---|
| 缓存空对象 | 中 | 100% | 少量非法 key | 简单 |
| 布隆过滤器 | 极低 | 99%+ | 海量数据、key 空间大 | 中等 |
| 数据库预检查 | 中 | 100% | 合法数据集合小 | 简单 |
工作原理
缓存空对象的流程
用户请求
Redis 缓存
invalid_001 __NULL__
beijing_weather {"temp": 25}
数据库
受到保护
无效请求 → 缓存空值 → 不再访问数据库
有效请求 → 正常缓存 → 提升性能
核心步骤:
1. 查询缓存
↓
2. 如果缓存中有特殊标记 "__NULL__"
→ 说明数据不存在,直接返回
↓
3. 如果缓存中有正常数据
→ 直接返回缓存数据
↓
4. 缓存未命中,调用外部 API
↓
5. 如果 API 返回 404 或空结果
→ 缓存空标记 "__NULL__"(较短过期时间)
↓
6. 如果 API 返回正常数据
→ 缓存真实数据(较长过期时间)为什么需要特殊标记?
# 问题:如何区分"缓存未命中"和"数据不存在"?
场景 1:缓存未命中
- redis.get("weather:invalid_city") → None
- 需要调用外部 API
场景 2:数据不存在(已缓存空标记)
- redis.get("weather:invalid_city") → "__NULL__"
- 直接返回 None,不需要调用外部 API
# 解决方案:使用特殊标记区分
NULL_MARKER = "__NULL__"
if cached == NULL_MARKER:
return None # 数据不存在
elif cached:
return cached # 有缓存数据
else:
# 缓存未命中,调用外部 API实战:缓存空对象防护缓存穿透
基础实现
import redis
redis_client = redis.Redis(host='localhost', port=6379, db=0)
NULL_MARKER = '__NULL__'
def get_weather(city):
"""
查询天气信息,带缓存空对象防护
"""
cache_key = f"weather:{city}"
# 1. 先查缓存
cached = redis_client.get(cache_key)
# 2. 缓存中有特殊标记,表示数据不存在
if cached == NULL_MARKER:
return None
# 3. 缓存中有正常数据
if cached:
return cached
# 4. 缓存未命中,调用外部 API
response = external_api.get_weather(city)
if response.status == 404:
# 5. 城市不存在,也缓存一个空标记
redis_client.setex(cache_key, 60, NULL_MARKER) # 空值缓存 1 分钟
return None
# 6. 数据存在,正常缓存
redis_client.setex(cache_key, 3600, response.data)
return response.data使用装饰器的实现
import redis
from functools import wraps
NULL_MARKER = '__NULL__'
def cache_protect(null_ttl=60, normal_ttl=3600):
"""
缓存穿透防护装饰器
- null_ttl: 空值缓存时间(秒)
- normal_ttl: 正常数据缓存时间(秒)
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 生成缓存 key
cache_key = f"{func.__name__}:{args}:{kwargs}"
# 查缓存
cached = redis.get(cache_key)
# 空值标记,直接返回
if cached == NULL_MARKER:
return None
if cached:
return cached
# 调用外部 API
try:
result = func(*args, **kwargs)
except Exception as e:
# 查询失败,也缓存空值,防止持续穿透
redis.setex(cache_key, null_ttl, NULL_MARKER)
raise
if result is None:
# 结果为空,缓存空值
redis.setex(cache_key, null_ttl, NULL_MARKER)
else:
# 结果正常,缓存数据
redis.setex(cache_key, normal_ttl, result)
return result
return wrapper
return decorator
# 使用示例
@cache_protect(null_ttl=60, normal_ttl=3600)
def get_weather(city):
# 调用外部天气 API
return external_api.get_weather(city)完整的 API 实现
import redis
import json
redis_client = redis.Redis(host='localhost', port=6379, db=0)
NULL_MARKER = '__NULL__'
def get_weather(city):
"""
查询天气信息,带缓存穿透防护
"""
cache_key = f"weather:{city}"
# 1. 查缓存
cached = redis_client.get(cache_key)
# 2. 空值标记,直接返回
if cached == NULL_MARKER:
return None
if cached:
return json.loads(cached)
# 3. 调用外部 API
try:
response = external_api.get_weather(city)
if response.status == 404:
# 4. 城市不存在,缓存空值(60 秒过期)
redis_client.setex(cache_key, 60, NULL_MARKER)
return None
# 5. 数据存在,正常缓存(1 小时过期)
redis_client.setex(cache_key, 3600, json.dumps(response.data))
return response.data
except Exception as e:
# API 调用失败,也缓存空值,防止持续穿透
redis_client.setex(cache_key, 60, NULL_MARKER)
raise
# 使用示例
@app.route('/api/weather')
def weather_api():
city = request.args.get('city')
# 参数校验(第一道防线)
if not city or len(city) < 2:
return {'error': '无效的城市名'}, 400
try:
data = get_weather(city)
if not data:
return {'error': '城市不存在'}, 404
return {'data': data}
except Exception as e:
return {'error': str(e)}, 500效果验证
防护前后对比
实施缓存空对象方案后,系统指标明显改善:
| 指标 | 攻击前 | 被攻击时 | 防护后 |
|---|---|---|---|
| 外部 API 调用失败率 | 1% | 80% | 2% |
| API 响应时间 | 200ms | 5000ms | 250ms |
| 错误率 | 0.1% | 30% | 0.5% |
攻击拦截统计
防护期间统计(24 小时):
拦截的恶意请求:
- 不存在的城市:50,000+ 次
- 无效的城市名:30,000+ 次
- 伪造的参数:20,000+ 次
外部 API 保护:
- 避免的 API 调用:100,000+ 次
- 节省的调用额度:约 60%当前技术架构
每个请求都直接调用外部 API,响应慢
客户端
用户请求 200 个开发者
应用服务
应用服务器 处理请求
缓存层 / 外部服务
外部天气 API 响应时间 2 秒
引入 Redis 缓存,大幅降低响应时间
客户端
用户请求 高并发访问
应用服务
应用服务器 处理请求
缓存层 / 外部服务
Redis 缓存 1 小时过期
外部天气 API 响应时间 2 秒
增加空值缓存,防止缓存穿透
客户端
用户请求 包含恶意请求
应用服务
应用服务器 参数校验
缓存层 / 外部服务
Redis 缓存 包含空值缓存
外部天气 API 有调用限制
使用布隆过滤器提前过滤无效请求
客户端
用户请求 包含恶意请求
应用服务
应用服务器 布隆过滤器校验
缓存层 / 外部服务
Redis 缓存 包含空值缓存
外部天气 API 有调用限制
使用互斥锁防止缓存击穿
客户端
用户请求 高并发访问
应用服务
应用服务器 互斥锁控制
缓存层 / 外部服务
Redis 缓存 热点 key 防护
外部天气 API 有调用限制
熔断器 + 降级策略应对缓存雪崩
客户端
用户请求 高并发访问
应用服务
应用服务器 熔断器 + 降级
缓存层 / 外部服务
Redis Sentinel 主从高可用
外部天气 API 有调用限制
多地域部署的高可用 API 平台
客户端
用户请求 10 万用户
应用服务
应用服务器集群 26 台,3 地域
缓存层 / 外部服务
Redis 集群 6 主 6 从
MySQL 主从 1 主 5 从
消息队列 RabbitMQ 3 台
课后练习
练习 1
缓存空对象方案中,为什么空值的过期时间要设置得比较短?
参考答案 (2 个标签)
缓存空对象 过期时间
答案:
空值过期时间设置较短(如 60 秒)的原因:
数据一致性:如果数据在空值缓存期间被创建,用户会一直看到”不存在”的结果
内存效率:空值也占用内存,过长的过期时间会浪费存储空间
安全性平衡:
- 60 秒足以抵挡大部分攻击
- 攻击者等待 60 秒后再次攻击,成本已经很高
- 如果需要更强防护,可以结合布隆过滤器
推荐设置:
- 空值缓存:30-120 秒
- 正常数据:根据业务设置(如 3600 秒)
练习 2
缓存空对象方案和布隆过滤器方案有什么区别?分别适用于什么场景?
参考答案 (3 个标签)
缓存空对象 布隆过滤器 方案对比
答案:
| 维度 | 缓存空对象 | 布隆过滤器 |
|---|---|---|
| 内存占用 | 中(每个空值约 90 字节) | 极低(10 万条约 100KB) |
| 准确率 | 100% | 99%+(存在误判) |
| 实现难度 | 简单 | 中等 |
| 维护成本 | 低 | 中(需要维护合法数据集合) |
| 适用场景 | 少量非法 key | 海量数据、key 空间大 |
场景选择:
使用缓存空对象:
- 非法 key 数量较少(< 1 万)
- 希望实现简单
- 需要 100% 准确
- 内存资源充足
使用布隆过滤器:
- 非法 key 数量巨大(> 10 万)
- 内存资源紧张
- 可以接受小幅误判
- 有明确的合法数据集合
组合使用:
- 第一层:布隆过滤器拦截大部分非法请求
- 第二层:缓存空对象处理误判和漏网之鱼
练习 3
请实现一个带有缓存空对象防护的用户查询函数,要求:
- 使用装饰器实现
- 空值缓存 60 秒,正常数据缓存 1 小时
- 包含错误处理
参考答案 (2 个标签)
缓存空对象 实战编程
参考答案:
import redis
import json
from functools import wraps
redis_client = redis.Redis(host='localhost', port=6379, db=0)
NULL_MARKER = '__NULL__'
def cache_protect(null_ttl=60, normal_ttl=3600):
"""
缓存穿透防护装饰器
Args:
null_ttl: 空值缓存时间(秒)
normal_ttl: 正常数据缓存时间(秒)
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 生成缓存 key
cache_key = f"{func.__name__}:{args}:{kwargs}"
# 查缓存
try:
cached = redis_client.get(cache_key)
except redis.ConnectionError:
# Redis 连接失败,直接调用函数
return func(*args, **kwargs)
# 空值标记,直接返回
if cached == NULL_MARKER:
return None
# 有缓存数据
if cached:
try:
return json.loads(cached)
except json.JSONDecodeError:
return cached
# 缓存未命中,调用原函数
try:
result = func(*args, **kwargs)
except Exception as e:
# 查询失败,也缓存空值,防止持续穿透
try:
redis_client.setex(cache_key, null_ttl, NULL_MARKER)
except redis.ConnectionError:
pass
raise
# 缓存结果
try:
if result is None:
# 结果为空,缓存空值
redis_client.setex(cache_key, null_ttl, NULL_MARKER)
else:
# 结果正常,缓存数据
if isinstance(result, (dict, list)):
redis_client.setex(cache_key, normal_ttl, json.dumps(result))
else:
redis_client.setex(cache_key, normal_ttl, result)
except redis.ConnectionError:
pass
return result
return wrapper
return decorator
# 使用示例
@cache_protect(null_ttl=60, normal_ttl=3600)
def get_user(user_id):
"""
查询用户信息,带缓存穿透防护
Args:
user_id: 用户 ID
Returns:
用户信息字典,如果不存在则返回 None
"""
# 参数校验
if not user_id or not isinstance(user_id, int):
raise ValueError("无效的用户 ID")
# 调用外部 API 或数据库
try:
response = external_api.get_user(user_id)
if response.status == 404:
return None
return response.data
except Exception as e:
# API 调用失败
raise Exception(f"查询用户失败: {str(e)}")
# 使用示例
@app.route('/api/users/<int:user_id>')
def user_api(user_id):
try:
user = get_user(user_id)
if not user:
return {'error': '用户不存在'}, 404
return {'data': user}
except ValueError as e:
return {'error': str(e)}, 400
except Exception as e:
return {'error': '服务器错误'}, 500关键点:
- 使用装饰器实现,代码复用性强
- 空值使用特殊标记
NULL_MARKER - 空值缓存时间短(60 秒)
- 正常数据缓存时间长(3600 秒)
- 完善的错误处理(Redis 连接失败、JSON 解析错误)
- 参数校验在函数内部进行
- 支持复杂数据类型(自动序列化)
