后端接口的“幂等性设计”:从“重复请求灾难”到“安全可靠”

100 阅读7分钟

在分布式系统中,接口调用可能因网络延迟、重试机制、前端误操作等原因出现重复请求——如用户连续点击支付按钮导致重复扣款,或分布式事务中因重试引发的数据重复插入。幂等性设计通过“让接口在重复调用时产生相同的效果”,避免重复请求导致的数据不一致、业务逻辑混乱等问题,是保障接口可靠性的核心原则。

幂等性的核心价值与适用场景

为什么需要幂等性?

  • 防止重复数据:避免因重复请求导致的订单重复创建、商品重复下单
  • 保障数据一致性:确保多次调用接口后,业务状态和数据结果保持一致
  • 支持安全重试:让接口调用失败时可以放心重试,无需担心副作用
  • 适应分布式环境:在网络不稳定、服务间调用复杂的分布式系统中,抵御不确定性

需保证幂等性的场景

  • 支付接口:防止重复扣款(如用户多次点击“确认支付”)
  • 订单创建:避免同一笔业务生成多个订单
  • 数据提交:如表单提交、信息注册,防止重复提交导致的数据重复
  • 分布式事务:在TCC、SAGA等模式中,补偿操作需要幂等性支持

无需幂等性的场景

  • 纯查询接口(多次查询不改变数据状态)
  • 一次性操作且无重试机制的接口(如日志上报)

幂等性的实现方案

1. 基于唯一标识的幂等(Token机制)

通过预生成的唯一Token标识请求,确保同一业务仅被处理一次:

// Token生成与验证服务
@Service
public class IdempotentTokenService {
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 生成幂等Token(用于后续请求验证)
     */
    public String generateToken(String businessKey) {
        // 生成唯一Token(如UUID)
        String token = UUID.randomUUID().toString();
        // 存储Token到Redis(键:业务标识+Token,值:未处理状态,过期时间30分钟)
        String redisKey = "idempotent:" + businessKey + ":" + token;
        redisTemplate.opsForValue().set(redisKey, "UNPROCESSED", 30, TimeUnit.MINUTES);
        return token;
    }

    /**
     * 验证Token并加锁(确保同一Token仅被处理一次)
     */
    public boolean verifyToken(String businessKey, String token) {
        String redisKey = "idempotent:" + businessKey + ":" + token;
        // 使用Redis的setIfAbsent实现分布式锁(原子操作)
        return redisTemplate.opsForValue().setIfAbsent(redisKey, "PROCESSING", 30, TimeUnit.MINUTES);
    }

    /**
     * 标记Token处理完成
     */
    public void markProcessed(String businessKey, String token) {
        String redisKey = "idempotent:" + businessKey + ":" + token;
        redisTemplate.opsForValue().set(redisKey, "PROCESSED", 24, TimeUnit.HOURS); // 保留24小时便于查询
    }

    /**
     * 检查Token是否已处理
     */
    public boolean isProcessed(String businessKey, String token) {
        String redisKey = "idempotent:" + businessKey + ":" + token;
        String status = redisTemplate.opsForValue().get(redisKey);
        return "PROCESSED".equals(status);
    }
}

// 接口调用流程
@RestController
@RequestMapping("/pay")
public class PaymentController {
    @Autowired
    private IdempotentTokenService tokenService;
    @Autowired
    private PaymentService paymentService;

    /**
     * 步骤1:预生成支付Token(如在订单确认页调用)
     */
    @GetMapping("/token")
    public Result generatePayToken(@RequestParam String orderNo) {
        String token = tokenService.generateToken("pay:" + orderNo);
        return Result.success(token);
    }

    /**
     * 步骤2:使用Token调用支付接口(确保幂等)
     */
    @PostMapping("/submit")
    public Result submitPayment(@RequestParam String orderNo, 
                               @RequestParam String token,
                               @RequestBody PaymentDTO dto) {
        // 1. 验证Token是否有效且未被处理
        if (!tokenService.verifyToken("pay:" + orderNo, token)) {
            // Token已处理或无效,直接返回结果
            if (tokenService.isProcessed("pay:" + orderNo, token)) {
                return Result.success("支付已处理");
            } else {
                return Result.fail(400, "无效的Token");
            }
        }

        try {
            // 2. 执行支付逻辑
            PaymentResult result = paymentService.doPayment(orderNo, dto);
            // 3. 标记Token处理完成
            tokenService.markProcessed("pay:" + orderNo, token);
            return Result.success(result);
        } catch (Exception e) {
            // 4. 异常时可删除Token锁,允许重试(或根据业务决定是否保留)
            // redisTemplate.delete("idempotent:pay:" + orderNo + ":" + token);
            return Result.fail(500, "支付失败:" + e.getMessage());
        }
    }
}

Token机制优势

  • 通用性强:适用于各类需要幂等的接口
  • 安全性高:Token一次性有效,避免被恶意复用
  • 支持分布式:基于Redis实现,适配集群环境

局限性

  • 需额外请求:生成Token需要前置接口调用
  • 增加复杂度:需要管理Token的生成、验证和状态

2. 基于业务唯一键的幂等

利用业务自身的唯一标识(如订单号、用户ID+业务类型)确保幂等:

@Service
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 创建订单(基于订单号确保幂等)
     */
    public Long createOrder(OrderDTO dto) {
        String orderNo = dto.getOrderNo(); // 业务唯一标识(如前端生成或后端生成)
        if (orderNo == null) {
            throw new BusinessException("订单号不能为空");
        }

        // 1. 先查询订单是否已存在(快速判断)
        Order existingOrder = orderMapper.selectByOrderNo(orderNo);
        if (existingOrder != null) {
            return existingOrder.getId(); // 已存在则返回原有ID
        }

        // 2. 使用分布式锁防止并发创建
        String lockKey = "lock:order:" + orderNo;
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(locked)) {
            // 获取锁失败,说明有并发请求,等待后重试查询
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            Order order = orderMapper.selectByOrderNo(orderNo);
            return order != null ? order.getId() : null;
        }

        try {
            // 3. 再次检查订单是否存在(防止锁等待期间已被创建)
            Order order = orderMapper.selectByOrderNo(orderNo);
            if (order != null) {
                return order.getId();
            }

            // 4. 执行创建订单逻辑
            order = new Order();
            order.setOrderNo(orderNo);
            order.setUserId(dto.getUserId());
            order.setAmount(dto.getAmount());
            // ... 其他字段设置
            orderMapper.insert(order);
            return order.getId();
        } finally {
            // 5. 释放锁
            redisTemplate.delete(lockKey);
        }
    }
}

业务唯一键优势

  • 无需额外Token:利用业务自身标识,减少接口调用
  • 实现简单:直接通过唯一键查询和判断

局限性

  • 依赖业务设计:需要业务有天然的唯一标识
  • 并发风险:需配合分布式锁防止并发创建

3. 基于数据库的幂等(唯一索引)

通过数据库唯一索引约束,防止重复数据插入:

-- 订单表添加唯一索引(确保order_no唯一)
ALTER TABLE `order` ADD UNIQUE INDEX `idx_order_no` (`order_no`);
@Service
public class OrderDbIdempotentService {
    @Autowired
    private OrderMapper orderMapper;

    public Long createOrderWithUniqueIndex(OrderDTO dto) {
        try {
            // 尝试插入订单(若order_no重复,会触发唯一索引异常)
            Order order = new Order();
            order.setOrderNo(dto.getOrderNo());
            // ... 其他字段设置
            orderMapper.insert(order);
            return order.getId();
        } catch (DuplicateKeyException e) {
            // 捕获唯一索引冲突异常,查询已有订单并返回
            log.warn("订单号重复,orderNo={}", dto.getOrderNo(), e);
            Order existingOrder = orderMapper.selectByOrderNo(dto.getOrderNo());
            return existingOrder != null ? existingOrder.getId() : null;
        }
    }
}

数据库唯一索引优势

  • 最底层保障:即使应用层逻辑失效,数据库仍能防止重复
  • 简单可靠:无需额外代码,仅通过数据库约束实现

局限性

  • 仅适用于插入场景:无法解决更新操作的幂等问题
  • 异常处理成本:需要捕获数据库异常,可能影响性能

不同操作类型的幂等性实现

操作类型幂等性实现方案示例
新增(INSERT)唯一索引、业务唯一键订单创建(order_no唯一)
更新(UPDATE)基于版本号、条件更新更新用户余额(UPDATE user SET balance = balance - 10 WHERE id = 1 AND balance >= 10
删除(DELETE)逻辑删除、条件删除删除订单(DELETE FROM order WHERE id = 1 AND status = 'UNPAID'
查询(SELECT)天然幂等查询商品详情

版本号机制实现更新幂等

@Service
public class UserBalanceService {
    @Autowired
    private UserMapper userMapper;

    /**
     * 更新用户余额(基于版本号确保幂等)
     */
    public boolean updateBalance(Long userId, BigDecimal amount, Integer version) {
        // 带版本号条件更新(仅当版本号匹配时才更新)
        int rows = userMapper.updateBalance(userId, amount, version);
        // 更新行数为0说明版本号不匹配(已被其他请求更新)
        return rows > 0;
    }
}

// Mapper接口
public interface UserMapper {
    @Update("UPDATE user SET balance = balance + #{amount}, version = version + 1 " +
            "WHERE id = #{userId} AND version = #{version}")
    int updateBalance(@Param("userId") Long userId, 
                     @Param("amount") BigDecimal amount, 
                     @Param("version") Integer version);
}

幂等性的最佳实践

1. 优先使用业务自身的唯一标识

尽量利用业务中天然的唯一键(如订单号、交易号),减少额外Token的使用,降低系统复杂度。

2. 结合多种方案保障幂等

例如:“业务唯一键 + 分布式锁 + 唯一索引”三重保障,即使某一层失效,其他层仍能发挥作用。

3. 明确幂等接口的返回值

对重复请求,应返回与首次请求相同的结果(如相同的订单ID、支付状态),避免调用方困惑。

4. 记录幂等处理日志

详细记录重复请求的处理过程(如“检测到重复支付,订单号xxx,返回已有结果”),便于问题排查。

避坑指南

  • 不要过度设计:纯查询接口无需实现幂等性
  • 注意锁的粒度:分布式锁的键应尽量具体(如lock:order:123而非lock:order),避免锁竞争
  • 处理锁超时:确保业务逻辑执行时间短于锁的过期时间,避免锁提前释放导致的并发问题
  • 考虑异常恢复:当处理过程中发生异常,需明确Token或锁的状态如何处理(释放还是保留)

幂等性设计的核心是“让重复请求无害”——通过技术手段确保多次调用接口的效果与一次调用一致。在分布式系统中,网络抖动、服务重试等情况不可避免,幂等性不是可有可无的优化,而是保障系统可靠性的“底线要求”。一个具备完善幂等性的接口,才能在复杂的生产环境中“稳如泰山”。