云计算百科
云计算领域专业知识百科平台

Redis 学习笔记(四):Lua 脚本

Lua 脚本是 Redis 原子性的灵魂。为什么 Redis 的事务不靠谱?为什么限流、分布式锁、防超卖都要靠 Lua?这篇把 Lua 在 Redis 里的作用讲透。


一、为什么要用 Lua 脚本?

1.1 先看一个真实痛点

假设你要做"限流"——控制某个接口 1 秒内最多被调用 10 次。用 Redis 实现:

1. 用 INCR 给计数器加1
2. 判断加完的值是否超过10

这两个操作,如果分开执行:

服务A:INCR counter → 得到11
服务A:准备判断 11 > 10(但还没判断)
服务B:INCR counter → 得到12
服务A:判断 11 > 10,返回"超限"
服务B:判断 12 > 10,返回"超限"

好像没问题?但如果要求是原子的——“加1和判断必须在同一瞬间完成,不能被其他请求插队”——单条命令没问题,但多条命令之间就可能被插队,结果就错了。

1.2 Redis 事务(MULTI)为什么不靠谱

Redis 有事务命令 MULTI / EXEC,但它的"事务"和我们理解的不一样:

  • Redis 事务只能保证命令按顺序执行不被插队(不会像多线程那样穿插)
  • 但不支持回滚!如果中间某条命令执行失败,前面成功的不会撤销

比如:

MULTI
INCR counter # 成功
DECR non_exist # 报错(key类型不对)
EXEC
# 结果是:counter 已经+1了,但不会回滚

所以在 Redis 里,靠事务做"要么全部成功要么全部失败"是不可靠的。

1.3 Lua 脚本才是正解

Lua 脚本是一个整体发送给 Redis,Redis 会一次性执行完整个脚本,执行期间不会被其他请求打断——天然原子。

服务A:执行整个Lua脚本(内部先INCR再判断)
↓ 脚本执行期间,Redis不处理任何其他请求
服务B:等待
服务A:脚本执行完,得到结果

Lua 脚本执行的特性:

  • 原子性 — 整个脚本一次性执行,中间不穿插其他命令
  • 减少网络开销 — 原本要发多条命令,现在一条脚本搞定
  • 可复用 — 脚本可以提前加载,反复调用
  • 这就是为什么 Redis 的限流、分布式锁、防超卖这些需要"多步原子操作"的场景,全部用 Lua 脚本实现。


    二、Lua 脚本基础语法

    2.1 最简单的 Lua

    Lua 脚本本身语法很简单,先看几个基础点:

    — 这是注释

    — 定义变量
    local name = "小明"

    — 条件判断
    if name == "小明" then
    return "认识你"
    else
    return "不认识"
    end

    2.2 在 Redis 里执行

    用 EVAL 命令执行 Lua 脚本:

    EVAL "return 'hello redis'" 0
    # 返回:hello redis

    0 表示后面没有 KEYS 参数。

    2.3 在 Lua 里调用 Redis 命令

    用 redis.call() 调用 Redis 命令:

    EVAL "return redis.call('set', 'name', '小明')" 0
    # 执行了 SET name 小明

    EVAL "return redis.call('get', 'name')" 0
    # 返回:小明

    也可以在脚本里组合多条命令,实现原子操作:

    EVAL "
    redis.call('set', 'count', 0)
    redis.call('incr', 'count')
    redis.call('incr', 'count')
    return redis.call('get', 'count')
    " 0
    # 返回:2

    整个脚本一次性执行完,中间任何请求都无法插队。


    二-补充、Lua 脚本里的数据类型

    写 Redis Lua 脚本时,要注意 Lua 和 Redis 之间数据类型的转换,这是新手经常踩坑的地方。

    Redis 返回给 Lua 的类型

    Redis 返回Lua 中变成
    整数 number
    字符串 string
    nil(不存在) false
    错误 抛异常(可用 pcall 捕获)

    特别注意:Redis 的 nil(key不存在)在 Lua 里是 false。 如果你直接对 false 做算术运算会报错,要判断一下:

    — 错误的写法:key不存在时 redis.call('get',…) 返回 false
    — local stock = tonumber(redis.call('get', KEYS[1])) — 如果key不存在,tonumber(false)报错

    — 正确的写法:先判断是否存在
    local stock = redis.call('get', KEYS[1])
    if stock == false or stock == nil then
    stock = 0 — 给个默认值
    else
    stock = tonumber(stock)
    end

    Lua 返回给 Redis 的类型

    Lua 返回Redis 结果
    number 整数
    string 字符串
    false/nil nil
    table 数组

    常见坑: 脚本里对字符串做算术运算,要用 tonumber() 转成数字,否则会报错或得到错误结果:

    local count = redis.call('incr', KEYS[1])
    — incr 返回的是 number,可以直接比
    if count > tonumber(ARGV[1]) then
    return 0
    end

    如果 redis.call 返回的是字符串(比如 get),一定要先 tonumber() 再比较。


    三、KEYS 和 ARGV 参数

    3.1 为什么要传参数

    直接把 key 写死在脚本里,每次都要重新写脚本,不方便。更关键的是,Redis 主从复制和集群模式下,要求脚本里的 key 通过参数传进去,Redis 才能知道操作了哪些 key(集群才能路由到正确的节点)。

    标准写法是把 key 用 KEYS[] 引用,参数用 ARGV[] 引用。

    3.2 EVAL 传参

    EVAL "
    redis.call('set', KEYS[1], ARGV[1])
    return redis.call('get', KEYS[1])
    " 1 mykey "小明"

    拆解:

    • 1 表示有1个 key 参数
    • mykey 传给 KEYS[1]
    • "小明" 传给 ARGV[1]

    下标从1开始,不是0! 这是 Lua 和很多语言不一样的地方,别搞错。

    3.3 多个 key 和参数

    EVAL "
    redis.call('set', KEYS[1], ARGV[1])
    redis.call('set', KEYS[2], ARGV[2])
    return redis.call('mget', KEYS[1], KEYS[2])
    " 2 key1 key2 val1 val2
    # KEYS[1]=key1 KEYS[2]=key2 ARGV[1]=val1 ARGV[2]=val2


    四、经典应用:分布式锁的原子释放

    上一章分布式锁里提到过:释放锁要"先判断是不是自己的锁,再删除",这两步必须原子。

    用 Lua 脚本实现:

    — 判断锁的值是不是自己的,是才删除
    if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
    else
    return 0
    end

    为什么必须用 Lua? 因为"get 比较 + del 删除"是两个操作,如果分开执行:

    服务A get lock → 得到自己的值 → 准备del
    (这时lock过期,服务B拿到锁,值变成B的)
    服务A del lock → 把服务B的锁删了!

    用 Lua 把比较和删除包在一个脚本里,Redis 一次性原子执行,中间不可能被插队,就不会误删。

    在 Java 里执行:

    DefaultRedisScript<Long> script = new DefaultRedisScript<>(
    "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
    Long.class);

    Long result = redisTemplate.execute(
    script,
    Arrays.asList("lock"), // KEYS[1]
    myValue // ARGV[1]
    );


    五、经典应用:限流(令牌桶)

    限流是 Lua 的经典应用。控制某个 key 在一段时间窗口内的访问次数。

    5.1 思路

    用"滑动窗口"思路:每次请求来,给当前时间戳对应的计数加1,同时清理掉窗口之外的老计数,判断窗口内总数是否超限。

    用 Redis 实现最简单的方式——固定时间窗口:每个单位时间一个 key,过期自动清理。

    — 简单限流:每分钟最多N次
    — KEYS[1] = 限流的key(如 user:9527:api)
    — ARGV[1] = 窗口秒数
    — ARGV[2] = 最大次数

    — 计数器加1
    local current = redis.call('incr', KEYS[1])

    — 第一次访问,设置过期时间(窗口)
    if current == 1 then
    redis.call('expire', KEYS[1], ARGV[1])
    end

    — 判断是否超限
    if current > tonumber(ARGV[2]) then
    return 0 — 拒绝
    else
    return 1 — 放行
    end

    这个脚本完整实现了"1秒内最多N次"的限流,而且因为整个脚本原子执行,不会并发出错。

    为什么要用 Lua 而不是分开命令? 因为"incr 计数 + expire 设过期 + 判断超限"要保证原子性,分开写在高并发下会算出错误结果。

    5.2 更精确的令牌桶

    Redis 官方提供的令牌桶限流脚本(完整版),用 ZSET 记录请求时间戳:

    — KEYS[1] = 限流key
    — ARGV[1] = 窗口毫秒数
    — ARGV[2] = 最大请求数

    — 当前时间
    local now = redis.call('TIME')[1] * 1000
    — 窗口起始时间
    local window_start = now – tonumber(ARGV[1])

    — 清理窗口外的过期请求
    redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)
    — 加入当前请求
    redis.call('ZADD', KEYS[1], now, now)
    — 统计窗口内请求数
    local count = redis.call('ZCARD', KEYS[1])

    if count > tonumber(ARGV[2]) then
    return 0
    else
    — 设置过期时间,防止ZSET无限增长
    redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]) / 1000)
    return 1
    end

    这是生产级限流的经典写法,靠 ZSET 按时间戳排序,滑动窗口统计。


    六、经典应用:防超卖

    秒杀场景的核心问题——多个请求同时扣库存,怎么保证不超卖?

    用 Lua 脚本原子扣库存:

    — 原子扣减库存,防止超卖
    — KEYS[1] = 库存key
    — ARGV[1] = 扣减数量

    — 当前库存
    local stock = tonumber(redis.call('get', KEYS[1]))

    — 库存不够,返回-1表示失败
    if stock < tonumber(ARGV[1]) then
    return –1
    end

    — 扣减库存
    redis.call('decrby', KEYS[1], ARGV[1])
    — 返回扣减后的库存
    return stock – tonumber(ARGV[1])

    为什么能防超卖? 因为"get 读库存 → 判断够不够 → decrby 扣减"这三步在 Lua 脚本里是原子的——同一时刻只有一个请求在执行这个脚本,其他请求都在等待。不会有两个人同时读到"还有1个库存"然后都扣成功。

    如果分开写(先 get 再 decrby):

    服务A get → 库存1
    服务B get → 库存1 ← 并发读到同样
    服务A decrby → 库存0
    服务B decrby → 库存-1 ← 超卖了!

    这就是秒杀系统必须用 Lua 的原因。


    七、在代码里执行 Lua(Java/Python)

    Java (Spring Data Redis)

    // 定义脚本
    DefaultRedisScript<Long> stockScript = new DefaultRedisScript<>();
    stockScript.setResultType(Long.class);
    stockScript.setScriptText(
    "local stock = tonumber(redis.call('get', KEYS[1])) " +
    "if stock < tonumber(ARGV[1]) then return -1 end " +
    "redis.call('decrby', KEYS[1], ARGV[1]) " +
    "return stock – tonumber(ARGV[1])");

    // 执行
    Long result = redisTemplate.execute(
    stockScript,
    Arrays.asList("stock:1001"), // KEYS[1]
    1L // ARGV[1] 扣减数量
    );

    if (result >= 0) {
    System.out.println("扣减成功,剩余库存:" + result);
    } else {
    System.out.println("库存不足");
    }

    Python (redis-py)

    import redis

    r = redis.Redis(decode_responses=True)

    # 脚本:原子扣库存
    script = """
    local stock = tonumber(redis.call('get', KEYS[1]))
    if stock < tonumber(ARGV[1]) then
    return -1
    end
    redis.call('decrby', KEYS[1], ARGV[1])
    return stock – tonumber(ARGV[1])
    """

    # 注册脚本,返回一个可调用的对象
    deduct = r.register_script(script)

    # 调用:KEYS=['stock:1001'], ARGV=[1]
    result = deduct(keys=["stock:1001"], args=[1])

    if result >= 0:
    print(f"扣减成功,剩余库存:{result}")
    else:
    print("库存不足")


    八、Lua 脚本的注意事项

    8.1 脚本要尽量短

    Lua 脚本执行期间,Redis 不处理其他请求(单线程)。如果脚本太复杂、太耗时,会阻塞整个 Redis,影响所有请求。

    原则:脚本只做必要的原子操作,复杂的业务逻辑放应用层做。

    8.2 不能用不确定的命令

    脚本里不能用会产生随机结果的命令(比如 TIME、RANDOMKEY),否则主从复制时数据会不一致。

    8.3 集群模式下 key 要在同一个 slot

    集群模式下,脚本操作的所有 key 必须落在同一个哈希槽(slot),否则报错。这就是为什么要用 KEYS[] 传参数。


    本篇小结

    • Lua 脚本让 Redis 多条命令原子执行,中间不会被其他请求插队
    • Redis 事务不支持回滚,Lua 才是真正的原子方案
    • 用 redis.call() 在脚本里调 Redis 命令,KEYS[] 和 ARGV[] 传参数(下标从1开始)
    • 分布式锁释放、限流、防超卖,三大经典场景全靠 Lua 保证原子性
    • 脚本要短,别阻塞 Redis;集群下 key 要在一个 slot
    • 下一篇讲 Redis 高可用:主从复制和哨兵集群
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Redis 学习笔记(四):Lua 脚本
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!