欢迎光临
我们一直在努力

别在 Java 里写三次 Redis 调用了!把复杂逻辑交给 Lua “存储过程”

在这里插入图片描述
你在做一个电商系统的“秒杀扣库存”功能。
你的 Java 代码逻辑:

  • int stock = redis.get("sku:1001:stock"); (获取库存)
  • if (stock > 0) { redis.decr("sku:1001:stock"); } (库存大于0则扣减)
  • 灾难降临:
    这两步操作之间有网络延迟,并且不是原子的。
    当库存只剩 1 个时,100 个并发线程同时执行到了第 1 步,都发现 stock = 1 > 0,于是全部执行了第 2 步。
    结果: 库存变成了 -99,超卖了 99 件商品,老板连夜找你谈话。
    破局之道:
    使用 Redis Lua 脚本。将“查库存 + 判断 + 扣减”打包成一段脚本发给 Redis。因为 Redis 执行 Lua 脚本是单线程且排他的,这 100 个请求只能乖乖排队一个个执行,完美解决超卖问题。


    1. 核心原理:为什么是 Lua?

    Redis 官方内置了 Lua 解析器。它能带来两大无可替代的优势:

  • 绝对的原子性 (Atomicity): Redis 会将整个 Lua 脚本作为一个整体执行。在脚本运行期间,Redis 不会执行其他任何客户端的命令。不用加任何分布式锁,天然防止并发冲突。
  • 减少网络开销 (Reduce Network Overhead): 原本需要 3 次请求(请求头+网络传输+解析)的操作,现在合并成 1 次请求发送给 Redis,极大地降低了网络延迟。

  • 2. 从“菜鸟”到“老鸟”:EVAL vs SCRIPT LOAD

    执行 Lua 脚本有两个命令,代表了两种不同的段位。

    菜鸟写法:每次都传整个脚本 (EVAL)

    # 语法:EVAL script numkeys key [key …] arg [arg …]
    EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 mylock myrandomvalue

    • 痛点: 你的业务逻辑脚本可能长达上百行(几 KB)。如果秒杀时 QPS 是 10万,你每秒钟都要在网络上传输 10万次 * 几 KB 的一段重复字符串,这会瞬间打满服务器的网卡带宽。
    老鸟写法:脚本预加载 (SCRIPT LOAD + EVALSHA)

    为了解决带宽浪费,Redis 提供了“缓存脚本”的机制。

    第一步:预加载脚本 (SCRIPT LOAD)
    在应用启动时(或者首次执行时),把长达百行的 Lua 脚本发送给 Redis。

    SCRIPT LOAD "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"

    Redis 编译这段代码,把它缓存在内存中,并返回一个 40 位的 SHA1 摘要(Hash 值),例如:b8059ba43fd6bb4979e8eb3c1a84c8fb23eabcc1。

    第二步:通过 Hash 值执行 (EVALSHA)
    真正的高并发请求到来时,客户端只需要发送那个短小精悍的 SHA1 值即可!

    EVALSHA b8059ba43fd6bb4979e8eb3c1a84c8fb23eabcc1 1 mylock myrandomvalue

    • 收益: 网络传输量从几 KB 骤降到只有 40 个字节,完美榨干网卡性能。

    3. 生产环境的“兜底逻辑” (NOSCRIPT 错误)

    脚本预加载非常棒,但有一个坑:Redis 重启,或者执行了 SCRIPT FLUSH,缓存的脚本会丢失。
    此时如果你继续用 EVALSHA 去请求,Redis 会报错:NOSCRIPT No matching script. Please use EVAL.

    标准工业级代码的执行流水线:

  • 客户端带着 SHA1 尝试执行 EVALSHA。
  • 如果成功,直接返回。
  • 如果捕获到 NOSCRIPT 异常,客户端立刻降级,使用原本的长脚本执行一次 EVAL。
  • EVAL 执行成功的同时,Redis 会自动将该脚本重新缓存。后续的请求就又能开心地用 EVALSHA 了。
    (注:主流的 Redis 客户端,如 Java 的 Redisson/Jedis,Go 的 go-redis,底层已经自动帮你封装了这个“EVALSHA 失败后降级 EVAL”的逻辑。)

  • 4. 三大高频实战场景与 Lua 源码

    场景一:分布式锁的原子释放 (Check-and-Delete)

    痛点: 释放锁时,必须判断“这把锁是不是我自己加的”(防止误删别人的锁)。查锁和删锁必须原子化。
    Lua 脚本:

    — KEYS[1]: 锁的 key
    — ARGV[1]: 当前线程的 UUID
    if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
    else
    return 0
    end

    场景二:极速秒杀扣库存 (Check-and-Deduct)

    痛点: 高并发下判断库存是否大于购买量,够才扣除。
    Lua 脚本:

    — KEYS[1]: 库存 key
    — ARGV[1]: 需要扣减的数量
    local stock = tonumber(redis.call('GET', KEYS[1]))
    local num = tonumber(ARGV[1])

    if (stock == nil) then
    return 1 — 商品不存在
    end

    if (stock >= num) then
    redis.call('DECRBY', KEYS[1], num)
    return 1 — 扣减成功
    else
    return 0 — 库存不足
    end

    场景三:滑动窗口限流 (Sliding Window Rate Limiter)

    痛点: 限制某个 IP 在 60 秒内只能访问 100 次,使用 ZSet 记录时间戳,清理旧数据和统计当前数据必须原子完成。
    Lua 脚本:

    — KEYS[1]: rate_limit:ip:192.168.1.1
    — ARGV[1]: 当前时间戳 (毫秒)
    — ARGV[2]: 窗口大小 (例如 60000 毫秒)
    — ARGV[3]: 限制次数 (例如 100)
    local current_time = tonumber(ARGV[1])
    local window_size = tonumber(ARGV[2])
    local max_requests = tonumber(ARGV[3])

    local window_start = current_time window_size

    — 1. 清除窗口外的旧数据
    redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)

    — 2. 统计当前窗口内的请求数
    local current_requests = redis.call('ZCARD', KEYS[1])

    if current_requests < max_requests then
    — 3. 未超限,记录本次请求 (Member可以用唯一UUID防重叠,Score用时间戳)
    — 这里简单用时间戳拼接一个随机后缀作为 Member
    redis.call('ZADD', KEYS[1], current_time, current_time .. tostring(current_requests))
    — 设置一下整体过期时间,防止冷数据占用内存
    redis.call('PEXPIRE', KEYS[1], window_size)
    return 1 — 放行
    else
    return 0 — 限流
    end


    5. 避坑指南:Lua 脚本的致命红线

  • 绝对不要写慢脚本!
    由于 Redis 单线程执行 Lua,如果你的脚本里有一个死循环,或者涉及大量的 keys 遍历(耗时超过毫秒级),整个 Redis 节点会被死死卡住,任何其他的 GET/SET 命令都进不来!
  • 避免使用外部依赖和随机状态。
    在 Redis 5.0 之前(按脚本复制模式),如果你的脚本里调用了获取系统时间的函数,或者生成了随机数,会导致主从节点执行结果不一致。虽然 5.0 之后引入了“效果复制”(只复制脚本引起的写命令),但尽量让脚本保持**纯函数(确定性)**依然是好习惯。
  • 合理区分 KEYS 和 ARGV。
    在集群模式(Redis Cluster)下,Redis 必须在解析命令时就知道这条脚本会操作哪些 Key,以便进行路由。所以,所有会被脚本操作的 Key,必须明确放到 KEYS 数组里传进去,绝对不能在脚本里面硬编码拼装 Key。
  • 赞(0)
    未经允许不得转载:171主机测评 » 别在 Java 里写三次 Redis 调用了!把复杂逻辑交给 Lua “存储过程”
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址