Spring Boot 3 企业级接口幂等设计实战:Token 令牌 + Redis Lua 原子校验
【摘要】 下单、支付接口重复提交是企业级后端最常见的线上事故之一。本文以 Token 令牌机制为主线,配合 Redis 与 Lua 脚本实现原子校验,讲清接口幂等的方案选型、核心代码实现与落地时容易踩的坑,并给出 JMeter 并发验证方法。
一、问题从哪里来
企业级后端最常见的线上事故之一,就是接口重复提交:用户在下单、支付、退款时因为网络抖动点了两次,或者前端超时重试,同一笔业务被执行了两次。结果可能是重复扣款、库存多扣、订单重复,最终都变成客诉和资损。
幂等,就是要保证:同一个请求无论被执行多少次,对系统产生的影响和第一次完全相同。
二、四种常用方案怎么选
| 方案 | 适用场景 | 缺点 |
|---|---|---|
| 数据库唯一索引 | 新增类接口,天然防重 | 只防新增,更新类接口用不上 |
| 状态机 | 订单、工单等有明确状态流转的业务 | 业务侵入大 |
| 分布式锁 | 临界资源抢占 | 锁粒度、超时处理容易出错 |
| Token 令牌机制 | 表单提交、支付、下单等一次性操作 | 需要前后端配合 |
本文实战采用 Token 令牌机制,配合 Redis 与 Lua 脚本实现原子校验。
三、Token 令牌机制的流程
- 前端进入下单页时,先调接口申请一个一次性 Token,后端把 Token 以
idempotent:token:{token}为 key 存入 Redis,设置 10 分钟过期。 - 提交订单时把 Token 放在请求头
Idempotent-Token里。 - 后端用 Lua 脚本原子执行“校验并删除”:key 存在则删掉并放行,不存在则直接拒绝。
- 业务成功后把处理结果也写入 Redis,Token 对应的 key 再次请求时可直接返回首次结果。
关键点在第 3 步:校验和删除必须原子化,否则并发下两个请求可能同时校验通过。用 Lua 脚本把 GET 与 DEL 放在一次执行里,是最稳妥的做法。
四、核心代码
1. 申请 Token
@Service
public class IdempotentTokenService {
private static final String KEY_PREFIX = "idempotent:token:";
@Resource
private StringRedisTemplate redisTemplate;
public String createToken(String userId) {
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue()
.set(KEY_PREFIX + token, userId, 10, TimeUnit.MINUTES);
return token;
}
}
2. Lua 脚本原子校验并删除
-- checkAndDel.lua
-- KEYS[1] 为 token 对应的 key
-- 返回 1 表示校验通过,0 表示 token 不存在或已使用
if redis.call('get', KEYS[1]) then
redis.call('del', KEYS[1])
return 1
else
return 0
end
3. 注解 + AOP 拦截
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String header() default "Idempotent-Token";
}
@Aspect
@Component
public class IdempotentAspect {
private static final DefaultRedisScript<Long> SCRIPT =
new DefaultRedisScript<>();
static {
SCRIPT.setScriptText(
"if redis.call('get', KEYS[1]) then " +
"redis.call('del', KEYS[1]) return 1 else return 0 end");
SCRIPT.setResultType(Long.class);
}
@Resource
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp,
Idempotent idempotent) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader(idempotent.header());
if (!StringUtils.hasText(token)) {
throw new BizException("缺少幂等 Token,请刷新后重试");
}
Long ok = redisTemplate.execute(SCRIPT,
List.of("idempotent:token:" + token));
if (ok == null || ok != 1L) {
throw new BizException("请求已提交,请勿重复操作");
}
return pjp.proceed();
}
}
使用时只需要在接口方法上加一个注解:
@PostMapping("/orders")
@Idempotent
public Result<Order> createOrder(@RequestBody OrderCreateRequest req) {
return Result.ok(orderService.create(req));
}
五、落地时容易踩的坑
- Token 必须一次性:校验通过立即删除,防止重放攻击。
- 不要用“先查后删”两步走:并发下两步之间有竞态窗口,必须用 Lua 保证原子性。
- 过期时间要合理:太短会导致正常用户提交超时,太长会占用 Redis 内存,表单类场景 10 分钟比较合适。
- 业务失败要不要还回 Token:视业务而定。订单创建失败(参数校验不通过)建议把 Token 放回,允许用户修正后重新提交;已经落库的失败则直接拒绝。
- 前端要配合:提交按钮提交后立即置灰,禁止连续点击;同时做好超时重试时不换 Token。
- 集群部署注意:Redis 用集群时 Lua 脚本只操作单个 key,不存在跨槽问题,可以放心使用。
六、怎么验证
用 JMeter 对下单接口做 50 并发压测,全部带同一个 Token:最终只有 1 个请求返回成功,其余 49 个返回“请勿重复操作”,且数据库里只产生 1 条订单。换成 50 个不同 Token:50 个请求全部成功,50 条订单,符合预期。
七、总结
接口幂等不是一个可选项,而是企业级 Java 后端必须守住的底线。Token 令牌机制实现成本低、通用性强,是表单提交类接口的首选;配合 Redis Lua 脚本做原子校验,能从根本上杜绝并发下的重复执行。后续可以再把“首次执行结果缓存”加上,让重复请求直接返回第一次的结果,体验更好。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)