聊到 Spring Boot 集成 Redis,很多人最先想到的是缓存读写、Session 共享这一类基础操作,但真正让我觉得“这套组合值得单独写一篇”的点,其实在 Redis 的 Lua 脚本能力上。Lua 脚本在 Redis 里最大的价值是原子性:一段脚本发给 Redis 后,会被当成一个整体执行,中间不会被其他客户端命令插队。这个特性在秒杀扣库存、接口限流、分布式锁这种高并发场景里,几乎是绕不开的必选项。
这篇文章我会从为什么用 Lua、如何在 Spring Boot 里配置 RedisTemplate,到脚本如何封装、如何传入参数、如何处理返回类型,再到集群环境下遇到的坑,完整走一遍。适合正在做 Spring Boot 项目、已经用过 Redis 做缓存但没碰过脚本,或者在并发扣减、幂等控制上被“超卖”“重复提交”坑过的人。对照代码跟着敲一遍,大概一个下午就能在自己项目里跑通。
1. 为什么偏偏要用 Lua 脚本,普通 Java 代码差在哪
1.1 Redis 单线程执行模型,才是原子性的真正来源
先理解 Redis 为什么能用脚本保证原子性。Redis 处理命令是单线程模型,意思是不管客户端并发量多大,Redis 服务端在单位时间里只执行一条命令,执行完再取下一条。你可以把它想象成只有一个窗口的柜台,所有客户请求都得排队,不存在两个人同时占用窗口的情况。
普通 Java 代码的问题在于,一次“检查-操作”逻辑往往需要多条 Redis 命令,比如扣库存时先 GET 查数量,再 DECRBY 减库存。这两条命令之间是存在时间间隔的,第一个请求刚查完库存,第二个请求也查到了同样的数据,然后两个请求都走减库存逻辑,超卖就这么发生了。Lua 脚本则把“检查-操作”打包成一个整体,Redis 执行时会把这个脚本当成一条命令来跑,脚本内的多条 redis.call() 命令不会再被其他客户端插队,原子性从源头解决。
1.2 典型场景:库存扣减、接口限流、分布式锁都靠它
实际项目中,我见过最多用 Lua 脚本的地方是这三类。
第一类是库存扣减,尤其是秒杀场景。常规思路是先查库存再减库存,但遇到突发流量就超卖。用 Lua 可以直接写“如果库存大于 0 才执行减库存并返回成功,否则返回失败”,这一整套判断在脚本内部完成,不需要 Java 层做任何加锁。
第二类是接口限流。比如短信验证码每分钟最多发 5 条,或者一个用户一秒钟只能请求一次。这类频率控制在 Lua 里可以用 INCR、EXPIRE、ZADD 这类命令组合实现,而且可以做到先判断再计数,不容易被并发绕过。
第三类是分布式锁的释放。释放锁时要先判断持有者是不是自己,再执行 DEL,这两个动作也必须原子化,否则可能出现线程 A 释放了线程 B 刚获取的锁。用 Lua 脚本就能把“GET 比较 + DEL 删除”两件事合成一步完成。
1.3 为什么不建议用事务(MULTI/EXEC)替代脚本
可能有人会问,Redis 不是有 MULTI/EXEC 事务吗,为什么不用它?事务确实能保证一批命令按顺序执行不被插队,但它有一个致命短板:事务里的命令只是被“打包排队”,执行过程中不能读取前一条命令的结果,无法做条件判断。比如“库存小于 0 就不减”这种逻辑,事务压根做不了,除非先 WATCH 某个 key,再通过丢弃事务来实现“悲观重试”,代码难写还容易出问题。
而 Lua 脚本天然支持 if/else、循环、局部变量,可以读取脚本内任意一条命令的结果,再根据这个结果决定下一步做什么。Redis 官方也多次强调,Lua 脚本就是替代“事务 + 条件判断”这类复杂操作的最佳方案。
1.4 两种方案的对比,直观理解差距
| 对比项 | 普通 Java 代码 + 多条命令 | Lua 脚本 |
|---|---|---|
| 原子性 | 不保证,中间可能被其他请求插队 | 打包成一个原子操作执行 |
| 条件判断 | 需要 Java 层先查再判断,存在并发窗口 | 脚本内部完成判断,无窗口 |
| 网络开销 | 每条命令一次 RTT | 一次脚本调用完成全部操作 |
| 调试复杂度 | 相对简单 | 需要懂 Lua 语法,前期有学习成本 |
| 适用场景 | 简单读写、无强校验 | 扣库存、限流、分布式锁等强校验场景 |
2. 环境准备:Spring Boot 里先把 Redis 跑起来
2.1 引入依赖和基础配置
用 Spring Boot 集成 Redis,目前主流方案是 Spring Data Redis,它既支持 Lettuce 也支持 Jedis。Spring Boot 2.x 之后默认使用 Lettuce,除非有特殊要求,否则直接用默认就好,无需额外配置连接池就能跑。
Maven 依赖只要两个,一个 starter,一个连接池。连接池不是必须的,但生产环境建议加上,避免高并发下连接被耗尽。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>application.yml 里配置 Redis 连接信息。以本地开发为例,host 用 localhost,端口 6379。需要说明的是,配置中的 timeout 指的是读取超时,连接超时另有 connection-timeout,团队里有人在这两个参数上踩过坑,这里写清楚。
spring: data: redis: host: localhost port: 6379 password: database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 22.2 本地快速起一个 Redis 实例
本地调试最省事的方式是用 Docker。如果你机器上没装 Redis,一条命令就能拉起来。
docker run -d --name redis-demo -p 6379:6379 redis:7-alpine启动后检查一下是否正常:
redis-cli ping能返回 PONG 就算成功。这里提一句,Redis 版本建议用 6.x 或 7.x,老版本虽然也能写 Lua,但部分命令和错误提示有差异,照着本文操作可能会遇到对不上的情况。
2.3 RedisTemplate 序列化器必须是第一道关
Spring Data Redis 默认使用 JdkSerializationRedisSerializer,如果直接把 String 类型的 key 存进去,Redis 客户端里看到的是类似\xAC\xED\x00\x05t\x00...的乱码。这不仅是可读性问题,更关键的是:如果你用 Lua 脚本去操作 key,而脚本里的键名是普通字符串,存储时的 key 是 JDK 序化后的字节数组,那脚本根本找不到数据。
所以在初始化 RedisTemplate 时必须主动配置序列化器。最稳妥的组合是:key 用 StringRedisSerializer,value 用 GenericJackson2JsonRedisSerializer。这样存进去的 key 是明文,Redis Desktop Manager 里一眼就能看懂。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; } }很多人写 Lua 脚本时发现返回结果经常是 null,排查到最后才发现是序列化配置的问题。记住,key 的序列化方式决定了你要不要为“脚本里用的是哪个 key”付出额外成本,这一条一定要在配置阶段处理好。
3. Lua 脚本的核心语法与 Spring 的封装逻辑
3.1 EVAL 命令的真实形态:KEYS 和 ARGV
Redis 执行 Lua 脚本的命令是 EVAL,完整语法是:
EVAL script numkeys key [key ...] arg [arg ...]其中 script 是 Lua 脚本内容,numkeys 是脚本中要用到的 key 的数量,后面跟的 key 就是 KEYS 数组,再往后就是 ARGV 数组。写 Lua 脚本时,key 和参数必须分开传,不能混在一起。
举个例子,最简单的自增脚本:
return redis.call('incr', KEYS[1])对应的 EVAL 命令是:
EVAL "return redis.call('incr', KEYS[1])" 1 counter这里的 1 表示有一个 key,counter 就是 KEYS[1]。如果需要传额外参数,比如给某个 key 设置过期时间:
EVAL "redis.call('set', KEYS[1], ARGV[1]); return redis.call('expire', KEYS[1], ARGV[2])" 1 user:1001 age 300其中 ARGV[1] 是 age,ARGV[2] 是 300。这个“举一反三”的能力在写限流脚本时非常有用。
3.2 Spring 的 DefaultRedisScript 如何承载 Lua 脚本
Spring Data Redis 提供了一个核心类DefaultRedisScript<T>。泛型 T 表示脚本返回值在 Java 侧的类型,非常重要,后面会专门讲。创建方式有两种:
第一种,脚本写在代码里:
DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText("return redis.call('incr', KEYS[1])"); script.setResultType(Long.class);第二种,脚本放在 classpath 下的 .lua 文件里,便于维护和审查:
DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/incr.lua")); script.setResultType(Long.class);推荐第二种。脚本文件单独管理,即使不会写复杂 Lua,也能一目了然地看到每个脚本干了什么,比把大段字符串糊在 Java 代码里清爽太多。
执行脚本的入口是 RedisTemplate 的 execute 方法,签名是这样的:
<T> T execute(RedisScript<T> script, List<K> keys, Object... args)一个实际调用例子:
Long result = redisTemplate.execute(script, Collections.singletonList("counter"), 5L);这个调用等价于执行命令EVAL "return redis.call('incr', KEYS[1])" 1 counter 5,不过由于 Spring 对参数做了序列化处理,实际中的参数类型对应关系要仔细一点,一会儿我在第 5 部分单独讲。
3.3 Spring 内部如何缓存脚本:默认走 EVALSHA
Redis 执行 Lua 脚本时,除了 EVAL 还有一条 EVALSHA 命令。EVALSHA 通过脚本的 SHA1 校验和直接执行已经被缓存过的脚本,好处是网络传输的数据更少,因为不用每次都把整个脚本内容发过去。Spring Data Redis 内部会自动做这件事:第一次执行时用 EVAL 把脚本注册到 Redis,后续执行就优先用 EVALSHA。如果 Redis 重启或者节点切换导致脚本丢失,Spring 会捕获 NOSCRIPT 错误并自动回退到 EVAL 重新注册。
所以你在业务代码里不用关心 EVAL 还是 EVALSHA 的选择,Spring 都处理好了。但理解这个机制有一个实际意义:如果你在 Java 层动态拼接 Lua 脚本文本,每次生成不同的 SHA1 内容,Spring 的缓存优化就完全失效,还会造成 Redis 里脚本缓存越积越多。脚本最好保持稳定不变,变化的部分通过 ARGV 传参。
4. 实战案例:用 Lua 脚本实现库存原子扣减
4.1 需求拆解:防止超卖,也要防止重复扣减
先明确业务需求。假设有一个商品秒杀活动,商品库存放在 Redis 的 keystock:1001中,用户点击秒杀后需要完成两件事:
- 检查库存是否大于 0,如果大于 0 就扣减 1,并返回扣减成功。
- 同一用户不能重复秒杀,需要校验用户是否已经买过。
如果只实现了第一点,可能出现同一个用户用多个账号刷接口,或者同一账号发疯式点击,把库存快速薅光。所以脚本里还要维护一个“已购买用户集合”的 key,比如stock:1001:buyers,用 SADD 命令去重。
4.2 编写 Lua 脚本文件
在 src/main/resources/lua 目录下新建stock_deduct.lua:
-- KEYS[1]: 商品库存 key -- KEYS[2]: 已购买用户集合 key -- ARGV[1]: 用户 ID -- ARGV[2]: 扣减数量 -- 用户是否已购买 local bought = redis.call('SISMEMBER', KEYS[2], ARGV[1]) if bought == 1 then return -2 -- 已购买,不能重复下单 end -- 当前库存 local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then -- 库存 key 不存在,说明商品可能尚未初始化 return -1 end -- 库存不足 if stock < tonumber(ARGV[2]) then return 0 end -- 扣减库存 redis.call('DECRBY', KEYS[1], ARGV[2]) -- 记录已购买用户 redis.call('SADD', KEYS[2], ARGV[1]) return 1 -- 扣减成功这里返回值的约定如下:
| 返回值 | 含义 |
|---|---|
| 1 | 扣减成功 |
| 0 | 库存不足,扣减失败 |
| -1 | 库存 key 不存在 |
| -2 | 用户重复购买 |
用约定好的整数返回,比返回一个 mid 字符串方便得多,Java 侧直接强转成 long 就能用。
4.3 将脚本声明为 Spring Bean
在配置类中把脚本声明成 Bean,业务代码里直接注入使用:
@Configuration public class RedisLuaConfig { @Bean public DefaultRedisScript<Long> stockDeductScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/stock_deduct.lua")); script.setResultType(Long.class); return script; } }4.4 业务层调用与结果处理
业务代码的调用逻辑很清爽,因为一切的检查和原子操作都在 Redis 里完成了,Java 层只需要按返回值判断结果:
@Service public class StockService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Resource(name = "stockDeductScript") private RedisScript<Long> stockDeductScript; public boolean deductStock(Long productId, Long userId) { String stockKey = "stock:" + productId; String buyerKey = "stock:" + productId + ":buyers"; Long result = redisTemplate.execute( stockDeductScript, Arrays.asList(stockKey, buyerKey), userId.toString(), 1L ); if (result == null) { // 这里要注意:脚本返回 nil 时 Java 侧拿到 null,需要记录日志 log.error("库存扣减脚本返回为空, productId={}, userId={}", productId, userId); return false; } return result == 1L; } }这段代码有两点值得留意。
第一,RedisScript 注入时类型是RedisScript<Long>,而不是DefaultRedisScript<Long>。RedisScript 是 Spring Data Redis 的核心接口,Bean 里声明 DefaultRedisScript 完全没问题,但业务代码面向接口编程更规范。
第二,execute 方法的第二个参数是 List<String>,这里必须和 Lua 脚本中 KEYS 的顺序严格对应。脚本里 KEYS[1] 是库存 key,KEYS[2] 是买家集合 key,传递顺序千万别反,否则会出现“库存不足”提示却把买家记到库存 key 上的离奇错误。
4.5 补充一段经典的分布式锁释放脚本
库存扣减说完了,顺手把分布式锁的加锁和解锁 Lua 脚本也写出来。加锁用官方推荐的 SET NX PX 命令即可:
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, token, 30, TimeUnit.SECONDS);但释放锁不能直接 DEL,因为可能把别人刚抢到的锁删掉。正确做法是先用 GET 判断锁的持有者是不是自己,是的话才 DEL。这两个动作必须原子,所以要用 Lua:
-- KEYS[1]: 锁的 key -- ARGV[1]: 当前线程的唯一标识 token if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end对应声明和调用:
DefaultRedisScript<Long> unlockScript = new DefaultRedisScript<>(); unlockScript.setScriptText("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"); unlockScript.setResultType(Long.class);这段脚本特别短,用字符串形式写不显得臃肿。刻意提醒下:锁的 value 一定要是一个全局唯一的 token,可以简单用 UUID + 线程 ID 生成,防止误删别人的锁。
5. 解决“参数对不上”和“类型返回不对”两大头痛问题
5.1 Lua 返回值到 Java 类型的映射规则
很多第一次写 Lua 脚本的同事都会在返回类型上栽跟头。比如脚本里明明return 1,到了 Java 侧却拿不到 Long,这是因为 Spring 需要在初始化脚本时指定 resultType,它决定了结果反序列化时用哪个类型去解析。
实际映射规则如下:
| Lua 返回值 | 无 resultType 时 Java 侧默认行为 | 指定 resultType 后的常见表现 |
|---|---|---|
| number | Integer 或 Long,取决于数据大小 | Long(若指定 Long.class) |
| string | byte[] 或 String,取决于序列化器 | String |
| table | List | List 或 Map,取决于内容结构 |
| nil | null | null |
| status reply(如 OK) | byte[] 或 String | 取决于 resultType |
所以用DefaultRedisScript<Long>并显式设置setResultType(Long.class),是最常见的姿势。如果你要处理多个返回值,可以返回一个 table,即 Lua 的列表,对应到 Java 侧一般是 List。
一个很典型的坑:脚本返回数字,但 resultType 没设置,Spring 会抛出类似Unable to determine RedisScript result type的异常,经验不足的人会以为代码逻辑有问题,其实只是少了 setResultType 那行。
5.2 参数类型与序列化器的配合
execute 方法的第三个参数是Object... args,也就是 ARGV。这里的参数类型不是随便传的,它会被 RedisTemplate 定义的序列化器序列化。
举个例子,如果你往 ARGV 里传了一个 Long 类型的 1,而 valueSerializer 是 GenericJackson2JsonRedisSerializer,那这个 1 在 Redis 侧收到的就是 JSON 序列化后的字符串1。在 Lua 脚本里如果直接用ARGV[1]做数值比较,可能得到的是字符串"1"而不是数字 1,导致比较结果出乎意料。
稳妥的做法是:脚本中凡是参与数值运算或数值比较的参数,统一用tonumber()转换。
local amount = tonumber(ARGV[2]) if stock < amount then return 0 end我在上一节的库存脚本里已经这么做了,现在要说的是为什么:GenericJackson2JsonRedisSerializer 会把参数转成带类型的 JSON 字符串,如果不转换直接参与比较,很容易得到 false。这也是我在项目里要求所有 Lua 脚本对数值类型参数一律 tonumber 的原因。
5.3 序列化不一致导致脚本找不到 key
这个问题前面提过,但它是出现频率最高的问题之一,值得再展开说。如果你的 RedisTemplate 没有改序列化器,默认用 JDK 序列化存储 key,那你在 redis-cli 里看到的 key 就是乱码。此时 Lua 脚本用KEYS[1]直接查stock:1001,实际上是拿着一个普通字符串去匹配一个 JDK 序列化后的字节数组,永远匹配不上。
解决办法很简单:确保 key 都用 StringRedisSerializer,value 根据业务选择。如果 project 里已经有大量 JDK 序列化的旧数据,要么写一次性迁移脚本把 key 重写,要么在 Lua 脚本中对 KEYS 做同样方式的 JDK 序列化后再传入。后者相当别扭,还是尽量一次到位,新项目建议直接统一序列化策略。
5.4 常见异常速查表
| 异常或现象 | 原因 | 解决方案 |
|---|---|---|
Unable to determine RedisScript result type | DefaultRedisScript 没有设置 resultType | 调用 setResultType 指定,如 Long.class |
| 脚本查不到 key,结果总是 null | RedisTemplate 默认 JDK 序列化,key 格式不一致 | 更换为 StringRedisSerializer |
| Lua 返回数字但 Java 侧是 null | resultType 不匹配,或脚本 return nil | 明确 Lua 返回路径,设置正确的 resultType |
| 比较 ARGV 中的数字总是失败 | 参数被序列化成字符串 | Lua 中使用 tonumber() 转换 |
| 脚本传入多个 key 但只声明了 1 个 | 执行时 keys 列表长度与 numkeys 不匹配 | 检查列表顺序和长度,必须与脚本 KEYS 一致 |
6. 集群环境下 Lua 脚本的经典坑
6.1 ERR eval ... keys must hash to same slot
如果你把项目部署到 Redis Cluster,执行带多个 key 的 Lua 脚本时,很可能会看到这个报错:
ERR 'eval' command keys must hash to same slot这个错误源于 Redis Cluster 的分片机制。集群把数据按 key 的 CRC16 哈希值分到 16384 个哈希槽(slot),每个节点负责一部分槽位。一个 Lua 脚本涉及的多个 key,必须落在同一个节点上,也就是必须哈希到同一个 slot,否则集群不知道该把脚本发给谁执行。
解决办法有两个层面。第一,脚本里的 key 数量尽量少;第二,同一业务的一组 key 要设计成能落到同一个 slot。这也是为什么写业务脚本时,我非常推荐把所有相关数据放在同一个 key 前缀下。
6.2 用 Hash Tag 强制 key 落到同一个 slot
Redis Cluster 提供一种“哈希标签”机制,可以让多个 key 强制落到同一个 slot。规则很简单:key 字符串中被{}括起来的内容作为实际参与哈希计算的部分。比如:
user:{1001}:cart user:{1001}:order这两个 key 因为都包含{1001},所以会被哈希到同一个 slot。在 Lua 脚本里同时操作这两个 key 就没有跨 slot 问题。
库存扣减场景里也可以这么设计:
stock:{1001}:stock stock:{1001}:buyers注意 Hash Tag 要慎用,因为它会让某些节点负载明显偏高,破坏集群的整体均衡。如果一个业务域的 key 都被打到同一个 slot,那这个 slot 所在的节点就会成为瓶颈。所以我的建议是:只在必须一起参与 Lua 脚本的 key 上使用 Hash Tag,不要所有 key 都套个大括号。
6.3 Lua 脚本中的随机命令和动态 key,集群下是重灾区
Redis 集群对 Lua 脚本中的命令有限制,某些命令在脚本里不被允许或者结果不可预期,比如 TIME、RANDOMKEY 这类和外部环境强相关的命令,在集群模式下部分版本会直接报错。即便在单机模式下,使用随机命令也可能导致主从复制时数据不一致,因为主节点执行脚本的结果和从节点执行脚本的结果可能完全不同。
另一个更隐蔽的坑是动态拼接 key。假设脚本里这样写:
local dynamicKey = 'stock:' .. ARGV[1] return redis.call('decr', dynamicKey)在单机模式下能跑通,但到了集群环境,Redis 没法在脚本执行前预判这个动态生成的 key 属于哪个 slot,无法完成重定向,就会报错。所以集群模式下,脚本涉及的所有 key 都必须通过 KEYS 显式传入,不要用 ARGV 去拼。这也是在集群环境里写 Lua 脚本要时刻遵守的一条硬规矩。
6.4 脚本执行时间过长,会阻塞整个 Redis
Redis 是单线程执行,Lua 脚本再长,执行期间也不会让出执行权。换句话说,一个耗时 5 秒的脚本会让整个 Redis 实例在这 5 秒内无法处理其他请求。线上出现“Redis 卡顿”时,很多情况不是命令慢,而是有人在里面跑了死循环或者大集合遍历。
以下几点建议直接抄进团队规范:
- Lua 脚本不要尝试做大集合遍历,尽量用命令自带的操作代替。
- 脚本内不要写 while 死循环,哪怕逻辑上不会死循环,也要评估最坏情况下的耗时。
- 线上观察
SLOWLOG GET,如果发现脚本类慢查询,优先优化脚本逻辑。 - 控制单条脚本的执行时间在 1 毫秒到几毫秒级别,超过 100 毫秒就要高度警惕。
7. 扩展:限流脚本、统一脚本管理、集成测试
7.1 用 Lua 实现滑动窗口限流
前面实战讲了库存扣减,限流也是一个高频场景。这里给一个基于有序集合的滑动窗口限流脚本,思路是:把每个请求的当前时间戳写入 ZSET,移除窗口外的记录,然后统计窗口内总数。
-- KEYS[1]: 限流 key -- ARGV[1]: 当前时间戳(毫秒) -- ARGV[2]: 窗口大小(毫秒) -- ARGV[3]: 允许的最大请求数 -- 移除窗口外的记录 redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2]) -- 获取窗口内请求数 local count = redis.call('ZCARD', KEYS[1]) if count < tonumber(ARGV[3]) then redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1]) redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end return 0Java 侧传递参数时,时间戳和窗口大小用 String 类型传入,脚本里统一 tonumber 转换,避免序列化问题。这个脚本在并发限流场景下非常好用,压测下来比 Java 侧用 TimeWindow 记录再判断要靠谱得多。
7.2 把脚本统一收敛到脚本管理类
随着项目里的 Lua 脚本越来越多,最怕的是每个 Service 各自 new 一个 DefaultRedisScript,脚本内容散落在各个类里,改一个逻辑得全局搜索。我的建议是做一个集中的脚本注册配置类,所有脚本都在同一个地方声明成 Bean,脚本文件统一放在 resources/lua 目录下,命名带前缀业务名。
@Configuration public class RedisScriptRegistry { @Bean public DefaultRedisScript<Long> rateLimitScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/rate_limit.lua")); script.setResultType(Long.class); return script; } @Bean public DefaultRedisScript<Long> stockDeductScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/stock_deduct.lua")); script.setResultType(Long.class); return script; } }这样做的好处是:脚本文件可以被 DBA 或者运维直接 review,不需要翻 Java 代码;改逻辑也只改 .lua 文件,Bean 配置不用动。有一点要提醒,Spring 容器启动时不会预编译 Lua 脚本,所以脚本语法错误只有第一次执行才会暴露。建议写一个简单的启动检查测试,调用一下脚本确认它能跑通。
7.3 用集成测试验证脚本的原子性和并发效果
最后说测试。Spring Boot 项目可以用spring-boot-starter-test加@SpringBootTest做集成测试,本地用 Docker 起 Redis,JUnit 里直接并发跑几十个线程扣库存,断言最终库存数量正确。
一个最简单的思路:初始化库存 100,然后用 200 个线程并发扣减,每个线程扣 1。如果脚本原子性没问题,最终只有 100 次成功,库存变为 0,另外 100 次返回失败。
@Test void testConcurrentDeduct() throws Exception { stringRedisTemplate.opsForValue().set("stock:test", "100"); int threadCount = 200; CountDownLatch latch = new CountDownLatch(threadCount); AtomicInteger success = new AtomicInteger(); for (int i = 0; i < threadCount; i++) { new Thread(() -> { try { Long result = redisTemplate.execute( stockDeductScript, Arrays.asList("stock:test", "stock:test:buyers"), "user" + Thread.currentThread().getId(), 1L ); if (result != null && result == 1L) { success.incrementAndGet(); } } finally { latch.countDown(); } }).start(); } latch.await(10, TimeUnit.SECONDS); assertEquals(100, success.get()); }这种测试跑通一次,基本就说明脚本的原子性和并发控制是靠谱的。测试环境记得用完清理 Redis 测试 key,避免脏数据干扰后续用例。
写在最后的几点个人体会
玩 Lua 脚本这几年,我最大的感受是它把“检查-执行”这个并发难点从 Java 层彻底挪到了 Redis 层,代码反而更简单了。但越简单的表象背后,越要警惕两类问题:一是序列化器和返回类型的配置,这是新手最常踩的坑;二是集群环境下的 key 设计,提前规划好 Hash Tag,能避免上线后大规模返工。
还有一个过来人的建议:Lua 脚本是保证原子性的手段,不是塞业务逻辑的地方。一个脚本里塞十几步业务操作,看起来省事,真出问题的时候相当难排查。脚本要短、职责要单一、注释要完整,返回值约定要写清楚。如果某个功能在某个版本的 Redis 里能用函数(Redis Functions)实现,语法更友好,但日常项目里传统 Lua 这套玩法已经足够稳定和通用,可以放心用。