一笔订单被创建了两次,两次都返回 201
工单是这么来的:用户说只下了一单,后台有两笔。查日志,两次请求间隔 800ms——用户双击。另一张工单更隐蔽:间隔整 2s,是客户端超时后自动重试,而服务端其实 2.1 秒才处理完,重试发出时第一单还没落库。两类都成功入库,两次都返回 201。
根因不在客户端手滑。创建类接口面对的本来就是「至少一次」的投递现实:双击、超时重试、网关重发、消息重投都会发生。服务端按「恰好一次」实现业务,重复只是时间问题。幂等键是服务端的答案:客户端为每次业务操作生成唯一键,服务端保证同键请求只产生一次副作用。
幂等设计的整体思路四月有一篇概念文;这篇只干一件事:把幂等键的完整实现拆开,包括那些概念文没展开的部分。
哪些操作需要幂等键
- POST 创建:订单、支付发起、开通会员——重复执行有资金或状态后果的写操作
- 支付回调:渠道方按合同会重推通知,渠道的 event id 就是现成的幂等键,作用域按渠道加商户号切
- GET 的边界:HTTP 语义里 GET 是 safe method,天然幂等的前提是它真的没有副作用。项目里总有「顺手写个统计、更新 last_login」的 GET,而浏览器预取、CDN、爬虫都会替你「重试」GET。有副作用的读,先改成 POST,再谈别的
表结构:唯一约束是最后防线
幂等键表本身不复杂,重要的是每列都有明确用途:
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
op TEXT NOT NULL, -- 操作类型:order.create / payment.callback
idem_key TEXT NOT NULL, -- 客户端提供的键
request_hash TEXT NOT NULL, -- 请求体指纹:同键不同参的检测依据
status TEXT NOT NULL DEFAULT 'processing',
response_status INT, -- 重放时还原的状态码
response_body JSONB, -- 重放时还原的响应体
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL -- TTL 依据,必须长于最大重试窗口
);
-- 作用域 = 用户 + 操作 + 键;全局唯一键会让 A 用户的键挡住 B 用户的同名键
CREATE UNIQUE INDEX uq_idempotency ON idempotency_keys (user_id, op, idem_key);
唯一约束兜底的意义:并发、重试、代码 bug 最终都撞在这条索引上。应用层的查重逻辑可以优化掉,这条索引不能省。
幂等记录和业务变更写进同一个事务
幂等表和业务表分属两个存储、两个事务,中间一定存在崩溃窗口:键记了、订单没建,或反过来。唯一可靠的做法是让它们同生共死:
package idemrepo
import (
"context"
"github.com/jackc/pgx/v5"
)
type Record struct {
Status string
ResponseStatus int
ResponseBody []byte
RequestHash string
}
// Acquire 在事务内抢占幂等键。
// first=true 表示我们是第一个到达的请求,业务继续在本事务内执行
func (r *Repo) Acquire(ctx context.Context, tx pgx.Tx, userID int64, op, key, reqHash string) (rec Record, first bool, err error) {
tag, err := tx.Exec(ctx, `
INSERT INTO idempotency_keys (user_id, op, idem_key, request_hash, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '72 hours')
ON CONFLICT (user_id, op, idem_key) DO NOTHING`,
userID, op, key, reqHash)
if err != nil {
return rec, false, err
}
first = tag.RowsAffected() == 1
// 冲突时读出来的是先到请求已提交的结果
err = tx.QueryRow(ctx, `
SELECT status, COALESCE(response_status, 0), response_body, request_hash
FROM idempotency_keys
WHERE user_id = $1 AND op = $2 AND idem_key = $3`,
userID, op, key).Scan(&rec.Status, &rec.ResponseStatus, &rec.ResponseBody, &rec.RequestHash)
return rec, first, err
}
// Finish 把最终响应写回幂等记录,与业务变更同一个事务提交
func (r *Repo) Finish(ctx context.Context, tx pgx.Tx, userID int64, op, key, status string, httpStatus int, body []byte) error {
_, err := tx.Exec(ctx, `
UPDATE idempotency_keys
SET status = $4, response_status = $5, response_body = $6
WHERE user_id = $1 AND op = $2 AND idem_key = $3`,
userID, op, key, status, httpStatus, body)
return err
}
handler 侧的全流程,重点在四个分支:
func (s *Server) handleCreateOrder(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" || len(key) > 128 {
http.Error(w, `{"error":"missing Idempotency-Key"}`, http.StatusBadRequest)
return
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
reqHash := sha256Hex(body) // 对原始 body 取指纹,见「同键不同 body」一节
userID := currentUser(r)
// 同键请求正在执行时,第二个请求会在唯一索引上等第一个事务提交;
// 给这一步单独设短超时,等待超限就让路,别耗尽整个请求的预算
acqCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
tx, err := s.pool.Begin(acqCtx)
if err != nil {
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
defer tx.Rollback(context.Background()) // 成功路径 Commit 之后它是 no-op
rec, first, err := s.idem.Acquire(acqCtx, tx, userID, "order.create", key, reqHash)
if err != nil { // 多半是同键请求还在执行,等不到它提交
w.Header().Set("Retry-After", "2")
http.Error(w, `{"error":"idempotency_key_in_flight"}`, http.StatusConflict)
return
}
if !first {
if rec.Status == "processing" { // 两阶段变体下会出现:已有请求在途
w.Header().Set("Retry-After", "2")
http.Error(w, `{"error":"idempotency_key_in_flight"}`, http.StatusConflict)
return
}
if rec.RequestHash != reqHash { // 键被复用,但请求不是同一个
http.Error(w, `{"error":"key_reused_with_different_body"}`, http.StatusUnprocessableEntity)
return
}
// 重放:原样还回上次的完整响应,包括上次的失败
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(rec.ResponseStatus)
_, _ = w.Write(rec.ResponseBody)
return
}
order, bizErr := s.orders.CreateInTx(acqCtx, tx, userID, body)
if bizErr != nil {
if isDeterministic(bizErr) { // 参数错、余额不足:重放等价,失败也记下来
resp := errorBody(bizErr)
_ = s.idem.Finish(acqCtx, tx, userID, "order.create", key, "failed", bizErr.HTTPStatus, resp)
_ = tx.Commit(acqCtx)
w.WriteHeader(bizErr.HTTPStatus)
_, _ = w.Write(resp)
return
}
_ = tx.Rollback(r.Context()) // 瞬时故障:不留记录,客户端重试等价于首次执行
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
resp, _ := json.Marshal(order)
_ = s.idem.Finish(acqCtx, tx, userID, "order.create", key, "succeeded", http.StatusCreated, resp)
if err := tx.Commit(acqCtx); err != nil { // 提交失败:订单与幂等记录一起回滚,可安全重试
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(resp)
}
同键并发:一个执行,另一个等待或让路
Postgres 的行为值得单独说清楚:第二个事务的 INSERT 撞上未提交的冲突行时,ON CONFLICT DO NOTHING 也不能跳过——它会等第一个事务的结局。第一个提交,第二个读到最终结果并重放;第一个回滚,第二个的 INSERT 生效、接棒执行。
所以「等待结果」是数据库白送的,「让路」靠 Acquire 的 3 秒超时转成 409。如果业务操作很长(支付网关要 30 秒),单事务方案会让并发同键请求集体等 3 秒再拿 409——这时改用两阶段:先在独立事务提交 processing 行,再执行业务、第二个事务写结果。代价是进程崩溃会留下孤儿 processing 行,需要租约超时回收,复杂度上一个台阶。短事务能覆盖的业务,别急着上两阶段。
重放语义:失败也要原样还回去
最容易实现错的一环。正确的重放是「上次的完整响应」:上次的 status code 加上次的响应体。客户端第一次拿到 402 余额不足,超时重试后却拿到 200 成功——这不是幂等,这是惊悚片。
失败要不要存,判断标准只有一条:同样的请求再执行一遍,结果会不会变。参数校验失败、余额不足是确定性的,存下来重放,客户端拿到稳定结果;DB 超时、下游 5xx 是瞬时的,回滚不留记录,让重试等价于首次执行。
键的作用域与 TTL
作用域前面已经埋了伏笔:user_id + op + idem_key 三列唯一。作用域太宽(全局唯一键),不同用户互相顶掉;太窄(把时间戳掺进键里),同一个重试会被当成新操作。
TTL 要长于最大重试窗口,这个窗口比直觉长:客户端退避重试是分钟级,对账补单、运营手动重放可能是天级。行业参考是 Stripe 保留 24 小时;支付场景建议按自己的对账周期放宽,72 小时是常见起点。清理只删已终结的记录,并且分批:
// 定时任务分批删除,避免大事务和长锁;expires_at 上要有索引
func (s *Server) cleanupExpired(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM idempotency_keys
WHERE id IN (
SELECT id FROM idempotency_keys
WHERE status <> 'processing' AND expires_at < now()
LIMIT 5000
)`)
return err
}
同键不同 body:用 422 拒绝
指纹用 SHA-256 直接对原始 body 算,别做字段级「规范化」:漏掉排序、浮点格式差异、可选字段的默认值,任何一处都会让同一个请求算出两个指纹。检测到键被复用但指纹不同,返回 422:这个键上一次属于另一个请求,宁可拒绝,也不能把上次的响应蒙混过关——那等于把 A 的订单还给了 B。
常见坑点
1. 幂等存储和业务库分离
键在 Redis、订单在 Postgres,两个事务之间必然有崩溃窗口,窗口内的重试就是重复创建。真要跨存储,就得上 outbox 或对账补偿,复杂度是另一个量级。能放同一个库,就放同一个库。
2. 只在内存里做幂等
用 map 记一下键就返回?进程重启全丢,多副本各自为政,重复照旧。内存结构最多作为数据库幂等表前面的 L1 缓存,正确性永远由那条唯一索引保证。
3. 热点键的锁竞争
回调场景里的固定键(比如按商户号)会把所有并发压到一行索引上:请求串行化、连接池被等锁的请求占满。入口处给每次投递生成唯一键(渠道 event id 就是现成的),让热点在入口摊开,而不是在幂等表上排队。
4. 响应体快照里的敏感信息
response_body 实际是一份「谁请求了什么、拿到了什么」的日志,token、脱敏不完整的手机号都会被存下来,并随重放再次外发。存最小投影加重放时重新渲染,或对快照列加密,并给这张表单独设访问权限。
总结
幂等键的完整链路就五件事:同库同事务、唯一索引兜底、并发让路、失败可重放、TTL 盖过重试窗口。
每一件都不难,难的是承认「请求会重复」是常态而不是事故——在这个前提下,幂等键不过是把网络的「至少一次」收窄成业务的「恰好一次」。
客户端可以重试千百次,服务端只需要认真地记一次账。