一、翻车现场与排查
用户发来一条消息:"我刚把配额改到 2048,过了两分钟刷新,又变回 1024 了。没报错,就是自己回去了。"
我第一反应是用户操作失误——这种问题八成都是用户自己搞错了。但出于习惯,还是去翻了日志。先查了用户操作日志,用户确实改了,2048,记录完整,时间戳也对。不是用户的问题。
那就奇怪了——我第一反应是代码有 bug。把 updateUserConfig 的代码翻出来看了两遍,逻辑没问题。又加了一堆日志,跑了三遍复现,结果每次都能跑通,就是不报错。折腾了将近一个小时,才想到去看系统日志——发现 2 秒后有一条 updateUserConfig 调用,来源是"数据同步Agent",把配额改回了 1024。我看了三遍日志才确认——两个 Agent 在同一个时间窗口操作了同一个用户的数据,相差不到 500ms。不是代码写错了,是两个 Agent 在并行跑同一个 Tool。
不是 bug,是并发问题。 而且最坑的是:两个操作都返回了 { success: true },没有任何报错。数据被静默覆盖了——这是分布式系统里最可怕的一种错误。
二、翻车复现与第一次修复尝试
先看当时的代码。坦白说,这段代码在单 Agent 场景下没有任何问题。
// 第一版 - 标准的"读→改→写"模式
// 为啥要看这段?因为单 Agent 场景下这段代码完全没问题
// 只有多 Agent 并发调用同一个 Tool 才会触发
server.tool("updateUserConfig", {
userId: z.string(),
quota: z.number().optional()
}, async ({ userId, quota }) => {
const user = await db.read(userId); // ① 读
if (quota) user.quota = quota; // ② 改
await db.write(userId, user); // ③ 写
return { success: true, data: user };
});
这就是最简单的"读→改→写"模式。读数据、改字段、写回去。看起来毫无问题。
但实际运行起来,两个 Agent 并行调用的时候,时间线是这样的:
T+0ms Agent A: read(userId) → quota=1024
T+1ms Agent B: read(userId) → quota=1024 ← 读到了旧值
T+2ms Agent A: write({quota:2048}) ✅
T+3ms Agent B: write({quota:1024}) ✅ ← 覆盖了A的修改
T+4ms 用户刷新 → quota=1024 ❌
注意看:Agent B 读的时候 A 还没写,所以读到了旧值,然后用自己的旧值覆盖了 A 的新值。两个操作都返回了 success,但结果是错的。
第一次修复尝试:加锁队列(走弯路了)
我当时想的是:"并发问题嘛,加锁不就行了?大学操作系统课不就这么教的。"然后花了半天写了个锁队列。
// 第一次修复 - 内存锁队列
// 为啥要看这段?因为这是大多数人的第一反应——加锁
// 但后来发现这个方案有问题
const locks = new Map<string, Promise<void>>();
server.tool("updateUserConfig", {
userId: z.string(),
quota: z.number().optional()
}, async ({ userId, quota }) => {
// 每个 userId 一把锁,串行执行
const prev = locks.get(userId) ?? Promise.resolve();
const next = prev.then(async () => {
const user = await db.read(userId);
if (quota) user.quota = quota;
await db.write(userId, user);
return { success: true, data: user };
});
locks.set(userId, next);
return next;
});
写完之后一测,发现三个问题:
1. 单进程有效,多实例就废了——如果部署了多个 MCP Server 实例,每个实例有自己的内存锁,跨实例的并发完全管不住。
2. 吞吐量掉了将近六成——我跑了个简单的压测,10 个并发请求同时写同一个用户的数据:
| 并发数 | 无锁(请求/秒) | 锁队列(请求/秒) | 下降幅度 |
|---|---|---|---|
| 10 | 980 | 420 | ↓57% |
| 50 | 4200 | 1150 | ↓73% |
| 100 | 7600 | 1600 | ↓79% |
并发越高,锁队列的瓶颈越明显。因为所有请求串行排队,吞吐量被锁的粒度死死卡住。
3. 内存锁不持久——Server 重启后锁丢失,如果重启期间有请求进来,照样冲突。
而且最让我不爽的是——治标不治本。加锁只是把并发挡住了,但并没有解决"为什么两个 Agent 会同时操作同一个资源"的问题。
三、第二次修复:版本号乐观锁
后来换了个思路:不阻止并发,而是检测冲突。让数据自己带版本号,写的时候校验版本号,版本不匹配就拒绝写入。
// 第二版修复 - 版本号乐观锁
// 不同之处:读写都带 version 校验,冲突时返回错误而不是静默覆盖
server.tool("updateUserConfig", {
userId: z.string(),
quota: z.number().optional(),
expectedVersion: z.number() // 客户端传期望的版本号
}, async ({ userId, quota, expectedVersion }) => {
const user = await db.read(userId);
// 版本校验
if (user.version !== expectedVersion) {
return {
success: false,
error: "CONFLICT",
message: `版本冲突: 当前 v${user.version},期望 v${expectedVersion}`,
currentData: user
};
}
// 更新数据 + 版本号自增
if (quota) user.quota = quota;
user.version += 1;
await db.write(userId, user);
return { success: true, data: user, newVersion: user.version };
});
修复后的运行结果:
T+0ms Agent A: read → {quota:1024, ver:1}
T+1ms Agent B: read → {quota:1024, ver:1}
T+2ms Agent A: write(ver:1→2, quota:2048) ✅
T+3ms Agent B: write(ver:1→2, quota:1024)
→ ❌ CONFLICT: expected ver=1, current ver=2
T+4ms Agent B: 收到冲突 → 重读 → {quota:2048, ver:2}
T+5ms Agent B: 判断不需要修改 → 跳过 ✅
T+6ms 用户刷新 → quota=2048 ✅
下面是一个展示 Agent A 和 Agent B 在乐观锁机制下完整流程的 Mermaid 时序图:
sequenceDiagram
participant User
participant AgentA
participant AgentB
participant DB as Database
Note over User,DB: 初始状态:quota=1024, version=1
User->>AgentA: 请求修改配额为 2048
User->>AgentB: 请求修改配额为 1024 (数据同步)
Note over AgentA,AgentB: 并发读取
AgentA->>DB: read(userId)
DB-->>AgentA: {quota:1024, version:1}
AgentB->>DB: read(userId)
DB-->>AgentB: {quota:1024, version:1}
Note over AgentA,DB: AgentA 先写入
AgentA->>DB: write(quota:2048, expectedVersion:1)
DB-->>AgentA: ✅ 成功,version=2
Note over AgentB,DB: AgentB 尝试写入(冲突检测)
AgentB->>DB: write(quota:1024, expectedVersion:1)
DB-->>AgentB: ❌ CONFLICT (current version=2)
Note over AgentB: 冲突处理与重试
AgentB->>DB: read(userId)
DB-->>AgentB: {quota:2048, version:2}
AgentB-->>AgentB: 判断:当前配额已为2048,无需修改
AgentB-->>User: ✅ 跳过(无操作)
Note over User,DB: 最终状态:quota=2048, version=2
User->>DB: 刷新查看
DB-->>User: {quota:2048, version:2}
该流程图清晰地展示了:
- 时间线:Agent A 和 Agent B 并发读取,A 先写入成功,B 后写入时触发冲突。
- 冲突检测:数据库通过版本号(expectedVersion ≠ currentVersion)检测到写冲突,并返回明确的 CONFLICT 错误。
- 重试流程:Agent B 收到冲突后,重新读取最新数据,并根据业务逻辑(此处为“配额已更新”)决定跳过写入。
- 最终结果:数据最终保持一致(quota=2048),避免了静默覆盖。
关键变化:Agent B 不再静默覆盖数据,而是明确返回了 CONFLICT 错误。收到冲突的 Agent 可以重读数据、重新判断、再做决定。
这个翻车就是上篇说的广播编排
如果你看了上篇主推文,应该还记得我聊了三种多 Agent 编排模式——顺序编排、路由编排、广播编排。当时说广播编排适合"越多越好"的场景,但有个没说透的坑:广播编排下,多个 Agent 并行执行,如果它们调用了同一个写 Tool,就会触发本文的翻车场景。
这个翻车案例就是广播编排的典型问题。两个 Agent 并行执行,数据同步 Agent 和用户配置修改 Agent 同时调用了 updateUserConfig,结果就是数据冲突。选型的时候要记住一个原则:广播编排只适合并行读,不适合并行写。 如果多个 Agent 需要并行写同一个资源,要么给 Tool 加锁(乐观锁),要么别用广播编排,改用顺序编排串行化。
四、方案对比与边界说明
翻车前(无锁):
Agent A ──读(1024)──→ 写(2048) ──────→ ✅ 成功
Agent B ──────读(1024)──→ 写(1024) ──→ ✅ 静默覆盖 ❌
修复后(乐观锁):
Agent A ──读(v1:1024)──→ 写(v1→v2:2048) ──────→ ✅
Agent B ──读(v1:1024)──→ 写(v1) ─→ ❌冲突 → 重读(v2) → 跳过 → ✅
| 场景 | 翻车前(无锁) | 第一次修复(内存锁) | 最终修复(乐观锁) |
|---|---|---|---|
| 用户最终看到 | 1024(被覆盖)❌ | 2048 ✅ | 2048 ✅ |
| 是否有报错 | 无(静默错误) | 无 | 有冲突提示 ✅ |
| 支持多实例 | — | ❌ | ✅ |
| 性能影响 | 无 | 串行化,吞吐量 ↓60% | 无(冲突才重试) |
| 代码复杂度 | 低(0 行锁逻辑) | 中(约 20 行) | 中(约 15 行) |
边界说明
乐观锁不是万能的,有几点得承认:
- 冲突频繁时重试成本高——如果冲突率超过 10%,重试次数会急剧增加,极端情况下可能一直重试失败
- 写密集型场景不合适——每秒数百次写入的场景,乐观锁的重试成本太高,需要用分布式锁或队列
- 不是所有 Tool 都需要锁——只对"共享可变状态"的 Tool 需要加锁。只读 Tool、无状态 Tool 不需要
- 版本号乐观锁只能检测冲突,不能预防冲突——它告诉你"撞了",但不解决"为什么会撞"
我的经验是:先用乐观锁,等冲突率超过 5% 再考虑升级方案。
五、生产监控与决策框架
可复用的决策框架:
| Tool 类型 | 示例 | 需要锁? | 推荐方案 |
|---|---|---|---|
| 只读 Tool | 查询用户信息、搜索文档 | ❌ | 不需要 |
| 写独立数据 | 每个 Agent 写自己的日志 | ❌ | 不需要 |
| 写共享数据(低频冲突) | 用户配置修改、权限变更 | ✅ | 乐观锁 |
| 写共享数据(高频冲突) | 计数器、库存扣减、实时排名 | ✅ | 分布式锁或队列 |
生产环境怎么发现这种问题:
乐观锁修好了翻车,但有个更本质的问题:在没有锁的时候,怎么第一时间发现数据被静默覆盖了?
我后来加了三层监控:
1. 版本号冲突告警——乐观锁上线后,把 CONFLICT 错误接入告警。如果某个 Tool 的冲突率在短时间内飙升,说明有 Agent 在"打架",需要排查调度逻辑
2. 数据一致性定时校验——每小时跑一次,对比数据库里的数据和预期的"最后修改时间+版本号"是否一致。不一致就告警
3. Tool 调用链追踪——给每个 Tool 调用加一个 traceId,把"谁调了哪个 Tool、传了什么参数、返回了什么结果"串起来。翻车的时候能直接看到"A 写了 2048,B 在 3ms 后写了 1024"这样的完整链路
| 监控层级 | 手段 | 发现速度 | 能发现什么 |
|---|---|---|---|
| L1 | 版本号冲突告警 | 实时 | 正在发生的冲突 |
| L2 | 一致性校验 | 小时级 | 已发生的静默错误 |
| L3 | 调用链追踪 | 排查时按需 | 完整的冲突时间线 |
这三层加上之后,至少不会再出现"用户反馈了才知道"的情况。
六、总结
这趟踩坑换来的四个经验:
- 多Agent 不等于多线程——Agent 之间的并发比线程更难排查,因为每个 Agent 都觉得自己是对的,日志里全是 success
- 先上乐观锁,冲突率超过 5% 再升级——过早优化比不优化更坑,内存锁队列白写了一天
- 不是所有 Tool 都需要锁——分清"共享可变状态"和"独立数据"是关键,大部分 Tool 其实不需要
- 监控比修复更重要——没有监控,你永远不知道静默错误什么时候发生。三层监控(冲突告警 + 一致性校验 + 调用链追踪)至少保证不会"用户反馈了才知道"
一句话总结: 两个操作都返回了 success,但数据是错的——这是分布式系统里最可怕的一种错误:静默错误。多Agent 架构下,Tool 的"无状态"假设不成立。