SpringBoot整合Redis执行Lua脚本:多命令原子操作的关键一步
简介这是一份讲解SpringBoot整合Redis执行Lua脚本的PDF教程面向有一定Redis与SpringBoot基础的后端开发人员用于解决多命令操作缺乏原子性、Redis事务不支持回滚和逻辑计算等痛点问题。文档从实际需求出发先说明Lua脚本的原子操作、减少网络开销、复用性三大优势再梳理EVAL命令中script、numkeys、key、arg等参数的含义以及KEYS[1]、ARGV[1]在脚本内的取值方式并分别演示基于.lua脚本文件和直接编写脚本字符串两种使用场景每个场景均配有完整可运行的Java测试代码。资料共1个文件为PDF格式大小63KB整体内容结构紧凑适合按需查阅。目前累计已有3206人学习下载。通过这份文档读者可快速掌握在SpringBoot项目中结合RedisTemplate执行Lua脚本的完整流程并可直接参考其中的分布式锁删除案例能够显著提升多命令操作的原子性与可靠性。1. SpringBoot整合Redis执行Lua脚本多命令变原子操作的关键一步SpringBoot项目里接入Redis后需要执行Lua脚本的方法步骤是我觉得最容易被低估的一环。很多同学用RedisTemplate只做简单的set/get一旦遇到扣库存、限流、防重提交就退回去用Redis事务或者分布式锁代码绕一圈才发现Redis本身就能用Lua脚本把几条命令打包成一个原子操作。今天这篇文章就是把“SpringBootRedis执行Lua脚本”从依赖引入、脚本编写、代码调用到线上排错完整拆一遍。适合刚开始做高并发服务、或者已经在用Redis但想减少命令往返的开发者阅读。看完你能照着在自己的项目里跑通一个验证过的库存扣减脚本也能避开我在生产环境里踩过的几个坑。2. 原理与选型为什么SpringBoot里要选择Lua脚本2.1 Lua脚本在Redis中是如何执行的EVAL命令与原子性Redis从2.6版本开始支持通过EVAL命令执行Lua脚本执行过程可以理解为把一段Lua代码交给Redis服务端解释执行脚本中调用的Redis命令都嵌套在同一个上下文里。由于Redis的执行模型是单线程事件循环脚本一旦被加载在它运行完毕之前Redis不会处理其他客户端的命令请求。这种“独占”特性带来了非常强的原子性脚本里的多个写操作要么全部执行要么中途出错时所有写操作都不会生效。先用redis-cli跑一个最简脚本直观理解参数传递EVAL return {KEYS[1], ARGV[1]} 1 k1 val1注意这里的“1”代表KEYS的数量k1是KEYS[1]val1是ARGV[1]。执行结果返回数组为k1和val1。关键点Lua脚本里的索引从1开始不是从0。很多开发者第一次用RedisTemplate传参时习惯把Java数组第0个元素对应ARGV[1]这在Spring Data Redis的封装中其实没有歧义因为框架会把Object数组原样作为ARGV传入ARGV[1]对应Java数组索引0。但写成原生EVAL命令时下标错位会导致取到nil。再补充一个容易忽略的细节Redis执行Lua脚本不具备数据库“回滚”语义。如果脚本里先执行了SET key1 v1再执行DECR key2时发现key2不存在脚本抛错后已经写入的key1并不会自动撤销。所以设计脚本时通常的事故保护方式是把所有检查放在前面在最后一段才做写操作也就是“校验与执行分离”。2.2 RedisScript与RedisTemplateSpringBoot官方推荐的组合在SpringBoot中操作Redis绝大多数项目用的是spring-boot-starter-data-redis它内部依赖Spring Data Redis提供的RedisTemplate。RedisTemplate本身封装了连接管理、序列化、命令分发。针对Lua脚本Spring Data Redis抽象出一个RedisScript接口默认实现是DefaultRedisScript用来承载Lua脚本内容和返回类型。用RedisTemplate执行Lua脚本的核心API是execute方法最常用的是带keys和arguments的重载T T execute(RedisScriptT script, ListK keys, Object... args)这个方法的第一个参数是脚本对象第二个参数是脚本里用KEYS[]访问的键名列表第三个参数是ARGV[]访问的额外参数。RedisTemplate会负责把keys和args发送到Redis服务端但我必须提醒这里有一个隐藏的序列化问题如果RedisTemplate的keySerializer是JdkSerializationRedisSerializer传入的String类型的key会被序列化成二进制带前缀的内容导致Redis里找不到实际key。后面第4章专门讲。DefaultRedisScript最核心的定义是脚本文本和返回类型。例如DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(redis.call(SET, KEYS[1], ARGV[1]); return 1;); script.setResultType(Long.class);返回类型决定Spring Data Redis如何把脚本返回值转换成Java对象。如果脚本返回整数使用Long.class如果返回布尔使用Boolean.class如果返回复杂结构就需要自定义序列化器或直接把RedsiScript的resultType声明为String.class再手动解析。推荐优先用Long或Integer简单可靠。2.3 对比其他方案为什么不是管道、事务或分布式锁做多步操作还有几个常见选择管道Pipeline、Redis事务MULTI/EXEC、分布式锁。管道只是减少了网络往返不保证原子性不能用。Redis事务虽然有隔离性但在事务中检测到错误时MULTI之后执行的命令仍然可能被部分执行使用起来没有Lua脚本熟悉。分布式锁则是在客户端协调多个线程要把业务逻辑拆到加锁、判断、解锁多个步骤复杂度高且锁失效问题需要小心设计。Lua脚本直接一次性提交给服务端优点是实现简单、原子可控、不需要设计锁超时。方案原子性实现复杂度适用场景Pipeline无低批量读取、非精确写入MULTI/EXEC有但部分命令可能部分执行中简单事务不需要脚本计算分布式锁有依赖锁实现高跨节点互斥业务代码复杂Lua脚本有低多命令原子组合带校验逻辑2.4 哪些场景值得用Lua脚本哪些场景不能用我一般会在三类场景用Lua脚本原子校验后写数据比如库存扣减时先GET库存再DECR多键组合操作比如把订单写入待支付集合同时设置过期时间限流计数比如INCR后判断是否超过阈值并设置过期时间。这些操作的共同点是“多步、有依赖、需要原子”。不适合的场景包括脚本里有耗时的长循环、循环次数与外部输入相关可能阻塞Redis数十毫秒、依赖随机函数会影响复制过程中的重放、需要访问外部服务Lua里没有socket库。特别是高并发环境务必控制脚本执行时间把主要逻辑放到写操作上避免在脚本里做过多的字符串切割和遍历。在这里给出一个我踩过的反例早期做多维度风控时把一个用户在某段时间内的所有订单号通过ZSET返回然后在Lua脚本里遍历所有元素做分割和二次查询。测试环境没问题上线后偶尔出现大量命令排队延迟。后来定位到是因为脚本处理了几百个订单号循环次数多导致单个脚本执行耗时几十毫秒。优化方式是把复杂计算挪到Java侧只把最后的判断和写入交给Lua。3. 完整实现步骤从依赖到单测跑通一个库存扣减脚本3.1 引入依赖并配置Redis连接参数首先在pom.xml中加入Spring Data Redis依赖一般用starter即可。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId version2.7.18/version /dependency注意这里如果你是1.x版本Redis客户端默认是Jedis2.x之后默认Lettuce。Lettuce是线程安全的推荐使用。为了让序列化简单可以配合commons-pool2连接池也可以不配看项目需求。我在生产环境建议配连接池避免频繁创建连接。然后在application.yml中配置连接信息spring: redis: host: 127.0.0.1 port: 6379 password: timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 0这里的timeout是连接超时和读写超时共用按实际情况调整。如果出现连接卡顿可以单独配置spring.redis.lettuce.pool。对于只有本地测试可以不配pool直接跑。3.2 编写库存扣减的Lua脚本在src/main/resources/lua目录下创建StockDeduct.lua。脚本逻辑先读当前库存如果足够就DECRBY否则返回-1。这里使用KEYS[1]作为库存键ARGV[1]作为扣减数量。-- 校验库存是否充足充足则扣减并返回扣减后的库存不足返回-1 local stock tonumber(redis.call(GET, KEYS[1]) or 0) if stock tonumber(ARGV[1]) then return redis.call(DECRBY, KEYS[1], ARGV[1]) end return -1这里为什么要用or 0因为库存键可能不存在GET返回nil直接tonumber(nil)会报错所以先补默认值0。这在脚本中非常常见。注意return的返回值是扣减后的剩余库存还是扣减数量我习惯返回剩余库存这样业务方可以精确知道当前剩下多少。开发者可以按自己业务定义但Java侧返回值类型要一致。3.3 在Java中加载脚本并执行首先需要定义一个配置或服务类专门负责加载和执行脚本。我用StringRedisTemplate而不是RedisTemplateString, String它能避免序列化问题所有key/value都以UTF-8字符串方式传输。Service public class StockService { private final StringRedisTemplate stringRedisTemplate; private final DefaultRedisScriptLong stockScript; public StockService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; this.stockScript new DefaultRedisScript(); this.stockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(lua/StockDeduct.lua))); this.stockScript.setResultType(Long.class); } public Long deductStock(String stockKey, int quantity) { ListString keys Collections.singletonList(stockKey); Object[] args new Object[]{String.valueOf(quantity)}; return stringRedisTemplate.execute(stockScript, keys, args); } }这段代码有几个关键点需要解释使用ClassPathResource从classpath加载lua文件spring会读取文件内容。更简便的方式是直接setScriptText字符串但独立文件更好维护。resultType声明为Long.class脚本返回DECRBY的整数结果会被转换成Long返回-1时也是Long不会抛类型转换异常。keys参数一定要是List 不是可变长数组args是Object[]每个元素都可以是String或数字。我习惯全部转成String避免序列化类型不一致。3.4 脚本发送的性能优化EVALSHA与脚本缓存每次调用stringRedisTemplate.execute都会先尝试用EVALSHA执行脚本的SHA1摘要如果Redis还没缓存过该脚本命令会报错并自动退化使用EVAL发送整个脚本。Spring Data Redis的DefaultRedisScript内部保存了脚本内容的sha1摘要所以这个“失败再重发”是框架自动完成的我们无需手动关心。但要注意DefaultRedisScript每次new出来都会重复计算sha1虽然成本很低但更好的做法是把脚本对象声明成单例Bean。上面的Service已经保证stockScript只创建一次。如果项目里有很多脚本可以统一放在一个RedisScriptConfig配置类里集中管理。Configuration public class RedisScriptConfig { Bean public DefaultRedisScriptLong stockDeductScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/StockDeduct.lua)); script.setResultType(Long.class); return script; } }然后在Service里直接注入这个bean代码更简洁。3.5 单元测试用SpringBootTest验证脚本原子性写一个SpringBoot测试类验证正常扣减和超卖场景。SpringBootTest class StockServiceTest { Autowired private StockService stockService; Autowired private StringRedisTemplate stringRedisTemplate; Test void testDeductStock_success() { String key stock:1001; stringRedisTemplate.opsForValue().set(key, 10); Long rest stockService.deductStock(key, 3); assertEquals(7L, rest); } Test void testDeductStock_insufficient() { String key stock:1002; stringRedisTemplate.opsForValue().set(key, 1); Long rest stockService.deductStock(key, 5); assertEquals(-1L, rest); } }这里需要注意如果连接的真实Redis中已经存在同名key测试用例会互相影响。建议在每个测试方法使用的key上加随机后缀或使用BeforeEach清理。上面代码为了简洁用了固定key实际工程中别这么干。4. 避坑集锦SpringBoot执行Lua脚本的五个高频坑4.1 现象KEYS参数传不进去脚本总是返回nil或nul有一次业务反馈接口偶发超时日志显示脚本执行结果为null。检查代码发现开发写的是stringRedisTemplate.execute(stockScript, stockKey, quantity);这里错把小写字母开头的变量直接当成多个参数但execute的重载签名需要List作为keys参数直接传String会被解释为Object... args的第一个值导致KEYS集合为空。框架内部把脚本里的KEYS[1]解析成nil自然找不到库存。解决必须显式构造List传参如Collections.singletonList(stockKey)。另外检查是否误用了execute(RedisScript, Object... args)这个重载。总之以IDE提示为准。4.2 现象脚本报语法错误但同样的逻辑在redis-cli里能跑在redis-cli里执行时记得用双引号包裹整个脚本内部字符串用单引号。但在Spring加载外部lua文件时文件里的引号不受这个问题影响。更多情况是脚本中包含Redis命令但是函数名写错例如redis.call写成redis.call_safe。redis-cli里报错后会立即显示但是Spring日志可能只记录一条ERR Unknown Redis command called比较难定位。解决先在redis-cli里用EVAL命令执行一次写好脚本后再放到classpath。redis-cli --eval /path/StockDeduct.lua key1 key2, arg1 arg2这种写法能直接加载本地文件方便验证。4.3 现象RedisTemplate默认序列化导致脚本找不到Key这个坑最常见。使用RedisTemplateString, Object时默认keySerializer是JdkSerializationRedisSerializer。它会为key字符串加上二进制类型头实际写入Redis的key是类似\xAC\xED...的字节。而Lua脚本里的KEYS[1]是从连接命令传进来的字符串经过Lettuce发送时使用的是UTF-8最终去查找key时当然匹配不到。解决统一使用StringRedisTemplate或者在定义RedisTemplate时设置keySerializer和valueSerializer为StringRedisSerializer。我的经验是执行Lua脚本的场景尽量用StringRedisTemplate所有放入Redis的数据都先转成String简单可控。4.4 现象脚本执行时间增长Redis出现命令堆积脚本本身没有问题但里面写了大循环或者使用了KEYS*这种全量扫描导致Redis主线程被占用后续读写命令排队。Redis官方明确要求Lua脚本只做少量命令操作不能有不可控循环。解决在脚本里限制循环次数使用local count tonumber(ARGV[1])当count超过一个阈值如100时直接返回错误。另一个手段是用Redis的slowlog观察SLOWLOG GET 20如果看到类似EVAL命令耗时超过几十毫秒就说明脚本需要优化。我在生产环境会额外配置Lua脚本执行超时保护config set lua-time-limit 100超过100毫秒Redis会中断脚本并记录日志。4.5 现象集群环境多键脚本报错报slot错误Redis Cluster要求一个Lua脚本操作的所有key必须在同一个hash slot。如果脚本使用KEYS[1]和KEYS[2]而这两个key的自然hash值落在不同槽Redis会拒绝执行。这个限制在单机Redis上不存在但上集群后就会翻车。解决设计key时使用hash tag例如order:{userId}:list和order:{userId}:lock。花括号里的部分参与hash计算使得两个key最终落在同一slot。强烈建议在分布式环境使用Lua脚本时统一规划key格式。5. 进阶技巧用Lua实现固定窗口限流并验证原子性这个章节落一个可以直接抄走的限流脚本。需求是同一个用户ID在1分钟内最多只能访问5次。常规里做的话需要两步INCR和EXPIRE但这两个操作必须原子否则并发时可能漏设过期时间导致key永远存在。Lua脚本可以同时完成-- KEYS[1] 限流对象ID -- ARGV[1] 窗口秒数 -- ARGV[2] 最大请求次数 local idx redis.call(INCR, KEYS[1]) if idx 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) end if idx tonumber(ARGV[2]) then return 1 end return 0Java侧使用GenericFunctionBean public DefaultRedisScriptLong rateLimitScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(...上面的Lua...); script.setResultType(Long.class); return script; }这里有个验证原子性的方法把脚本里的EXPIRE去掉并发调用100个请求观察Redis中key的TTL是不是有的为-1永久。一旦发现有一部分请求创建key但没有设置过期时间就说明业务逻辑被拆开了。这个测试用普通的countdownlatch并发跑即可不需要引入压测工具。我在一次业务里就因为这个没了EXPIRE导致线上限流key永久存在后续同用户永远被限流。后来养成习惯凡是Lua脚本里有写操作第一遍先用redis-cli --eval手工跑一次第二遍写单测最后才发布。这个流程虽然多花10分钟但能筛出绝大多数错误。希望帮到你。本文还有配套的精品资源点击获取