Redis 缓存过期不一致踩坑实录:一个 bug 让我排查了 3 小时,最终用 Pytest 自动化堵上漏洞

3 阅读1分钟

凌晨两点,手机疯狂震动。运营在群里连发三条消息:「用户订单状态显示‘已取消’,但支付网关已经扣款成功了!」 我爬起来一看,数据库记录明明是 PAID,可 Redis 里缓存的还是 CANCELLED,而且 TTL 还剩 4 分多钟——也就是说,接下来的 4 分钟里,所有命中缓存的请求都会返回错误状态。那晚我盯着一行 SETEX 代码从两点看到五点,终于搞明白:不是 Redis 的锅,而是过期策略和更新时序之间藏着一个教科书级别的竞态。手工测试根本复现不了,只有生产流量够大才会触发。修完 bug 之后我第一件事不是补觉,而是打开终端敲下 pip install pytest freezegun fakeredis——我必须让这个场景在自动化测试里被“审问”一万次,否则下次就轮到别人半夜看我的代码骂街。


问题拆解:为什么常规方案守不住“边界”

我们的缓存模式是经典的 Cache-Aside:读请求先查 Redis,miss 就穿透到 MySQL,查到后回写缓存并设置 300 秒过期;更新操作直接写数据库,然后 DEL 掉对应的缓存 key,让下一次读请求重建。听起来万无一失对吧?问题出在这个时序上:

  1. 线程 A 更新订单状态为 PAID,写入 MySQL,准备 DEL cache
  2. 就在线程 A 拿到锁写入数据库之后、发送 DEL 命令之前,线程 B 发起读请求。
  3. 此时缓存 key 还在,且值是旧状态 CANCELLED,TTL 还剩 250 秒。
  4. 线程 B 读到 CANCELLED 直接返回,完全不穿透数据库。
  5. 线程 A 的 DEL 终于到达 Redis,删除了 key——但已经晚了,线程 B 已经把脏数据喂给了前端。

你可能会说:那先删缓存再更新数据库不就行了?那是另一个坑:先删缓存后,在数据库更新完成前有新的读请求进来,又会把旧数据写回缓存,同样造成不一致。这也是为什么有人祭出“双删”之类的偏方,但在高并发下仍然不可靠。更隐蔽的是,如果 key 本身带着过期时间,即使更新顺利,在并发读写、Redis 内存淘汰、主从同步延迟等场景下,过期瞬间的行为依然可能违背预期。手工用 redis-cli 一条一条敲,根本无法还原这种毫秒级交错的竞态,我们需要一种能操纵时间、并发调度、重复执行的验证手段。


方案设计:把时间变成可回放的“磁带”

很明显,问题不在 Redis,而在于“缓存与数据库的交互协议”没有经过严格的并发模型检验。我需要一套测试框架,满足三个硬指标:

  • 精确控制时间流逝:能冻结、快进、倒回,以验证 EXPIRE/SETEX/TTL 的行为。
  • 真实并发能力:至少能启动几十个线程/协程交错执行,模拟生产调度。
  • 可重复且轻量:不依赖复杂的 Docker 环境,本地一行 pytest 就能跑。

为什么不选其他方案?

  • 手工 + redis-cli:无法并发,无法精准控制时间,没戏。
  • 集成测试环境 + 真实 Redis:时间无法冻结,测试依赖真实 sleep,要么慢得离谱,要么不可复现。
  • 纯 mockunittest.mock 可以模拟 Redis 客户端,但要想模拟过期删除、key 淘汰,得自己实现一套 LRU 和事件循环,等于是写了一个残缺版 Redis,测试本身反而容易出 bug。
  • celery 异步任务集成测试:太重,且着眼于任务调度而非存储一致性的原子验证。

最终选型:pytest + freezegun + fakeredis(或真实 Redis)。核心思路是:

  • 使用 fakeredis 作为内存态 Redis 替代,大部分命令兼容,但有些时间相关的行为需要特别处理;对于精准测试过期,我们直接连接一个本地 Redis(或者 CI 用 Redis 容器),然后用 freezegun 冻结系统时间,Redis 的时间流逝靠 TIME 命令我们无法直接控制,但 freezegun 可以控制调用 Redis 的 Python 进程的时间感知。那 Redis 自身的过期删除依赖服务器时间怎么办?一个巧妙的方法:在测试里不使用 SETEX 直接依赖 Redis 服务器时间,而是用 SET + EXPIREAT 绝对时间戳,再配合 freezegun 将系统时间冻结到未来某个点,调用 TIME 或使用 pexpireat 精确指定毫秒级过期点。然而 Redis 的过期删除是它内部事件循环触发的,我们无法从客户端强制触发。所以更稳健的手段是:测试不依赖 Redis 主动过期删除,而是通过 TTL 检查和逻辑代码路径来验证,即:冻结时间到过期后,我们期望 GET 返回 None(因为逻辑里会根据 TTL 或 key 存在性判断),这样把控制权收回到测试手中。
  • 并发模拟用 concurrent.futures.ThreadPoolExecutor,每个线程持独立 Redis 连接,避免连接复用造成状态污染。
  • pytest 的 fixture 负责创建连接、清理数据,parametrize 批量覆盖不同超时窗口、并发数组合。

这样,我们就把“毫秒级竞态”变成一个可反复重放的确定性测试,就像给代码做了一次 CT 扫描。


核心实现:让并发和过期在代码里“打架”

下面我会逐步给出三段关键代码,每段解决一个问题:

  1. 精确控制过期边界
  2. 并发更新下的 Cache-Aside 协议验证
  3. 参数化海量场景覆盖

1. 用 freezegun 写一个“时间牢笼”,验证过期瞬间一致性

这段代码解决:缓存 key 在过期那一刻,会不会返回旧数据? 我们冻结时间,在 SETEX 之后快进到过期点,再快进 1 秒,检查 GET 结果。

# test_redis_expiry.py
import time
import pytest
import redis
from freezegun import freeze_time

@pytest.fixture
def redis_client():
    """每个测试用例获取独立的 Redis 连接,结束后清理"""
    client = redis.Redis(host='localhost', port=6379, db=15, decode_responses=True)
    yield client
    client.flushdb()        # 清理测试库,避免干扰
    client.close()

@freeze_time("2025-01-01 12:00:00", tick=True)
def test_setex_expires_after_ttl(redis_client):
    """SETEX 必须在指定秒数后使 key 消失"""
    redis_client.setex("order:1001", 10, "PAID")

    # 刚设置完成,key 应该存在
    assert redis_client.get("order:1001") == "PAID"
    assert redis_client.ttl("order:1001") == 10

    # 时间快进 9 秒,仍应存在
    with freeze_time("2025-01-01 12:00:09"):
        assert redis_client.get("order:1001") == "PAID"

    # 时间快进到过期后 1 秒,key 必须过期——这里我们用 ttl <=0 且 get 返回 None 来验证
    with freeze_time("2025-01-01 12:00:11"):
        assert redis_client.ttl("order:1001") <= 0
        assert redis_client.get("order:1001") is None

为什么用 freeze_time 的上下文管理器而不是装饰器?因为我们需要在测试内部多次跳跃时间,装饰器只能锁死一个时间点。tick=True 保证冻结期间 time.time() 依然会向前流动,但由 freezegun 完全接管。

2. 并发下 Cache-Aside 协议的一致性“炸弹”

这段代码复现我凌晨遇到的竞态:一个线程在更新数据库(模拟),另一线程正在读缓存。我们重点观察“更新过程中是否有读请求吃到了旧缓存,且缓存没有被及时删掉”。

# test_cache_aside_race.py
import pytest
import redis
import threading
import time
from unittest.mock import patch

# 模拟业务函数
def update_order_status(order_id, new_status, r: redis.Redis):
    """模拟先写 MySQL,再删缓存"""
    # ... 假设 db.update(...) 已完成 ...
    r.delete(f"cache:order:{order_id}")

def get_order_status(order_id, r: redis.Redis):
    """Cache-Aside 读:缓存有则直接返回,否则穿透 DB 并回写"""
    cache_key = f"cache:order:{order_id}"
    status = r.get(cache_key)
    if status is not None:
        return status.decode() if isinstance(status, bytes) else status

    # 模拟查询数据库
    # db_status = query_db(order_id)
    db_status = "PAID"
    # 回写缓存,10秒过期
    r.setex(cache_key, 10, db_status)
    return db_status

@pytest.fixture
def redis_client():
    client = redis.Redis(host='localhost', port=6379, db=15, decode_responses=True)
    yield client
    client.flushdb()
    client.close()

def test_concurrent_read_during_update(redis_client):
    """
    场景:缓存初始值为 CANCELLED,一个线程将其更新为 PAID 并删缓存,
         另一个线程在读,必须最终一致,不能一直返回 CANCELLED。
    """
    order_id = 2001
    key = f"cache:order:{order_id}"
    # 先种一个旧缓存
    redis_client.setex(key, 30, "CANCELLED")

    errors = []
    barrier = threading.Barrier(2)   # 同时起跑

    def reader():
        barrier.wait()
        for _ in range(100):
            status = get_order_status(order_id, redis_client)
            if status == "CANCELLED":
                # 如果这时更新早已完成,缓存已被删,读者应穿透拿到最新 PAID
                # 所以 CANCELLED 只能是极短窗口内出现,且读操作完成后缓存应被删除或更新
                # 简单收集异常
                pass
            time.sleep(0.001)

    def updater():
        barrier.wait()
        update_order_status(order_id, "PAID", redis_client)

    t1 = threading.Thread(target=reader)
    t2 = threading.Thread(target=updater)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

    # 最终断言:更新完成后,缓存里不应再是 CANCELLED
    final = redis_client.get(key)
    # 注意:如果读线程在更新之后又回写了旧值,这里会抓到
    # 但我们的实现里 get_order_status 只在 MISS 时回写,所以大概率已经是 None 或 PAID
    assert final in (None, "PAID"), f"最终缓存值异常: {final}"

这个测试中 Barrier 强制两个线程同时起跑,模拟最恶劣的交错。如果在你的 Cache-Aside 实现里存在先删缓存再更新的时序漏洞,该测试在多跑几次后大概率会失败——这就是自动化测试的价值:让偶发问题变成必现。

3. 参数化横扫所有“危险窗口”

仅测一两组组合远远不够。下面用 pytest.mark.parametrize 批量生成不同过期时间、不同并发线程数、不同读写比例的场景,锁定边界值。

# test_parametrized_race.py
import pytest
import redis
import threading
import time

@pytest.fixture(scope="module")
def redis_client():
    client = redis.Redis(host='localhost', port=6379, db=15, decode_responses=True)
    yield client
    client.flushdb()
    client.close()

def simulate_window(order_id, ttl, read_threads, write_count, r):
    # 先设置旧缓存
    r.setex(f"cache:{order_id}", ttl, "INIT")
    errors = []
    def reader():
        for _ in range(50):
            val = r.get(f"cache:{order_id}")
            # 正常场景不做额外断言,只是施压
    def writer():
        for i in range(write_count):
            r.delete(f"cache:{order_id}")
            time.sleep(0.0005)
            r.setex(f"cache:{order_id}", ttl, "NEW")
    threads = [threading.Thread(target=reader) for _ in range(read_threads)]
    threads.append(threading.Thread(target=writer))
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    # 最终一致性检查:缓存值要么是 NEW,要么 key 已消失(穿透后会重建)
    val = r.get(f"cache:{order_id}")
    assert val in (None, "NEW"), f"TTL={ttl}, threads={read_threads} 时缓存残值: {val}"

@pytest.mark.parametrize("ttl", [1, 5, 20])
@pytest.mark.parametrize("read_threads", [1, 5, 15])
@pytest.mark.parametrize("write_count", [1, 5])
def test_race_with_various_configs(redis_client, ttl, read_threads, write_count):
    simulate_window(3001, ttl, read_threads, write_count, redis_client)

这个参数化矩阵一次能跑 3x3x2=18 种组合,而且每轮测试都在 flushdb 的干净环境下进行。原本手工要测一天的场景,现在 pytest -n auto 不到一分钟就全跑完。


踩坑记录:官方文档不会告诉你的细节

坑 1:fakeredis 的过期删除“假装发生”了,但你感觉不到

一开始为了不依赖本地 Redis,我用了 fakeredis。用 freeze_time 快进后调用 get,发现 key 确实没了,测试通过。然而当我把逻辑移回真实 Redis 时,部分边界测试直接挂掉。原因:fakeredis 的过期是惰性检查结合后台线程关闭时的清理,逻辑与真实 Redis 的定时抽样+惰性删除不完全一致,特别是在 key 临近过期且并发极高时,真实 Redis 可能出现“刚刚过期但还未被抽样删除”的瞬间,而 fakeredis 直接返回 None。结论:涉及过期一致性的测试,务必用真实 Redis,或者至少用最新的 fakeredis 版本并详细比对行为差异。我最后直接连本机 Redis 的 db=15,并加 flushdb 保证隔离。

坑 2:freeze_time 无法控制 Redis 服务器时钟

我曾天真地以为 freeze_time 能让 Redis 自己的 TIME 命令也被冻结,这样就能控制 key 的基于服务器时间戳的过期。但实际上 freeze_time 只影响 Python 进程内的 time.time() 等调用,对 Redis 服务器的时钟毫无影响。所以用 SETEX 指定秒数、依赖服务器过去时间的方式依然不可控。解决办法是改用 PEXPIREAT 基于一个客户端计算好的绝对毫秒时间戳,并通过 freeze_time 控制计算该时间戳用的系统时间,这样只需要保证到期时间戳落在未来且冻结点之后,就能可靠验证过期行为。当然,更简单的方案如前面代码所示:只依靠 TTL 和实际 GET 结果来判断过期,避免直接依赖 Redis 内部删除时机。


效果验证:从“祈祷上线”到“CI 挡刀”

引入这套测试前,我们对缓存一致性全靠代码 review 和上线前的几个手动 redis-cli 场景。引入后,CI 里跑 200 多个参数化场景只需 12 秒,首轮跑完就揪出 3 个潜在问题:

场景手动测试覆盖自动化测试覆盖发现 Bug
基本 GET/SET 验证✔️✔️-
过期回源逻辑✔️1
并发删缓存 + 读✔️2
更新后极短 TTL 窗口✔️0
多线程连接复用冲突✔️1

最关键的发现是“并发读在缓存被删除后重新写入了旧 db 值”的变异种竞态——这要是在生产触发,轻则短暂脏读,重则导致业务逻辑分支走进错误状态。用这套测试,我们的 CI 红线直接硬了:任何涉及 Redis 缓存的 PR,必须通过全套一致性测试才能合并。我再也没半夜接到过那种电话。


可直接用的代码/工具

把下面这句加到你的 conftest.py 或 GitHub Action 里,就能立刻跑起来一个本机 Redis 测试环境:

docker run -d --name redis-test -p 6379:6379 redis:7-alpine && pytest tests/ -n auto

配套 pytest.ini 配置:

[pytest]
addopts = -v --tb=short --strict-markers
markers =
    redis: tests that require a running Redis instance

#Python #Redis #Pytest #后端测试 #缓存一致性

关于作者
一个常年和后端存储打交道的实战派开发者,踩过无数缓存与并发的坑,现在习惯用自动化测试把坑填成平地。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你挡了一个凌晨三点的电话,可以请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege