**摘要:**本文从接口幂等性的定义出发,说明为什么在分布式系统和微服务架构中必须关注幂等,并系统梳理唯一索引、Token 机制、状态机、乐观锁、分布式锁、去重表等常见设计方案,最后给出选型建议与落地注意事项。
一、什么是接口幂等性
接口幂等性(Idempotency)指的是:同一个请求被执行一次和执行多次,对系统产生的业务影响是一致的。换句话说,调用方因为超时、网络抖动、重试等原因重复提交了同一个请求,服务端最终的业务结果应当与只提交一次相同。
需要特别区分两个概念:
- 幂等 ≠ 结果完全相同:多次调用返回的响应体可以不同(例如第二次返回"已处理"),但业务状态不能因为重复调用而再次变化。
- 幂等 ≠ 并发安全:幂等解决的是"重复请求"问题,并发安全解决的是"同时请求"问题,两者常常需要配合使用。
二、为什么需要幂等性
在单体应用中,一次请求通常只经过一次数据库操作,重复提交的概率较低。但在分布式系统中,重复请求几乎不可避免:
- 网络超时重试:客户端未收到响应,自动重试,服务端可能已经处理成功。
- 网关与 RPC 框架重试:Feign、Dubbo、Nginx 等组件在超时后可能发起重试。
- 消息队列重复投递:Kafka、RocketMQ 在消费失败或 rebalance 时会重复投递消息。
- 用户误操作:用户连续点击"提交订单""支付"按钮。
- 定时任务重复调度:多实例部署时任务被多个节点同时触发。
如果接口不具备幂等性,重复请求可能导致重复扣款、重复下单、库存超卖、重复发券等严重业务问题。
三、哪些接口需要幂等设计
并不是所有接口都需要幂等。通常按 HTTP 语义和业务性质区分:
| 请求类型 | 是否天然幂等 | 说明 |
|---|---|---|
| GET | 是 | 只读查询,不应修改数据 |
| PUT | 是 | 全量更新,多次执行结果一致 |
| DELETE | 是 | 删除同一资源,多次执行结果一致 |
| POST | 否 | 通常用于创建资源,需要额外设计幂等 |
实际业务中,支付、下单、退款、发券、扣库存、消息消费等写操作是幂等设计的重点场景。
四、常见幂等设计方案
4.1 唯一索引 + 数据库约束
最直接、最可靠的方式。为业务唯一键建立唯一索引,重复插入时数据库会抛出唯一约束异常,服务端捕获后返回"已处理"。
sql
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_order_no (order_no)
);
优点是实现简单、由数据库保证强一致性;缺点是依赖唯一键设计,且异常处理需要区分"唯一冲突"和"其他错误"。
4.2 Token 机制(防重令牌)
流程如下:
- 客户端在进入提交页面前,先调用服务端接口获取一个全局唯一的 Token。
- 服务端将 Token 存入 Redis,设置合理过期时间。
- 客户端提交请求时携带该 Token。
- 服务端使用
DEL或 Lua 脚本原子性地删除 Token,删除成功才继续执行业务,删除失败说明是重复请求。
java
public boolean tryAcquireToken(String token) {
// 使用 DEL 的返回值判断是否首次消费,保证原子性
Long deleted = redisTemplate.opsForValue().getOperations()
.delete(TOKEN_PREFIX + token) ? 1L : 0L;
return deleted != null && deleted > 0;
}
该方案适合前端表单提交、下单等场景,能有效拦截用户重复点击。注意 Token 必须一次性使用,且过期时间不宜过长。
4.3 状态机 + 条件更新
对于有明确状态流转的业务(如订单:待支付 → 已支付 → 已发货),可以通过状态判断实现幂等:只有处于前置状态的记录才允许流转。
sql
UPDATE t_order
SET status = 1, pay_time = NOW()
WHERE order_no = #{orderNo}
AND status = 0;
根据受影响行数判断是否执行成功:返回 1 表示本次真正完成了状态流转,返回 0 表示已被处理过或状态不合法。这种方式天然幂等,且能防止并发下的重复流转。
4.4 乐观锁(版本号)
在表中增加 version 字段,更新时带上版本号条件,版本不匹配则更新失败。
sql
UPDATE t_account
SET balance = balance - #{amount}, version = version + 1
WHERE user_id = #{userId}
AND version = #{version}
AND balance >= #{amount};
乐观锁适合读多写少、冲突较少的场景。若冲突频繁,重试成本会上升,此时可考虑悲观锁或分布式锁。
4.5 分布式锁
通过 Redis、ZooKeeper 等实现分布式互斥锁,保证同一业务键在同一时刻只有一个请求在执行。
java
String lockKey = "lock:order:" + orderNo;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
if (!locked) {
throw new BizException("请求处理中,请勿重复提交");
}
try {
// 执行业务逻辑
} finally {
redisTemplate.delete(lockKey);
}
注意:分布式锁只能保证"同一时刻不并发",不能保证跨时间的重复请求幂等。因此它通常与唯一索引、状态机等方案组合使用。
4.6 去重表 / 幂等表
建立一张独立的幂等记录表,以业务唯一标识(如请求 ID、业务流水号)作为唯一索引。每次请求先插入幂等记录,插入成功才执行业务,插入冲突则直接返回上次结果。
sql
CREATE TABLE t_idempotent_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(32) NOT NULL,
biz_key VARCHAR(128) NOT NULL,
result TEXT,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz (biz_type, biz_key)
);
该方案通用性强,适合跨服务、跨表的复杂业务,但会引入额外的存储和一次写操作,需要评估性能开销。
五、方案对比与选型建议
| 方案 | 实现复杂度 | 适用场景 | 主要局限 |
|---|---|---|---|
| 唯一索引 | 低 | 创建类操作,有天然唯一键 | 依赖唯一键设计 |
| Token 机制 | 中 | 前端表单、下单提交 | 需要额外获取 Token |
| 状态机 | 低 | 有明确状态流转的业务 | 仅适用于状态型业务 |
| 乐观锁 | 低 | 更新类操作,冲突较少 | 高冲突时重试成本高 |
| 分布式锁 | 中 | 并发互斥控制 | 不解决跨时间重复 |
| 去重表 | 中高 | 跨服务复杂业务 | 额外存储与写开销 |
选型时建议遵循以下原则:
- 优先用数据库约束兜底:唯一索引是最可靠的最后一道防线。
- 按业务语义选择:状态型业务用状态机,创建型业务用唯一索引或 Token。
- 组合使用:前端 Token 拦截重复点击,后端唯一索引或状态机保证最终一致。
- 控制粒度:幂等键要能唯一标识一次业务操作,避免粒度过粗导致正常请求被误拦。
六、落地注意事项
- 幂等键的设计:优先使用业务流水号、订单号等全局唯一标识;若由客户端生成,需防止伪造和碰撞。
- 幂等记录的过期与清理:Redis Token 需设置 TTL,去重表需定期归档,避免无限增长。
- 返回结果的一致性:重复请求应返回与首次一致或明确的"已处理"结果,避免调用方误判为失败而继续重试。
- 事务边界:幂等判断与业务操作应尽量在同一事务或同一原子操作内完成,防止判断通过后业务失败导致状态不一致。
- 可观测性:对拦截到的重复请求打点监控,便于发现上游重试策略异常或客户端 bug。
七、总结
接口幂等性是分布式系统稳定性的基础保障。它的核心不是"防止请求重复到达",而是"保证重复到达不产生副作用"。实际落地时,应结合业务语义选择合适的方案,并以数据库唯一约束作为最终兜底,同时配合 Token、状态机、分布式锁等手段形成多层防护。