一、从一次真实的重复扣款事故说起
2022 年某电商平台在”双十一”大促期间,因网关超时重试机制配置不当,导致支付服务在 3 分钟内对同一笔订单执行了两次扣款。受影响用户超过 1.2 万人,平台不得不在 48 小时内完成逐笔退款与补偿,直接经济损失超过 800 万元。事后复盘发现,根本原因并非网络故障本身,而是支付接口缺乏幂等性(Idempotency)保护。
这一事故揭示了分布式系统中一个基本问题:网络是不可靠的。请求可能因为超时、重试、消息重放等原因被执行多次。如果系统不具备幂等性保障,重复执行将带来数据不一致、资金损失、业务逻辑错乱等严重后果。
幂等性设计并非锦上添花的优化手段,而是分布式环境下保障系统正确性的基本要求。本文将从数学定义出发,深入探讨幂等键(Idempotency Key)的设计策略、多种实现方案的权衡、以及在支付等关键场景中的工程实践。
二、幂等性的严格定义
2.1 数学定义
在数学中,幂等性描述的是一种运算性质:对某个操作执行一次与执行多次,产生的效果完全相同。形式化表达为:
f(f(x)) = f(x)
推广到任意次数:
f^n(x) = f(x),其中 n >= 1
典型的幂等运算包括:取绝对值函数
abs(abs(x)) = abs(x)、集合的并集操作
A ∪ A = A、以及布尔逻辑中的
true AND true = true。
2.2 计算机科学中的定义
在计算机科学领域,幂等性的定义需要更加精确。一个操作是幂等的,当且仅当:
- 相同的输入:请求参数完全一致。
- 相同的副作用:对系统状态的改变只发生一次。
- 相同的响应:每次调用返回相同的结果。
需要特别区分两个概念:
- 安全性(Safety):操作不会改变服务器状态。
GET请求是安全的,PUT请求不是。 - 幂等性(Idempotency):多次执行与一次执行效果相同。
PUT请求是幂等的但不是安全的。
安全 ⊂ 幂等
安全操作一定是幂等的,幂等操作不一定是安全的。
2.3 幂等性与重试的关系
在分布式系统中,以下场景都可能导致请求被重复发送:
| 场景 | 触发条件 | 典型频率 |
|---|---|---|
| 客户端超时重试 | 请求超时未收到响应 | 高 |
| 负载均衡器重试 | 后端返回 502/503 | 中 |
| 消息队列重投 | 消费者未确认(ACK) | 高 |
| 网络分区恢复 | 分区恢复后消息重放 | 低 |
| 用户重复提交 | 用户多次点击提交按钮 | 高 |
三、HTTP 方法的幂等语义
3.1 RFC 7231 规范定义
根据 RFC 7231(HTTP/1.1 语义与内容)的定义,HTTP 方法的幂等性与安全性如下:
| HTTP 方法 | 安全 | 幂等 | 说明 |
|---|---|---|---|
| GET | 是 | 是 | 读取资源,不产生副作用 |
| HEAD | 是 | 是 | 与 GET 相同,但不返回响应体 |
| OPTIONS | 是 | 是 | 查询支持的方法 |
| PUT | 否 | 是 | 用完整资源替换目标,多次执行结果相同 |
| DELETE | 否 | 是 | 删除资源,多次删除等同于一次删除 |
| POST | 否 | 否 | 创建资源或触发处理,天然非幂等 |
| PATCH | 否 | 否 | 部分更新,视实现而定 |
3.2 为什么 POST 天然非幂等
POST
请求通常用于创建新资源。每次执行都会在系统中产生一个新的实体:
POST /orders
第一次:创建订单 order-001
第二次:创建订单 order-002(重复!)
而 PUT
请求用整个资源替换目标,无论执行多少次,结果始终是目标被替换为相同的内容:
PUT /orders/order-001
第一次:创建或替换订单 order-001
第二次:替换订单 order-001(结果不变)
3.3 DELETE 的幂等性细节
DELETE
的幂等性存在一个容易混淆的地方:第一次删除返回
200 OK,第二次删除返回
404 Not Found。响应码不同,但这仍然是幂等的,因为幂等性关注的是服务器状态而非响应内容。两次操作后,服务器状态都是”该资源不存在”,因此语义上是幂等的。
// Go:DELETE 处理示例
func handleDelete(w http.ResponseWriter, r *http.Request) {
id := extractID(r)
result, err := db.Exec("DELETE FROM orders WHERE id = ?", id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
// 资源已不存在,但操作仍是幂等的
http.Error(w, "not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}3.4 PATCH 的特殊性
PATCH 请求是否幂等取决于具体实现。如果
PATCH 使用 JSON Merge Patch
语义(设置为某个固定值),则是幂等的;如果使用增量操作语义(例如”余额加
100”),则不是幂等的:
// 幂等的 PATCH:设置绝对值
{ "status": "confirmed" }
// 非幂等的 PATCH:增量操作
{ "op": "increment", "path": "/balance", "value": 100 }四、幂等键设计:Stripe 的工程实践
4.1 Stripe 幂等键概述
Stripe 是全球领先的支付平台,其幂等性设计被广泛认为是行业标杆。Stripe 的幂等机制基于以下核心要素:
- 客户端在请求头(Header)中携带
Idempotency-Key。 - 服务端将该键与请求参数、响应结果绑定存储。
- 相同键的后续请求直接返回缓存的响应,不重复执行业务逻辑。
- 键的有效期为 24 小时,过期后自动清除。
POST /v1/charges
Idempotency-Key: ord_20260413_abc123
Content-Type: application/json
{
"amount": 2000,
"currency": "usd",
"source": "tok_visa"
}
4.2 幂等键的生命周期
下面的流程图展示了 Stripe 风格的幂等键处理流程:
flowchart TD
A[客户端发送请求<br/>携带 Idempotency-Key] --> B{查询幂等键<br/>是否已存在}
B -->|不存在| C[加锁:SETNX 幂等键]
C --> D{加锁成功}
D -->|是| E[执行业务逻辑]
E --> F{执行成功}
F -->|是| G[存储响应结果<br/>设置 24 小时过期]
G --> H[返回响应 201]
F -->|否| I[删除幂等键<br/>允许重试]
I --> J[返回错误 500]
D -->|否| K[等待并重试查询]
K --> B
B -->|已存在且已完成| L{请求参数<br/>是否一致}
L -->|一致| M[返回缓存响应 200]
L -->|不一致| N[返回错误 422<br/>参数冲突]
B -->|已存在但执行中| K
4.3 幂等键的关键设计决策
键的生成策略
幂等键应由客户端生成,而非服务端分配。常见的生成方式:
// Go:幂等键生成策略
// 方式一:UUID v4,全局唯一
import "github.com/google/uuid"
key := uuid.New().String()
// 例如:550e8400-e29b-41d4-a716-446655440000
// 方式二:业务语义键,可读性强且天然去重
key := fmt.Sprintf("order_%s_pay_%s", orderID, userID)
// 例如:order_20260413001_pay_user123
// 方式三:请求内容哈希,自动去重
import "crypto/sha256"
body := []byte(`{"amount":2000,"currency":"usd"}`)
hash := sha256.Sum256(body)
key := fmt.Sprintf("%x", hash)推荐使用业务语义键,原因如下:
| 生成方式 | 可读性 | 天然去重 | 客户端负担 |
|---|---|---|---|
| UUID v4 | 低 | 否 | 需持久化键 |
| 业务语义键 | 高 | 是 | 无需额外存储 |
| 内容哈希 | 低 | 是 | 需保证参数序列化一致 |
过期策略
Stripe 选择 24 小时过期是经过权衡的:
- 太短(如 5 分钟):客户端重试窗口可能不够,特别是跨时区或离线后重试的场景。
- 太长(如 7 天):存储成本高,且业务状态可能已变化(如订单已取消)。
- 24 小时:覆盖绝大多数重试场景,存储压力可控。
参数校验
当幂等键已存在时,Stripe
会比对请求参数是否一致。如果不一致,返回
422 Unprocessable Entity
错误。这是为了防止客户端错误地复用幂等键发起不同的请求:
// Java:参数一致性校验
public class IdempotencyValidator {
public void validate(
IdempotencyRecord record,
String requestBody) throws IdempotencyConflictException {
String storedHash = record.getRequestBodyHash();
String currentHash = sha256(requestBody);
if (!storedHash.equals(currentHash)) {
throw new IdempotencyConflictException(
"Idempotency key already used with different parameters. "
+ "Key: " + record.getIdempotencyKey()
);
}
}
private String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}4.4 响应缓存的数据模型
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
request_method VARCHAR(10) NOT NULL,
request_path VARCHAR(512) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_code INT,
response_body TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'processing',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
CONSTRAINT chk_status CHECK (status IN ('processing', 'completed', 'failed'))
);
CREATE INDEX idx_idempotency_key ON idempotency_keys (key);
CREATE INDEX idx_idempotency_expires ON idempotency_keys (expires_at);五、实现方案对比
在实际工程中,幂等性有多种实现方案,各有优劣。以下是主流方案的全面对比:
| 维度 | 数据库唯一约束 | 分布式锁(Redis) | 幂等键表 | 消息去重表 | Token 预分配 |
|---|---|---|---|---|---|
| 实现复杂度 | 低 | 中 | 中 | 中 | 高 |
| 性能开销 | 中(数据库写入) | 低(内存操作) | 中 | 中 | 低(校验阶段) |
| 一致性保障 | 强(事务保障) | 弱(锁可能失效) | 强(可事务化) | 强(可事务化) | 强 |
| 适用场景 | 单数据库系统 | 高并发短时去重 | 通用 API | 消息队列消费 | 表单提交防重 |
| 存储依赖 | 关系数据库 | Redis 集群 | 关系数据库或缓存 | 关系数据库 | 关系数据库和缓存 |
| 过期管理 | 随业务数据 | TTL 自动过期 | 需定时清理 | 需定时清理 | 使用后删除 |
| 跨服务支持 | 差 | 好 | 好 | 好 | 中 |
| 响应缓存 | 否 | 否 | 是 | 否 | 否 |
| 故障恢复 | 好 | 需谨慎处理 | 好 | 好 | 好 |
选型建议:
- 简单业务:优先使用数据库唯一约束,实现最简单,利用数据库事务保障一致性。
- 高并发 API:使用 Redis 分布式锁 + 幂等键表组合方案,兼顾性能与一致性。
- 消息驱动架构:消息去重表是标准做法,配合消费者确认机制使用。
- 面向用户的表单:Token 预分配可以在前端层面防止重复提交。
- 支付等关键业务:建议组合多种方案形成多层防护。
六、数据库唯一约束方案
6.1 基本原理
利用数据库的唯一约束(Unique
Constraint)保障幂等性,是最直接的实现方式。核心思路是:将幂等键作为业务表的唯一索引,利用数据库的
INSERT ... ON CONFLICT
语义实现”存在则跳过,不存在则插入”。
sequenceDiagram
participant C as 客户端
participant S as 服务端
participant DB as 数据库
C->>S: POST /payments(第一次请求)
S->>DB: INSERT INTO payments ... ON CONFLICT DO NOTHING
DB-->>S: 插入成功(affected = 1)
S-->>C: 201 Created
C->>S: POST /payments(重复请求)
S->>DB: INSERT INTO payments ... ON CONFLICT DO NOTHING
DB-->>S: 已存在(affected = 0)
S->>DB: SELECT * FROM payments WHERE idempotency_key = ?
DB-->>S: 返回已有记录
S-->>C: 200 OK(返回已有结果)
6.2 PostgreSQL 实现
-- PostgreSQL:支付记录表,带幂等键唯一约束
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(255) NOT NULL,
order_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
channel VARCHAR(32),
channel_tx_id VARCHAR(128),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uk_idempotency_key UNIQUE (idempotency_key)
);6.3 Go 实现
// Go:基于数据库唯一约束的幂等支付处理
package payment
import (
"context"
"database/sql"
"fmt"
)
type PaymentService struct {
db *sql.DB
}
func (s *PaymentService) CreatePayment(
ctx context.Context,
idempotencyKey, orderID, userID, currency string,
amount float64,
) (*Payment, error) {
query := `
INSERT INTO payments (idempotency_key, order_id, user_id, amount, currency, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id, created_at
`
var payment Payment
err := s.db.QueryRowContext(ctx, query,
idempotencyKey, orderID, userID, amount, currency,
).Scan(&payment.ID, &payment.CreatedAt)
if err == sql.ErrNoRows {
// ON CONFLICT 命中,返回已有记录
return s.getByIdempotencyKey(ctx, idempotencyKey)
}
if err != nil {
return nil, fmt.Errorf("insert payment: %w", err)
}
// 新建记录,执行实际支付逻辑
if err := s.processPayment(ctx, &payment); err != nil {
return nil, fmt.Errorf("process payment: %w", err)
}
return &payment, nil
}
func (s *PaymentService) getByIdempotencyKey(
ctx context.Context, key string,
) (*Payment, error) {
var p Payment
err := s.db.QueryRowContext(ctx,
`SELECT id, idempotency_key, order_id, user_id, amount,
currency, status, created_at
FROM payments WHERE idempotency_key = $1`, key,
).Scan(&p.ID, &p.IdempotencyKey, &p.OrderID, &p.UserID,
&p.Amount, &p.Currency, &p.Status, &p.CreatedAt)
if err != nil {
return nil, fmt.Errorf("query payment: %w", err)
}
return &p, nil
}
func (s *PaymentService) processPayment(
ctx context.Context, payment *Payment,
) error {
_, err := s.db.ExecContext(ctx,
"UPDATE payments SET status = 'completed', updated_at = NOW() WHERE id = $1",
payment.ID)
return err
}6.4 MySQL 的差异
MySQL 不支持 ON CONFLICT 语法,需要使用
INSERT ... ON DUPLICATE KEY UPDATE 替代:
-- MySQL:等效写法
INSERT INTO payments (idempotency_key, order_id, user_id, amount, currency, status)
VALUES (?, ?, ?, ?, ?, 'pending')
ON DUPLICATE KEY UPDATE id = id;
-- 通过 ROW_COUNT() 判断是否为新插入
-- ROW_COUNT() = 1 表示新插入
-- ROW_COUNT() = 0 表示已存在(ON DUPLICATE KEY 未实际更新)七、分布式锁方案
7.1 Redis SETNX 实现
当系统涉及多个数据库或跨服务调用时,单一数据库的唯一约束无法覆盖全局幂等性。此时可以引入 Redis 分布式锁(Distributed Lock)作为前置去重层。
核心命令是
SET key value NX EX ttl,即只在键不存在时设置值,并指定过期时间(TTL):
// Go:基于 Redis SETNX 的幂等性保障
package idempotency
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var (
ErrRequestInProgress = errors.New("request is being processed")
ErrDuplicateRequest = errors.New("duplicate request")
)
const (
keyPrefix = "idempotency:"
lockTTL = 30 * time.Second // 处理中锁定时长
responseTTL = 24 * time.Hour // 响应缓存时长
)
type CachedResponse struct {
StatusCode int `json:"status_code"`
Body string `json:"body"`
CreatedAt int64 `json:"created_at"`
}
type IdempotencyGuard struct {
rdb *redis.Client
}
func NewIdempotencyGuard(rdb *redis.Client) *IdempotencyGuard {
return &IdempotencyGuard{rdb: rdb}
}
// TryAcquire 尝试获取幂等锁
// 返回值:
// cached - 如果请求已处理过,返回缓存的响应
// acquired - 是否成功获取锁
// err - 错误信息
func (g *IdempotencyGuard) TryAcquire(
ctx context.Context,
idempotencyKey string,
) (cached *CachedResponse, acquired bool, err error) {
lockKey := keyPrefix + "lock:" + idempotencyKey
resultKey := keyPrefix + "result:" + idempotencyKey
// 先检查是否已有处理结果
resultData, err := g.rdb.Get(ctx, resultKey).Bytes()
if err == nil {
var resp CachedResponse
if jsonErr := json.Unmarshal(resultData, &resp); jsonErr == nil {
return &resp, false, nil
}
}
if err != nil && !errors.Is(err, redis.Nil) {
return nil, false, fmt.Errorf("check result: %w", err)
}
// 尝试加锁
ok, err := g.rdb.SetNX(ctx, lockKey, "processing", lockTTL).Result()
if err != nil {
return nil, false, fmt.Errorf("acquire lock: %w", err)
}
if !ok {
return nil, false, ErrRequestInProgress
}
return nil, true, nil
}
// Complete 标记请求已完成,缓存响应
func (g *IdempotencyGuard) Complete(
ctx context.Context,
idempotencyKey string,
statusCode int,
body string,
) error {
lockKey := keyPrefix + "lock:" + idempotencyKey
resultKey := keyPrefix + "result:" + idempotencyKey
resp := CachedResponse{
StatusCode: statusCode,
Body: body,
CreatedAt: time.Now().Unix(),
}
data, err := json.Marshal(resp)
if err != nil {
return fmt.Errorf("marshal response: %w", err)
}
// 使用 Pipeline 原子地存储结果并释放锁
pipe := g.rdb.Pipeline()
pipe.Set(ctx, resultKey, data, responseTTL)
pipe.Del(ctx, lockKey)
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("save result: %w", err)
}
return nil
}
// Rollback 业务失败时释放锁,允许客户端重试
func (g *IdempotencyGuard) Rollback(
ctx context.Context,
idempotencyKey string,
) error {
lockKey := keyPrefix + "lock:" + idempotencyKey
return g.rdb.Del(ctx, lockKey).Err()
}7.2 锁的粒度与超时
分布式锁方案需要特别注意以下问题:
锁超时(Lock Timeout)与业务执行时间的关系
如果锁的 TTL 短于业务处理时间,可能出现以下竞态条件:
时间线:
t0 - 请求 A 获取锁(TTL = 30s)
t0 - 请求 A 开始处理业务
t30 - 锁自动过期
t31 - 请求 B 获取锁(请求 A 仍在执行!)
t31 - 请求 B 开始处理业务
t35 - 请求 A 完成,存储结果
t40 - 请求 B 完成,覆盖结果(数据不一致!)
解决方案是引入锁续期(Lock Renewal)机制,又称为”看门狗”(Watchdog):
// Go:看门狗锁续期
func (g *IdempotencyGuard) startWatchdog(
ctx context.Context,
lockKey string,
interval time.Duration,
) context.CancelFunc {
watchCtx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-watchCtx.Done():
return
case <-ticker.C:
// 每隔 interval 续期一次
g.rdb.Expire(watchCtx, lockKey, lockTTL)
}
}
}()
return cancel
}7.3 Redis 集群下的注意事项
在 Redis 集群(Cluster)模式下,SETNX
操作只在单个节点上执行。如果该节点在锁未同步到从节点前发生故障,主从切换后锁会丢失。对于强一致性要求的场景,可以考虑使用
Redlock 算法,但需要注意 Redlock 本身也存在争议(Martin
Kleppmann 与 Salvatore Sanfilippo 的著名辩论)。
对于支付等关键业务,推荐的做法是:以数据库唯一约束作为最终保障,Redis 分布式锁仅作为性能优化层。
八、消息去重表方案
-- 消息去重表
CREATE TABLE message_dedup (
message_id VARCHAR(128) NOT NULL,
topic VARCHAR(64) NOT NULL,
consumer VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'processing',
processed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (message_id, topic, consumer)
);
-- 定期清理已处理的历史记录
CREATE INDEX idx_dedup_created ON message_dedup (created_at);8.3 Java 实现
// Java:消息去重消费者
import org.springframework.transaction.annotation.Transactional;
public class IdempotentConsumer {
private final JdbcTemplate jdbcTemplate;
private final OrderService orderService;
public IdempotentConsumer(
JdbcTemplate jdbcTemplate,
OrderService orderService) {
this.jdbcTemplate = jdbcTemplate;
this.orderService = orderService;
}
@Transactional
public void consume(Message message) {
String messageId = message.getMessageId();
String topic = message.getTopic();
String consumer = "order-service";
// 尝试插入去重记录
int inserted = jdbcTemplate.update(
"INSERT INTO message_dedup (message_id, topic, consumer, status) "
+ "VALUES (?, ?, ?, 'processing') "
+ "ON CONFLICT (message_id, topic, consumer) DO NOTHING",
messageId, topic, consumer
);
if (inserted == 0) {
// 消息已被处理或正在处理
log.info("重复消息,跳过处理: messageId={}", messageId);
return;
}
try {
// 执行业务逻辑
orderService.processOrder(message.getBody());
// 更新去重记录状态
jdbcTemplate.update(
"UPDATE message_dedup SET status = 'completed', "
+ "processed_at = NOW() "
+ "WHERE message_id = ? AND topic = ? AND consumer = ?",
messageId, topic, consumer
);
} catch (Exception e) {
// 业务处理失败,删除去重记录,允许重试
jdbcTemplate.update(
"DELETE FROM message_dedup "
+ "WHERE message_id = ? AND topic = ? AND consumer = ?",
messageId, topic, consumer
);
throw e;
}
}
}8.4 Kafka 消费者偏移量与幂等性
Kafka 的消费者偏移量(Consumer Offset)提供了一定程度的去重能力,但并非完美。当消费者在处理消息后、提交偏移量前崩溃,重启后会从上次提交的偏移量开始消费,导致消息重复处理。
正常流程:
1. 拉取消息(offset = 100)
2. 处理业务逻辑
3. 提交偏移量(offset = 101)
异常流程:
1. 拉取消息(offset = 100)
2. 处理业务逻辑(成功)
3. 进程崩溃,偏移量未提交
4. 重启,从 offset = 100 重新消费(重复!)
因此,即使使用 Kafka,消费端的幂等性保障仍然是必要的。Kafka 0.11 引入的幂等生产者(Idempotent Producer)解决的是生产端的重复问题,而非消费端。
8.5 去重记录的清理策略
去重表会随时间持续增长,需要定期清理历史记录:
-- 每天凌晨执行,清理 7 天前的已完成记录
DELETE FROM message_dedup
WHERE status = 'completed'
AND created_at < NOW() - INTERVAL '7 days';
-- 清理超时的 processing 状态记录(可能是僵尸记录)
UPDATE message_dedup
SET status = 'timeout'
WHERE status = 'processing'
AND created_at < NOW() - INTERVAL '1 hour';九、支付系统中的幂等设计
9.1 工程案例:某第三方支付平台的幂等架构
某大型第三方支付平台日均处理交易量超过 5000 万笔,其幂等性设计采用了多层防护策略。以下是该平台的架构设计。
整体架构
flowchart TB
subgraph 接入层
GW[API 网关]
end
subgraph 幂等防护层
IK[幂等键检查<br/>Redis 缓存]
end
subgraph 业务处理层
PS[支付服务]
RS[风控服务]
AS[账务服务]
end
subgraph 数据层
PDB[(支付数据库<br/>唯一约束)]
ADB[(账务数据库<br/>唯一约束)]
MQ[消息队列]
end
subgraph 异步处理层
NC[通知消费者<br/>去重表]
SC[结算消费者<br/>去重表]
end
GW -->|1. 提取幂等键| IK
IK -->|2. 首次请求| PS
IK -->|2. 重复请求| GW
PS -->|3. 风控检查| RS
PS -->|4. 创建支付单| PDB
PS -->|5. 记账| AS
AS -->|6. 写入账务| ADB
PS -->|7. 发送事件| MQ
MQ --> NC
MQ --> SC
第一层:API 网关幂等键缓存
网关层使用 Redis 缓存检查幂等键。如果键已存在且有响应缓存,直接返回缓存结果,请求不会到达后端服务。这一层的目标是快速拦截明显的重复请求,降低后端压力。
第二层:支付数据库唯一约束
支付单表以 (merchant_id, merchant_order_no)
为联合唯一索引。即使第一层缓存因 Redis
故障而失效,数据库层仍能保障同一笔商户订单不会被创建两次。
CREATE TABLE payment_orders (
id BIGSERIAL PRIMARY KEY,
payment_no VARCHAR(32) NOT NULL UNIQUE,
merchant_id VARCHAR(32) NOT NULL,
merchant_order_no VARCHAR(64) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL,
channel VARCHAR(32),
channel_trade_no VARCHAR(128),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uk_merchant_order
UNIQUE (merchant_id, merchant_order_no)
);第三层:账务系统幂等
账务系统以支付单号(payment_no)作为幂等键。即使支付服务因网络抖动重复调用账务接口,账务系统也能保证同一笔支付只记一次账。
// Java:账务系统幂等记账
@Service
public class AccountService {
private final AccountRepository accountRepo;
private final TransactionRepository txRepo;
@Transactional(isolation = Isolation.READ_COMMITTED)
public TransactionResult debit(DebitRequest request) {
String paymentNo = request.getPaymentNo();
// 检查是否已记账
Optional<Transaction> existing =
txRepo.findByPaymentNo(paymentNo);
if (existing.isPresent()) {
Transaction tx = existing.get();
log.info("重复记账请求,返回已有结果: paymentNo={}, txId={}",
paymentNo, tx.getId());
return TransactionResult.fromExisting(tx);
}
// 执行记账
Account account = accountRepo
.findByAccountNoForUpdate(request.getAccountNo())
.orElseThrow(() -> new AccountNotFoundException(
request.getAccountNo()));
if (account.getBalance().compareTo(request.getAmount()) < 0) {
throw new InsufficientBalanceException(
request.getAccountNo(), request.getAmount());
}
account.setBalance(
account.getBalance().subtract(request.getAmount()));
accountRepo.save(account);
Transaction tx = new Transaction();
tx.setPaymentNo(paymentNo);
tx.setAccountNo(request.getAccountNo());
tx.setAmount(request.getAmount().negate());
tx.setType("DEBIT");
tx.setBalanceAfter(account.getBalance());
txRepo.save(tx);
return TransactionResult.fromNew(tx);
}
}第四层:异步消息去重
支付完成后发出的通知消息和结算消息,消费者端各自维护去重表,确保通知不会重复发送、结算不会重复执行。
9.2 防重复扣款的关键要点
在支付场景中,防重复扣款需要关注以下关键问题:
状态机约束
支付单的状态转移必须是单向的、不可逆的。结合幂等性校验,可以在状态层面防止重复操作:
// Go:支付状态机
type PaymentStatus string
const (
StatusPending PaymentStatus = "pending"
StatusPaying PaymentStatus = "paying"
StatusSuccess PaymentStatus = "success"
StatusFailed PaymentStatus = "failed"
StatusRefunded PaymentStatus = "refunded"
StatusClosed PaymentStatus = "closed"
)
// 合法的状态转移
var validTransitions = map[PaymentStatus][]PaymentStatus{
StatusPending: {StatusPaying, StatusClosed},
StatusPaying: {StatusSuccess, StatusFailed},
StatusSuccess: {StatusRefunded},
StatusFailed: {StatusPending}, // 允许重新支付
StatusRefunded: {}, // 终态
StatusClosed: {}, // 终态
}
func (s PaymentStatus) CanTransitTo(target PaymentStatus) bool {
allowed, ok := validTransitions[s]
if !ok {
return false
}
for _, a := range allowed {
if a == target {
return true
}
}
return false
}
// 使用乐观锁更新状态
func updatePaymentStatus(
ctx context.Context,
db *sql.DB,
paymentNo string,
fromStatus PaymentStatus,
toStatus PaymentStatus,
) error {
result, err := db.ExecContext(ctx,
`UPDATE payment_orders
SET status = $1, updated_at = NOW()
WHERE payment_no = $2 AND status = $3`,
toStatus, paymentNo, fromStatus,
)
if err != nil {
return fmt.Errorf("update status: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf(
"status transition rejected: %s -> %s for payment %s",
fromStatus, toStatus, paymentNo,
)
}
return nil
}对账机制
幂等性设计是”事前防护”,对账机制(Reconciliation)是”事后校验”。完善的支付系统需要两者兼备:
| 层次 | 机制 | 频率 | 目的 |
|---|---|---|---|
| 实时 | 幂等键去重 | 每笔交易 | 防止重复执行 |
| 准实时 | 流水核对 | 每分钟 | 发现金额异常 |
| 日终 | 全量对账 | 每天 | 确保账务平衡 |
| 周期 | 差错处理 | 每周 | 处理未决差异 |
9.3 Token 预分配方案
Token 预分配(Pre-allocated Token)是另一种幂等性保障方式,特别适用于面向用户的支付表单。其流程是:
- 用户打开支付页面时,服务端生成一个一次性 Token 并嵌入表单。
- 用户提交时携带该 Token。
- 服务端校验 Token 的有效性,使用后立即作废。
- 如果 Token 已被使用,拒绝请求。
// Go:Token 预分配核心逻辑
package token
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type TokenService struct {
rdb *redis.Client
}
// Generate 生成一次性 Token,存入 Redis 并设置 30 分钟过期
func (s *TokenService) Generate(ctx context.Context, userID string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
key := fmt.Sprintf("submit_token:%s:%s", userID, token)
return token, s.rdb.Set(ctx, key, "valid", 30*time.Minute).Err()
}
// Consume 使用 Lua 脚本原子地校验并删除 Token
func (s *TokenService) Consume(ctx context.Context, userID, token string) error {
key := fmt.Sprintf("submit_token:%s:%s", userID, token)
script := redis.NewScript(`
local val = redis.call('GET', KEYS[1])
if val == false then return -1 end
if val ~= 'valid' then return -2 end
redis.call('DEL', KEYS[1])
return 1
`)
result, err := script.Run(ctx, s.rdb, []string{key}).Int()
if err != nil {
return fmt.Errorf("consume token: %w", err)
}
if result < 0 {
return fmt.Errorf("token invalid or already used (code=%d)", result)
}
return nil
}十、幂等性测试策略
10.1 测试金字塔
幂等性测试应覆盖以下层次:
| 层次 | 测试内容 | 工具 |
|---|---|---|
| 单元测试 | 幂等键生成逻辑、状态机转移 | Go testing、JUnit |
| 集成测试 | 数据库唯一约束、Redis 去重 | Testcontainers |
| 接口测试 | HTTP 接口重复调用 | curl、Postman |
| 压力测试 | 高并发下的幂等性保障 | wrk、k6 |
| 混沌测试 | 网络分区、节点宕机 | Chaos Monkey |
10.2 并发重复请求测试
测试幂等性的核心场景是:多个相同请求并发到达时,只有一个请求实际执行业务逻辑。
// Go:并发幂等性测试
func TestIdempotency_ConcurrentRequests(t *testing.T) {
db := setupTestDB(t)
rdb := setupTestRedis(t)
svc := NewPaymentService(db)
guard := NewIdempotencyGuard(rdb)
key := "test_order_001_pay"
concurrency := 10
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cached, acquired, err := guard.TryAcquire(context.Background(), key)
if err != nil || cached != nil {
return
}
if acquired {
svc.CreatePayment(context.Background(), key, "order_001", "user_123", "CNY", 100.00)
guard.Complete(context.Background(), key, 201, `{"payment_no":"PAY001"}`)
}
}()
}
wg.Wait()
// 验证:只创建了一笔支付记录
var count int
db.QueryRow("SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", key).Scan(&count)
if count != 1 {
t.Errorf("expected 1 payment record, got %d", count)
}
}10.3 异常场景测试矩阵
完备的幂等性测试需要覆盖以下异常场景:
| 测试场景 | 输入条件 | 预期行为 |
|---|---|---|
| 首次请求 | 新幂等键 | 执行业务,返回 201 |
| 重复请求(相同参数) | 已存在的幂等键 | 返回缓存响应 200 |
| 重复请求(不同参数) | 已存在的幂等键,参数不同 | 返回 422 错误 |
| 请求执行中重复 | 幂等键处于 processing 状态 | 返回 409 或等待 |
| 首次请求失败 | 业务处理异常 | 释放幂等键,允许重试 |
| 幂等键过期后重试 | 已过期的幂等键 | 视为新请求执行 |
| Redis 故障降级 | Redis 不可用 | 降级到数据库去重 |
| 数据库故障 | 数据库不可用 | 拒绝请求,返回 503 |
| 并发请求竞争 | 多个请求同时到达 | 仅一个执行,其余等待或拒绝 |
10.4 幂等性与 Exactly-Once 语义
幂等性(Idempotency)与精确一次投递(Exactly-Once Delivery)是两个相关但不同的概念:
| 维度 | 幂等性 | Exactly-Once |
|---|---|---|
| 定义 | 多次执行效果等同于一次 | 消息恰好被处理一次 |
| 实现层次 | 应用层 | 基础设施层 + 应用层 |
| 现实可行性 | 可实现 | 理论上不可能在纯网络层实现 |
| 典型做法 | 幂等键、唯一约束 | At-Least-Once + 幂等消费 |
严格的 Exactly-Once 在分布式系统中理论上是不可能仅在传输层实现的(FLP 不可能定理的推论)。实践中所谓的”Exactly-Once”,本质上是”At-Least-Once 投递 + 幂等消费”:
Exactly-Once 语义 = At-Least-Once 投递 + 幂等消费端
消息队列
生产者 ──→ [ msg ] ──→ [ msg msg ] ──→ 消费者
可能重复 幂等处理
投递 去重保障
Kafka 的 Exactly-Once 语义(引入于 0.11 版本)通过事务性生产者(Transactional Producer)和消费者偏移量的事务性提交来实现,但其本质也是在更底层实现了幂等性保障。
10.5 API 幂等性测试脚本
以下是一个实用的 Shell 测试脚本,用于验证 API 的幂等性行为:
#!/bin/bash
# 幂等性接口测试脚本
API_BASE="http://localhost:8080"
KEY="test-$(date +%s)-001"
ENDPOINT="${API_BASE}/api/v1/payments"
echo "=== 场景一:首次请求 ==="
R1=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
-d '{"order_id":"ORD001","amount":100.00,"currency":"CNY"}')
C1=$(echo "$R1" | tail -1); echo "状态码: $C1"
echo "=== 场景二:重复请求(相同参数) ==="
R2=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
-d '{"order_id":"ORD001","amount":100.00,"currency":"CNY"}')
C2=$(echo "$R2" | tail -1); echo "状态码: $C2"
echo "=== 场景三:重复请求(不同参数) ==="
R3=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
-d '{"order_id":"ORD002","amount":200.00,"currency":"CNY"}')
C3=$(echo "$R3" | tail -1); echo "状态码: $C3(预期 422)"
echo "=== 验证 ==="
[ "$C1" = "201" ] && [ "$C2" = "200" ] && [ "$C3" = "422" ] \
&& echo "所有幂等性测试通过" || { echo "测试失败"; exit 1; }10.6 性能基准测试
在引入幂等性保障后,需要通过基准测试确认性能影响在可接受范围内。典型的性能数据参考:
| 操作 | 平均延迟 | P99 延迟 | 吞吐量 |
|---|---|---|---|
| Redis 幂等键检查 | 0.3ms | 1.2ms | 50000 QPS |
| Redis 幂等键写入 | 0.5ms | 2.0ms | 30000 QPS |
| 数据库唯一约束检查 | 2.0ms | 8.0ms | 5000 QPS |
| 缓存响应返回 | 0.4ms | 1.5ms | 45000 QPS |
建议在集成测试阶段加入基准测试,使用 Go 的
testing.B 或 Java 的 JMH
框架,持续监控幂等层引入的额外延迟,确保 P99 延迟不超过业务
SLA 的 10%。
参考资料
- Fielding, R. T. and Reschke, J. “Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content.” RFC 7231, June 2014. https://datatracker.ietf.org/doc/html/rfc7231
- Stripe API Reference. “Idempotent Requests.” https://stripe.com/docs/api/idempotent_requests
- Brandur Leach. “Implementing Stripe-like Idempotency Keys in Postgres.” https://brandur.org/idempotency-keys
- Martin Kleppmann. “How to do distributed locking.” February 2016. https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
- Salvatore Sanfilippo. “Is Redlock safe?” http://antirez.com/news/101
- Pat Helland. “Idempotence Is Not a Medical Condition.” Communications of the ACM, Vol. 55, No. 5, May 2012.
- Apache Kafka Documentation. “Exactly Once Semantics.” https://kafka.apache.org/documentation/#semantics
- Chris Richardson. “Pattern: Idempotent Consumer.” https://microservices.io/patterns/communication-style/idempotent-consumer.html
- Designing Data-Intensive Applications, Martin Kleppmann, O’Reilly Media, 2017. Chapter 11: Stream Processing.
- 李运华。《从零开始学架构》。电子工业出版社,2018。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。
【系统架构设计百科】复杂性管理:架构的核心战场
系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略
【系统架构设计百科】微服务架构深度审视:优势、代价与适用边界
微服务不是免费的午餐。本文从分布式系统八大谬误出发,拆解微服务真正解决的问题与引入的代价,梳理服务边界划分的工程方法论,还原 Amazon 和 Netflix 从单体到微服务的真实演进时间线,给出微服务适用与不适用的判断框架。