简介:这是一份讲解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脚本把几条命令打包成一个原子操作。今天这篇文章就是把“SpringBoot+Redis执行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与RedisTemplate:SpringBoot官方推荐的组合
在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(RedisScript<T> script, List<K> keys, Object... args)这个方法的第一个参数是脚本对象,第二个参数是脚本里用KEYS[]访问的键名列表,第三个参数是ARGV[]访问的额外参数。RedisTemplate会负责把keys和args发送到Redis服务端,但我必须提醒:这里有一个隐藏的序列化问题,如果RedisTemplate的keySerializer是JdkSerializationRedisSerializer,传入的String类型的key会被序列化成二进制带前缀的内容,导致Redis里找不到实际key。后面第4章专门讲。
DefaultRedisScript最核心的定义是脚本文本和返回类型。例如:
DefaultRedisScript<Long> 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>2.7.18</version> </dependency>注意这里如果你是1.x版本,Redis客户端默认是Jedis;2.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而不是RedisTemplate<String, String>,它能避免序列化问题,所有key/value都以UTF-8字符串方式传输。
@Service public class StockService { private final StringRedisTemplate stringRedisTemplate; private final DefaultRedisScript<Long> 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) { List<String> 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 DefaultRedisScript<Long> stockDeductScript() { DefaultRedisScript<Long> 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
这个坑最常见。使用RedisTemplate<String, 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侧使用GenericFunction:
@Bean public DefaultRedisScript<Long> rateLimitScript() { DefaultRedisScript<Long> 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分钟,但能筛出绝大多数错误。希望帮到你。
本文还有配套的精品资源,点击获取