接口幂等性设计
什么是幂等性
幂等性(Idempotent):同一个操作无论执行多少次,产生的效果与执行一次相同。
text
幂等: GET /user/1 → 查询,无论查多少次结果一致
幂等: PUT /user/1 → 全量更新,第 N 次结果与第 1 次一致
幂等: DELETE /user/1 → 删除,多次删除效果相同
非幂等: POST /order → 重复提交可能创建多个订单
非幂等: PATCH /user/1 → 部分更新(如 INCR),每次效果不同为什么需要幂等性
text
产生重复请求的常见场景:
1. 用户快速双击提交按钮
2. 网络超时后的重试机制
3. MQ 消息重复消费
4. 第三方回调重推
5. 前端防抖失效幂等方案对比
| 方案 | 实现复杂度 | 可靠性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 数据库唯一索引 | ⭐ | ★★★★★ | ★★★★ | 创建订单、注册等唯一约束场景 |
| Token 机制 | ⭐⭐ | ★★★★ | ★★★★ | 表单提交、支付 |
| 状态机 | ⭐⭐ | ★★★★★ | ★★★★★ | 订单状态流转 |
| Redis 分布式锁 | ⭐⭐⭐ | ★★★★ | ★★★ | 高并发扣减 |
| 去重表 | ⭐ | ★★★★★ | ★★★ | 消息消费、回调处理 |
方案一:数据库唯一索引
原理
利用数据库的 unique 约束,重复插入会抛出异常。
sql
-- 订单表:业务流水号唯一
CREATE TABLE `t_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '订单号(唯一)',
`user_id` bigint(20) NOT NULL,
`status` tinyint(4) NOT NULL,
`amount` decimal(10,2) NOT NULL,
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`) -- 核心幂等约束
) ENGINE=InnoDB;核心逻辑
java
@Service
public class OrderService {
@Transactional
public Order createOrder(CreateOrderRequest request) {
// 1. 生成订单号(传入或生成)
String orderNo = request.getOrderNo() != null
? request.getOrderNo()
: IdGenerator.generate();
// 2. 尝试插入(幂等唯一约束)
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.CREATED);
order.setCreateTime(LocalDateTime.now());
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 3. 重复插入 → 查询已有订单返回
log.warn("订单重复创建 orderNo={}", orderNo);
return orderMapper.selectByOrderNo(orderNo);
}
// 4. 后续操作(扣库存等)
inventoryService.deduct(request.getSkuId(), request.getQuantity());
return order;
}
}适用场景
- 订单创建:业务唯一键(订单号)天然适合
- 用户注册:手机号/邮箱唯一索引
方案二:Token 机制
流程
text
前端 后端
│ │
├── 获取 Token ──┤ Token 存入 Redis,key=token:xxx
│ │ 返回 token 给前端
├── 提交表单 ────┤
│ (携带 token) │ 1. 从 Redis 删除 token(DEL 原子操作)
│ │ 2. 删除成功 → 继续执行业务
│ │ 3. 删除失败(不存在)→ 重复请求,拒绝
│ │ 4. 返回结果
│◄── 响应 ──────┤核心代码
java
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
// 1. 获取 token(请求头或参数)
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes()).getRequest();
String token = request.getHeader("Idempotent-Token");
if (token == null || token.isEmpty()) {
throw new IllegalArgumentException("缺少幂等 Token");
}
// 2. 尝试删除 Redis key(原子操作)
String key = "idempotent:" + token;
Boolean deleted = redisTemplate.delete(key);
if (Boolean.FALSE.equals(deleted)) {
// key 不存在 → 重复请求
throw new RepeatSubmitException("重复提交,请稍后重试");
}
// 3. 执行目标方法
return pjp.proceed();
}
}适用场景
- 表单提交:防止用户双击
- 支付接口:防止重复扣款
方案三:状态机
原理
业务对象的状态变更具有方向性,只有特定状态才能流转到下一状态,重复更新不会生效。
text
CREATED → PAID → SHIPPED → DELIVERED → COMPLETED
↑ ↑
└── REFUNDED ←───────┘实现
java
@Service
public class OrderStateMachine {
// 状态流转映射
private static final Map<OrderStatus, Set<OrderStatus>> STATE_MAP = new HashMap<>();
static {
STATE_MAP.put(OrderStatus.CREATED, Set.of(OrderStatus.PAID, OrderStatus.CANCELED));
STATE_MAP.put(OrderStatus.PAID, Set.of(OrderStatus.SHIPPED, OrderStatus.REFUNDING));
STATE_MAP.put(OrderStatus.SHIPPED, Set.of(OrderStatus.DELIVERED));
STATE_MAP.put(OrderStatus.DELIVERED, Set.of(OrderStatus.COMPLETED, OrderStatus.REFUNDING));
}
@Transactional
public boolean updateStatus(Long orderId, OrderStatus fromStatus, OrderStatus toStatus) {
// SQL: UPDATE t_order SET status = toStatus
// WHERE id = ? AND status = fromStatus
// 受影响行数为 0 表示状态已变更,重复请求不做任何操作
int rows = orderMapper.updateStatus(orderId, fromStatus, toStatus);
return rows > 0;
}
}适用场景
- 订单状态流转:最常用方案
- 审批流:审核状态逐级变更
方案四:Redis 分布式锁
原理
利用 Redis SET NX 实现短时间的排它锁,确保同一业务键在锁有效期内只能执行一次。
java
public boolean tryIdempotentLock(String businessKey, long expireMs) {
String key = "idempotent:lock:" + businessKey;
// SET NX: key 不存在才设置成功
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofMillis(expireMs));
return Boolean.TRUE.equals(success);
}
// 业务中组合使用
public void processPayment(String paymentNo) {
if (!tryIdempotentLock(paymentNo, 3000)) {
log.warn("支付请求正在处理中 paymentNo={}", paymentNo);
return; // 或返回正在处理中的提示
}
try {
// 执行支付逻辑
} finally {
// 注意:任务执行完成后必须释放
redisTemplate.delete("idempotent:lock:" + paymentNo);
}
}注意:锁释放需要在 finally 中保证,且要处理锁过期后业务未完成的问题——通常锁的超时时间需要大于业务执行时间。
方案五:去重表
原理
针对 MQ 消息消费、回调处理等场景,建一张去重表,利用唯一约束保证幂等。
sql
CREATE TABLE `t_idempotent` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`business_type` varchar(32) NOT NULL COMMENT '业务类型(payment/refund/order)',
`business_key` varchar(64) NOT NULL COMMENT '业务唯一键(支付号/退款单号)',
`status` tinyint(4) NOT NULL COMMENT '处理状态',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_business` (`business_type`, `business_key`)
) ENGINE=InnoDB;java
@Component
public class IdempotentHandler {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 尝试获取执行权限(幂等)
* @return true: 首次处理,可执行; false: 已处理过
*/
public boolean tryProcess(String type, String key) {
try {
jdbcTemplate.update(
"INSERT INTO t_idempotent(business_type, business_key, status, create_time, update_time) VALUES(?, ?, 0, now(), now())",
type, key);
return true;
} catch (DuplicateKeyException e) {
return false;
}
}
public void markCompleted(String type, String key) {
jdbcTemplate.update(
"UPDATE t_idempotent SET status = 1, update_time = now() WHERE business_type = ? AND business_key = ?",
type, key);
}
}
// MQ 消费方使用
@RabbitListener(queues = "order.payment")
public void onPaymentMessage(PaymentMessage msg) {
if (!idempotentHandler.tryProcess("payment", msg.getPaymentNo())) {
log.info("支付消息已处理过 paymentNo={}", msg.getPaymentNo());
return;
}
try {
// 处理支付成功后续逻辑
paymentService.processPayment(msg);
idempotentHandler.markCompleted("payment", msg.getPaymentNo());
} catch (Exception e) {
log.error("支付处理失败", e);
// 幂等记录保留,人工介入处理
}
}幂等最佳实践
方案选型矩阵
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 创建订单 | 唯一索引 | 天然唯一键,简单可靠 |
| 表单提交 | Token 机制 | 防止误触,用户体验好 |
| 状态变更 | 状态机 | 代码层面确保合法流转 |
| 高并发扣减 | Redis + 乐观锁 | 性能优先 |
| MQ 消费 | 去重表 | 消息可能重复投递 |
| 支付回调 | 去重表 + 唯一索引 | 第三方重推频繁 |
通用规则
text
1. 所有 POST/PATCH 接口都应考虑幂等
2. 幂等 Token 由后端生成并返回,前端携带提交
3. 幂等超时时间 ≥ 业务最大执行时间
4. 幂等记录需要清理策略(TTL 或定期清理)
5. 写日志记录幂等判定结果,便于排查