凌晨 2:07,手机震了三下,告警群弹出一串红字:“数据库连接池耗尽,API 响应超时 20s”。我眯着眼打开 Grafana,果然——Redis 里几十个热点 Key 同时过期,每秒几万请求直接砸到 MySQL,CPU 瞬间飙到 95%。那一刻我就知道,又是缓存雪崩,又是开发改了过期时间没通知任何人。第二天晨会,我拍板:必须用 pytest 把缓存行为写成自动化测试,不通过不准合代码。 三个月后,这 50 个用例在生产提前揪出 12 个高危缺陷,缓存引发的线上事故降了 90%。这篇文章就聊聊这套测试方案怎么落地的。
问题拆解:为什么缓存 Bug 总是在半夜才现身
我们维护一个电商中台,商品、库存、价格全走缓存。经典问题是两种:
- 缓存穿透:请求一个数据库根本不存在的数据(比如商品 ID 为负数),每次都穿过 Redis 打到 DB。如果被恶意刷接口,DB 直接挂。
- 缓存雪崩:一批 Key 设置了一样的过期时间,到了时间点集体失效,流量洪峰瞬间拍到 DB 上。
团队当然知道这些理论,也加了防线:空值缓存防穿透、过期时间加随机值防雪崩。但问题在于,这些防护措施都是写在业务代码里的,重构、需求迭代时很容易被“误删”或“优化掉”。有一次,一个同学觉得“空值缓存占用内存”,顺手把缓存空对象的逻辑删了,Code Review 没人看出来,上线三天才被攻击者利用。靠人肉保证不了缓存策略的一致性,必须让自动化测试来做守门员。
方案设计:为什么不 Mock,而是用真实 Redis + pytest
我们评估过三种测试方案:
- 纯 Mock Redis 调用——太假,测不出序列化、网络延迟、pipeline 行为差异。
- 完全依赖 CI 的 Docker Redis——太慢,每次跑完 50 个用例要 8 分钟。
- 用
fakeredis做基础回归,再配合 Docker Redis 做关键路径验证——兼顾速度和真实性。
最终选了第 3 种:日常开发用 fakeredis 跑单元测试,速度快、无依赖;在 CI 的 merge_request 阶段拉起真实 Redis 容器跑集成用例,拦截边界行为。这样既不会拖慢开发节奏,又能抓住 fakeredis 与真实 Redis 的差异。
测试框架用 pytest,理由很粗暴——fixture 天生适合管理 Redis 连接,parametrize 能轻松覆盖“有缓存/无缓存/缓存过期”三种状态组合。
核心实现:把缓存逻辑变成可复用的 pytest 用例
这段代码解决“缓存穿透”防护是否生效的问题:请求不存在的 ID,第一次穿透 DB,第二次必须命中空缓存
import time
import pytest
from fakeredis import FakeRedis
from myapp.cache import get_product # 被测试的业务函数
FAKE_PRODUCT_ID = "product:99999" # 数据库中不存在的 ID
NULL_CACHE_TTL = 60 # 空值缓存过期时间
@pytest.fixture
def redis_client():
"""每个测试用例独立 Redis 实例,自动清理"""
client = FakeRedis(decode_responses=True)
yield client
client.flushall()
def test_cache_penetration_guard(redis_client, mocker):
"""
验证空值缓存:连续查询不存在的商品,DB 只被访问一次。
"""
# 模拟 DB 查询,返回 None 表示不存在
mock_db = mocker.patch("myapp.cache.query_db", return_value=None)
# 第一次调用:应该穿透到 DB
result1 = get_product(redis_client, FAKE_PRODUCT_ID)
assert result1 is None
assert mock_db.call_count == 1, "第一次应查 DB"
# 立即第二次调用:必须命中空值缓存,不再查 DB
result2 = get_product(redis_client, FAKE_PRODUCT_ID)
assert result2 is None
assert mock_db.call_count == 1, "第二次不应再查 DB,空值缓存生效"
# 检查 Redis 中是否真的写入了空值标记
cached = redis_client.get(FAKE_PRODUCT_ID)
assert cached == "NULL"
# TTL 应该在设定范围内(FakeRedis 支持 ttl)
ttl = redis_client.ttl(FAKE_PRODUCT_ID)
assert 0 < ttl <= NULL_CACHE_TTL
注释说明:空值缓存 key 的值设为特殊字符串 "NULL",与正常序列化对象区分开;过期时间不要设为永久,否则内存慢慢被打满。
这段代码解决“缓存雪崩”防护:一批 Key 的过期时间不能集中在同一秒
import random
from typing import List
# 生产代码中设置缓存时的过期时间逻辑
def set_product_with_random_ttl(redis, key: str, value: str, base_ttl: int = 3600):
"""
实际业务函数:过期时间 = base_ttl + 随机 0~600 秒
"""
ttl = base_ttl + random.randint(0, 600)
redis.setex(key, ttl, value)
def test_avalanche_prevention_ttl_distribution(redis_client):
"""
测试要点:批量设置 100 个 Key,它们的 TTL 不能完全相同。
"""
base_ttl = 300
keys: List[str] = [f"hot:item:{i}" for i in range(100)]
for key in keys:
set_product_with_random_ttl(redis_client, key, "data", base_ttl)
ttls = [redis_client.ttl(key) for key in keys]
# 断言:所有 TTL 不能是一个值
assert len(set(ttls)) > 1, "批量过期时间完全相同,雪崩风险!"
# 额外检查:最大与最小 TTL 的差值应 >= 随机范围的一半(统计学宽松判断)
ttl_range = max(ttls) - min(ttls)
assert ttl_range >= 200, f"TTL 分布过窄 ({ttl_range}s),仍可能集体过期"
注意:用 fakeredis 验证 TTL 分布时,random.randint 在测试环境会得到确定值(取决于 seed),我们可以通过 random.seed(0) 固定随机序列,保证用例可重复。真实 CI 集成测试时改用真实 Redis,不再 mock random。
这段代码解决“缓存重建时惊群效应”——互斥锁防止多线程同时回源 DB
import threading
from unittest.mock import patch
from myapp.cache import get_product_with_lock
def test_mutex_prevents_cache_stampede(redis_client):
"""
模拟两个并发请求同时遇到过期 Key,只允许一个重建缓存。
"""
hot_key = "hot:product:123"
redis_client.setex(hot_key, 1, "old_data") # 1 秒后过期
time.sleep(1.1) # 保证过期(fakeredis 时间与 time 同步)
db_value = {"name": "iPhone15", "price": 7999}
call_counter = 0
def mock_db():
nonlocal call_counter
call_counter += 1
return db_value
results = []
barrier = threading.Barrier(2) # 让两个线程同时起跑
def fetch():
barrier.wait()
results.append(get_product_with_lock(redis_client, hot_key, mock_db))
t1 = threading.Thread(target=fetch)
t2 = threading.Thread(target=fetch)
t1.start(); t2.start()
t1.join(); t2.join()
# 两次调用都拿到正确数据
assert results == [db_value, db_value]
# 但 DB 实际只被调用一次,互斥锁生效
assert call_counter == 1, f"惊群:DB 被调用了 {call_counter} 次"
这要配合业务代码里的 setnx 锁实现,我们采用「加锁—查 DB—写缓存—释放锁」的经典三步骤,setnx 的 value 是唯一令牌,删除时比较令牌防误删。
踩坑记录:官方文档没告诉你的两个坑
坑 1:fakeredis 的 ttl 在 key 过期后返回什么?
现象:fakeredis 里 Key 过期后,调用 ttl() 返回 -2,而真实 Redis 返回 -2,看起来一致。但 get() 一个过期 Key,fakeredis 立刻返回 None,真实 Redis 也返回 None。区别在于过期删除的时机——fakeredis 是惰性检查(访问时判断时间戳),真实 Redis 有被动和主动两种策略。这导致一个隐藏问题:我们的测试依赖“Key 过期后立刻不再存在”,但在真实 Redis 中,如果采用主动过期扫描,可能刚好在两次访问之间删除了 Key,导致 exists 判断不符。测试还是能过,但概率性失效。解决:在关键集成用例中,对过期判断增加 time.sleep(2) 并重试,容忍时间偏差。
坑 2:pytest-xdist 并行跑测试时数据串了
为了加速,我们用了 pytest-xdist 多进程并行。结果缓存测试集体失败,因为每个 worker 共享了同一个 Redis DB,Key 冲突。我们很快加上了 --dist loadscope 和给每个测试的 Key 加前缀 f"test:{os.getpid()}:" 解决隔离问题。但真正踩坑的是:fakeredis 虽然内存隔离,但我们一些 fixture 的 scope="session" 引用了同一个对象,导致测试间状态污染。教训:凡是涉及状态的 fixture,scope 一律设为 function,除非你明确知道自己在做什么。
效果验证
在 CI 中集成了 50 个缓存策略用例后,近三个月的数据对比:
| 指标 | 人工测试阶段 | 自动化测试后 |
|---|---|---|
| 缓存相关 Bug 线上泄漏 | 18 个 | 2 个(均为未知边界) |
| MR 阶段发现缓存缺陷 | 3 个 | 12 个 |
| 因缓存引发的 P0 事故 | 4 起 | 0 起 |
| 每次回归耗时 | 1.5 人时 | 0(CI 自动运行 3 分钟) |
上线后第一次全量跑,就发现了两个严重问题:一个接口把空值缓存的 TTL 设成了 0(直接不过期),另一个页面的热点 Key 完全没加随机抖动——没这套用例,大概率又是一次半夜雪崩。
可直接用的代码/工具
把下面的 conftest.py 放进项目根目录,所有测试自动获得 Redis 客户端:
# conftest.py
import pytest
from fakeredis import FakeRedis
@pytest.fixture
def redis_client():
client = FakeRedis(decode_responses=True)
yield client
client.flushall()
然后装依赖:pip install pytest fakeredis pytest-mock,跑 pytest -v -k cache 就能把核心缓存用例跑起来。想集成真实 Redis,只需把 fixture 里的 FakeRedis 换成 redis.Redis。
#Python #Redis #pytest #自动化测试 #后端
关于作者
一个常年和数据库、缓存、分布式系统较劲的后端工程师,坚信“没被测试覆盖的代码都是不定时炸弹”。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章让你少排了一个 Bug,请我喝杯咖啡 ☕
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege