重试三遍,订单三单:我说的是接口幂等性,不是玄学
先讲个真实的故事。
某天晚上十一点,我正在愉快地打着游戏,手机突然震了。运维群弹出消息:订单系统出现异常,有用户收到了三笔相同的扣款通知。我当时的反应是——血压瞬间从打游戏时的放松状态飙升到开会时的战斗状态。
查了一个小时,结论是这样的:支付接口超时了,前端没收到成功响应,于是前端重试了三次。支付系统没有做幂等处理,三次调用全部执行成功,钱扣了三次。
用户的钱打了水漂?不对,是打到了我们公司账上。用户打电话来投诉,客服以为是诈骗。场面一度非常尴尬。
这个故事告诉我们一个道理:接口幂等性这事儿,不做不知道,一做就后悔——后悔没早点做。
什么是幂等性?先把这个概念说人话
幂等性这个概念,最早是从数学里来的。意思是 f(f(x)) = f(x)。但在API设计里,它的意思是:同一个请求,你执行一次和执行多次,效果是一样的。
听起来挺简单的对吧?但问题出在"真实世界"这三个字上。
真实世界里,网络会超时、服务器会宕机、负载均衡会漂移、前端会手抖点重试。这些情况会导致什么?同一个请求,可能被发送多次。而你的后端,如果没有做幂等处理,每一次调用都会真实执行。
想象一下这个场景:用户下单,调用创建订单接口。如果这个接口没有幂等保护,网络超时后前端重试,就会创建出多个订单。用户一脸懵逼:我只点了一次下单,为什么收到三条订单确认短信?
更极端的例子:支付接口。用户支付完成后,网络超时了,前端重试。如果支付接口没有幂等处理,就会出现重复扣款。这就不是用户体验问题了,这是法律问题——资金安全是底线。
所以幂等性不是什么高深莫测的概念,它就是:你设计的接口,能不能在被人调用多次的情况下,还能保持正确行为?
哪些接口必须做幂等?列个清单
不是所有接口都需要做幂等处理。一个只读的查询接口,你调用一百遍和调用一遍效果完全一样——它本身就是幂等的,不需要额外处理。
但下面这些接口,必须做幂等处理:
1. 写操作接口
POST 请求,尤其是涉及资金、订单、库存等业务实体的创建操作。这些接口如果被重复调用,会导致数据重复创建或者数据不一致。
2. 状态变更接口
比如订单状态从"待支付"改成"已支付"。如果这个接口被重复调用,状态可能会被错误地覆盖或者跳跃。想象一下:订单本来已经取消了,但支付回调接口因为网络问题被重试,状态被改成了"已支付"——这就出大事了。
3. 第三方交互接口
调用微信支付、调用短信网关、调用物流接口……这些和外部系统交互的接口尤其要注意。因为你不知道外部系统会不会因为超时而收到你的重复请求。
我之前踩过一个坑:调用短信接口发验证码,超时了,前端重试,结果用户收到了两条验证码。我当时还以为是短信通道的问题,查了半天才发现是自己的接口没有做幂等处理,两次调用都真实发送了短信。用户投诉说我们乱发短信,解释了好久才说清楚。
实现幂等性的几种武器
说完了什么是幂等性和为什么要做幂等性,现在讲点硬核的:怎么实现幂等性。
方案一:唯一请求ID(最推荐)
这是最标准、最通用的方案。思路是这样的:
1. 客户端在发起请求时,生成一个全局唯一的 ID(通常是 UUID)
2. 将这个 ID 放在请求头或者请求体里
3. 服务端使用这个 ID 作为分布式锁的 key,或者将它存入 Redis/数据库
4. 服务端在处理请求前,先检查这个 ID 是否已经被处理过
5. 如果已经处理过,直接返回上次的结果;如果没有,执行正常逻辑并记录结果
// 服务端伪代码
public Result handleCreateOrder(Request request) {
String idempotentKey = request.getHeader("X-Idempotent-Key");
// 检查是否已经处理过
Result cached = redis.get("idempotent:" + idempotentKey);
if (cached != null) {
return cached; // 直接返回之前的结果
}
// 执行创建订单逻辑
Result result = orderService.createOrder(request);
// 记录结果,设置过期时间
redis.setex("idempotent:" + idempotentKey, 24*3600, result);
return result;
}
这个方案的优点是实现简单、效果可靠。缺点是需要客户端配合生成唯一 ID。但这个缺点其实不算缺点——现代前端框架生成一个 UUID 简直不要太容易。
方案二:数据库唯一索引
如果你的接口是创建数据的操作,可以用数据库唯一索引来保证幂等性。
比如创建订单这个操作,可以在订单表上建一个唯一索引,索引字段是业务 ID(比如订单编号)。这样,如果同一个订单编号被插入两次,数据库会报错,你捕获这个异常就行了。
-- 创建订单表,带唯一索引
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL UNIQUE, -- 订单编号,唯一索引
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 插入订单时,如果重复会抛异常
INSERT INTO orders (order_no, user_id, amount) VALUES ('ORDER_20260816_001', 10001, 99.90);
这个方案的优点是不需要额外的缓存层,直接依赖数据库保证。缺点是只适用于"保证数据不重复创建"的场景,对于"状态变更"类操作就无能为力了。
方案三:基于状态机的乐观锁
对于状态变更类操作,可以用乐观锁来实现幂等性。思路是:在更新数据时,检查当前状态是否符合预期,只有符合预期才执行更新。
-- 订单状态变更,使用乐观锁
UPDATE orders
SET status = 'PAID', pay_time = NOW()
WHERE order_no = 'ORDER_20260816_001'
AND status = 'PENDING'; -- 必须是待支付状态才能改成已支付
-- 如果返回影响行数为0,说明状态已经被其他操作改过了
这个方案有个巨重要的细节:状态变更必须是单向的,或者状态机必须是完善的。什么意思?比如订单状态:待支付 → 已支付 → 已完成 → 已取消。这个链条里,"已支付"只能由"待支付"变过来,不能由"已取消"变过来。这样用乐观锁就安全。
但如果你的状态机设计有问题,比如"已取消"也能改成"已支付",那乐观锁也救不了你。
方案四:防重令牌
这种方案适合前端防抖和防重复提交的场景。思路是:
1. 前端在展示表单时,向服务端获取一个令牌
2. 表单提交时,带着这个令牌一起提交
3. 服务端校验令牌有效后,执行操作,然后销毁令牌
// 获取令牌
public String generateToken(String userId) {
String token = UUID.randomUUID().toString();
redis.setex("token:" + token, 300, userId); // 5分钟有效期
return token;
}
// 校验令牌
public boolean validateAndConsumeToken(String token, String userId) {
String stored = redis.get("token:" + token);
if (userId.equals(stored)) {
redis.del("token:" + token); // 使用后删除
return true;
}
return false;
}
这个方案的优点是用户体验好——用户点击提交后,令牌就被消费了,即使手抖再点一次,第二次请求会因为令牌无效而被拒绝。缺点是多一次网络请求(获取令牌),而且需要前端配合改造。
最容易踩的坑,我帮你踩过了
坑一:过期时间设置不合理
如果你用 Redis 做幂等记录,过期时间设置太小,正常的重试请求可能刚好超过过期时间,导致幂等失效。设置太大,又会浪费内存。我的经验是:一般业务操作,24小时比较合适;支付类操作,建议72小时起步。
坑二:只考虑了成功路径,没考虑失败路径
有些同学做幂等处理时,只考虑了正常流程——请求成功了,记录结果。 但如果请求执行到一半失败了(比如数据库插入成功但 Redis 记录失败),下次重试会怎样?会再次执行一遍。
所以幂等记录应该在业务逻辑执行成功之后才写入,而不是执行之前。如果业务逻辑执行失败,不要写入幂等记录,让重试能够正常执行。
坑三:分布式环境下的并发问题
单机环境下,用 synchronized 或者 ReentrantLock 就能保证幂等。但分布式环境下怎么办?
答案是用分布式锁。Redis 的 SETNX 或者 Zookeeper 的临时顺序节点都能实现分布式锁。但要注意:锁的粒度要精确到具体请求,不要锁住整个接口,否则会严重影响并发性能。
public Result handleRequest(Request request) {
String lockKey = "lock:idempotent:" + request.getIdempotentKey();
// 尝试获取分布式锁,最多等3秒
boolean locked = redis.setnx(lockKey, "1", 3);
if (!locked) {
throw new IdempotencyException("请求正在处理中,请勿重复提交");
}
try {
// 业务逻辑
return doBusiness(request);
} finally {
redis.del(lockKey);
}
}
坑四:没有区分"可重试"和"不可重试"错误
不是所有错误都应该重试的。比如业务校验失败(余额不足、库存不够),这类错误重试多少次都是失败,反而浪费资源。 只有网络超时、服务器宕机这类"临时性错误"才应该重试。
所以你的重试机制要有判断逻辑:什么错误能重试,什么错误不能重试。不要一把梭哈全是重试。
一个完整的幂等处理流程
说了这么多理论,来个完整的流程镇楼:
1. [客户端] 生成唯一请求ID(UUID),放到 Header X-Idempotent-Key
2. [服务端] 收到请求,检查 X-Idempotent-Key 是否存在
3. [服务端] 如果不存在,返回错误:缺少幂等标识
4. [服务端] 如果存在,尝试获取分布式锁(key = "lock:" + X-Idempotent-Key)
5. [服务端] 获取锁失败,说明有其他请求正在处理,返回"请求正在处理中"
6. [服务端] 获取锁成功,检查 Redis 中是否有缓存结果(key = "result:" + X-Idempotent-Key)
7. [服务端] 如果有缓存结果,直接返回(幂等生效)
8. [服务端] 如果没有缓存,执行业务逻辑
9. [服务端] 业务执行成功,将结果写入 Redis(设置过期时间,如24小时)
10. [服务端] 释放分布式锁
11. [服务端] 返回结果给客户端
这个流程看起来复杂,但实际上每个步骤都很简单。而且一旦封装好之后,所有接口加幂等保护就是加一行注解的事儿。
如果你用的 Spring Boot,可以用拦截器或者 AOP 把这个逻辑封装起来。代码我就不写了——留给你当作业吧,反正你也不想看太多代码。
最后说两句
幂等性这个话题,说大不大,说小不小。小到可以只是一个 Redis key,大到可以影响整个系统的数据一致性。
我见过太多团队在这个上面翻车了——要么是早期快速迭代的时候没做,后期重构代价太大;要么是知道要做,但总觉得"应该不会有人重试吧";要么是做了,但做得不完整,留了个小尾巴,结果某天线上就炸了。
我的建议是:从第一个接口开始就把幂等性考虑进去。别等到出事了再后悔。
毕竟,重试三遍、订单三单的事儿,真的发生了,那可就不是技术问题了——那是钱的问题、用户信任的问题、还有你半夜三点被叫起来处理问题的问题。
珍爱生命,重视幂等。