凌晨三点,手机像发了疯一样震动。告警信息只有一句话:"MySQL连接池耗尽,API成功率跌至12%"。哆嗦着打开监控,数据库QPS从800直接冲上2万,所有请求都在穿透打到MySQL。问题出在Redis------它刚被自动重启,但缓存全空了,3000多条商品热数据全部从内存消失,RDB文件没加载上来。运维前天改过一个配置,把RDB目录指向了一个不存在的路径,重启后Redis安安静静地启动了零数据的实例。我们自信满满的回滚机制,就这样华丽地"成功回滚到了空白"。
从那以后,我给自己立了一条规矩:任何涉及持久化、缓存恢复的变更,必须有一条自动化测试链路,能像黑客一样无情地注入故障,然后用事实告诉我"数据到底丢了没有"。这就是我折腾出 Playwright + Python + Docker 这套自动化验证方案的起因。今天把完整的实现和血泪教训写出来,希望能帮同行少掉一层皮。
问题拆解:为什么你的缓存回滚测试形同虚设
很多团队对"Redis持久化恢复"的测试仅停留在:停掉Redis → 重启Redis → 看看keys还在不在。这种测试根本测不出生产的问题,因为:
- 持久化文件的状态是不可信的 。RDB可能因为磁盘满而损坏、AOF可能被
bgrewrite截断、配置改错导致读不到文件------这些都是高频故障,但手工测试几乎不会覆盖。 - 业务逻辑对"空缓存"的反应没有被验证 。Redis恢复失败 → 缓存为空 → Go/Python服务有没有兜底从MySQL加载?加载过程中会不会因为并发穿透打崩数据库?这些需要端到端的流程测试,而不是简单的
GET一个key。 - 回滚成功与否没有和前端表现挂钩。用户看到的是商品详情报错还是旧数据?白屏吗?接口返回什么状态码?只有把整个链路跑通才算验证到位。
所以我们需要的不是几行单元测试,而是一套能模拟真实用户操作 → 制造持久化故障 → 检查业务层恢复效果的自动化方案。
方案设计:为什么是Playwright,而不是JMeter或单测?
我们评估了三条路:
- JMeter/Locust压测:能模拟大量请求,但做不了"点击按钮→填写表单→提交"这样的用户行为,也无法校验页面元素的值(比如商品名称是否正确恢复)。
- 后端单元测试+Mock Redis:只能测逻辑分支,测不到真正的RDB文件系统、进程重启、IO负载这些内核级行为。
- Playwright + Docker + pytest :Playwright控制浏览器模拟完整用户流程,pytest的fixture机制让我们可以随意停止/销毁Redis容器、删RDB文件、改配置,Docker保证每次测试环境干净。最大优势:故障注入和业务验证在同一个测试用例里闭环。
最终架构很简单:docker-compose拉起一个最小业务环境(Flask Web + Redis + MySQL),Playwright通过浏览器界面创建数据,pytest执行Redis"破坏",然后再次用Playwright读取页面数据并对比MySQL,判定回滚是否成功。
核心实现:从零搭建故障注入测试框架
下面这段docker-compose.yml是整个测试的基准环境。注意Redis的volume配置------这是第一个踩坑点,必须把RDB目录显式挂载出来,否则你删的文件根本不是它启动时加载的那个。
yaml
# docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
environment:
REDIS_HOST: redis
DB_HOST: mysql
depends_on:
- redis
- mysql
redis:
image: redis:7-alpine
# 必须显式挂载 /data,否则RDB文件会写在容器层,重启后丢失路径关系
volumes:
- redis_data:/data
command: redis-server --save 60 1 --loglevel warning
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: shop
volumes:
- mysql_data:/var/lib/mysql
volumes:
redis_data:
mysql_data:
接下来是pytest测试用例的整体结构,这段代码解决"怎么在一个测试函数里把用户操作、Redis破坏、恢复验证串起来"。我们使用async函数和Playwright的async API。
python
# test_cache_rollback.py
import asyncio
import time
import pytest
import docker
import redis
import pymysql
from playwright.async_api import async_playwright
BASE_URL = "http://localhost:8000"
REDIS_HOST = "localhost"
REDIS_PORT = 6379
@pytest.fixture(scope="module")
def docker_client():
return docker.from_env()
@pytest.fixture(scope="module")
def redis_conn():
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
yield r
r.close()
@pytest.fixture(scope="module")
def mysql_conn():
conn = pymysql.connect(host='localhost', user='root', password='testpass', database='shop')
yield conn
conn.close()
async def test_rollback_after_rdb_loss(docker_client, redis_conn, mysql_conn):
# 1. 用户通过前端创建商品,缓存写入Redis
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(f"{BASE_URL}/products/new")
await page.fill('input[name="name"]', '限量球鞋')
await page.fill('input[name="price"]', '999')
await page.click('button[type="submit"]')
# 等待创建成功
await page.wait_for_selector("text=商品创建成功")
product_id = await page.get_attribute('#product-id', 'value')
# 2. 验证Redis缓存存在
cache_key = f"product:{product_id}"
assert redis_conn.exists(cache_key), "初始缓存未写入"
# 3. 注入故障:停掉Redis,删除RDB文件,模拟持久化丢失
redis_container = docker_client.containers.get("redis")
redis_container.stop()
# 删除宿主机上传的dump.rdb,这要求volume正确挂载(前面docker-compose已保证)
import os
rdb_path = os.path.join(os.getcwd(), "redis_data", "dump.rdb")
if os.path.exists(rdb_path):
os.remove(rdb_path)
# 重启Redis
redis_container.start()
# 关键:等待Redis加载完毕(持久化文件为空,会启动一个空实例)
time.sleep(2)
# 4. 刷新页面,验证业务层是否从MySQL回源并重建缓存
await page.goto(f"{BASE_URL}/products/{product_id}")
product_name = await page.text_content('.product-name')
assert product_name == '限量球鞋', "页面未正确显示商品,可能回源失败"
# 5. 再次检查Redis缓存是否被重建
assert redis_conn.exists(cache_key), "缓存未重建,回滚机制有bug"
# 6. 最后和MySQL数据进行终极对比
with mysql_conn.cursor() as cursor:
cursor.execute("SELECT name FROM products WHERE id=%s", (product_id,))
result = cursor.fetchone()
assert result and result[0] == '限量球鞋', "数据库数据不一致"
await browser.close()
这段测试覆盖了最致盲的故障:Redis完全丢失RDB后重启。但现实世界远没有这么友善,踩的坑都在下面。
踩坑记录:官方文档没告诉你的事
坑1:Docker volume不挂载,删文件删了个寂寞
现象:我测试时手动docker exec进容器删掉/data/dump.rdb,重启后Redis竟然还能恢复出数据。
原因:我偷懒没在docker-compose.yml里写volumes,Redis的RDB实际落在了容器可写层,容器停止后那一层并未被提交,重启后是全新的容器,自然看不到原来的RDB文件,所以我的"删除"操作根本没影响到新实例。
解决:必须挂载具名volume到/data,确保RDB文件在宿主机上持久化。测试时删除宿主机路径下的文件才能真正模拟RDB丢失。
坑2:Redis重启并不是原子性的"立即可用"
现象:测试中stop()、删文件、start()之后立刻发请求,经常得到空缓存,导致断言失败,但等几秒再查又好了。
原因:Redis启动后有一个短暂的加载过程,哪怕没有RDB文件,它也会初始化内部数据结构。start()方法返回仅代表容器进程起来了,不代表Redis服务已经accept连接。
解决:采用轮询等待redis_conn.ping()成功,或检查INFO persistence中loading字段为0。生产代码同样应该对恢复完成事件敏感,不能上来就无脑写缓存。
效果验证
同样的测试人力,手动测试一个月只能覆盖重启后keys是否存在,而且不可能去改容器卷。自动化后,每提交一次代码,以下5种故障场景全自动跑完:
| 测试场景 | 手动测试 | 自动化后 |
|---|---|---|
| 正常重启恢复 | 1次/月 | 每次PR |
| RDB文件丢失 | 0 | 每次PR |
| AOF截断修复 | 0 | 每次PR |
| 磁盘满写入失败 | 0 | 每次PR |
| 配置dir错误 | 0 | 每次PR |
上线前最后一次自动化跑出RDB文件丢失场景失败,追查发现是回源逻辑中缺少分布式锁,并发回源时击穿了数据库。修完当天部署,此后零数据丢失故障。
可直接用的代码/工具
把下面的 fixture 粘贴到你的 conftest.py,直接复用Redis故障注入能力:
python
@pytest.fixture
def redis_crash_injection(docker_client):
def _inject(volume_path: str):
container = docker_client.containers.get("redis")
container.stop()
# 删除本地rdb文件
import os
rdb = os.path.join(volume_path, "dump.rdb")
if os.path.exists(rdb):
os.remove(rdb)
container.start()
import time; time.sleep(2) # 简单等加载
return _inject
测试里调用 redis_crash_injection("./redis_data") 即可。
#Python #Redis #Playwright #自动化测试 #后端踩坑
关于作者
我是宝哥,一个在深夜和数据库搏斗的后端/架构实战派,坚信"不经过故障注入的容灾方案都是定时炸弹"。
GitHub: github.com/baofugege
如果这篇文章让你少掉了几根头发,欢迎 Sponsor 请我喝杯咖啡。
提供 Python 后端性能优化 / 工具定制 / 技术咨询服务,联系 Telegram @baofugege。