AI 时代的乐观可失败队列


🌐This article is also available in English:English

本文源于为 MCP 官方 Memory Server 修复并发写入问题 PR #3286 的思考。

问题背景:AI 会频繁多次调用 MCP 进行记忆存储,而多个 AI 客户端同时写同一个文件会直接导致文件格式损坏。

选择解决方案

显而易见的,悲观锁。

记忆,这么重要的东西,怎么可能可以丢失和读写失败呢?

但是,MCP 本身需要确保这一点吗?把 MCP Tool 想象为互联网,AI 是上层应用,MCP 只需要“尽力而为”就够了。

回到问题本身:同时写会导致文件损坏,而记忆读的次数远远大于写。如果我们为读和写都加锁排队,本质就是悲观锁——最保守、最“可靠”,但也完全忘记了 MCP Tool 的初衷:这是给 AI 准备的工具。

只给写加锁

那如果我们只给写加锁呢?

立刻会想到两个问题:

  1. 读写乱序
  2. 大量写/缓慢 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 设置极低。

最初我把这个测试叫“混沌测试”,因为我想实现“混沌测试”,后来觉得“压力测试”更准确——因为混沌测试通常指随机注入故障,而我们这里是可控的高并发压力。

所以我设计了两种压力测试:

  1. 单进程并发:10 线程 async 1000 写入的并发
  2. 多进程并发:5 个独立进程,每个写 2000 次,模拟真实的多 AI 客户端场景

测试的结果判断是:检查记忆文件里确实能读取到我们函数表示存入成功的数据,而且不存在任何一行别的数据了,也就是成功的数据都在里面且只有成功数据。

压力测试的必须性

避免系统陷入失败螺旋。也就是系统无法优雅降级,情况越来越坏导致服务停机。

  1. 重试导致的自我 DDoS
  2. 状态的失败更新但无法自动降级导致的失败扩大化——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 原生软件的测试,测的不是”绝对正确”,而是”可靠的概率分布 + 清晰的失败信号”。

这就是为什么队列可以是乐观的,锁可以是可失败的——只要我们保证:成功一定成功,失败一定失败,绝不静默。

乐观,但不能陷入失败螺旋。