幂等性设计:接口重复调用不是小问题,是架构问题

0 阅读6分钟

幂等性设计:接口重复调用不是小问题,是架构问题

我见过太多团队在"幂等"这件事上翻车。不是不知道要做,是做了但没做对——要么做太重影响性能,要么做太轻线上出事。这篇文章把我这些年踩过的坑和判断框架整理一下。


一道面试题,暴露了一个架构盲区

先说一道我常拿来问候选人的题:

用户在支付页点击"确认支付",网络卡了,APP 超时重试。用户实际上只付了一次钱,但后端收到了两笔扣款请求。问:后端怎么保证不重复扣款?

大部分候选人会答:"加个锁。"再追问:"加什么锁?锁的粒度是订单还是用户?锁住了性能怎么保障?"十个人里有七个开始语塞。

这说明什么?幂等性设计不是加不加锁的问题,是分层的架构决策。


先说清楚,什么是幂等

HTTP 语义里,GET、PUT、DELETE 是天然幂等的——你发一次和发一百次,效果一样。但 POST(创建订单、发起支付)不是:发两次就是两个订单、两笔扣款。

所以幂等的本质是:同一个请求被重复执行,不产生副作用。

这个"副作用"在不同场景里有不同的破坏力:

  • 支付接口被重复调用 → 钱扣多了,用户投诉,资损
  • 库存扣减被重复执行 → 超卖,库存对不上
  • 消息消费被重复执行 → 积分重复发放,数据错乱

幂等做不好,不是’有风险’,是’必然翻车’。  系统跑久了,重试必然发生,网络超时必然发生,并发重复必然发生。


第一层:业务层幂等——从源头减少重复

1. Token 机制:让服务端判断"这张单我见过没"

流程:

  1. 客户端在发起请求前,先从服务端拿一个全局唯一的 token
  2. 请求带上 token,服务端用 Redis 存 token → 已处理
  3. 第一次处理成功,第二次携带同一个 token 时直接返回成功(不重复处理)

java复制

public Result handleOrderCreate(OrderCreateRequest request) {
    String idempotentKey = "order:create:" + request.getIdempotentToken();

    // 第一次:SET NX 返回 true,正常处理
    // 第二次:SET NX 返回 false,说明处理过了,直接返回
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(idempotentKey, "1", Duration.ofHours(2));

    if (!Boolean.TRUE.equals(locked)) {
        return Result.ok("订单已创建"); // 幂等返回
    }

    try {
        return doCreateOrder(request);
    } catch (Exception e) {
        redisTemplate.delete(idempotentKey); // 失败时删除,允许重试
        throw e;
    }
}

我的判断:Token 机制适合"用户可感知"的操作——支付、订单创建。但 token 生成和传递本身就有复杂度,分布式环境下要保证 token 全局唯一。

2. 状态机:天然幂等的业务设计

java复制

public void handlePayCallback(PayCallbackRequest request) {
    Order order = orderService.getByOrderNo(request.getOrderNo());

    // 状态机校验:只有待支付才能切到已支付
    if (order.getStatus() != OrderStatus.PENDING_PAY) {
        log.info("订单状态非待支付,忽略重复回调");
        return; // 幂等:重复回调直接忽略
    }

    orderService.updateStatus(order.getId(), OrderStatus.PAID);
}

状态机的好处是零额外存储、性能无损、业务语义自解释。但它只适用于状态流转是单向的场景。

我的观点:能用状态机解决的问题不用分布式锁,这是幂等设计的第一选择。


第二层:数据库层幂等——用约束兜底

3. 唯一索引:最后的防线

sql复制

CREATE TABLE t_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) NOT NULL,
    idem_key VARCHAR(64) NOT NULL COMMENT '幂等键:用户ID+场景+时间戳',
    user_id BIGINT NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    UNIQUE KEY uk_idem_key (idem_key)
);
-- idem_key = user_id + "pay" + yyyyMMddHHmmss + order_no_hash

java复制

public void createOrder(Order order) {
    try {
        orderMapper.insert(order);
    } catch (DuplicateKeyException e) {
        // 唯一键冲突 → 查回已存在订单返回
        Order exist = orderMapper.selectByIdemKey(order.getIdemKey());
    }
}

我的踩坑经验:早期只用 order_no 做唯一键,微信同一个 out_trade_no 会出现两次回调(支付成功+退款成功),幂等键设计不区分场景就误杀正常请求。幂等键 = 业务场景 + 用户标识 + 业务单号

4. 乐观锁:版本号防更新冲突

java复制

public boolean deductStock(Long productId, Integer quantity) {
    int rows = productMapper.updateStockWithVersion(
        productId, quantity, currentVersion
    );
    if (rows == 0) {
        throw new OptimisticLockException("库存更新冲突,请重试");
    }
    return true;
}

xml复制

UPDATE t_product_stock
SET stock = stock - #{quantity}, version = version + 1
WHERE id = #{productId} AND stock >= #{quantity} AND version = #{currentVersion}

乐观锁只适合冲突概率不高的场景。秒杀系统用?那重试队列会爆炸。


第三层:消息队列层幂等——消费端不能依赖上游

上游做了幂等,但 MQ 的 At-Least-Once 投递决定了:消息一定会被重复投递。Broker 重发、Consumer 超时、Rebalance——任何一种都会导致重复消费。

5. 消息去重表

java复制

@KafkaListener(topics = "order-paid")
public void handleOrderPaid(OrderPaidEvent event) {
    String msgId = event.getMessageId();

    // 先查去重表
    if (msgLogMapper.exists(msgId) > 0) return; // 已处理过

    // 事务:处理业务 + 记录消息ID
    doProcessOrderPaid(event);
    msgLogMapper.insert(new MsgLog().setMsgId(msgId));
}

去重表用数据库唯一键约束代替内存判断,解决了集群多实例的幂等问题。代价:每次查库 + 表持续膨胀需归档。

6. Redis 去重

java复制

Boolean first = redisTemplate.opsForValue().setIfAbsent(
    "msg:dedup:" + msgId, "1", Duration.ofDays(7)
);
if (!Boolean.TRUE.equals(first)) return; // 幂等返回
doChangePoints(event);

生产推荐:Redis + 去重表双保险——Redis 挡住 99%,数据库兜底 1%。


三层协作策略

层次方案作用性能影响
业务层入口Token 去重挡住用户重试极低
业务层处理中状态机校验挡住重复状态流转
数据库层唯一索引兜底防护
数据库层乐观锁防并发冲突
MQ消费层Redis 去重挡重复投递极低
MQ消费层去重表集群级兜底

经验:入口 Token + DB 唯一索引覆盖 95% 场景。MQ 层按消息重要程度决定——钱相关的必须去重表,普通通知可去掉。


一个反直觉的观点

不是所有接口都需要幂等。

判断原则是:看这个接口失败后会不会被重试。

  • 用户主动发起的操作(支付、下单)→ 必须幂等
  • 下游系统回调(支付回调、发货回调)→ 必须幂等
  • 内部定时任务、手动脚本 → 看场景
  • 读接口 → 天然幂等,不用做

不要为了’保险’给所有接口加幂等,架构是做取舍,不是堆方案。


最后

真正考验架构师功力的,不是知道多少方案,而是知道在什么场景选什么方案,知道方案之间的配合与取舍

面试时能讲清楚这个分层逻辑,比背一个"用 Redis SET NX 做幂等"要强得多。


附:幂等键设计参考公式

code复制

幂等键 = 业务场景 + 用户标识 + 业务单号 + 请求时间戳(可选)

支付幂等键 = "pay:" + userId + ":" + orderNo
库存幂等键 = "stock:" + productId + ":" + orderId
积分幂等键 = "points:" + userId + ":" + changeType + ":" + bizId

首发于「随生门户」公众号,转载须授权。