如何利用Redis Lua脚本防止接口幂等性冲突_实现原子化的Token检查与设置

作者:袖梨 2026-07-10
必须用Lua脚本原子执行“判断+删除”,因GET与DEL分离会导致高并发下两次校验都通过而重复提交;脚本需用redis.call("get")判空后del并return 1/0,Java调用须用DefaultRedisScript封装且resultType设为Long.class。

直接用 GET + DEL 两步校验 Token,高并发下必然失效;必须用 Lua 脚本把「存在判断 + 删除」锁死在一次 Redis 执行中,否则重复提交拦不住。

为什么不能分开执行 GET 和 DEL

两个并发请求同时执行 GET token:abc,都拿到非空结果;接着都执行 DEL token:abc,再都走业务逻辑——订单就生成了两次。这不是理论风险,是线上真实会炸的场景。

  • GETDEL 是两个独立命令,Redis 不保证它们之间不被其他客户端插入操作
  • 哪怕加了 EXPIRE,也不能解决中间窗口期的竞态
  • EXISTS 判断后再 DEL 同样无效,本质一样是非原子

Lua 脚本必须这样写:用 redis.call("get") + del 原子返回

核心是让整个判断和清理只返回一个确定值(1 或 0),业务层靠这个值决定是否放行。下面这个脚本是经过生产验证的最小可靠形态:

if redis.call("get", KEYS[1]) ~= false then  redis.call("del", KEYS[1])  return 1else  return 0end
  • 必须用 redis.call("get", ...),不能用 redis.call("exists", ...),因为 exists 返回整数,而 get 返回 nil 或字符串,Lua 中 ~= false 才能准确捕获 key 不存在的情况
  • 脚本里不能出现 TIMERANDOMKEY 等非确定性命令,否则 Redis 主从同步会拒绝加载
  • 不要在脚本里做业务逻辑(比如拼接日志、调用外部接口),它只负责“钥匙是否有效+立刻作废”这件事

SpringBoot 中调用 EVAL 的关键配置点

Java 层不是简单拼字符串发过去,得用 DefaultRedisScript 封装并指定返回类型,否则返回值解析会出错:

  • redisScript.setScriptText(...) 必须赋值为上面那段 Lua 字符串
  • redisScript.setResultType(Long.class) —— 因为脚本最后 return 1 是数字,不是字符串,不设类型会导致 ClassCastException
  • 调用时传参: redisTemplate.execute(redisScript, Collections.singletonList(token))KEYS[1] 对应的就是这个 token 字符串
  • 返回值为 1L 表示校验通过且已删除;0L 表示 token 不存在或已被用过

Token 存储本身也要防覆盖,用 SET NX EX 更稳妥

生成 Token 时别用 set(token, "1"),而是用原子命令确保不会意外覆盖:

  • 推荐写法:stringRedisTemplate.opsForValue().set(token, "1", 5, TimeUnit.MINUTES, RedisSetOption.UPSET)
  • 更底层可直接用:redis.call("set", KEYS[1], ARGV[1], "EX", ARGV[2], "NX"),其中 "NX" 是关键,避免并发生成时写入冲突
  • 过期时间别设太长(比如 24 小时),5~10 分钟足够覆盖用户操作窗口,过长会堆积无效 key

真正容易被忽略的是:Lua 脚本的返回值类型和 Java 调用时的泛型必须严格一致,差一个 LongInteger 就会静默失败;还有就是前端拿 token 后没及时带出,或者后端没校验 header/param 是否为空,这些地方一漏,原子性再强也白搭。

相关文章

精彩推荐