返回博客

两个服务同写一张子表,队列只保护了一个——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 是两条独立语句,不是原子操作。没有外部串行化,任何两个连接都可能走出上面这条时序。

坑:只给 owner 服务加队列,借用方绕过去了照样撞

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 看起来能消掉报错,实际上掩埋了架构债

INSERT IGNOREON DUPLICATE KEY UPDATE 让错误消失,但 DELETE+INSERT 序列依然非原子——两个连接仍然可以交错,只是碰撞后悄悄忽略,写入结果可能丢行或保留脏数据。更大的问题是:表上的主键约束是负载承重的 invariant,沉默地绕过它只会让未来的真实数据 bug 更难查。

正解:导出 queue,让借用方进同一条串行 lane

一个进程一张 queue,所有写这张子表的路径必须共享同一个 Promise chain。两套独立的 queue 不能互斥,哪怕各自内部是安全的。

验证方法:并发触发 3 个借用服务任务,全部指向同一个父聚合,Fix 前至少一个报 Duplicate entry,Fix 后全部成功,总耗时约为 N 倍单任务时间(串行代价,这是可接受的 trade-off)。

一句话外卖

子表的 DELETE+INSERT 竞态保护必须覆盖所有写路径——队列不导出给借用方,等于没加。