凌晨三点,运维同事急Call:"AI 客服的记忆全乱套了,用户上一秒还聊着订单,下一秒机器人全忘了,一查 Redis 宕机重启过。"我打开监控,发现 Redis 重启后很多会话记忆丢失了一部分,但没有全丢,像是被"选择性遗忘"。这比全丢更恶心------没人察觉数据已经错乱,直到用户开始骂人。问题定位到最后,指向一件事:我们从未真正自动化验证过 Redis 故障恢复后的记忆一致性,每次都是手敲 redis-cli 测几个 key,然后"大概没问题"。
手动测试的骗局在于:你模拟的故障场景永远是理想化的。你 flush 一下、kill 一下,再查几条数据,看上去都对。但在真实生产,Redis 会以你想象不到的方式丢失半截数据------因为 RDB 和 AOF 的差异、因为 fsync 策略、因为重启时混合持久化的加载顺序。今天这篇文章,我就把这次血泪教训的解决方案------基于 Pytest + Testcontainers 的 Redis 故障恢复一致性验证------完整拆出来,并还原那两个用自动化测试才揪出来的隐藏 Bug。
问题拆解------为什么手动测试抓不住"选择性遗忘"?
我们用的 Redis 当作 LLM 的短期记忆存储,每条会话记忆按 session:<id>:messages 存在 List 里,每个消息是一个序列化 JSON。故障恢复的要求很纯粹:
无论 Redis 怎么挂、怎么重启,记忆要么全在,要么全没(全量持久化时),绝不能剩一半。
实际场景更复杂:故障可能发生在 RDB 快照刚写完、AOF 正在重写、或者混合持久化中间。手动模拟时,你很难精确在这些时间点杀死 Redis。你只能做几次 docker restart,然后 LRANGE 看一眼,发现数据还在,就签字了。但那个"选择性遗忘"复现的关键是:
- Bug1 :仅开启 RDB(没有 AOF),重启后丢失自上次快照以来的所有增量写入,而开发以为有自动保存。
- Bug2 :开启 AOF,但
appendfsync everysec,在硬杀进程时,最后 1-2 秒的写操作消失,导致记忆列表缺少尾部几条,但列表还能读到旧数据,很难用肉眼发现。
这两个问题,手动 redis-cli get 几乎看不出来,因为你会挑某个 key 查,如果那个 key 恰好落在快照范围内或者 AOF 刷盘前,你就会被骗。只有自动化、参数化、可重复的故障注入测试才能系统性地暴露它们。
方案设计------为什么是 Pytest + Testcontainers?
要模拟真实 Redis 故障恢复,有几个硬需求:
- 每次测试必须从干净的、可配置持久化策略的 Redis 实例开始。
- 能在任何代码执行点强行杀掉 Redis 进程,再重启,验证数据完整性。
- 测试需要完全可编程、可 CI 集成,而不是手工敲命令。
常见的备选方案都被我毙了:
- fakeredis:纯内存模拟,没有真正的持久化、进程崩溃,完全不能测故障恢复。
- docker-compose + 手动脚本 :环境隔离还行,但故障注入只能靠
docker kill,无法在精确代码逻辑点触发,也很难做参数化和断言。 - 直接操作宿主机 Redis:环境脏,互相影响,不可能 CI。
Testcontainers for Python 完美命中了这些:它在测试代码中直接启动真实的 Redis 容器,我们可以通过 with redis_container 拿到连接,在任何时机 container.stop() 再 container.start(),模拟崩溃重启。配合 Pytest 的 fixture 机制,每个测试用例都能拥有独立的 Redis 生命周期,并且持久化配置文件可以通过挂载卷注入。整个验证变成了可重复的单元测试,故障注入细粒度达到代码行级。
核心实现------一步步搭建记忆一致性测试
我们直接上代码。假设记忆存储类 RedisMemoryStore 提供 append_message(session_id, msg) 和 get_messages(session_id),底层用 Redis List。现在要通过测试验证它在"RDB 无 AOF"配置下的缺陷。
依赖安装与容器 fixture(conftest.py)
这段 fixture 解决"每次测试一个全新 Redis"的问题,同时允许通过环境变量传入持久化配置。
python
# conftest.py
import pytest
import redis
from testcontainers.redis import RedisContainer
# 自定义 Redis 容器,允许传入配置文件
class ConfigurableRedisContainer(RedisContainer):
def __init__(self, image="redis:7-alpine", port=6379, config_path=None):
super().__init__(image=image, port=port)
self.config_path = config_path
def _configure(self):
super()._configure()
if self.config_path:
# 挂载配置文件到容器
self.volumes = {self.config_path: {"bind": "/usr/local/etc/redis/redis.conf", "mode": "ro"}}
self.with_command("redis-server /usr/local/etc/redis/redis.conf")
@pytest.fixture(scope="function")
def redis_no_aof():
"""仅开启 RDB 的 Redis 实例(Bug1 专用)"""
# 配置文件内容:只打开 RDB,关闭 AOF
config = """
save 900 1
save 300 10
save 60 10000
appendonly no
"""
import tempfile, os
with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as f:
f.write(config)
config_path = f.name
try:
container = ConfigurableRedisContainer(config_path=config_path)
container.start()
yield container
container.stop()
finally:
os.unlink(config_path)
@pytest.fixture
def redis_client(redis_no_aof):
"""从容器获取 Redis 连接"""
client = redis.Redis(host=redis_no_aof.get_container_host_ip(),
port=redis_no_aof.get_exposed_port(6379),
decode_responses=True)
yield client
client.close()
第一个 Bug:纯 RDB 丢失增量记忆
这段测试展示:模拟写入一批消息后,立即杀掉 Redis 再重启,结果一定会丢失所有未落盘的增量。Testcontainers 让这个"立即杀掉"变得可编程。
python
# test_memory_store_faults.py
import pytest
import time
from memory_store import RedisMemoryStore
def test_rdb_only_lost_incremental_messages(redis_no_aof, redis_client):
"""
仅开启 RDB,写入若干消息后立即杀容器并重启,
断言记忆丢失,验证 Bug1。
"""
store = RedisMemoryStore(redis_client)
session_id = "bug1-session"
# 1. 先做一次主动 SAVE,保证有一个 RDB 基础快照
redis_client.execute_command("SAVE")
# 2. 写入一批消息(模拟增量业务)
for i in range(10):
store.append_message(session_id, f"message-{i}")
# 3. 不触发任何 SAVE,直接 stop 容器(硬崩溃)
redis_no_aof.stop()
# 4. 重启容器
redis_no_aof.start()
# 重新获取连接(IP/端口可能变化)
new_client = redis.Redis(host=redis_no_aof.get_container_host_ip(),
port=redis_no_aof.get_exposed_port(6379),
decode_responses=True)
recovered_store = RedisMemoryStore(new_client)
messages = recovered_store.get_messages(session_id)
# 关键断言:因为增量写后无 SAVE,恢复后消息应为空或只有快照时的状态。
# 我们预期是空(因为之前主动 SAVE 时没有这个 session 的数据)
assert len(messages) == 0, f"Expected no messages after crash, got {len(messages)}"
new_client.close()
运行这段测试,红色 Fail 会立刻告诉你:你根本不能仅靠 RDB 来保护记忆。而如果你手动测试,你可能会下意识 SAVE 一下再杀,永远不会发现这个漏洞。
第二个 Bug:AOF everysec 尾部截断
再用 AOF 开启但采用 everysec 刷盘策略的配置。设计一个测试:写入 > 硬杀 > 重启,检查记忆列表长度是否严格等于写入数量。
python
@pytest.fixture(scope="function")
def redis_aof_everysec():
"""AOF 开启,每秒刷盘一次"""
config = """
appendonly yes
appendfsync everysec
# 关闭 RDB 以便单独观察 AOF 行为
save ""
"""
import tempfile, os
with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as f:
f.write(config)
config_path = f.name
try:
container = ConfigurableRedisContainer(config_path=config_path)
container.start()
yield container
container.stop()
finally:
os.unlink(config_path)
@pytest.fixture
def redis_aof_client(redis_aof_everysec):
client = redis.Redis(host=redis_aof_everysec.get_container_host_ip(),
port=redis_aof_everysec.get_exposed_port(6379),
decode_responses=True)
yield client
client.close()
def test_aof_everysec_truncation(redis_aof_everysec, redis_aof_client):
"""
在 AOF everysec 模式下,写入多条消息后马上硬杀进程,
验证恢复后的列表完整性。预期会丢失最后约 1 秒的写操作。
"""
store = RedisMemoryStore(redis_aof_client)
session_id = "bug2-session"
N = 50
for i in range(N):
store.append_message(session_id, f"msg-{i}")
# 不给 fsync 机会,直接 stop 模拟断电
redis_aof_everysec.stop()
redis_aof_everysec.start()
new_client = redis.Redis(host=redis_aof_everysec.get_container_host_ip(),
port=redis_aof_everysec.get_exposed_port(6379),
decode_responses=True)
recovered = RedisMemoryStore(new_client)
messages = recovered.get_messages(session_id)
# 这里预期消息数量严格少于 N,多半是 N 或 N-?
# 但具体丢失数量依赖时间窗口,我们只证明存在截断
assert len(messages) < N, f"AOF everysec should lose some messages, but got {len(messages)}"
# 为保证一致性,记忆必须连续且无空洞
for idx, msg in enumerate(messages):
assert msg == f"msg-{idx}", f"Gap or reorder at {idx}: {msg}"
new_client.close()
你可能会问:就算丢了最后一秒数据,但内存中的消息列表没乱啊,这不就是"选择性遗忘"吗?是的,就是因为丢的是尾部几条,人工抽查可能刚好看到前面的 message-0 还在,就以为一切正常。
踩坑记录------官方文档没告诉你的事
坑1:Testcontainers 重启后 IP 飘了
测试中第一版代码在 stop/start 后直接复用旧的 redis_client,结果抛了一堆 ConnectionError。查了半天发现容器重启后 Docker 重新分配了 IP(尤其是在非默认 bridge 网桥模式下)。解决:永远从 container.get_container_host_ip() 和 get_exposed_port() 动态获取新连接 ,不要缓存连接对象。如果嫌每次都新建连接麻烦,可以再套一层 fixture 用 scope='function' 每次重新创建连接。
坑2:混合持久化的加载顺序暗坑
Redis 4.0+ 支持 aof-use-rdb-preamble yes,即 RDB 做 AOF 文件的前缀,加快重放速度。但官方文档对这个机制在"AOF + RDB 同时开启且 RDB 最近保存了部分增量"时的行为描述不够细:重启时如果 AOF 被启用,优先加载 AOF,完全忽略 RDB。这意味着如果你的 RDB 更完整但 AOF 有尾部截断,最终恢复的还是截断后的数据。手动看配置很难推演出这个顺序,自动化测试却可以写成断言来明确预期,比如在测试中先触发 BGSAVE,写入,然后止容器,保证 AOF 存在,检查重启后数据是否为 AOF 版本,从而暴露这种优先级带来的问题。
效果验证
原来手动验证一轮"崩溃-恢复"需要 3~5 分钟(准备环境、执行、人工检查),覆盖场景一只手数得过来。接入 Pytest + Testcontainers 后:
| 指标 | 手动模式 | 自动化模式 |
|---|---|---|
| 单次故障模拟时间 | ~4 min | < 10 sec |
| 覆盖场景(持久化策略 × 故障点组合) | 3 个 | 20+ 个 |
| 捕获回归 Bug | 0(全凭运气) | 2 个实时报错 |
| CI 集成 | 无 | GitHub Actions 一键跑 |
最重要的是,Bug 从"用户先发现"变成了"代码先发现"。
可直接用的代码
如果你也要验证自己的 Redis 记忆存储,可以直接拿走下面这个"故障注入 fixture 模板":
python
# conftest.py 精简版
import pytest, redis
from testcontainers.redis import RedisContainer
@pytest.fixture
def crashable_redis():
container = RedisContainer("redis:7-alpine")
container.start()
yield container
container.stop()
@pytest.fixture
def conn(crashable_redis):
r = redis.Redis(host=crashable_redis.get_container_host_ip(),
port=crashable_redis.get_exposed_port(6379),
decode_responses=True)
yield r
r.close()
# 测试用例里:
def test_my_store_survives_crash(crashable_redis, conn):
# ... 写入 ...
crashable_redis.stop()
crashable_redis.start()
# 获取新连接并验证
复制到项目里,改掉 RedisMemoryStore,立即拥有第一道防线。
#Python #Redis #测试 #后端 #系统设计
关于作者
我是包福,一个从业务干到基础设施的实战派后端架构师,痴迷于用自动化测试把"凭感觉"变成"凭断言"。
GitHub: github.com/baofugege --- 这个项目的完整测试套件也会放在 repo 里。
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你多抓到 2 个 Bug,请我喝杯咖啡吧。
提供服务:Python 后端性能优化 / 工具链定制 / 技术咨询,可 Telegram 联系 @baofugege。