本文面向有 Java 基础、想系统掌握 Redis Lua 的开发者。每个场景都包含完整脚本、逐行解释、Java 调用代码、避坑指南,可以直接抄进项目。
开篇:什么时候该用 Lua?
写 Lua 之前,先问自己三个问题:
- 这个操作能不能用单条 Redis 命令完成? 能,就别写脚本。
SET NX PX、INCR、DECRBY都是原子的,不需要 Lua。 - 是否需要多个命令原子组合? 只有需要"读-判断-写"这种组合操作,且 Redis 没有对应原生命令时,才写 Lua。
- 脚本能不能控制在 100 行以内? Lua 执行期间会阻塞整个 Redis 实例。脚本越长、循环越多,阻塞越久。
记住一句话:Lua 是原子性的最后手段,不是第一选择。
场景一:分布式锁
分布式锁有两个操作:加锁 和解锁。加锁不需要 Lua,解锁才需要。
加锁:不需要 Lua
SET NX PX 本身就是单条原子命令:
java
vbnet
public boolean tryLock(String lockKey, String token, long expireMs) {
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(lockKey, token, expireMs, TimeUnit.MILLISECONDS);
return Boolean.TRUE.equals(ok);
}
为什么不用 SETNX + EXPIRE 两条命令?
如果在 Java 里分两次调用:
java
scss
redisTemplate.opsForValue().setIfAbsent(lockKey, token); // SETNX
redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); // EXPIRE
两次调用之间,如果客户端崩溃、网络断开,EXPIRE 没执行,锁就没有过期时间,永久占用。
如果一定要拆成两条命令,可以用 Lua 包起来:
lua
vbnet
if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
return 1
else
return 0
end
这样脚本内部是原子的,不会被其他客户端插入命令。但 Redis 服务器在两条命令之间宕机的极端情况仍然存在。而 SET NX PX 是单条原生命令,原子性由 Redis 命令层面保证,更可靠。所以能用单条命令就不要用 Lua 包。
解锁:必须用 Lua
解锁需要"比较 token + 删除"两步,没有单条命令能干这事:
lua
vbnet
-- 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
逐行解释:
redis.call('GET', KEYS[1]):取出当前锁的 token。== ARGV[1]:和传入的 token 比较。这一步是防止误删。- 如果相等,说明锁是自己的,执行
DEL,返回删除数量(1)。 - 如果不相等,说明锁已经被别人拿走了(自己的锁过期后别人加了锁),返回 0。
为什么必须比 token?
假设客户端 A 拿到锁,但业务执行太久,锁过期了。客户端 B 趁机拿到锁。此时 A 执行完了,如果直接 DEL,就会把 B 的锁删掉。比 token 就能避免这个问题------A 的 token 和当前锁的 token 不一致,A 不会删。
Java 调用:
java
typescript
public boolean unlock(String lockKey, String token) {
Long r = redisTemplate.execute(
unlockScript,
Collections.singletonList(lockKey),
token
);
return r != null && r == 1L;
}
注意: Collections.singletonList(lockKey) 对应 KEYS[1],token 对应 ARGV[1]。Lua 里 ARGV 都是字符串类型,Redis 命令会自动转换。
生产环境建议: 直接用 Redisson。它帮你处理了看门狗续期、可重入计数、发布订阅唤醒,比手写 Lua 更可靠。
场景二:秒杀扣库存(防超卖)
秒杀需要同时做三件事:判断是否重复购买、判断库存、扣减库存并记录用户。没有单条命令能完成,必须用 Lua。
lua
lua
-- KEYS[1]: 库存 key
-- KEYS[2]: 已购用户集合 key
-- ARGV[1]: 用户 ID
-- ARGV[2]: 扣减数量
-- 返回: 剩余库存;-1 库存不足;-2 重复购买;-3 库存未初始化
-- 1. 判断是否重复购买
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -2
end
-- 2. 判断库存
local stock = redis.call('GET', KEYS[1])
if not stock then
return -3
end
stock = tonumber(stock)
local num = tonumber(ARGV[2])
if stock < num then
return -1
end
-- 3. 扣减库存 + 记录用户
redis.call('DECRBY', KEYS[1], num)
redis.call('SADD', KEYS[2], ARGV[1])
return stock - num
逐行解释:
第 1 步:防重复购买
KEYS[2]是一个 Set,存的是"已经买过的用户 ID"。SISMEMBER判断ARGV[1](当前用户 ID)是否已经在集合里。- 返回 1 表示已存在,直接返回
-2。
第 2 步:判断库存
redis.call('GET', KEYS[1]):取库存值。如果 key 不存在,返回false(Lua 里的 nil)。if not stock then return -3 end:显式判断 key 不存在,返回-3。这样能把"库存未初始化"和"库存真的为 0"区分开。tonumber(stock):把字符串转成数字,因为 Redis 返回值都是字符串。local num = tonumber(ARGV[2]):扣减数量,也转成数字。if stock < num then return -1:库存不足。
第 3 步:扣减 + 记录
DECRBY KEYS[1] num:库存减 num。SADD KEYS[2] ARGV[1]:把用户 ID 加入已购集合。return stock - num:返回剩余库存。
为什么这三步必须在一个脚本里?
如果分开调用,假设 Java 先 GET 库存 = 1,判断够,然后 DECRBY。但在 GET 和 DECRBY 之间,另一个请求也 GET 到 1,也判断够,然后两个请求都 DECRBY,库存变成 -1,超卖。Lua 脚本在执行期间不会被其他命令打断,这三步是原子的。
为什么用 tonumber?
Redis 的 GET 返回的是字符串 "10",ARGV[2] 也是字符串 "1"。Lua 里字符串比较 "10" < "1" 是按字典序的,结果是 true(因为 '1' 和 '1' 相等,然后 '0' < '1'),这完全错误。必须转成数字再比较。
Java 调用:
java
typescript
public long seckill(String stockKey, String userSetKey, String userId) {
Long remain = redisTemplate.execute(
seckillScript,
Arrays.asList("{seckill:1001}:stock", "{seckill:1001}:users"),
userId, "1"
);
if (remain == null) throw new RuntimeException("脚本执行失败");
if (remain == -1) throw new RuntimeException("库存不足");
if (remain == -2) throw new RuntimeException("请勿重复购买");
if (remain == -3) throw new RuntimeException("库存未初始化");
return remain;
}
集群下的 {tag}:
两个 key 都带 {seckill:1001},Redis Cluster 会根据大括号里的内容计算哈希槽,保证两个 key 落在同一个节点。否则脚本会报 CROSSSLOT 错误。
场景三:滑动窗口限流
限流需要"清理过期记录 + 统计 + 记录新请求"三步,没有单条命令能完成,必须用 Lua。
lua
lua
-- KEYS[1]: 限流 key
-- ARGV[1]: 当前时间戳(毫秒)
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 最大请求数
-- ARGV[4]: 唯一请求 ID(由 Java 传入,如 UUID)
-- 返回: 0 放行;1 限流
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local maxCount = tonumber(ARGV[3])
local requestId = ARGV[4]
-- 1. 清除窗口外的记录
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
-- 2. 统计窗口内请求数
local count = redis.call('ZCARD', KEYS[1])
if count >= maxCount then
return 1
end
-- 3. 记录本次请求
redis.call('ZADD', KEYS[1], now, requestId)
redis.call('PEXPIRE', KEYS[1], window)
return 0
逐行解释:
参数解析:
now:当前时间戳(毫秒),由 Java 传入。window:窗口大小,比如 60000 表示 1 分钟。maxCount:窗口内最大请求数。requestId:唯一请求 ID,由 Java 传入 ,避免用math.random导致碰撞。
第 1 步:清理过期记录
ZREMRANGEBYSCORE KEYS[1] 0 now-window:删除 ZSET 中分数在0到now-window之间的所有成员。- ZSET 的分数是请求时间戳。
now - window是窗口的左边界。 - 比如 now = 100000,window = 60000,左边界 = 40000。分数小于 40000 的记录全部删除。
第 2 步:统计窗口内请求数
ZCARD KEYS[1]:返回 ZSET 的成员数量。if count >= maxCount then return 1:超过限制,返回 1(限流)。
第 3 步:记录本次请求
ZADD KEYS[1] now requestId:把当前请求加入 ZSET,分数是 now。- 为什么用 Java 传入的 requestId 而不是
math.random? Redis 的 Lua 沙箱里math.random的种子是固定的,除非显式调用math.randomseed,否则每次脚本执行的随机序列可能相同。更稳妥的做法是从 Java 传入 UUID。 PEXPIRE KEYS[1] window:给整个 ZSET 设置过期时间,防止内存泄漏。return 0:放行。
为什么时间戳从 Java 传入,不用 redis.call('TIME')?
TIME 命令返回的是 Redis 服务器的时间。如果 Redis 是主从架构,主从时间可能不一致;如果脚本在从节点执行,时间也不对。从 Java 传入可以用应用服务器的统一时间,更可控。
Java 调用:
java
vbnet
public boolean allowRequest(String key, int maxCount, long windowMs) {
Long r = redisTemplate.execute(
rateLimitScript,
Collections.singletonList(key),
String.valueOf(System.currentTimeMillis()),
String.valueOf(windowMs),
String.valueOf(maxCount),
UUID.randomUUID().toString()
);
return r != null && r == 0L;
}
这个算法的问题:
每个请求都在 ZSET 里存一条记录。如果 QPS 很高,ZSET 会很大。比如 1 万 QPS,窗口 1 分钟,ZSET 里有 60 万条记录,内存占用高。
改进方案:
- 固定窗口 :每个窗口一个计数器,内存 O(1),但窗口边界处可能突刺。用
INCR+EXPIRE即可。 - 令牌桶 :只存令牌数和上次刷新时间,内存 O(1),且能平滑限流。Redisson 的
RRateLimiter就是令牌桶实现。
场景四:排行榜(Top N + 个人排名)
排行榜需要"更新分数 + 查排名 + 查分数 + 查 Top N"四步。如果分四次调用,并发下可能拿到不一致的数据。
lua
ini
-- KEYS[1]: 排行榜 ZSET key
-- ARGV[1]: 用户 ID
-- ARGV[2]: 分数
-- ARGV[3]: Top N
-- 返回: {排名, 分数, Top N 列表}
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])
local rank = redis.call('ZREVRANK', KEYS[1], ARGV[1])
if not rank then
return {-1, -1, {}}
end
local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
local topN = redis.call('ZREVRANGE', KEYS[1], 0, tonumber(ARGV[3]) - 1, 'WITHSCORES')
return {rank, score, topN}
逐行解释:
ZADD KEYS[1] ARGV[2] ARGV[1]:更新分数。ARGV[2]是分数,ARGV[1]是用户 ID。如果用户已存在,更新分数;不存在,新增。ZREVRANK KEYS[1] ARGV[1]:返回用户在当前 ZSET 中的降序排名(从 0 开始)。分数最高的排名是 0。if not rank then return {-1, -1, {}} end:兜底。虽然前面刚ZADD过,理论上ZREVRANK不会返回 nil,但如果ZADD因为某种原因失败(比如 key 类型不对),ZREVRANK会返回false。这里显式处理。ZSCORE KEYS[1] ARGV[1]:返回用户的分数。ZREVRANGE KEYS[1] 0 topN-1 WITHSCORES:返回降序排列的前 topN 名,带分数。return {rank, score, topN}:Lua 的 table 会被 Spring 转成 Java 的List。
Java 侧的类型转换:
java
vbnet
List<Object> result = redisTemplate.execute(leaderboardScript, ...);
// result.get(0) → Long(排名)
// result.get(1) → String(分数,Redis 返回字符串)
// result.get(2) → List(Top N 列表,每个元素是 String)
注意:
rank是Long,因为ZREVRANK返回整数。score是String,因为 Redis 的ZSCORE返回字符串(浮点数格式)。topN是List,里面是[member1, score1, member2, score2, ...]交替排列。
为什么用脚本?
如果分四次调用(ZADD、ZREVRANK、ZSCORE、ZREVRANGE),在并发下可能拿到不一致的数据。比如 ZADD 之后,别人又 ZADD 了,你的 ZREVRANK 就是新的排名,但 ZSCORE 可能还是旧的。脚本保证四个操作看到的是同一时刻的数据。
场景五:CAS 更新(乐观锁)
CAS 需要"读-比较-写"三步,没有单条命令能完成。
lua
sql
-- KEYS[1]: 目标 key
-- ARGV[1]: 期望的旧值
-- ARGV[2]: 新值
-- 返回: 1 成功;0 旧值不匹配
local old = redis.call('GET', KEYS[1])
if old == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end
逐行解释:
GET KEYS[1]:取出当前值。old == ARGV[1]:和期望的旧值比较。- 如果相等,
SET新值,返回 1(成功)。 - 如果不相等,返回 0(失败,说明别人已经改过了)。
Java 调用:
java
typescript
public boolean casUpdate(String key, String expect, String newValue) {
Long r = redisTemplate.execute(
casScript,
Collections.singletonList(key),
expect, newValue
);
return r != null && r == 1L;
}
使用场景:
java
ini
String oldValue = redisTemplate.opsForValue().get("config:timeout");
// 业务逻辑计算新值
boolean ok = casUpdate("config:timeout", oldValue, newValue);
if (!ok) {
// 重试或报错
}
和 WATCH + MULTI 的区别:
WATCH+MULTI需要客户端维护事务状态,代码复杂,且失败后要手动重试。- Lua 脚本把"读-比较-写"封装成一个原子操作,Java 侧只需处理返回的 0/1。
值很大时的优化:用版本号替代
如果值很大(比如 JSON 几 KB),每次比较字符串开销大。可以用版本号:
lua
lua
-- KEYS[1]: 值 key
-- KEYS[2]: 版本号 key
-- ARGV[1]: 期望版本号
-- ARGV[2]: 新值
local version = tonumber(redis.call('GET', KEYS[2]) or '0')
if version == tonumber(ARGV[1]) then
redis.call('SET', KEYS[1], ARGV[2])
redis.call('INCR', KEYS[2])
return 1
end
return 0
注意: 如果并发极高,CAS 失败率会很高,需要配合重试机制。
场景六:安全删除(带日志)
单纯删除用 DEL 就够了,不需要脚本。但如果删除前需要做其他事(比如记录日志、释放关联资源),就需要 Lua 把"删除 + 其他操作"原子化。
lua
lua
-- KEYS[1]: 目标 key
-- KEYS[2]: 日志 key
-- ARGV[1]: 操作人
-- 返回: 1 删除成功;0 key 不存在
if redis.call('EXISTS', KEYS[1]) == 1 then
redis.call('LPUSH', KEYS[2], 'deleted:' .. KEYS[1] .. ':' .. ARGV[1])
redis.call('UNLINK', KEYS[1])
return 1
else
return 0
end
逐行解释:
EXISTS KEYS[1]:判断 key 是否存在。- 如果存在,
LPUSH记录日志,UNLINK删除 key,返回 1。 - 如果不存在,返回 0。
为什么用 UNLINK 而不是 DEL?
如果 KEYS[1] 是一个有百万元素的 Set 或 List,DEL 会同步删除 ,阻塞 Redis 几秒甚至更久。UNLINK 是异步删除,不阻塞主线程。
Java 调用:
java
vbnet
public boolean safeDelete(String key, String logKey, String operator) {
Long r = redisTemplate.execute(
safeDeleteScript,
Arrays.asList(key, logKey),
operator
);
return r != null && r == 1L;
}
注意: 两个 key 在集群下也需要用 {tag} 保证同槽。
通用避坑清单(Java + Lua)
| 坑 | 表现 | 解决 |
|---|---|---|
| 序列化器不对 | 脚本找不到 key | 用 StringRedisTemplate |
| KEYS 硬编码 | 集群报 CROSSSLOT |
所有 key 通过 KEYS 传入,用 {tag} 同槽 |
| 返回值类型不匹配 | ClassCastException |
Lua number → Long.class,table → List.class |
| 脚本里有全局变量 | 报 Attempt to create global variable |
所有变量加 local |
| 脚本太长 | 阻塞 Redis | 脚本控制在 100 行内,避免循环 |
| 大 key 操作 | 阻塞 | 用 UNLINK、SCAN 替代 DEL、KEYS |
| 时间不一致 | 限流不准 | 时间戳从 Java 传入,别用 redis.call('TIME') |
| 忘记设过期 | 内存泄漏 | 限流、锁等临时 key 必须 PEXPIRE |
math.random 种子固定 |
限流 member 碰撞 | 从 Java 传入 UUID |
| 库存 key 不存在 | 误判为库存 0 | 显式返回 -3 区分 |
Lua 原子性的三个层次
很多人以为"Lua 脚本原子"就等于"绝对安全",其实要分三层理解:
| 层次 | 是否原子 | 说明 |
|---|---|---|
| 单条 Redis 命令 | 原子 | SET NX PX、INCR、DECRBY 都是原子的 |
| Lua 脚本内部 | 原子 | 脚本执行期间不被其他客户端打断,内部多条命令连续执行 |
| Java 多次调用 | 不原子 | 两次 redisTemplate 调用之间,可能被其他客户端插入命令,也可能客户端崩溃 |
Lua 原子,但不隔离。 脚本执行期间,其他客户端只是被阻塞,不是看不到中间状态。如果脚本里有耗时循环,会阻塞整个 Redis 实例,所有其他请求都卡住。而且脚本不能回滚------如果执行到一半报错,前面已执行的命令不会撤销。
总结:Lua 脚本的通用思维
写 Lua 脚本时,脑子里要问自己四个问题:
- 哪些操作必须原子? 只有需要原子性的操作才值得写脚本。单个命令能搞定的,不要写脚本。
- KEYS 和 ARGV 怎么分? 所有键名走
KEYS,其他参数走ARGV。这是集群兼容的前提。 - 返回值怎么设计? 用数字表示状态(1 成功,0 失败,-1 库存不足),比返回字符串语义更清晰,Java 侧也更好处理。
- 脚本够短吗? 控制在 100 行以内,避免
for循环和大 key 操作。脚本越长,阻塞 Redis 越久。
调试技巧
命令行直接测脚本:
bash
css
redis-cli --eval seckill.lua "{seckill:1001}:stock" "{seckill:1001}:users" , "user123" "1"
注意 , 前面是 KEYS,后面是 ARGV。
查看脚本是否缓存:
bash
sql
SCRIPT EXISTS <sha1>
清空脚本缓存:
bash
SCRIPT FLUSH