高并发幂等设计实战:接口防重与重复扣款的代码实现方案

幂等性指同一操作执行多次与执行一次的效果相同。支付扣款、订单创建、库存扣减这类接口在网络超时重试、用户重复点击、MQ消息重投递的场景下,会被调用不止一次,没有幂等保障就会重复扣款、重复下单。这不是理论问题,是生产事故的直接来源。实现幂等的核心思路是给每个业务操作定义唯一标识,处理前先判断该标识是否已处理过。

唯一索引防重的数据库层兜底方案

最可靠的幂等手段放在数据库层,因为应用层缓存、Redis都可能异常,唯一约束是最后的防线。以用户参与一次秒杀活动为例,活动表加唯一索引限制单用户单活动仅一条记录:

CREATE TABLE activity_join_record (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  activity_id BIGINT NOT NULL,
  user_id     BIGINT NOT NULL,
  created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_activity_user (activity_id, user_id)
);

重复插入时MySQL抛出DuplicateKeyException,应用捕获后按业务语义返回已参与:

@Transactional
public JoinResult joinActivity(Long activityId, Long userId) {
    try {
        joinRecordMapper.insert(new ActivityJoinRecord(activityId, userId));
        // 首次参与,继续后续业务
        awardService.grant(activityId, userId);
        return JoinResult.success();
    } catch (DuplicateKeyException e) {
        // 唯一索引冲突 = 该用户已参与过,幂等返回
        return JoinResult.alreadyJoined();
    }
}

这个方案的前提是存在天然唯一的业务键(活动ID+用户ID)。支付场景的业务键是支付流水号,上游系统生成的out_trade_no加唯一索引即可。没有天然业务键的场景,需要应用层先生成操作凭证,也就是token机制。

Token机制与分布式锁的请求级防重实现

Token机制的流程是:客户端请求表单页时,服务端预生成一个token存入Redis并返回给页面;提交时请求带上token,服务端用Redis的原子删除操作抢占token,抢占成功才执行业务。关键在原子性,用Redis的GETDEL或LUA脚本:

-- 幂等检查LUA脚本:token存在则删除返回1,否则返回0
local val = redis.call('GET', KEYS[1])
if val then
    redis.call('DEL', KEYS[1])
    return 1
else
    return 0
end
public IdempotentResult submit(String token, OrderRequest req) {
    // 1. 原子抢占token,保证同一token只有一个请求能通过
    Long grabbed = redisTemplate.execute(idempotentScript,
            Collections.singletonList("idem:" + token));
    if (grabbed == null || grabbed == 0) {
        return IdempotentResult.duplicate(); // 重复请求直接拒绝
    }
    // 2. 抢到token后执行业务
    return orderService.create(req);
}

GETDEL保证了检查与删除在一步完成,两个并发请求携带同一token时,只有一个能读到值。如果业务执行失败,token已被删,重试请求会被误判重复,需要业务侧在失败时重新获取token,这个代价与安全性是匹配的。

分布式锁方案与token的差异在于锁会过期释放,适合保护长耗时任务,比如MQ消费场景下的重复消息。消费逻辑:

public ConsumeResult onMessage(PayMessage msg) {
    String key = "consume:" + msg.getMessageId();
    Boolean first = redisTemplate.opsForValue()
            .setIfAbsent(key, "1", Duration.ofHours(24));
    if (Boolean.FALSE.equals(first)) {
        return ConsumeResult.success(); // 已处理过,直接ACK
    }
    try {
        payService.handle(msg);       // 业务处理
    } catch (Exception e) {
        redisTemplate.delete(key);    // 失败释放,允许重投递
        throw e;
    }
    return ConsumeResult.success();
}

messageId作为幂等键,setIfAbsent即SETNX语义。24小时过期时间覆盖MQ最大重投递窗口。注意这个方案在Redis主从切换瞬间存在锁丢失的极小概率窗口,强一致场景用唯一索引兜底双保险。

状态机幂等与超时重试的边界处理

有状态的业务对象,状态机本身就能做幂等。订单支付流程中,只在待支付状态下扣款才生效:

@Transactional
public PayResult pay(String orderNo, String payId) {
    Order order = orderMapper.selectByOrderNoForUpdate(orderNo); // 行锁
    if (order.getStatus() != OrderStatus.WAIT_PAY) {
        // 已支付/已取消,重复请求按当前状态返回
        return PayResult.of(order.getStatus());
    }
    accountService.deduct(order.getUserId(), order.getAmount(), payId);
    orderMapper.updateStatus(orderNo, OrderStatus.PAID);
    return PayResult.success();
}

重复的支付请求进来时订单状态已是PAID,直接返回成功结果,客户端拿到一致响应不会误重试。行锁for update防止两个并发请求同时读到WAIT_PAY。扣款接口accountService.deduct内部再用payId唯一索引防重,形成两层保护。判断幂等边界的实践经验:读接口天然幂等不用处理;写接口中,创建类用唯一索引,状态变更类用状态机条件更新,累计类(如累加积分)把操作ID与增量一起落库防重。

最后是超时重试策略配合。设置合理的重试次数与退避间隔,指数退避公式interval = base * 2^n,把重试压力摊开。防重与重试是一体两面:接口幂等做到位,客户端与服务间重试才敢开启,系统整体的失败恢复能力才真正建立起来。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/gao-bing-fa-mi-deng-she-ji-shi-zhan-jie-kou-fang-zhong-yu/

(0)
小编小编
上一篇 50分钟前
下一篇 50分钟前

相关推荐