PAYATHON 2026

GraphQL 写入后立即读取引发 Race condition

支付阿杰

结论

不要采用“先查询,再决定是否插入”的方式,也不要依赖延迟。更稳妥的做法是通过主键或唯一约束,让数据库原子地处理重复写入。可以使用 Hasura 的 on_conflict 实现 upsert,也可以在发生冲突时忽略插入。

即使 JavaScript 中的 await 按预期顺序执行,“查询不存在 → 插入”也不是原子操作。只要有多个事件处理器、并发请求、缓存或读副本,两个流程就可能同时判断用户不存在,然后同时执行插入。

推荐方案:使用 on_conflict

假设用户表名为 User,主键约束为 user_pkey,可以直接尝试插入,不必提前查询:

mutation EnsureUser($id: uuid!) {
  insert_User_one(
    object: { id: $id }
    on_conflict: {
      constraint: user_pkey
      update_columns: []
    }
  ) {
    id
  }
}

调用代码:

async function ensureUser(userId) {
  return client.mutate({
    mutation: ensure_user_mutation,
    variables: { id: userId }
  });
}

for (const event of events) {
  await ensureUser(event.user);

  if (event.type === payment) {
    await handlePaymentEvent(event);
  } else {
    await handleOtherEvent(event);
  }
}

冲突判断由数据库完成:

  • 用户不存在时,插入用户。
  • 用户已经存在时,不再重复插入。
  • 两个请求同时插入同一用户时,由唯一约束处理冲突,不会留下应用层“先查后写”的并发窗口。

constraint 的实际枚举名称由 Hasura 根据数据库约束生成,请以当前 GraphQL Schema 中显示的名称为准。部分 Hasura 配置或版本可能不接受空的 update_columns。遇到这种情况,可以改为更新一个允许修改且不影响业务的字段,例如:

mutation EnsureUser($id: uuid!) {
  insert_User_one(
    object: { id: $id }
    on_conflict: {
      constraint: user_pkey
      update_columns: [id]
    }
  ) {
    id
  }
}

发生冲突时,这段 mutation 会把 id 更新为原来的值。能否这样处理,还取决于 Hasura 为当前角色配置的 insert 和 update 权限。

为什么 await 仍可能看起来没有生效

在普通的 for...of 循环中,下面的代码确实会串行执行:

for (const event of events) {
  await processEvent(event);
}

只有一次 await client.mutate(...) 完成后,代码才会进入下一轮。如果实际代码采用这种结构,问题未必是 mutation 尚未完成,也可能由以下情况引起。

GraphQL 客户端返回了缓存结果

Apollo Client 的查询可能使用 cache-first 策略。第一次查询得到“用户不存在”的结果后,这份结果可能仍在缓存中。即使 mutation 已经成功,下一次查询也可能再次读到旧缓存。

如果业务上必须重新查询,可以强制从网络获取:

const { data } = await client.query({
  query: user_query,
  variables: { id: event.user },
  fetchPolicy: "network-only"
});

也可以完全绕过查询缓存:

const { data } = await client.query({
  query: user_query,
  variables: { id: event.user },
  fetchPolicy: "no-cache"
});

另一种办法是在 mutation 完成后正确更新 Apollo Cache。不过,对于“确保用户存在”这个场景,直接使用 on_conflict 通常更简单、更可靠。

实际代码存在并发处理

下面的写法不会等待每个事件依次处理完成:

events.forEach(async event => {
  await processEvent(event);
});

forEach 不会等待异步回调,应改为:

for (const event of events) {
  await processEvent(event);
}

使用 Promise.all 时,事件同样会并发执行:

await Promise.all(events.map(processEvent));

即使改成串行处理,也只能保证当前进程中这个数组的处理顺序,无法阻止其他请求或服务实例同时插入同一用户。因此,仍然需要在数据库层处理冲突。

查询和写入使用了不同的数据源

如果查询访问只读副本,而 mutation 写入主库,复制延迟可能导致写入已经成功,但查询在短时间内仍找不到记录。在这种架构下,人为增加 timeout 只能降低问题出现的概率,无法保证正确性。应使用原子 upsert,或者让需要满足“读己之写”一致性的查询访问主库。

可选优化:先对事件中的用户去重

如果同一批事件包含大量重复用户,可以先去重,减少不必要的 mutation:

const userIds = [...new Set(events.map(event => event.user))];

for (const userId of userIds) {
  await ensureUser(userId);
}

for (const event of events) {
  if (event.type === payment) {
    await handlePaymentEvent(event);
  } else {
    await handleOtherEvent(event);
  }
}

去重只能减少请求次数,不能取代数据库唯一约束和 on_conflict,因为其他进程仍可能同时写入。

注意事项

  • 必须保留主键或唯一约束,这是防止重复数据的最后一道保障。
  • 不要用固定延迟解决并发问题。延迟无法保证一致性,还会增加处理时间。
  • 捕获并忽略特定的唯一约束错误可以作为兜底方案,但不要通过解析含义模糊的错误文本来判断。应优先使用数据库提供的原子冲突处理。
  • 如果插入时还要写入姓名、邮箱等字段,需要明确发生冲突后的业务规则,例如保留原值、更新指定字段,或者拒绝不一致的数据。
  • 如果后续操作依赖当前请求刚写入的用户,应直接使用 mutation 返回的数据,不要立刻再次查询。

核心原则是把“用户不存在时插入,存在时忽略或更新”放在一次数据库操作中完成,不要在应用层拆成查询和插入两个步骤。

备注:内容仅供参考。