把Redis缓存一致性测试从人工改为自动化,线上事故月均3次→0,我是怎么做到的

2 阅读1分钟

凌晨两点十七分,手机炸了。监控告警一条接一条: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 已经接受连接后才开始测试”。用 subprocessdocker-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)
缓存不一致导致的月度事故次数30
缓存相关 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