第 68 章:幂等性设计
学习目标
- 理解幂等性的本质和应用场景
- 掌握 Token 机制、唯一索引、状态机、分布式锁四种方案
- 学会用 Spring AOP 实现幂等注解
一、什么是幂等性?
幂等性:同一个请求被执行多次,产生的结果与执行一次相同。
java
// ❌ 非幂等
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 重复调用 → 多次转账 → 钱扣多次
accountMapper.deduct(fromId, amount);
accountMapper.add(toId, amount);
}
// ✅ 幂等
public void transfer(String bizNo, Long fromId, Long toId, BigDecimal amount) {
// bizNo 是业务单号,重复请求直接返回之前的结果
if (transferLogMapper.exists(bizNo)) return; // 已处理过
// ... 执行转账
}二、为什么需要幂等?
常见幂等场景:
- 用户重复点击(前端没防抖)
- 表单重复提交
- 网络重试(前端 axios/fetch 重试)
- MQ 消息重复消费
- 第三方支付回调(可能回调多次)
三、Token 机制(前端方案)
流程
实现
java
// ① 生成 Token
@GetMapping("/idempotent/token")
public Result<String> getToken() {
String token = UUID.randomUUID().toString();
// 存到 Redis(5 分钟有效)
redis.opsForValue().set(
"idempotent:token:" + token, "1", 5, TimeUnit.MINUTES);
return Result.ok(token);
}
// ② 业务接口(用 Token 防重)
@PostMapping("/order")
public Result<Long> createOrder(@RequestHeader("Idempotent-Token") String token,
@RequestBody OrderDTO dto) {
String key = "idempotent:token:" + token;
Boolean deleted = redis.delete(key); // 原子删除
if (Boolean.FALSE.equals(deleted)) {
// token 已用过或不存在
throw new BusinessException(ErrorCode.IDEMPOTENT_INVALID, "请勿重复提交");
}
// 执行业务
return Result.ok(orderService.create(dto));
}前端配合
javascript
// ① 进入页面时获取 token
const tokenRes = await api.get('/idempotent/token')
// ② 提交时携带
await api.post('/order', data, {
headers: {
'Idempotent-Token': tokenRes.data
}
})
// ③ 提交后刷新 token(防止再次点击)
await api.get('/idempotent/token')四、AOP 幂等注解(更优雅)
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/** 幂等 key 的 SpEL 表达式 */
String key() default "";
/** Token 来源:HEADER(从请求头取)/ PARAM(从参数取) */
TokenFrom from() default TokenFrom.HEADER;
/** Token 有效期(秒),默认 5 分钟 */
long expire() default 300;
/** 失败提示 */
String message() default "请勿重复提交";
enum TokenFrom {
HEADER, PARAM
}
}java
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class IdempotentAspect {
private final RedisTemplate<String, Object> redis;
private final HttpServletRequest request;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
// ① 获取 token
String token = extractToken(idempotent.from());
if (token == null || token.isEmpty()) {
throw new BusinessException(ErrorCode.PARAM_INVALID, "缺少幂等 token");
}
// ② 校验并删除 token(原子操作)
String key = "idempotent:" + idempotent.key() + ":" + token;
Boolean deleted = redis.delete(key);
if (Boolean.FALSE.equals(deleted)) {
throw new BusinessException(ErrorCode.IDEMPOTENT_INVALID, idempotent.message());
}
// ③ 设置 token(第一次进入时设置)
redis.opsForValue().set(key, "1", idempotent.expire(), TimeUnit.SECONDS);
// ④ 执行业务
return pjp.proceed();
}
private String extractToken(Idempotent.TokenFrom from) {
if (from == Idempotent.TokenFrom.HEADER) {
return request.getHeader("Idempotent-Token");
}
// 从方法参数中找
// ...
return null;
}
}java
// 用法
@PostMapping("/order")
@Idempotent(key = "createOrder", message = "订单请勿重复提交")
public Result<Long> createOrder(@RequestBody OrderDTO dto) {
return Result.ok(orderService.create(dto));
}五、唯一索引(数据库方案)
sql
-- ① 业务字段加唯一约束
ALTER TABLE sys_order ADD UNIQUE KEY uk_biz_no (biz_no);
-- ② 业务代码
public Long createOrder(OrderDTO dto) {
Order order = new Order();
order.setBizNo(dto.getBizNo()); // 业务单号(前端生成 UUID)
orderMapper.insert(order); // 重复 INSERT 抛 DuplicateKeyException
return order.getId();
}java
// ③ 异常处理
@Service
public class OrderService {
public Long createOrder(OrderDTO dto) {
try {
orderMapper.insert(order);
return order.getId();
} catch (DuplicateKeyException e) {
// 查已有的订单,返回
Order exist = orderMapper.selectByBizNo(dto.getBizNo());
return exist.getId();
}
}
}bizNo 由前端生成 UUID:保证唯一性,让前端能多次重试。
六、乐观锁 / 状态机(业务方案)
java
// ① 支付场景:订单状态从「待支付」→「已支付」
public void pay(Long orderId, BigDecimal amount) {
Order order = orderMapper.selectById(orderId);
// ② 状态判断(保证幂等)
if (order.getStatus() == OrderStatus.PAID) {
log.info("订单已支付,无需重复处理");
return; // 已支付过,直接返回
}
if (order.getStatus() != OrderStatus.UNPAID) {
throw new BusinessException("订单状态不允许支付");
}
// ③ 乐观锁更新(防止并发)
int rows = orderMapper.updateStatus(orderId,
OrderStatus.UNPAID, // 旧状态
OrderStatus.PAID, // 新状态
amount);
if (rows == 0) {
throw new BusinessException("订单状态已被修改");
}
}sql
-- SQL 层面
UPDATE sys_order
SET status = '已支付', pay_time = NOW()
WHERE id = #{orderId} AND status = '待支付';七、分布式锁(并发方案)
java
// 场景:并发请求「同一个订单的同一操作」,需要串行化
public void processOrder(Long orderId) {
String lockKey = "order:process:" + orderId;
try {
// ① 抢锁
boolean locked = redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("操作太频繁,请稍后再试");
}
// ② 业务处理(带幂等检查)
Order order = orderMapper.selectById(orderId);
if (order.getProcessed()) return; // 已处理过
// ... 执行业务 ...
orderMapper.markProcessed(orderId);
} finally {
redisLock.unlock(lockKey);
}
}八、消息幂等(MQ 场景)
java
@RocketMQMessageListener(topic = "order.created", consumerGroup = "inventory")
public void onMessage(Message msg) {
String msgId = msg.getKeys(); // 消息唯一 ID
// ① Redis SETNX 检查
String key = "mq:consumed:" + msgId;
Boolean firstTime = redis.opsForValue().setIfAbsent(key, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(firstTime)) {
log.warn("重复消息,跳过 msgId={}", msgId);
return;
}
// ② 业务处理(也可以再加重试 + 死信)
processOrder(msg.getBody());
}九、支付回调幂等(实战案例)
java
@Service
@RequiredArgsConstructor
public class PaymentCallbackService {
private final OrderMapper orderMapper;
private final RedisTemplate<String, Object> redis;
// 微信支付回调:可能回调 N 次,必须幂等
public void handleWechatCallback(WechatPayNotify notify) {
// ① 校验签名
if (!verifySign(notify)) {
throw new BusinessException("签名验证失败");
}
// ② 用交易号作为幂等 key
String idempotentKey = "pay:notify:" + notify.getTransactionId();
// ③ Redis SETNX 抢锁
Boolean first = redis.opsForValue().setIfAbsent(
idempotentKey, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(first)) {
log.info("支付回调重复,transactionId={}", notify.getTransactionId());
return; // 已处理过
}
// ④ 业务处理(订单状态从「待支付」→「已支付」)
Order order = orderMapper.selectByOutTradeNo(notify.getOutTradeNo());
if (order == null) {
throw new BusinessException("订单不存在");
}
if (order.getStatus() == OrderStatus.PAID) {
return; // 已是已支付状态
}
// ⑤ 状态机更新(带条件)
orderMapper.markPaid(order.getId(), notify.getTransactionId(),
notify.getAmount());
}
}十、幂等方案选型
| 方案 | 适用场景 | 复杂度 | 可靠性 |
|---|---|---|---|
| 前端防抖 | 简单按钮 | 低 | 中(前端可绕过) |
| Token 机制 | 表单提交、订单创建 | 中 | 高 |
| 唯一索引 | 订单创建、用户注册 | 低 | 高(DB 兜底) |
| 状态机 | 支付、状态流转 | 中 | 高 |
| 分布式锁 | 高并发同一资源 | 高 | 高 |
| 乐观锁 | 并发更新 | 中 | 高 |
| Redis SETNX | MQ 消费、定时任务 | 低 | 中(依赖 Redis) |
最佳实践:唯一索引(DB 兜底)+ Token/AOP(前置拦截) 即使 Redis 挂了,DB 唯一索引也能保证不会重复写入。
十一、本章小结
| 要点 | 关键 |
|---|---|
| 本质 | 多次执行 = 一次执行 |
| Token 方案 | 前端获取 token → 提交携带 → 服务端校验后删除 |
| 唯一索引 | DB 层兜底,bizNo 用 UUID |
| 状态机 | 订单状态流转前判断(已支付则返回) |
| 分布式锁 | Redis SETNX 防并发 |
| MQ 消费 | SETNX + 业务状态判断 |
| AOP 注解 | @Idempotent 拦截器统一处理 |
| 最佳实践 | DB 唯一索引 + Token 拦截双保险 |
动手练习
练习 1:基础题
实现一个 @Idempotent AOP 注解:拦截重复提交,通过 Redis SETNX 实现,返回友好提示。
练习 2:进阶题
实现支付回调的完整幂等流程:Redis SETNX + 订单状态机双重校验,模拟回调 3 次,验证只处理一次。
练习 3:思考题
设计一个"用户注册"的幂等方案:用户可能多次点击注册按钮、网络可能重试、MQ 异步发送欢迎邮件可能重复。综合运用本章方案。
下一章:第 69 章:限流与熔断 →