两个Agent同时写同一个Tool,数据被覆盖了——我是怎么用乐观锁修好的

0 阅读9分钟

一、翻车现场与排查

用户发来一条消息:"我刚把配额改到 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 个并发请求同时写同一个用户的数据:

并发数无锁(请求/秒)锁队列(请求/秒)下降幅度
10980420↓57%
5042001150↓73%
10076001600↓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}

该流程图清晰地展示了:

  1. 时间线:Agent A 和 Agent B 并发读取,A 先写入成功,B 后写入时触发冲突。
  2. 冲突检测:数据库通过版本号(expectedVersion ≠ currentVersion)检测到写冲突,并返回明确的 CONFLICT 错误。
  3. 重试流程:Agent B 收到冲突后,重新读取最新数据,并根据业务逻辑(此处为“配额已更新”)决定跳过写入。
  4. 最终结果:数据最终保持一致(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 行)

边界说明

乐观锁不是万能的,有几点得承认:

  1. 冲突频繁时重试成本高——如果冲突率超过 10%,重试次数会急剧增加,极端情况下可能一直重试失败
  2. 写密集型场景不合适——每秒数百次写入的场景,乐观锁的重试成本太高,需要用分布式锁或队列
  3. 不是所有 Tool 都需要锁——只对"共享可变状态"的 Tool 需要加锁。只读 Tool、无状态 Tool 不需要
  4. 版本号乐观锁只能检测冲突,不能预防冲突——它告诉你"撞了",但不解决"为什么会撞"

我的经验是:先用乐观锁,等冲突率超过 5% 再考虑升级方案。


五、生产监控与决策框架

可复用的决策框架:

Tool 类型示例需要锁?推荐方案
只读 Tool查询用户信息、搜索文档不需要
写独立数据每个 Agent 写自己的日志不需要
写共享数据(低频冲突)用户配置修改、权限变更乐观锁
写共享数据(高频冲突)计数器、库存扣减、实时排名分布式锁或队列

生产环境怎么发现这种问题:

乐观锁修好了翻车,但有个更本质的问题:在没有锁的时候,怎么第一时间发现数据被静默覆盖了?

我后来加了三层监控:

1. 版本号冲突告警——乐观锁上线后,把 CONFLICT 错误接入告警。如果某个 Tool 的冲突率在短时间内飙升,说明有 Agent 在"打架",需要排查调度逻辑

2. 数据一致性定时校验——每小时跑一次,对比数据库里的数据和预期的"最后修改时间+版本号"是否一致。不一致就告警

3. Tool 调用链追踪——给每个 Tool 调用加一个 traceId,把"谁调了哪个 Tool、传了什么参数、返回了什么结果"串起来。翻车的时候能直接看到"A 写了 2048,B 在 3ms 后写了 1024"这样的完整链路

监控层级手段发现速度能发现什么
L1版本号冲突告警实时正在发生的冲突
L2一致性校验小时级已发生的静默错误
L3调用链追踪排查时按需完整的冲突时间线

这三层加上之后,至少不会再出现"用户反馈了才知道"的情况。


六、总结

这趟踩坑换来的四个经验:

  1. 多Agent 不等于多线程——Agent 之间的并发比线程更难排查,因为每个 Agent 都觉得自己是对的,日志里全是 success
  2. 先上乐观锁,冲突率超过 5% 再升级——过早优化比不优化更坑,内存锁队列白写了一天
  3. 不是所有 Tool 都需要锁——分清"共享可变状态"和"独立数据"是关键,大部分 Tool 其实不需要
  4. 监控比修复更重要——没有监控,你永远不知道静默错误什么时候发生。三层监控(冲突告警 + 一致性校验 + 调用链追踪)至少保证不会"用户反馈了才知道"

一句话总结: 两个操作都返回了 success,但数据是错的——这是分布式系统里最可怕的一种错误:静默错误。多Agent 架构下,Tool 的"无状态"假设不成立。