Spring Boot 中使用 Redis Lua 脚本详细教程

张开发
2026/4/7 21:20:44 15 分钟阅读

分享文章

Spring Boot 中使用 Redis Lua 脚本详细教程
在 Spring Boot 项目中将多个 Redis 操作封装为 Lua 脚本一次性提交执行是保证原子性、减少网络往返RTT的高效手段。Spring Data Redis 提了RedisTemplate和DefaultRedisScript等高级抽象让开发者无需手动处理EVAL/EVALSHA的底层细节。本文将从零开始完整讲解如何在 Spring Boot 中编写、加载、调试和执行 Lua 脚本并通过限流、分布式锁等实战案例加以巩固。一、为什么在 Spring Boot 中使用 Lua 脚本把逻辑下推到 Redis 端用 Lua 执行核心价值有三原子性Redis 采用单线程事件循环模型整个 Lua 脚本作为一个整体执行中间不会被其他命令插入。相比传统的“先查再改”三步走Lua 脚本彻底避免了竞态条件。减少网络开销多条命令合并为一次网络请求脚本内部所有的读写都在 Redis 服务端完成显著降低 RTT。逻辑内聚条件判断、循环、数值运算等业务逻辑可下沉到 Redis 端减轻应用服务器压力同时实现“判断执行”的原子操作。典型场景包括分布式锁原子释放、固定窗口/令牌桶限流、库存扣减、抽奖、幂等性校验等。二、项目准备2.1 添加依赖使用 Spring Initializr 创建项目时勾选Spring Web和Spring Data Redis (Access Driver)或者在pom.xml中添加以下依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencySpring Boot 默认使用 Lettuce 作为 Redis 客户端无需额外配置即可使用。2.2 Redis 连接配置在application.yml中添加 Redis 连接信息spring: data: redis: host: localhost port: 6379 password: database: 0 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 02.3 项目目录结构建议将 Lua 脚本统一放在src/main/resources/lua/目录下便于版本管理和 IDE 语法高亮textsrc/ └── main/ ├── java/ │ └── com/example/demo/ │ ├── config/RedisLuaConfig.java │ ├── service/RateLimitService.java │ └── ... └── resources/ └── lua/ ├── rate_limit.lua ├── unlock.lua └── deduct_stock.lua三、Lua 脚本基础Spring Boot 视角3.1 KEYS 与 ARGV 的区分规则在 Redis Lua 脚本中参数分为两类数组用途示例KEYS存放 Redis 键名用于路由尤其在集群中KEYS[1]、KEYS[2]ARGV存放普通参数阈值、值、过期时间等ARGV[1]、ARGV[2]⚠️核心原则所有 Redis 键必须通过KEYS数组传入绝不能把键名放在ARGV中否则在 Redis Cluster 环境下会发生“跨槽执行失败”的错误。3.2redis.call()与redis.pcall()redis.call()执行 Redis 命令发生错误时直接抛出异常脚本终止。redis.pcall()执行 Redis 命令发生错误时返回包含错误信息的 Lua table脚本继续执行。多数场景下使用redis.call()即可因为它更符合“要么全成功要么全失败”的原子性预期。3.3 数据类型映射Redis 返回值Lua 类型integernumberstringstringnilnilarrayLua table索引从 1 开始OK 状态table含 ok 字段3.4 常用 Lua 语法速查-- 局部变量声明 local current redis.call(GET, KEYS[1]) -- 类型转换ARGV 默认是字符串 local limit tonumber(ARGV[1]) -- 条件判断 if not current then current 0 else current tonumber(current) end -- 循环 for i 1, #KEYS do redis.call(DEL, KEYS[i]) end -- 返回多种类型 return {err rate limit exceeded} -- 返回错误表 return 1 -- 返回数字 return {1, 2, 3} -- 返回数组四、Spring Boot 集成方式4.1 方式一通过DefaultRedisScriptBean 管理脚本推荐将 Lua 脚本声明为 Spring Bean框架会自动处理脚本加载、SHA1 缓存和EVALSHA/EVAL的自动回退。步骤 1编写 Lua 脚本文件创建src/main/resources/lua/rate_limit.lua-- 固定窗口限流 -- KEYS[1] 限流 key -- ARGV[1] 最大次数limit -- ARGV[2] 窗口秒数windowSeconds local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], tonumber(ARGV[2])) end if current tonumber(ARGV[1]) then return 0 else return 1 end步骤 2配置DefaultRedisScriptBeanimport org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.script.DefaultRedisScript; Configuration public class RedisLuaConfig { Bean public DefaultRedisScriptLong rateLimitScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/rate_limit.lua)); script.setResultType(Long.class); return script; } }setLocation()从 classpath 加载脚本文件。setResultType(Long.class)必须与脚本实际返回类型匹配。如果脚本返回数字建议用Long.class避免Integer/Long混用导致的类型转换异常。步骤 3Service 层调用import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.List; Service public class RateLimitService { private final StringRedisTemplate stringRedisTemplate; private final DefaultRedisScriptLong rateLimitScript; public RateLimitService(StringRedisTemplate stringRedisTemplate, DefaultRedisScriptLong rateLimitScript) { this.stringRedisTemplate stringRedisTemplate; this.rateLimitScript rateLimitScript; } public boolean allow(String key, long limit, long windowSeconds) { ListString keys Collections.singletonList(key); Long result stringRedisTemplate.execute( rateLimitScript, keys, String.valueOf(limit), String.valueOf(windowSeconds) ); return result ! null result 1L; } }4.2 方式二直接内联脚本字符串适用于临时测试或简单逻辑但不推荐在生产环境中使用脚本不可复用、难以维护。String script if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); Long result redisTemplate.execute(redisScript, Collections.singletonList(key), value);4.3 方式三PostConstruct预加载并保存 SHA1在某些场景下需要显式预加载脚本并保存 SHA1以便后续使用EVALSHA直接执行Service public class LuaScriptLoader { private final RedisTemplateString, Object redisTemplate; public static String UNLOCK_SCRIPT_SHA; public LuaScriptLoader(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } PostConstruct public void loadScripts() { String script if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); // 执行 SCRIPT LOAD返回 SHA1 UNLOCK_SCRIPT_SHA redisTemplate.execute(redisScript, Collections.singletonList(dummy), dummy); } }4.4 四种集成方式对比方式适用场景优点缺点DefaultRedisScriptBean✅ 生产推荐自动 SHA1 缓存、易维护、支持热加载需要额外配置 Bean内联脚本字符串临时测试快速、无配置难以维护、无法复用文件 手动加载需显式控制 SHA1可获取 SHA1 供后续使用代码略繁琐RedisScript.of()简单场景语法简洁功能与 DefaultRedisScript 类似最佳实践生产环境统一使用DefaultRedisScriptBean 方式将脚本放在resources/lua/下通过ClassPathResource加载。五、实战案例5.1 固定窗口限流完整代码上面的rate_limit.lua加RateLimitService已给出完整实现。使用时在 Controller 中调用即可RestController public class ApiController { Autowired private RateLimitService rateLimitService; GetMapping(/api/test) public ResponseEntity? test() { String clientIp getClientIp(); if (!rateLimitService.allow(rate_limit: clientIp, 10, 60)) { return ResponseEntity.status(429).body(Too Many Requests); } return ResponseEntity.ok(Success); } }5.2 分布式锁原子释放解锁解锁时必须先判断锁的持有者再决定是否删除两步操作必须原子执行。Lua 脚本unlock.lua-- KEYS[1] 锁的 key -- ARGV[1] 期望的锁值如 UUID if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 endSpring 配置与调用Configuration public class RedisLuaConfig { Bean public DefaultRedisScriptLong unlockScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/unlock.lua)); script.setResultType(Long.class); return script; } } Service public class DistributedLockService { Autowired private StringRedisTemplate redisTemplate; Autowired private DefaultRedisScriptLong unlockScript; public boolean unlock(String lockKey, String lockValue) { Long result redisTemplate.execute( unlockScript, Collections.singletonList(lockKey), lockValue ); return result ! null result 1L; } }5.3 Check-and-SetCAS操作经典的“检查并设置”场景只有当前值等于预期值时才将其修改为新值。Lua 脚本checkandset.lualocal current redis.call(GET, KEYS[1]) if current ARGV[1] then redis.call(SET, KEYS[1], ARGV[2]) return true end return falseSpring 调用Bean public RedisScriptBoolean casScript() { ScriptSource scriptSource new ResourceScriptSource( new ClassPathResource(lua/checkandset.lua)); return RedisScript.of(scriptSource, Boolean.class); } public boolean compareAndSet(String key, String expected, String newValue) { return redisTemplate.execute(casScript, List.of(key), expected, newValue); }5.4 批量操作 原子返回从多个 Hash 中批量获取状态字段过滤后返回符合条件的 key 列表local result {} for i, key in ipairs(KEYS) do local status redis.call(HGET, key, status) if status active then table.insert(result, key) end end return resultSpring 调用时setResultType(List.class)DefaultRedisScriptList batchScript new DefaultRedisScript(); batchScript.setLocation(new ClassPathResource(lua/batch_filter.lua)); batchScript.setResultType(List.class); ListString activeKeys redisTemplate.execute(batchScript, keys, args);六、高级特性6.1EVALSHA自动缓存机制Spring Data Redis 的ScriptExecutor默认会先获取脚本的 SHA1 并尝试执行EVALSHA如果脚本尚未缓存在 Redis 中返回 NOSCRIPT 错误则自动回退到EVAL执行。这意味着开发者无需手动调用SCRIPT LOADSpring 已经帮我们处理好了缓存逻辑。但对于高频脚本可在应用启动时预加载避免首次执行时的额外开销。6.2redis.replicate_commands()与主从一致性如果 Lua 脚本中包含随机性命令如TIME()、RANDOMKEY默认情况下脚本会以事务模式复制到从库/AOF可能导致主从不一致。解决方案是在脚本开头调用redis.replicate_commands() local now redis.call(TIME)[1] redis.call(SET, KEYS[1], now)启用后Redis 会记录脚本中写命令的具体参数并复制而非复制整个脚本。6.3 自定义序列化默认情况下RedisTemplate使用配置的序列化器处理键和值。如需为脚本参数或结果使用不同的序列化器可以传入额外参数redisTemplate.execute(script, keys, argsSerializer, resultSerializer, args);七、调试与开发技巧7.1 IDEA 插件EmmyLua在 IntelliJ IDEA 中安装EmmyLua插件可获得 Lua 语法高亮、括号自动补全和函数跳转功能显著提升脚本编写体验。7.2 使用redis.log()打印日志在 Lua 脚本中插入日志便于调试redis.log(redis.LOG_NOTICE, Processing key: , KEYS[1])日志级别LOG_DEBUG、LOG_VERBOSE、LOG_NOTICE、LOG_WARNING。需确保 Redis 日志级别足够高才能看到。7.3 Maven 插件语法校验上线前对 Lua 脚本进行语法校验避免运行时才发现错误plugin groupIdorg.codehaus.mojo/groupId artifactIdexec-maven-plugin/artifactId version3.1.0/version configuration executableluac/executable workingDirectory${project.basedir}/src/main/resources/lua/workingDirectory arguments argument-p/argument argumentrate_limit.lua/argument /arguments /configuration /plugin7.4 Spring Boot 单元测试SpringBootTest class LuaScriptTest { Autowired private StringRedisTemplate redisTemplate; Autowired private DefaultRedisScriptLong rateLimitScript; Test void testRateLimit() { String key test:rate:user1; Long result redisTemplate.execute(rateLimitScript, List.of(key), 5, 10); System.out.println(Result: result); } }八、注意事项与最佳实践8.1 键的传入规则✅ 正确❌ 错误redisTemplate.execute(script, keys, args)在 Lua 内部用ARGV[1]接收键名所有 key 放在KEYS数组中把 key 当作普通参数传入集群环境下的关键约束Redis Cluster 要求 Lua 脚本中访问的所有键必须在同一个 hash slot 中否则会报CROSSSLOT错误。解决方案是使用hash tag将相关键强制路由到同一 slot如{order:123}:details和{order:123}:status。8.2 返回值类型选择脚本返回类型setResultType应设为整数如限流结果 0/1Long.class布尔值如 CAS 结果Boolean.class字符串String.class数组List.class无意义状态如 OK可为null如果不确定 Lua 脚本的返回类型可以先在redis-cli中用EVAL测试确认。8.3 避免循环中大量命令在 Lua 脚本中使用for或while循环执行大量 Redis 命令会导致脚本执行时间过长触发 Redis 的lua-time-limit默认 5 秒超时。超时后 Redis 会标记脚本为“忙碌”后续命令被阻塞只能执行SCRIPT KILL或SHUTDOWN NOSAVE恢复。建议将大批量操作拆分为多个脚本调用或改用游标SCAN/SSCAN分批处理。8.4 集群读写分离问题在配置了读写分离如 Lettuce 的ReadFrom.SLAVE_PREFERRED的集群环境中包含写操作的 Lua 脚本会被整体视为写操作。如果请求被路由到只读从节点会抛出READONLY异常。解决方案升级 Lettuce 到 6.2 版本或调整读取策略为ReadFrom.MASTER_PREFERRED确保包含写操作的脚本路由到主节点。8.5 超时配置可通过 Redis 配置文件或启动参数调整脚本超时时间lua-time-limit 5000 # 单位毫秒默认 50008.6 脚本变更管理脚本内容变更后旧的 SHA1 不再有效Spring Data Redis 会自动重新计算 SHA1 并重新加载。在集群环境下需确保所有节点都已加载新脚本通过SCRIPT FLUSH清除旧缓存或等待自然过期。建议将 Lua 脚本文件纳入 Git 版本管理变更时需同步更新对应的调用方代码。九、总结本文完整介绍了在 Spring Boot 中使用 Redis Lua 脚本的全流程集成核心使用DefaultRedisScriptBean RedisTemplate.execute()是标准姿势框架自动处理脚本加载、SHA1 缓存和EVAL/EVALSHA回退。脚本规范所有 Redis 键必须通过KEYS数组传入参数通过ARGV传入避免集群环境下的路由问题。返回值类型setResultType()必须与脚本实际返回类型一致数字类型建议用Long.class。性能优化优先使用DefaultRedisScriptBean 方式利用 Spring Data Redis 内置的 SHA1 缓存机制集群环境下通过 hash tag 确保多键操作落在同一 slot。调试与维护安装 EmmyLua 插件提高编写体验通过 Maven 插件进行语法校验使用redis.log()辅助调试。

更多文章