Spring Boot集成Redis Lua脚本,解决高并发原子性问题
2026/9/21 1:05:41 网站建设 项目流程

说实话,第一次把 Lua 脚本真正塞进 Spring Boot 项目里,是在做秒杀扣库存的时候。当时接口一压测就超卖,Redis 里先查再扣的那套逻辑在高并发下全是漏洞。后来把整个判断和扣减逻辑写进 Lua 脚本,一次性原子执行,问题当场解决。那次之后我才意识到,Lua 在 Redis 里不是花架子,是真正能解决并发一致性问题的硬工具。

这篇教程就把我摸索过的路整理出来。从环境准备到脚本语法,从 Spring Boot 里的几种调用方式到限流、分布式锁、库存扣减的真实案例,再到我踩过的序列化、返回类型、脚本缓存等一堆坑。内容偏实操,每个核心步骤都给了可直接照抄的代码和解释,适合已经会用 RedisTemplate 做简单存取、想往深处走的开发者,也适合被并发问题逼到头、需要一个可靠方案的朋友。

1. 为什么要在 Spring Boot 里用 Redis Lua 脚本

1.1 这个场景解决了什么问题

日常写业务,用 RedisTemplate 做 get、set、increment 很方便。但一旦涉及多步操作,比如检查库存、扣减库存、记录订单,麻烦就来了。三步操作之间不是原子的,高并发下两个请求同时读到库存为 1,各自执行扣减,结果就超卖了。

传统解决办法是加分布式锁。Redis 的 SETNX 锁能解决一部分问题,但锁本身也有释放异常、超时续期、重入等一堆边界情况要处理。而且锁是串行化整个业务,性能损耗不小。

Lua 脚本的解法更干净。Redis 从 2.6 版本开始内置 Lua 解释器,支持把一段脚本作为一个整体在服务端执行。脚本执行期间,其他命令不会插队,整个脚本天然具备原子性。这就相当于把原本在应用层靠锁保护的逻辑,压缩成 Redis 服务端的一步操作,没有锁的额外管理成本,也不存在锁失效的问题。

对于高频访问的热点数据,这个特性价值很大。比如做限流,要检查当前窗口计数是否超限、超限则返回拒绝、未超限则计数加一,这三个动作拆开做必然有竞态,写成一个 Lua 脚本就是一次原子调用,性能损耗极小,准确性还能拉满。

1.2 相比 Java 代码实现有哪些本质优势

从开发体验来看,Lua 脚本解决的不只是并发问题。多个 Redis 操作打包成一个脚本后,网络往返次数从 N 次降为 1 次。比如原来扣库存需要 get 一次、判断一次、decrBy 一次,三次网络 IO;Lua 脚本一次调用全部完成,接口响应时间直接从几十毫秒降到几毫秒。

从一致性角度看,脚本在 Redis 单线程模型下执行,Redis 本身是单线程执行命令,Lua 脚本运行时天然阻塞其他命令,但换来的是一致性保证。这跟数据库事务类似——你牺牲一点并发能力,换回数据不出错。

还有一点很实际:脚本可以复用。把复杂业务逻辑写在 .lua 文件里,Java 侧只需要加载并传参调用,后续要调整判断逻辑时,只改 Lua 脚本,不需要动 Java 代码。这块在业务规则变化频繁的场景下节约的时间非常多。

需要特别注意的是,嵌入脚本时要有点边界感。阻塞类操作比如长时间的 while 循环、耗时的计算尽量别写进脚本,因为脚本执行期间会阻塞 Redis 服务。脚本超时默认 5 秒会被 Redis 强制终止,但这种情况一旦出现,线上影响面是即时且广泛的。我的原则是:脚本做逻辑判断和简单的计数增减,重计算永远留在应用侧。

2. 环境准备与 Spring Boot 接入基础

2.1 Redis 安装与可视化客户端选型

把环境先跑通是第一步。Redis 官方支持 Windows 版,但更新节奏一直比 Linux 版慢,生产环境基本清一色部署在 Linux。本机开发时我改用 Docker 方式,省去一堆编译安装的麻烦。一条命令就能起一个单机 Redis:

docker run -d --name redis \ -p 6379:6379 \ -e TZ=Asia/Shanghai \ redis:7.2 \ redis-server --appendonly yes --requirepass 123456

如果不想用 Docker,Windows 用户可以到 Redis 官方或开源镜像站下载 zip 包,解压后直接运行 redis-server.exe,配置文件在 redis.windows.conf 里。需要注意 Windows 版的 Redis 版本通常滞后,有些老版本对 Lua 脚本的支持不够完善,建议至少使用 5.0 以上版本,避免踩到无谓的兼容坑。

可视化客户端是排查问题的重要工具。我用过的几款里,Redis Desktop Manager(RDM)最主流,跨平台支持好,能直接查看 key 的类型、TTL 和值。开源替代方案 Another Redis Desktop Manager 也是个不错的选择,界面更清爽,内存占用更低。这类工具在日常开发中最大的用途不是看数据,而是手动执行 Lua 脚本调试——脚本先跑通,再往代码里搬。

Redis Desktop Manager 里执行脚本的方式很简单:连上实例后切到 Console 标签页,输入 EVAL 命令加脚本内容和参数,回车立刻看到结果。这个流程在脚本调参阶段特别高效,省掉一遍遍启动 Spring Boot 项目的时间。

2.2 Redis 数据类型对脚本参数设计的影响

Lua 脚本里操作的数据结构,决定了脚本怎么设计参数。Redis 的五种基本数据类型,在脚本里各有各的注意点:

  • String:最常操作,存计数、状态、用户信息 JSON,脚本里用 KEYS[1] 传 key,ARGV 传 value 或过期时间。
  • Hash:存对象数据非常合适,一个用户的所有属性放在一个 key 里。脚本里用 HGETALL、HINCRBY 这类命令操作字段级数据。
  • List:适合做消息队列、最新列表。脚本里可以用 LPUSH、LRANGE 做批量操作,但要注意列表长度,别在脚本里遍历整个大列表。
  • Set:适合做去重、标签、在线状态集合。SADD、SISMEMBER、SCARD 在脚本里操作很顺手。
  • ZSet(有序集合):做排行榜、延迟队列、滑动窗口限流全靠它。ZADD、ZREMRANGEBYSCORE、ZCARD 组合使用,能实现很多原有业务逻辑要写一坨代码才能搞定的功能。

脚本参数的传入规则是固定的:KEYS 数组放 key 名称,ARGV 数组放其他参数。这个设计有安全层面的考虑——Redis 集群模式下,key 的哈希槽决定了命令路由到哪个节点,只有把 key 明确放进 KEYS 里,集群才能正确路由,ARGV 里出现的 key 不会被识别。开发阶段单机无所谓,但一旦上集群,脚本执行报错“Lua script attempted to access a non local key in a cluster node”,基本就是这个原因。

2.3 Maven 依赖与 RedisTemplate 基础配置

Spring Boot 项目接入 Redis 很简单,引入官方 Starter 即可。我在 pom.xml 里的配置如下:

<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>

RedisTemplate 在 Spring Boot 自动配置里已经注册好了,但默认用的是 JDK 序列化,存到 Redis 里的 key 会带着 \xac\xed\x00\x05t 之类的前缀,肉眼没法看,也很占空间。我一般会重新定义一个 StringRedisTemplate 或者自定义序列化器,统一用 JSON 格式。

@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // 使用 GenericJackson2JsonRedisSerializer 替代默认 JDK 序列化 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }

这一步影响深远。脚本执行时,RedisTemplate 传入的 key 和参数会经过序列化器转换成字节数组,默认序列化器和 String 序列化器转换出来的内容完全不一样。如果脚本里的 KEYS[1] 和实际存储时用的 key 序列化方式不一致,脚本会直接找不到数据,返回空结果。这是个非常隐蔽的坑,后面在常见问题部分会细讲。

2.4 Spring Boot 项目里脚本文件放哪个位置

脚本文件在项目里的组织方式,我试过几种,最推荐的是固定放在 resources/scripts/lua/ 目录下,用文件名区分业务场景。比如:

src/main/resources/scripts/lua/ ├── limit_rate.lua ├── inventory_deduct.lua ├── lock_acquire.lua └── lock_release.lua

之所以不把脚本硬编码成 Java 字符串,一是可读性差,拼接转义太痛苦;二是脚本长了以后维护困难,.lua 文件可以直接用代码编辑器高亮提示。Spring Boot 打包时 resources 目录下的文件会自动进入 classpath,用 ClassPathResource 加载即可,完全没问题。

3. Lua 脚本基础与 Redis 命令集

3.1 KEYS、ARGV 和返回值:脚本的三个入口

一个标准的 Redis Lua 脚本结构分三块:入参、逻辑、返回值。入参有两类,KEYS 和 ARGV。看个最简单的例子:

-- 脚本内容 local current = tonumber(redis.call('GET', KEYS[1]) or '0') local delta = tonumber(ARGV[1]) local newValue = current + delta redis.call('SET', KEYS[1], newValue) return newValue

KEYS[1] 是第一个 key 名,ARGV[1] 是第一个参数。Lua 的 table 索引从 1 开始,跟 Java 的数组从 0 开始不同,这个细节经常让人懵一下。

需要注意 Redis 里的值本质上都是字符串,做数值运算必须用 tonumber() 转换。直接拿 GET 回来的字符串做加法,Lua 会报错。反过来,存回 Redis 时可以直接存数字,Redis 内部会把它转成字符串。

返回值类型有讲究:脚本 return 一个数字,Java 侧拿到的是 Long;return 一个字符串,Java 侧拿到的是 String;return false 在 Redis 里对应 nil,Java 侧拿到的是 null。如果脚本 return 一个 table(比如 {1, 2, 3}),Redis 会转换成数组。这些对应关系在 Spring Boot 里取返回值时需要格外小心,弄错了轻则类型转换异常,重则数据凭空消失。

3.2 redis.call 与 redis.pcall 的差异和选择

脚本里操作 Redis 命令有两条路径,redis.call 和 redis.pcall。两者用法相同,唯一的区别是错误处理方式:call 遇到错误会直接抛出异常,整个脚本立即终止,错误信息返回给调用方;pcall 遇到错误时会把错误信息作为返回值返回,而脚本本身不终止。

local ok = redis.pcall('SET', KEYS[1], ARGV[1]) if type(ok) ~= 'table' then return { success = true, msg = 'ok' } else return { success = false, msg = ok.err } end

实际开发中我的习惯是:脚本内需要捕获可能失败的场景用 pcall,比如一个 key 可能不存在、可能类型不对、可能已过期;核心逻辑必须保证执行成功时用 call,让它快速失败暴露问题。全部用 pcall 有个坏处——错误被静默吞掉,排错时一头雾水。全部用 call 又有风险——一处小异常让整个操作中断,影响业务连续性。

还有个知识点:redis.call 能调用的命令,是所有 Redis 命令的绝大部分,包括 SET、GET、EXPIRE、ZADD、LPUSH、HINCRBY 等。但一些带子命令的操作,比如 CLIENT、SLOWLOG 这类管理命令,在脚本里通常不被允许。日常业务场景中完全不用纠结,涉及到的概率极低。

3.3 脚本内部的流程控制和数据结构操作

Lua 语言本身简单,写业务脚本用到的语法很集中。if/else 判断、for 循环、while 循环、table 类型,这些就覆盖了绝大多数场景。看一个结合 ZSet 做滑动窗口限流的脚本逻辑骨架:

local key = KEYS[1] local window = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local currentTime = tonumber(ARGV[3]) local member = ARGV[4] -- 移除窗口之外的记录 redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - window) -- 统计当前窗口内的请求数 local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, currentTime, member) redis.call('EXPIRE', key, window) return 1 end return 0

这段脚本的逻辑很清晰:先清理过期数据,再统计当前数量,未超限就加入新记录。脚本里可以按需调用任意组合的 Redis 命令,这比 Java 侧多次调用 + 手动拼接结果要优雅得多。

关于脚本里遍历数据,有一点要提醒:别在脚本里循环执行 LRANGE 或 SMEMBERS 这种可能返回大结果集的命令。Redis 单线程执行脚本,一个大循环就会阻塞其他请求。常见的做法是限制范围,比如用 ZRANGE 带 LIMIT 限制条数,或者把大集合拆分成多个小 key。经验法则是脚本内部的时间复杂度控制在 O(log N) 以内,O(N) 的操作能避免就避免。

4. Spring Boot 调用 Redis Lua 脚本的三种方式

4.1 直接用 RedisTemplate.execute 跑 EVAL

最简单的调用方式,是把 Lua 脚本作为字符串直接传给 execute 方法。Spring 提供了对应的 API:

@Autowired private StringRedisTemplate redisTemplate; public Long evalSimpleScript() { String luaScript = "return redis.call('SET', KEYS[1], ARGV[1])"; RedisScript<Void> script = RedisScript.of(luaScript); return redisTemplate.execute(script, List.of("myKey"), "myValue"); }

这种方式适合脚本很短、逻辑固定、不需要频繁调整的场景。缺点是脚本每次执行都需要 Redis 端重新编译解析,性能不是最优;而且脚本以字符串形式散落在 Java 代码里,稍微长一点就变成维护噩梦。

RedisScript.of 方法默认将返回值解析为对应类型。如果你的脚本返回多个值或者复合结构,需要指定返回类型和 ResultMapper,具体在 4.3 节展开。

4.2 用 DefaultRedisScript 加载 .lua 文件(推荐)

项目里脚本多、逻辑复杂时,我建议用 ClassPathResource 加载 .lua 文件,配合 DefaultRedisScript 使用。先看代码:

@Configuration public class LuaScriptConfig { @Bean public DefaultRedisScript<Long> limitRateScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("scripts/lua/limit_rate.lua")); script.setResultType(Long.class); return script; } }

使用时直接注入这个 Bean:

@Autowired private StringRedisTemplate redisTemplate; @Autowired private DefaultRedisScript<Long> limitRateScript; public boolean tryAcquire(String key, int limit, int windowSeconds) { List<String> keys = List.of(key); Object[] args = new Object[]{String.valueOf(limit), String.valueOf(windowSeconds), String.valueOf(System.currentTimeMillis()), UUID.randomUUID().toString()}; Long result = redisTemplate.execute(limitRateScript, keys, args); return result != null && result == 1L; }

默认情况下 DefaultRedisScript 会在第一次执行时把整个脚本发给 Redis,Redis 返回 SHA1 校验和。后续执行时 Spring 会自动改用 EVALSHA 方式,也就是用脚本的 SHA1 摘要来引用脚本,省去脚本内容传输和重新解析的开销。这对性能的提升在调用频繁的场景下非常明显。

脚本文件的加载路径是 classpath 下的相对路径,用 ClassPathResource 指定。Spring Boot 打 jar 包后,resources 下的文件会自动包含,正常运行没有问题。

4.3 脚本返回值如何映射成 Java 对象

脚本返回值与 Java 返回对象的映射是整个调用链路中最容易出问题的环节。Spring 的 DefaultRedisScript 要求你在创建脚本对象时指定 resultType,它的作用不仅是类型转换,更决定了 Redis 返回值的解析方式。

常见的映射关系如下:

Lua 返回resultType 设置Java 侧拿到的值
数字(如 1)Long.classLong
字符串(如 "ok")String.classString
布尔(仅 true/false)Boolean.classBoolean
多值数组List.classList
复合结构(table)自定义类型 + ResultMapper对应 Java 对象或 List 嵌套

如果脚本返回的是一个数组(table),resultType 设置成 List.class 后,Spring 会把 Redis 返回的数组转成 List,元素类型是脚本里存储的原始类型。如果需要转成更具体的对象,比如一个包含 id 和 name 的 Map,可以使用 ResultMapper 自定义转换逻辑,或者干脆在 Lua 里就把结果用 JSON 字符串返回,Java 侧再做反序列化。

我个人更倾向于后者:脚本里用 cjson.encode(table) 输出 JSON 字符串,Java 侧用 Fastjson / Jackson 解析。好处是调试直观,日志里能直接看到完整结果;坏处是多了序列化和反序列化的一笔开销,但相比脚本本身带来的性能提升,这点开销可以忽略不计。

5. 实战:三个高频场景的 Lua 脚本实现

5.1 库存扣减:原子性防止超卖

秒杀和抢购场景下,库存扣减要求原子性。传统 Java 实现容易超卖,用 Lua 脚本处理就稳了:

-- inventory_deduct.lua local stockKey = KEYS[1] local soldKey = KEYS[2] local quantity = tonumber(ARGV[1]) local stock = tonumber(redis.call('GET', stockKey) or '0') if stock < quantity then return 0 end redis.call('DECRBY', stockKey, quantity) redis.call('INCRBY', soldKey, quantity) return 1

调用上面的脚本时,KEYS[1] 是库存 key,KEYS[2] 是已售 key,ARGV[1] 是本次扣减数量。脚本先检查库存是否充足,不足直接返回 0,由业务侧提示“库存不足”;充足则原子执行扣减和增加已售数,返回 1。整个过程一次网络调用完成,不存在并发穿插的问题。

这个脚本能解决超卖的核心原因在于 Redis 单线程模型下 Lua 脚本的隔离性。两个请求同时执行脚本,Redis 会排队执行,第一个脚本执行完,第二个脚本才看到最新的库存值。实际压测环境里,我用 100 个线程同时抢 10 件商品,最终库存和已售数完全对得上,没有一条超卖数据。

业务侧还可以在这个基础上加一个“防重复购买”的判断,把用户 ID 作为 key,脚本里先检查是否已在购买集合中,未购买才执行扣减,并把用户 ID 加入集合,这样接口天然具备幂等性,不需要额外处理。

5.2 分布式锁:获取和释放都用脚本保证安全

分布式锁用 Lua 实现是标准做法。锁的占用要原子地设置值和过期时间,释放要原子地校验持有者并删除 key。先看获取锁的脚本:

-- lock_acquire.lua local lockKey = KEYS[1] local requestId = ARGV[1] local ttl = tonumber(ARGV[2]) local result = redis.call('SET', lockKey, requestId, 'NX', 'PX', ttl) if result then return 1 end return 0

释放锁的脚本:

-- lock_release.lua local lockKey = KEYS[1] local requestId = ARGV[1] local value = redis.call('GET', lockKey) if value == requestId then redis.call('DEL', lockKey) return 1 end return 0

释放锁的脚本必须校验持有者身份(requestId),否则会出现“A 的锁被 B 释放”的经典问题。将 GET 和 DEL 合并进一个脚本,保证校验和删除之间没有窗口期,这也解决了使用 RedisTemplate 单独执行两步操作时存在的并发隐患。

分布式锁还有一个问题是锁过期时间不好定。设太短,业务没执行完锁就掉了,其他线程进来并发;设太长,一旦持有者宕机,锁要等很久才能被其他线程获取。我自己的做法是根据业务的最大执行时间上浮 50% 作为 TTL,同时配合看门狗机制定期续期。看门狗可以用 Spring 的 Schedule 或单独的守护线程实现,每执行到 TTL 的 1/3 时用脚本刷新过期时间。核心是:锁状态的所有变更都走脚本,保证任何时刻只有一个线程持有锁。

另外一个踩过的坑:用 SETNX 加锁时如果不设置过期时间,一旦业务线程崩溃,锁变成死锁,其他线程永远等不到。SET key value NX PX ttl 一条命令搞定原子加锁,千万别拆成两步。

5.3 限流器:滑动窗口限流与令牌桶

接口限流是 Lua 脚本的另一大应用场景。滑动窗口限流可以精确到秒级甚至毫秒级控制请求速率。前面 3.3 节已经给出了 ZSet 实现的骨架,这里补全一个直接可用的版本:

-- rate_limit_window.lua local key = KEYS[1] local window = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local member = ARGV[4] redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000) local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, now, member) redis.call('PEXPIRE', key, window * 1000) return 1 end return 0

Java 侧调用时,member 可以用 UUID 保证唯一,避免窗口内重复计数。窗口大小和限流阈值从 ARGV 传入,同一个脚本可以服务不同的接口和不同的限流策略,灵活度很高。

令牌桶算法也很常用。核心思路是系统按固定速率往桶里放令牌,请求必须拿到令牌才能通过。实现上可以用 Hash 记录上次补充时间和当前令牌数:

-- rate_limit_token_bucket.lua local bucketKey = KEYS[1] local capacity = tonumber(ARGV[1]) local refillRate = tonumber(ARGV[2]) -- 每秒补充速率 local now = tonumber(ARGV[3]) local bucket = redis.call('HMGET', bucketKey, 'tokens', 'lastRefill') local tokens = tonumber(bucket[1] or capacity) local lastRefill = tonumber(bucket[2] or now) local elapsed = math.max(0, now - lastRefill) tokens = math.min(capacity, tokens + elapsed * refillRate) if tokens >= 1 then tokens = tokens - 1 redis.call('HMSET', bucketKey, 'tokens', tokens, 'lastRefill', now) redis.call('PEXPIRE', bucketKey, 60000) return 1 end return 0

这个脚本做了一层很重要的判断:if tokens >= 1 时才扣减。如果 tokens 不足,直接返回 0,同时保留之前的令牌数和时间戳。注意脚本末尾要设置 PEXPIRE,否则长时间不用的 key 会残留,内存白白被占着。令牌桶和滑动窗口的取舍看业务形态:需要平滑限流选令牌桶,需要精确控制窗口内次数选滑动窗口。两者在压测下表现差异明显,最好按实际流量模型选型。

5.4 批量数据读取:一次网络调用拿到多份数据

除了并发控制,Lua 脚本还能优化普通的读多写少场景。比如前端页面同时需要用户信息、订单统计、优惠券数量,常规做法是三次 Redis 查询、三次网络往返。用脚本合并后一次搞定:

-- batch_get.lua local userInfo = redis.call('GET', KEYS[1]) local orderCount = redis.call('HLEN', KEYS[2]) local couponCount = redis.call('SCARD', KEYS[3]) return { userInfo, orderCount, couponCount }

Java 侧 resultType 设为 List.class,返回的 List 里依次是用户信息字符串、订单数量、优惠券数量。调用一次 Redis,网络耗时节约了三分之二。这个方法在首页聚合、报表查询、消息列表等场景下效果拔群。

需要注意的是列表里的值类型不一定一致。比如 userInfo 是 JSON 字符串,orderCount 是数字转的字符串。Java 侧拿到 List 后要按索引区分处理,或者统一在脚本里用 tostring 转成字符串返回,再统一按字符串处理,避免 ClassCastException。

6. 常见问题与排查技巧实录

6.1 序列化不一致导致脚本查不到数据

这是我把 Lua 脚本接入现有项目时踩的最深的一个坑。之前的项目用默认的 JdkSerializationRedisSerializer 存数据,key 是加了类型前缀的二进制序列化结果。我后来用 StringRedisTemplate 执行 Lua 脚本,脚本里的 GET KEYS[1] 按字符串解析 key,结果发现之前存的数据全部查不到。

排查过程很典型:先用 Redis Desktop Manager 手动执行 EVAL,脚本正常返回结果;通过 Java 代码执行,却返回空。后来翻看 Redis 里实际的 key 内容,才发现存储格式完全对不上。

解决方式有两种。第一种是统一切换序列化器,项目里所有 RedisTemplate 都用 String 序列化,key 和 value 都存可读字符串。第二种是脚本里显式指定 key 名称,比如调 redis.call('GET', KEYS[1]) 之前,确认 KEYS[1] 传的实际值和写入时的 key 完全一致。这也解释了为什么我建议给 RedisTemplate 显式设置 StringRedisSerializer——虽然默认的 JDK 序列化也能用,但一旦和 Lua 脚本混合使用,问题就会被放大。

6.2 返回类型强制转换报错的问题

使用 DefaultRedisScript 时,resultType 设置不对是非常常见的报错来源。脚本返回 1,但 resultType 传了 String.class,Spring 内部就会尝试把 Long 型结果转成 String,通常直接抛异常。反过来,脚本里返回 "true",resultType 却用 Long.class,解析也会失败。

看一个典型的报错现场:

org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Cannot deserialize value of type `java.lang.Long` from String "true"

出现这类问题,我的排查步骤是先明确脚本实际返回什么类型。最直接的方式是在 Redis Desktop Manager 的 Console 里手动执行一遍脚本,观察返回值形态。然后对照 resultType 设置,确保一致。

另一个容易忽略的点是:脚本里 return false 在 Redis 中会被转成空值 nil,Java 侧拿到的是 null。如果你在 Java 代码里直接对结果做 == 0 判断,就会 NPE。安全的写法是先判空再做数值比较。

6.3 EVALSHA 报 NOSCRIPT 的异常

Spring Data Redis 的 DefaultRedisScript 默认采用 EVALSHA 方式执行脚本。EVALSHA 的原理是用脚本 SHA1 摘要定位缓存脚本,如果 Redis 端缓存被清空(比如重启、FLUSHALL),就会返回 NOSCRIPT 错误。

ERR Error running script (call to f_xxxx): NOSCRIPT No matching script. Please use EVAL.

Spring Data Redis 对这个问题有内置处理,检测到 NOSCRIPT 后会自动回退到 EVAL 重新发送脚本内容。但如果你在一些老版本的 Spring Data Redis 里遇到,或者自己封装了调用逻辑,就要注意这个异常。我的建议是使用官方 DefaultRedisScript 而不是自己造轮子,Spring 框架已经把这些问题都考虑进去了。

Redis 服务端对脚本缓存的清理策略也要了解。脚本缓存是按实例级别的,主从切换后新主节点可能没有缓存脚本。此时客户端需要保证 NOSCRIPT 后能自动重发。Spring Data Redis 的默认行为能满足绝大多数场景,但如果你的项目对异常极其敏感,可以在初始化阶段用 ScriptUtils 预加载脚本到 Redis,把缓存预热好。

6.4 Lua 脚本里操作了不存在的 key 或过期 key

脚本里 GET 一个不存在的 key,返回 false(nil),如果直接 tonumber(false),在 Lua 里会得到 nil 或报错。处理方式要养成习惯,先判空再转换:

local stock = redis.call('GET', KEYS[1]) if not stock then return 0 end local stockNum = tonumber(stock)

还有一种情况:key 存在但已经过期,GET 返回 nil,而 TTL 命令返回 -2。脚本里的处理逻辑要考虑到过期状态,特别是在限流、库存这类场景,过期后的 key 要当作不存在处理,否则逻辑会出错。

我在脚本里常见的兜底写法是:

local stock = tonumber(redis.call('GET', KEYS[1]) or '0')

or '0' 的作用是:如果 GET 返回 nil,就用字符串 '0' 兜底。这样一行代码同时处理了 key 不存在和值非数字的异常情况,脚本健壮性提升一个档次。

6.5 已过期 key 的 TTL 处理细节

在滑动窗口限流脚本里,每次添加新元素时都会设置 PEXPIRE。这个 TTL 的作用是在窗口结束后自动清理整个 key,避免内存堆积。但有个细节问题:如果窗口期间一直有请求,每次进来都重置 PEXPIRE,那 key 的过期时间会不断往后推,实际生命周期会远超窗口大小。

这个要不要处理,看业务需求。如果是严格的滑动窗口,每次重置 TTL 其实不影响正确性,因为窗口内的数据点会被 ZREMRANGEBYSCORE 持续清理。但如果你想保证 key 在窗口结束后尽快释放,就不能每次重置过期时间,而是只在第一次创建 key 时设置 TTL。这个细节决定了 Redis 内存占用模式,长期运行的系统要留意。

6.6 Lua 脚本性能调优与监控

脚本性能直接影响 Redis 整体吞吐。我的监控经验是:Redis 的 slowlog(慢查询日志)是排查脚本性能的首选工具。

redis-cli slowlog get 10

slowlog 会记录执行时间超过阈值的命令,Lua 脚本会被记录为一条 EVAL 或 EVALSHA 记录,执行时间一目了然。看到脚本耗时持续偏高时,优先检查几类问题:脚本里是否有大循环、是否有阻塞命令、网络往返是否频繁、脚本体是否过大导致传输成本高。

脚本体大小控制也很重要。太长的脚本不仅有传输开销,Redis 解析和编译也需要时间。一个实用原则是脚本不超过 1KB;如果超过,考虑把重复逻辑拆成公共函数,或者用 Redis 7 的 Function 特性做服务端管理。Redis 7 开始支持 Function,它比脚本缓存更进一步,支持函数库管理、自动同步到从节点,适合团队协作的大型项目,但学习成本更高,多数场景下 Lua 脚本已经足够。

7. Spring Boot 业务整合与代码组织

7.1 封装统一的 Lua 脚本执行服务

项目中脚本多了以后,每次执行都手工组装 RedisScript、List、args 太繁琐。我习惯封装一个 LuaScriptExecutor 服务类,集中管理脚本加载和执行逻辑:

@Service public class LuaScriptExecutor { private final StringRedisTemplate redisTemplate; public LuaScriptExecutor(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public <T> T execute(String scriptClasspath, Class<T> returnType, List<String> keys, Object... args) { DefaultRedisScript<T> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource(scriptClasspath)); script.setResultType(returnType); return redisTemplate.execute(script, keys, args); } }

使用时:

String luaPath = "scripts/lua/inventory_deduct.lua"; Long result = luaScriptExecutor.execute(luaPath, Long.class, List.of(stockKey, soldKey), quantity);

这样做的另一个好处是脚本文件变更后,业务代码不需要修改,只要保持脚本内部的 KEYS 和 ARGV 顺序不变即可。我把所有脚本文件的参数约定写进 README 或注释里,方便团队协作。半年后再回来维护,也能快速定位到问题的入口。

7.2 多业务模块共享脚本的命名规范

业务模块多了以后,脚本的管理很快会乱掉。我现在维护的脚本目录有二十几个文件,没有合理规范的时候经常出现两个模块各写一份功能类似的脚本,改一个忘另一个。

我的命名规范是:模块名_业务动作_版本.lua,比如order_stock_deduct_v1.luauser_login_limit_v1.lua。脚本内部用注释标明入参含义、返回值含义、业务逻辑说明。Redis 的 KEYS 使用业务前缀加冒号,比如order:stock:10086,一方面避免 key 冲突,另一方面在 Redis Desktop Manager 里能按前缀快速过滤定位。

共享逻辑用 Lua function 也可以,但要注意:Redis 里脚本之间没有全局共享状态,每个脚本都是独立解释执行的。想复用逻辑只能把公共代码复制到多个脚本里,或者用 Redis 7 的 Function 特性。目前我在团队里还是以复制为主,毕竟大部分情况下公共逻辑只有几行。

7.3 脚本参数校验与非法输入防御

脚本作为服务端执行代码,参数校验不能省。尤其是从外部传入的数值参数,不校验可能导致脚本运行时崩溃或产生脏数据。

我的检查项分为三类。第一,空值检查:key 列表为空、参数为空时直接返回错误,避免执行空逻辑。第二,类型检查:数值参数必须能转成数字,字符串参数不能包含危险字符。第三,范围检查:比如限流阈值必须大于 0,库存扣减数量不能为负。这些校验放在脚本开头做,保证后续逻辑的输入一定是合法的。

if not KEYS[1] or not ARGV[1] then return redis.error_reply('Invalid arguments') end local quantity = tonumber(ARGV[1]) if not quantity or quantity <= 0 then return redis.error_reply('Invalid quantity') end

redis.error_reply 返回的错误信息会同步给 Java 侧,日志里排查问题时能一眼看出是哪一步参数不对。

7.4 监控日志与告警指标的设计

接入脚本后,我用 Micrometer 对执行情况进行埋点。核心指标有三个:执行次数、执行耗时分布、异常次数。执行耗时是最重要的指标,因为我前面提过 Redis 单线程模型下脚本执行耽误的是所有请求的时间。如果脚本 P99 耗时超过 50ms,就要立刻优化。

日志方面,我会在 Java 侧记录脚本名称、keys、args(敏感信息脱敏)、执行结果。异常要记录完整堆栈,特别是 NOSCRIPT、序列化异常、类型转换异常这几类常见问题。告警阈值我设置为:单脚本执行耗时超过 100ms 或每分钟异常数超过 10 次,立即触发钉钉/企微告警。团队看板上有这些指标,线上问题基本能提前发现。

生产环境我还会定期导出所有 Lua 脚本的源码和缓存信息,人工走查一遍是否有独占资源的风险头部逻辑。这个走查的频率不高,但每次都有收获。

8. 经验之谈

8.1 关于脚本复用的三个小技巧

脚本复用的核心是参数化和易读性。我自己的三个小习惯,每次都能减少返工。

第一个技巧是所有脚本文件的第一行写清注释,标注出参、入参、返回值和依赖的 key 结构。这个习惯一开始觉得多余,半年后回改脚本时会感谢当初的自己。第二个技巧是脚本里的 Redis 命令按业务逻辑顺序排列,同一类操作集中放一起,别穿插着写。第三个技巧是定义明确的常量名,比如 local DEFAULT_TTL = 30000,不要魔法数字满天飞。

8.2 什么时候别用 Lua 脚本

Lua 脚本很强大,但不是银弹。我总结了几个不适合用脚本的场景。

第一个是脚本里包含需要大量计算的逻辑。Redis 单线程执行,10 万次循环的 CPU 运算会让整个实例卡顿几十毫秒,所有请求都受影响。这种计算应该放到应用侧做。

第二个是依赖外部状态的逻辑。脚本只能访问 Redis 内的数据,拿不到配置文件、数据库、远程接口。跟外部系统交互就别指望脚本了。

第三个是过于复杂的分支逻辑。脚本超过一百行时,排错的成本会明显上升。维护时改一行逻辑要重新部署脚本,压力很大。这种复杂逻辑建议拆成多个简单脚本,在业务层编排。

8.3 压测环境验证脚本稳定性的方法

脚本上线前,我建议至少做三轮验证。第一轮是功能验证:在压测环境准备一批真实数据,用接口触发脚本执行,观察返回值和数据变化是否与预期一致。第二轮是并发验证:用 JMeter 或 wrk 压测接口,并发数从 50 到 500 逐步加压,观察脚本有没有异常、Redis CPU 是否飙升、有没有慢查询。第三轮是故障验证:模拟 Redis 重启、主从切换、网络抖动,确认脚本能自动恢复。

压测中最常发现的问题是脚本对边界条件的处理不完善。比如库存从 0 开始扣减、限流窗口刚过边界、key 过期瞬间访问等,都要在压测用例里覆盖到。我的经验是压测环境的数据越接近生产,脚本的验证效果越好。

8.4 最近一次上线中脚本帮我避免的事故

最后分享一次真实经历。上月给一个活动页面做流量控制,准备阶段就把限流逻辑写成了 Lua 脚本。上线当天流量突然涨了 20 倍,接口 QPS 逼近 3 万。如果按照传统方案在应用层用 Redisson 分布式锁保护,光锁请求就能把 Redis 压垮。但我的脚本方案把整个限流逻辑下推到了 Redis 内部,每个请求只产生一次内存操作,Redis 负载最高才 30%,活动平稳结束,零超卖零崩溃。

那次之后我更加确定,在 Redis 场景里 Lua 脚本不是一个可选项,而是处理并发一致性问题的标准答案。它真正把“数据一致性”从业务代码级别下沉到了数据存储级别,这个思路在系统不断变复杂时价值会越来越明显。希望这篇教程能帮你少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询