哨兵模式下lua脚本执行失败典型现象是eval中途报noauth、readonly或连接超时,根本原因是主从切换时节点角色瞬变、地址更新延迟及连接未及时刷新,导致脚本打到只读旧主或未同步新主;必须重试前强制刷新master地址并校验角色与复制状态,脚本自身需幂等设计。

Redis哨兵模式下 Lua 脚本执行失败的典型现象
脚本执行中途报 NOAUTH、READONLY 或直接连接超时,甚至返回 MOVED / ASK(虽然哨兵不涉及集群重定向,但客户端可能误判角色切换中的节点状态)。根本原因不是脚本写错了,而是故障转移期间:主节点降为从、新主尚未完成同步、Sentinel 通知客户端更新 master 地址存在毫秒级延迟——而 Lua 脚本要求原子性执行,一旦连接断在 EVAL 过程中,就无法续跑。
关键判断:只要脚本里没用 redis.call("set", ...) 这类会修改数据的命令,纯读场景可容忍短暂 READONLY;但含写操作时,必须确保打到当前有效的 master 上。
重试前必须校验 master 地址是否已更新
很多同学直接套用通用 HTTP 重试逻辑,在 catch 到异常后立刻 eval 重试,结果反复打到旧 master(已只读)或刚升主但 replication offset 还没追平的节点,导致数据不一致或无限循环失败。
正确做法是每次重试前强制刷新 master 地址:
- 调用
sentinel get-master-addr-by-name <master-name></master-name>获取最新地址(注意:该命令需连任意 Sentinel 节点,不是 Redis 实例) - 若返回地址与当前连接不同,关闭旧连接,新建连接到新地址
- 对新连接做一次
PING+INFO replication检查:确认role:master且connected_slaves:>0(避免打到脑裂中孤立的“伪主”)
不要依赖客户端库自动发现——Jedis 的 JedisSentinelPool 在故障转移后需要主动调用 pool.destroy() + pool.init() 才能刷新,Lettuce 则需监听 Pub/Sub 的 +switch-master 事件并手动触发 reload。
Lua 脚本自身要支持幂等与状态检查
即使重试逻辑完善,脚本若包含非幂等操作(如 INCR、LPUSH),多次执行就会出错。不能指望靠“重试次数限制”来规避。
必须在脚本开头加入前置校验:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
if redis.call("EXISTS", KEYS[1]) == 1 then
local status = redis.call("HGET", KEYS[1], "status")
if status == "done" or status == "processing" then
return {err="already_handled"}
end
end
同时,所有写操作应封装在 if not exists 或 if current_status == expected 条件下。避免用 SETNX 后再 HSET 这种两步操作——Lua 里它们是原子的,但重试时整个脚本会重跑,所以条件判断本身就得覆盖全部业务状态。
重试策略要区分错误类型,不能一概 while(true)
READONLY 和 Connection refused 的含义完全不同:READONLY 说明节点还活着但角色变了,大概率几秒内就能通过地址刷新解决;而 Connection refused 可能是 Sentinel 还没完成选举,此时激进重试(如 10ms 间隔)只会压垮剩余节点。
建议按错误码分层处理:
-
READONLY:立即刷新地址,最多重试 2 次,间隔 50ms -
NOAUTH:检查 auth 密码是否随 master 切换被重置(某些部署中从节点密码为空),重新 auth 后重试 1 次 - 连接级错误(
SocketTimeoutException、Connection refused):退避重试,初始 200ms,指数增长至 1s,总超时控制在 3s 内
真正的难点不在代码怎么写,而在于你得清楚自己用的是哪套 Sentinel 部署方案——密码是否统一、是否启用了 requirepass + masterauth 双配置、从节点是否禁止写(slave-read-only yes)。这些细节不摸清,重试逻辑再漂亮也会在凌晨三点失败。










