两个服务同写一张子表,队列只保护了一个——Duplicate entry 的跨服务根因
上周排查了一个只在并发上传时才复现的错误:Duplicate entry 'xxx-yyy' for key 'child_table.PRIMARY'。错误信息直接暴露 SQL,第一反应是数据重复或者 INSERT 逻辑有 bug。翻了半天调用链,发现这是一道典型的「两个服务各自以为自己安全,实际上谁也不安全」的并发陷阱。
现象
用户连续上传三份文档,后台会为每份文档异步跑一个分析任务。当三个任务在约 100ms 内先后完成时,偶发失败,错误是:
Duplicate entry 'parent_id_A-child_id_1' for key 'child_table.PRIMARY'
子表的主键是 (parent_id, child_id) 复合索引,对应经典的 1:N 父子关系(比如订单与行项目、某聚合实体的子记录列表)。单任务绝不复现,三个并发几乎必现。
根因
代码里有两个服务都会写这张子表:
- owner 服务(比如
extraction-service):用DELETE WHERE parent_id = ? + INSERT VALUES ...做全量改写。它在模块顶层维护了一个 Promise chain——patchQueue,所有写操作都排队进入,确保同一进程内串行执行。 - 借用服务(比如
template-service):它的某个写路径会调到refreshAllRelatedReadiness(),这个函数最终也走到同一张子表的 DELETE+INSERT——但完全绕过了 owner 服务的 patchQueue,直接拿连接执行 SQL。
两个 DB 连接并发执行时的交错大致如下:
T0 conn A (owner): DELETE FROM child WHERE parent_id = P -- ok
T1 conn B (borrower):DELETE FROM child WHERE parent_id = P -- blocks on A's lock
T2 conn A: INSERT (P, c_1) -- ok
T3 conn A: COMMIT
T4 conn B: DELETE 解锁完成,但 A 的行已提交
T5 conn B: INSERT (P, c_1) -- 碰撞!Duplicate entry
MySQL InnoDB 默认 REPEATABLE READ,DELETE + INSERT 是两条独立语句,不是原子操作。没有外部串行化,任何两个连接都可能走出上面这条时序。
patchQueue 保护的是「同一服务内」的并发写。只要还有另一个服务通过自己的 DB 连接写同一张子表,两套连接在 InnoDB 层毫无互斥——队列形同虚设。
诊断方法:
# 1. 找到 queue 和它的注释(注释通常点名了被保护的表)
rg -n 'PatchQueue|patchQueue|withExtractionPatchQueue' src/
# 2. 找到子表的所有 writer
rg -n 'replaceRelational\|replaceChildren\|setXxxList' src/
# 3. 对每一个 writer,回溯调用链:这条路径有没有进队列?
# 没进队列的就是 bug。正解
把 owner 服务的 withPatchQueue 导出,让借用服务在写子表的路径上也进同一条队列。
Step 1:owner 服务导出 queue wrapper
// extraction-service.mjs
let extractionPatchQueue = Promise.resolve()
export function withExtractionPatchQueue(task) {
const run = () => task()
const next = extractionPatchQueue.then(run, run)
// 尾部吞掉 rejection,防止 queue 因单次失败卡死;调用方仍能 catch
extractionPatchQueue = next.catch(() => {})
return next
}Step 2:借用服务在触发子表写操作的路径上包一层
// template-service.mjs
import { withExtractionPatchQueue } from './extraction-service.mjs'
async function patchTemplate(id, updater, options = {}) {
// syncRelated 路径最终触发 refreshAllRelatedReadiness → 写子表
// 必须与 owner 服务共用同一条串行 lane
const run = () => runPatchTemplate(id, updater, options)
return options.syncRelated ? withExtractionPatchQueue(run) : run()
}
async function runPatchTemplate(id, updater, options) {
// 原有逻辑不变
}两个关键点:
- 只在
syncRelated: true时才进队列,避免把无关写操作也串行化,损耗吞吐量。 - 包在最外层(入口函数处),而不是只包
replaceRelationalChildren那一行——队列必须覆盖整个 DELETE+INSERT 序列及其依赖的读操作。
INSERT IGNORE 或 ON DUPLICATE KEY UPDATE 让错误消失,但 DELETE+INSERT 序列依然非原子——两个连接仍然可以交错,只是碰撞后悄悄忽略,写入结果可能丢行或保留脏数据。更大的问题是:表上的主键约束是负载承重的 invariant,沉默地绕过它只会让未来的真实数据 bug 更难查。
一个进程一张 queue,所有写这张子表的路径必须共享同一个 Promise chain。两套独立的 queue 不能互斥,哪怕各自内部是安全的。
验证方法:并发触发 3 个借用服务任务,全部指向同一个父聚合,Fix 前至少一个报 Duplicate entry,Fix 后全部成功,总耗时约为 N 倍单任务时间(串行代价,这是可接受的 trade-off)。
一句话外卖
子表的 DELETE+INSERT 竞态保护必须覆盖所有写路径——队列不导出给借用方,等于没加。