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 返回的数据,不要立刻再次查询。
核心原则是把“用户不存在时插入,存在时忽略或更新”放在一次数据库操作中完成,不要在应用层拆成查询和插入两个步骤。
备注:内容仅供参考。
