被架构师怼了三次,小明终于懂了接口幂等设计

19,450 阅读7分钟

引言

隔壁组的小明最近有点不痛快,在设计支付接口时被架构师老李连怼三次,就因为接口的幂等性没有考虑充分。

每次都是自信满满,可每次都被找出问题。

这次我们跟着隔壁小明的视角,循序渐进,彻底搞懂接口幂等设计的精髓。

真的是精髓。

1042.png

什么是接口幂等性?

mindmap
  root((接口幂等性))
    定义
      多次调用结果一致
      不会产生副作用
      状态不会改变
    重要性
      防止重复操作
      提高系统稳定性
      避免数据不一致
    应用场景
      支付接口
      订单创建
      数据修改
      消息发送

幂等性:在数学和计算机科学中,幂等操作指的是任意多次执行所产生的影响均与一次执行的影响相同。

第一次:欠考虑的支付接口

小明写的第一版支付接口,我们来看下:

@RestController
public class PaymentController {
    
    @Autowired
    private PaymentService paymentService;
    
    @PostMapping("/pay")
    public ResponseEntity<String> pay(@RequestBody PaymentRequest request) {
        // 直接处理支付,没考虑重复提交
        PaymentResult result = paymentService.processPayment(request);
        return ResponseEntity.ok("支付成功,订单号:" + result.getOrderId());
    }
}

@Service
public class PaymentService {
    
    public PaymentResult processPayment(PaymentRequest request) {
        // 危险:直接扣款,没有任何防重复机制
        Account account = accountRepository.findById(request.getUserId());
        account.setBalance(account.getBalance().subtract(request.getAmount()));
        accountRepository.save(account);
        
        // 创建支付记录
        Payment payment = new Payment();
        payment.setUserId(request.getUserId());
        payment.setAmount(request.getAmount());
        payment.setStatus("SUCCESS");
        return paymentRepository.save(payment);
    }
}

是不是问题还挺明显?

架构师老李看过代码后,也认为离谱的过于明显。

他怒了:小明!如果用户网络不好,连点三次支付按钮,直接被扣三次钱!你这接口有幂等性保证吗?

这里我们忽略掉前端的防抖~将视角完全落在后端。

sequenceDiagram
    participant U as 用户
    participant F as 前端
    participant B as 后端
    participant DB as 数据库
    
    U->>F: 点击支付(网络慢)
    F->>B: 支付请求1
    B->>DB: 扣款100元
    DB-->>B: 余额1900
    U->>F: 再次点击支付(以为没成功)
    F->>B: 支付请求2
    B->>DB: 再次扣款100元
    DB-->>B: 余额1800
    Note over U,DB: 用户被重复扣款!

第二次:简单粗暴的去重方案

小明意识到问题后,想到可以用 Redis 来做简单去重:

@RestController
public class PaymentController {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @PostMapping("/pay")
    public ResponseEntity<String> pay(@RequestBody PaymentRequest request) {
        // 简单用用户ID+金额做key
        String key = "payment:" + request.getUserId() + ":" + request.getAmount();
        
        // 如果key存在,认为是重复请求
        if (redisTemplate.hasKey(key)) {
            return ResponseEntity.ok("重复请求,支付已处理");
        }
        
        // 设置标记
        redisTemplate.opsForValue().set(key, "processing", 60, TimeUnit.SECONDS);
        
        PaymentResult result = paymentService.processPayment(request);
        return ResponseEntity.ok("支付成功,订单号:" + result.getOrderId());
    }
}

似乎……可以了?小明自测后觉得针不戳,直接提交了代码。

结果老李又怒了:小明!你这去重的key设计百分百有问题啊!用户连续支付两笔相同金额怎么办?而且万一处理失败了,这个key还在 Redis 里,用户就再也无法支付这个金额了!

看看这个业务流程:

flowchart TD
    A[用户支付100元] --> B{Redis中是否存在key}
    B -->|不存在| C[设置key到Redis]
    C --> D[处理支付逻辑]
    D --> E{处理是否成功}
    E -->|成功| F[返回成功]
    E -->|失败| G[返回失败但key仍存在]
    G --> H[用户无法再支付相同金额]
    B -->|存在| I[直接返回重复请求]
    
    style G fill:#ffcccc
    style H fill:#ffcccc

第三次:考虑不周的幂等token方案

小明这次学聪明了,使用幂等token:

@RestController
public class PaymentController {
    
    @PostMapping("/pay")
    public ResponseEntity<String> pay(@RequestBody PaymentRequest request) {
        String idempotentToken = request.getIdempotentToken();
        if (StringUtils.isEmpty(idempotentToken)) {
            return ResponseEntity.badRequest().body("缺少幂等token");
        }
        
        String key = "payment:token:" + idempotentToken;
        
        // 检查token是否已使用
        if (redisTemplate.hasKey(key)) {
            String result = redisTemplate.opsForValue().get(key);
            return ResponseEntity.ok("重复请求:" + result);
        }
        
        try {
            PaymentResult result = paymentService.processPayment(request);
            // 成功后保存结果
            redisTemplate.opsForValue().set(key, "SUCCESS:" + result.getOrderId(), 
                                          24, TimeUnit.HOURS);
            return ResponseEntity.ok("支付成功,订单号:" + result.getOrderId());
        } catch (Exception e) {
            // 失败了也要记录,避免重试
            redisTemplate.opsForValue().set(key, "FAILED:" + e.getMessage(), 
                                          24, TimeUnit.HOURS);
            throw e;
        }
    }
}

这下肯定没问题了,这代码简直不要太完美~

小明自信满满,又一次提交了代码。

结果老李继续怒:小明!你这样处理有并发问题啊!如果有两个请求同时到达,都检查到token不存在,然后都去处理支付,还是会重复扣款!而且失败的请求也被标记了,用户在24小时内永远无法重试!

flowchart TD
    A[请求1和请求2同时到达] --> B[都检查token不存在]
    B --> C[请求1开始处理支付]
    B --> D[请求2也开始处理支付]
    C --> E[请求1扣款成功]
    D --> F[请求2也扣款成功]
    E --> G[用户被重复扣款]
    F --> G
    
    style G fill:#ffcccc

正确的幂等设计方案

经过三次被怼,小明终于在老李的指导下设计出了完善的幂等方案:

mindmap
  root((完善的幂等设计))
    核心要素
      唯一标识
        业务幂等key
        全局唯一token
      原子性保证
        分布式锁
        数据库唯一约束
      状态管理
        处理中状态
        最终状态持久化
    技术方案
      分布式锁+Redis
      数据库唯一约束
      状态机设计
      消息队列幂等

分布式锁 + 状态管理

@RestController
public class PaymentController {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private PaymentService paymentService;
    
    @PostMapping("/pay")
    public ResponseEntity<PaymentResponse> pay(@RequestBody PaymentRequest request) {
        String idempotentKey = request.getIdempotentToken();
        validateRequest(request, idempotentKey);
        
        // 先查询是否已有处理结果
        PaymentResponse existingResult = paymentService.getPaymentResult(idempotentKey);
        if (existingResult != null) {
            return ResponseEntity.ok(existingResult);
        }
        
        // 使用分布式锁保证并发安全
        RLock lock = redissonClient.getLock("payment:lock:" + idempotentKey);
        
        try {
            // 尝试获取锁,最多等待3秒,锁定10秒
            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
                // 再次检查,避免重复处理
                existingResult = paymentService.getPaymentResult(idempotentKey);
                if (existingResult != null) {
                    return ResponseEntity.ok(existingResult);
                }
                
                // 执行支付逻辑
                PaymentResponse result = paymentService.processPaymentWithIdempotent(request);
                return ResponseEntity.ok(result);
            } else {
                throw new RuntimeException("系统繁忙,请稍后重试");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("请求被中断");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    private void validateRequest(PaymentRequest request, String idempotentKey) {
        if (StringUtils.isEmpty(idempotentKey)) {
            throw new IllegalArgumentException("缺少幂等token");
        }
        // 其他业务校验...
    }
}

说明:

  • 在获取锁之前先做一次结果查询,这是性能优化的关键。基本上大部分重复请求都能在这一步快速返回,无需等待锁;
  • 双重检查锁 :获取锁后再次检查结果,防止在等待锁的过程中其他线程已经完成了处理;
  • 等待锁最多3秒,避免请求长时间阻塞;锁定时间10秒,为支付业务预留足够的时间;
  • 区分好业务异常和系统异常,确保锁能正确释放,避免死锁;

核心业务逻辑

@Service
@Transactional
public class PaymentService {
    
    @Autowired
    private PaymentRecordRepository paymentRecordRepository;
    
    @Autowired
    private AccountRepository accountRepository;
    
    public PaymentResponse processPaymentWithIdempotent(PaymentRequest request) {
        String idempotentKey = request.getIdempotentToken();
        
        try {
            // 创建支付记录,状态为处理中
            PaymentRecord record = createPaymentRecord(request, idempotentKey);
            
            // 执行支付逻辑
            processPayment(request);
            
            // 更新状态为成功
            record.setStatus(PaymentStatus.SUCCESS);
            record.setCompletedAt(LocalDateTime.now());
            paymentRecordRepository.save(record);
            
            return PaymentResponse.success(record.getOrderId());
            
        } catch (Exception e) {
            // 处理失败,更新状态但不阻止重试
            updatePaymentRecordOnFailure(idempotentKey, e.getMessage());
            throw e;
        }
    }
    
}

这一段就是常规的业务流程了,不再多做赘述。

private PaymentRecord createPaymentRecord(PaymentRequest request, String idempotentKey) {
    PaymentRecord record = new PaymentRecord();
    record.setIdempotentKey(idempotentKey);
    record.setUserId(request.getUserId());
    record.setAmount(request.getAmount());
    record.setStatus(PaymentStatus.PROCESSING);
    record.setCreatedAt(LocalDateTime.now());
    
    try {
        return paymentRecordRepository.save(record);
    } catch (DataIntegrityViolationException e) {
        // 幂等key重复,说明已有请求在处理
        throw new RuntimeException("重复的支付请求");
    }
}

private void updatePaymentRecordOnFailure(String idempotentKey, String errorMsg) {
    PaymentRecord record = paymentRecordRepository.findByIdempotentKey(idempotentKey);
    if (record != null) {
        record.setStatus(PaymentStatus.FAILED);
        record.setErrorMessage(errorMsg);
        record.setCompletedAt(LocalDateTime.now());
        paymentRecordRepository.save(record);
    }
}

public PaymentResponse getPaymentResult(String idempotentKey) {
    PaymentRecord record = paymentRecordRepository.findByIdempotentKey(idempotentKey);
    if (record == null) {
        return null;
    }
    
    switch (record.getStatus()) {
        case SUCCESS:
            return PaymentResponse.success(record.getOrderId());
        case PROCESSING:
            return PaymentResponse.processing();
        case FAILED:
            // 失败的记录不返回,允许重试
            return null;
        default:
            return null;
    }
}

创建payment记录这段做个简易的说明:

  • 通过数据库约束做兜底:即使分布式锁出现问题,数据库的唯一约束也能防止重复记录的创建;
  • 失败的记录最好保留在数据库中用于审计和复查,但在业务中要返回null,允许用户重新发起支付;
  • 把PROCESSING状态告知前端,由前端显示“请求正在处理中”,避免用户重复提交;

完整的幂等处理流程

flowchart TD
    A[接收支付请求] --> B{验证幂等token}
    B -->|无效| C[返回参数错误]
    B -->|有效| D[查询是否已有结果]
    D -->|有结果| E{检查结果状态}
    E -->|成功| F[返回成功结果]
    E -->|处理中| G[返回处理中状态]
    E -->|失败| H[继续处理]
    D -->|无结果| I[尝试获取分布式锁]
    H --> I
    I -->|获取失败| J[返回系统繁忙]
    I -->|获取成功| K[再次检查结果]
    K -->|有结果| L[释放锁并返回结果]
    K -->|无结果| M[创建处理中记录]
    M -->|创建失败| N[返回重复请求]
    M -->|创建成功| O[执行支付业务]
    O -->|成功| P[更新状态为成功]
    O -->|失败| Q[更新状态为失败]
    P --> R[释放锁并返回成功]
    Q --> S[释放锁并返回失败]
    
    style C fill:#ffcccc
    style J fill:#ffcccc
    style N fill:#ffcccc
    style S fill:#ffcccc

这个方案基本上适用于我们的常规业务,但也有局限性,尤其是亿级流量的场景。

比方说当QPS达到5000-10000时,大量请求排队等待同一个幂等key的锁,那么接口的响应时间必然会变得缓慢。

而且这时候Redis的连接数、数据库的连接池也有可能成为瓶颈。

如果业务上真的达到了亿级流量,那就需要考虑更加复杂且强大的方案了,这里举些例子:

  • 基于消息队列的异步幂等处理

  • 分片式幂等设计(按用户ID或地理位置分片)

  • 基于CRDT的最终一致性幂等

  • 混合式架构(同步+异步+缓存预热)

了解即可,点到为止。

不同场景的幂等策略

并不是所有的业务系统都会用到Redis,也并不是所有的场景都需要应对高并发,我们抛砖引玉,也对其他场景做个归纳:

mindmap
  root((幂等策略选择))
    高并发场景
      Redis分布式锁
      本地锁+数据库
      消息队列削峰
    普通业务场景
      数据库唯一约束
      状态机设计
      版本号控制
    对外接口
      Token机制
      签名验证
      时间窗口限制
    内部服务
      业务主键幂等
      状态检查
      补偿机制

监控&告警

完善的幂等设计还需要结合运维,配套好线程池的监控、Redis的监控等。

当有异常业务时还可以通过钉钉、飞书、邮件等实现实时的推送告警。

最后

好的幂等设计不仅要防止业务被重复执行,还得保证良好的用户体验,不能芝麻没捡到还丢了西瓜。

幂等性的本质,其实就是要求我们的系统在面对不确定性时保持一致。

技术的迭代就是如此。

从简单粗暴的方案到最后考虑周全的设计,但这真的就是“最后”了吗?

小明走过的路其实就是千千万万个我们的成长轨迹。

我们都曾写过那些能跑就行的代码,也都被现实狠狠DISS过。但正是这些经历,让我们学会了思考边界、考虑异常、关注用户体验。

真正的成熟,不是写出多么复杂的代码,而是能在简单与复杂之间找到恰当的平衡。

知道什么时候该用简单方案,什么时候需要复杂设计;明白技术是为了解决问题,而不是炫技。

就像幂等设计一样,人生最好的状态也许就是:无论经历多少次冲击,都能保持内心的那份坚定与一致。

与君共勉。

006mowZngy1ftgq2o2m2yg305k05k40v.gif