Java 企业级接口幂等与防重提交实战:从 Token 令牌、Redis 原子锁到数据库唯一索引的三重防线

举报
yd_216727992 发表于 2026/11/07 12:28:33 2026/11/07
【摘要】 接口幂等是企业级 Java 后端的高频问题。本文以支付、下单、扣库存等写接口为例,讲清 Token 令牌、Redis 原子锁、数据库唯一索引三道防线的分工与组合落地方法,给出可直接参考的示例代码和上线前检查清单。

在企业级 Java 后端开发中,接口幂等是最容易被低估、又最容易在线上“炸出”问题的一环:用户在支付页多点了一次提交、前端超时后自动重试、网关把同一请求转发了两次,订单就可能重复创建,库存就可能被重复扣减。本文结合微服务、分布式事务和高并发缓存这几类常见场景,讲清楚一套可落地的组合方案:前端 Token 令牌防重复提交、Redis 原子锁防并发重入、数据库唯一索引做最终兜底。文中代码均为示例,需按项目实际依赖和版本调整。

一、什么是接口幂等,为什么只靠前端防不住

幂等的含义很直白:同一个请求,无论执行一次还是多次,对系统产生的影响应该相同。对查询接口这天然成立,对下单、扣减库存、发放权益、创建工单这类写接口则不然。

只在前端禁用提交按钮是不够的,原因有三点:第一,前端防不住网络重试,超时重发是后端必须面对的常态;第二,前端防不住多端同时操作,同一账号可能在手机和电脑上同时提交;第三,前端防不住恶意或异常的重复调用。所以幂等校验必须放在服务端,而且要放在业务逻辑执行之前。

判断一个接口是否需要幂等,看两个问题:这个接口是写操作吗?重复执行会不会产生多余的业务效果?两个答案都是“是”,就需要做幂等。支付、退款、下单、扣库存、领取优惠券、提交审批这类接口都属于这个范围。

二、三道防线怎么分工

防线 解决的问题 适用阶段 失效时的后果
Token 令牌 用户重复点击、表单重复提交 请求入口 只能防住同一页面来源的重复提交
Redis 原子锁 并发请求同时到达、重复执行业务 业务执行前 Redis 不可用时需要有降级策略
数据库唯一索引 极端情况下的重复写入 落库时 依赖业务键设计得是否唯一

三道防线不是三选一,而是层层递进。Token 挡住大部分重复提交,Redis 挡住并发重入,唯一索引在最后一刻挡住重复落库。任何一道单独使用都有短板,组合使用才能覆盖常见链路。

三、第一道防线:Token 令牌

思路是:页面打开或提交前,先向服务端申请一个一次性 Token,服务端把 Token 存入 Redis 并设置过期时间;提交时把 Token 一起带上,服务端校验通过后立即删除 Token,校验不通过直接返回提示。Token 只能使用一次,第二次提交必然失败。

下面是一个简化的示例。申请接口生成 UUID 作为 Token,存入 Redis,过期时间按业务需要设置,例如 10 分钟,这个数值只是示例:

// 示例:申请一次性 Token
String token = UUID.randomUUID().toString().replace("-", "");
stringRedisTemplate.opsForValue().set("idem:token:" + token, "1", 10, TimeUnit.MINUTES);
return token;

提交接口校验时要用 Redis 的原子删除做“认领”,而不是先查后删,先查后删在并发下会有两个请求同时通过校验:

// 示例:用 Lua 脚本保证“校验并删除”是原子操作
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
             "return redis.call('del', KEYS[1]) else return 0 end";
Long result = stringRedisTemplate.execute(
        new DefaultRedisScript<>(lua, Long.class),
        Collections.singletonList("idem:token:" + token), token);
if (result == null || result == 0) {
    throw new BusinessException("请勿重复提交");
}

Token 方案要注意两点:Token 与业务主体要对应,不能一个 Token 被挪用到别的订单上;Token 校验失败时要返回明确的业务提示,而不是直接报系统异常,避免用户反复重试。

四、第二道防线:Redis 原子锁防并发重入

Token 能防住同一个页面的重复提交,但防不住两个不同请求带着不同的 Token 同时创建同一笔业务。例如用户在两个设备上同时下单,或者消息队列把同一条消息投递了两次。这时需要在业务执行前加一把分布式锁,锁的 Key 由业务唯一标识组成,如订单号、用户编号加业务类型。

用 Redis 实现时,关键点是加锁和设置过期时间必须是原子操作,否则进程崩溃后锁可能永远不释放;解锁时必须校验锁的归属,只能由加锁的持有者释放,否则可能误删别人的锁。Spring Data Redis 提供了 setIfAbsent 方法,示例:

// 示例:加锁与过期时间一次完成
Boolean locked = stringRedisTemplate.opsForValue()
        .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
    throw new BusinessException("请求正在处理中,请稍后再试");
}
try {
    // 执行业务逻辑:校验、落库、扣减等
    return doCreateOrder(request);
} finally {
    // 示例:用 Lua 脚本校验 requestId 后再删除,避免误删他人的锁
    String unlockLua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                       "then return redis.call('del', KEYS[1]) else return 0 end";
    stringRedisTemplate.execute(
            new DefaultRedisScript<>(unlockLua, Long.class),
            Collections.singletonList(lockKey), requestId);
}

锁的过期时间要按业务耗时留出余量,过短会导致业务还没执行完锁就被释放,过长会导致异常情况下后续请求长时间被拒绝。更稳妥的做法是:锁只保护“判断是否已经执行过”这一小段逻辑,而不是把整个长业务都包在锁里;业务执行结果本身用业务状态或唯一键来保证。

为减少重复样板代码,可以把幂等逻辑抽成注解加 AOP 切面。注解上声明业务 Key 的表达式,切面在方法执行前完成加锁,执行后释放锁。这样业务方法本身不需要关心幂等细节,新增接口时只要加上注解并指定 Key 规则即可。

五、第三道防线:数据库唯一索引

即使前两道防线都做了,仍然可能出现极端情况:Redis 故障后服务降级放行,或者两个请求分别走了不同节点、锁 Key 生成规则不一致。这时最后一道防线是数据库唯一索引。

做法是给业务表加一个业务唯一键字段,例如订单表的业务订单号、流水表的幂等键,并在这个字段上建唯一索引:

-- 示例:为订单表的业务订单号建唯一索引
ALTER TABLE t_order ADD UNIQUE KEY uk_biz_order_no (biz_order_no);

写入时如果出现唯一键冲突,捕获异常并按业务语义处理:如果是同一业务的重复请求,返回已有的处理结果或提示“订单已创建”;如果是不同业务恰好撞了 Key,说明 Key 生成规则有问题,需要修复,而不是简单吞掉异常。要注意,唯一索引兜底的前提是业务键本身设计得唯一,Key 里要包含能区分不同业务的字段,不能只用用户编号或时间戳。

六、落地顺序与异常处理

一个接口完整的处理顺序建议是:

  1. 参数校验:先校验必填字段和格式,不合法的请求直接拒绝,不要消耗 Token 或锁;
  2. Token 校验并删除:一次性 Token 在入口处认领;
  3. Redis 加锁:用业务唯一标识加锁,挡住并发重入;
  4. 查业务状态:如果这笔业务已处理过,直接返回已有结果,不要重复执行;
  5. 执行写操作:落库时依靠唯一索引做最后一道校验,写操作与业务状态更新放在同一个本地事务里;
  6. 释放锁并返回结果:无论成功失败都要释放锁,失败时按业务需要决定 Token 是否需要重新发放。

异常处理上有两个常见坑。第一是业务失败后锁和 Token 的状态:如果是参数错误导致的失败,Token 可以按业务需要重新发放;如果是系统异常导致的失败,要让用户能够重新提交,而不是因为 Token 已消耗就再也提交不了。第二是返回结果的区分:重复请求返回“已处理”的提示和已有结果,与真正的系统异常要区分开,前者是正常业务分支,后者需要告警和排查。

七、上线前检查清单

  • 锁 Key 的生成规则是否覆盖了全部业务维度,会不会把不同业务误判成同一笔;
  • Token 的有效期是否合理,既不会让用户在正常操作时间内过期,也不会长期占用 Redis;
  • Redis 不可用时是直接拒绝还是降级放行,降级后唯一索引兜底是否仍然有效;
  • 分布式锁的过期时间、归属校验、异常释放是否都有处理;
  • 唯一索引字段是否真正唯一,冲突时返回的提示是否符合业务语义;
  • 重复请求和系统异常的日志、监控是否区分,避免重复请求淹没真实告警;
  • 压测场景是否包含同一请求并发提交、超时重试、双端同时提交这三种情况。

总结

接口幂等没有一个“万能”的方案,关键是按链路分层设防:Token 令牌挡重复提交,Redis 原子锁挡并发重入,数据库唯一索引挡重复落库。三层组合起来,单点失效时仍有下一层兜底。落地时先从最容易出问题的支付、下单、扣减类接口开始,把 Key 规则、锁超时、异常分支这三个点写清楚,再逐步推广到其他写接口,会比一次性全量改造稳妥得多。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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