8.3 Redis:EVAL 原子脚本现场 本节摘要:Redis 在 2.6 版把 Lua 5.1 关进服务进程:EVAL 命令收一段脚本与两组参数(KEYS 与 ARGV),脚本经 redis.call 操作数据,整段执行期间单线程不处理任何其它命令——多步操作由此获得免锁的原子性,多条命令的网络往返也压缩成一次。本节写出限流、库存扣减、标签去重三个参考实现,讲清原子窗口的由来、超时处理与脚本缓存。 单线程的房契 先看没有脚本时代的痛。实现"扣减库存、不足即拒"需要两步:先读库存、再判断扣减。两条命令分开发,客户端与服务器之间隔着网络——两步之间别的客户端可以插队:A 读到剩 1,B 也读到剩 1,双双判断"够扣",超卖发生。
本节摘要:Redis 在 2.6 版把 Lua 5.1 关进服务进程:EVAL 命令收一段脚本与两组参数(KEYS 与 ARGV),脚本经 redis.call 操作数据,整段执行期间单线程不处理任何其它命令——多步操作由此获得免锁的原子性,多条命令的网络往返也压缩成一次。本节写出限流、库存扣减、标签去重三个参考实现,讲清原子窗口的由来、超时处理与脚本缓存。
先看没有脚本时代的痛。实现"扣减库存、不足即拒"需要两步:先读库存、再判断扣减。两条命令分开发,客户端与服务器之间隔着网络——两步之间别的客户端可以插队:A 读到剩 1,B 也读到剩 1,双双判断"够扣",超卖发生。经典解法有事务(MULTI/EXEC),但事务里不能"读了再判断"(命令入队时就要定死);乐观锁(WATCH)能做,高冲突下重试风暴;专用命令(DECR 配负值回滚)能凑,逻辑一复杂就拼不动。
Redis 的答案是把判断逻辑搬进服务端:让一段程序在两步之间不被人插队。它把 Lua 解释器嵌进服务进程,给了它一份史上最严的房契(第 6.3 节清单的现场核对):
redis.call 出错即停、redis.pcall 出错返回错误对象(第 6.2 节两形态的移植);
"可重放"值得多说两句。主从复制时,主库执行了脚本、把脚本文本加参数发给从库重放。若脚本里读了系统时间或真随机数,主从结果就会劈开——所以房契禁了 os.time 与带种子随机(后来的版本改为每脚本自动重置种子,文档仍告诫慎用)。集群模式再加一条:脚本要碰的键必须在 KEYS 里显式声明,宿主据此把脚本路由到管这些键的分片——KEYS 与 ARGV 的区分(键名与普通参数分开传)不是语法洁癖,是路由协议。
EVAL "return redis.call('GET', KEYS[1])" 1 user:1001:name
四个部件:脚本文本、键个数(1)、键名列表、参数列表。脚本侧的视角:
-- 脚本收到的世界 -- KEYS[1] == "user:1001:name" -- ARGV == {} -- redis == 宿主注入的能力表 local val = redis.call("GET", KEYS[1]) return val
返回值按类型映射回客户端:数字回整数、字符串回批量串、表回数组、nil 回空、table 带 err 字段回错误响应(第 6.2 节表错误的现场应用)。redis.call 的第一个参数就是普通命令名,参数照抄命令行——脚本即"用 Lua 写的多命令组合",学习成本几乎为零。
实现一:固定窗口限流。计数器加过期,一条脚本的原子读改写:
-- EVAL "脚本" 1 ratelimit:user:1001 60 100 -- KEYS[1]=计数键 ARGV[1]=窗口秒 ARGV[2]=上限 local key = KEYS[1] local window = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local current = tonumber(redis.call("GET", key) or "0") -- 1.3节的nil防御 if current + 1 > limit then return { rejected = true, count = current } -- 拒:带当前计数 end redis.call("INCR", key) if current == 0 then redis.call("EXPIRE", key, window) -- 首次进入窗口才设过期 end return { rejected = false, count = current + 1 }
INCR 与 EXPIRE 两步在窗口内原子完成——外部客户端方案里"INCR 后崩溃没设 EXPIRE 导致永久计数"的经典缺陷被窗口直接消灭。返回表带语义字段,客户端拿到结构化结果(RESP 数组)。
实现二:库存扣减(秒杀):
-- KEYS[1]=库存键 ARGV[1]=扣减数量 local stock = tonumber(redis.call("GET", KEYS[1]) or "0") local want = tonumber(ARGV[1]) if stock < want then return { ok = false, reason = "insufficient", stock = stock } end redis.call("DECRBY", KEYS[1], want) return { ok = true, left = stock - want }
读、判、扣三步一气呵成,超卖在窗口内不可能发生。把"订单流水写入"也并进同一脚本(LPUSH 一条记录)就得到完整的秒杀原子单元——窗口内的步数没有限制,限制只有时长。
实现三:标签集合去重统计:
-- KEYS[1]=用户标签集合 KEYS[2]=标签热度计数 KEYS[3..]=本次标签列表 local user = KEYS[1] local hot = KEYS[2] local added = 0 for i = 3, #KEYS do local isNew = redis.call("SADD", user, KEYS[i]) -- 新元素返回1 if isNew == 1 then added = added + 1 redis.call("ZINCRBY", hot, 1, KEYS[i]) -- 热度加一 end end return added
循环里逐个判断加更新,多个键的协调在一扇原子窗内完成——这是 WATCH 重试方案做不到的顺滑(集合操作的返回值当场用作分支条件)。注意集群模式下这个脚本碰两个键,键 hash 必须同分片(用 hash tag 把键名括起来),房契的路由条款在此显形。
SCRIPT LOAD 与 EVALSHA。每次 EVAL 都传完整脚本文本浪费带宽——SCRIPT LOAD 把脚本收进服务端缓存、返回一个 SHA1 摘要;之后 EVALSHA 只传摘要加参数,命中即执行。客户端库的常规策略:先 EVALSHA,报 "NOSCRIPT" 错误再 fallback 到 EVAL——缓存未命中自动降级,两行重试逻辑换常驻带宽节省。第 6.1 节 require 缓存的思路在协议层的镜像。
busy script 与超时。脚本跑太久,整个服务被一个人占着。Redis 的处置:超过时限(默认数秒级,可配)后服务器进入"脚本忙"状态——只响应有限的几个命令,SCRIPT KILL 是唯一温柔的退出(脚本尚未写数据时可用);若脚本已经写过数据,KILL 无法保证一致性,只剩 SHUTDOWN NOSAVE 一条路。这是第 6.3 节"第四道预算防线"的原生实现:宿主对脚本的时间维度有硬性执法。写脚本的纪律随之而来:不写大循环、不做全量扫描(KEYS 命令在脚本里等于自杀)、分支尽量早退——你在别人的单线程里做客。
⚠️ 常见坑四枚。其一,键名硬编码在脚本里不走 KEYS:单机模式能跑,集群模式直接路由错误——键必须从 KEYS 来。其二,脚本里用 math.random 想做抽样:可重放性被破坏,主从劈开(要"随机"就用参数把种子从客户端传进来)。其三,返回值类型想当然:数字回整数没问题,浮点会被截断成整数返回——小数结果转字符串回。其四,pcall 吞错继续跑:redis.pcall 拿到错误对象后继续执行后续写命令,可能留下半套状态——出错就该让它停(或显式补偿)。
💡 关键直觉:Redis 的脚本不是"扩展 Redis 的编程语言",是"把客户端的判断力搬进原子窗"。写每段脚本前问一句:这几步命令之间,怕不怕别人插队?怕,就进脚本;不怕,普通命令更简单。
redis.pcall 与 redis.call 的唯一差别:出错不炸脚本、返回带 err 字段的表对象(第 6.2 节表错误的协议版)。它的正确用途是可预见的失败要补偿——比如"目标键类型不对时先清理再重试":
-- 安全写入:类型冲突时清掉重建 local function safe_hset(key, map) local t = redis.call("TYPE", key) if t ~= "hash" and t ~= "none" then redis.call("DEL", key) -- 类型不对,清场重来 end for field, v in pairs(map) do redis.call("HSET", key, field, v) end end
错误用法是把它当 try-catch 吞一切:pcall 到错误后继续跑后面的写命令,可能留下半套状态(8.3 节坑四)。pcall 的语义边界要清楚:预期内的失败(类型、不存在)可以补偿,预期外的失败应该让脚本停——停了主库不落、复制不传,一致性还在;继续跑则一半生效一半没生效,排障地狱。
Redis 7 引入 FUNCTIONS 家族,把"脚本"升级为"有名字、有库、可管理的函数":FUNCTION LOAD 声明函数库(含注册的函数名),调用走 FCALL 名字加参数——脚本的版本管理、依赖共享(多个函数共用一个库的辅助函数)、权限分片都由此改善。EVAL 并未废弃,两者定位不同:EVAL 是一次性原子窗,FUNCTION 是常驻函数目录。从寄宿视角看,这是宿主在"脚本的房契"上开出的第二层楼——第一层(EVAL)按次计费,第二层(FUNCTION)按户口管理。学完本节的窗口模型,读新文档只是换门牌。
什么时候该把逻辑放进脚本、什么时候留在客户端?给一条稳定的判断线:
判断需要的中间数据在服务端(库存、计数、集合状态)——逻辑进脚本,省往返、保原子。判断只需要输入参数(格式校验、路由选择)——留在客户端,脚本窗口是稀缺资源,别用来做算术。介于两者之间(先查后判)——看频率与一致性要求:高频且要一致的进脚本,低频的拆两条命令加 WATCH 也行。这条线画清楚,脚本目录不会膨胀成一锅炖——每个脚本都应该能用一句话说出"我在保护哪段窗口",说不出的话它可能不配住在单线程里。
Redis 沙箱里没有 print、没有 io,脚本怎么调试?三层手段由浅入深。
返回值当探针。最朴素也最有效:把中间值塞进返回表,客户端直接看。开发期把脚本临时改成 return { step1 = stock, step2 = want, ... }——原子窗内的一切都能这样"照亮",看完再删。
SCRIPT DEBUG 会话。Redis 内置了脚本调试器(命令形态为 SCRIPT DEBUG yes/no/step),连上后可单步执行、打印局部变量——沙箱为调试专门开了个小门(调试会话是非阻塞模式,不影响其它客户端,但只允许只读命令,写了就断)。线上集群慎用,测试实例上排查逻辑问题的第一利器。
慢日志侧写。服务端的慢查询日志会记录执行超阈值的脚本(含源码与耗时)——上线后脚本性能退化的第一发现渠道。配合 INFO 的 commandstats(每命令调用数与累计耗时),脚本的运行画像不需要在脚本内埋任何点。三层合起来印证沙箱的设计立场:宿主把观测权留给自己,脚本只有数据没有旁路——安全与可调试并不对立,只要宿主肯提供窗口。
下一节看最小的家:路由器上几兆内存里的 Lua。