工程实践 架构心得

幂等性设计:银行分布式系统的资金安全基石

从接口重试到分布式事务,详解银行系统中幂等性的六种实现方案,以及如何设计真正可靠的资金交易幂等机制。

发布于 2026/03/20 更新于 2026/03/20 2 分钟

“用户点击了两次付款按钮,你扣了他两次钱——这就是幂等性失败的代价。“

前言

幂等性(Idempotency)是指:同一个操作执行一次和执行多次,结果完全相同

在银行系统里,这不只是”好的实践”,而是监管合规要求

  • 支付系统:同一笔交易重复扣款 = 合规违规 + 客户投诉
  • 对账系统:重复入账 = 账不平 + 监管罚款
  • 异步消息处理:消息重复消费 = 数据重复 = 灾难

HTTP 的 GET、PUT、DELETE 是天然幂等的。但 POST(创建资源)、PATCH(部分更新)不是——而银行里 POST 恰恰是最常用的操作(转账、支付、开户)。

1. 幂等性失效的四个典型场景

场景1:用户手抖双击(最常见)

用户点击「转账」→ 网络卡了 → 前端超时 → 用户又点一次
→ 后端收到两笔相同金额、相同账户的转账请求
→ 如果没有幂等处理 → 扣两次款

场景2:超时重试

客户端发起转账 → 后端处理成功 → 响应超时 → 客户端以为失败 → 重新发起
→ 后端再次处理 → 再次扣款

场景3:消息队列重复投递

消息队列可靠性不高 → 同一条消息被投递两次
→ 消费者处理两次 → 重复入账

场景4:分布式事务补偿

TCC 事务中 Try 成功、Confirm 网络超时 → 补偿机制触发 → 重试 Confirm
→ 如果 Confirm 不是幂等的 → 重复操作

2. 幂等 Token 方案(生产首选)

核心思路:前端生成唯一幂等 Key → 后端存储处理结果 → 相同 Key 直接返回缓存结果

2.1 幂等 Key 设计

幂等 Key 必须能唯一标识一笔业务操作:

// 幂等 Key 组成:业务类型 + 业务ID + 时间戳 + 随机数
// 格式:TRANSFER:{fromAccount}:{toAccount}:{amount}:{timestamp}:{nonce}

public class IdempotencyKeyGenerator {
    public String generate(TransferRequest request, String userId) {
        return String.format("TRANSFER:%s:%s:%s:%d:%s",
            request.getFromAccountId(),
            request.getToAccountId(),
            request.getAmount().toPlainString(),
            System.currentTimeMillis() / 1000 / 60, // 精度到分钟,防止 KEY 太长
            UUID.randomUUID().toString().substring(0, 8)
        );
    }
}

银行场景的特殊处理:如果业务本身有唯一业务编号(如柜员交易流水号),用它做幂等 Key 更可靠:

public String generateFromBusinessId(String transactionRef) {
    // transactionRef = "TXN-20260320-001-USD"
    return "BANK:TRANSFER:" + transactionRef;
}

2.2 Redis 存储处理结果

@Service
@Slf4j
public class IdempotencyService {
    private final RedisTemplate<String, Object> redis;

    private static final Duration IDEMPOTENCY_TTL = Duration.ofHours(24);

    /**
     * 幂等检查 + 结果缓存
     * 返回值包含:是否首次处理(NEW)、处理结果、幂等 Key
     */
    public IdempotencyResult check(String idempotencyKey, String businessType) {
        String storageKey = buildKey(businessType, idempotencyKey);

        // 1. 查缓存
        Object cached = redis.opsForValue().get(storageKey);
        if (cached != null) {
            IdempotencyRecord record = (IdempotencyRecord) cached;
            log.info("幂等命中: key={}, originalResult={}", storageKey, record.result());
            return new IdempotencyResult(false, record.result(), record.idempotencyKey());
        }

        // 2. SETNX 原子占位(防止并发重复请求)
        Boolean acquired = redis.opsForValue().setIfAbsent(
            storageKey + ":lock", "PROCESSING", Duration.ofSeconds(30));

        if (Boolean.FALSE.equals(acquired)) {
            // 另一个请求正在处理中
            return new IdempotencyResult(false, null, idempotencyKey, true);
        }

        return new IdempotencyResult(true, null, idempotencyKey, false);
    }

    /**
     * 存储处理结果(处理完成后调用)
     */
    public void saveResult(String businessType, String idempotencyKey,
                           Object result, Object request) {
        String storageKey = buildKey(businessType, idempotencyKey);

        IdempotencyRecord record = new IdempotencyRecord(
            idempotencyKey, request, result,
            LocalDateTime.now().toString(), "SUCCESS"
        );

        redis.opsForValue().set(storageKey, record, IDEMPOTENCY_TTL);
        redis.delete(storageKey + ":lock");

        log.info("幂等结果已存储: key={}", storageKey);
    }

    /**
     * 处理失败时的清理
     */
    public void markFailed(String businessType, String idempotencyKey, String errorMsg) {
        String storageKey = buildKey(businessType, idempotencyKey);
        redis.delete(storageKey);
        redis.delete(storageKey + ":lock");
    }

    private String buildKey(String businessType, String idempotencyKey) {
        return String.format("idempotency:%s:%s", businessType, idempotencyKey);
    }
}

public record IdempotencyRecord(
    String idempotencyKey,
    Object request,
    Object result,
    String processedAt,
    String status
) {}

public record IdempotencyResult(
    boolean isNew,       // true = 本次需要处理,false = 命中缓存
    Object cachedResult,
    String idempotencyKey,
    boolean isProcessing // true = 另一个请求正在处理,应等待
) {}

2.3 业务层集成

@PostMapping("/api/payment/transfer")
public ResponseEntity<?> transfer(@RequestBody TransferRequest request,
                                  @RequestHeader(value = "X-Idempotency-Key", required = false)
                                  String idempotencyKey) {

    // 1. 生成或验证幂等 Key
    if (idempotencyKey == null || idempotencyKey.isBlank()) {
        idempotencyKey = idempotencyService.generate(request, request.getUserId());
    }

    // 2. 幂等检查
    IdempotencyResult check = idempotencyService.check(idempotencyKey, "TRANSFER");

    if (!check.isNew() && check.isProcessing()) {
        // 另一个请求正在处理中,返回 Accepted 让客户端稍等
        return ResponseEntity.accepted().body(Map.of(
            "message", "请求正在处理中,请稍候",
            "idempotencyKey", idempotencyKey
        ));
    }

    if (!check.isNew() && check.cachedResult() != null) {
        // 命中缓存,直接返回之前的结果
        log.info("幂等命中,直接返回缓存结果: key={}", idempotencyKey);
        return ResponseEntity.ok(check.cachedResult());
    }

    // 3. 执行真正的转账逻辑
    TransferResponse response;
    try {
        response = transferService.execute(request, idempotencyKey);
        idempotencyService.saveResult("TRANSFER", idempotencyKey, response, request);
    } catch (Exception e) {
        idempotencyService.markFailed("TRANSFER", idempotencyKey, e.getMessage());
        throw e;
    }

    return ResponseEntity.ok(response);
}

3. 数据库幂等:乐观锁 + 防重表

3.1 唯一约束幂等(最适合银行)

在数据库层加唯一约束,从根本上保证不重复:

-- 防重表
CREATE TABLE payment_idempotency (
    idempotency_key VARCHAR(128) PRIMARY KEY,  -- 唯一约束
    request_hash    VARCHAR(64)  NOT NULL,       -- 请求内容摘要(防止同 key 不同内容)
    response_data   TEXT,
    status          VARCHAR(20)  NOT NULL DEFAULT 'PROCESSING',
    created_at      TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMP,
    expires_at      TIMESTAMP,

    INDEX idx_status_expires (status, expires_at)
);
@Service
public class PaymentIdempotencyDao {

    @Autowired
    private JdbcTemplate jdbc;

    public boolean tryAcquire(String idempotencyKey, String requestHash) {
        try {
            jdbc.update("""
                INSERT INTO payment_idempotency (idempotency_key, request_hash, status, expires_at)
                VALUES (?, ?, 'PROCESSING', DATE_ADD(NOW(), INTERVAL 1 DAY))
                """, idempotencyKey, requestHash);
            return true; // 成功获取,说明是新请求
        } catch (DuplicateKeyException e) {
            // Key 已存在,检查状态
            String status = jdbc.queryForObject(
                "SELECT status FROM payment_idempotency WHERE idempotency_key = ?",
                String.class, idempotencyKey);
            return "SUCCESS".equals(status); // 已成功则返回 true(允许后续操作)
        }
    }

    public void markSuccess(String idempotencyKey, String responseData) {
        jdbc.update("""
            UPDATE payment_idempotency
            SET status = 'SUCCESS', response_data = ?, updated_at = NOW()
            WHERE idempotency_key = ?
            """, responseData, idempotencyKey);
    }

    public Optional<String> getCachedResult(String idempotencyKey) {
        try {
            return Optional.ofNullable(
                jdbc.queryForObject(
                    "SELECT response_data FROM payment_idempotency WHERE idempotency_key = ? AND status = 'SUCCESS'",
                    String.class, idempotencyKey));
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
}

3.2 乐观锁防重复更新

对于余额扣减这类操作,乐观锁天然防并发:

@Service
public class AccountService {

    @Transactional
    public void debit(String accountId, BigDecimal amount, String transactionId) {
        int rows = accountDao.debitWithVersion(accountId, amount, transactionId);
        if (rows == 0) {
            // 两种可能:账户不存在 或 乐观锁冲突(并发修改)
            throw new OptimisticLockException(
                "账户余额更新失败,accountId=" + accountId + ", transactionId=" + transactionId);
        }
    }
}

@Mapper
public interface AccountDao {
    @Update("""
        UPDATE account
        SET balance = balance - #{amount},
            version = version + 1,
            last_transaction_id = #{transactionId}
        WHERE account_id = #{accountId}
          AND balance >= #{amount}
          AND (last_transaction_id != #{transactionId} OR last_transaction_id IS NULL)
        """)
    int debitWithVersion(@Param("accountId") String accountId,
                         @Param("amount") BigDecimal amount,
                         @Param("transactionId") String transactionId);
}

注意这里用 last_transaction_id 判断——如果同一 transactionId 两次执行,第二次会因条件不满足而更新 0 行。天然幂等

4. 消息队列幂等:Exactly-Once 的工程实现

Kafka、RabbitMQ 等消息队列的 at-least-once 投递特性要求消费者必须实现幂等。

4.1 消息处理幂等表

@Service
@Slf4j
public class MessageIdempotencyHandler {

    private final JdbcTemplate jdbc;

    /**
     * 消息幂等处理框架
     */
    public <T> void handle(String messageId, Class<T> payloadClass,
                            Consumer<T> processor) {
        try {
            // 1. 尝试插入消息 ID(成功=新消息,失败=重复)
            int inserted = jdbc.update("""
                INSERT IGNORE INTO message_processed (message_id, status, processed_at)
                VALUES (?, 'PROCESSING', NOW())
                """, messageId);

            if (inserted == 0) {
                log.info("消息已处理过,跳过: messageId={}", messageId);
                return;
            }

            // 2. 执行业务处理
            T payload = parseMessage(messageId, payloadClass);
            processor.accept(payload);

            // 3. 标记成功
            jdbc.update("UPDATE message_processed SET status = 'SUCCESS' WHERE message_id = ?",
                messageId);

        } catch (Exception e) {
            // 4. 失败时删除幂等标记,以便下次重试
            jdbc.update("DELETE FROM message_processed WHERE message_id = ? AND status = 'PROCESSING'",
                messageId);
            throw e;
        }
    }

    private <T> T parseMessage(String messageId, Class<T> clazz) {
        // 从 Kafka/RabbitMQ payload 解析
        return null; // 实现细节略
    }
}

4.2 在业务中使用

@KafkaListener(topics = "payment.events")
public void handlePaymentEvent(String messageId, @Payload PaymentEvent event) {
    messageIdempotencyHandler.handle(messageId, PaymentEvent.class, payload -> {
        switch (payload.getType()) {
            case "TRANSFER_CONFIRMED" -> accountService.processTransferConfirmed(payload);
            case "TRANSFER_FAILED" -> accountService.processTransferFailed(payload);
        }
    });
}

5. 全局幂等架构

银行系统推荐的三层幂等体系:

第一层:API Gateway 层
  → 解析 X-Idempotency-Key,拒绝格式错误的 Key

第二层:业务服务层
  → Redis 分布式幂等检查(高性能)
  → 幂等命中 → 直接返回缓存结果
  → 幂等未命中 → 开启数据库事务

第三层:数据库层
  → 唯一约束(兜底保护)
  → 乐观锁(并发安全)
  → 防重表(消息幂等)

6. 总结:幂等性检查清单

场景推荐方案关键点
HTTP POST 转账/支付幂等 Token + Redis必须返回原始结果,不能只返回”成功”
消息队列消费防重表 + 消息 IDINSERT IGNORE 原子插入
数据库更新乐观锁 + 唯一约束双重保护
TCC 分布式事务Confirm 幂等设计重试不破坏状态
定时任务补偿版本号 + 去重补偿操作必须可重复执行

银行系统幂等性设计的黄金原则:永远不要相信”只执行一次”——假设每个操作都会被执行 N 次,然后设计你的系统。


相关阅读:分布式事务与 Saga 模式实战 · Spring Cloud Gateway 银行网关实战 · MySQL 事务与锁