首页 / Redis 入门教程 / 23. Lua 脚本与 EVAL

Redis 入门教程

23. Lua 脚本与 EVAL

本教程共 40 篇 · 第 23 篇 · 更新于 2026-08-02

LuaEVALEVALSHASCRIPT脚本原子性服务端编程

23. Lua 脚本与 EVAL

Redis 从 2.6 起内嵌了 Lua 5.1 解释器,允许你把一段自定义逻辑放到服务端执行。它解决了一个核心痛点:当你需要“读—计算—写”多步操作、又要求这整个过程不可分割且尽量少 round-trip 时,单条命令和事务都力不从心,而 Lua 脚本恰好胜任。

Lua 脚本是 Redis 服务端编程的基石,也是第 24 章「函数 FUNCTION」的前身。

本节目标

  • EVAL 执行脚本,理解 numkeysKEYSARGV 的参数约定。
  • 掌握 SCRIPT LOAD + EVALSHA 缓存复用脚本,减少网络传输。
  • 理解脚本的原子执行语义及其阻塞代价。
  • 知道 busy-reply-thresholdSCRIPT KILL / SHUTDOWN NOSAVE 的超时处理。
  • 记住最佳实践:脚本要短、要纯、避免长循环与随机写入。

EVAL 基本用法

EVAL 的签名是:

EVAL script numkeys key [key ...] arg [arg ...]
  • script:一段 Lua 源码字符串。
  • numkeys:后面跟着的“键参数”个数。
  • KEYS:键名列表,从脚本里通过 KEYS[1]KEYS[2] 访问。
  • ARGV:普通参数列表,通过 ARGV[1]ARGV[2] 访问。

约定上,脚本访问的所有 key 都必须通过 KEYS 传入,这样 Redis 才能在做集群路由、键空间通知时知道脚本“碰了哪些 key”。下面这个例子把两个 key 名和两个参数原样返回:

127.0.0.1:6379> EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 k1 k2 v1 v2
1) "k1"
2) "k2"
3) "v1"
4) "v2"

一个更有用的例子:判断某个计数器是否存在,不存在则初始化、存在则自增,整个过程原子完成:

127.0.0.1:6379> EVAL "if redis.call('EXISTS', KEYS[1]) == 0 then redis.call('SET', KEYS[1], ARGV[1]) return 'init' else return redis.call('INCR', KEYS[1]) end" 1 counter 10
"init"
127.0.0.1:6379> EVAL "if redis.call('EXISTS', KEYS[1]) == 0 then redis.call('SET', KEYS[1], ARGV[1]) return 'init' else return redis.call('INCR', KEYS[1]) end" 1 counter 10
(integer) 11

脚本里通过 redis.call() 调用 Redis 命令,返回 Lua 值后由 Redis 自动转换成 RESP 类型。

SCRIPT LOAD 与 EVALSHA

每次都用 EVAL 把完整源码传给服务器,长脚本会很费带宽,也不利于缓存。更好的做法是先用 SCRIPT LOAD 把脚本编译并缓存,拿到一个 SHA1 摘要,之后用 EVALSHA 只传摘要即可:

127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"c3458e6f30e34a8a3a9b1c2d3e4f5a6b7c8d9e0f"
127.0.0.1:6379> SET greeting hello
OK
127.0.0.1:6379> EVALSHA c3458e6f30e34a8a3a9b1c2d3e4f5a6b7c8d9e0f 1 greeting
"hello"

如果 EVALSHA 引用的脚本没被缓存(例如刚重启、缓存被 SCRIPT FLUSH 清空),Redis 会返回 NOSCRIPT 错误,此时应用层应回退到 EVAL 重新加载。因此成熟客户端库都内置了“EVALSHA 失败自动降级 EVAL”的逻辑。

其他 SCRIPT 子命令:

  • SCRIPT EXISTS sha1 [sha1 ...]:判断脚本是否已在缓存。
  • SCRIPT FLUSH:清空全部脚本缓存(慎用)。
  • SCRIPT KILL:杀死当前正在运行的只读脚本。

原子性与阻塞代价

脚本最宝贵的特性是原子执行:从脚本开始到结束,Redis 会阻塞所有其他客户端的请求,期间不会被任何别的命令插断。这正好弥补了第 22 章说的“事务不回滚”——用脚本实现“读—算—写”,就能真正做到要么完整生效、要么完全没发生。

但代价也很明确:脚本运行期间,整个服务器被卡住。所以那条铁律是——绝不要写慢脚本。几条注意事项:

  • 不要写 while true 之类无限循环;
  • 不要在脚本里做大量遍历超大集合的操作;
  • 一次处理的数据量要可控。

脚本有最大执行时间限制,由 busy-reply-threshold 控制(默认 5 秒,因为正常脚本通常不到 1 毫秒就跑完,这个上限只是用来兜住开发期的死循环):

127.0.0.1:6379> CONFIG GET busy-reply-threshold
1) "busy-reply-threshold"
2) "5000"

一旦脚本超过阈值,Redis 不会自动杀掉它(那会破坏原子性、留下半成品写入),而是:开始对其他客户端返回 BUSY 错误,只允许 SCRIPT KILLFUNCTION KILLSHUTDOWN NOSAVE 三个命令。如果脚本还没写过数据,可以用 SCRIPT KILL 终止;如果已经写过哪怕一次,唯一的出路就是 SHUTDOWN NOSAVE(不落盘强制停机,会丢失未持久化数据)。因此写脚本务必谨慎。

提示:从 Redis 7.0 起支持只读脚本——给脚本加 no-writes 标志,或用 EVAL_RO / EVALSHA_RO 执行。只读脚本始终可在从节点运行、永不会因为超时被 OOM 拒绝、也不会在故障转移写暂停期间被阻塞,且可被 SCRIPT KILL 随时终止。纯查询逻辑的脚本应优先声明为只读。

小结

  • EVAL script numkeys KEYS... ARGV... 在服务端执行 Lua;所有 key 经 KEYS 传入是强制约定。
  • SCRIPT LOAD + EVALSHA 用 SHA1 摘要复用脚本,省带宽;客户端应处理 NOSCRIPT 回退。
  • 脚本原子且阻塞全服,能实现真正的“全有或全无”,弥补事务无回滚的短板。
  • busy-reply-threshold(默认 5s)约束;超时后只读脚本可用 SCRIPT KILL,写过数据的只能 SHUTDOWN NOSAVE
  • 最佳实践:脚本要短小、纯逻辑、避免长循环;纯查询用只读脚本变体。

实战:用脚本做原子限流

Lua 相比事务的最大价值,是把“多步读—判断—写”打包成不可分割的一步。一个典型场景是滑动窗口限流:把“读取窗口内计数、判断是否超阈、未超则自增并续期”全部放进一个脚本,配合 INCREXPIRE 一次完成,既避免客户端多次往返,又保证整个判断原子的、不会被并发插断。

127.0.0.1:6379> EVAL "local n = redis.call('INCR', KEYS[1]); if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end; return n" 1 ratelimit:ip:1.2.3.4 60
(integer) 1

该脚本对 key 自增,若是窗口内第一次访问则设置 60 秒过期;返回的计数值由应用层与阈值比较决定是否放行。由于整段逻辑在服务器端原子执行,不会出现“两个请求同时读到 n=99 然后都放行成 100”的竞态。

常见误区

  • 误区一:脚本越复杂越好、一次干完所有事。 脚本执行期间整个 Redis 被阻塞,所有客户端都得排队。长脚本等于全实例卡死,因此务必让脚本短小、单次处理的数据量可控,严禁无限循环。
  • 误区二:KEYS 可以不传、在脚本里硬编码 key 名。 所有被访问的 key 都必须通过 KEYS 传入,这是强制约定。否则在集群模式下 Redis 无法判断脚本该路由到哪个 slot,键空间通知与超时统计也会失效。
  • 误区三:脚本里用随机性或时间导致主从不一致。 math.random 若不显式 math.randomseedredis.call('TIME') 等,会让主节点与从节点、或重写 AOF 时产生不同结果,破坏复制一致性。写脚本要“确定性”:相同输入永远得到相同输出。
  • 误区四:以为 EVALSHA 永远可用。 脚本只缓存在内存里,实例重启或 SCRIPT FLUSH 后摘要就失效,EVALSHA 会返回 NOSCRIPT。健壮的客户端必须能自动降级回 EVAL 重新加载——这也是为什么多数项目直接用现成客户端库的脚本 API,而非手工拼 EVALSHA

提示:本教程统一基于 Redis 8.x(最新稳定版)行文。补充说明:官方 redis.io 下载页另标 8.8 为 “Latest stable”,而 GitHub 上的最新发布 tag 为 8.10.0,二者同属 8.x 大版本,对 Lua 脚本命令与执行模型的讲述没有影响。