接口幂等设计实战:让你的API稳如老狗!

接口幂等设计实战:让你的API稳如老狗!

作为一名资深后端开发,你有没有遇到过这样的场景:用户在支付时网络卡顿,疯狂点击支付按钮,结果银行卡被扣了三次款?或者在提交订单时页面无响应,用户以为没提交就又点了一次,结果收到了两个一模一样的包裹?

今天就来聊聊如何通过接口幂等设计,让你的API稳如老狗,再也不怕用户"手抖"!

一、什么是接口幂等性?

在开始实战之前,我们先来理解一下什么是接口幂等性。

1.1 幂等性的定义

幂等性(Idempotence)是数学中的一个概念,表示一个操作无论执行多少次,结果都是一样的。在计算机领域,特别是API设计中,幂等性指的是:

同一个请求,无论执行多少次,产生的结果和副作用都是一样的。

举个生活中的例子:

  • 乘电梯:按下5楼按钮一次和按十次,电梯最终都会停在5楼
  • 开关灯:开关灯按钮无论按多少次,灯的状态只有两种(开或关)

1.2 为什么需要接口幂等性?

在分布式系统中,由于网络不稳定、用户误操作、系统重试机制等原因,同一个请求可能会被多次发送。如果没有幂等性保障,就会出现各种问题:

  1. 重复支付:用户被重复扣款
  2. 重复下单:用户收到多个相同订单
  3. 重复创建:数据库中出现重复数据
  4. 库存超卖:库存被错误扣减
  5. 状态异常:业务状态被错误修改

二、常见的幂等性问题场景

让我们来看看在实际开发中,哪些场景容易出现幂等性问题:

2.1 用户操作层面

  1. 网络卡顿:用户点击提交后页面无响应,以为没成功就重复点击
  2. 误触操作:用户不小心双击或多次点击按钮
  3. 刷新页面:用户提交后刷新页面导致重复提交

2.2 系统层面

  1. 网络重试:客户端或网关自动重试机制
  2. 消息队列:消息消费失败后的重复投递
  3. 负载均衡:请求被转发到多个实例
  4. 超时重试:服务调用超时后的自动重试

2.3 典型业务场景

  1. 支付场景:支付接口被重复调用
  2. 订单场景:创建订单接口被重复调用
  3. 库存场景:扣减库存接口被重复调用
  4. 退款场景:退款接口被重复调用

三、接口幂等性实现方案

针对不同的业务场景,我们可以采用不同的幂等性实现方案:

3.1 数据库唯一约束

这是最简单直接的方式,通过数据库的唯一约束来保证数据不重复。

-- 创建订单表时添加唯一约束
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(32) UNIQUE NOT NULL,  -- 订单号唯一
    user_id BIGINT NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    status TINYINT DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

优点:

  • 实现简单
  • 数据库层面保证

缺点:

  • 只能防止重复插入,不能防止重复处理
  • 错误处理复杂(需要捕获唯一约束异常)

3.2 状态机控制

通过业务状态来控制操作的幂等性。

// 订单状态机示例
public class OrderService {
    
    public void payOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        
        // 检查订单状态
        if (order.getStatus() != OrderStatus.PENDING) {
            throw new BusinessException("订单状态不正确");
        }
        
        // 更新订单状态
        order.setStatus(OrderStatus.PAID);
        order.setPaidAt(new Date());
        orderRepository.save(order);
        
        // 执行支付逻辑
        paymentService.processPayment(order);
    }
}

优点:

  • 业务逻辑清晰
  • 适用于有明确状态流转的场景

缺点:

  • 状态设计复杂
  • 需要仔细考虑所有状态转换

3.3 Token机制

这是最常用也是最灵活的幂等性实现方式。

3.3.1 Token机制原理

  1. 客户端在发起业务请求前,先向服务端申请一个唯一的Token
  2. 服务端生成Token并存储到Redis中,设置过期时间
  3. 客户端带着Token发起业务请求
  4. 服务端收到请求后,检查Redis中是否存在该Token
  5. 如果存在,则处理业务并删除Token;如果不存在,则认为是重复请求

3.3.2 核心实现代码

首先,我们需要定义一个自定义注解:

// Idempotent.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
    
    /**
     * 幂等key的前缀
     */
    String prefix() default "idempotent";
    
    /**
     * 过期时间(秒)
     */
    long expireTime() default 300;
    
    /**
     * 提示信息
     */
    String message() default "请勿重复提交";
}

接下来实现AOP切面:

// IdempotentAspect.java
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Around("@annotation(idempotent)")
    public Object handleIdempotent(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        // 获取请求中的token
        String token = getRequestToken();
        if (StringUtils.isBlank(token)) {
            throw new BusinessException("缺少幂等token");
        }
        
        // 构造Redis key
        String key = idempotent.prefix() + ":" + token;
        
        // 使用Lua脚本保证原子性
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                       "return redis.call('del', KEYS[1]) " +
                       "else return 0 end";
        
        Boolean result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Boolean.class),
            Collections.singletonList(key),
            "1"  // 这里可以是任意值,用于Lua脚本比较
        );
        
        if (Boolean.TRUE.equals(result)) {
            // Token有效,执行业务逻辑
            try {
                return joinPoint.proceed();
            } catch (Exception e) {
                // 如果业务执行失败,需要将token重新放回redis(可选)
                redisTemplate.opsForValue().set(key, "1", idempotent.expireTime(), TimeUnit.SECONDS);
                throw e;
            }
        } else {
            // Token无效或已使用
            throw new BusinessException(idempotent.message());
        }
    }
    
    /**
     * 从请求头或参数中获取token
     */
    private String getRequestToken() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        
        // 先从header中获取
        String token = request.getHeader("Idempotent-Token");
        if (StringUtils.isBlank(token)) {
            // 从参数中获取
            token = request.getParameter("idempotentToken");
        }
        
        return token;
    }
}

Token服务实现:

// TokenService.java
@Service
public class TokenService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 生成幂等token
     */
    public String generateToken(String prefix) {
        String token = UUID.randomUUID().toString().replace("-", "");
        String key = prefix + ":" + token;
        
        // 存储到Redis,设置过期时间
        redisTemplate.opsForValue().set(key, "1", 300, TimeUnit.SECONDS);
        
        return token;
    }
    
    /**
     * 验证并消费token
     */
    public boolean consumeToken(String prefix, String token) {
        String key = prefix + ":" + token;
        
        // 使用原子操作删除token
        Boolean result = redisTemplate.delete(key);
        return Boolean.TRUE.equals(result);
    }
}

控制器实现:

// OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private TokenService tokenService;
    
    /**
     * 生成幂等token
     */
    @GetMapping("/token")
    public Result<String> generateToken() {
        String token = tokenService.generateToken("order:create");
        return Result.success(token);
    }
    
    /**
     * 创建订单(幂等)
     */
    @PostMapping
    @Idempotent(prefix = "order:create", expireTime = 300, message = "订单已提交,请勿重复提交")
    public Result<Order> createOrder(@RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return Result.success(order);
    }
}

3.4 分布式锁

对于需要严格保证顺序执行的场景,可以使用分布式锁。

// DistributedLockService.java
@Service
public class DistributedLockService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 获取分布式锁
     */
    public boolean tryLock(String key, String value, long expireTime) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                       "return redis.call('expire', KEYS[1], ARGV[2]) " +
                       "else return redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') end";
        
        Boolean result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Boolean.class),
            Collections.singletonList(key),
            value,
            String.valueOf(expireTime)
        );
        
        return Boolean.TRUE.equals(result);
    }
    
    /**
     * 释放分布式锁
     */
    public void releaseLock(String key, String value) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                       "return redis.call('del', KEYS[1]) " +
                       "else return 0 end";
        
        redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(key),
            value
        );
    }
}

使用分布式锁的业务方法:

// OrderService.java
@Service
public class OrderService {
    
    @Autowired
    private DistributedLockService lockService;
    
    public Order createOrder(CreateOrderRequest request) {
        // 使用用户ID和商品ID作为锁的key
        String lockKey = "order:create:" + request.getUserId() + ":" + request.getProductId();
        String lockValue = UUID.randomUUID().toString();
        
        try {
            // 获取锁,超时时间10秒
            if (!lockService.tryLock(lockKey, lockValue, 10)) {
                throw new BusinessException("系统繁忙,请稍后重试");
            }
            
            // 执行创建订单逻辑
            return doCreateOrder(request);
        } finally {
            // 释放锁
            lockService.releaseLock(lockKey, lockValue);
        }
    }
    
    private Order doCreateOrder(CreateOrderRequest request) {
        // 实际的创建订单逻辑
        return order;
    }
}

四、不同HTTP方法的幂等性

RESTful API中不同HTTP方法的幂等性要求是不同的:

HTTP方法幂等性安全性说明
GET获取资源,不应产生副作用
HEAD获取资源元信息,不应产生副作用
POST创建资源,通常会产生副作用
PUT更新资源,多次执行结果相同
PATCH部分更新资源,可能产生不同结果
DELETE删除资源,多次执行结果相同

五、最佳实践建议

5.1 选择合适的幂等性方案

  1. 简单场景:使用数据库唯一约束
  2. 复杂业务:使用状态机控制
  3. 高频场景:使用Token机制
  4. 严格顺序:使用分布式锁

5.2 Token机制优化

  1. 合理设置过期时间:根据业务特点设置合适的过期时间
  2. Token前缀分类:不同业务使用不同的前缀
  3. 异常处理:业务执行失败时考虑是否需要恢复Token
  4. 监控告警:监控幂等性失败的情况

5.3 前端配合

  1. 按钮防重复点击:点击后禁用按钮
  2. Loading状态:显示加载状态提示用户
  3. 友好提示:给用户明确的操作反馈
// 前端防重复提交示例
class OrderService {
    async createOrder(orderData) {
        // 禁用提交按钮
        this.disableSubmitButton();
        
        try {
            // 获取幂等token
            const tokenResponse = await this.getAuthToken();
            const token = tokenResponse.data;
            
            // 设置请求头
            const headers = {
                'Idempotent-Token': token,
                'Content-Type': 'application/json'
            };
            
            // 发起请求
            const response = await fetch('/api/orders', {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(orderData)
            });
            
            if (response.ok) {
                const result = await response.json();
                // 显示成功提示
                this.showSuccess('订单提交成功');
                return result;
            } else {
                throw new Error('提交失败');
            }
        } catch (error) {
            // 显示错误提示
            this.showError('提交失败,请重试');
            throw error;
        } finally {
            // 恢复提交按钮
            this.enableSubmitButton();
        }
    }
}

5.4 监控和日志

  1. 记录幂等性失败:统计幂等性检查失败的次数
  2. 分析失败原因:分析用户重复提交的原因
  3. 性能监控:监控Redis等组件的性能
// 幂等性监控示例
@Aspect
@Component
@Slf4j
public class IdempotentMonitorAspect {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    @Around("@annotation(idempotent)")
    public Object monitorIdempotent(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        Timer.Sample sample = Timer.start(meterRegistry);
        String methodName = joinPoint.getSignature().getName();
        
        try {
            Object result = joinPoint.proceed();
            // 记录成功请求
            sample.stop(Timer.builder("idempotent.requests")
                .tag("method", methodName)
                .tag("result", "success")
                .register(meterRegistry));
            return result;
        } catch (BusinessException e) {
            // 记录幂等性失败
            if (e.getMessage().contains("重复")) {
                sample.stop(Timer.builder("idempotent.requests")
                    .tag("method", methodName)
                    .tag("result", "duplicate")
                    .register(meterRegistry));
                log.warn("幂等性检查失败: method={}, message={}", methodName, e.getMessage());
            }
            throw e;
        } catch (Exception e) {
            // 记录其他异常
            sample.stop(Timer.builder("idempotent.requests")
                .tag("method", methodName)
                .tag("result", "error")
                .register(meterRegistry));
            throw e;
        }
    }
}

六、总结

接口幂等性设计是构建高可用系统的重要一环,通过合理的幂等性保障,我们可以:

  1. 提升用户体验:避免用户因误操作导致的问题
  2. 保证数据一致性:防止重复数据和状态异常
  3. 增强系统稳定性:减少因重复请求导致的系统异常
  4. 降低业务风险:避免重复支付等严重问题

在实际项目中,建议根据业务场景选择合适的幂等性方案:

  • 对于简单创建操作,可以使用数据库唯一约束
  • 对于复杂业务逻辑,推荐使用Token机制
  • 对于需要严格顺序的场景,可以结合分布式锁
  • 前后端协同配合,提供更好的用户体验

掌握了这些幂等性设计技巧,相信你再面对重复请求问题时会更加从容不迫,让你的API稳如老狗!

今日思考:你们团队在项目中是如何处理接口幂等性的?有没有遇到过什么有趣的场景?欢迎在评论区分享你的经验!


标题:接口幂等设计实战:让你的API稳如老狗!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2025/12/21/1766304295069.html

    0 评论
avatar