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() 辅助调试。
相关推荐
杨运交4 小时前
[041][公共模块]分布式唯一ID生成器设计与实现:一款灵活可扩展的雪花算法框架
spring boot
用户30745969820719 小时前
Redis 延时队列详解
redis
烤代码的吐司君1 天前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
Flittly1 天前
【AgentScope Java新手村系列】(14)人机交互
java·spring boot·spring
Flynt2 天前
从Spring Boot 4.0升到4.1,我在Maven和gRPC上栽了跟头
java·spring boot·后端
掉鱼的猫3 天前
Spring Boot → Solon 注解迁移实战指南:一张对照表说清楚
java·spring boot
leeyi3 天前
Checkpoint 机制:Agent 怎么在断电后接着跑
redis·aigc·agent
人活一口气4 天前
Spring Boot与AIGC的完美结合:从零搭建智能内容生成平台
java·spring boot·aigc
云技纵横4 天前
一个 @Async 让循环依赖暴雷:Spring 代理的暗坑
redis
犯困蛋挞yy5 天前
用Claude快速解决Redis代码报错反复无解的问题
redis