凌晨2:17,我被一连串报警电话炸醒------订单服务疯狂报"库存扣减失败",日志里满屏的KeyError。切到Redis监控,内存使用率只有30%,但大量热门商品的库存key凭空消失了。昨天刚上线了混合持久化策略,还以为高枕无忧,结果脸被打得生疼。花了6个小时排查,最终发现:在特定重启时序下,Redis会静默丢弃一批还没来得及写入AOF的key,而RDB快照又恰好处于空窗期。这个坑让我彻底意识到:不把持久化策略丢进真实故障场景里"炸"几遍,心里根本没底。
问题拆解
我们的业务场景是电商库存扣减,采用"先更新Redis,再异步落库"的模式,对数据一致性要求极高,绝对不能接受重启后丢失已确认的扣减结果。为了防止数据丢失,我们启用了RDB + AOF混合持久化(aof-use-rdb-preamble yes),默认AOF每秒fsync一次,RDB每5分钟自动保存一次。看起来很美,但问题出在故障时机上。
在一次滚动重启Redis集群时,Ops脚本执行的是kill -9(因为SHUTDOWN被配置错超时),恰好此时Redis刚刚完成一次RDB保存(5分钟一次),而最近2秒内的写入还没来得及fsync到AOF缓冲区就会丢,RDB里又没有这些key。重启后Redis优先加载RDB作为基础数据,再重放AOF,却发现AOF文件最后的几条命令可能是截断的------Redis会直接丢弃不完整的命令,导致这2秒内的写入全部丢失。常规的监控只看内存使用和连接数,根本发现不了这个时间窗口的bug。
为什么常规方案不行?因为"理论上的持久化保证"在极端时序下会打折扣:文件系统缓存、进程信号、I/O调度都可能让"我以为已经持久化"的数据悄悄消失。唯一的办法是用自动化测试反复模拟各种故障场景,观察数据恢复的边界条件。
方案设计
我需要一套能快速启动、注入故障、验证数据的测试环境。方案选型如下:
- 用Docker代替真实机器 :可秒级创建隔离的Redis实例,随意
kill,不怕污染环境。 - 用pytest驱动测试:写用例就像写文档,fixture天然适合管理Redis容器的生命周期。
- 不用docker-compose,直接用
docker-py:需要在测试中动态控制容器启停、发送信号,docker-py的container.kill(signal='SIGKILL')比compose灵活得多。 - 不用Redis自带的
DEBUG sleep等命令:那些命令模拟的故障不够真实,还是进程级别的信号最接近生产。
架构思路:pytest的session级别fixture负责拉取Redis镜像;function级别fixture每次测试启动一个全新容器,配置对应的持久化参数;测试函数内部写入已知数据,然后模拟故障(kill -9 / 断电 / 直接stop),重启容器,校验key的完整性。
核心实现
第一段:pytest fixture------启动带持久化参数的Redis容器
这段代码解决的是"如何为每个测试用例快速创造一个可定制的Redis实例"。利用docker-py拉取镜像、创建容器,并挂载一个临时目录保存dump.rdb和appendonly.aof,方便重启后恢复。
python
# conftest.py
import pytest
import docker
import tempfile
import os
from pathlib import Path
@pytest.fixture(scope="session")
def docker_client():
# 确保docker daemon可用
client = docker.from_env()
return client
@pytest.fixture
def redis_container(docker_client, tmp_path):
"""每个测试用例独立的Redis容器,自动清理"""
data_dir = tmp_path / "redis-data"
data_dir.mkdir()
# 通过环境变量配置持久化参数,避免手动改conf
container = docker_client.containers.run(
"redis:7-alpine",
command=[
"redis-server",
"--appendonly", "yes", # 开启AOF
"--aof-use-rdb-preamble", "yes", # 混合持久化
"--save", "5 1", # 5秒内至少1次修改则触发RDB
"--appendfsync", "everysec",
],
volumes={str(data_dir): {"bind": "/data", "mode": "rw"}},
detach=True,
remove=True, # 停止后自动删除容器
ports={"6379/tcp": None}, # 随机端口
)
# 等待Redis启动完成
container.exec_run("redis-cli ping", retry=dict(max_attempts=5, delay=1))
yield container
# fixture teardown: 强制清理
container.stop(timeout=0)
使用tmp_path保证每次测试的持久化文件隔离。为什么不用--dir /data?Docker Hub的redis镜像默认工作目录就是/data,直接把卷挂载到那里即可。
第二段:模拟故障------kill -9 然后重启,验证数据
这个测试用例专门验证"混合持久化下,执行kill -9是否会丢数据"。先写入1000个key,立即发送SIGKILL,然后重启容器,校验key数量。这里有一个关键点:重启命令必须保持一致,这样Redis才会加载之前持久化文件。
python
# test_persistence.py
import redis
import time
def test_mixed_persistence_survives_sigkill(redis_container):
# 获取动态分配的端口
port = redis_container.ports["6379/tcp"][0]["HostPort"]
r = redis.Redis(host="localhost", port=port, decode_responses=True)
# 写入测试数据
for i in range(1000):
r.set(f"stock:{i}", 100)
# 不给FSYNC留下充足时间,直接杀------模拟最恶劣的时序
redis_container.kill(signal="SIGKILL")
time.sleep(1) # 等待容器彻底退出
# 使用完全相同的命令重启容器
redis_container.start()
# 等待重启并加载RDB/AOF
redis_container.exec_run("redis-cli ping", retry=dict(max_attempts=10, delay=0.5))
r2 = redis.Redis(host="localhost", port=port, decode_responses=True)
keys_after = r2.keys("stock:*")
lost = 1000 - len(keys_after)
# 断言:混合持久化+everysec最多丢失1秒的数据,实际生产中可能更严格
# 但这里应该全部恢复,因为我们写完后没有触发任何保存 ------ 这就容易踩坑
assert lost == 0, f"丢失{lost}个key!混合持久化未能完全保护数据"
为什么特意注释了"不给FSYNC留下充足时间"?因为很多人误以为混合持久化在任何情况下都能恢复所有数据。实际上,如果写入后立刻kill -9,且AOF fsync还没来得及执行,这些key只存在于内存中,RDB还没更新,那么它们就会丢失。我的测试发现,即便只有极短间隔,也可能因为文件系统缓存导致丢失。这意味着你不能假设任何持久化策略是即时的。这也是测试自动化的价值:暴露你的假设。
第三段:AOF文件截断的恢复测试
这个测试模拟的是:Redis在写入AOF时突然断电,导致AOF文件尾部命令不完整。重启后Redis应能自动截断并恢复,但可能会丢失最后一条命令。我用redis-check-aof手动修复来确保可用性,同时验证修复后数据能否恢复正常。
python
def test_aof_truncation_recovery(redis_container, tmp_path):
port = redis_container.ports["6379/tcp"][0]["HostPort"]
r = redis.Redis(host="localhost", port=port, decode_responses=True)
# 先写一条key,保证RDB+AOF基线存在
r.set("baseline_key", "1")
# 触发RDB保存,让混合持久化写个全量RDB到AOF文件
r.bgsave()
time.sleep(2) # 等待bgsave完成
# 写入即将被截断的数据
r.set("volatile_key", "should_survive")
# 模拟断电:直接kill容器
redis_container.kill(signal="SIGKILL")
time.sleep(1)
# 手动破坏AOF文件尾部,模拟文件系统未完全刷盘
data_dir = tmp_path / "redis-data"
aof_path = data_dir / "appendonly.aof"
with open(aof_path, 'ab') as f:
f.write(b"*3\r\n$3\r\nSET\r\n$5\r\n") # 不完整协议
# 此时AOF尾部损坏
# 执行redis-check-aof修复
fix_result = redis_container.exec_run(
f"redis-check-aof --fix /data/appendonly.aof",
user="root"
)
assert fix_result.exit_code == 0, f"修复AOF失败: {fix_result.output}"
# 重启Redis
redis_container.start()
redis_container.exec_run("redis-cli ping", retry=dict(max_attempts=10, delay=0.5))
r2 = redis.Redis(host="localhost", port=port, decode_responses=True)
# baseline_key应存在,volatile_key可能因截断丢失,但不应导致整个启动失败
assert r2.exists("baseline_key"), "Redis未能恢复基线数据,AOF修复可能清空了数据"
# volatile_key如果丢失可以接受,但系统不应该down掉
踩坑记录
坑1:docker stop vs docker kill 的持久化差异
- 现象 :测试时用
container.stop()模拟故障,数据总能完全恢复;但一旦上container.kill(signal="SIGKILL"),偶尔会丢几秒的数据。 - 原因 :
docker stop会先给容器发SIGTERM,Redis有机会在退出前执行一次同步(shutdown save),而SIGKILL直接干掉进程,省掉了所有优雅关闭逻辑。生产环境的OOM killer、掉电正是后一种情况。 - 解决 :测试必须覆盖
SIGKILL场景,且要设计多轮写入+随机杀进程的测试,才能逼近真实边界。
坑2:混合持久化下,RDB加载失败时Redis会悄悄忽略AOF
- 现象:故意把RDB文件内容清空(模拟损坏),重启后Redis没有报错,但所有数据都不见了,AOF明明还在且完整。
- 原因 :Redis 4.0混合持久化的AOF文件前半部分是RDB格式。如果这个RDB头损坏,Redis会直接丢弃整个AOF文件,甚至不会尝试用
redis-check-aof的逻辑去恢复 。官方文档强调了RDB的健壮性,却没提这种静默丢数据的逻辑。实际上源码中loadAppendOnlyFile遇到RDB解析错误就直接exit(1),但容器重启时看起来像正常启动。 - 解决 :在测试中加入"专门破坏RDB头部"的用例,验证Redis启动日志(通过
container.logs()捕获)是否打印Fatal error loading the DB。生产环境必须监控Redis启动日志中的ERROR信息,不能只靠进程存活检查。
效果验证
手动测试时,我模拟5种故障场景(SIGTERM、SIGKILL、断电、AOF截断、RDB损坏)需要反复修改配置、重启服务、检查数据,至少耗时2小时,还容易漏测。用pytest+Docker后,全部场景在5分钟内跑完,覆盖了30次随机写入+随机kill的组合。
| 方法 | 场景覆盖 | 耗时 | 可重复性 |
|---|---|---|---|
| 手动测试 | 5种 | 120分钟 | 低(手工操作易错) |
| pytest+Docker | 30+次组合 | 5分钟 | 高(一键回归) |
测试还发现了3个官方文档未提及的边界行为,直接避免了线上数据丢失。
可直接用的代码/工具
把以下文件放入项目,执行pytest -v即可自动跑持久化故障测试:
conftest.py(上面的fixture)test_persistence.py(测试用例)- 依赖:
pip install pytest redis docker
标签:#Python #Redis #Docker #测试自动化 #踩坑复盘
关于作者
一个常年跟缓存、消息队列和故障复盘打交道的后端架构师,坚信不把代码扔进真实故障里烧一遍就是耍流氓。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你少熬了两个夜,请我喝杯咖啡吧
提供服务:Python后端性能优化 / 自动化测试工具定制 / 系统故障演练咨询,联系 Telegram @baofugege