接口幂等性设计:如何避免重复提交、重复扣款和消息重复消费?

在后端开发中,接口幂等性是一个很容易被忽略,但线上出问题后影响非常大的设计点。

很多业务事故并不是因为代码完全写错,而是因为系统没有处理好"重复请求"。

比如:

复制代码
用户连续点击提交按钮,生成了两笔订单
网络超时后客户端重试,导致重复扣款
消息队列重复投递,导致库存被扣了两次
定时任务重复执行,生成了重复报表
第三方回调多次通知,业务状态被反复更新

这些问题背后,本质上都和一个概念有关:

复制代码
幂等性

本文从实际业务角度,总结接口幂等性的常见场景、设计方式和一些容易踩坑的地方。


一、什么是接口幂等性?

简单来说,接口幂等性指的是:

复制代码
同一个请求执行一次和执行多次,对系统产生的最终结果应该一致。

注意,这里说的是"最终结果一致",不是"每次返回值完全一致"。

举个例子。

查询接口天然就是幂等的:

复制代码
GET /api/order/10001

无论调用多少次,都只是查询订单,不会改变系统状态。

但创建订单接口就不一定:

复制代码
POST /api/order/create

如果没有任何限制,用户请求两次,就可能创建两笔订单。

所以幂等性主要关注的是会修改系统状态的接口,比如:

复制代码
创建订单
发起支付
扣减库存
领取优惠券
提交表单
创建会议记录
发送通知
消费消息
处理回调

二、为什么接口会被重复调用?

很多人会觉得:

复制代码
前端不要重复提交不就行了吗?

但真实线上环境里,重复请求来源非常多,不是前端禁用按钮就能解决。

常见原因包括:

场景 重复原因
用户操作 连续点击按钮、刷新页面、返回后再次提交
网络异常 请求超时,客户端自动重试
网关重试 代理层或网关配置了失败重试
RPC 调用 上游服务超时后重新发起调用
MQ 消息 消息队列至少一次投递导致重复消费
定时任务 多实例同时执行任务
第三方回调 支付、物流、审核系统多次通知
页面恢复 移动端 App 切回前台后重复提交

所以幂等性不能只依赖前端,也不能只依赖调用方。

真正可靠的幂等设计,一定要在服务端完成。


三、哪些接口需要做幂等?

不是所有接口都需要额外设计幂等性。

可以按请求类型简单分类。

1. 查询接口

查询接口一般天然幂等。

复制代码
GET /api/user/profile
GET /api/order/list

这类接口不会修改系统状态,通常不需要额外处理。

2. 更新接口

更新接口要看更新方式。

例如:

复制代码
UPDATE user SET nickname = 'Tom' WHERE id = 1;

执行多次结果一样,通常是幂等的。

但下面这种就不一定:

复制代码
UPDATE account SET balance = balance - 100 WHERE user_id = 1;

执行一次扣 100,执行两次扣 200,明显不幂等。

3. 创建接口

创建接口通常需要重点关注。

比如:

复制代码
POST /api/order/create
POST /api/payment/pay
POST /api/coupon/receive

这类接口如果重复执行,可能会生成多条业务数据。

4. 消息消费

消息消费也必须考虑幂等。

很多 MQ 都不能保证消息只被消费一次,更多是保证"至少投递一次"。

也就是说,消费者一定要能处理重复消息。


四、方案一:前端防重复提交

最基础的方式是在前端做按钮状态控制。

例如:

复制代码
let submitting = false;

async function submitForm() {
  if (submitting) return;

  submitting = true;

  try {
    await request('/api/order/create', {
      method: 'POST',
      body: JSON.stringify(formData)
    });
  } finally {
    submitting = false;
  }
}

或者在 UI 上禁用按钮:

复制代码
<button disabled={submitting}>提交中...</button>

这种方式可以解决一部分用户连续点击问题。

但它不是完整的幂等方案。

原因很简单:

复制代码
用户可以刷新页面
请求可以被重放
移动端可能重复发起请求
网关和服务之间可能重试
消息队列仍然可能重复投递

所以前端防重只能作为第一层优化,不能作为最终保障。


五、方案二:数据库唯一约束

数据库唯一约束是最可靠、最简单的幂等手段之一。

例如用户领取优惠券的场景:

复制代码
同一个用户,同一张优惠券,只能领取一次。

可以设计表结构:

复制代码
CREATE TABLE user_coupon (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    coupon_id BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_user_coupon (user_id, coupon_id)
);

插入时:

复制代码
INSERT INTO user_coupon(user_id, coupon_id, created_at)
VALUES (10001, 20001, NOW());

如果重复插入,数据库会直接报唯一键冲突。

代码里捕获异常后,可以返回"已领取":

复制代码
try {
    userCouponMapper.insert(userId, couponId);
    return "领取成功";
} catch (DuplicateKeyException e) {
    return "已领取";
}

这种方式的优点是:

复制代码
实现简单
可靠性高
不依赖缓存
并发情况下也能生效

缺点是:

复制代码
只适用于能抽象出唯一业务键的场景
异常处理要写清楚
高并发下可能有数据库压力

六、方案三:请求唯一号 Idempotency Key

有些接口没有天然唯一业务键,或者业务键生成在服务端。

这时可以让客户端生成一个唯一请求号。

例如:

复制代码
POST /api/order/create
Idempotency-Key: 7f7a9b9e-0c6a-4e7b-9a21-d3b4d02f1c90

服务端第一次收到这个 key 时,正常处理请求,并记录处理结果。

如果后续再次收到同一个 key,就直接返回第一次的结果。

伪代码如下:

复制代码
public OrderCreateResult createOrder(CreateOrderRequest request, String idempotencyKey) {
    IdempotencyRecord record = idempotencyMapper.findByKey(idempotencyKey);

    if (record != null) {
        return JSON.parseObject(record.getResponseBody(), OrderCreateResult.class);
    }

    OrderCreateResult result = doCreateOrder(request);

    idempotencyMapper.insert(
        idempotencyKey,
        "ORDER_CREATE",
        JSON.toJSONString(result),
        LocalDateTime.now()
    );

    return result;
}

幂等记录表可以这样设计:

复制代码
CREATE TABLE idempotency_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    idempotency_key VARCHAR(128) NOT NULL,
    biz_type VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    response_body TEXT,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_idempotency_key (idempotency_key)
);

这种方案适合:

复制代码
创建订单
创建支付单
提交表单
创建会议任务
生成导出文件

需要注意的是,Idempotency-Key 不能长期无限保存。

通常要设置过期时间,例如保存 24 小时或 7 天,视业务场景而定。


七、方案四:状态机控制

很多业务并不是简单的"执行一次",而是有明确状态流转。

比如订单状态:

复制代码
待支付 → 已支付 → 已发货 → 已完成

支付回调可能重复通知。

如果没有状态判断,可能会重复处理。

正确做法是先判断当前状态:

复制代码
public void handlePayCallback(String orderNo) {
    Order order = orderMapper.findByOrderNo(orderNo);

    if (order == null) {
        throw new BizException("订单不存在");
    }

    if ("PAID".equals(order.getStatus())) {
        return;
    }

    if (!"WAIT_PAY".equals(order.getStatus())) {
        throw new BizException("订单状态不允许支付");
    }

    orderMapper.updateStatus(orderNo, "PAID");
}

更严谨的方式是在 SQL 层加状态条件:

复制代码
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE order_no = 'ORDER_10001'
  AND status = 'WAIT_PAY';

然后判断影响行数:

复制代码
int rows = orderMapper.markPaid(orderNo);

if (rows == 0) {
    return;
}

这样即使多个回调并发执行,也只有一个请求能成功改变状态。


八、方案五:分布式锁

分布式锁也经常用于幂等控制。

比如用户提交某个任务:

复制代码
同一个用户同一时间只能发起一个导出任务

可以使用 Redis 锁:

复制代码
String lockKey = "export:lock:" + userId;

Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, "1", Duration.ofSeconds(30));

if (!Boolean.TRUE.equals(locked)) {
    throw new BizException("任务处理中,请勿重复提交");
}

try {
    createExportTask(userId);
} finally {
    redisTemplate.delete(lockKey);
}

分布式锁适合解决短时间重复请求。

但要注意几个问题:

复制代码
锁必须设置过期时间
业务执行时间不能超过锁过期时间
释放锁时要确认锁归属
不要把分布式锁当成唯一保障

更安全的 Redis 锁应该带上 requestId:

复制代码
String requestId = UUID.randomUUID().toString();

Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, requestId, Duration.ofSeconds(30));

释放锁时判断 value 是否一致,避免误删其他请求的锁。


九、方案六:消息消费幂等

消息队列重复消费是非常常见的问题。

例如订单支付成功后发送消息:

复制代码
{
  "messageId": "msg_10001",
  "orderNo": "ORDER_10001",
  "event": "ORDER_PAID"
}

消费者收到后要先判断是否处理过。

可以建立消息消费记录表:

复制代码
CREATE TABLE message_consume_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    message_id VARCHAR(128) NOT NULL,
    consumer_group VARCHAR(64) NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_msg_group (message_id, consumer_group)
);

消费逻辑:

复制代码
public void consume(Message message) {
    try {
        consumeRecordMapper.insert(message.getMessageId(), "order-service");
    } catch (DuplicateKeyException e) {
        return;
    }

    handleBusiness(message);
}

这种方式简单有效。

但更完整的实现还要考虑事务问题。

如果先插入消费记录,再执行业务逻辑失败,那么后续重试会被误判为已消费。

所以更推荐把"消费记录"和"业务处理"放在同一个本地事务里:

复制代码
@Transactional
public void consume(Message message) {
    consumeRecordMapper.insert(message.getMessageId(), "order-service");
    handleBusiness(message);
}

如果业务失败,事务回滚,消费记录也不会保留。


十、真实业务中怎么选方案?

幂等方案没有唯一标准,通常要根据业务选择。

|-----------|--------------------------|
| 场景 | 推荐方案 |
| 防止按钮重复点击 | 前端禁用按钮 + 服务端幂等 |
| 用户领取权益 | 数据库唯一索引 |
| 创建订单 | Idempotency Key + 唯一业务单号 |
| 支付回调 | 状态机 + SQL 条件更新 |
| MQ 重复消费 | 消息消费记录表 |
| 短时间重复任务 | Redis 分布式锁 |
| 定时任务多实例执行 | 分布式锁 + 任务状态表 |
| 文件导出任务 | 任务唯一键 + 状态机 |
| 第三方通知 | 回调流水号 + 状态判断 |

通常不要只依赖一种手段。

比如支付场景里,可能同时需要:

复制代码
支付单唯一索引
支付状态机
第三方流水号唯一约束
回调幂等处理
消息消费幂等

这类高风险业务一定要多层保障。


十一、AI 应用里的幂等问题

现在很多系统开始接入 AI 能力,幂等问题也变得更明显。

例如:

复制代码
语音转文字任务
文档摘要生成
会议纪要生成
实时翻译片段处理
AI 客服回复生成
图片识别任务

这些任务通常都有几个特点:

复制代码
耗时较长
可能异步执行
可能调用外部模型服务
失败后需要重试
结果需要落库

如果没有幂等设计,很容易出现:

复制代码
同一段音频被重复转写
同一场会议生成多份纪要
同一条消息重复触发 AI 回复
同一个翻译片段被重复写入

例如跨语言会议场景中,像**同言翻译(Transync AI)**这类实时翻译工具,会涉及实时字幕、AI 会议总结、关键词上下文等链路。站在工程角度看,这类系统除了模型效果,也需要处理任务重试、结果落库、片段去重、会议记录合并等幂等问题。

比如可以为每个会议生成一个 meetingId,为每个语音片段生成一个 segmentId

复制代码
{
  "meetingId": "m_10001",
  "segmentId": "seg_000123",
  "sourceText": "Let's review the API timeout issue.",
  "targetText": "我们来回顾一下 API 超时问题。"
}

落库时增加唯一索引:

复制代码
CREATE UNIQUE INDEX uk_meeting_segment
ON meeting_transcript(meeting_id, segment_id);

这样即使服务重试,也不会重复写入同一段字幕。


十二、幂等设计容易踩的坑

1. 只在前端防重

前端防重只能提升体验,不能保证安全。

服务端必须兜底。

2. 分布式锁没有过期时间

如果服务执行到一半宕机,锁没有过期时间,就可能造成死锁。

3. 锁时间小于业务执行时间

如果锁 10 秒过期,但业务执行 30 秒,后续请求可能重新拿到锁,导致并发执行。

4. 消费记录和业务操作不在同一事务

这会导致"记录已消费,但业务没执行成功"的问题。

5. 幂等 key 粒度太粗

比如只用 userId 做幂等 key,可能导致用户无法同时提交不同任务。

更合理的是:

复制代码
userId + bizType + requestId

6. 幂等记录永不过期

幂等记录如果一直保留,表会越来越大。

需要定期清理历史数据。


十三、一套接口幂等性设计清单

开发新接口时,可以用下面这套清单检查:

复制代码
1. 这个接口是否会修改系统状态?
2. 用户是否可能重复提交?
3. 客户端或网关是否可能重试?
4. MQ 是否可能重复投递?
5. 是否有天然业务唯一键?
6. 是否需要客户端传 Idempotency-Key?
7. 数据库是否有唯一约束兜底?
8. 是否有状态机判断?
9. 是否需要分布式锁?
10. 幂等记录是否和业务操作在同一事务?
11. 重复请求应该返回成功还是提示重复?
12. 幂等数据是否需要过期清理?

这套检查不复杂,但可以避免很多线上事故。


总结

接口幂等性不是一个单独的技术点,而是一种系统稳定性设计思路。

它要解决的问题是:

复制代码
同一个业务请求被执行多次时,系统结果仍然可控。

常见方案包括:

复制代码
前端防重复提交
数据库唯一索引
Idempotency Key
状态机控制
分布式锁
消息消费记录
SQL 条件更新

实际项目里,最可靠的方式通常是组合使用。

对于低风险业务,可以用唯一索引或请求 key 解决。

对于支付、库存、权益、AI 异步任务这类场景,就要从接口层、数据库层、消息层和任务层一起设计。

一句话总结:

复制代码
幂等不是为了防止请求重复发生,而是为了让重复发生时系统仍然正确。
相关推荐
阿狸猿1 小时前
论基于架构的软件设计方法及应用
架构
铁皮饭盒2 小时前
彩色命令行,Node21自带函数1行实现 ,Bun也兼容, 附Bun.color实现渐变色的代码
前端·后端
锋行天下2 小时前
关于websocket,真实场景踩坑经验
前端·后端
PinkSun2 小时前
我用Spring AI做了个简历优化工具(1):Structured Output实战,让AI返回Java对象
后端
用户372927651252 小时前
从我的 Sidecar 到 vLLM:LLM 推理调度的进化
架构
东风微鸣2 小时前
Argo CD 用户管理:本地用户配置与权限分离实践
git·后端
Yeats_Liao2 小时前
Java网络编程(五):Selector选择器与高并发实现
java·后端·架构
小小龙学IT2 小时前
Go语言后端开发入门指南
开发语言·后端·golang
土星碎冰机2 小时前
实现飞书群推送报错接口,critical复现curl
后端·飞书