4.2 Lua脚本:把多条命令焊成一条


文档摘要

4.2 Lua脚本:把多条命令焊成一条 本节摘要:EVAL 把一段 Lua 脚本送进服务端执行,整段脚本在主线程里作为一条命令运行——读、判断、写在同一个原子单元里完成。它是条件扣减类并发问题的终极解法,代价是脚本必须短小,否则阻塞全世界。 为什么非它不可 4.1 的 WATCH 方案在五千并发抢一百件库存时,几乎人人冲突、人人重试,吞吐崩塌。根因是判断在客户端:读到值、走网络、回来写,判断与写入之间隔着一次往返,窗口里世界早变了。Lua 的解法是把判断搬进服务端: 脚本里的 redis.call 顺序执行,主线程在脚本返回前不接受任何其他命令——判断与写入之间不再有窗口,超卖在结构上不可能发生。 两种方案的竞争窗口 两种方案的竞争窗口 两个工程要点 脚本缓存。

4.2 Lua脚本:把多条命令焊成一条

本节摘要:EVAL 把一段 Lua 脚本送进服务端执行,整段脚本在主线程里作为一条命令运行——读、判断、写在同一个原子单元里完成。它是条件扣减类并发问题的终极解法,代价是脚本必须短小,否则阻塞全世界。

为什么非它不可

4.1 的 WATCH 方案在五千并发抢一百件库存时,几乎人人冲突、人人重试,吞吐崩塌。根因是判断在客户端:读到值、走网络、回来写,判断与写入之间隔着一次往返,窗口里世界早变了。Lua 的解法是把判断搬进服务端:

-- KEYS[1] 库存键 ARGV[1] 买家 local stock = tonumber(redis.call('GET', KEYS[1]) or '0') if stock <= 0 then return -1 -- 售罄 end redis.call('DECR', KEYS[1]) redis.call('SADD', KEYS[2], ARGV[1]) -- 记录买家 return stock - 1
> EVAL "脚本内容" 2 stock:1001 buyers:1001 user:88 (integer) 99

脚本里的 redis.call 顺序执行,主线程在脚本返回前不接受任何其他命令——判断与写入之间不再有窗口,超卖在结构上不可能发生。

两种方案的竞争窗口

两种方案的竞争窗口

两个工程要点

脚本缓存。每次传脚本全文浪费带宽,惯例是 SCRIPT LOAD 加 EVALSHA:

> SCRIPT LOAD "local s=... " # 返回 sha1 "a5260dd66ce02462c5b52..." > EVALSHA a5260dd66ce02462c5b52... 2 stock:1001 buyers:1001 user:88

集群模式下每个节点各有一份脚本缓存,客户端库通常自动处理"没缓存就退回 EVAL"。

脚本要短。主线程执行脚本期间所有请求排队,一个循环一亿次的脚本等于把整个 Redis 按暂停键。写脚本的纪律:不做循环大扫描、不操作海量键、毫秒级返回。慢查询日志里出现 EVAL 开头的条目,优先怀疑脚本写肥了。

⚠️ 常见坑:脚本内写日志、做重活、调用随机命令后再写键(随机性导致主从结果不一致,这类写法会被拒绝)都是雷区。脚本只做"读少量键、算一步、写少量键"。

两个高频报错展开说。NOSCRIPT No matching script:EVALSHA 找不到缓存时抛出,标准处理是捕获后退回 EVAL 全文重发,主流客户端库把这个回退做成了内置行为;自研网关要把这段逻辑补上,否则脚本缓存被 SCRIPT FLUSH 清空后(比如故障重启),所有请求齐刷刷报错。Write commands not allowed after non deterministic commands:脚本里先调了 TIME、SRANDMEMBER 这类随机命令,后面又写键,服务端直接拒绝——因为主从各自执行脚本时随机值不同,复制流就分叉了。绕开的正路是把随机数作为参数从客户端传进 ARGV,脚本内部保持纯粹确定性。

还有一条集群专属纪律:脚本里访问的所有键必须传进 KEYS 数组、且落在同一个槽上(配合 7.3 的 hash tag),直接在脚本里拼键名访问是集群模式下的头号报错来源——服务端没法校验没声明过的键,也保证不了它们同槽。

三个高频脚本模式

-- 限流:每秒最多 N 次,滑动窗口计数 local c = tonumber(redis.call('GET', KEYS[1]) or '0') if c >= tonumber(ARGV[1]) then return 0 end redis.call('INCR', KEYS[1]) redis.call('EXPIRE', KEYS[1], 1) return 1
-- 分布式锁安全释放:只有持锁人能删锁 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0

这两个模式加上秒杀脚本,覆盖了 Lua 在生产里的九成用途。共同点一目了然:全部是"先看条件再决定写不写"——这正是事务做不了、脚本一句话搞定的事。

把分布式锁这个最经典的组合完整走一遍。背景:三个服务实例抢同一把锁,谁持锁谁干活,要求"只有持锁人能释放";操作分三步:

# 第一步:抢锁。值用唯一标识(实例id加随机数),NX加EX必须一条命令完成 > SET lock:job1 "host-a:78f2" NX EX 10 OK # 第二步:释放。比对"锁还是我的"再删,两步必须焊成一个脚本 > EVAL "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end" 1 lock:job1 host-a:78f2 (integer) 1 # 第三步:长任务的保险。到期前续期,同样是比对后续命 > EVAL "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('PEXPIRE',KEYS[1],ARGV[2]) else return 0 end" 1 lock:job1 host-a:78f2 10000 (integer) 1

解读:SET NX EX 把"检查不存在"与"写入并设 TTL"合成一条命令,天然原子;释放与续期都是"读、比对、写"三步,必须靠脚本焊住,否则比对完到删除之间的窗口里锁可能已易主,删掉的就是别人的锁。变式与边界:持锁方崩溃后锁要等 TTL 自然过期,任务会出现短暂无主——对"宁可有空窗不可双跑"的场景这套就够;要"无空窗"就得引入看门狗续期线程(拿到锁后定时续命,进程退出续命自然停止),客户端库的锁实现基本都长这个样子。锁值必须全局唯一,释放脚本才认得出"是不是我的锁",写死成 1 的话,任何客户端都能解锁。

本节要点回顾

  • 脚本即命令:整段 Lua 在主线程原子执行,天然隔离
  • 把判断搬进服务端是它解决竞争的本质,不是什么魔法
  • EVALSHA 加 SCRIPT LOAD 省带宽,集群下注意各节点缓存
  • 脚本必须短,否则阻塞所有客户端
  • 典型用途:条件扣减、限流、锁的安全释放

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U