
在后端开发中,接口幂等性是一个很容易被忽略,但线上出问题后影响非常大的设计点。
很多业务事故并不是因为代码完全写错,而是因为系统没有处理好"重复请求"。
比如:
用户连续点击提交按钮,生成了两笔订单
网络超时后客户端重试,导致重复扣款
消息队列重复投递,导致库存被扣了两次
定时任务重复执行,生成了重复报表
第三方回调多次通知,业务状态被反复更新
这些问题背后,本质上都和一个概念有关:
幂等性
本文从实际业务角度,总结接口幂等性的常见场景、设计方式和一些容易踩坑的地方。
一、什么是接口幂等性?
简单来说,接口幂等性指的是:
同一个请求执行一次和执行多次,对系统产生的最终结果应该一致。
注意,这里说的是"最终结果一致",不是"每次返回值完全一致"。
举个例子。
查询接口天然就是幂等的:
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 异步任务这类场景,就要从接口层、数据库层、消息层和任务层一起设计。
一句话总结:
幂等不是为了防止请求重复发生,而是为了让重复发生时系统仍然正确。
