
说实话第一次把 Lua 脚本真正塞进 Spring Boot 项目里是在做秒杀扣库存的时候。当时接口一压测就超卖Redis 里先查再扣的那套逻辑在高并发下全是漏洞。后来把整个判断和扣减逻辑写进 Lua 脚本一次性原子执行问题当场解决。那次之后我才意识到Lua 在 Redis 里不是花架子是真正能解决并发一致性问题的硬工具。这篇教程就把我摸索过的路整理出来。从环境准备到脚本语法从 Spring Boot 里的几种调用方式到限流、分布式锁、库存扣减的真实案例再到我踩过的序列化、返回类型、脚本缓存等一堆坑。内容偏实操每个核心步骤都给了可直接照抄的代码和解释适合已经会用 RedisTemplate 做简单存取、想往深处走的开发者也适合被并发问题逼到头、需要一个可靠方案的朋友。1. 为什么要在 Spring Boot 里用 Redis Lua 脚本1.1 这个场景解决了什么问题日常写业务用 RedisTemplate 做 get、set、increment 很方便。但一旦涉及多步操作比如检查库存、扣减库存、记录订单麻烦就来了。三步操作之间不是原子的高并发下两个请求同时读到库存为 1各自执行扣减结果就超卖了。传统解决办法是加分布式锁。Redis 的 SETNX 锁能解决一部分问题但锁本身也有释放异常、超时续期、重入等一堆边界情况要处理。而且锁是串行化整个业务性能损耗不小。Lua 脚本的解法更干净。Redis 从 2.6 版本开始内置 Lua 解释器支持把一段脚本作为一个整体在服务端执行。脚本执行期间其他命令不会插队整个脚本天然具备原子性。这就相当于把原本在应用层靠锁保护的逻辑压缩成 Redis 服务端的一步操作没有锁的额外管理成本也不存在锁失效的问题。对于高频访问的热点数据这个特性价值很大。比如做限流要检查当前窗口计数是否超限、超限则返回拒绝、未超限则计数加一这三个动作拆开做必然有竞态写成一个 Lua 脚本就是一次原子调用性能损耗极小准确性还能拉满。1.2 相比 Java 代码实现有哪些本质优势从开发体验来看Lua 脚本解决的不只是并发问题。多个 Redis 操作打包成一个脚本后网络往返次数从 N 次降为 1 次。比如原来扣库存需要 get 一次、判断一次、decrBy 一次三次网络 IOLua 脚本一次调用全部完成接口响应时间直接从几十毫秒降到几毫秒。从一致性角度看脚本在 Redis 单线程模型下执行Redis 本身是单线程执行命令Lua 脚本运行时天然阻塞其他命令但换来的是一致性保证。这跟数据库事务类似——你牺牲一点并发能力换回数据不出错。还有一点很实际脚本可以复用。把复杂业务逻辑写在 .lua 文件里Java 侧只需要加载并传参调用后续要调整判断逻辑时只改 Lua 脚本不需要动 Java 代码。这块在业务规则变化频繁的场景下节约的时间非常多。需要特别注意的是嵌入脚本时要有点边界感。阻塞类操作比如长时间的 while 循环、耗时的计算尽量别写进脚本因为脚本执行期间会阻塞 Redis 服务。脚本超时默认 5 秒会被 Redis 强制终止但这种情况一旦出现线上影响面是即时且广泛的。我的原则是脚本做逻辑判断和简单的计数增减重计算永远留在应用侧。2. 环境准备与 Spring Boot 接入基础2.1 Redis 安装与可视化客户端选型把环境先跑通是第一步。Redis 官方支持 Windows 版但更新节奏一直比 Linux 版慢生产环境基本清一色部署在 Linux。本机开发时我改用 Docker 方式省去一堆编译安装的麻烦。一条命令就能起一个单机 Redisdocker run -d --name redis \ -p 6379:6379 \ -e TZAsia/Shanghai \ redis:7.2 \ redis-server --appendonly yes --requirepass 123456如果不想用 DockerWindows 用户可以到 Redis 官方或开源镜像站下载 zip 包解压后直接运行 redis-server.exe配置文件在 redis.windows.conf 里。需要注意 Windows 版的 Redis 版本通常滞后有些老版本对 Lua 脚本的支持不够完善建议至少使用 5.0 以上版本避免踩到无谓的兼容坑。可视化客户端是排查问题的重要工具。我用过的几款里Redis Desktop ManagerRDM最主流跨平台支持好能直接查看 key 的类型、TTL 和值。开源替代方案 Another Redis Desktop Manager 也是个不错的选择界面更清爽内存占用更低。这类工具在日常开发中最大的用途不是看数据而是手动执行 Lua 脚本调试——脚本先跑通再往代码里搬。Redis Desktop Manager 里执行脚本的方式很简单连上实例后切到 Console 标签页输入 EVAL 命令加脚本内容和参数回车立刻看到结果。这个流程在脚本调参阶段特别高效省掉一遍遍启动 Spring Boot 项目的时间。2.2 Redis 数据类型对脚本参数设计的影响Lua 脚本里操作的数据结构决定了脚本怎么设计参数。Redis 的五种基本数据类型在脚本里各有各的注意点String最常操作存计数、状态、用户信息 JSON脚本里用 KEYS[1] 传 keyARGV 传 value 或过期时间。Hash存对象数据非常合适一个用户的所有属性放在一个 key 里。脚本里用 HGETALL、HINCRBY 这类命令操作字段级数据。List适合做消息队列、最新列表。脚本里可以用 LPUSH、LRANGE 做批量操作但要注意列表长度别在脚本里遍历整个大列表。Set适合做去重、标签、在线状态集合。SADD、SISMEMBER、SCARD 在脚本里操作很顺手。ZSet有序集合做排行榜、延迟队列、滑动窗口限流全靠它。ZADD、ZREMRANGEBYSCORE、ZCARD 组合使用能实现很多原有业务逻辑要写一坨代码才能搞定的功能。脚本参数的传入规则是固定的KEYS 数组放 key 名称ARGV 数组放其他参数。这个设计有安全层面的考虑——Redis 集群模式下key 的哈希槽决定了命令路由到哪个节点只有把 key 明确放进 KEYS 里集群才能正确路由ARGV 里出现的 key 不会被识别。开发阶段单机无所谓但一旦上集群脚本执行报错“Lua script attempted to access a non local key in a cluster node”基本就是这个原因。2.3 Maven 依赖与 RedisTemplate 基础配置Spring Boot 项目接入 Redis 很简单引入官方 Starter 即可。我在 pom.xml 里的配置如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependencyRedisTemplate 在 Spring Boot 自动配置里已经注册好了但默认用的是 JDK 序列化存到 Redis 里的 key 会带着 \xac\xed\x00\x05t 之类的前缀肉眼没法看也很占空间。我一般会重新定义一个 StringRedisTemplate 或者自定义序列化器统一用 JSON 格式。Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 使用 GenericJackson2JsonRedisSerializer 替代默认 JDK 序列化 GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }这一步影响深远。脚本执行时RedisTemplate 传入的 key 和参数会经过序列化器转换成字节数组默认序列化器和 String 序列化器转换出来的内容完全不一样。如果脚本里的 KEYS[1] 和实际存储时用的 key 序列化方式不一致脚本会直接找不到数据返回空结果。这是个非常隐蔽的坑后面在常见问题部分会细讲。2.4 Spring Boot 项目里脚本文件放哪个位置脚本文件在项目里的组织方式我试过几种最推荐的是固定放在 resources/scripts/lua/ 目录下用文件名区分业务场景。比如src/main/resources/scripts/lua/ ├── limit_rate.lua ├── inventory_deduct.lua ├── lock_acquire.lua └── lock_release.lua之所以不把脚本硬编码成 Java 字符串一是可读性差拼接转义太痛苦二是脚本长了以后维护困难.lua 文件可以直接用代码编辑器高亮提示。Spring Boot 打包时 resources 目录下的文件会自动进入 classpath用 ClassPathResource 加载即可完全没问题。3. Lua 脚本基础与 Redis 命令集3.1 KEYS、ARGV 和返回值脚本的三个入口一个标准的 Redis Lua 脚本结构分三块入参、逻辑、返回值。入参有两类KEYS 和 ARGV。看个最简单的例子-- 脚本内容 local current tonumber(redis.call(GET, KEYS[1]) or 0) local delta tonumber(ARGV[1]) local newValue current delta redis.call(SET, KEYS[1], newValue) return newValueKEYS[1] 是第一个 key 名ARGV[1] 是第一个参数。Lua 的 table 索引从 1 开始跟 Java 的数组从 0 开始不同这个细节经常让人懵一下。需要注意 Redis 里的值本质上都是字符串做数值运算必须用 tonumber() 转换。直接拿 GET 回来的字符串做加法Lua 会报错。反过来存回 Redis 时可以直接存数字Redis 内部会把它转成字符串。返回值类型有讲究脚本 return 一个数字Java 侧拿到的是 Longreturn 一个字符串Java 侧拿到的是 Stringreturn false 在 Redis 里对应 nilJava 侧拿到的是 null。如果脚本 return 一个 table比如 {1, 2, 3}Redis 会转换成数组。这些对应关系在 Spring Boot 里取返回值时需要格外小心弄错了轻则类型转换异常重则数据凭空消失。3.2 redis.call 与 redis.pcall 的差异和选择脚本里操作 Redis 命令有两条路径redis.call 和 redis.pcall。两者用法相同唯一的区别是错误处理方式call 遇到错误会直接抛出异常整个脚本立即终止错误信息返回给调用方pcall 遇到错误时会把错误信息作为返回值返回而脚本本身不终止。local ok redis.pcall(SET, KEYS[1], ARGV[1]) if type(ok) ~ table then return { success true, msg ok } else return { success false, msg ok.err } end实际开发中我的习惯是脚本内需要捕获可能失败的场景用 pcall比如一个 key 可能不存在、可能类型不对、可能已过期核心逻辑必须保证执行成功时用 call让它快速失败暴露问题。全部用 pcall 有个坏处——错误被静默吞掉排错时一头雾水。全部用 call 又有风险——一处小异常让整个操作中断影响业务连续性。还有个知识点redis.call 能调用的命令是所有 Redis 命令的绝大部分包括 SET、GET、EXPIRE、ZADD、LPUSH、HINCRBY 等。但一些带子命令的操作比如 CLIENT、SLOWLOG 这类管理命令在脚本里通常不被允许。日常业务场景中完全不用纠结涉及到的概率极低。3.3 脚本内部的流程控制和数据结构操作Lua 语言本身简单写业务脚本用到的语法很集中。if/else 判断、for 循环、while 循环、table 类型这些就覆盖了绝大多数场景。看一个结合 ZSet 做滑动窗口限流的脚本逻辑骨架local key KEYS[1] local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local currentTime tonumber(ARGV[3]) local member ARGV[4] -- 移除窗口之外的记录 redis.call(ZREMRANGEBYSCORE, key, 0, currentTime - window) -- 统计当前窗口内的请求数 local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, currentTime, member) redis.call(EXPIRE, key, window) return 1 end return 0这段脚本的逻辑很清晰先清理过期数据再统计当前数量未超限就加入新记录。脚本里可以按需调用任意组合的 Redis 命令这比 Java 侧多次调用 手动拼接结果要优雅得多。关于脚本里遍历数据有一点要提醒别在脚本里循环执行 LRANGE 或 SMEMBERS 这种可能返回大结果集的命令。Redis 单线程执行脚本一个大循环就会阻塞其他请求。常见的做法是限制范围比如用 ZRANGE 带 LIMIT 限制条数或者把大集合拆分成多个小 key。经验法则是脚本内部的时间复杂度控制在 O(log N) 以内O(N) 的操作能避免就避免。4. Spring Boot 调用 Redis Lua 脚本的三种方式4.1 直接用 RedisTemplate.execute 跑 EVAL最简单的调用方式是把 Lua 脚本作为字符串直接传给 execute 方法。Spring 提供了对应的 APIAutowired private StringRedisTemplate redisTemplate; public Long evalSimpleScript() { String luaScript return redis.call(SET, KEYS[1], ARGV[1]); RedisScriptVoid script RedisScript.of(luaScript); return redisTemplate.execute(script, List.of(myKey), myValue); }这种方式适合脚本很短、逻辑固定、不需要频繁调整的场景。缺点是脚本每次执行都需要 Redis 端重新编译解析性能不是最优而且脚本以字符串形式散落在 Java 代码里稍微长一点就变成维护噩梦。RedisScript.of 方法默认将返回值解析为对应类型。如果你的脚本返回多个值或者复合结构需要指定返回类型和 ResultMapper具体在 4.3 节展开。4.2 用 DefaultRedisScript 加载 .lua 文件推荐项目里脚本多、逻辑复杂时我建议用 ClassPathResource 加载 .lua 文件配合 DefaultRedisScript 使用。先看代码Configuration public class LuaScriptConfig { Bean public DefaultRedisScriptLong limitRateScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(scripts/lua/limit_rate.lua)); script.setResultType(Long.class); return script; } }使用时直接注入这个 BeanAutowired private StringRedisTemplate redisTemplate; Autowired private DefaultRedisScriptLong limitRateScript; public boolean tryAcquire(String key, int limit, int windowSeconds) { ListString keys List.of(key); Object[] args new Object[]{String.valueOf(limit), String.valueOf(windowSeconds), String.valueOf(System.currentTimeMillis()), UUID.randomUUID().toString()}; Long result redisTemplate.execute(limitRateScript, keys, args); return result ! null result 1L; }默认情况下 DefaultRedisScript 会在第一次执行时把整个脚本发给 RedisRedis 返回 SHA1 校验和。后续执行时 Spring 会自动改用 EVALSHA 方式也就是用脚本的 SHA1 摘要来引用脚本省去脚本内容传输和重新解析的开销。这对性能的提升在调用频繁的场景下非常明显。脚本文件的加载路径是 classpath 下的相对路径用 ClassPathResource 指定。Spring Boot 打 jar 包后resources 下的文件会自动包含正常运行没有问题。4.3 脚本返回值如何映射成 Java 对象脚本返回值与 Java 返回对象的映射是整个调用链路中最容易出问题的环节。Spring 的 DefaultRedisScript 要求你在创建脚本对象时指定 resultType它的作用不仅是类型转换更决定了 Redis 返回值的解析方式。常见的映射关系如下Lua 返回resultType 设置Java 侧拿到的值数字如 1Long.classLong字符串如 okString.classString布尔仅 true/falseBoolean.classBoolean多值数组List.classList复合结构table自定义类型 ResultMapper对应 Java 对象或 List 嵌套如果脚本返回的是一个数组tableresultType 设置成 List.class 后Spring 会把 Redis 返回的数组转成 List元素类型是脚本里存储的原始类型。如果需要转成更具体的对象比如一个包含 id 和 name 的 Map可以使用 ResultMapper 自定义转换逻辑或者干脆在 Lua 里就把结果用 JSON 字符串返回Java 侧再做反序列化。我个人更倾向于后者脚本里用 cjson.encode(table) 输出 JSON 字符串Java 侧用 Fastjson / Jackson 解析。好处是调试直观日志里能直接看到完整结果坏处是多了序列化和反序列化的一笔开销但相比脚本本身带来的性能提升这点开销可以忽略不计。5. 实战三个高频场景的 Lua 脚本实现5.1 库存扣减原子性防止超卖秒杀和抢购场景下库存扣减要求原子性。传统 Java 实现容易超卖用 Lua 脚本处理就稳了-- inventory_deduct.lua local stockKey KEYS[1] local soldKey KEYS[2] local quantity tonumber(ARGV[1]) local stock tonumber(redis.call(GET, stockKey) or 0) if stock quantity then return 0 end redis.call(DECRBY, stockKey, quantity) redis.call(INCRBY, soldKey, quantity) return 1调用上面的脚本时KEYS[1] 是库存 keyKEYS[2] 是已售 keyARGV[1] 是本次扣减数量。脚本先检查库存是否充足不足直接返回 0由业务侧提示“库存不足”充足则原子执行扣减和增加已售数返回 1。整个过程一次网络调用完成不存在并发穿插的问题。这个脚本能解决超卖的核心原因在于 Redis 单线程模型下 Lua 脚本的隔离性。两个请求同时执行脚本Redis 会排队执行第一个脚本执行完第二个脚本才看到最新的库存值。实际压测环境里我用 100 个线程同时抢 10 件商品最终库存和已售数完全对得上没有一条超卖数据。业务侧还可以在这个基础上加一个“防重复购买”的判断把用户 ID 作为 key脚本里先检查是否已在购买集合中未购买才执行扣减并把用户 ID 加入集合这样接口天然具备幂等性不需要额外处理。5.2 分布式锁获取和释放都用脚本保证安全分布式锁用 Lua 实现是标准做法。锁的占用要原子地设置值和过期时间释放要原子地校验持有者并删除 key。先看获取锁的脚本-- lock_acquire.lua local lockKey KEYS[1] local requestId ARGV[1] local ttl tonumber(ARGV[2]) local result redis.call(SET, lockKey, requestId, NX, PX, ttl) if result then return 1 end return 0释放锁的脚本-- lock_release.lua local lockKey KEYS[1] local requestId ARGV[1] local value redis.call(GET, lockKey) if value requestId then redis.call(DEL, lockKey) return 1 end return 0释放锁的脚本必须校验持有者身份requestId否则会出现“A 的锁被 B 释放”的经典问题。将 GET 和 DEL 合并进一个脚本保证校验和删除之间没有窗口期这也解决了使用 RedisTemplate 单独执行两步操作时存在的并发隐患。分布式锁还有一个问题是锁过期时间不好定。设太短业务没执行完锁就掉了其他线程进来并发设太长一旦持有者宕机锁要等很久才能被其他线程获取。我自己的做法是根据业务的最大执行时间上浮 50% 作为 TTL同时配合看门狗机制定期续期。看门狗可以用 Spring 的 Schedule 或单独的守护线程实现每执行到 TTL 的 1/3 时用脚本刷新过期时间。核心是锁状态的所有变更都走脚本保证任何时刻只有一个线程持有锁。另外一个踩过的坑用 SETNX 加锁时如果不设置过期时间一旦业务线程崩溃锁变成死锁其他线程永远等不到。SET key value NX PX ttl 一条命令搞定原子加锁千万别拆成两步。5.3 限流器滑动窗口限流与令牌桶接口限流是 Lua 脚本的另一大应用场景。滑动窗口限流可以精确到秒级甚至毫秒级控制请求速率。前面 3.3 节已经给出了 ZSet 实现的骨架这里补全一个直接可用的版本-- rate_limit_window.lua local key KEYS[1] local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local now tonumber(ARGV[3]) local member ARGV[4] redis.call(ZREMRANGEBYSCORE, key, 0, now - window * 1000) local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, now, member) redis.call(PEXPIRE, key, window * 1000) return 1 end return 0Java 侧调用时member 可以用 UUID 保证唯一避免窗口内重复计数。窗口大小和限流阈值从 ARGV 传入同一个脚本可以服务不同的接口和不同的限流策略灵活度很高。令牌桶算法也很常用。核心思路是系统按固定速率往桶里放令牌请求必须拿到令牌才能通过。实现上可以用 Hash 记录上次补充时间和当前令牌数-- rate_limit_token_bucket.lua local bucketKey KEYS[1] local capacity tonumber(ARGV[1]) local refillRate tonumber(ARGV[2]) -- 每秒补充速率 local now tonumber(ARGV[3]) local bucket redis.call(HMGET, bucketKey, tokens, lastRefill) local tokens tonumber(bucket[1] or capacity) local lastRefill tonumber(bucket[2] or now) local elapsed math.max(0, now - lastRefill) tokens math.min(capacity, tokens elapsed * refillRate) if tokens 1 then tokens tokens - 1 redis.call(HMSET, bucketKey, tokens, tokens, lastRefill, now) redis.call(PEXPIRE, bucketKey, 60000) return 1 end return 0这个脚本做了一层很重要的判断if tokens 1 时才扣减。如果 tokens 不足直接返回 0同时保留之前的令牌数和时间戳。注意脚本末尾要设置 PEXPIRE否则长时间不用的 key 会残留内存白白被占着。令牌桶和滑动窗口的取舍看业务形态需要平滑限流选令牌桶需要精确控制窗口内次数选滑动窗口。两者在压测下表现差异明显最好按实际流量模型选型。5.4 批量数据读取一次网络调用拿到多份数据除了并发控制Lua 脚本还能优化普通的读多写少场景。比如前端页面同时需要用户信息、订单统计、优惠券数量常规做法是三次 Redis 查询、三次网络往返。用脚本合并后一次搞定-- batch_get.lua local userInfo redis.call(GET, KEYS[1]) local orderCount redis.call(HLEN, KEYS[2]) local couponCount redis.call(SCARD, KEYS[3]) return { userInfo, orderCount, couponCount }Java 侧 resultType 设为 List.class返回的 List 里依次是用户信息字符串、订单数量、优惠券数量。调用一次 Redis网络耗时节约了三分之二。这个方法在首页聚合、报表查询、消息列表等场景下效果拔群。需要注意的是列表里的值类型不一定一致。比如 userInfo 是 JSON 字符串orderCount 是数字转的字符串。Java 侧拿到 List 后要按索引区分处理或者统一在脚本里用 tostring 转成字符串返回再统一按字符串处理避免 ClassCastException。6. 常见问题与排查技巧实录6.1 序列化不一致导致脚本查不到数据这是我把 Lua 脚本接入现有项目时踩的最深的一个坑。之前的项目用默认的 JdkSerializationRedisSerializer 存数据key 是加了类型前缀的二进制序列化结果。我后来用 StringRedisTemplate 执行 Lua 脚本脚本里的 GET KEYS[1] 按字符串解析 key结果发现之前存的数据全部查不到。排查过程很典型先用 Redis Desktop Manager 手动执行 EVAL脚本正常返回结果通过 Java 代码执行却返回空。后来翻看 Redis 里实际的 key 内容才发现存储格式完全对不上。解决方式有两种。第一种是统一切换序列化器项目里所有 RedisTemplate 都用 String 序列化key 和 value 都存可读字符串。第二种是脚本里显式指定 key 名称比如调 redis.call(GET, KEYS[1]) 之前确认 KEYS[1] 传的实际值和写入时的 key 完全一致。这也解释了为什么我建议给 RedisTemplate 显式设置 StringRedisSerializer——虽然默认的 JDK 序列化也能用但一旦和 Lua 脚本混合使用问题就会被放大。6.2 返回类型强制转换报错的问题使用 DefaultRedisScript 时resultType 设置不对是非常常见的报错来源。脚本返回 1但 resultType 传了 String.classSpring 内部就会尝试把 Long 型结果转成 String通常直接抛异常。反过来脚本里返回 trueresultType 却用 Long.class解析也会失败。看一个典型的报错现场org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Cannot deserialize value of type java.lang.Long from String true出现这类问题我的排查步骤是先明确脚本实际返回什么类型。最直接的方式是在 Redis Desktop Manager 的 Console 里手动执行一遍脚本观察返回值形态。然后对照 resultType 设置确保一致。另一个容易忽略的点是脚本里 return false 在 Redis 中会被转成空值 nilJava 侧拿到的是 null。如果你在 Java 代码里直接对结果做 0 判断就会 NPE。安全的写法是先判空再做数值比较。6.3 EVALSHA 报 NOSCRIPT 的异常Spring Data Redis 的 DefaultRedisScript 默认采用 EVALSHA 方式执行脚本。EVALSHA 的原理是用脚本 SHA1 摘要定位缓存脚本如果 Redis 端缓存被清空比如重启、FLUSHALL就会返回 NOSCRIPT 错误。ERR Error running script (call to f_xxxx): NOSCRIPT No matching script. Please use EVAL.Spring Data Redis 对这个问题有内置处理检测到 NOSCRIPT 后会自动回退到 EVAL 重新发送脚本内容。但如果你在一些老版本的 Spring Data Redis 里遇到或者自己封装了调用逻辑就要注意这个异常。我的建议是使用官方 DefaultRedisScript 而不是自己造轮子Spring 框架已经把这些问题都考虑进去了。Redis 服务端对脚本缓存的清理策略也要了解。脚本缓存是按实例级别的主从切换后新主节点可能没有缓存脚本。此时客户端需要保证 NOSCRIPT 后能自动重发。Spring Data Redis 的默认行为能满足绝大多数场景但如果你的项目对异常极其敏感可以在初始化阶段用 ScriptUtils 预加载脚本到 Redis把缓存预热好。6.4 Lua 脚本里操作了不存在的 key 或过期 key脚本里 GET 一个不存在的 key返回 falsenil如果直接 tonumber(false)在 Lua 里会得到 nil 或报错。处理方式要养成习惯先判空再转换local stock redis.call(GET, KEYS[1]) if not stock then return 0 end local stockNum tonumber(stock)还有一种情况key 存在但已经过期GET 返回 nil而 TTL 命令返回 -2。脚本里的处理逻辑要考虑到过期状态特别是在限流、库存这类场景过期后的 key 要当作不存在处理否则逻辑会出错。我在脚本里常见的兜底写法是local stock tonumber(redis.call(GET, KEYS[1]) or 0)or 0 的作用是如果 GET 返回 nil就用字符串 0 兜底。这样一行代码同时处理了 key 不存在和值非数字的异常情况脚本健壮性提升一个档次。6.5 已过期 key 的 TTL 处理细节在滑动窗口限流脚本里每次添加新元素时都会设置 PEXPIRE。这个 TTL 的作用是在窗口结束后自动清理整个 key避免内存堆积。但有个细节问题如果窗口期间一直有请求每次进来都重置 PEXPIRE那 key 的过期时间会不断往后推实际生命周期会远超窗口大小。这个要不要处理看业务需求。如果是严格的滑动窗口每次重置 TTL 其实不影响正确性因为窗口内的数据点会被 ZREMRANGEBYSCORE 持续清理。但如果你想保证 key 在窗口结束后尽快释放就不能每次重置过期时间而是只在第一次创建 key 时设置 TTL。这个细节决定了 Redis 内存占用模式长期运行的系统要留意。6.6 Lua 脚本性能调优与监控脚本性能直接影响 Redis 整体吞吐。我的监控经验是Redis 的 slowlog慢查询日志是排查脚本性能的首选工具。redis-cli slowlog get 10slowlog 会记录执行时间超过阈值的命令Lua 脚本会被记录为一条 EVAL 或 EVALSHA 记录执行时间一目了然。看到脚本耗时持续偏高时优先检查几类问题脚本里是否有大循环、是否有阻塞命令、网络往返是否频繁、脚本体是否过大导致传输成本高。脚本体大小控制也很重要。太长的脚本不仅有传输开销Redis 解析和编译也需要时间。一个实用原则是脚本不超过 1KB如果超过考虑把重复逻辑拆成公共函数或者用 Redis 7 的 Function 特性做服务端管理。Redis 7 开始支持 Function它比脚本缓存更进一步支持函数库管理、自动同步到从节点适合团队协作的大型项目但学习成本更高多数场景下 Lua 脚本已经足够。7. Spring Boot 业务整合与代码组织7.1 封装统一的 Lua 脚本执行服务项目中脚本多了以后每次执行都手工组装 RedisScript、List、args 太繁琐。我习惯封装一个 LuaScriptExecutor 服务类集中管理脚本加载和执行逻辑Service public class LuaScriptExecutor { private final StringRedisTemplate redisTemplate; public LuaScriptExecutor(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public T T execute(String scriptClasspath, ClassT returnType, ListString keys, Object... args) { DefaultRedisScriptT script new DefaultRedisScript(); script.setLocation(new ClassPathResource(scriptClasspath)); script.setResultType(returnType); return redisTemplate.execute(script, keys, args); } }使用时String luaPath scripts/lua/inventory_deduct.lua; Long result luaScriptExecutor.execute(luaPath, Long.class, List.of(stockKey, soldKey), quantity);这样做的另一个好处是脚本文件变更后业务代码不需要修改只要保持脚本内部的 KEYS 和 ARGV 顺序不变即可。我把所有脚本文件的参数约定写进 README 或注释里方便团队协作。半年后再回来维护也能快速定位到问题的入口。7.2 多业务模块共享脚本的命名规范业务模块多了以后脚本的管理很快会乱掉。我现在维护的脚本目录有二十几个文件没有合理规范的时候经常出现两个模块各写一份功能类似的脚本改一个忘另一个。我的命名规范是模块名_业务动作_版本.lua比如order_stock_deduct_v1.lua、user_login_limit_v1.lua。脚本内部用注释标明入参含义、返回值含义、业务逻辑说明。Redis 的 KEYS 使用业务前缀加冒号比如order:stock:10086一方面避免 key 冲突另一方面在 Redis Desktop Manager 里能按前缀快速过滤定位。共享逻辑用 Lua function 也可以但要注意Redis 里脚本之间没有全局共享状态每个脚本都是独立解释执行的。想复用逻辑只能把公共代码复制到多个脚本里或者用 Redis 7 的 Function 特性。目前我在团队里还是以复制为主毕竟大部分情况下公共逻辑只有几行。7.3 脚本参数校验与非法输入防御脚本作为服务端执行代码参数校验不能省。尤其是从外部传入的数值参数不校验可能导致脚本运行时崩溃或产生脏数据。我的检查项分为三类。第一空值检查key 列表为空、参数为空时直接返回错误避免执行空逻辑。第二类型检查数值参数必须能转成数字字符串参数不能包含危险字符。第三范围检查比如限流阈值必须大于 0库存扣减数量不能为负。这些校验放在脚本开头做保证后续逻辑的输入一定是合法的。if not KEYS[1] or not ARGV[1] then return redis.error_reply(Invalid arguments) end local quantity tonumber(ARGV[1]) if not quantity or quantity 0 then return redis.error_reply(Invalid quantity) endredis.error_reply 返回的错误信息会同步给 Java 侧日志里排查问题时能一眼看出是哪一步参数不对。7.4 监控日志与告警指标的设计接入脚本后我用 Micrometer 对执行情况进行埋点。核心指标有三个执行次数、执行耗时分布、异常次数。执行耗时是最重要的指标因为我前面提过 Redis 单线程模型下脚本执行耽误的是所有请求的时间。如果脚本 P99 耗时超过 50ms就要立刻优化。日志方面我会在 Java 侧记录脚本名称、keys、args敏感信息脱敏、执行结果。异常要记录完整堆栈特别是 NOSCRIPT、序列化异常、类型转换异常这几类常见问题。告警阈值我设置为单脚本执行耗时超过 100ms 或每分钟异常数超过 10 次立即触发钉钉/企微告警。团队看板上有这些指标线上问题基本能提前发现。生产环境我还会定期导出所有 Lua 脚本的源码和缓存信息人工走查一遍是否有独占资源的风险头部逻辑。这个走查的频率不高但每次都有收获。8. 经验之谈8.1 关于脚本复用的三个小技巧脚本复用的核心是参数化和易读性。我自己的三个小习惯每次都能减少返工。第一个技巧是所有脚本文件的第一行写清注释标注出参、入参、返回值和依赖的 key 结构。这个习惯一开始觉得多余半年后回改脚本时会感谢当初的自己。第二个技巧是脚本里的 Redis 命令按业务逻辑顺序排列同一类操作集中放一起别穿插着写。第三个技巧是定义明确的常量名比如 local DEFAULT_TTL 30000不要魔法数字满天飞。8.2 什么时候别用 Lua 脚本Lua 脚本很强大但不是银弹。我总结了几个不适合用脚本的场景。第一个是脚本里包含需要大量计算的逻辑。Redis 单线程执行10 万次循环的 CPU 运算会让整个实例卡顿几十毫秒所有请求都受影响。这种计算应该放到应用侧做。第二个是依赖外部状态的逻辑。脚本只能访问 Redis 内的数据拿不到配置文件、数据库、远程接口。跟外部系统交互就别指望脚本了。第三个是过于复杂的分支逻辑。脚本超过一百行时排错的成本会明显上升。维护时改一行逻辑要重新部署脚本压力很大。这种复杂逻辑建议拆成多个简单脚本在业务层编排。8.3 压测环境验证脚本稳定性的方法脚本上线前我建议至少做三轮验证。第一轮是功能验证在压测环境准备一批真实数据用接口触发脚本执行观察返回值和数据变化是否与预期一致。第二轮是并发验证用 JMeter 或 wrk 压测接口并发数从 50 到 500 逐步加压观察脚本有没有异常、Redis CPU 是否飙升、有没有慢查询。第三轮是故障验证模拟 Redis 重启、主从切换、网络抖动确认脚本能自动恢复。压测中最常发现的问题是脚本对边界条件的处理不完善。比如库存从 0 开始扣减、限流窗口刚过边界、key 过期瞬间访问等都要在压测用例里覆盖到。我的经验是压测环境的数据越接近生产脚本的验证效果越好。8.4 最近一次上线中脚本帮我避免的事故最后分享一次真实经历。上月给一个活动页面做流量控制准备阶段就把限流逻辑写成了 Lua 脚本。上线当天流量突然涨了 20 倍接口 QPS 逼近 3 万。如果按照传统方案在应用层用 Redisson 分布式锁保护光锁请求就能把 Redis 压垮。但我的脚本方案把整个限流逻辑下推到了 Redis 内部每个请求只产生一次内存操作Redis 负载最高才 30%活动平稳结束零超卖零崩溃。那次之后我更加确定在 Redis 场景里 Lua 脚本不是一个可选项而是处理并发一致性问题的标准答案。它真正把“数据一致性”从业务代码级别下沉到了数据存储级别这个思路在系统不断变复杂时价值会越来越明显。希望这篇教程能帮你少走一些弯路。