Idempotency in Payment Systems — 从概念到 Celoria POS 支付的真实修复案例
一个操作执行一次和执行多次,对系统产生的副作用完全相同。
注意关键词是副作用(服务端状态变化),而非返回值。例如 DELETE /users/123 第一次返回 200,第二次返回 404,响应不同但系统状态相同(用户 123 都不存在)——这仍然是幂等的。
| 操作 | 幂等? | 原因 |
|---|---|---|
| 按电梯 "3楼" 按钮 | 是 | 按 1 次和按 10 次,电梯都只去 3 楼 |
| 设置空调温度为 24°C | 是 | 设 1 次和设 10 次,结果都是 24°C |
| 往杯子里倒水 | 否 | 倒 1 次和倒 10 次,水量完全不同 |
| 银行转账 100 元 | 否 | 转 1 次扣 100,转 10 次扣 1000 |
| 方法 | 幂等? | 说明 |
|---|---|---|
GET | 是 | 读取,不改状态 |
PUT | 是 | "设置为这个状态",重复执行结果一样 |
DELETE | 是 | 第一次删掉,后续操作不改变最终状态 |
POST | 否 | 每次都创建一个新资源(除非专门设计) |
PUT 和 DELETE 有副作用但幂等;GET 既无副作用又幂等。
支付场景下网络是不可靠的。以下情况随时可能发生:
如果支付接口不是幂等的,这些场景都可能导致重复扣款或重复创建交易记录。
sequenceDiagram
participant Web as Web 前端
participant API as 后端 API
participant POS as POS 终端
Web->>API: POST /pos-request(创建支付请求)
API->>POS: WebSocket 推送支付请求
POS->>POS: 顾客刷卡,扣款成功
POS->>API: pos-confirm(成功)
API->>API: INSERT transaction ✅
Note over API: 但 payment_request 状态
没更新为 completed
(部分失败)
API--xWeb: 网络断开,前端不知道
Web->>Web: 用户看到仍在"等待中"
Web->>API: POST /pos-manual-confirm(手动确认)
API->>API: INSERT transaction
Note over API: 💥 duplicate key!
order_id 唯一约束冲突
payment_request 的状态没有同步更新为 completed。
手动确认时,代码直接 INSERT 新 transaction,触发 order_id 唯一约束冲突 → 500 错误。
// 直接 INSERT,不管有没有
const { rows: [transaction] } = await client.query(
`INSERT INTO transactions
(order_id, ...) VALUES ($1, ...)`,
[paymentRequest.order_id, ...]
);
// 如果 order_id 已存在
// → 💥 unique constraint violation
这就像"往杯子里倒水"——每次调用都尝试新建一条记录。
// 先查,再决定是否插入
const { rows: [existingTxn] } = await client.query(
`SELECT * FROM transactions
WHERE order_id = $1`,
[paymentRequest.order_id]
);
if (existingTxn) {
// 已存在 → 只修复 payment_request 状态
await client.query(
`UPDATE payment_requests
SET status = 'completed',
transaction_id = $1
WHERE id = $2`,
[existingTxn.id, requestId]
);
return { paymentRequest,
transaction: existingTxn };
}
// 不存在 → 正常创建
const { rows: [transaction] } = await client.query(
`INSERT INTO transactions ...`
);
这就像"设置空调温度"——不管调用几次,最终状态一致。
backend/api/payment/pos-protected-routes.jsPOST /api/payment/pos-manual-confirmtenantDb.transaction + FOR UPDATE 行锁防止并发竞争
三次调用做了不同的事,但最终系统状态一致——transaction 存在 + payment_request 标记为 completed。这就是幂等。
客户端生成唯一 ID,服务端用它去重。Stripe、微信支付等主流支付 API 都用此方案。
// 客户端每次请求带一个唯一 key
POST /api/payment/charge
Headers: { "Idempotency-Key": "abc-123-xyz" }
// 服务端:先查这个 key 处理过没
const existing = await db.query(
'SELECT * FROM idempotency_log WHERE key = $1',
[idempotencyKey]
);
if (existing) return existing.response; // 直接返回之前的结果
// 否则正常处理,并记录结果
const result = await processPayment(...);
await db.query(
'INSERT INTO idempotency_log (key, response) VALUES ($1, $2)',
[idempotencyKey, JSON.stringify(result)]
);
用一个业务唯一键(如 order_id)在插入前检查。Celoria POS 手动确认使用此方案。
const existing = await db.query(
'SELECT * FROM transactions WHERE order_id = $1',
[orderId]
);
if (existing) { /* 复用已有记录 */ }
else { /* 新建记录 */ }
FOR UPDATE)防止并发竞争。
否则两个并发请求可能同时查到"不存在",都去 INSERT → 仍然冲突。
数据库层面直接处理冲突,一条 SQL 搞定。
INSERT INTO transactions (order_id, amount, status) VALUES ($1, $2, 'completed') ON CONFLICT (order_id) DO UPDATE SET status = 'completed' RETURNING *;
只允许特定状态转换,到达终态后拒绝重复操作。
// payment_request 已完成 → 直接拒绝 SELECT * FROM payment_requests WHERE id = $1 AND status != 'completed' -- 终态保护 FOR UPDATE;
Celoria 的手动确认组合使用了策略二 + 策略四:先用状态机过滤掉已完成的请求,再用 order_id 检查防止重复 INSERT。
flowchart TD
A["需要幂等保护?"] --> B{"有自然业务唯一键?
如 order_id"}
B -->|是| C{"并发风险高?"}
B -->|否| D["使用 Idempotency Key
(客户端生成)"]
C -->|低| E["先查再插
(Check-then-Act)"]
C -->|高| F{"冲突时需要复杂逻辑?"}
F -->|否| G["UPSERT
(ON CONFLICT)"]
F -->|是| H["先查再插 + 事务锁
(FOR UPDATE)"]
style A fill:#16213e,stroke:#00d4ff,color:#eee
style D fill:#16213e,stroke:#f59e0b,color:#eee
style E fill:#16213e,stroke:#4ade80,color:#eee
style G fill:#16213e,stroke:#818cf8,color:#eee
style H fill:#16213e,stroke:#f85149,color:#eee
一个常见误解:幂等操作意味着"什么都不做"。实际上:
| 调用次数 | 实际行为 | 系统最终状态 |
|---|---|---|
| 第 1 次 | 创建 transaction + 更新 status | transaction 存在 + payment_request = completed |
| 第 2 次 | 发现已存在,修复 status | |
| 第 3 次 | 404(已完成,不再处理) |
每次调用做了不同的事,但最终系统状态一致——这就是幂等的本质。
existingTxn.tip_amount 提示用户"此交易已由 POS 自动完成,实际 tip 为 $5"。