Redis 记忆存储踩坑实录:一个并发写入 Bug 让我排查了 4 小时

0 阅读1分钟

凌晨两点被报警电话叫醒,用户投诉说和 AI 聊着聊着对话历史就丢了半截。我睡眼惺忪地翻日志,发现既没有报错也没有宕机——只是 Redis 里少了几条消息。直到打开监控看到那个接口的并发曲线,我才意识到:我们用 Redis 存会话历史的方式,在并发下根本不正确。

问题拆解

我们的场景很常见:每个用户的对话记忆都存在 Redis 里。为了“省事”,早期设计直接用一个 String 键,把整个会话记录序列化成 JSON 数组。每次对话发生,后端就:

  1. GET 整个 JSON
  2. 反序列化,追加新消息
  3. 序列化,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-pyScript 对象注册脚本,通过 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)1001000约 62038%
Lua 原子追加100100010000%
Redis List + RPUSH100100010000%

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 #并发 #最终一致性 #后端踩坑