在电商下单、支付回调、表单提交等场景中,用户快速双击按钮、网络重试或消息队列重复投递,都可能导致同一个请求被服务端处理多次,进而产生重复订单、重复扣款等严重问题。幂等性设计就是为了让同一操作无论执行多少次,产生的效果都与执行一次相同。本文将以 Spring Boot 为技术底座,讲解一套基于 Token 机制与 Redis 的接口幂等性解决方案,并给出完整的代码实现。

什么是幂等性,为什么接口需要它
幂等性原本是数学中的概念,指一个操作执行一次和执行多次的结果完全一致。映射到 HTTP 接口层面,就是客户端对同一个接口发起多次请求,服务端的业务结果应当保持不变。需要特别注意,GET、PUT、DELETE 天然具有幂等语义,而 POST 通常不是幂等的,所以幂等性设计的重点往往集中在 POST 类型的写操作接口上。
导致接口被重复调用的原因有很多:前端没有做按钮防抖,用户在网络卡顿时连续点击;网关或负载均衡在超时后自动重试;客户端 App 的请求重试机制;MQ 消息在生产端未确认时重复投递;支付渠道的异步回调多次通知等。这些场景在开发阶段往往难以完全复现,只能靠服务端的幂等设计来兜底。
如果不做幂等控制,最直接的后果就是脏数据。例如用户购买一件商品,由于双击提交产生了两张订单,库存被扣减两次,后续的退款和对账流程都会变得混乱。因此在涉及资金、库存、订单等核心链路时,幂等性不是可选项,而是必须项。
Token 机制的设计思路
Token 幂等方案的核心流程分为两步:第一步,客户端在进入业务页面时,先调用一个获取 Token 的接口,服务端生成一个唯一 Token 并存入 Redis,同时把值返回给客户端;第二步,客户端提交业务请求时携带这个 Token,服务端在处理业务前先去 Redis 中删除该 Token,删除成功才继续执行业务逻辑,删除失败说明 Token 已被消费或不存在,直接拒绝请求并提示重复提交。
这个方案之所以有效,关键在于 Redis 的删除操作是原子的。第一次请求到达时删除 Token 成功,业务正常执行;后续重复请求到达时 Token 已经不存在,删除失败,请求被拦截。这样即使客户端在极短时间内发出多个并发请求,也只有一个请求能通过校验。
需要强调一个常见的错误实现:先调用 get 判断 Token 是否存在,再调用 del 删除。这种写法在并发下存在竞态条件,两个请求可能同时读到 Token 存在,然后都执行删除并都通过校验。正确的做法是使用 Lua 脚本把校验和删除合并成一个原子操作,或者直接依赖 del 命令的返回值判断是否删除成功。
代码实现:注解加拦截器的完整方案
下面在 Spring Boot 项目中落地这套方案。整体结构包括三个部分:自定义注解 @Idempotent 用于标记需要幂等控制的接口;一个拦截器负责提取 Token 并调用 Redis 完成原子校验;一个工具接口用于发放 Token。
首先定义注解:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// Token 在请求头中的键名,默认为 idempotentToken
String headerName() default "idempotentToken";
}
接着编写拦截器,核心逻辑是使用 Lua 脚本保证校验与删除的原子性:
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
// Lua 脚本:存在则删除并返回1,不存在返回0
private static final DefaultRedisScript<Long> SCRIPT =
new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 end",
Long.class);
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod method = (HandlerMethod) handler;
Idempotent annotation = method.getMethodAnnotation(Idempotent.class);
if (annotation == null) {
return true;
}
String token = request.getHeader(annotation.headerName());
if (token == null || token.isEmpty()) {
writeError(response, "缺少幂等Token");
return false;
}
String key = "idempotent:token:" + token;
Long result = redisTemplate.execute(SCRIPT,
Collections.singletonList(key), token);
if (result == null || result == 0) {
writeError(response, "请勿重复提交");
return false;
}
return true;
}
private void writeError(HttpServletResponse response, String msg)
throws IOException {
response.setStatus(409);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":409,\"message\":\"" + msg + "\"}");
}
}
然后注册拦截器,并提供 Token 发放接口:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private IdempotentInterceptor idempotentInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(idempotentInterceptor)
.addPathPatterns("/**");
}
}
@RestController
@RequestMapping("/token")
public class TokenController {
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/generate")
public String generateToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"idempotent:token:" + token, token, 5, TimeUnit.MINUTES);
return token;
}
}
最后在业务接口上使用注解即可:
@RestController
@RequestMapping("/order")
public class OrderController {
@PostMapping("/create")
@Idempotent
public Result createOrder(@RequestBody OrderRequest req) {
// 走到这里说明Token校验通过,业务只会执行一次
return orderService.createOrder(req);
}
}
使用流程非常清晰:前端进入下单页时先请求 /token/generate 拿到 Token,提交订单时把 Token 放在请求头 idempotentToken 中一起发送。拦截器完成原子校验后放行,重复请求则直接返回 409 状态码。
方案细节与常见坑点
第一个需要注意的点是 Token 的过期时间。设置过长会占用 Redis 内存,设置过短则用户在页面停留较久后提交会失败,一般建议 5 到 10 分钟。第二个点是 Token 必须一次性消费,即用即删,绝不能在校验通过后保留 Token 供再次使用,否则幂等性就失效了。
第三个坑是业务执行失败后的处理。如果 Token 已被删除但业务代码抛出异常,用户修正后再次提交会被拦截。针对这种情况有两种策略:一是捕获业务异常后在拦截器的 afterCompletion 阶段回写 Token(实现较复杂);二是业务失败时由前端重新调用生成接口获取新 Token,这种方式实现简单,推荐优先采用。
第四个点是企业级落地时的 Token 与用户绑定。仅靠 UUID 无法防止 Token 被他人窃取后伪造请求,更严谨的做法是在生成 Token 时记录当前登录用户 ID,校验时验证请求者身份与 Token 归属一致,可以通过把用户 ID 拼入 Redis 的 value 中并在 Lua 脚本里一并比对来实现。
与其他幂等方案的对比
Token 加 Redis 并不是唯一的幂等手段,实际项目中经常多种方案配合使用:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| Token + Redis | 前置发放Token,请求时原子删除 | 通用性强,不依赖数据库 | 需要前端配合,多一次请求 |
| 唯一索引 | 数据库对业务唯一键建唯一约束 | 绝对可靠,兜底能力强 | 只适合插入场景,冲突会抛异常 |
| 分布式锁 | Redis或ZooKeeper对业务键加锁 | 可控制并发执行 | 锁过期与业务超时需要权衡 |
| 状态机 | 订单状态只能单向流转 | 天然幂等,语义清晰 | 仅适合有状态流转的业务 |
| 乐观锁版本号 | 更新时携带版本号比对 | 解决并发更新覆盖 | 需要重试机制配合 |
从表中可以看出,Token 方案的优势在于对业务侵入小、通用性好,特别适合表单提交、下单这类由用户主动触发的操作。而唯一索引适合作为最终兜底,即使前面的校验全部失效,数据库约束也能保证不产生重复数据。支付回调场景则更适合状态机加唯一索引的组合,因为回调方通常不会先来获取 Token。
总结来说,幂等性设计没有银弹,Token 加 Redis 的方案在 Spring Boot 中实现成本低、效果可靠,是防重复提交的首选方案。建议在核心写接口上叠加使用 Token 前置校验与数据库唯一索引兜底,两层防护结合,才能在复杂的生产环境中真正保障数据一致性。
Spring Boot幂等性Token机制Redis防重复提交修改时间:2026-09-01 17:04:44