Skip to content

幂等设计:超时以后,怎样安全地再试一次 ​

客户端提交了一次订单,服务端已经写入数据库,但返回响应前连接断了。客户端只能看到超时。如果换一个请求重新提交,可能生成第二张订单;如果直接告诉用户失败,又可能让用户看不见已经成立的订单。

幂等设计要解决的正是这种不确定性:同一个业务意图可以多次送达,但不重复产生本来只该发生一次的效果。要做到这一点,服务端得认出请求身份,原子地决定谁能执行,并在重试时找回结果。仅仅加一个 Redis 锁或请求头,还没有完成这些工作。[1]

把“相同请求”定义成一个协议 ​

HTTP 规范中,幂等讨论的是重复相同请求后的预期服务器效果。它不要求每次响应一模一样:第一次 DELETE 可以删除资源并返回成功,第二次可以报告资源已经不存在。业务 API 也可以提供更强的约定,例如相同幂等键返回保存的结果;两种含义要分开写。[2]

概念它能回答的问题它单独回答不了的问题
业务唯一标识这笔订单、这条流水是哪一个业务实体?创建它的请求是否正在执行?
幂等键这是不是同一次逻辑操作的再次送达?载荷是否被修改,结果保存在哪里?
唯一约束是否已经存在相同受约束值?外部接口的副作用是否发生?
状态机当前请求可以执行、等待、重放还是核对?状态迁移是否和业务更新原子提交?
去重表已处理或正在处理的请求记录在哪里?何时可以安全清除记录?
一次性 Token谁持有执行某次操作的资格?消费 Token 后崩溃了,业务究竟完成没有?

幂等键通常由客户端为一次业务意图生成。发送失败后保留原键,用户明确发起另一笔操作才生成新键。不能只对请求 JSON 做哈希就认定是同一意图:用户可能确实要买两份内容相同的商品。请求身份和请求内容摘要有不同用途,前者区分操作,后者检查键是否被错误复用。[1]

一个实用的键空间是 (tenant_id, operation, idempotency_key)。租户和操作来自已经认证、授权的上下文,不能允许用户随意指定另一个租户来读取其重放结果。摘要应包含会改变业务结果的字段,以及需要纳入身份的 API 版本;忽略追踪 ID 等非业务字段的规则要写进协议。原始 JSON 字节哈希会把字段顺序、空白也算成差异;要么要求重试保留原始字节,要么先解析、校验,再按明确的规范构造摘要。不要未经定义就声称两个 JSON “语义相同”。

一个请求不是只有“见过”和“没见过” ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
stateDiagram-v2
    [*] --> Absent
    Absent --> Pending: 原子占有键并保存摘要
    Pending --> Succeeded: 业务效果与结果原子提交
    Pending --> FailedFinal: 确定未成功且不再执行
    Pending --> Unknown: 外部结果无法确认
    Unknown --> Succeeded: 对账确认已经成功
    Unknown --> Pending: 确认未执行且获准重试
    Succeeded --> Absent: 约定的保留期结束
    FailedFinal --> Absent: 按协议清理
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
stateDiagram-v2
    [*] --> Absent
    Absent --> Pending: 原子占有键并保存摘要
    Pending --> Succeeded: 业务效果与结果原子提交
    Pending --> FailedFinal: 确定未成功且不再执行
    Pending --> Unknown: 外部结果无法确认
    Unknown --> Succeeded: 对账确认已经成功
    Unknown --> Pending: 确认未执行且获准重试
    Succeeded --> Absent: 约定的保留期结束
    FailedFinal --> Absent: 按协议清理

这里的 Unknown 是业务结果未知,不是数据库枚举的通用标准。只有跨进程副作用或执行权恢复使“是否完成”不清楚时,才需要这类状态或等价的对账流程。不同业务可以用不同名字,但不能丢掉这条路径。

重复请求看到的状态建议的处理语义不能做的事
不存在原子取得执行权先 SELECT,再无约束地 INSERT
Pending,同摘要有界等待,或返回处理中并给查询入口再执行一次,和原请求竞争
Succeeded,同摘要重放已保存结果或返回同一业务资源返回当前动态状态却宣称是原响应
任意存在状态,不同摘要明确冲突静默忽略新金额、新收货地址等变化
FailedFinal按协议重放确定失败随便把永久错误当临时错误再做
Unknown查询下游、查业务流水、进入核对因为本地超时就把记录删掉

失败是否缓存也属于接口协议。Stripe API v1 在端点开始执行后保存首次状态码与响应体,包括 500;参数验证失败和并发冲突等没有进入执行的情况另作处理。其当前文档对 API v2 的行为又有不同说明。因此“幂等请求一定缓存所有失败”并不成立,应按所用 API 版本核对。[3]

唯一约束把并发竞争交给真正能裁决的一层 ​

以下代码有竞态,即使 exists 和 insert 各自线程安全也没有用:

go
if !exists(key) {
    doBusiness()
    insert(key)
}

两个请求可以同时看到不存在,然后都执行。用单进程 Mutex 可以保护这一进程;多实例同时访问数据库时,需要数据库的唯一约束和事务来裁决,而非各进程各锁一次。

在 PostgreSQL 18 中,可以对 (tenant_id, operation, idem_key) 建组合主键,用 INSERT ... ON CONFLICT DO NOTHING 尝试取得记录。唯一约束负责阻止重复键,事务负责让业务更新与去重结果一起提交。DO NOTHING 没插入一行,不代表业务成功;调用方还要读取已有记录、比较摘要并检查状态。并发事务与隔离级别也会影响等待和可见性,不能把一条 UPSERT 当成整个幂等协议。[4][5]

如果业务记录和去重记录在同一数据库,常见做法是:

  1. 开启事务,尝试写入唯一键记录。
  2. 只有取得执行权的事务更新业务表。
  3. 同事务写入可重放的结果和完成状态。
  4. 提交后返回响应。提交结果未知时,通过原键查询和核对。

这样业务更新和完成记录不会正常地出现“一个提交、一个没提交”的分裂。代价是事务持有资源,长事务会增加锁等待。不要把慢外部 RPC 包在这个事务里,然后假设远端也参加了本地回滚。[6]

有时为了让第二个请求看见 Pending,会先提交占位记录,再开始业务。这会引入“占位成功后进程崩溃”的恢复窗口。需要执行租约、接管规则和 fencing:新执行者使用递增代际标识,旧执行者迟到后不能再提交。租约过期只是执行资格变了,不能证明原副作用没有发生。外部系统若不检查代际或幂等键,本地 fencing 也不能约束它。

用一个本地模型,看清并发、重放和到期 ​

文末 Go 程序用 Mutex 把状态表和一个计数余额放在同一临界区。它是可运行的状态机模型,没有持久化,也没有连接 PostgreSQL;进程崩溃会丢状态。这样做是为了单独观察协议,不把内存测试冒充数据库验证。

go
e, status := s.Start(k, []byte("amount=10"), now)
fmt.Println("first:", status)
_, status = s.Start(k, []byte("amount=10"), now)
fmt.Println("concurrent duplicate:", status)
response, err := s.Commit(k, e.Generation, 10, now, time.Hour)
fmt.Println("commit:", response, err)

实际输出:

text
first: claimed
concurrent duplicate: pending
commit: balance=10 <nil>

第二次调用没有取得执行权。Commit 在同一个锁内加余额、写响应、改状态;100 个并发请求的测试最终只获得一次执行权,余额只增加 1。这个结论仅覆盖模型的原子区和测试路径。

再发同键同内容、同键不同内容:

text
retry: replay balance=10 balance: 10
changed payload: conflict

重放没有再次加余额。不同内容也没有被当作一次合法重试。模型额外检查代际编号:成功记录过期并生成新执行者后,旧执行者不能用旧编号完成新请求。完整测试验证这条负路径。

生产中的摘要必须由服务端根据通过验证的业务输入计算,业务更新也必须来自同一份输入。模型为便于观察把 payload 与 delta 分开传参,因此不是可以直接暴露给不可信客户端的 API;照搬它而不绑定二者会破坏协议。

去重记录的寿命,就是保证的边界 ​

文末模型在成功一小时后允许再次占有原键:

text
after retention: claimed

这不是测试失败,而是用输出来暴露保留期限:记录已经忘了这次操作,系统不能再靠它判断是不是旧请求。Stripe API v1 文档也明确说明,键清理后重用会发起新请求;该产品的最短保留时间是其自己的约定,不应照抄成所有系统的 24 小时标准。[3]

保留期至少要考虑客户端重试上限、消息积压时间、离线终端重连、人工补发、灾备恢复后旧任务重放。真正不能重复的业务还应保留业务级唯一流水,例如一张订单只允许一条某类型支付确认。去重表可以清理,业务不变量仍应有适当的长期约束。

清理也不能只看“创建时间很久了”。Pending 可能还在执行,Unknown 可能需要核对。示例只使成功记录过期,未知状态一天后仍返回:

text
unresolved external result: unknown

这会带来存储增长,因此生产需要独立的核对和告警流程。给未知记录加 TTL 然后自动重新执行,可以暂时减少表大小,却重新打开了重复副作用的窗口。清理水位、归档和人工处理方式都属于协议成本。

一次性 Token 机制,难点在“消费之后” ​

一个常见流程是服务端先发 Token,客户端提交表单时带回,服务端原子地消费它。Token 可以绑定用户、操作、载荷摘要和到期时间,用难以猜测的随机值表示。Token 是一次业务资格,不等于登录凭证,也不天然提供 CSRF 防护;安全功能需要各自的校验语义。

实现能防住什么剩下的窗口
先查 Token 存在,再删除无可靠并发保证两个请求都可能先查到
原子删除并检查是否删到同一 Token 的并发重复消费删除后业务失败,Token 已丢
业务更新与 Token 状态同事务提交同库的消费与业务状态一致外部系统仍不在事务内
Token 状态机保存结果重试时可查询、重放仍需持久化、期限和未知结果恢复

发 Token 本身也可能响应丢失。重新申请一个 Token 是否代表新意图,需要明确:可以让客户端提供创建意图 ID,使“申请 Token”也可幂等;也可以接受多个 Token,但最终用业务唯一键约束副作用。若两次申请都生成独立资格,又没有业务约束,“提交 Token 一次”并不能推出“业务只执行一次”。这是流程直接推导出的限制。

跨系统时,把“已提交”与“已送达”拆开处理 ​

订单入库后要发布消息,如果先提交订单再发消息,可能在两步之间崩溃;如果先发消息再提交订单,消费者可能先看到一张最终没成立的订单。事务 outbox 把业务更新和待发送事件写进同一个数据库事务,再由发布器发送。发布器仍可能在发送后、标记完成前崩溃,因而消费者还要去重。[7]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[订单事务] --> B[业务表更新]
    A --> C[同事务写 outbox]
    C --> D[发布器可重试发送]
    D --> E[消费者按事件 ID 原子去重]
    E --> F[消费者本地业务效果]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[订单事务] --> B[业务表更新]
    A --> C[同事务写 outbox]
    C --> D[发布器可重试发送]
    D --> E[消费者按事件 ID 原子去重]
    E --> F[消费者本地业务效果]

如果调用第三方支付,优先把稳定的业务操作 ID 作为对方支持的幂等键。超时后用同键重试或查询对方状态;不知道结果时保持处理中或核对中。对方既不支持幂等,又不能查操作结果时,仅靠本地表无法可靠区分“未执行”与“已执行但响应丢了”。此时需要业务补偿、人工核对或接受明确的重复风险,不能宣称实现了端到端 exactly-once。

EC2 的 client token 还展示了一个容易漏掉的细节:幂等的作用域可能是区域或可用区,参数不一致会返回冲突。接第三方 API 时,应把作用域、参数比较、期限和响应重放规则一起核对。[8]

上线前要测的是失败窗口 ​

除正常重复请求外,至少构造同键并发、同键不同载荷、提交成功但响应丢失、占位后崩溃、旧执行者迟到、下游结果未知和清理边界。对每条路径写清:余额或订单改了几次,客户端看见什么,记录由谁恢复,恢复需要多长时间。

本次 Go 1.27.2 / Linux amd64 下,模型的 go test、go test -race 和 go vet 均通过;测试覆盖并发单一执行权、隔离租户、冲突拒绝、成功记录到期、旧代际拒绝,以及 Pending/Unknown 不被静默清理。文末 SQL 是 PostgreSQL 18 设计示例,未连接数据库执行;事务隔离、进程崩溃和真实外部支付结果均未实测。

依据 ​

  1. AWS Builders' Library:Making retries safe with idempotent APIs,Late arriving requests、Same client request ID, different intent:请求身份与重试语义。
  2. RFC 9110 §9.2.2:幂等的预期效果定义和自动重试限制。
  3. Stripe:Idempotent requests:2026-10-13 核对;API v1/v2 区别、参数比较、响应重放和保留期限。
  4. PostgreSQL 18:Constraints §5.5.3、§5.5.4:唯一约束和主键。
  5. PostgreSQL 18:INSERT / ON CONFLICT:冲突动作、RETURNING 的返回范围。
  6. PostgreSQL 18:Transactions、Transaction Isolation:事务原子性与并发可见性;本文未做数据库实测。
  7. AWS Prescriptive Guidance:Transactional outbox pattern:双写窗口、事务 outbox 与重复消息。
  8. Amazon EC2:Ensuring idempotency:client token、区域/可用区作用域和参数冲突。
  9. Go 1.27.2 sync.Mutex、crypto/sha256:模型所用互斥和内容摘要;不能据此推导持久化保证。

完整可复现实验 ​

main.go 是本地模型,不发起网络请求。main_test.go 的负例通过断言“应拒绝”来验证,测试本身应该成功。复制三个文件到空目录后执行:

sh
go run .
go test -v ./...
go test -race ./...
go vet ./...

go.mod ​

go
module example.com/idempotency

go 1.27.0

main.go ​

代码块 · 101 行 · 点击收起 / 展开
go
package main

import (
	"crypto/sha256"
	"errors"
	"fmt"
	"sync"
	"time"
)

type Key struct{ Tenant, Operation, ID string }
type Entry struct {
	Hash            [32]byte
	Generation      uint64
	State, Response string
	Expires         time.Time
}
type Store struct {
	mu      sync.Mutex
	entries map[Key]Entry
	next    uint64
	balance int
}

func NewStore() *Store { return &Store{entries: make(map[Key]Entry)} }

// Start and Commit model serializable changes in one process; no durable database is used.
func (s *Store) Start(k Key, payload []byte, now time.Time) (Entry, string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	h := sha256.Sum256(payload)
	if e, ok := s.entries[k]; ok {
		if e.State == "succeeded" && !now.Before(e.Expires) {
			delete(s.entries, k)
		} else {
			if e.Hash != h {
				return e, "conflict"
			}
			if e.State == "succeeded" {
				return e, "replay"
			}
			return e, e.State
		}
	}
	s.next++
	e := Entry{Hash: h, Generation: s.next, State: "pending"}
	s.entries[k] = e
	return e, "claimed"
}
func (s *Store) Commit(k Key, generation uint64, delta int, now time.Time, retention time.Duration) (string, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	e, ok := s.entries[k]
	if !ok || e.Generation != generation || e.State != "pending" {
		return "", errors.New("stale or unresolved owner")
	}
	if retention <= 0 {
		return "", errors.New("retention must be positive")
	}
	s.balance += delta
	e.State = "succeeded"
	e.Response = fmt.Sprintf("balance=%d", s.balance)
	e.Expires = now.Add(retention)
	s.entries[k] = e
	return e.Response, nil
}
func (s *Store) MarkUnknown(k Key, generation uint64) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	e, ok := s.entries[k]
	if !ok || e.Generation != generation || e.State != "pending" {
		return errors.New("stale owner")
	}
	e.State = "unknown"
	s.entries[k] = e
	return nil
}
func (s *Store) Balance() int { s.mu.Lock(); defer s.mu.Unlock(); return s.balance }
func main() {
	s := NewStore()
	k := Key{"tenant-a", "credit", "op-1"}
	now := time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC)
	payload := []byte("amount=10")
	e, status := s.Start(k, payload, now)
	fmt.Println("first:", status)
	_, status = s.Start(k, payload, now)
	fmt.Println("concurrent duplicate:", status)
	response, err := s.Commit(k, e.Generation, 10, now, time.Hour)
	fmt.Println("commit:", response, err)
	e, status = s.Start(k, payload, now)
	fmt.Println("retry:", status, e.Response, "balance:", s.Balance())
	_, status = s.Start(k, []byte("amount=99"), now)
	fmt.Println("changed payload:", status)
	_, status = s.Start(k, payload, now.Add(time.Hour))
	fmt.Println("after retention:", status)
	k2 := Key{"tenant-a", "external-credit", "op-2"}
	e, _ = s.Start(k2, payload, now)
	_ = s.MarkUnknown(k2, e.Generation)
	_, status = s.Start(k2, payload, now.Add(24*time.Hour))
	fmt.Println("unresolved external result:", status)
}

main_test.go ​

代码块 · 83 行 · 点击收起 / 展开
go
package main

import (
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

func TestConcurrentExactlyOneLocalClaim(t *testing.T) {
	s := NewStore()
	k := Key{"a", "add", "1"}
	var claims atomic.Int64
	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() {
			e, status := s.Start(k, []byte("1"), time.Time{})
			if status == "claimed" {
				claims.Add(1)
				if _, err := s.Commit(k, e.Generation, 1, time.Time{}, time.Hour); err != nil {
					t.Error(err)
				}
			}
		})
	}
	wg.Wait()
	if claims.Load() != 1 || s.Balance() != 1 {
		t.Fatal(claims.Load(), s.Balance())
	}
}
func TestPendingAndUnknownNeverSilentlyExpire(t *testing.T) {
	s := NewStore()
	k := Key{"a", "add", "1"}
	e, _ := s.Start(k, nil, time.Time{})
	if _, status := s.Start(k, nil, time.Time{}.Add(100*time.Hour)); status != "pending" {
		t.Fatal(status)
	}
	if err := s.MarkUnknown(k, e.Generation); err != nil {
		t.Fatal(err)
	}
	if _, status := s.Start(k, nil, time.Time{}.Add(100*time.Hour)); status != "unknown" {
		t.Fatal(status)
	}
	if _, err := s.Commit(k, e.Generation, 1, time.Time{}, time.Hour); err == nil {
		t.Fatal("unknown cannot blindly commit")
	}
}
func TestConflictRetentionAndStaleOwner(t *testing.T) {
	s := NewStore()
	k := Key{"a", "add", "1"}
	now := time.Time{}
	e, _ := s.Start(k, []byte("1"), now)
	if _, status := s.Start(k, []byte("2"), now); status != "conflict" {
		t.Fatal(status)
	}
	if _, err := s.Commit(k, e.Generation, 1, now, time.Hour); err != nil {
		t.Fatal(err)
	}
	if _, status := s.Start(k, []byte("1"), now.Add(time.Hour-time.Nanosecond)); status != "replay" {
		t.Fatal(status)
	}
	next, status := s.Start(k, []byte("1"), now.Add(time.Hour))
	if status != "claimed" {
		t.Fatal(status)
	}
	if _, err := s.Commit(k, e.Generation, 10, now, time.Hour); err == nil {
		t.Fatal("stale generation accepted")
	}
	if _, err := s.Commit(k, next.Generation, 1, now, time.Hour); err != nil {
		t.Fatal(err)
	}
	if s.Balance() != 2 {
		t.Fatal(s.Balance())
	}
}
func TestTenantScope(t *testing.T) {
	s := NewStore()
	for _, tenant := range []string{"a", "b"} {
		if _, status := s.Start(Key{tenant, "add", "1"}, nil, time.Time{}); status != "claimed" {
			t.Fatal(status)
		}
	}
}

schema.sql ​

sql
-- PostgreSQL 18 illustrative schema, not executed in this experiment.
-- The application must bind request_hash to the validated business input.
CREATE TABLE idempotency (
    tenant_id text NOT NULL,
    operation text NOT NULL,
    idem_key text NOT NULL,
    request_hash bytea NOT NULL,
    state text NOT NULL CHECK (state IN ('pending','succeeded','failed_final','unknown')),
    generation bigint NOT NULL,
    response_status integer,
    response_body bytea,
    expires_at timestamptz,
    PRIMARY KEY (tenant_id, operation, idem_key)
);
-- Example claim statement. Placeholder values are supplied by the application.
-- INSERT INTO idempotency(tenant_id, operation, idem_key, request_hash, state, generation)
-- VALUES ($1, $2, $3, $4, 'pending', 1)
-- ON CONFLICT DO NOTHING
-- RETURNING generation;
-- This is not a full transaction protocol: business updates and outcome storage
-- must be placed in the same transaction when they share this database.

保留各书来源与许可 · 示例运行条件以正文为准