Redis Lua 脚本实战:从入门到精通(Java 版)

本文面向有 Java 基础、想系统掌握 Redis Lua 的开发者。每个场景都包含完整脚本、逐行解释、Java 调用代码、避坑指南,可以直接抄进项目。


开篇:什么时候该用 Lua?

写 Lua 之前,先问自己三个问题:

  1. 这个操作能不能用单条 Redis 命令完成? 能,就别写脚本。SET NX PXINCRDECRBY 都是原子的,不需要 Lua。
  2. 是否需要多个命令原子组合? 只有需要"读-判断-写"这种组合操作,且 Redis 没有对应原生命令时,才写 Lua。
  3. 脚本能不能控制在 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。但在 GETDECRBY 之间,另一个请求也 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 中分数在 0now-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)

注意:

  • rankLong,因为 ZREVRANK 返回整数。
  • scoreString,因为 Redis 的 ZSCORE 返回字符串(浮点数格式)。
  • topNList,里面是 [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 操作 阻塞 UNLINKSCAN 替代 DELKEYS
时间不一致 限流不准 时间戳从 Java 传入,别用 redis.call('TIME')
忘记设过期 内存泄漏 限流、锁等临时 key 必须 PEXPIRE
math.random 种子固定 限流 member 碰撞 从 Java 传入 UUID
库存 key 不存在 误判为库存 0 显式返回 -3 区分

Lua 原子性的三个层次

很多人以为"Lua 脚本原子"就等于"绝对安全",其实要分三层理解:

层次 是否原子 说明
单条 Redis 命令 原子 SET NX PXINCRDECRBY 都是原子的
Lua 脚本内部 原子 脚本执行期间不被其他客户端打断,内部多条命令连续执行
Java 多次调用 不原子 两次 redisTemplate 调用之间,可能被其他客户端插入命令,也可能客户端崩溃

Lua 原子,但不隔离。 脚本执行期间,其他客户端只是被阻塞,不是看不到中间状态。如果脚本里有耗时循环,会阻塞整个 Redis 实例,所有其他请求都卡住。而且脚本不能回滚------如果执行到一半报错,前面已执行的命令不会撤销。


总结:Lua 脚本的通用思维

写 Lua 脚本时,脑子里要问自己四个问题:

  1. 哪些操作必须原子? 只有需要原子性的操作才值得写脚本。单个命令能搞定的,不要写脚本。
  2. KEYS 和 ARGV 怎么分? 所有键名走 KEYS,其他参数走 ARGV。这是集群兼容的前提。
  3. 返回值怎么设计? 用数字表示状态(1 成功,0 失败,-1 库存不足),比返回字符串语义更清晰,Java 侧也更好处理。
  4. 脚本够短吗? 控制在 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
相关推荐
知识的搬运工旺仔1 小时前
唯一索引与 NULL 值:PostgreSQL 主键约束与 NULLS NOT DISTINCT
数据库·后端·sql·postgresql
大勇前进1 小时前
从 OC 到 Swift,老 iOS 开发者踩过的 10 个语法大坑
后端
我的xiaodoujiao1 小时前
Django 基础知识详细图文教程 9-Django 模板引擎 2
开发语言·数据库·后端·django
烈风逍遥2 小时前
第五篇:通用 LLM 流式对话:前后端联接的完整实现
前端·后端·架构
Ticnix2 小时前
别再手动上线了:一条命令带备份、健康检查和自动回滚
后端·python·ci/cd
ArkPppp2 小时前
如何从零上线一个耐造的Redis缓存系统——最直接最不绕弯子的方式
后端·ai编程
Kyrie_kk2 小时前
Java--ProcessBuilder操作系统进程
java·后端
Ticnix2 小时前
迁移脚本能跑通,不代表你回滚得回来
后端·python
SamDeepThinking2 小时前
关于java final关键字的可见性
java·后端·面试