凌晨一点多,被测试同事的钉钉轰炸:"又来了!明明上一轮我教会它的偏好,这次全忘了,Redis 里查到的还是旧值!"我看着聊天记录一哆嗦------这已经是本月第三次"记忆丢失"了。我们的 AI 应用把用户上下文存在 Redis 里,每次模型会话都依赖这份记忆。问题是,Redis 写入和查询行为在业务逻辑里的坑远比想象中多,纯手工验证根本覆盖不全,还动不动丢锅给网络抖动。那晚,我决定彻底用 pytest + Redis 搭一套自动化一致性测试,把记忆存储的坑一个个挖出来。
问题拆解
我们维护的是一个多轮对话式 AI 服务,每个会话的"记忆"------比如用户偏好、历史决定、关联实体------都以 JSON 字符串存在 Redis 的 String / Hash 里,key 按 mem:{session_id}:{slot} 设计。业务的写入路径有:对话流中实时写入、后台异步摘要聚合后回写、过期重刷等。读取则是每次模型调用前批量 MGET。
问题现象很典型:用户反馈"刚改的设置又变回默认",但转储出的 Redis 快照却显示最新值。根因往往不在 Redis 本身,而在业务侧的写入原子性、序列化版本冲突、过期时间逻辑以及多个路径对同一 key 的竞争。要重现这类问题,必须构造有状态的场景:连续多轮对话、并发请求、key 过期后重新加载,且断言数据内容、TTL、结构完全一致。手工造数据跑一轮要 20 多分钟,200 条回归用例就更不可能了,而且人眼比对 JSON 极易漏错。
常规的单元测试方案(比如 mock Redis)在一致性场景下几乎无效:mock 不会过期、不处理多客户端并发、不暴露序列化 bug。生产环境中真正导致"记忆丢失"的,往往是真实的 Redis 行为 + 业务竞态的叠加。所以,必须用真实 Redis 实例 + 自动化回归来回放这些场景。
方案设计
选型上没有悬念:测试框架用 pytest,搭配 redis-py 直连一个测试专用 Redis 实例。为了贴近线上,用 Docker Compose 起 Redis 7,每次跑测试前自动 FLUSHDB。为什么不选 Redis 自带的 MOCK 模式或者 fakeredis?fakeredis 不支持 Stream、某些命令的过期语义也与真实 Redis 有微妙差异,踩过坑------一个用 EXAT 精确时间戳的过期逻辑在 fakeredis 上通过,上线后因为秒级误差导致记忆提前失效,所以真实实例是底线。
架构思路就一条:以"记忆契约"为中心组织用例。对每一个记忆 slot,定义它应有的状态转换:新建(TTL 无限)、更新(值变,TTL 可重置)、软过期(被动删除后重新拉取)、并发更新(多个写入源争夺)。每个用例就是一次契约验证,pytest 的 fixture 负责准备干净 Session,测试函数用断言严格检查值、TTL、甚至 type 类型。
核心实现
1. 测试环境 fixture:提供干净、隔离的 Redis 连接
这段代码解决的是每一条用例启动时的环境一致性:自动连接 Redis、执行 FLUSHDB,同时构造可用的 session_id,避免测试间污染。
python
import pytest
import redis
import uuid
from typing import Generator
@pytest.fixture(scope="function")
def redis_client() -> Generator[redis.Redis, None, None]:
"""创建连接,清库,确保每个用例独立的 Redis 环境"""
r = redis.Redis(
host="localhost",
port=6379,
db=0,
decode_responses=True, # 自动解码为字符串,方便断言
socket_connect_timeout=2
)
r.flushdb() # 关键:每次测试从零开始,避免脏数据
yield r
r.close()
@pytest.fixture
def session_id() -> str:
return f"sess:{uuid.uuid4().hex[:12]}"
2. 核心用例:多轮对话下记忆写入、更新、读取的一致性
这段用例模拟典型场景:先创建记忆,经过一轮更新后再次读取,校验整个生命周期里 JSON 负载、key 存在性、TTL 都符合预期。直接验证了最容易出"记忆回退"的写入路径。
python
import json
from typing import Dict, Any
def put_memory(r: redis.Redis, sess: str, slot: str, payload: Dict[str, Any], ttl: int = None):
key = f"mem:{sess}:{slot}"
r.set(key, json.dumps(payload))
if ttl:
r.expire(key, ttl)
def get_memory(r: redis.Redis, sess: str, slot: str) -> Dict[str, Any]:
key = f"mem:{sess}:{slot}"
raw = r.get(key)
return json.loads(raw) if raw else {}
def test_memory_update_consistency(redis_client, session_id):
"""验证记忆更新后读取的一致性,以及 TTL 是否正确重置"""
r = redis_client
slot = "user_prefs"
payload_v1 = {"theme": "dark", "lang": "en"}
payload_v2 = {"theme": "light", "lang": "zh", "font_size": 14}
# 1. 初次写入,给一个较短的 TTL
put_memory(r, session_id, slot, payload_v1, ttl=30)
mem = get_memory(r, session_id, slot)
assert mem == payload_v1, "首次写入的内容应完全一致"
assert r.ttl(f"mem:{session_id}:{slot}") <= 30
# 2. 模拟业务更新:重新 PUT 新值,重置 TTL 到 60 秒
put_memory(r, session_id, slot, payload_v2, ttl=60)
mem = get_memory(r, session_id, slot)
assert mem == payload_v2, "更新后读到的是新值,不能出现旧值残留"
# 检查 TTL 被重置到 60 左右
new_ttl = r.ttl(f"mem:{session_id}:{slot}")
assert new_ttl > 30, "TTL 应被更新重置,而不是保留原过期时间"
3. 并发更新与版本冲突的防御性测试
这块是真实业务里最难排查的记忆丢失场景:两个不同来源(例如实时对话流和离线摘要服务)同时 PUT 同一个 slot。测试用多线程模拟并发写入,确保最终一致性规则(如"最后写入者获胜"或基于版本号"被正确遵循。哪怕只是验证了 Redis 的 SET 是原子的,也能暴露业务层没加分布式锁的问题。
python
import threading
import time
def test_concurrent_memory_updates(redis_client, session_id):
"""模拟并发写入同一 slot,检测是否丢失更新或产生损坏数据"""
r = redis_client
slot = "summary"
key = f"mem:{session_id}:{slot}"
# 初始值
r.set(key, json.dumps({"count": 0}))
results = []
def updater(increment: int, sleep: float):
time.sleep(sleep) # 错开执行顺序
current = get_memory(r, session_id, slot)
current["count"] += increment
# 注意:下面直接用 SET 覆盖,模拟无锁并发写入
r.set(key, json.dumps(current))
results.append(current)
t1 = threading.Thread(target=updater, args=(1, 0.1))
t2 = threading.Thread(target=updater, args=(2, 0.11))
t1.start(); t2.start()
t1.join(); t2.join()
final = get_memory(r, session_id, slot)
# 如果业务没有锁,最终 count 很可能是 2 而不是 3,暴露丢失更新
# 此处我们只记录现象,实际生产应增加分布式锁
assert "count" in final, "值结构不能损坏"
# 可进一步断言写入日志 / 版本号逻辑
踩坑记录
坑1:pytest fixture scope 引发的虚假通过
现象:部分用例单独跑全绿,收集到类级别 (scope="class") 后再跑,总有两条用例断言失败,值互相污染。
原因:我把 Redis fixture 的 scope 设成了 "class",虽然后面调了 flushdb,但第一个用例结束后第二个用例开始前,根本没再执行 flush。
解决:强制改为 scope="function",让每个测试函数获得全新的 Redis 环境。这也是 官方文档没明说的事------即使你 flushdb 了,fixture 的 set up/tear down 边界依然按 scope 控制,函数级别才是真正隔离。
坑2:decode_responses=True 导致 TTL 断言类型错误
现象:r.ttl(key) 在真实 Redis 返回 int,但我用 assert r.ttl(key) > 30 时偶发 TypeError: '>' not supported between instances of 'int' and 'str'。
原因:由于开启了 decode_responses=True,某些 Redis 客户端版本在连接异常重连后,会把所有响应强制 decode,甚至把整数转成了字符串。但更常见的是我在测试里无意写了 r.set(key, 123),数字被存为字符串,但 ttl 仍是 int,断言混乱。
解决:统一使用 json.dumps/loads 处理复杂值,杜绝直接 SET 非字符串;同时检查 redis-py 版本,升级至 ≥4.5 以稳定 decode 行为。额外增加类型守卫:assert isinstance(r.ttl(key), int)。
效果验证
用这套自动化回归替换手工测试后,单次回归从 20 分钟降到 8 秒,缺陷发现率变化更关键:
| 指标 | 手工测试 | pytest 自动化 |
|---|---|---|
| 单次回归耗时 | ~20 min | 0.13 min (8秒) |
| 覆盖用例数 | 15(仅主流程) | 42(含并发、过期、版本冲突) |
| 近 2 周发现记忆 BUG | 3 个 | 7 个(其中 4 个是以前手动测不出的并发问题) |
更直接的结果:再也没收到过半夜的"记忆丢失"报警------因为问题在 CI 阶段就被挡下了。
可直接用的代码/工具
把下面的 docker-compose.yml 和上述 fixture 一贴,你就能在本地每秒启动全量一致性测试:
yaml
# docker-compose.yml
version: "3.9"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --save "" --appendonly no
bash
docker compose up -d && pytest test_memory_consistency.py -v
这套脚本我已经放在 GitHub 的示例仓库里,开箱即用。
#Python #Redis #自动化测试 #AI工程 #一致性
关于作者
一个靠调试 Redis 竞态条件续命的实战派后端工程师,专注把脆弱的手工验证流程锤成自动化铁幕。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你少熬一个夜,请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege