一个真实的线上事故
上个月,我们线上的支付回调接口出了一个 P1 事故。
原因是这样的:第三方支付平台的回调通知机制是"如果没收到 200 响应,就间隔重试"。有一次我们的服务在处理回调时,业务逻辑执行成功了(订单状态已更新、库存已扣减),但在返回响应前发生了 GC 停顿,第三方支付平台没收到响应,于是 30 秒后重发了一次回调。
第二次回调到达时,接口没有做幂等校验,又执行了一遍业务逻辑------库存被重复扣减,用户被多扣了一次钱。
事后排查,光对账和退款就花了两天。
这个事故的根因很简单:接口不具备幂等性。
什么是幂等性
幂等(Idempotent)这个概念来自数学,定义是:对同一个操作执行一次和执行多次,产生的结果相同。
映射到接口设计中:同一个请求,无论被服务端处理一次还是多次,对系统状态的影响是一致的。
用伪代码表达:
scss
// 幂等的接口
process(request) → 系统状态变为 S
process(request) → 系统状态仍为 S(不会因为重复调用而改变)
// 非幂等的接口
process(request) → 系统状态变为 S1
process(request) → 系统状态变为 S2(S2 ≠ S1,状态被重复修改)
哪些场景必须做幂等
不是所有接口都需要幂等。以下场景是幂等设计的重灾区:
- 支付回调:第三方支付平台可能重复推送同一笔支付成功的通知;
- 消息队列消费:MQ 的 at-least-once 语义保证消息至少被消费一次,但不保证只消费一次;
- 用户重复提交:用户快速双击"提交"按钮、网络超时后用户手动重试;
- 接口超时重试:调用方在超时后自动重试,但服务端其实已经处理成功;
- 分布式事务补偿:补偿操作可能被多次触发。
简单来说,只要存在"同一个请求可能被执行多次"的可能性,接口就必须具备幂等性。
幂等 vs 防重:两个容易混淆的概念
在讨论方案之前,需要先厘清两个经常被混用的概念:
| 维度 | 幂等(Idempotent) | 防重(Deduplication) |
|---|---|---|
| 关注点 | 多次执行结果一致 | 阻止重复执行 |
| 实现层面 | 服务端保证 | 可在客户端或服务端 |
| 典型手段 | 状态机、唯一约束 | Token 机制、按钮置灰 |
| 可靠性 | 高(服务端兜底) | 低(前端可被绕过) |
防重是优化用户体验的手段,幂等是保障数据正确的底线。 前端按钮置灰、loading 状态可以防止用户重复点击,但这只是防重,不是幂等------恶意请求或网络重试仍然可以绕过前端限制。
真正可靠的幂等必须在服务端实现。
方案一:数据库唯一约束
原理
利用数据库的唯一索引(Unique Index)来保证同一条业务数据不会被重复插入。
sql
-- 以支付回调为例,用第三方支付流水号作为唯一键
CREATE TABLE payment_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
trade_no VARCHAR(64) NOT NULL, -- 第三方支付流水号
order_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL,
UNIQUE KEY uk_trade_no (trade_no) -- 唯一约束
);
当重复的回调到达时,第二次插入会因为 trade_no 重复而触发唯一约束冲突,数据库层面直接拒绝。
代码实现
csharp
@Transactional
public void handlePayCallback(PayCallbackRequest request) {
try {
PaymentRecord record = new PaymentRecord();
record.setTradeNo(request.getTradeNo());
record.setOrderId(request.getOrderId());
record.setAmount(request.getAmount());
record.setStatus(1);
paymentRecordMapper.insert(record);
// 插入成功 = 首次处理,执行业务逻辑
orderService.completeOrder(request.getOrderId());
} catch (DuplicateKeyException e) {
// 唯一约束冲突 = 重复请求,直接返回成功
log.info("重复回调,tradeNo={}", request.getTradeNo());
}
}
适用场景
- 业务天然有唯一标识(如支付流水号、订单号);
- 幂等逻辑可以通过"插入一条记录"来表达;
- 并发量不极端高的场景。
局限性
- 只适用于"插入"类操作,对于"更新"类操作(如状态变更),唯一约束无法直接保证幂等;
- 高并发下大量重复请求会导致大量唯一约束冲突,数据库压力增大。
方案二:状态机 + 乐观锁
原理
对于"更新"类操作,通过状态机约束状态流转方向,配合乐观锁(版本号)保证每次状态变更只被执行一次。
sql
-- 订单表,带版本号
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
status TINYINT NOT NULL, -- 0:待支付 1:已支付 2:已发货 3:已完成
version INT NOT NULL DEFAULT 0, -- 乐观锁版本号
updated_at DATETIME NOT NULL
);
状态流转规则:待支付(0) → 已支付(1) → 已发货(2) → 已完成(3),不允许逆向流转。
代码实现
java
@Transactional
public void handlePayCallback(Long orderId) {
// 1. 查询当前订单
Order order = orderMapper.selectById(orderId);
// 2. 状态机校验:只有"待支付"状态才能处理支付回调
if (order.getStatus() != OrderStatus.PENDING) {
log.info("订单状态不是待支付,跳过处理,orderId={}, status={}",
orderId, order.getStatus());
return; // 重复请求,直接返回
}
// 3. 乐观锁更新:带上版本号条件
int rows = orderMapper.updateStatus(
orderId,
OrderStatus.PAID, // 目标状态
OrderStatus.PENDING, // 前置状态
order.getVersion() // 当前版本号
);
// 4. 更新成功 = 首次处理;更新失败 = 并发冲突或重复请求
if (rows == 0) {
log.info("乐观锁冲突或状态已变更,orderId={}", orderId);
return;
}
// 5. 执行后续业务逻辑
inventoryService.deduct(orderId);
notificationService.sendPaySuccess(orderId);
}
对应的 SQL:
ini
UPDATE orders
SET status = #{targetStatus},
version = version + 1,
updated_at = NOW()
WHERE id = #{orderId}
AND status = #{expectedStatus} -- 状态机约束
AND version = #{version} -- 乐观锁约束
适用场景
- 业务有明确的状态流转(如订单状态、审批流程);
- 需要防止并发修改同一资源;
- "更新"类操作的幂等保证。
局限性
- 需要业务本身具备可定义的状态机;
- 高并发下乐观锁冲突率较高时,需要配合重试机制。
方案三:Token 机制(防重令牌)
原理
服务端在请求执行前先生成一个唯一 Token 下发给客户端,客户端在提交请求时携带该 Token。服务端通过 Redis 的原子操作校验 Token 是否已被使用:
markdown
1. 客户端请求 Token → 服务端生成 Token 写入 Redis → 返回 Token
2. 客户端携带 Token 提交请求 → 服务端用 Redis DEL 原子删除 Token
→ 删除成功 = 首次请求,执行业务
→ 删除失败 = Token 已不存在,重复请求,拒绝
代码实现
less
// 生成 Token
@GetMapping("/token")
public String generateToken() {
String token = UUID.randomUUID().toString().replace("-", "");
// 设置 30 分钟过期
redisTemplate.opsForValue().set(
"idempotent:token:" + token, "1", 30, TimeUnit.MINUTES
);
return token;
}
// 业务接口
@PostMapping("/order/create")
public Result createOrder(@RequestBody OrderRequest request,
@RequestHeader("X-Idempotent-Token") String token) {
// 原子删除 Token:如果 key 存在则删除并返回 true,不存在则返回 false
Boolean deleted = redisTemplate.delete("idempotent:token:" + token);
if (Boolean.FALSE.equals(deleted)) {
// Token 不存在或已被使用 → 重复请求
return Result.fail("请勿重复提交");
}
// Token 首次使用 → 执行业务逻辑
return orderService.createOrder(request);
}
适用场景
- 用户主动提交的表单类操作(如创建订单、提交审批);
- 需要防止用户重复点击提交的场景;
- 请求没有天然的业务唯一标识时。
局限性
- 需要客户端配合,先获取 Token 再提交请求,增加了交互步骤;
- Token 有过期时间,需要合理设置;
- 对于服务端之间的调用(如 MQ 消费、回调通知),Token 机制不太适用。
方案四:基于业务唯一键的去重表
原理
创建一张独立的去重表,用业务唯一标识作为主键或唯一键。每次处理请求前先尝试插入去重记录,利用唯一约束判断是否为重复请求:
sql
CREATE TABLE idempotent_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(32) NOT NULL, -- 业务类型
biz_key VARCHAR(128) NOT NULL, -- 业务唯一键
created_at DATETIME NOT NULL,
UNIQUE KEY uk_biz (biz_type, biz_key)
);
代码实现
csharp
@Transactional
public void handleMessage(Message msg) {
String bizKey = msg.getMsgId(); // 消息ID作为业务唯一键
try {
// 尝试插入去重记录
IdempotentRecord record = new IdempotentRecord();
record.setBizType("ORDER_PAY_CALLBACK");
record.setBizKey(bizKey);
idempotentRecordMapper.insert(record);
} catch (DuplicateKeyException e) {
// 重复消息,直接返回
log.info("重复消息,bizKey={}", bizKey);
return;
}
// 首次处理,执行业务逻辑
processPayCallback(msg);
}
适用场景
- MQ 消费场景,消息 ID 天然作为去重键;
- 多种业务类型共用一套幂等机制;
- 需要长期保留幂等记录用于审计。
局限性
- 去重表会持续增长,需要定期清理过期数据;
- 每次请求都多一次数据库写入,有一定性能开销。
方案五:分布式锁
原理
利用 Redis 分布式锁(如 Redisson)对同一业务资源加锁,确保同一时刻只有一个请求能执行临界区逻辑:
csharp
public void handlePayCallback(String tradeNo) {
String lockKey = "lock:pay:callback:" + tradeNo;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,等待 0 秒,锁定 30 秒自动释放
if (lock.tryLock(0, 30, TimeUnit.SECONDS)) {
try {
// 加锁成功,检查是否已处理
if (isAlreadyProcessed(tradeNo)) {
log.info("已处理过,tradeNo={}", tradeNo);
return;
}
// 执行业务逻辑
processPayCallback(tradeNo);
// 标记为已处理
markAsProcessed(tradeNo);
} finally {
lock.unlock();
}
} else {
// 未获取到锁,说明有相同请求正在处理
log.info("获取锁失败,可能有重复请求正在处理,tradeNo={}", tradeNo);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
适用场景
- 需要严格控制并发执行的场景;
- 业务逻辑复杂,无法通过简单的唯一约束或状态机保证幂等;
- 作为其他方案的补充手段。
局限性
- 分布式锁本身有性能开销;
- 锁的超时时间需要合理设置,太短可能导致业务未执行完锁就释放,太长可能导致故障时锁无法释放;
- 单独使用分布式锁不能保证幂等(锁释放后重复请求仍可执行),需要配合"是否已处理"的判断逻辑。
方案选型决策指南
arduino
你的请求有天然的业务唯一标识吗?(如支付流水号、消息ID)
│
├── 有
│ ├── 操作是"插入"类 → 方案一:数据库唯一约束
│ ├── 操作是"更新"类,有状态流转 → 方案二:状态机 + 乐观锁
│ └── 需要长期保留去重记录 → 方案四:去重表
│
└── 没有
├── 是用户主动提交 → 方案三:Token 机制
└── 是服务端调用(回调/MQ消费) → 方案五:分布式锁 + 处理标记
实际工程中,往往不是只用一种方案,而是组合使用。例如:
- 状态机 + 唯一约束:状态机保证流转正确性,唯一约束兜底防重;
- 分布式锁 + 去重表:分布式锁控制并发,去重表保证最终幂等;
- Token + 唯一约束:Token 防前端重复提交,唯一约束防服务端重复处理。
几个容易踩的坑
1. 不要用 SELECT + INSERT 代替唯一约束
ini
// 错误示范:存在并发漏洞
PaymentRecord existing = mapper.selectByTradeNo(tradeNo);
if (existing == null) {
mapper.insert(newRecord); // 两个线程可能同时通过 null 检查
}
在高并发下,两个线程可能同时执行 SELECT 都返回 null,然后都执行 INSERT,导致重复插入。必须依赖数据库的唯一约束来保证原子性。
2. 幂等校验必须在业务逻辑之前
kotlin
// 错误:先执行业务再校验幂等
orderService.completeOrder(orderId);
if (isDuplicate(request)) { return; } // 太晚了,业务已经执行
// 正确:先校验幂等再执行业务
if (isDuplicate(request)) { return; }
orderService.completeOrder(orderId);
3. 注意幂等记录的过期与清理
去重表、Redis 中的幂等标记都不能无限增长。需要根据业务特点设置合理的过期时间------例如支付回调的去重记录保留 7 天即可(第三方支付平台的重试不会超过 24 小时)。
4. 幂等不等于"返回相同响应"
幂等保证的是"系统状态不变",不要求返回完全相同的响应。例如第一次请求返回"创建成功",重复请求返回"已处理",两者响应不同,但系统状态是一致的------这仍然是幂等的。
小结
接口幂等性设计的核心思路可以总结为一句话:找到一种方式,让服务端能够识别"这是同一个请求的第 N 次到达",并对第 2 次及以后的到达做无害化处理。
具体的技术手段------唯一约束、状态机、Token、去重表、分布式锁------都是这个思路的不同实现。选哪种,取决于你的业务场景、数据特征和性能要求。
但有一条原则是通用的:不要信任调用方会只发一次请求。 无论是第三方平台的回调重试、MQ 的消息重投、还是用户的手抖双击,重复请求是分布式系统中必然会出现的情况。幂等设计不是"锦上添花",而是"必备防线"。