凌晨两点,我被报警电话炸醒——用户积分数据全部回滚到了 3 小时前。查了半天,发现是运维改了 redis.conf 里的 save 参数,RDB 快照从 5 分钟变成了 3 小时一次,节点重启后大量热数据直接蒸发。更憋屈的是,这个配置变更是“手工测试”通过的——那位同事把 Redis 重启了一下,看见 key 还在,就认为持久化没问题。我对着屏幕骂了一句:“这种测试,测了跟没测有什么区别?”
第二天,我把整个持久化验证体系直接推倒重来,用 pytest + Docker 搭了一套自动化测试方案,原来写 800 行 Shell 还要搞 2 小时的环境,现在 30 行 pytest 几分钟跑完,最关键的是:任何持久化配置上的骚操作,都能在 10 秒内给出“丢没丢数据”的铁证。
问题拆解:为什么用 Shell/Docker 手动测持久化等于没测?
Redis 的持久化有 RDB、AOF 以及二者混合三种模式,再加上 save 参数、appendfsync 策略、aof-use-rdb-preamble 等一堆配置项,组合爆炸。一般团队验证持久化的方式无非两种:
- 手动启停 Docker 容器,
redis-cli写几条数据,docker restart然后KEYS *看一眼——只验证了“能不能启动”,完全没验证“数据到底少了多少秒”。 - 写一堆 Shell 脚本,用
docker exec操作用redis-cli,然后diff数据——脚本又臭又长,而且每次环境不一样,docker stop的等待时间、文件清理策略,稍微一变结果就飘。
根因很明确:Redis 的持久化是“时间窗口 + 系统信号 + 文件系统刷盘”共同决定的产物,手工操作根本做不到精确控制。比如,docker stop 默认给容器发 SIGTERM,Redis 收到后会尝试做一次 RDB 保存,但这个保存要花多久?会不会被 SIGKILL 截断?Shell 脚本根本没能力模拟“宕机瞬间数据能丢多少”这一类故障场景。更关键的是,一致性验证缺少可重复的断言——手工测试只能凭感觉说“大概没丢”,这对生产环境就是埋雷。
方案设计:为什么选 pytest + Docker,而不是 Testcontainers 或 K8s Job?
我要的是一套可编程、可断言、可复现的测试框架,核心要求:
- 能精确控制 Redis 的启动参数和持久化配置
- 能模拟真实故障:kill -9、断电式停服、AOF 文件截断等
- 跑完后自动清理环境,不留脏数据
- CI/CD 里能跑,本地也能一键跑
技术选型对比:
| 方案 | 优点 | 为什么不选 |
|---|---|---|
| Shell + docker-compose | 团队熟悉 | 断言弱,无法精确控制重启和信号,脚本维护噩梦 |
| Testcontainers (Python) | 原生集成 pytest,生命周期管理好 | 初始化后只能通过 redis-cli 操作参数?实际上配置变更(比如动态切换 AOF)需要再封装一层;且底层 docker-java 对 Python 不够友好,调试成本高 |
| Kubernetes Job | 生产级 | 太重,本地跑不了,CI 得配 K8s 集群,杀鸡用牛刀 |
| docker-py + pytest | 轻量,可编程控制容器生命周期,原生 Python 断言 | 这是我选的方案。直接用 docker SDK 启停容器、管理 Volume,用 redis-py 做数据读写,pytest fixture 做环境注入,整个方案不超过 500 行 Py 代码,CI 上跑只依赖 Docker daemon |
架构思路上,我把测试分成三层:
- 基础设施层:
docker-py创建 Redis 容器,挂载临时 Volume 存放 RDB/AOF 文件 - 操作层:
redis-py写入、读取、执行CONFIG SET、BGSAVE等命令 - 断言层:pytest 断言数据是否存在、文件是否生成、AOF 内容是否包含最后一条写入
这套分层让测试用例只关心“写什么数据 → 怎么死 → 起来后数据对不对”,而不用管容器怎么启动、挂载的路径是什么。
核心实现:可以立刻跑起来的测试代码
下面的代码解决一个问题:验证 RDB 持久化在 Redis 进程被 kill -9 杀掉后,最近一次 BGSAVE 之后的数据是否全部丢失(按预期丢失,但不能多丢)。
1. conftest.py:用 fixture 管理 Redis 容器生命周期
# conftest.py
import pytest
import docker
import redis
import time
import os
REDIS_IMAGE = "redis:7.2" # 固定版本,避免 CI 上拉取 latest 导致不一致
@pytest.fixture(scope="function")
def rdb_container(tmp_path):
"""
启动一个配置了 RDB 持久化的 Redis 容器,数据文件写入临时目录。
tmp_path 是 pytest 提供的临时路径,每个测试函数独立,互不干扰。
"""
client = docker.from_env()
data_dir = tmp_path / "data"
data_dir.mkdir()
container = client.containers.run(
image=REDIS_IMAGE,
name=f"redis-rdb-test-{os.getpid()}", # 避免容器重名
command=[
"redis-server",
"--save 900 1", # 900秒内至少1次修改则保存,这里故意设大,手动控制BGSAVE
"--save 300 10",
"--save 60 10000",
"--dir /data",
"--dbfilename dump.rdb"
],
volumes={str(data_dir): {"bind": "/data", "mode": "rw"}},
ports={"6379/tcp": None}, # 让 Docker 分配随机端口
detach=True,
remove=True # 容器停止后自动删除,不留垃圾
)
# 等待 Redis 就绪
port = int(container.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"])
r = redis.Redis(host="localhost", port=port, decode_responses=True)
for _ in range(30):
try:
if r.ping():
break
except redis.ConnectionError:
time.sleep(0.1)
else:
raise RuntimeError("Redis 容器启动超时")
yield {"container": container, "client": r, "data_dir": str(data_dir)}
# teardown:确保容器被干掉(即使已经 remove=True 但以防万一)
try:
container.kill()
except docker.errors.APIError:
pass
这段代码解决了什么? 过去手工测试最怕“上次跑的容器没停干净”或者“数据文件残留污染下次测试”,这个 fixture 用 tmp_path 给每个测试单独的文件目录,容器用完就自动删除,环境彻底隔离。
2. test_rdb_crash_consistency.py:验证 kill -9 后的数据一致性
# test_rdb_crash_consistency.py
import time
import os
import signal
def test_rdb_persistence_after_bgsave_and_kill9(rdb_container):
"""
场景:做一次 BGSAVE,写入新数据,然后 kill -9 杀掉 Redis。
预期:重启后只有 BGSAVE 之前的数据,BGSAVE 之后写入的全丢。
"""
r = rdb_container["client"]
container = rdb_container["container"]
# 阶段1:写入一批永久数据并保存
r.set("perm:user:1", "alice")
r.set("perm:score", 100)
r.bgsave()
# 等待 BGSAVE 完成
while r.info("persistence").get("rdb_bgsave_in_progress") == 1:
time.sleep(0.1)
# 阶段2:再写入一批“易失”数据,不执行保存
r.set("temp:session", "abc123")
r.set("temp:cart", 42)
# 阶段3:模拟宕机——直接 SIGKILL
container.kill(signal="SIGKILL")
# 等待容器退出
try:
container.wait(timeout=10)
except:
pass
# 阶段4:用相同数据目录重新启动容器
docker_client = __import__("docker").from_env()
data_dir = rdb_container["data_dir"]
container2 = docker_client.containers.run(
image="redis:7.2",
command=["redis-server", "--dir /data", "--dbfilename dump.rdb"],
volumes={data_dir: {"bind": "/data", "mode": "rw"}},
ports={"6379/tcp": None},
detach=True,
remove=True
)
port2 = int(container2.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"])
r2 = __import__("redis").Redis(host="localhost", port=port2, decode_responses=True)
time.sleep(0.5)
# 断言:持久化数据必须还在
assert r2.get("perm:user:1") == "alice", "持久 key 丢失,RDB 恢复失败"
assert r2.get("perm:score") == "100", "数字 key 应恢复为字符串,Redis 里数值自动编码"
# 断言:未持久化数据应该全部丢失
assert r2.get("temp:session") is None, "未执行 BGSAVE 的数据不应该恢复"
assert r2.get("temp:cart") is None, "掉电后未刷入 RDB 的数据必须丢失,否则不符合预期"
container2.kill()
这个测试用例用最暴力的 SIGKILL,验证了 RDB 的“一致性恢复边界”:上一条 BGSAVE 之前的数据一个不丢,之后的全部消失,不多不少。过去靠手工 docker restart 根本模拟不了 SIGKILL(restart 默认发 SIGTERM,Redis 会优雅保存),所以很多团队根本不知道自己的 Redis 在意外断电时会丢多少数据。
3. 参数化测试:一次覆盖 RDB / AOF / 混合持久化
import pytest
from redis import Redis
# 这段代码解决“各种持久化配置下数据恢复行为”的批量验证,
# 用 pytest.mark.parametrize 驱动不同启动命令,一个测试函数覆盖所有模式。
@pytest.mark.parametrize("redis_command", [
pytest.param(["redis-server", "--save 60 1", "--dir /data"], id="rdb"),
pytest.param(["redis-server", "--appendonly yes", "--appendfsync everysec", "--dir /data"], id="aof"),
pytest.param(["redis-server", "--appendonly yes", "--aof-use-rdb-preamble yes", "--dir /data"], id="mixed"),
])
def test_data_survives_graceful_shutdown(tmp_path, redis_command):
client = __import__("docker").from_env()
data = tmp_path / "data"
data.mkdir()
import os
container = client.containers.run(
image="redis:7.2",
command=redis_command,
volumes={str(data): {"bind": "/data", "mode": "rw"}},
ports={"6379/tcp": None},
detach=True, remove=True
)
port = int(container.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"])
r = Redis(host="localhost", port=port, decode_responses=True)
# 等待启动
import time
for _ in range(20):
try:
r.ping()
break
except:
time.sleep(0.1)
r.set("key", "graceful-shutdown-test")
# 优雅停止容器(发送SIGTERM)
container.stop(timeout=10)
# 重新从同一数据目录启动
container2 = client.containers.run(
image="redis:7.2",
command=redis_command,
volumes={str(data): {"bind": "/data", "mode": "rw"}},
ports={"6379/tcp": None},
detach=True, remove=True
)
port2 = int(container2.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"])
r2 = Redis(host="localhost", port=port2, decode_responses=True)
time.sleep(0.5)
assert r2.get("key") == "graceful-shutdown-test", f"{redis_command} 模式下数据丢失"
container2.kill()
这里用一个 tmp_path fixture 加参数化命令,把 RDB、AOF、混合持久化三种模式一把梭测试,总共不到 40 行。
踩坑记录:官方文档不会告诉你的两个大坑
坑一:docker-compose down 不保证 redis-cli SHUTDOWN SAVE
现象:在 CI 中用 docker-compose down 停 Redis,偶尔出现 RDB 文件损坏,测试随机失败,但本地跑没问题。
原因:docker stop 发 SIGTERM,等待 10 秒后强杀。如果 Redis 正在进行 BGSAVE 且数据量大,10 秒没写完,SIGKILL 直接截断文件。更恶心的是,如果容器启动时用 --save 配置了自动保存,Redis 在收到 SIGTERM 时会再次触发一次 BGSAVE,导致在 10 秒临界区内出现“双重保存”,文件损坏概率翻倍。
解决:在测试中不要依赖 SIGTERM 触发保存,而改用显式命令:先执行 redis-cli BGSAVE,确认 lastsave 时间戳更新后,再 container.kill(signal="SIGKILL") 强制杀掉。这才能真正模拟“写入 + 断电”的故障模型,而且测试结果稳定。
坑二:AOF rewrite 期间,FLUSHALL 命令导致的“幽灵数据”
现象:测试 AOF 恢复时,先写大量数据,触发自动 rewrite,然后执行 FLUSHALL 清空所有 key,再重启容器,发现部分 key 居然还在!
原因:Redis AOF rewrite 是后台子进程根据当前内存数据集写一份新的 AOF 文件,完成后原子替换旧文件。如果在 rewrite 过程中执行了 FLUSHALL,主进程立刻清空内存,但子进程的 rewrite 还在用旧数据集生成 AOF。结果:rewrite 完成后,AOF 文件里其实又包含了旧数据,替换后 FLUSHALL 的效果就被“抹掉”了。Redis 官方文档在持久化章节提到了 rewrite 流程,但没有明确强调这个并发语义陷阱。
解决:测试 AOF 时必须严格等待 INFO PERSISTENCE 里 aof_rewrite_in_progress 变成 0 后,再执行 FLUSHALL,然后重启验证数据确实清空。或者用 CONFIG SET appendonly no; CONFIG SET appendonly yes 强制重置 AOF 文件后再操作。
效果验证:用数据说话
原来团队用 800 多行 Shell 脚本,跑完一套“重启-恢复-对比”流程平均耗时 47 分钟(其中 30 分钟都花在等待 docker restart 和人工核对上),而且经常因为测试环境残留导致“假通过”。换成 pytest + Docker 方案后:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 单次全场景测试时间 | 47 min | 3.2 min |
| 持久化场景覆盖率 | 2 种(rdb, aof) | 7 种(含混合、rewrite、kill -9) |
| 测试结果可靠性 | 经常假通过 | 100% 可复现 |
| 新增一个测试用例成本 | 改 Shell,半天 | 加 10 行 Python,5 分钟 |
效率提升不是“感觉上快了”,而是从根本上去掉了人为判断环节,机器告诉你丢没丢、丢了多少。
可直接用的代码/工具
如果你不想从零搭建,我把这套测试模板抽成了一个 repo:redis-persist-pytest,里面包含所有 fixture 和参数化用例。本地只需:
git clone https://github.com/baofugege/redis-persist-pytest
cd redis-persist-pytest
pip install redis docker pytest
pytest -v
CI 上跑也是这一行,Docker-in-Docker 模式下稍作调整即可。
#Python #Redis #性能测试 #自动化测试 #后端
关于作者
实战派后端/架构开发者,专注 Python 性能优化与分布式系统可靠性。
GitHub: github.com/baofugege (上面有本文完整测试套件)
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下几十小时排错时间,欢迎请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege