“用户点击了两次付款按钮,你扣了他两次钱——这就是幂等性失败的代价。“
前言
幂等性(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 | 必须返回原始结果,不能只返回”成功” |
| 消息队列消费 | 防重表 + 消息 ID | INSERT IGNORE 原子插入 |
| 数据库更新 | 乐观锁 + 唯一约束 | 双重保护 |
| TCC 分布式事务 | Confirm 幂等设计 | 重试不破坏状态 |
| 定时任务补偿 | 版本号 + 去重 | 补偿操作必须可重复执行 |
银行系统幂等性设计的黄金原则:永远不要相信”只执行一次”——假设每个操作都会被执行 N 次,然后设计你的系统。
相关阅读:分布式事务与 Saga 模式实战 · Spring Cloud Gateway 银行网关实战 · MySQL 事务与锁