根本原因是Lua脚本调用非确定性命令(如math.random、TIME、KEYS等)导致主从执行结果不一致;Redis仅原子转发脚本而不校验其确定性,故必须禁用所有依赖时间、随机数、实时状态的命令。

Redis主从复制中Lua脚本执行结果不一致的根本原因
根本不是Redis没同步脚本,而是脚本本身在主从节点上产生了不同输出——只要脚本里调用了 math.random()、redis.call("TIME")、redis.call("KEYS", "*") 这类非确定性命令,主从就必然分叉。
Redis的主从复制只保证「相同输入 → 相同输出」,但不校验脚本是否真的满足这个前提。它把整个 EVAL 请求当成原子操作转发给从节点执行,一旦脚本内部依赖了时间、随机数、当前key列表、客户端连接ID等不可复现状态,从节点跑出来的结果就和主节点对不上。
哪些Lua函数/命令属于“危险操作”必须禁用
不是所有Redis Lua命令都安全。判断标准只有一个:该命令的返回值是否在相同输入、相同数据集、相同执行时刻下,每次都能稳定复现。
-
math.random()、math.randomseed():每次调用结果不可预测,绝对禁止 -
redis.call("TIME")、redis.call("PING"):返回当前服务端时间或状态,主从执行时刻不同,结果不同 -
redis.call("KEYS", pattern):结果依赖当前数据库实际key分布,且是非原子操作,主从可能因复制延迟看到不同快照 -
redis.call("CLIENT", "ID")、redis.call("CLIENT", "LIST"):返回客户端上下文,主从完全无关 -
redis.call("DEBUG", ...):调试命令,行为不稳定,生产环境本就不该出现
安全的替代方案:用客户端传入时间戳(如 ARGV[1])、用已知key代替 KEYS、用哈希取模代替随机选择。
如何在运行时拦截非确定性脚本(Redis 7.0+)
Redis 7.0 引入了 lua-time-limit 和更严格的沙箱控制,但默认仍不阻止非确定性命令。真正起作用的是 lua-replicate-commands 配置 + 显式声明。
- 开启
lua-replicate-commands yes(默认关闭):让Redis尝试将脚本拆解为单个命令重放,而非整体eval,此时非确定性命令会直接报错ERR Lua script attempted to access a non deterministic command - 脚本开头加
--!fips-mode enabled注释(仅限Redis 7.2+):强制启用FIPS兼容模式,自动拒绝所有已知非确定性调用 - 用
SCRIPT LOAD+EVALSHA替代直连EVAL:便于统一做脚本白名单校验(需自行在client层实现)
注意:lua-replicate-commands 开启后,部分依赖 redis.call() 返回值做分支判断的脚本会失效——比如根据 redis.call("EXISTS", key) 结果决定是否写入,这种逻辑依然安全;但若根据 redis.call("TIME") 做分支,就会在从节点报错中断。
本地开发与CI阶段怎么提前发现隐患
靠人工 review 脚本几乎必漏。最有效的是在提交前跑一个轻量级静态检查器,识别出高危函数调用。
- 用
grep -n "math\.random\|redis\.call.*\"TIME\"\|redis\.call.*\"KEYS\"\|redis\.call.*\"CLIENT\"" *.lua快速扫描 - 在CI中集成
redis-cli --eval的dry-run:启动一个临时Redis实例(redis-server --port 0),用EVAL执行脚本两次,比对输出是否一致(适用于无写操作的纯读脚本) - 对关键脚本加单元测试:用
redis-py连接主节点执行一次,再用redis-py模拟从节点行为(即忽略复制,直接在另一实例上重放相同脚本),断言结果相等
最容易被忽略的是:脚本里看似无害的 os.time() 或 os.clock() ——它们在Lua沙箱中虽被禁用,但有些旧版Redis未彻底屏蔽,得靠grep硬抓;还有通过 redis.call("GET", "config:seed") 动态读取随机种子,这种间接方式更难察觉。

















