Redis Lua 脚本——把多条命令打包成一个原子操作

没有 Lua 的时候,问题长什么样

先看一个最简单的场景:点赞。

java 复制代码
// 服务端代码:判断用户是否已经点赞,没点就加上
String key = "user:like:10001";
String member = "note:888";

Long exists = redisTemplate.opsForSet().isMember(key, member) ? 1L : null;

if (exists == null) {
    redisTemplate.opsForSet().add(key, member);   // 这两行之间,可能被其他请求插入
}

问题在哪?isMember 返回 false 后、add 执行之前,另一个线程也判断了同一个 key,也是 false,于是也 add------同样的操作执行了两次。
请求B Redis 请求A 请求B Redis 请求A #mermaid-svg-fagrKUlnZgJlpq58{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fagrKUlnZgJlpq58 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fagrKUlnZgJlpq58 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fagrKUlnZgJlpq58 .error-icon{fill:#552222;}#mermaid-svg-fagrKUlnZgJlpq58 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fagrKUlnZgJlpq58 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fagrKUlnZgJlpq58 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fagrKUlnZgJlpq58 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fagrKUlnZgJlpq58 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fagrKUlnZgJlpq58 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fagrKUlnZgJlpq58 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fagrKUlnZgJlpq58 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fagrKUlnZgJlpq58 .marker.cross{stroke:#333333;}#mermaid-svg-fagrKUlnZgJlpq58 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fagrKUlnZgJlpq58 p{margin:0;}#mermaid-svg-fagrKUlnZgJlpq58 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fagrKUlnZgJlpq58 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-fagrKUlnZgJlpq58 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fagrKUlnZgJlpq58 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-fagrKUlnZgJlpq58 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-fagrKUlnZgJlpq58 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-fagrKUlnZgJlpq58 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-fagrKUlnZgJlpq58 .sequenceNumber{fill:white;}#mermaid-svg-fagrKUlnZgJlpq58 #sequencenumber{fill:#333;}#mermaid-svg-fagrKUlnZgJlpq58 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-fagrKUlnZgJlpq58 .messageText{fill:#333;stroke:none;}#mermaid-svg-fagrKUlnZgJlpq58 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fagrKUlnZgJlpq58 .labelText,#mermaid-svg-fagrKUlnZgJlpq58 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-fagrKUlnZgJlpq58 .loopText,#mermaid-svg-fagrKUlnZgJlpq58 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-fagrKUlnZgJlpq58 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fagrKUlnZgJlpq58 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-fagrKUlnZgJlpq58 .noteText,#mermaid-svg-fagrKUlnZgJlpq58 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-fagrKUlnZgJlpq58 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fagrKUlnZgJlpq58 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fagrKUlnZgJlpq58 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fagrKUlnZgJlpq58 .actorPopupMenu{position:absolute;}#mermaid-svg-fagrKUlnZgJlpq58 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-fagrKUlnZgJlpq58 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fagrKUlnZgJlpq58 .actor-man circle,#mermaid-svg-fagrKUlnZgJlpq58 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-fagrKUlnZgJlpq58 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 添加了两次! SISMEMBER user:like:10001 note:8880(不存在)SISMEMBER user:like:10001 note:8880(也不存在)SADD user:like:10001 note:888SADD user:like:10001 note:888

这叫 check-then-act 竞态条件。Java 代码帮不上忙------两条 Redis 命令之间,Redis 不负责加锁。


Lua 怎么解决的

Redis 执行 Lua 脚本的规则很简单:

整个脚本作为一个原子操作执行。脚本运行期间,Redis 不执行任何其他命令。

所以把"判断 + 执行"写成一段 Lua,丢给 Redis------Redis 一口气跑完,全程无打扰。

lua 复制代码
-- 判断 + 执行的完整逻辑,在 Redis 内部一气呵成,没人插得进来
if redis.call('EXISTS', KEYS[1]) == 0 then
    return -1  -- 集合还不存在
end

if redis.call('ZSCORE', KEYS[1], ARGV[1]) then
    return -2  -- 已经点赞过了
end

redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])
return 0  -- 点赞成功

六行 Lua,解决了 Java 端需要分布式锁才能保证的原子性。

跟 Java 代码的对比就很直观了

复制代码
Java 方式:EXISTS → 网络往返1 → ZCARD → 网络往返2 → ZSCORE → 网络往返3 → ZADD → 网络往返4
           4 次网络 IO,中间可能被其他请求插入。

Lua 方式: 1 次网络 IO,Redis 内部一口气跑完 4 条命令,中间无打断。

注意:Redis 的原子性只保证单条命令或 Lua 脚本内部不被打断。如果你的 Lua 脚本需要操作多个不相关的 key,Redis Cluster 模式下要注意所有 key 必须在同一个 slot,否则脚本执行会报错。单机或 Sentinel 模式没这个限制。


Spring Boot 里怎么写------两种方式

方式一:外部 .lua 文件 + DefaultRedisScript

把脚本放在 resources/lua/ 目录下,Java 里加载执行:

java 复制代码
// 读取 .lua 文件
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(
    new ResourceScriptSource(
        new ClassPathResource("/lua/follow_check_and_add.lua")));
script.setResultType(Long.class);

// KEYS = 一个 key,ARGV = 两个参数
List<String> keys = Collections.singletonList("user:following:10001");
Long result = redisTemplate.execute(script, keys, "20002", String.valueOf(System.currentTimeMillis()));
lua 复制代码
-- resources/lua/follow_check_and_add.lua
local key = KEYS[1]              -- "user:following:10001"
local followUserId = ARGV[1]     -- "20002"
local timestamp = ARGV[2]        -- 时间戳

-- 校验集合是否存在
if redis.call('EXISTS', key) == 0 then
    return -1  -- 集合不存在
end

-- 校验关注人数是否达到上限
local size = redis.call('ZCARD', key)
if size >= 1000 then
    return -2  -- 关注已达上限
end

-- 校验是否已经关注
if redis.call('ZSCORE', key, followUserId) then
    return -3  -- 已经关注过了
end

redis.call('ZADD', key, timestamp, followUserId)
return 0  -- 关注成功

把 Lua 返回值定义为枚举类,把 Lua 返回值和业务含义映射起来

java 复制代码
@Getter
@AllArgsConstructor
public enum FollowLuaResultEnum {

    SUCCESS(0, "关注成功"),
    NOT_EXIST(-1, "关注列表不存在"),
    UPPER_LIMIT(-2, "关注已达上限(1000人)"),
    ALREADY_FOLLOWED(-3, "已经关注过了");

    private final int code;
    private final String description;

    public static FollowLuaResultEnum valueOf(long code) {
        for (FollowLuaResultEnum e : values()) {
            if (e.getCode() == code) {
                return e;
            }
        }
        throw new IllegalArgumentException("未知的 Lua 返回码: " + code);
    }
}

Java 端用枚举映射返回值

java 复制代码
Long rawResult = redisTemplate.execute(script, keys, followUserId, timestamp);
FollowLuaResultEnum result = FollowLuaResultEnum.valueOf(rawResult);

switch (result) {
    case SUCCESS -> log.info("关注成功");
    case NOT_EXIST -> throw new BizException("关注列表不存在");
    case UPPER_LIMIT -> throw new BizException("关注已达上限(1000人)");
    case ALREADY_FOLLOWED -> throw new BizException("已经关注过了");
}

方式二:字符串内联 + RedisScript.of()

简单的脚本直接在 Java 里写,不用单独建 .lua 文件:

java 复制代码
// 一行 Lua 内联:布隆过滤器添加 + 设置过期
RedisScript<Long> bloomAddScript = RedisScript.of(
    "return redis.call('BF.ADD', KEYS[1], ARGV[1])",
    Long.class);

redisTemplate.execute(bloomAddScript,
    Collections.singletonList(bloomKey),
    noteCreatorId);

什么时候用哪种?

方式 适用场景
DefaultRedisScript + .lua 文件 多行逻辑、多返回值、需要版本管理、IDE 语法高亮
RedisScript.of() 内联 一两行简单操作,懒得建文件

KEYS 和 ARGV 的区别

java 复制代码
redisTemplate.execute(script, keys, arg1, arg2, ...);
//                            ↑ KEYS  ↑ ARGV
KEYS ARGV
在 Lua 里怎么取 KEYS[1]KEYS[2]...... ARGV[1]ARGV[2]......
传的是 Redis key 普通参数(value、时间戳、过期秒数......)
为什么区分 Redis Cluster 模式下,框架需要根据 KEYS 做哈希路由,决定脚本在哪个节点执行。ARGV 只传值,不参与路由

所以 keys 传 Redis key,argv 传其他参数:

java 复制代码
// ✅ 正确:key 放 KEYS,值放 ARGV
redisTemplate.execute(script,
    Collections.singletonList("user:like:10001"),  // KEYS
    "note:888", String.valueOf(System.currentTimeMillis()));  // ARGV

// ❌ 错误:key 放 ARGV------单机没问题,Cluster 模式下路由出错
redisTemplate.execute(script,
    Collections.emptyList(),
    "user:like:10001", "note:888");

什么时候该用 Lua,什么时候不该用

该用:

  • 多条 Redis 命令之间有逻辑依赖(check-then-act)
  • 操作同一个 key 需要先判断再执行(比如判上限再 ZADD、判重复再 SADD)
  • 两个 key 之间需要同步操作(比如取关同时删关注表和粉丝表)

不该用:

  • 单条命令本来就是原子的,不需要包一层 Lua
  • 脚本太复杂------Redis 执行 Lua 期间是单线程的,上百行的脚本会长时间阻塞其他请求
  • 脚本里想调外部服务------Redis Lua 不能发 HTTP、不能查 DB

Lua 脚本怎么写------基础语法够用了

Redis Lua 只用 Lua 5.1 的一个子集。写 Redis 脚本不需要学完整 Lua,下面这些就够日常用了。

变量和注释

lua 复制代码
-- 单行注释用两个减号
local name = "hello"      -- local 声明局部变量,没有 local 就是全局变量(别用全局变量)
local count = 0
count = count + 1          -- 赋值

条件判断

lua 复制代码
if redis.call('EXISTS', KEYS[1]) == 0 then
    return -1               -- return 直接结束脚本,返回值给 Java 端
end

-- 多分支
local size = redis.call('ZCARD', KEYS[1])
if size >= 1000 then
    return -2
elseif size < 10 then
    return -3
else
    return 0
end

Redis 命令怎么调------redis.call()

lua 复制代码
-- 格式:redis.call('命令名', 参数1, 参数2, ...)
-- 跟你在 redis-cli 里敲的命令一一对应

redis.call('SET', 'key1', 'value1')                     -- SET key1 value1
redis.call('EXPIRE', 'key1', 3600)                       -- EXPIRE key1 3600
local val = redis.call('GET', 'key1')                    -- GET key1,返回值可以存到变量
local exists = redis.call('EXISTS', KEYS[1])             -- EXISTS key
local size = redis.call('ZCARD', KEYS[1])                -- ZCARD key
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])           -- ZADD key score member
redis.call('ZREM', KEYS[1], ARGV[1])                    -- ZREM key member
local score = redis.call('ZSCORE', KEYS[1], ARGV[1])    -- ZSCORE key member

就是把你熟悉的 Redis 命令搬进 Lua,中间没啥区别。

取传进来的参数------KEYS 和 ARGV

lua 复制代码
-- 数组下标从 1 开始,不是 0
local myKey = KEYS[1]       -- 第一个 key
local myVal = ARGV[1]       -- 第一个参数
local myVal2 = ARGV[2]      -- 第二个参数

循环

lua 复制代码
-- for 循环
for i = 1, 10 do
    redis.call('SET', 'key' .. i, i)
end

-- 遍历数组
local members = redis.call('SMEMBERS', KEYS[1])
for i, member in ipairs(members) do
    redis.call('DEL', member)
end

返回值

lua 复制代码
-- 返回单个值
return 0
return redis.call('EXISTS', KEYS[1])  -- 直接把 Redis 命令的返回值返回

-- 返回数组(Java 端用 List<Long> 接收)
local result = {}
result[1] = 100
result[2] = 200
return result

写 Lua 脚本的几个习惯

  1. 所有变量用 local ------不加 local 就是全局变量,多个脚本之间可能互相覆盖
  2. 脚本保持短小------控制在几十行内。长逻辑放 Java 里处理,Lua 里只放需要原子性的那几行
  3. 用不同的返回值区分不同的失败原因 ------别所有异常都 return -1,像上面关注脚本用 -1/-2/-3 各自对应不同的情况
  4. 数组下标从 1 开始 ------Lua 的习惯,KEYS[1] 是第一个元素,KEYS[0] 是 nil
  5. 测试的时候直接用 redis-cli --eval ------不用重启 Java,redis-cli --eval script.lua key1 key2 , arg1 arg2 直接跑
相关推荐
凤凰院凶涛QAQ1 小时前
《Java版数据结构 & 集合类剖析》栈与队列:“push/pop 是栈的灵魂,offer/poll 是队列的骨架——四组 API,两种人生”
java·开发语言·数据结构
掘金_答案2 小时前
上线那天,一个 ConcurrentHashMap 差点送走我的 AI 客服——3 天排查 JVM 血泪史
java·后端·架构
猿与禅3 小时前
CosId 分布式 ID 生成器完全教程:从架构原理到生产落地
java·shardingsphere·雪花算法·分布式id·高性能·cosid·号段模式
MindUp3 小时前
企业网盘权限模型解析:多层级访问控制与审计能力选型指南
java·linux·运维
NG4773 小时前
【软件设计与体系结构】-策略设计模式
java·设计模式·软件工程
程序员天天困3 小时前
优雅记录操作日志:从注解到 SpEL 的全链路实践与开源方案对比
java·后端
Leighteen4 小时前
Redis与MySQL双写一致性:从问题根源到工程落地
数据库·redis·mysql
战血石LoveYY4 小时前
Integer类型超限,导致数据异常
java·算法
乐观的Terry5 小时前
1、为什么要自己造发布系统
java·spring boot·后端·spring cloud·架构
兰令水5 小时前
hot100【acm版】【2026.7.13打卡-java版本】
java·开发语言·数据结构·算法·leetcode·面试