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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependencyapplication.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 用 StringRedisSerializervalue 用 GenericJackson2JsonRedisSerializer。这样存进去的 key 是明文Redis Desktop Manager 里一眼就能看懂。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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 和 ARGVRedis 执行 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 表示有一个 keycounter 就是 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] 是 ageARGV[2] 是 300。这个“举一反三”的能力在写限流脚本时非常有用。3.2 Spring 的 DefaultRedisScript 如何承载 Lua 脚本Spring Data Redis 提供了一个核心类DefaultRedisScriptT。泛型 T 表示脚本返回值在 Java 侧的类型非常重要后面会专门讲。创建方式有两种第一种脚本写在代码里DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(return redis.call(incr, KEYS[1])); script.setResultType(Long.class);第二种脚本放在 classpath 下的 .lua 文件里便于维护和审查DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/incr.lua)); script.setResultType(Long.class);推荐第二种。脚本文件单独管理即使不会写复杂 Lua也能一目了然地看到每个脚本干了什么比把大段字符串糊在 Java 代码里清爽太多。执行脚本的入口是 RedisTemplate 的 execute 方法签名是这样的T T execute(RedisScriptT script, ListK 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 内部如何缓存脚本默认走 EVALSHARedis 执行 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 DefaultRedisScriptLong stockDeductScript() { DefaultRedisScriptLong 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 RedisTemplateString, Object redisTemplate; Resource(name stockDeductScript) private RedisScriptLong 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 注入时类型是RedisScriptLong而不是DefaultRedisScriptLong。RedisScript 是 Spring Data Redis 的核心接口Bean 里声明 DefaultRedisScript 完全没问题但业务代码面向接口编程更规范。第二execute 方法的第二个参数是 ListString这里必须和 Lua 脚本中 KEYS 的顺序严格对应。脚本里 KEYS[1] 是库存 keyKEYS[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对应声明和调用DefaultRedisScriptLong 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.classstringbyte[] 或 String取决于序列化器StringtableListList 或 Map取决于内容结构nilnullnullstatus reply如 OKbyte[] 或 String取决于 resultType所以用DefaultRedisScriptLong并显式设置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 都用 StringRedisSerializervalue 根据业务选择。如果 project 里已经有大量 JDK 序列化的旧数据要么写一次性迁移脚本把 key 重写要么在 Lua 脚本中对 KEYS 做同样方式的 JDK 序列化后再传入。后者相当别扭还是尽量一次到位新项目建议直接统一序列化策略。5.4 常见异常速查表异常或现象原因解决方案Unable to determine RedisScript result typeDefaultRedisScript 没有设置 resultType调用 setResultType 指定如 Long.class脚本查不到 key结果总是 nullRedisTemplate 默认 JDK 序列化key 格式不一致更换为 StringRedisSerializerLua 返回数字但 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 落到同一个 slotRedis 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 脚本执行时间过长会阻塞整个 RedisRedis 是单线程执行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 DefaultRedisScriptLong rateLimitScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/rate_limit.lua)); script.setResultType(Long.class); return script; } Bean public DefaultRedisScriptLong stockDeductScript() { DefaultRedisScriptLong 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 起 RedisJUnit 里直接并发跑几十个线程扣库存断言最终库存数量正确。一个最简单的思路初始化库存 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 这套玩法已经足够稳定和通用可以放心用。
返回列表