Redis Functions 与脚本工程化:EVAL、EVALSHA 与 FUNCTION 全解析

Redis 脚本与 Functions 工程化指南:EVAL/EVALSHA/SCRIPT LOAD 脚本缓存与 NOSCRIPT 处理、Lua 沙箱与 redis.call/redis.pcall 差异、KEYS/ARGV 键声明与集群槽位校验、Redis 7.0 FUNCTION LOAD/FCALL/FCALL_RO 函数库注册与

Redis 从 2.6 引入 EVAL 起,就把 Lua 脚本作为服务端原子计算的一等公民;7.0 又推出 Redis Functions,把散落在客户端的脚本升级为「数据库内注册、按名调用」的持久化函数库。二者解决同一个问题:把「读-算-写」的多条命令压缩成一次网络往返、一段原子执行。

不过脚本也是最容易被滥用的特性。KEYS 声明不当会在集群里触发 CROSSSLOT;redis.call 与 redis.pcall 的差异会在出错时让整个脚本中断;math.random、TIME 这类非确定性调用会让主从数据分叉;EVALSHA 的 NOSCRIPT 处理不当会在扩容或重启后集中报错。

本文从 Lua 基础讲到 Functions 工程化,覆盖缓存机制、复制语义、调试手段与生产陷阱。


一、为什么需要服务端脚本

1.1 网络往返与竞态窗口

一次「先读后写」的逻辑若拆成 GET + 判断 + SET,要付出两次 RTT。同机房 RTT 约 0.1~0.5ms,在 10 万 QPS 下意味着两倍的连接占用与上下文切换。更严重的是竞态窗口:在 GET 与 SET 之间,别的客户端可能已改写同一个 Key。

多条命令需要 N 次 RTT 且无原子性;MULTI/EXEC 配合 WATCH 有乐观锁但冲突需重试;Lua 脚本与 Redis Functions 都只需 1 次 RTT 且强原子,无需重试。

1.2 原子性的边界

Redis 单线程执行命令,一段脚本在执行期间不会插入其他命令,这就是原子性来源。注意它不等于事务的 ACID:脚本执行到一半出错,已经执行的写命令不会回滚。

记忆:脚本保证「不被打断」,不保证「要么全做要么全不做」。需要回滚语义时,要么在脚本内自己做补偿,要么改用带版本校验的乐观锁。

1.3 适合与不适合的场景

适合条件写入(CAS)、批量原子更新、限流计数、库存扣减、排行榜复合计算;不适合耗时超过 10ms 的复杂计算、依赖外部 IO、循环数万次的全量扫描。脚本会阻塞整个实例——一个 100ms 的脚本等于让全部客户端一起等 100ms,生产环境应把执行时间控制在 1ms 量级。


二、Lua 基础与脚本骨架

2.1 最小脚本与参数传递

-- hello.lua
return 'hello ' .. ARGV[1] .. ' from ' .. KEYS[1]

EVAL 的签名是 EVAL script numkeys key [key ...] arg [arg ...],numkeys 之后的 numkeys 个参数进 KEYS,其余进 ARGV。例如 redis-cli EVAL "$(cat hello.lua)" 1 mykey world 返回 hello world from mykey。

2.2 Lua 与 Redis 类型的映射

number 映射为 Integer(自动截断小数),string 映射为 Bulk string,数组 table 映射为 Multi-bulk,带 ok 字段的 table 映射为 Simple status,带 err 字段的映射为 Error,true 映射为整数 1,false 与 nil 映射为 Null bulk。返回状态与错误用 redis.status_reply('OK') 与 redis.error_reply('ERR custom message')。

Lua 只有 number 一种数值类型(double),return 1.5 会被截断成 1。需要浮点结果时用 tostring() 或 string.format('%.2f', x)。

2.3 redis.call 与 redis.pcall

-- redis.call:命令出错时立即中断脚本,把错误抛给客户端
local v = redis.call('INCR', KEYS[1])

-- redis.pcall:出错时返回含 err 字段的 table,脚本继续执行
local ok = redis.pcall('SET', KEYS[1], 'x', 'BADOPTION')
if ok.err then
  return redis.error_reply('参数不合法: ' .. ok.err)
end
函数出错行为适用
redis.call抛错并终止脚本关键路径,错误必须暴露
redis.pcall返回错误对象,脚本可控需要容错分支、探测类型

用 TYPE 探测后再操作是常见防御写法:if redis.call('TYPE', k).ok == 'string' then ... end。TYPE 返回 status reply,在 Lua 里是带 ok 字段的 table。

2.4 沙箱与禁用能力

Redis 的 Lua 环境是受限沙箱:没有 os、没有文件 IO、没有 require。可用库为 string、table、math、cjson、cmsgpack、bit、struct,例如 cjson.decode(ARGV[1]) 解析 JSON、cmsgpack.pack({1,2,3}) 打包、bit.band(7, 3) 位运算。

math.random 默认种子固定,且随机结果会破坏主从一致性。写路径应避免随机,必须用时改为由客户端传入随机值作为 ARGV。


三、EVALSHA 与脚本缓存

3.1 SCRIPT LOAD 与 SHA1

每次 EVAL 都要把整段脚本文本传给服务端,脚本大了就是浪费带宽。Redis 把执行过的脚本按 SHA1 缓存,用 EVALSHA 只需传 40 字符摘要。

redis-cli SCRIPT LOAD "$(cat inventory.lua)"
# "b7e2f8c9a1d4e6f0b3c5a7d9e1f3b5c7d9e1f3b5"

redis-cli EVALSHA b7e2f8c9a1d4e6f0b3c5a7d9e1f3b5c7d9e1f3b5 1 stock:1001 1
redis-cli SCRIPT EXISTS b7e2f8c9a1d4e6f0b3c5a7d9e1f3b5c7d9e1f3b5
# 1) (integer) 1

3.2 NOSCRIPT 与客户端降级

脚本缓存不持久化,SCRIPT FLUSH、实例重启、主从切换、槽迁移都会让它丢失,此时 EVALSHA 返回 NOSCRIPT No matching script. Please use EVAL.。标准处理是先 EVALSHA,失败再 SCRIPT LOAD 后重试:

def run_script(conn, script, keys, args):
    sha = hashlib.sha1(script.encode()).hexdigest()
    try:
        return conn.execute_command('EVALSHA', sha, len(keys), *keys, *args)
    except redis.exceptions.NoScriptError:
        conn.execute_command('SCRIPT', 'LOAD', script)
        return conn.execute_command('EVALSHA', sha, len(keys), *keys, *args)
// Lettuce / Jedis 已内置该逻辑,推荐直接用高层封装
RedisScript<Long> script = RedisScript.of(luaText, Long.class);
Long r = redisTemplate.execute(script, Collections.singletonList("stock:1001"), "1");

主流客户端(Lettuce、Jedis、go-redis 的 redis.NewScript、redis-py 的 register_script)都内置了 NOSCRIPT 自动重载。不要手写裸 EVALSHA 而不处理异常,一次主从切换就能让业务全线报错。

3.3 SCRIPT 管理命令

命令作用生产建议
SCRIPT LOAD <lua>加载并返回 SHA1发布时预热
SCRIPT EXISTS <sha...>判断是否在缓存健康检查
SCRIPT FLUSH [ASYNC]清空脚本缓存慎用,会引发 NOSCRIPT 潮
SCRIPT KILL杀掉未执行过写命令的脚本救火用
SHUTDOWN NOSAVE脚本已写入时的唯一出路极端情况

SCRIPT KILL 只对「尚未执行任何写命令」的脚本有效。若脚本已产生写操作,强杀会导致数据不一致,Redis 会拒绝并提示改用 SHUTDOWN NOSAVE。


四、Redis Functions:脚本的工程化形态

4.1 EVAL 的四个工程痛点

脚本散落客户端导致版本不一致、只能用 SHA1 引用而不可读、脚本之间无法复用、缓存易失导致扩容时新节点没有脚本。Redis 7.0 的 Functions 正是为此设计:函数以**库(library)**为单位注册到服务端,随 RDB/AOF 持久化,支持主从复制与 FUNCTION DUMP 迁移。

4.2 函数库结构与加载

#!lua name=inventory

redis.register_function('deduct_stock', function(keys, args)
    local stock = tonumber(redis.call('GET', keys[1]) or '0')
    local qty = tonumber(args[1])
    if stock < qty then
        return redis.error_reply('INSUFFICIENT_STOCK')
    end
    redis.call('DECRBY', keys[1], qty)
    return stock - qty
end)
redis-cli -x FUNCTION LOAD < inventory.lua
# "inventory"

redis-cli FCALL deduct_stock 1 stock:1001 5
# (integer) 95

4.3 函数标志与只读调用

#!lua name=report

redis.register_function{
    function_name = 'top_n',
    callback = function(keys, args)
        return redis.call('ZRANGE', keys[1], 0, tonumber(args[1]) - 1, 'REV')
    end,
    flags = { 'no-writes' }        -- 声明只读,允许在只读副本上执行
}
redis-cli FCALL_RO top_n 1 rank:global 10
调用方式要求可路由到副本
FCALL函数可写可读否
FCALL_RO函数声明 no-writes是

4.4 函数库管理命令

命令说明
FUNCTION LOAD [REPLACE] <code>加载或替换函数库
FUNCTION LIST [LIBRARYNAME x] [WITHCODE]列出库与函数
FUNCTION DELETE <lib>删除函数库
FUNCTION FLUSH [ASYNC]清空全部库
FUNCTION DUMP / FUNCTION RESTORE备份与恢复(跨实例迁移)
FUNCTION STATS运行中函数统计

迁移时用 redis-cli FUNCTION DUMP > functions.rdb 导出、redis-cli -h new-host FUNCTION RESTORE functions.rdb 恢复。

函数库会随持久化文件保存。这意味着主从切换、重启、槽迁移后函数依然存在,彻底消除了 NOSCRIPT 类问题。新项目应优先使用 Functions。

4.5 EVAL 与 Functions 选型

维度EVAL/EVALSHARedis Functions
注册方式每次传文本或 SHA1一次 FUNCTION LOAD
持久化不持久化随 RDB/AOF 保存
命名空间无库 + 函数名
复用不能互相调用同库内可调用
只读路由不支持FCALL_RO
最低版本2.67.0

五、原子性、复制与持久化语义

5.1 脚本是原子的,但不是隔离的

脚本执行期间 Redis 不处理其他客户端命令,但不会阻止过期键淘汰、键空间通知等内部事件。脚本内多次读取同一 Key 一定拿到相同值(除非脚本自己改了它)。

5.2 复制语义:effects 与 verbatim

早期 Redis 把脚本文本原样传给副本和 AOF(verbatim replication),要求脚本必须确定性。Redis 5.0 起默认改用 effects replication:主节点执行脚本,把产生的实际写命令作为 MULTI/EXEC 事务复制给副本。

verbatim 语义复制脚本文本,体积小但非确定性会导致主从分叉;effects 语义(默认)复制实际写命令,天然一致但脚本长时复制量大。

虽然 effects replication 消除了大部分非确定性风险,但仍不建议在脚本里使用 TIME、math.random、SRANDMEMBER。另外,effects 模式下脚本的每条写命令都会追加到 AOF,循环 1000 次的脚本会产生 1000 条记录,因此写路径应尽量「少量命令、批量参数」。


六、调试与测试

6.1 redis-cli ldb 交互式调试

redis-cli --ldb --eval inventory_test.lua key1 , arg1 arg2

常用调试命令:s / n 单步进入与跳过、c 继续、b <行号> 设断点、p <变量> 打印、abort 中止回滚。

--ldb 会阻塞服务端并 fork 出调试会话,绝不可在生产实例上使用。另有 --ldb-sync-mode 更彻底但更危险。

6.2 redis-cli eval 批量执行

# KEYS 与 ARGV 用逗号分隔(逗号两侧需空格)
redis-cli --eval deduct.lua stock:1001 , 5
# 等价于 EVAL "$(cat deduct.lua)" 1 stock:1001 5

6.3 redis.log 与测试准则

脚本内可用 redis.log(redis.LOG_WARNING, 'stock low: ...') 打日志,级别常量依次为 redis.LOG_DEBUG、redis.LOG_VERBOSE、redis.LOG_NOTICE、redis.LOG_WARNING。测试三条准则:纯函数优先(只依赖 KEYS/ARGV)、覆盖边界(Key 不存在、类型不符、参数越界)、建立性能基线。

redis-benchmark -n 100000 -c 50 -P 8 \
  evalsha b7e2f8c9a1d4e6f0b3c5a7d9e1f3b5c7d9e1f3b5 1 stock:1001 1

七、键空间通知与脚本配合

7.1 开启键空间通知

# redis.conf
notify-keyspace-events KEA
redis-cli CONFIG SET notify-keyspace-events Kx      # 只订阅过期事件
redis-cli PSUBSCRIBE '__keyevent@0__:expired'
字符含义
K键空间事件 __keyspace@0__:key
E键事件 __keyevent@0__:op
g通用命令(DEL、EXPIRE、RENAME)
x过期事件
zZSet 相关
A除 m、n 外全部

7.2 脚本触发通知的注意事项

脚本内的写命令同样会触发键空间通知,一段循环 1000 次的脚本会推送 1000 条消息,订阅方可能被淹没。另外,CONFIG、SUBSCRIBE、MULTI 等命令在脚本中被禁止调用,需要临时调整通知策略时应在脚本外由客户端控制。

键空间通知是 fire-and-forget:不保证投递,订阅者掉线期间的事件永久丢失,且过期事件在键被实际删除时才触发。对可靠性要求高的场景(如订单超时关单),应使用 Stream + 定时扫描或 Redisson 延迟队列。


八、生产陷阱与错误重试

8.1 集群模式下的 KEYS 声明

# 错误:两个 Key 落在不同槽,集群直接拒绝
redis-cli -c EVAL "return redis.call('MSET', KEYS[1], ARGV[1], KEYS[2], ARGV[2])" 2 a b c d
# (error) CROSSSLOT Keys in request don't hash to the same slot

# 正确:用 hash tag 强制同槽
redis-cli -c EVAL "return redis.call('MSET', KEYS[1], ARGV[1], KEYS[2], ARGV[2])" 2 {u1}:a {u1}:b c d

所有被脚本访问的 Key 必须通过 KEYS 传入,不能拼在字符串里。{u1}:a 与 {u1}:b 中 {} 内的内容决定槽位,因此落在同一节点。

8.2 脚本阻塞与超时

配置项 lua-time-limit(别名 busy-reply-threshold)默认为 5000ms,超过后 Redis 开始回复 BUSY,但不中断脚本。

脚本超时后,Redis 只在新的客户端请求上回复 BUSY Redis is busy running a script,脚本本身仍在跑。此时只能用 SCRIPT KILL(未写)或 SHUTDOWN NOSAVE(已写)。

8.3 幂等与重试

脚本虽原子,但客户端超时≠服务端未执行。重试前必须校验状态:

-- KEYS[1] 库存 Key,KEYS[2] 已处理请求集合
local req = ARGV[1]
if redis.call('SISMEMBER', KEYS[2], req) == 1 then
  return redis.call('GET', KEYS[1])      -- 已处理,直接返回当前值
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty = tonumber(ARGV[2])
if stock < qty then return redis.error_reply('INSUFFICIENT_STOCK') end
redis.call('DECRBY', KEYS[1], qty)
redis.call('SADD', KEYS[2], req)
redis.call('EXPIRE', KEYS[2], 86400)
return stock - qty

幂等集合的 Key 必须与库存 Key 同槽(用 {stock:1001}:reqs 形式),否则集群下 CROSSSLOT。集合本身也要设 TTL。

8.4 常见错误速查

报错原因对策
NOSCRIPT脚本缓存丢失客户端自动重载
CROSSSLOT多 Key 不同槽使用 hash tag
BUSY脚本执行超时定位慢脚本,拆分逻辑
ERR Error compiling script语法错误本地 luac -p 预检
ERR user_script: N: ...运行时错误看行号定位,redis.pcall 容错
nonexistent global variable访问未声明全局变量全部变量加 local

九、最佳实践清单

9.1 编码规范

  • 所有变量声明 local,禁止污染全局命名空间
  • Key 全部经 KEYS 传入,绝不字符串拼接
  • 用 redis.pcall 处理可预期的失败分支,用 redis.call 暴露不可恢复错误
  • 避免 math.random、TIME、SRANDMEMBER,需要随机值由客户端传入
  • 循环次数与 Key 数量设上限,超过 1000 次的批量操作考虑分片
  • 脚本内不做大字符串拼接,table.concat 优于 ..

9.2 工程化建议

事项做法
版本管理脚本纳入代码仓库,与业务同版本发布
发布预热上线时 SCRIPT LOAD,避免首批请求走 EVAL
迁移方式优先 Functions,随 RDB 自动携带
监控关注 INFO commandstats 中 evalsha 的 usec_per_call
灰度新脚本先小流量验证 p99,再全量
redis-cli INFO commandstats | grep -E 'cmdstat_(eval|evalsha|fcall)'
# cmdstat_evalsha:calls=10234,usec=8123,usec_per_call=0.79,...

9.3 迁移到 Functions 的检查清单

  • Redis 版本 ≥ 7.0,且所有节点一致
  • 每个业务域一个函数库,库名与 #!lua name= 一致
  • 只读逻辑声明 no-writes 标志,走 FCALL_RO
  • 已用 FUNCTION DUMP 备份,并演练过 FUNCTION RESTORE
  • 客户端 SDK 已升级到支持 FCALL 的版本
  • 监控接入 FUNCTION STATS 的调用次数与错误数
  • 老 EVAL 脚本标记废弃并设定下线时间

9.4 性能参考

空脚本 EVAL 约 0.05ms,单次 GET 脚本约 0.10ms,10 次命令脚本约 0.25ms,1000 次命令脚本则升到 8~15ms(已接近危险区);FCALL 的名解析开销可忽略,与 EVALSHA 基本相当。


结语

脚本与 Functions 是 Redis 从「键值存储」走向「服务端计算平台」的关键能力。核心要点回顾:

  1. 原子不等于事务:脚本执行不被打断,但中途出错不回滚,需要回滚就得自己写补偿逻辑
  2. 优先 Functions:7.0 起函数库随持久化保存、支持命名空间与只读路由,从根本上消灭 NOSCRIPT 问题
  3. 键必须显式声明:所有 Key 经 KEYS 传入,集群下用 hash tag 保证同槽,否则 CROSSSLOT 报错
  4. 确定性是底线:不用随机、不用时间、不依赖外部状态,让主从与 AOF 重放始终一致
  5. 短小是美德:脚本阻塞单线程,1ms 是心理红线,超过 10ms 就该重新设计
  6. 幂等是护城河:客户端超时不代表服务端未执行,重试前用请求 ID 去重
  7. 调试不上生产:--ldb 会阻塞实例,只在本地与预发使用

脚本的真正价值不是「少写几行代码」,而是把竞态窗口从应用层彻底消除。当一个业务逻辑的正确性依赖「这两条命令之间不能有别人插手」时,脚本就是唯一正确的答案。把它当作数据库的存储过程来严肃对待:有版本、有测试、有监控、有下线计划,才能在生产里长久可靠。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. Redis 线上排障与延迟诊断:SLOWLOG、LATENCY 与阻塞命令全流程
  2. 云托管 Redis 选型与运维:ElastiCache、MemoryDB、Redis Cloud 与 Upstash 对比
  3. RedisTimeSeries 时序数据实战:降采样、压缩与监控告警