idempotentlisted
Install: claude install-skill 1398281322-a11y/java-backend-guardrails
# 幂等
## When to Invoke
重复提交、支付/退款/第三方回调、MQ 重试、超时后客户端重试、核销、分佣入账。
## When NOT
只读查询;确定不会重试、没有回调、没有唯一业务键的一次性写。
## 风险(面试考点)
同一笔业务被执行两次:重复扣款、重复核销、重复分佣、重复发券。HTTP 超时、MQ 至少一次投递、回调重放都会制造重复。面试常考「如何保证消息不被重复消费」——本质是消费侧幂等,不是 MQ 只投一次。
## 适用场景
- 支付/退款回调(同一 `notifyId` / `outTradeNo` 会打多次)
- 订单提交、核销、结算入账
- RocketMQ 消费失败 reconsume
- Feign/HTTP 超时后重试
## 方案选型(轻量优先)
| 优先级 | 方案 | 用在 | 代价 |
|--------|------|------|------|
| 1 | 数据库唯一约束 + 状态机 | 所有写库业务 | 要设计好唯一键 |
| 2 | 业务单号查重(先查后写,仍靠唯一约束兜底) | 回调、核销 | 并发下必须靠唯一键 |
| 3 | Redis `SET key NX EX` 短窗口 | 按钮连点、秒级防抖 | 不能当唯一资金防线 |
| 4 | 分布式锁 | 无法用唯一键表达的临界区 | 重,见 `distributed-lock` |
默认:唯一约束 + 状态机。不要只用 Redis 防重当资金安全网。
## 默认方案
1. 每笔写操作有业务单号:`requestId` / `outTradeNo` / `verifyNo`。
2. 表上建唯一键,插入冲突视为「已处理」,返回首次结果。
3. 状态只允许单向跳:`CREATED → PAID → REFUNDED`,非法跳转直接忽略或报业务错误。
4. MQ:消费成功后再 ACK;失败重试;处理函数按业务单号幂等。
```sql
-- 支付回调 / 核销 落库
UNIQUE KEY uk_pay_callback (out_trade_no, channel)
UNIQUE KEY uk_verify (order_id, voucher_id)
UNIQUE KEY uk_commission (order_id, rule_id)
```
```java
@Transactional
public void onPayNotify(PayNotify n) {
PayRecord row = new PayRecord();
row.setOutTradeNo(n.getOutTradeNo());
row.setChannel(n.getChannel());
row.setPayload(n.getRaw());
try {
payRecordMapper.insert(row); // uk 冲突即重复回调
} catch (DuplicateKeyException e) {
return; // 已处理
}
int ok = orderMapper.update(null, Wrappers.<Order>lambdaUpdate()
.set(Order::getStatus, "PAID")
.eq(Order::getId, n.getOrderId())