土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】幂等性设计:分布式环境下的安全重试

文章导航

分类入口
architecture
标签入口
#idempotency#idempotency-key#Stripe#distributed-lock#deduplication

目录

一、从一次真实的重复扣款事故说起

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 计算机科学中的定义

在计算机科学领域,幂等性的定义需要更加精确。一个操作是幂等的,当且仅当:

  1. 相同的输入:请求参数完全一致。
  2. 相同的副作用:对系统状态的改变只发生一次。
  3. 相同的响应:每次调用返回相同的结果。

需要特别区分两个概念:

安全 ⊂ 幂等

安全操作一定是幂等的,幂等操作不一定是安全的。

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 的幂等机制基于以下核心要素:

  1. 客户端在请求头(Header)中携带 Idempotency-Key
  2. 服务端将该键与请求参数、响应结果绑定存储。
  3. 相同键的后续请求直接返回缓存的响应,不重复执行业务逻辑。
  4. 键的有效期为 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 小时过期是经过权衡的:

参数校验

当幂等键已存在时,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 自动过期 需定时清理 需定时清理 使用后删除
跨服务支持
响应缓存
故障恢复 需谨慎处理

选型建议:

六、数据库唯一约束方案

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)是另一种幂等性保障方式,特别适用于面向用户的支付表单。其流程是:

  1. 用户打开支付页面时,服务端生成一个一次性 Token 并嵌入表单。
  2. 用户提交时携带该 Token。
  3. 服务端校验 Token 的有效性,使用后立即作废。
  4. 如果 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%。

参考资料

  1. 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
  2. Stripe API Reference. “Idempotent Requests.” https://stripe.com/docs/api/idempotent_requests
  3. Brandur Leach. “Implementing Stripe-like Idempotency Keys in Postgres.” https://brandur.org/idempotency-keys
  4. Martin Kleppmann. “How to do distributed locking.” February 2016. https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
  5. Salvatore Sanfilippo. “Is Redlock safe?” http://antirez.com/news/101
  6. Pat Helland. “Idempotence Is Not a Medical Condition.” Communications of the ACM, Vol. 55, No. 5, May 2012.
  7. Apache Kafka Documentation. “Exactly Once Semantics.” https://kafka.apache.org/documentation/#semantics
  8. Chris Richardson. “Pattern: Idempotent Consumer.” https://microservices.io/patterns/communication-style/idempotent-consumer.html
  9. Designing Data-Intensive Applications, Martin Kleppmann, O’Reilly Media, 2017. Chapter 11: Stream Processing.
  10. 李运华。《从零开始学架构》。电子工业出版社,2018。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。

2026-04-13 · architecture

【系统架构设计百科】复杂性管理:架构的核心战场

系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略


By .