凌晨两点十七分,手机炸了。监控告警一条接一条:RDS CPU 100%,连接数打满,用户端开始报 502。翻日志发现,全是同一个用户 profile 接口——缓存没了,每秒近千个请求直接砸到数据库,把连接池挤爆。这不是第一次,已经是本月第三次因为缓存不一致导致的雪崩。和几个同事在电话里沉默半天,有人说了句憋了很久的话:“缓存逻辑真的靠谱吗?我们每次上线都在赌。”
赌不起,就得验证。我们决定用 Pytest + Docker 搭建一套自动化测试,专门盯着这层“记忆存储”,把缓存不一致的坑在 CI 阶段就炸出来,不让它溜到线上。
问题拆解:缓存不一致到底怎么来的
表面上看,存、读、删是三个简单操作,但在真实业务里处处是陷阱:
- 更新 DB 之后删缓存失败:代码里 try/except 悄悄吞了 redis.delete() 的异常,你根本没感知。
- 并发写后读:线程 A 更新了数据库,还没来得及删缓存,线程 B 读到旧缓存返回。
- 缓存 Key 变化:业务重构改变了 Key 模板,但老缓存残留在 Redis,永远不过期。
- 序列化不一致:写入时用
pickle,读取时用json.loads,直接报错导致全量穿透。 - 缓存穿透隐患:恶意请求不存在的 user_id 直接打到 DB,以为布隆过滤器能扛,结果布隆过滤器的误判率悄悄跑偏。
常规方案一般是“写好代码 + 严格 review + 上线观察”,这靠的是人——人会累、会漏、会被紧急需求压垮。Mock 测试根本摸不到真实 Redis 的网络、序列化、阻塞这些细节,就像在驾校学漂移,上路必翻。
我们要的是一套真实环境 + 自动化断言:提交代码前,让缓存逻辑跟真实的 Redis 打一架,赢了才算通过。
方案设计:为什么选 Pytest + Docker,而不是单元测试 Mock
一开始想过几个路子:
- 用 fakeredis 在内存模拟:省事,但连
expire都实现不全,测不出分布式锁、key 淘汰策略等行为。 - 本地装 Redis:每个人开发环境不同,有人用 Redis 6、有人用 7,还有 Mac 上编译的坑。
- 真正可复用的答案:Docker Compose + Pytest 集成测试。
docker-compose.yml 统一声明 Redis7 服务;Pytest 通过 session 级 fixture 启动容器、等待端口就绪,给每个测试函数一个隔离的干净 Redis 库(用不同 db 或 flush 清理)。为什么不用 pytest-redis 这类插件?很多对 Docker 封装过重,出问题时 debug 困难,自己管理生命周期更透明。而且在 CI 里直接 docker-compose up -d 然后跑测试,干净利落。
测试用例覆盖:缓存读/写、更新失效、并发读写、穿透保护的 bool filter、极端数据序列化。每个测试都是一个真实的 Redis 交互。
核心实现:一步一步把测试架子搭起来
1. 让 Redis 容器随测试启动
先把 docker-compose.yml 写好,定义一个 redis 服务,暴露 6379,设置 --appendonly no 避免持久化文件干扰,测试追求快,不需要 RDB/AOF。
# docker-compose.yml
version: "3.9"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "no", "--save", ""]
下面这段 fixture 解决的是“怎么在 Pytest 里管理 Docker 生命周期,且保证 Redis 已经接受连接后才开始测试”。用 subprocess 调 docker-compose,轮询 socket 直到连通,测试结束后停掉容器。
# tests/conftest.py
import subprocess
import time
import socket
import pytest
import redis
def _redis_ready(host: str, port: int, timeout: int = 30) -> bool:
"""轮询直到Redis接受连接"""
start = time.time()
while time.time() - start < timeout:
try:
sock = socket.create_connection((host, port), timeout=1)
sock.close()
return True
except (ConnectionRefusedError, OSError):
time.sleep(0.5)
return False
@pytest.fixture(scope="session")
def docker_redis():
"""session级别启动docker-compose,测试结束自动停"""
# 确保用 compose 文件路径
subprocess.run(
["docker-compose", "-f", "docker-compose.yml", "up", "-d"],
check=True,
)
if not _redis_ready("localhost", 6379):
subprocess.run(["docker-compose", "down"])
pytest.exit("Redis 容器启动超时", returncode=1)
yield # 测试执行
subprocess.run(["docker-compose", "-f", "docker-compose.yml", "down"])
@pytest.fixture
def redis_client(docker_redis):
"""每个测试函数拿到独立的Redis连接,测试前清空DB 0"""
client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
client.flushdb()
yield client
client.close()
decode_responses=True 让拿到的值直接是 str,省去自己 decode 的麻烦——这是第一个容易踩的配置点,后面踩坑记录会细说。
2. 被测试的缓存层:不能只是一个简单的 get/set
我们有一层业务代码 user_cache.py,模拟真实场景:读缓存 -> 缓存空则查 DB(这里用假的 _db_fetch 模拟)-> 写入缓存并设置过期时间;更新操作要同时失效缓存。
# app/user_cache.py
import json
import redis
from typing import Optional, Dict
r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
CACHE_TTL = 60 # 60秒
def _db_fetch(user_id: int) -> Optional[Dict]:
"""模拟耗时的数据库查询"""
# 在实际项目中这里会查MySQL
return {"id": user_id, "name": f"user_{user_id}"}
def get_user(user_id: int) -> Optional[Dict]:
key = f"user:{user_id}"
cached = r.get(key)
if cached:
return json.loads(cached) # 直接从缓存返回
# 缓存未命中,查数据库
data = _db_fetch(user_id)
if data:
r.setex(key, CACHE_TTL, json.dumps(data))
return data
def update_user(user_id: int, name: str):
"""更新用户信息,之后应该使缓存失效"""
# 伪代码:更新数据库...
# db.execute("UPDATE users SET name=...")
# 让缓存失效
r.delete(f"user:{user_id}")
这部分就是平时让我们头痛的“看起来没毛病”的逻辑,实际埋了序列化、Key 冲突、删除顺序等雷。下面测试用例就是专门拆这些雷的。
3. 第一个测试:验证缓存命中与 DB 查询次数
这段测试证明“第二次访问同一条数据时不会再查 DB”。我们通过给 _db_fetch 打补丁来统计调用次数,确保缓存生效。
# tests/test_cache_basic.py
import json
from unittest.mock import patch
from app.user_cache import get_user
def test_cache_hit_avoids_db(redis_client):
"""第一次查库写缓存;第二次直接从缓存拿,不再查库"""
user_id = 1
with patch("app.user_cache._db_fetch") as mock_fetch:
mock_fetch.return_value = {"id": user_id, "name": "user_1"}
# 第一次调用:缓存miss,会查库
user1 = get_user(user_id)
assert user1["name"] == "user_1"
assert mock_fetch.call_count == 1
# 第二次调用:缓存hit,不应查库
user2 = get_user(user_id)
assert user2["name"] == "user_1"
assert mock_fetch.call_count == 1 # 还是1,没增加
4. 验证缓存失效逻辑:更新后必须让缓存消失
很多事故就出在“更新了数据库但忘记删缓存”或者“删缓存的异常被吞”。这个测试同时确认缓存存在时更新会把它删掉,并且下一次读会重新查库。
# tests/test_cache_invalidation.py
from unittest.mock import patch
from app.user_cache import get_user, update_user, r
def test_cache_invalidation_after_update(redis_client):
user_id = 2
key = f"user:{user_id}"
# 先造一条缓存数据,假装已经查过库
r.setex(key, 60, json.dumps({"id": user_id, "name": "old_name"}))
# 执行更新
update_user(user_id, "new_name")
# 更新后,缓存key应该被删除了
assert r.exists(key) == 0
# 再次获取用户数据应当重新查库(拿到新名字)
with patch("app.user_cache._db_fetch") as mock_fetch:
mock_fetch.return_value = {"id": user_id, "name": "new_name"}
user = get_user(user_id)
assert user["name"] == "new_name"
mock_fetch.assert_called_once() # 确实走了一次DB
5. 并发读写场景:多线程抢同一份数据不会脑裂
下面这个测试用 concurrent.futures 模拟三个线程同时请求同一个未缓存 user_id,验证只有一次 DB 查询,不会有重复写入导致短暂不一致或资源浪费。这是防止缓存击穿的简易版。
# tests/test_concurrent.py
import concurrent.futures
from unittest.mock import patch
from app.user_cache import get_user
def test_concurrent_cache_fill_one_db_call(redis_client):
user_id = 3
call_count = 0
# 真实场景,_db_fetch 会有真实查库,这里用原来函数但稍加统计
original_fetch = __import__("app.user_cache", fromlist=["_db_fetch"])._db_fetch
def traced_fetch(uid):
nonlocal call_count
call_count += 1
return original_fetch(uid)
with patch("app.user_cache._db_fetch", side_effect=traced_fetch):
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(get_user, user_id) for _ in range(3)]
results = [f.result() for f in futures]
# 所有结果应该一致,并且只触发了1次DB查询
assert all(r["name"] == "user_3" for r in results)
assert call_count == 1
踩坑记录:那些文档没告诉你的事
坑 1:decode_responses=True 让你的 json.loads 报错
现象:写入缓存时用了 json.dumps 存字符串,读回来再 json.loads 直接报 TypeError: the JSON object must be str, bytes or bytearray, not 'str'——离谱,明明就是 str。
根因:redis.Redis(decode_responses=True) 返回的确实是字符串,但如果你在另一个地方创建客户端时忘记加这个参数,拿到的是 b'...' bytes,两个客户端混用就会把你逼疯。
解决:项目里统一封装一个 get_redis() 工厂函数,禁止直接 Redis() 实例化,在函数内强制设置 decode_responses=True。
坑 2:Docker 容器内连不上 localhost:6379
现象:测试在宿主机跑得好好的,放到 Jenkins CI(Docker in Docker)直接连接拒绝。原因:在 CI 的容器里,localhost 指向 CI 自己,而不是 Redis 容器。用服务名 redis 作为 host 也不行,因为 docker-compose 创建的 network 跟 CI 的隔离。
解决:通过环境变量注入 Redis 地址。测试 fixture 里改成 host = os.getenv("REDIS_HOST", "localhost"),CI 脚本里 export REDIS_HOST=redis 并在同一个 compose 网络内运行 pytest 容器。或者让 CI 直接把 Redis 端口映射到宿主机。
坑 3:flushdb() 没有清干净?测试结果不可重复
部分测试跑了后,下一个测试莫名失败。原因是 flushdb() 清的是 db 0,但某个测试用了 db 1,两个测试跑到同一个 Redis 实例里互相污染。后来约定所有测试都用 db 0 且在 fixture 里显式 flushdb,避免自作聪明换库。
效果验证:月均事故从 3 次降到 0
上线这套自动化测试并接入 CI 后,缓存相关的线上问题统计变化明显:
| 指标 | 优化前(人工验证) | 优化后(Pytest+Docker) |
|---|---|---|
| 缓存不一致导致的月度事故次数 | 3 | 0 |
| 缓存相关 PR 代码 review 覆盖盲区 | 大量并发、序列化场景未覆盖 | 15 个场景全自动覆盖 |
| 定位缓存问题的平均时间 | 2.5 小时(翻日志猜) | 5 分钟(测试直接报错到具体函数) |
| 回归测试时间 | 每次上线前 1 人/时手工测 | 0(CI 3 分钟跑完) |
最直接的价值——我们再也没半夜接过“数据库又炸了”的电话,睡眠质量显著提升。
可直接用的代码/工具
把下面的 conftest.py 粘到你的 tests/ 目录,配合 docker-compose.yml 运行 pytest,就能立刻拥有 Redis 集成测试环境。GitHub 仓库 有完整示例,开箱即用。命令:docker-compose up -d && pytest。
# conftest.py 完整模板(含等待和清理)
import pytest, redis, time, socket, subprocess
def _wait_for_redis(host, port, timeout=30):
start = time.time()
while time.time() - start < timeout:
try:
sock = socket.create_connection((host, port), timeout=1)
sock.close()
return
except (ConnectionRefusedError, OSError):
time.sleep(0.5)
raise TimeoutError(f"Redis {host}:{port} 无法连接")
@pytest.fixture(scope="session")
def docker_redis():
subprocess.run(["docker-compose", "up", "-d"], check=True)
_wait_for_redis("localhost", 6379)
yield
subprocess.run(["docker-compose", "down"])
@pytest.fixture
def redis_client(docker_redis):
client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
client.flushdb()
yield client
client.close()
#Python #后端 #Redis #自动化测试 #性能优化
关于作者
一个在后端和架构摸爬滚打多年的工程师,喜欢把线上炸过的坑变成可复用的代码,专注 Python 系统优化与工具链打磨。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你少加了一天班,欢迎请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege