上一章谈 MULTI/EXEC 时,我们已经碰到一个边界:它能让一组命令连续执行,却不能让后面的命令根据前一条命令的结果临时选择分支。库存接口真正想表达的不是“把 GET 和 DECRBY 一起发出去”,而是“只有剩余库存不少于本次购买量,才允许扣减;相同订单重试时不能再扣一次”。这是一段小程序,不是一张静态命令清单。
如果把这段程序留在 Node.js 里,请求就得先读 Redis,把结果带回应用判断,再把写命令发回 Redis。网络往返把一个业务动作撕成了几段,另一个请求正好可以钻进这些缝隙。Lua 的价值就在这里:把短小、确定的读—判断—写逻辑送到数据旁边执行。脚本开始后,其他客户端不能把命令插进脚本中间;脚本结束后,Redis 再处理下一项工作。
先把两个容易混淆的词分开。这里的“原子”指执行期间不可穿插,不等于关系型数据库里的“报错后自动回滚”。Lua 能消掉 Redis 内部的竞态窗口,却不会替你完成订单数据库、支付系统与 Redis 之间的跨系统事务。我们这一章就沿着这条边界往下走:先修掉一场库存竞态,再把脚本做成可部署、可观察、能在故障后安全重试的业务接口。
假设 inventory:sku1001:stock 的值是 1。请求 A 和请求 B 几乎同时执行下面这段应用代码:
const stock = Number(await redis.get(stockKey));
if (stock >= quantity) {
await redis.decrby(stockKey, quantity);
return { ok: true };
}A 读到 1,B 也读到 1;两边的判断都通过,然后各扣一次。每一条 Redis 命令本身都没有执行到一半被打断,错误仍然发生了,因为原子命令之间的业务判断不原子。最后库存可能变成 -1,也可能因为代码又做了错误的“归零保护”而停在 0,但两张订单已经拿到了成功响应。
有人会想到 pipeline。它只能减少网络往返,不能阻止其他客户端的命令在管线中的两条命令之间执行。MULTI/EXEC 虽然能让命令队列连续执行,却不能在服务端读出 GET 的返回值后再决定要不要排入 DECRBY。WATCH 可以做乐观并发控制,但冲突时要回到应用层重试;抢购高峰中,同一个热点库存会让大量请求反复冲突。
Lua 改变的是判断发生的位置。A 的脚本读取库存、判断、扣减全部完成后,B 的脚本才能开始。B 读到的是 0,于是走库存不足分支。两次请求仍然都到达 Redis,只是再也没有“都看见旧库存”的缝。

下面的时序模拟器把库存固定为 1。先在“应用层分步执行”模式逐步推进 A、B,再切到“Lua 原子脚本”。留意第二个请求读取库存的时刻,而不是只看最后的数字。
Redis 所说的脚本原子执行,可以理解成整段脚本在命令执行队列里占一个连续时段。不要把它解释成“Lua 永远运行在某个固定线程上”,也不要推导出跨 Redis 节点、跨数据库的全局原子性。这个保证只覆盖脚本实际访问的 Redis 数据与本次执行。
一段生产脚本至少要回答四件事:它会碰哪些键,调用者传哪些普通参数,成功与失败如何编码,执行到哪个阶段才允许第一次写入。把这四件事先定下来,Lua 才不会变成藏在字符串里的随手代码。
EVAL、EVALSHA 都要求调用者先给出键的数量。前 numkeys 个参数进入 KEYS,其余进入 ARGV:
EVALSHA <sha1> 2 \
inventory:{sku1001}:stock \
inventory:{sku1001}:tokens \
2 order-20260819-001 86400这里 KEYS[1] 是库存键,KEYS[2] 是幂等结果键;ARGV[1] 是扣减数量,ARGV[2] 是请求 token,ARGV[3] 是 token 记录的保留秒数。不要把 SKU 拼进脚本文本,也不要把某个 ARGV 当键名再去访问。所有会被脚本访问的键都应由调用者明确声明。这样客户端才能路由 Cluster,请求参数变化也不会制造一份新的脚本摘要。
脚本文本应保持稳定,差异放在 KEYS 与 ARGV。如果应用每次把订单号直接拼入 Lua 源码,Redis 会把它们视为不同脚本,缓存里留下大量一次性内容。参数化既是接口设计,也是缓存控制。
redis.call('GET', key) 在 Redis 命令报错时会抛出 Lua 运行错误,脚本立刻停止,错误返回客户端。redis.pcall(...) 不抛出,而是把错误作为一份带 err 字段的结果交给脚本,后面的 Lua 可以选择返回自定义错误、记录日志,或执行真正安全的替代路径。
pcall 不是“忽略错误”。如果错误代表数据类型被破坏,继续写别的键通常会把现场弄得更难修。只有当脚本确实知道如何处理某类失败时才用它,例如一个非关键的清理命令失败后,主业务能够返回明确状态。库存扣减这类短事务更适合先验证参数,再用 redis.call 让异常响亮地失败。

Lua 返回值会被转换成 Redis 协议,再由 ioredis 转成 JavaScript 值。为了避免客户端把空值、布尔值和错误混在一起,业务脚本可以返回稳定数组:第一项是状态码,第二项是剩余库存。
这套数字不一定适合所有团队,关键是把它当成接口版本的一部分。调用者不应靠匹配错误文案判断业务结果;日志里也应同时记录脚本版本、状态码、耗时与 Redis 节点,而不是把整段脚本和所有参数原样打印出来。
只做 GET、判断、DECRBY,能防两个并发请求一起超卖,却挡不住同一个请求被重放。典型情况是 Redis 已经扣减成功,但响应在网络中丢了。API 网关看到超时后重试,应用若换一枚新 token 或根本没有 token,库存会再扣一次。
下面的脚本把幂等判断也放进同一个不可穿插区。它先完成所有可以在写入前完成的参数检查,然后查 token 处理结果,再读库存。只有条件全部满足,才扣库存并记录这枚 token 对应的剩余量。
local stockKey = KEYS[1]
local tokenKey = KEYS[2]
local quantity = tonumber(ARGV[1])
local token = ARGV[2]
local tokenTtl = tonumber(ARGV[3])
if not quantity or quantity <= 0 or quantity
用 5 件初始库存依次发送首次扣减、相同 token 重试和一次库存不足请求,会得到:
首次扣减(2 件) -> [1, 3]
相同 token 重试 -> [2, 3]
新 token 扣减 4 件 -> [0, 3]
最终库存 -> 3相同 token 返回第一次的结果,没有再次扣库存。库存不足没有写入 token 记录,因此业务可以在补货后决定是否允许同一请求重新尝试。若你希望“失败请求也永久保持失败”,就需要把失败结果也写入幂等记录;这不是 Lua 技术选择,而是订单接口的合约选择。

幂等记录的 TTL 不能随便写一个小数字。只要上游仍可能重试,token 就应仍然存在;否则旧请求在记录过期后会被当成新请求。另一方面,把所有订单 token 永久塞进一个 Hash 会形成大 key。生产系统通常按 SKU 与业务周期拆分 token 键,或把最终幂等事实落到持久数据库,再让 Redis 只覆盖可接受的重试窗口。
下面的实验台可以改初始库存、扣减量和 token。先运行一次,再保持 token 不变重复运行;随后换 token 并把扣减量调到超过库存。
ioredis 的 defineCommand 可以把键数量与 Lua 源码绑定起来。客户端会尽量使用 EVALSHA,需要时处理脚本加载,业务代码不必在每个调用点复制缓存逻辑。
import Redis from "ioredis";
import { readFile } from "node:fs/promises";
const redis = new Redis(process.env.REDIS_URL!, {
commandTimeout: 80,
maxRetriesPerRequest: 1,
});
const deductLua = await
TypeScript 项目可以再给自定义命令补类型声明,并把状态码解析集中在 repository 层。Controller 只接收“成功、重复、库存不足、系统错误”等业务结果,不应该知道 KEYS[1] 或 SHA1。
客户端命令超时只说明调用者没有及时拿到响应,不代表服务端停止了脚本。库存脚本可能已经成功执行。此时不能直接走一条不带 token 的数据库扣减“兜底”,也不能换 token 重试;应使用同一 token 查询或重试,让脚本返回第一次的处理结果。
固定窗口限流常见的事故不是 INCR 不原子,而是第一次递增成功后应用还没来得及设置过期时间就断线。这个 key 从此没有 TTL,窗口再也不会自动重置。Lua 可以让“第一次计数时设置 TTL”和“返回是否放行”连续完成:
local limit = tonumber(ARGV[1])
local windowSeconds = tonumber(ARGV[2])
if not limit or limit <= 0 then
return redis.error_reply('LIMIT_MUST_BE_POSITIVE')
end
if not windowSeconds or windowSeconds <= 0 then
return redis.
返回数组依次是“是否放行、窗口内第几次请求、剩余毫秒数”。参数检查放在第一次写之前,避免 INCR 已经发生后才发现过期时间是非法值。超过阈值的请求也继续计数,这能显示窗口承受了多少压力;如果产品希望拒绝请求不计数,可以改合约,但不要在应用层额外 DECR,否则又把一次判断拆成两个调用。
Lua 修复的是命令组合的竞态,不会修复固定窗口算法自身的边界突刺:某个客户端可以在前一个窗口末尾发满额度,又在新窗口开头立刻发满一次。需要更平滑的限制时,可以用令牌桶,或把 ZREMRANGEBYSCORE、ZCARD、ZADD 放进一个短小的滑动窗口脚本。无论哪种算法,都应先决定 key 维度:按用户、IP、接口还是租户计数,会直接改变公平性、内存与热点分布。
这条规则值得单独讲,因为“原子执行”很容易被听成“要么全部成功,要么完全没有变化”。Redis Lua 保证别的命令看不到脚本执行到一半的中间状态;如果脚本后半段报错,前半段已经成功的写命令不会自动回滚。
redis.call('SET', KEYS[1], 'written')
redis.call('INCR', KEYS[2]) -- KEYS[2] 当前保存字符串 text
return 1运行后客户端收到类型错误,再读取两个键会看到:
ERR value is not an integer or out of range ...
MGET ch13:marker ch13:type-error
1) "written"
2) "text"第一条 SET 保留了下来。redis.pcall 只会让脚本有机会处理第二条命令的错误,也不会替第一条写入做撤销。因此,脚本的安全写法是:把参数范围、必需键、能够提前判断的数据类型尽量在第一次写入前检查完;写入阶段保持短而直,不在写入后调用结果不确定的大段逻辑。
如果业务真的要求失败后恢复,就要显式设计补偿,并承认补偿本身也可能失败。库存场景更常用的做法是让 Redis 内部扣减脚本不产生运行错误,再用订单状态机、消息和对账处理“Redis 已扣、数据库订单未落成”这种跨系统差异。Lua 不能把两个系统装进一个事务。
EVAL 每次都携带完整 Lua 文本。SCRIPT LOAD 会编译脚本并返回 SHA1,之后 EVALSHA 只发摘要与参数,网络负担更小,也让调用方式更稳定。但脚本缓存是易失的:Redis 重启、故障转移到另一节点、执行 SCRIPT FLUSH,或者目标节点本来就没有加载过这份脚本,都可能得到 NOSCRIPT。
SCRIPT LOAD "return {KEYS[1],ARGV[1]}"
-> 14d8...c9f
EVALSHA 14d8...c9f 1 demo:key hello
-> ["demo:key", "hello"]
SCRIPT FLUSH
EVALSHA 14d8...c9f 1 demo:key hello
-> NOSCRIPT No matching script. Please use EVAL.正确路径是把 Lua 正文随应用版本发布:正常请求走 EVALSHA;遇到 NOSCRIPT,在收到请求的目标节点执行 SCRIPT LOAD,拿到摘要后重试一次。应用启动时预加载可以降低首个请求的抖动,却不能代替运行时自愈,因为故障转移随时可能把流量送到另一节点。

有两处容易踩坑。第一,在 pipeline 里才收到 NOSCRIPT 时,客户端很难把“加载后重试”插回已经发送的同一批命令。需要严格依赖管线时,应提前在相关节点 SCRIPT LOAD,或按客户端文档选择能够安全退回 EVAL 的调用方式。第二,不要把 NOSCRIPT 当成业务失败直接降级为另一套非原子写法;那会让部署或故障切换恰好成为超卖窗口。
ioredis 的 defineCommand 能处理常规调用中的脚本缓存细节,但团队仍应演练重启、主从切换和 SCRIPT FLUSH。可观测性至少包括脚本名称与版本、调用次数、耗时分位数、NOSCRIPT 次数、BUSY 错误、业务状态码分布。不要只看 Redis 总 QPS,一段慢脚本可能只占很少请求,却堵住后面大量普通 GET。
从 Redis 7.4 起,持续通过 EVAL 产生的大量不同脚本还可能被按最近最少使用规则逐出。真正的解决方式仍然是固定脚本文本、使用参数,而不是不断提高缓存容量。
脚本文本改一个空格都会得到不同摘要,所以 SHA1 适合确认“服务器缓存的是哪份文本”,却不能代替人能读懂的业务版本。建议把文件名、日志字段和指标都带上版本,例如 deduct-inventory-v2.lua;返回状态码只新增、不复用,旧调用方遇到不认识的状态码时按系统错误处理。蓝绿发布期间,新旧应用可能同时发请求,v1 与 v2 应各自保留完整源码和加载能力,不能先删旧脚本再等所有实例升级。
若新版只是新增一个可选参数,可以让脚本在参数缺省时沿用旧语义;若幂等键结构或返回含义已经变化,更稳妥的做法是使用新命令名与新键前缀,完成迁移后再回收旧版。回滚也要按同样思路演练:应用回到旧版本时,它是否还读得懂新脚本写下的数据,旧 token 是否仍能阻止重复扣减。只有代码回滚、数据兼容和脚本加载三个环节都能闭合,这次发布才真的可回退。
多节点环境还要区分“脚本已随制品发布”和“每个当前主节点已经缓存”。前者是源码可用性,后者只是运行时状态。发布检查可以预热各目标节点,但请求路径仍要处理 NOSCRIPT;告警也应按节点统计,才能看出是否只有故障转移后的新主节点在反复加载。
Lua 的不可穿插来自阻塞语义。脚本运行 300 微秒,其他请求多等一点点;脚本运行 300 毫秒,期间排队的会话读取、缓存查询、库存扣减都要等。原子性没有免费午餐:你把多长的工作塞进原子区,就向这个 Redis 节点借走了多长的独占时间。

以下逻辑不适合放进脚本:
KEYS 做全库匹配,或把完整 SCAN 循环跑到游标归零;“每次只 SCAN 100 条”也不自动安全。如果脚本内部不断继续 SCAN,直到扫描完整个库,工作总量仍然不确定,而且脚本执行期间其他客户端照样等待。批处理应该在应用或任务系统里分批推进,让每批之间把执行权还给 Redis;需要原子的只是每一小批的状态变更,不是整场迁移。
Redis 有脚本执行时间阈值,常见默认值是 5 秒,但它是事故保护线,不是性能预算。脚本越过阈值后,Redis 会记录慢脚本并对普通命令返回 BUSY;服务器不会为了满足客户端超时而随意中断一段已经写过数据的脚本。只读且尚未写入的脚本可以尝试终止,已发生写入后不能靠 SCRIPT KILL 安全撤销。线上脚本的目标应是稳定落在亚毫秒或少量毫秒范围,具体上限由实例延迟目标、QPS 与同节点其他业务共同决定。
下面的预算工具把脚本耗时、QPS、扫描规模、缓存丢失比例和部署路径放在一起。数字是排队模型,不是对某台机器的压测承诺;重点是观察脚本利用率靠近 100% 时,等待为何会突然放大。
上线前应对真实数据分布压测,而不只测一个空 Redis。记录脚本自身耗时、服务端命令延迟和端到端延迟;用大集合、临近大 key 上限、热点集中等数据覆盖最坏路径。客户端超时要略高于正常延迟预算,但不能把超时调大来掩盖慢脚本。出现持续超时时,库存应优先失败关闭或返回“处理中”,限流则按安全要求选择拒绝、有限放行或本地保护,不能让每个接口临时发挥。
单机 Redis 可以在一段脚本里访问多个键;到了 Cluster,一个脚本涉及的键必须能路由到同一个哈希槽。库存与幂等记录可以这样命名:
inventory:{sku1001}:stock
inventory:{sku1001}:tokens花括号中的 sku1001 是 hash tag,计算槽位时只使用这段内容,因此两个键必定同槽。inventory:sku1001:stock 与 inventory:sku1001:tokens 看上去前缀很像,却没有同槽保证。客户端知道 numberOfKeys: 2 后,会根据键路由;如果键不在同槽,请求应明确失败,而不是把脚本拆到两个分片上分别执行。
hash tag 应围绕最小业务聚合设计。把所有库存都写成 {inventory}:sku1001、{inventory}:sku1002,确实同槽,却会把整站库存压到一个分片,失去水平扩展。以 SKU 为 tag,表示“同一个 SKU 的库存和去重记录需要原子操作”,不同 SKU 仍能散到不同槽。
也不要在 Lua 里根据库存值动态拼出另一个键。所有访问键都要显式列在 KEYS 中,这既让 Cluster 能在执行前检查,也让代码审查能看清脚本的写入边界。脚本如果需要碰订单、用户、库存三个彼此独立的聚合,往往说明原子边界画得太大,应重新设计状态机与异步协调。
EVAL 脚本属于应用:源码跟着服务发布,服务负责在缓存丢失后重载。从 Redis 7 开始,Functions 提供另一种选择:把一组带名字的 Lua 函数装入函数库,通过 FCALL 调用。函数库随数据库持久化并复制到副本,调用日志里也能看到有意义的函数名,适合多个服务共享一套稳定的数据操作接口。
这不表示 Redis 7 以后就应该把所有脚本迁成 Functions。只有两三段短脚本、生命周期与单个应用完全一致时,defineCommand 加 EVAL 脚本更容易跟随应用回滚。函数库由平台或数据库运维流程部署时,应用发布与函数发布需要兼容窗口;库内容以整体替换,接口名、参数和返回码要有明确版本,例如保留 inventory_deduct_v1,等所有调用方升级后再移除。
Cluster 中,Functions 还需要由管理流程加载到所有主节点,不能假设在一个节点 FUNCTION LOAD 就自动覆盖整个集群。新环境、纯缓存实例和扩容节点也要进入发布检查。Functions 的执行仍然会阻塞所在 Redis 节点,仍要求相关键同槽,也仍然不会在运行错误时自动回滚先前写入。它解决的是代码归属、命名、持久化和部署,不是把 Redis 变成支持跨分片回滚的数据库。
一段 Lua 能在本地跑通,只完成了很小一部分工作。准备上线时,可以按下面的顺序过一遍:
先写业务合约:列出所有 KEYS、ARGV、状态码和幂等语义,说明库存键不存在、库存不足、重复 token 与系统错误分别怎样处理。
把能够提前发现的问题放到第一次写入之前,包括数量范围、必需参数与关键数据格式;然后让写入阶段尽量短,不假设出错会回滚。
给每条访问键使用明确的 KEY 参数,在 Cluster 环境用最小业务实体设计 hash tag,并验证所有键同槽。
固定脚本文本并随应用或 Functions 库做版本管理,演练 NOSCRIPT、重启、故障转移、超时后的同 token 重试,以及旧版调用方与新版脚本同时存在的窗口。
到这里,Lua 的定位就很清楚了:它适合把很短、工作量有界、只触碰同一 Redis 原子边界的条件更新收在服务端。库存和限流都能因此消掉命令间的竞态,但持久化、故障转移和跨系统对账仍然要另行设计。下一章会接着看 RDB 与 AOF:当这次扣减已经写进 Redis,它在进程重启和机器故障后究竟能保留到什么程度。
用接近生产的数据规模测最慢分支,为脚本耗时设告警;看到大循环、全库扫描或不确定工作量,就把任务拆成多个有界批次。