凌晨两点被报警电话叫醒,用户投诉说和 AI 聊着聊着对话历史就丢了半截。我睡眼惺忪地翻日志,发现既没有报错也没有宕机——只是 Redis 里少了几条消息。直到打开监控看到那个接口的并发曲线,我才意识到:我们用 Redis 存会话历史的方式,在并发下根本不正确。
问题拆解
我们的场景很常见:每个用户的对话记忆都存在 Redis 里。为了“省事”,早期设计直接用一个 String 键,把整个会话记录序列化成 JSON 数组。每次对话发生,后端就:
GET整个 JSON- 反序列化,追加新消息
- 序列化,
SET回去
这在 QPS 个位数的时候岁月静好。但最近用户增长,前端又加了自动重试机制——同一个对话请求可能在极短时间内被触发两次。于是经典的 read-modify-write 竞态条件出现了:请求 A 和请求 B 都读到了同一个旧数组,各自加上自己的消息再写回,最后必然有一个请求的消息被覆盖丢失。
这就是「最终不一致」的根因——不是 Redis 不可靠,而是我们对 Redis 的原子性抱有超出它能力的期待。常规的单条 SET/GET 是原子的,但“读取-修改-写回”这种复合操作不是。直接加分布式锁当然能解决,但会严重拉低吞吐,而且引入锁就意味着要处理死锁、锁超时等一系列麻烦。我们必须找一个锁-free 的原子操作。
方案设计
几个备选方案:
- Redis List +
RPUSH:每条消息压入列表,原子操作,天然追加,不会覆盖。但问题是读取历史要LRANGE全量拉取,每次对话都拖整个历史,对大会话不友好。 - Redis Stream:适合消息队列场景,消费后还能持久化,但引入消费者组会让记忆读取变复杂,对接现有代码成本高。
- Lua 脚本:在 Redis 服务端执行一段原子脚本,直接在 JSON 数组上追加元素并写回。单次网络往返,无锁,完全原子。唯一代价是需要把 JSON 逻辑写进 Lua,好在 Redis 7 内置
cjson库。
我们最终选了 Lua 脚本方案。因为它改动最小——仍然用 String 存整个会话,业务层接口保持不变,只是把原来的“读-改-写”替换成一条 EVAL 命令。而且可以渐进迁移,老数据完全兼容。
核心实现
第一步:用 Pytest 复现并发 Bug
在动手修复之前,我先写了一个会失败的测试,确保能稳定复现问题。这里用 pytest-asyncio 模拟 20 个并发协程同时为一个用户追加消息,最后检查历史中是否所有消息都在。
import asyncio
import json
import pytest
import redis.asyncio as aioredis
@pytest.mark.asyncio
async def test_concurrent_append_causes_missing_messages():
"""复现 read-modify-write 导致的消息丢失"""
r = aioredis.from_url("redis://localhost:6379", decode_responses=True)
user_key = "user:123:history"
# 初始化空历史
await r.set(user_key, json.dumps([]))
async def bad_append(msg: str):
# 模拟有问题的做法:GET -> 修改 -> SET
raw = await r.get(user_key)
history = json.loads(raw) if raw else []
history.append(msg)
await r.set(user_key, json.dumps(history))
tasks = [bad_append(f"msg-{i}") for i in range(20)]
await asyncio.gather(*tasks)
# 验证
final_raw = await r.get(user_key)
final_history = json.loads(final_raw)
# 这里很可能小于 20,因为发生了覆盖
assert len(final_history) == 20, f"丢失消息!期望 20 条,实际 {len(final_history)}"
这段测试会在大部分运行中失败,断言直接告诉你丢了多少条消息。只有让 Bug 可重现,我们才敢谈“最终一致性”。
第二步:编写原子追加的 Lua 脚本
Lua 脚本做三件事:如果 key 不存在或为空,初始化为空数组;用 cjson.decode/cjson.encode 解析并追加新元素;返回新长度(可选,用于监控)。
-- lua/append_history.lua
local key = KEYS[1]
local new_message = ARGV[1]
local raw = redis.call('GET', key)
local history = {}
if raw and raw ~= '' then
history = cjson.decode(raw)
end
-- 将新消息追加到数组
table.insert(history, new_message)
local new_raw = cjson.encode(history)
redis.call('SET', key, new_raw)
return #history
第三步:在 Python 里封装原子操作
用 redis-py 的 Script 对象注册脚本,通过 evalsha 执行以避免每次都传输脚本体。
import redis.asyncio as aioredis
LUA_APPEND = """
local key = KEYS[1]
local new_message = ARGV[1]
local raw = redis.call('GET', key)
local history = {}
if raw and raw ~= '' then
history = cjson.decode(raw)
end
table.insert(history, new_message)
local new_raw = cjson.encode(history)
redis.call('SET', key, new_raw)
return #history
"""
class HistoryStore:
def __init__(self, r: aioredis.Redis):
self.r = r
self._script = r.register_script(LUA_APPEND)
async def append_message(self, user_key: str, message: str) -> int:
"""原子追加一条消息,返回当前总条数"""
return await self._script(keys=[user_key], args=[message])
第四步:用同样并发的测试验证修复
只把 bad_append 换成 store.append_message,同样的 20 并发,断言必定通过——因为 Lua 脚本在 Redis 内部是原子执行的,不会出现交错。
@pytest.mark.asyncio
async def test_concurrent_append_with_lua_consistency():
r = aioredis.from_url("redis://localhost:6379", decode_responses=True)
store = HistoryStore(r)
user_key = "user:123:history_v2"
await r.set(user_key, "[]")
tasks = [store.append_message(user_key, f"msg-{i}") for i in range(20)]
results = await asyncio.gather(*tasks)
final_raw = await r.get(user_key)
final_history = json.loads(final_raw)
assert len(final_history) == 20, "Lua 方案不应该丢失任何消息"
所有并发请求都成功追加,最终历史长度精确等于 20。这个测试成为了我们 CI 流水线里的常驻守护。
踩坑记录
坑 1:register_script 在异步集群客户端下会报 No way to dispatch this command
现象:测试从单节点 Redis 改到集群,redis.asyncio.RedisCluster 调用 register_script 直接抛异常。原因是 RedisCluster 客户端会按 key 路由到不同节点,而脚本注册是挂在整个客户端上的,它没法自动去所有节点执行 SCRIPT LOAD。
解决:改为每次执行时用 EVAL 直接传脚本体(牺牲一点网络开销),或者在启动阶段手动通过每个集群节点的底层连接去 SCRIPT LOAD。我们选择前者,因为脚本体很小,这点开销完全可接受——并发正确性比几微秒的网络传输重要得多。
坑 2:decode_responses=True 导致 Lua 脚本里的 cjson.encode 嵌套引号问题
现象:开启了 decode_responses=True 后,Lua 返回的字符串会被 Python 客户端自动解码,但如果 message 本身包含双引号或换行,cjson 编码后嵌套在 Lua 字符串返回时可能出现转义不一致,导致 Python 端拿到的结果里引号错乱。
原因:Lua 返回字符串时,Redis 协议会保留原始内容,但 Python 客户端解码成 str 后,我们不小心又做了一次 json.loads,二次解析导致异常。
解决:明确约定——Lua 脚本返回的是最新的消息条数(一个整数),而不是整个 JSON。Python 侧永远不要依赖 Lua 返回的 JSON 内容,只拿它作为成功与否的辅助信息。读取历史仍通过普通的 GET + json.loads,完全绕开转义问题。
效果验证
我们用 pytest 编写了并发压测 fixture,模拟 100 用户 × 10 并发追加,对比修复前后的数据完整性:
| 方案 | 并发量 | 期望消息数 | 实际消息数 | 丢失率 |
|---|---|---|---|---|
| 原方案 (GET-SET) | 100 | 1000 | 约 620 | 38% |
| Lua 原子追加 | 100 | 1000 | 1000 | 0% |
| Redis List + RPUSH | 100 | 1000 | 1000 | 0% |
Lua 方案的 P99 延迟只增加了 0.3ms,但彻底解决了数据覆盖。而且所有旧会话无需迁移,代码改动不到 30 行。
可直接用的代码/工具
如果你也需要验证 Redis 并发一致性,可以直接用我写的这个 fixture:
# conftest.py
import pytest_asyncio
import redis.asyncio as aioredis
@pytest_asyncio.fixture
async def redis_client():
r = aioredis.from_url("redis://localhost:6379", decode_responses=True)
yield r
await r.close()
把上面的 Lua 脚本和测试用例粘进你的项目,跑一遍就知道自己的会话存储是否真的能扛住并发。
关于作者
作者是一个常年和后端基础设施打交道的架构师/后端开发者,专注于用自动化测试和可观测性把“偶然故障”变成“可复现 Bug”,再逐个干掉。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下了排查时间,可以请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege
#Redis #Pytest #并发 #最终一致性 #后端踩坑