拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot集成Redis Lua脚本实战:实现原子扣库存、限流与分布式锁

Spring Boot集成Redis Lua脚本实战:实现原子扣库存、限流与分布式锁

聊到 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: 2

2.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中,用户点击秒杀后需要完成两件事:

  1. 检查库存是否大于 0,如果大于 0 就扣减 1,并返回扣减成功。
  2. 同一用户不能重复秒杀,需要校验用户是否已经买过。

如果只实现了第一点,可能出现同一个用户用多个账号刷接口,或者同一账号发疯式点击,把库存快速薅光。所以脚本里还要维护一个“已购买用户集合”的 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 后的常见表现
numberInteger 或 Long,取决于数据大小Long(若指定 Long.class)
stringbyte[] 或 String,取决于序列化器String
tableListList 或 Map,取决于内容结构
nilnullnull
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 typeDefaultRedisScript 没有设置 resultType调用 setResultType 指定,如 Long.class
脚本查不到 key,结果总是 nullRedisTemplate 默认 JDK 序列化,key 格式不一致更换为 StringRedisSerializer
Lua 返回数字但 Java 侧是 nullresultType 不匹配,或脚本 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 卡顿”时,很多情况不是命令慢,而是有人在里面跑了死循环或者大集合遍历。

以下几点建议直接抄进团队规范:

  1. Lua 脚本不要尝试做大集合遍历,尽量用命令自带的操作代替。
  2. 脚本内不要写 while 死循环,哪怕逻辑上不会死循环,也要评估最坏情况下的耗时。
  3. 线上观察SLOWLOG GET,如果发现脚本类慢查询,优先优化脚本逻辑。
  4. 控制单条脚本的执行时间在 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 0

Java 侧传递参数时,时间戳和窗口大小用 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 这套玩法已经足够稳定和通用,可以放心用。

返回列表