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

在 Spring Boot 项目中,将多个 Redis 操作封装为 Lua 脚本一次性提交执行,是保证原子性、减少网络往返(RTT)的高效手段。Spring Data Redis 提了 RedisTemplateDefaultRedisScript 等高级抽象,让开发者无需手动处理 EVAL / EVALSHA 的底层细节。

本文将从零开始,完整讲解如何在 Spring Boot 中编写、加载、调试和执行 Lua 脚本,并通过限流、分布式锁等实战案例加以巩固。

一、为什么在 Spring Boot 中使用 Lua 脚本

把逻辑下推到 Redis 端用 Lua 执行,核心价值有三:

  • 原子性:Redis 采用单线程事件循环模型,整个 Lua 脚本作为一个整体执行,中间不会被其他命令插入。相比传统的"先查再改"三步走,Lua 脚本彻底避免了竞态条件。
  • 减少网络开销:多条命令合并为一次网络请求,脚本内部所有的读写都在 Redis 服务端完成,显著降低 RTT。
  • 逻辑内聚:条件判断、循环、数值运算等业务逻辑可下沉到 Redis 端,减轻应用服务器压力,同时实现"判断+执行"的原子操作。

典型场景包括:分布式锁原子释放、固定窗口/令牌桶限流、库存扣减、抽奖、幂等性校验等。

二、项目准备

2.1 添加依赖

使用 Spring Initializr 创建项目时勾选 Spring WebSpring Data Redis (Access + Driver) ,或者在 pom.xml 中添加以下依赖:

复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Spring 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: 0

2.3 项目目录结构

建议将 Lua 脚本统一放在 src/main/resources/lua/ 目录下,便于版本管理和 IDE 语法高亮:

text

复制代码
src/
└── 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.2 redis.call()redis.pcall()

  • redis.call():执行 Redis 命令,发生错误时直接抛出异常,脚本终止。
  • redis.pcall():执行 Redis 命令,发生错误时返回包含错误信息的 Lua table,脚本继续执行。

多数场景下使用 redis.call() 即可,因为它更符合"要么全成功,要么全失败"的原子性预期。

3.3 数据类型映射

Redis 返回值 Lua 类型
integer number
string string
nil nil
array Lua 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 方式一:通过 DefaultRedisScript Bean 管理脚本(推荐)

将 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:配置 DefaultRedisScript Bean

复制代码
import 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 DefaultRedisScript<Long> rateLimitScript() {
        DefaultRedisScript<Long> 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 混用导致的类型转换异常。

步骤 3:Service 层调用

复制代码
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 DefaultRedisScript<Long> rateLimitScript;

    public RateLimitService(StringRedisTemplate stringRedisTemplate,
                            DefaultRedisScript<Long> rateLimitScript) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.rateLimitScript = rateLimitScript;
    }

    public boolean allow(String key, long limit, long windowSeconds) {
        List<String> 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";
DefaultRedisScript<Long> 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 RedisTemplate<String, Object> redisTemplate;
    public static String UNLOCK_SCRIPT_SHA;

    public LuaScriptLoader(RedisTemplate<String, 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";
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        // 执行 SCRIPT LOAD,返回 SHA1
        UNLOCK_SCRIPT_SHA = redisTemplate.execute(redisScript, 
            Collections.singletonList("dummy"), "dummy");
    }
}

4.4 四种集成方式对比

方式 适用场景 优点 缺点
DefaultRedisScript Bean ✅ 生产推荐 自动 SHA1 缓存、易维护、支持热加载 需要额外配置 Bean
内联脚本字符串 临时测试 快速、无配置 难以维护、无法复用
文件 + 手动加载 需显式控制 SHA1 可获取 SHA1 供后续使用 代码略繁琐
RedisScript.of() 简单场景 语法简洁 功能与 DefaultRedisScript 类似

💡 最佳实践 :生产环境统一使用 DefaultRedisScript Bean 方式,将脚本放在 resources/lua/ 下,通过 ClassPathResource 加载。

五、实战案例

5.1 固定窗口限流(完整代码)

上面的 rate_limit.luaRateLimitService 已给出完整实现。使用时在 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
end

Spring 配置与调用

复制代码
@Configuration
public class RedisLuaConfig {
    @Bean
    public DefaultRedisScript<Long> unlockScript() {
        DefaultRedisScript<Long> 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 DefaultRedisScript<Long> 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-Set(CAS)操作

经典的"检查并设置"场景:只有当前值等于预期值时,才将其修改为新值。

Lua 脚本 checkandset.lua

复制代码
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
    redis.call('SET', KEYS[1], ARGV[2])
    return true
end
return false

Spring 调用

复制代码
@Bean
public RedisScript<Boolean> 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 result

Spring 调用时 setResultType(List.class)

复制代码
DefaultRedisScript<List> batchScript = new DefaultRedisScript<>();
batchScript.setLocation(new ClassPathResource("lua/batch_filter.lua"));
batchScript.setResultType(List.class);
List<String> activeKeys = redisTemplate.execute(batchScript, keys, args);

六、高级特性

6.1 EVALSHA 自动缓存机制

Spring Data Redis 的 ScriptExecutor 默认会先获取脚本的 SHA1 并尝试执行 EVALSHA,如果脚本尚未缓存在 Redis 中(返回 NOSCRIPT 错误),则自动回退到 EVAL 执行

这意味着开发者无需手动调用 SCRIPT LOAD,Spring 已经帮我们处理好了缓存逻辑。但对于高频脚本,可在应用启动时预加载,避免首次执行时的额外开销。

6.2 redis.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_DEBUGLOG_VERBOSELOG_NOTICELOG_WARNING。需确保 Redis 日志级别足够高才能看到。

7.3 Maven 插件语法校验

上线前对 Lua 脚本进行语法校验,避免运行时才发现错误:

复制代码
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>exec-maven-plugin</artifactId>
    <version>3.1.0</version>
    <configuration>
        <executable>luac</executable>
        <workingDirectory>${project.basedir}/src/main/resources/lua</workingDirectory>
        <arguments>
            <argument>-p</argument>
            <argument>rate_limit.lua</argument>
        </arguments>
    </configuration>
</plugin>

7.4 Spring Boot 单元测试

复制代码
@SpringBootTest
class LuaScriptTest {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private DefaultRedisScript<Long> 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/1) Long.class
布尔值(如 CAS 结果) Boolean.class
字符串 String.class
数组 List.class
无意义状态(如 "OK") 可为 null

如果不确定 Lua 脚本的返回类型,可以先在 redis-cli 中用 EVAL 测试确认。

8.3 避免循环中大量命令

在 Lua 脚本中使用 forwhile 循环执行大量 Redis 命令会导致脚本执行时间过长,触发 Redis 的 lua-time-limit(默认 5 秒)超时。超时后 Redis 会标记脚本为"忙碌",后续命令被阻塞,只能执行 SCRIPT KILLSHUTDOWN NOSAVE 恢复。

建议 :将大批量操作拆分为多个脚本调用,或改用游标(SCAN / SSCAN)分批处理。

8.4 集群读写分离问题

在配置了读写分离(如 Lettuce 的 ReadFrom.SLAVE_PREFERRED)的集群环境中,包含写操作的 Lua 脚本会被整体视为写操作。如果请求被路由到只读从节点,会抛出 READONLY 异常。

解决方案:升级 Lettuce 到 6.2+ 版本;或调整读取策略为 ReadFrom.MASTER_PREFERRED,确保包含写操作的脚本路由到主节点。

8.5 超时配置

可通过 Redis 配置文件或启动参数调整脚本超时时间:

复制代码
lua-time-limit 5000   # 单位毫秒,默认 5000

8.6 脚本变更管理

  • 脚本内容变更后,旧的 SHA1 不再有效,Spring Data Redis 会自动重新计算 SHA1 并重新加载。
  • 在集群环境下,需确保所有节点都已加载新脚本(通过 SCRIPT FLUSH 清除旧缓存或等待自然过期)。
  • 建议:将 Lua 脚本文件纳入 Git 版本管理,变更时需同步更新对应的调用方代码。

九、总结

本文完整介绍了在 Spring Boot 中使用 Redis Lua 脚本的全流程:

  1. 集成核心 :使用 DefaultRedisScript Bean + RedisTemplate.execute() 是标准姿势,框架自动处理脚本加载、SHA1 缓存和 EVAL/EVALSHA 回退。
  2. 脚本规范 :所有 Redis 键必须通过 KEYS 数组传入,参数通过 ARGV 传入,避免集群环境下的路由问题。
  3. 返回值类型setResultType() 必须与脚本实际返回类型一致,数字类型建议用 Long.class
  4. 性能优化 :优先使用 DefaultRedisScript Bean 方式,利用 Spring Data Redis 内置的 SHA1 缓存机制;集群环境下通过 hash tag 确保多键操作落在同一 slot。
  5. 调试与维护 :安装 EmmyLua 插件提高编写体验,通过 Maven 插件进行语法校验,使用 redis.log() 辅助调试。
相关推荐
不爱吃大饼2 小时前
Redis核心点
redis
William Dawson2 小时前
【实战分享】DTU设备高并发数据接入全流程(Redis + RabbitMQ + 数据库)
数据库·redis·rabbitmq
却话巴山夜雨时i3 小时前
互联网大厂Java面试实录:从Spring Boot到Kafka的场景应用深度解析
spring boot·kafka·prometheus·微服务架构·java面试·技术解析·互联网大厂
小程故事多_803 小时前
Harness实战指南,在Java Spring Boot项目中规范落地OpenSpec+Claude Code
java·人工智能·spring boot·架构·aigc·ai编程
CodeMartain5 小时前
Redis为什么快?
数据库·redis·缓存
南汐以墨13 小时前
一个另类的数据库-Redis
数据库·redis·缓存
计算机毕设指导613 小时前
基于SpringBoot校园学生健康监测管理系统【源码文末联系】
java·spring boot·后端·spring·tomcat·maven·intellij-idea
mysuking13 小时前
springboot与springcloud对应版本
java·spring boot·spring cloud
希望永不加班13 小时前
SpringBoot 数据库连接池配置(HikariCP)最佳实践
java·数据库·spring boot·后端·spring