一笔订单被创建了两次,两次都返回 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 盖过重试窗口。

每一件都不难,难的是承认「请求会重复」是常态而不是事故——在这个前提下,幂等键不过是把网络的「至少一次」收窄成业务的「恰好一次」。


客户端可以重试千百次,服务端只需要认真地记一次账。