HTTP Method 的冪等性
HTTP spec 定義了哪些 method 應該是冪等的:
GET → 冪等(讀取,多次相同)
PUT → 冪等(整體替換,多次結果相同)
DELETE → 冪等(刪一次和刪兩次結果一樣——資源消失)
PATCH → 不一定(取決於操作:「設為 active」冪等;「+1」不冪等)
POST → 通常不冪等(每次建立新資源)
問題出在 POST:建立訂單、發起支付、發送通知——這些都是 POST,都有副作用,都不是天然冪等的。
網路是不可靠的。Client 送出 POST 之後,可能發生:
- Request 送到 server,server 處理成功,但 response 在路上丟了
- Request 在路上丟了,server 沒收到
- Server 在處理中崩潰,不確定有沒有完成
Client 看到 timeout 或 error,不知道是哪種情況,只能重試。如果 server 沒有 idempotency 機制,重試就可能建兩張訂單、扣兩次錢。
Idempotency Key 模式
Stripe 的做法(業界標準):
POST /v1/charges
Idempotency-Key: a8b4f9e2-3c1d-4a5b-8e7f-9d2a1b3c4e5f
Content-Type: application/json
{
"amount": 2000,
"currency": "usd",
"source": "tok_visa"
}
Client 在第一次請求時生成一個唯一的 key(UUID)。如果需要重試,帶同樣的 key。Server 看到這個 key 就知道「這是我處理過的請求,直接回傳上次的結果」。
Server 端實作:
// 使用 Redis 儲存 idempotency record
interface IdempotencyRecord {
status: 'processing' | 'completed' | 'failed';
response?: { statusCode: number; body: unknown };
createdAt: number;
}
export const idempotencyMiddleware = async (req, res, next) => {
const idempotencyKey = req.headers['idempotency-key'] as string;
// 只對 POST 和 PATCH 做 idempotency
if (!['POST', 'PATCH'].includes(req.method) || !idempotencyKey) {
return next();
}
// 驗 key 格式(防止惡意構造的 key)
if (!/^[\w-]{1,64}$/.test(idempotencyKey)) {
return res.status(400).json({ error: 'Invalid Idempotency-Key format' });
}
const cacheKey = `idempotency:${req.path}:${idempotencyKey}`;
const existing = await redis.get(cacheKey);
if (existing) {
const record: IdempotencyRecord = JSON.parse(existing);
if (record.status === 'processing') {
// 還在處理中,告訴 client 稍後再試
return res.status(409).json({ error: 'Request is being processed' });
}
if (record.status === 'completed' && record.response) {
// 回傳上次的結果
res.set('Idempotency-Replayed', 'true');
return res.status(record.response.statusCode).json(record.response.body);
}
}
// 標記為「處理中」,防止並發重複請求
await redis.set(
cacheKey,
JSON.stringify({ status: 'processing', createdAt: Date.now() }),
'EX', 86400 // 24 小時過期
);
// 攔截 response,儲存結果
const originalJson = res.json.bind(res);
res.json = function (body) {
redis.set(
cacheKey,
JSON.stringify({
status: res.statusCode < 500 ? 'completed' : 'failed',
response: { statusCode: res.statusCode, body },
createdAt: Date.now(),
}),
'EX', 86400
);
return originalJson(body);
};
next();
};Key 的生成與有效期
Client 端生成 key(不是 Server):因為 client 要在重試時帶同一個 key,所以必須是 client 在第一次請求前就生成好。
// Frontend / Client SDK
import { v4 as uuidv4 } from 'uuid';
async function createOrder(orderData) {
const idempotencyKey = uuidv4(); // 每個操作生成一個新 UUID
// 存到 localStorage,方便重試時帶同樣的 key
localStorage.setItem('pending-order-key', idempotencyKey);
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(orderData),
});
localStorage.removeItem('pending-order-key'); // 成功後清除
return response.json();
} catch (error) {
// 下次重試時從 localStorage 取同一個 key
throw error;
}
}有效期設計:
- 太短(1 小時):client 操作慢或網路問題,有效期過了重試就變成新請求
- 太長(30 天):server 要維護大量歷史記錄
- 建議:24 小時。Stripe 用 24 小時。
什麼操作需要 Idempotency Key
一定要有:
- 支付、退款、扣款
- 建立訂單
- 發送 email / SMS / push notification(重送一封信是大問題)
- 庫存扣減
可以不要(但有也無妨):
- 建立草稿(重複建立兩個草稿影響不大)
- 純查詢操作(GET 天然冪等)
- 可容忍重複的操作(tag 加倍頂多 UI 有點奇怪)
資料庫層的冪等性
除了 API 層的 idempotency key,資料庫操作本身也可以設計成冪等:
-- INSERT OR IGNORE(SQLite / MySQL)
INSERT OR IGNORE INTO orders (idempotency_key, user_id, amount)
VALUES ('key-xxx', 1, 200);
-- ON CONFLICT DO NOTHING(PostgreSQL)
INSERT INTO orders (idempotency_key, user_id, amount)
VALUES ('key-xxx', 1, 200)
ON CONFLICT (idempotency_key) DO NOTHING;
-- UPSERT:如果已存在就更新,如果不存在就插入
INSERT INTO feature_flags (name, enabled)
VALUES ('new-feature', false)
ON CONFLICT (name) DO UPDATE SET updated_at = NOW();這和 Seeder 的冪等性設計 是同一個原則——任何寫入操作,加上 ON CONFLICT DO NOTHING 就變成了可安全重試的冪等操作。
