AI 时代的乐观可失败队列
本文源于为 MCP 官方 Memory Server 修复并发写入问题 PR #3286 的思考。
问题背景:AI 会频繁多次调用 MCP 进行记忆存储,而多个 AI 客户端同时写同一个文件会直接导致文件格式损坏。
选择解决方案
显而易见的,悲观锁。
记忆,这么重要的东西,怎么可能可以丢失和读写失败呢?
但是,MCP 本身需要确保这一点吗?把 MCP Tool 想象为互联网,AI 是上层应用,MCP 只需要“尽力而为”就够了。
回到问题本身:同时写会导致文件损坏,而记忆读的次数远远大于写。如果我们为读和写都加锁排队,本质就是悲观锁——最保守、最“可靠”,但也完全忘记了 MCP Tool 的初衷:这是给 AI 准备的工具。
只给写加锁
那如果我们只给写加锁呢?
立刻会想到两个问题:
- 读写乱序
- 大量写/缓慢 IO 会导致超时失败(当然你可以不设超时,但 MCP Client 一定会有超时的。AI 一旦发现超时就会开始重试,这时候 AI 就会变成不断按下请求按钮 DDoS 你服务器的用户)
这两个问题的本质是:最终状态的不确定性。
但 AI 天生就是处理不确定性的。当我们用 AI 阅读代码、阅读资料时,它就是在把复杂的、不确定的“乱码”变成简洁的回答。
所以 AI 遇到这两个问题会怎么办?
读写乱序:AI 发现写入成功但读取的数据不对(先读后写了),它会再读一次来确认——没什么大不了的。
超时:MCP 函数超时了,AI 会重新请求——也没什么大不了的。
设计
AI 的稳定前提
但是这一切都必须基于我们的 MCP 设计的行为是“稳定”的,也就是对于失败的操作,我们一定返回的是失败而不是静默,对于成功的操作我们一定得返回成功,否则 AI 会开始 DDoS 把一切变得更糟。
具体到代码层面,这意味着要区分“锁获取失败”和“业务逻辑失败”——两者对 AI 来说是不同的信号。
使用场景
MCP SDK 只是遵循 JSON-RPC 的乱序发送标准,所以单 AI Client 也会出错。这就是为什么会有 PR #3060 这个解决方案。
但是,显而易见的,多 AI Client 才是常态。
问题在于,MCP Server 使用 stdio 传输方式时,会为每个 AI 客户端启动一个独立进程:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Claude Code │ │ Claude Code │ │ Claude Code │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│mcp-server-memory│ │mcp-server-memory│ │mcp-server-memory│
│ (Process A) │ │ (Process B) │ │ (Process C) │
│ [In-memory lock]│ │ [In-memory lock]│ │ [In-memory lock]│
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└──────────┬──────────┴──────────┬──────────┘
▼ ▼
┌─────────────────────────────────┐
│ memory.json │
│ (STILL VULNERABLE TO RACES!) │
└─────────────────────────────────┘
PR #3060 用的是内存锁,每个进程有自己独立的锁——它们之间根本不知道彼此的存在。所以多个 AI 客户端同时写同一个文件,照样会损坏。
解决方案
我们必须不能是软件内部的内存锁,因为很难凭空共享。
那我们可以选择基于文件系统的锁。我们不是在重构存储底层,所以 SQLite 和 Redis 显然不在考虑范围内,一个文件锁就够了。
所以我们终于开始写了是吗,这很简单,一个文件锁。
逻辑代码完成了。
编写测试
压力测试
说到锁,怎么能没有压力测试呢,那么我们如何设计它。
核心论点:返回成功的一定成功,失败一定返回失败。
那么我们需要一个测试,去压力我们本地的文件系统(而且这个文件系统最好是慢速,也就是不是 memfs),然后等 IO 负载上来后 timeout 设置极低。
最初我把这个测试叫“混沌测试”,因为我想实现“混沌测试”,后来觉得“压力测试”更准确——因为混沌测试通常指随机注入故障,而我们这里是可控的高并发压力。
所以我设计了两种压力测试:
- 单进程并发:10 线程 async 1000 写入的并发
- 多进程并发:5 个独立进程,每个写 2000 次,模拟真实的多 AI 客户端场景
测试的结果判断是:检查记忆文件里确实能读取到我们函数表示存入成功的数据,而且不存在任何一行别的数据了,也就是成功的数据都在里面且只有成功数据。
压力测试的必须性
避免系统陷入失败螺旋。也就是系统无法优雅降级,情况越来越坏导致服务停机。
- 重试导致的自我 DDoS
- 状态的失败更新但无法自动降级导致的失败扩大化——Cloudflare 2025 年 11 月那次事故就是个典型,他们事后总结的教训之一就是:能在出问题时快速停止“失败”的传播。
反哺解决方案
高延迟 IO,我们想到了什么,很显然,这是 NFS。
所以锁的配置需要考虑 NFS 兼容性(而且它真的可以被运行在 NFS 上,所以我们得避免超时问题导致的无限失败):
为什么 stale 超时要设 60 秒?因为 NFS 有个叫 acregmax 的属性缓存机制,默认就是 60 秒。如果你的 stale 设得比这个短,另一个进程可能看到的是 50 秒前缓存的文件修改时间,然后误以为锁已经过期了——实际上锁还活着呢。
heartbeat 设 10 秒,是为了让锁保持“新鲜”,即使 NFS 缓存有延迟,也能确保锁不会被误判为过期。
重试策略用指数退避:50 毫秒起步,本地磁盘能快速拿到锁;如果是 NFS 这种高延迟的,退避到最后大概等 51 秒,也足够了。
对于本地磁盘,这些设置完全没问题——唯一的代价是,如果进程真的崩溃了,需要等 60 秒而不是 10 秒才能释放锁,这是可以接受的。
混沌测试
这是一个思想测试,因为没写代码测试,测试太多怕 PR 合不进去了。
场景:MCP Tool 写入内存文件一半的时候,被杀死或者崩溃了。
结果:内存文件损坏。
反哺解决方案
所以,我们还需要原子写入。
一开始我自己实现了:先写临时文件,再 rename。看起来很简单,但当我开始思考各种边缘情况时,事情变得复杂了起来。
最基本的问题是:fs.rename 依赖文件系统移动文件的底层实现。在大部分日志文件系统(ext4、XFS、NTFS 等)上,rename 是原子性的——要么完成,要么没发生。但这有个前提:源文件和目标文件必须在同一个文件系统上。
如果临时文件放在 /tmp,而目标文件在 /home,它们很可能是不同的挂载点、不同的文件系统。这时候 fs.rename 会退化成”复制 + 删除”——原子性直接没了,我们又回到了起点。
所以临时文件必须放在目标文件的同一目录下。但这又带来了新问题:并发写入时临时文件的命名冲突、写入完成后的权限继承、临时文件清理……边界情况太多了,于是改用第三方库(write-file-atomic)。
但你可能注意到我故意关闭了每次写入完成后进行 fsync,这个功能是避免写入完成后,操作系统断电导致文件信息没更新。
为什么关闭呢,因为有开发者测试 fsync 开启后,在 Windows 上单次写入时间从 3 毫秒到 300 毫秒,这会导致我们本就不快的锁更慢,高压力 IO 下的失败的操作更多,也就是情况越来越坏。
这是一个权衡:接受极小概率的断电风险,换取更少的超时失败。
但更重要的是,这是软件职责的划分。保证文件系统在断电后的一致性,是操作系统该做的事,不是应用该做的。无限扩大的职责,往往意味着无限膨胀的软件系统。
结语
以前我们”不得不”接受概率性,现在我们可以”主动拥抱”概率性——因为用户从人变成了 AI。
以前软件要像机器一样精确,现在软件可以像概率系统一样运作。这不是退步,而是解放——因为 AI 用户本身就是用概率思维工作的。
所以 AI 原生软件的测试,测的不是”绝对正确”,而是”可靠的概率分布 + 清晰的失败信号”。
这就是为什么队列可以是乐观的,锁可以是可失败的——只要我们保证:成功一定成功,失败一定失败,绝不静默。
乐观,但不能陷入失败螺旋。