订单开通的幂等:一个 if 挡不住双击,需要三道防线

订单开通的幂等:一个 if 挡不住双击,需要三道防线

自动交付最怕的不是开通失败,是同一张订单被开两次。一次多扣一台机器,用户高兴,账上不对。最麻烦的是它不报错、不告警,等月底对账才发现多出三台没人认领的容器。

很多人以为幂等就是开头一行 if (status === 'active') return。不够。这行只管串行场景,管不住并发双击。

三个真实的失败场景

场景 表面现象 实际后果
页面双击”确认支付” 两个请求几乎同时到 两次都读到 paid,都往下走,开两台
网络超时后用户重试 第一次还在跑 第二次进来又开一台
开通失败后重试 旧容器还在 再开一台,旧的成孤儿,vmid 泄漏

第一种是自己手快,第二种是网络不好,第三种是代码没写干净。三种都要防。

第一道:状态机幂等


async function pay(id, byUser) {
  const cur = listOrders().find((x) => x.id === id)
  if (!cur) throw Object.assign(new Error('订单不存在'), { status: 404 })
  if (cur.status === 'active') return cur          // 已开通,直接返回原订单
  if (cur.status === 'provisioning' || INFLIGHT.has(id)) {
    throw Object.assign(new Error('该订单正在开通中,请勿重复提交'), { status: 409 })
  }
  ...
}

这里有个细节值得说:已开通返回 200 和原订单,开通中返回 409。

两者的语义完全不同:

  • active → 结果已经确定,重复请求是合法的,应该返回成功。用户刷新页面不应该看到错误。
  • provisioning → 结果还没确定,重复请求不该被接受,必须明确拒绝,并且告诉用户”稍后再看”。

如果两个都返回 200,用户以为成功了,其实还在跑,中间失败了也不知道。如果两个都返回 409,用户刷新一下页面就报错,体验崩了。

幂等的关键不是”不重复执行”,是”重复请求得到一致的结果”。

第二道:在途标记

状态判断有个天然的并发窗口:


请求 A:读 status = 'paid'  ✓
请求 B:读 status = 'paid'  ✓        ← A 还没来得及改成 provisioning
请求 A:改成 'provisioning',开始创建
请求 B:改成 'provisioning',开始创建

两个都通过了检查。中间隔的那几毫秒,就是事故发生的窗口。

补法是在检查之后立刻占位:


const INFLIGHT = new Set()

if (cur.status === 'provisioning' || INFLIGHT.has(id)) throw ...  // 409
INFLIGHT.add(id)

try {
  // ... 创建容器、初始化、绑定
} finally {
  INFLIGHT.delete(id)      // 无论成败都要释放
}

finally 那句不能省。少了它,一次异常就永久占住这张订单,之后所有重试都被 409 挡住,只能人工去重启进程。

这道防线的边界要说清楚:INFLIGHT 是内存里的 Set。单进程部署时它是可靠的;一旦上了多进程、或者做了多节点,两个进程各有一份 Set,互相看不见,这道防线就直接失效。那时候得换 Redis 的 SET NX 或者数据库行锁。知道它什么时候会失效,比它会用更重要。

第三道:写操作串行化

还有个更隐蔽的问题:订单是存在一个 JSON 数组里的,读出来、改一个字段、整个写回去。


function updateOrder(id, patch) {
  const run = _orderLock.then(() => {
    const all = listOrders()
    const o = all.find((x) => x.id === id)
    if (!o) return null
    Object.assign(o, patch)
    saveOrders(all)          // 整个文件重写
    return o
  }).catch(() => null)
  _orderLock = run.catch(() => {})
  return run
}

_orderLock = run.catch(() => {}) 这一行在做什么?

它把上一次的完成(或失败)当成下一次的起点,形成一条 Promise 链。每个 updateOrder 都挂在这条链的末尾,天然串行。

关键在 .catch(() => {}):如果上一次写失败了,链不能断。断一次,后面所有写操作全部挂死——因为它们都在等一个永远不会 resolve 的 Promise。整套订单系统看起来还活着,实际上一次也写不进去。

这不是真正的锁,只是单进程内的串行化。够用,但别指望它扛多实例。

一条业务规则:交付阶段才写归属

代码里有一句注释:


// 交付阶段才写归属:机器已验证可用,此时分配才是"真正交付"
if (D.assignOwner) {
  D.assignOwner(vmid, order.user)
}

位置很讲究——它在“容器创建 → 等 IP → 系统初始化 → 全部通过之后”,而不是创建完就写。

原因很直接:容器创建成功不等于可用。网卡没起来、系统初始化脚本挂了、SSH 连不上,这些都发生在创建之后。占着归属但用不了的机器,比没有机器更糟——用户看到自己名下有台机器,连不上,来问你,你还得去查是不是自己的问题。

同一个位置的还有这条判断:


if (!bs || !bs.ok) {
  // 关键:初始化没完成就不交付,订单转 failed 等待修复
  throw new Error('容器 #' + vmid + ' 初始化未完成,已中止交付')
}

宁可失败,不交付半成品。

失败重试要先清场

订单失败后,items 里还记着已经创建的 vmid。如果直接重试,会再开一台,旧的没人管:


if (cur.status === 'failed' && (cur.items || []).length) {
  for (const it of cur.items) {
    if (!it.vmid) continue
    await BASE.destroyGuest(it.vmid)      // 清理上次留下的
  }
  await setStatus(id, { items: [], logs })   // 清空记录
}

这两步顺序不能反。先销毁,再清空记录——反过来的话,清空之后 vmid 就丢了,容器变成孤儿,只能靠人工去 PVE 里一个个翻。

幂等不只是“不重复做”,还包括“重复做之前把上次的残留擦干净”。

小结

三道防线各管一段:

防线 挡什么 什么时候失效
状态机判断 串行的重复请求 并发窗口那几毫秒
INFLIGHT 在途标记 并发双击 多进程 / 多节点
updateOrder 串行化 并发写覆盖 多实例同时写同一个文件

三道叠起来,单机场景基本稳了。要真上多实例,这三道全得换——换成数据库行锁或者分布式锁,逻辑反而更简单,因为锁的语义是现成的,不用自己拼 Promise 链。

但在那之前,先想清楚每一道防线在什么条件下会失效。这比把它写得完美更重要。

💬 留下你的爪印