主题
幂等设计:超时以后,怎样安全地再试一次
客户端提交了一次订单,服务端已经写入数据库,但返回响应前连接断了。客户端只能看到超时。如果换一个请求重新提交,可能生成第二张订单;如果直接告诉用户失败,又可能让用户看不见已经成立的订单。
幂等设计要解决的正是这种不确定性:同一个业务意图可以多次送达,但不重复产生本来只该发生一次的效果。要做到这一点,服务端得认出请求身份,原子地决定谁能执行,并在重试时找回结果。仅仅加一个 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: 按协议清理
这里的 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]
如果业务记录和去重记录在同一数据库,常见做法是:
- 开启事务,尝试写入唯一键记录。
- 只有取得执行权的事务更新业务表。
- 同事务写入可重放的结果和完成状态。
- 提交后返回响应。提交结果未知时,通过原键查询和核对。
这样业务更新和完成记录不会正常地出现“一个提交、一个没提交”的分裂。代价是事务持有资源,长事务会增加锁等待。不要把慢外部 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[消费者本地业务效果]
如果调用第三方支付,优先把稳定的业务操作 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 设计示例,未连接数据库执行;事务隔离、进程崩溃和真实外部支付结果均未实测。
依据
- AWS Builders' Library:Making retries safe with idempotent APIs,Late arriving requests、Same client request ID, different intent:请求身份与重试语义。
- RFC 9110 §9.2.2:幂等的预期效果定义和自动重试限制。
- Stripe:Idempotent requests:2026-10-13 核对;API v1/v2 区别、参数比较、响应重放和保留期限。
- PostgreSQL 18:Constraints §5.5.3、§5.5.4:唯一约束和主键。
- PostgreSQL 18:INSERT / ON CONFLICT:冲突动作、RETURNING 的返回范围。
- PostgreSQL 18:Transactions、Transaction Isolation:事务原子性与并发可见性;本文未做数据库实测。
- AWS Prescriptive Guidance:Transactional outbox pattern:双写窗口、事务 outbox 与重复消息。
- Amazon EC2:Ensuring idempotency:client token、区域/可用区作用域和参数冲突。
- 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.0main.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.