Spring Boot 3 企业级接口幂等设计实战:Token 令牌 + Redis Lua 原子校验

举报
yd_216727992 发表于 2026/11/09 10:43:16 2026/11/09
【摘要】 下单、支付接口重复提交是企业级后端最常见的线上事故之一。本文以 Token 令牌机制为主线,配合 Redis 与 Lua 脚本实现原子校验,讲清接口幂等的方案选型、核心代码实现与落地时容易踩的坑,并给出 JMeter 并发验证方法。

一、问题从哪里来

企业级后端最常见的线上事故之一,就是接口重复提交:用户在下单、支付、退款时因为网络抖动点了两次,或者前端超时重试,同一笔业务被执行了两次。结果可能是重复扣款、库存多扣、订单重复,最终都变成客诉和资损。

幂等,就是要保证:同一个请求无论被执行多少次,对系统产生的影响和第一次完全相同。

二、四种常用方案怎么选

方案 适用场景 缺点
数据库唯一索引 新增类接口,天然防重 只防新增,更新类接口用不上
状态机 订单、工单等有明确状态流转的业务 业务侵入大
分布式锁 临界资源抢占 锁粒度、超时处理容易出错
Token 令牌机制 表单提交、支付、下单等一次性操作 需要前后端配合

本文实战采用 Token 令牌机制,配合 Redis 与 Lua 脚本实现原子校验。

三、Token 令牌机制的流程

  1. 前端进入下单页时,先调接口申请一个一次性 Token,后端把 Token 以 idempotent:token:{token} 为 key 存入 Redis,设置 10 分钟过期。
  2. 提交订单时把 Token 放在请求头 Idempotent-Token 里。
  3. 后端用 Lua 脚本原子执行“校验并删除”:key 存在则删掉并放行,不存在则直接拒绝。
  4. 业务成功后把处理结果也写入 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)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。