为什么会重复
用户点了一次提交,前端因为超时又重试了一次;网关等不到响应,把请求转给了另一台实例;MQ 消费失败,消息被重新投递。这些场景在分布式系统里几乎必然发生,客户端无法确认“服务端到底处理了没有”,只能重试。重试带来的是至少一次投递,而不是恰好一次。
幂等要解决的就是:同一个业务请求到达多次,最终效果和执行一次相同。注意是“效果相同”,不是“请求被拒绝”。重复请求可以返回第一次的结果,也可以返回“处理中”,但不能重复扣款、重复创建订单。
幂等的几个层次
| 手段 | 强度 | 适用场景 | 局限 |
|---|---|---|---|
| 数据库唯一索引 | 强 | 创建类操作,有天然业务唯一键 | 需要能定义唯一键 |
| 状态机条件更新 | 强 | 支付、发货等状态流转 | 只适用于有状态实体 |
| 幂等 Token | 中 | 表单提交、下单入口 | 需要客户端配合 |
| Redis SETNX 去重 | 中 | 短时间内的重复请求 | 过期后失效,需兜底 |
| 分布式锁 | 弱 | 串行化执行 | 只防并发,不防先后重复 |
实际项目通常组合使用:入口用 Token 或请求号挡住大部分重复,数据库唯一索引做最终兜底,状态机保证流转不会倒退。只靠 Redis 或只靠锁,都会在极端情况下漏掉。
代码:Token + 唯一索引
客户端每次提交生成一个 Idempotency-Key,服务端第一次见到它时占位,处理成功后把结果缓存起来,重复请求直接返回第一次的结果:
@PostMapping("/orders")
public Result<OrderVO> create(@RequestHeader("Idempotency-Key") String requestId,
@RequestBody @Valid CreateOrderRequest request) {
String key = "idem:order:" + requestId;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return Result.ok(JsonUtils.parse(cached, OrderVO.class));
}
Boolean first = redis.opsForValue().setIfAbsent(key, "PROCESSING", Duration.ofMinutes(10));
if (!Boolean.TRUE.equals(first)) {
throw new BizException("请求正在处理中,请勿重复提交");
}
try {
OrderVO order = orderService.create(requestId, request);
redis.opsForValue().set(key, JsonUtils.toJson(order), Duration.ofHours(24));
return Result.ok(order);
} catch (RuntimeException e) {
redis.delete(key);
throw e;
}
}
占位值用 PROCESSING 而不是直接写结果,是为了区分“正在处理”和“已经完成”。处理失败要删除 key,否则用户重试会被一直挡住;但如果是“数据库已提交、响应丢失”,删除 key 会导致下一次重复执行,所以还需要数据库唯一索引兜底:
CREATE TABLE orders (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(16) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_request_id (request_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
@Transactional(rollbackFor = Exception.class)
public OrderVO create(String requestId, CreateOrderRequest request) {
Order order = new Order(requestId, request.userId(), request.amount());
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
Order existing = orderMapper.selectByRequestId(requestId);
if (existing == null) {
throw e;
}
return OrderVO.from(existing);
}
return OrderVO.from(order);
}
request_id 必须是全局唯一且不可猜测的,通常由客户端生成 UUID,或者由服务端在下单页初始化时下发。不要把用户 ID、时间戳拼一拼就用,重复概率和安全性都不可控。
状态机条件更新
支付回调、发货通知这类操作,用条件更新比查状态再更新更可靠:
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE id = #{orderId} AND status = 'UNPAID';
受影响行数为 1 表示本次真的完成了支付流转,为 0 表示已经支付过或状态不允许。业务代码判断影响行数,不要先 SELECT 再 UPDATE,那中间存在并发窗口。
常见坑
1. 用分布式锁代替幂等
锁只能保证同一时刻只有一个线程执行,第一个请求释放锁后,重复请求还是会进来执行第二次。锁解决的是并发,幂等解决的是重复,两者不能互相替代。
2. Token 用完没有删除或过期太长
Token 一次性使用,处理成功后就该失效;如果做成永久有效,用户第二天用同一个 key 提交会被误判为重复。TTL 要覆盖“客户端可能重试的时间窗”,通常几小时到一天。
3. 幂等键没有绑定请求内容
同一个 key 被用于不同的请求体,服务端会把第二次请求当成重复,直接返回第一次的结果。幂等键应该和用户、接口、请求参数绑定,或者对请求体做摘要,发现内容不一致时返回冲突而不是旧结果。
4. Redis 挂了就完全失效
只用 Redis 做幂等,Redis 故障时要么全部放行、要么全部拒绝。放行会重复,拒绝会丢单。关键业务必须有数据库唯一约束兜底,Redis 只是性能层。
5. 唯一索引和软删除冲突
订单软删除后,同一个 request_id 再次插入会撞唯一索引。要么唯一索引带上删除标记,要么用独立的幂等记录表,不要为了“能删”去掉唯一约束。
6. MQ 消费端没有幂等
生产者发送成功不代表消费只执行一次,网络抖动、消费者重启都会导致重复投递。消费端要用消息 ID 或业务唯一键做去重,不能假设 MQ 保证恰好一次。
排查步骤
出现重复订单时,先按业务唯一键查数据:
SELECT id, request_id, user_id, amount, status, created_at
FROM orders
WHERE request_id = '上送的单号';
再确认 Redis 里的幂等键状态和过期时间:
redis-cli GET idem:order:8f3c2a
redis-cli TTL idem:order:8f3c2a
redis-cli --scan --pattern 'idem:order:*' | head -n 20
然后看网关和客户端的重试配置:超时时间是不是短于接口实际耗时,重试次数是不是没有上限。最后检查数据库唯一索引是否真的存在:
SHOW INDEX FROM orders WHERE Key_name = 'uk_request_id';
如果索引存在但仍有重复,重点查是不是走了不同的库或分片,或者唯一键里带了时间戳、随机数导致每次都不一样。
小结
幂等不是加一个注解就能解决的,它是接口契约的一部分:客户端负责生成稳定的幂等键,服务端负责占位、去重、返回一致结果,数据库负责最终兜底。先想清楚“重复请求应该返回什么”,再选技术手段,顺序反了,代码只会越写越乱。
评论