订单开通的幂等:一个 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 链。
但在那之前,先想清楚每一道防线在什么条件下会失效。这比把它写得完美更重要。