Skip to content
第 68 / 250 章后端⏱ 10 分钟阅读

第 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 SETNXMQ 消费、定时任务中(依赖 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 章:限流与熔断

本站基于 VitePress 构建 · 由 Codebook 团队维护