幂等性在支付系统中的实践

Idempotency in Payment Systems — 从概念到 Celoria POS 支付的真实修复案例

1. 什么是幂等性

一个操作执行一次和执行多次,对系统产生的副作用完全相同。

f(f(x)) = f(x)

注意关键词是副作用(服务端状态变化),而非返回值。例如 DELETE /users/123 第一次返回 200,第二次返回 404,响应不同系统状态相同(用户 123 都不存在)——这仍然是幂等的。

日常类比

操作幂等?原因
按电梯 "3楼" 按钮按 1 次和按 10 次,电梯都只去 3 楼
设置空调温度为 24°C设 1 次和设 10 次,结果都是 24°C
往杯子里倒水倒 1 次和倒 10 次,水量完全不同
银行转账 100 元转 1 次扣 100,转 10 次扣 1000

HTTP 方法的幂等性

方法幂等?说明
GET读取,不改状态
PUT"设置为这个状态",重复执行结果一样
DELETE第一次删掉,后续操作不改变最终状态
POST每次都创建一个新资源(除非专门设计)
关键区分:幂等 ≠ 无副作用(safe)。PUTDELETE 有副作用但幂等;GET 既无副作用又幂等。

2. 为什么支付系统必须幂等

支付场景下网络是不可靠的。以下情况随时可能发生:

如果支付接口不是幂等的,这些场景都可能导致重复扣款重复创建交易记录

Celoria POS 支付的真实故障

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 唯一约束冲突
根因:Transaction 已经被 POS 自动确认创建了,但 payment_request 的状态没有同步更新为 completed。 手动确认时,代码直接 INSERT 新 transaction,触发 order_id 唯一约束冲突 → 500 错误。

3. 修复:先查再插的幂等性保护

❌ 修复前(非幂等)

// 直接 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.js
端点:POST /api/payment/pos-manual-confirm
保护机制:外层 tenantDb.transaction + FOR UPDATE 行锁防止并发竞争

修复后的多次调用行为

第一次调用
创建 transaction + 更新 payment_request → 状态变了
第二次调用
发现 transaction 已存在 → 只修复 payment_request 状态 → 状态修复了
第三次调用
发现 payment_request 已是 completed → 404(已完成)→ 什么都没变

三次调用做了不同的事,但最终系统状态一致——transaction 存在 + payment_request 标记为 completed。这就是幂等。

4. 实现幂等性的四种常见策略

策略一:幂等键(Idempotency Key)

客户端生成唯一 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)]
);
优点:通用性强,不依赖业务字段
缺点:需要额外的存储(idempotency_log 表),需要清理过期记录

策略二:先查再插(Check-then-Act)

用一个业务唯一键(如 order_id)在插入前检查。Celoria POS 手动确认使用此方案。

const existing = await db.query(
  'SELECT * FROM transactions WHERE order_id = $1',
  [orderId]
);
if (existing) { /* 复用已有记录 */ }
else           { /* 新建记录 */ }
注意:必须配合事务 + 行锁FOR UPDATE)防止并发竞争。 否则两个并发请求可能同时查到"不存在",都去 INSERT → 仍然冲突。

策略三:UPSERT(INSERT ... ON CONFLICT)

数据库层面直接处理冲突,一条 SQL 搞定。

INSERT INTO transactions (order_id, amount, status)
VALUES ($1, $2, 'completed')
ON CONFLICT (order_id)
  DO UPDATE SET status = 'completed'
RETURNING *;
优点:原子性由数据库保证,不需要应用层事务
缺点:冲突时的 UPDATE 逻辑有限,复杂场景不适合(如已有的 transaction 不应被覆盖字段)

策略四:状态机守卫

只允许特定状态转换,到达终态后拒绝重复操作。

// payment_request 已完成 → 直接拒绝
SELECT * FROM payment_requests
WHERE id = $1 AND status != 'completed'  -- 终态保护
FOR UPDATE;

Celoria 的手动确认组合使用了策略二 + 策略四:先用状态机过滤掉已完成的请求,再用 order_id 检查防止重复 INSERT。

5. 策略选型决策树

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

6. 思考:幂等 ≠ 无副作用

一个常见误解:幂等操作意味着"什么都不做"。实际上:

调用次数实际行为系统最终状态
第 1 次创建 transaction + 更新 statustransaction 存在
+ payment_request
= completed
第 2 次发现已存在,修复 status
第 3 次404(已完成,不再处理)

每次调用做了不同的事,但最终系统状态一致——这就是幂等的本质。

延伸思考:Tip 金额冲突

场景:POS 自动确认时 tip = $5,但手动确认时用户输入 tip = $10,该用哪个?

当前设计:直接复用已有 transaction,忽略手动输入的 tip 金额。

合理性:卡已经实际扣了 $5 tip,手动确认只是"承认这笔已完成的交易",不应该改变已发生的金额。 如果需要更精细的处理,可以在前端展示 existingTxn.tip_amount 提示用户"此交易已由 POS 自动完成,实际 tip 为 $5"。

7. 相关文档