直接用 SET key value EX seconds NX 做心跳不安全,因其无法原子性续期并判断存活,易致状态滞后或误判离线;应使用 Lua 脚本原子执行读旧值、写新值、返回旧值,并结合时间戳校验与状态机迁移。
为什么直接用 SET key value EX seconds NX 做心跳不安全
因为单次 set 无法原子性地“续期 + 判断是否存活”,客户端可能在写入前已崩溃,但旧心跳仍有效,导致状态滞后。更糟的是,多个 worker 同时刷新时,nx 失败会误判为“节点离线”。真实场景中,你不是要“设值”,而是要“确认我还在干活且没被踢出”。
- 必须用
GETSET或 Lua 脚本实现“读旧值 + 写新值 + 返回旧值”原子三连 - 旧值为空说明是首次注册;非空则提取时间戳比对,判断是否超时漂移(比如本地时钟快了 5 秒)
- Redis 6.0+ 支持
SET key value PXAT ms-timestamp NX,可避免依赖本地时间,但需服务端统一授时或 NTP 对齐
用 Lua 脚本统一管理心跳与状态迁移逻辑
把“更新心跳时间”“检查是否过期”“触发状态机跳转(如 online → suspect → offline)”全收进一个脚本里,避免网络往返和竞态。Redis 执行 Lua 是原子的,且能复用 redis.call() 的返回结果做条件分支。
local now = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local old = redis.call("GET", KEYS[1])
if not old then
redis.call("SET", KEYS[1], now, "PX", ttl)
return {0, "registered"}
else
local last = tonumber(old)
if now - last > ttl * 1.2 then
redis.call("SET", KEYS[1], now, "PX", ttl)
return {1, "recovered"}
else
redis.call("PEXPIRE", KEYS[1], ttl)
return {0, "updated"}
end
end
- 传入参数
ARGV[1]必须是毫秒时间戳(用time.Now().UnixMilli()),不能传秒级,否则精度不够 - 脚本返回数组第一项是状态码,Go 侧用
redis.IntSliceCmd.Result()解包,别用String()直接转 - 别在脚本里做复杂计算(如 JSON 解析),Lua 性能弱,且 Redis 集群模式下 KEYS 必须落在同一 slot
Go 客户端如何避免频繁重连和 pipeline 阻塞
心跳是高频操作(比如每 3 秒一次),若每次调用都走 client.Do(ctx, cmd),连接池打满、DNS 缓存失效、TIME_WAIT 爆表都会让检测失真。必须复用连接并控制并发粒度。
- 用
redis.NewClient(&redis.Options{PoolSize: 16}),PoolSize 至少为预期并发心跳数 × 2,别设成 100+ —— 连接太多反而触发内核端口耗尽 - 心跳协程应带限速:用
time.Ticker+select { case ,别用 <code>time.Sleep配for,防止 goroutine 泄漏 - 不要对单个节点的心跳做 pipeline(如
pipe.Set(...).Expire(...)),Redis pipeline 不保证原子性,且心跳失败时难以定位哪一步挂了
状态机同步的关键不在 Redis,而在“谁负责驱动状态跃迁”
Redis 只是状态快照存储,真正的决策点在 watcher 服务:它定期 SCAN 所有心跳 key,比对最后更新时间,再按策略触发状态变更。这里最容易被忽略的是“SCAN 游标管理”和“状态跃迁幂等性”。
- SCAN 必须用游标分页,单次
Count设为 100–500,避免阻塞主线程;游标存到本地变量或临时 Redis key,别硬编码从 0 开始 - 状态跃迁(如
suspect → offline)必须带 CAS 条件:redis.SetNX(ctx, "node:abc:state", "offline", 0),失败说明其他 watcher 已处理,直接跳过 - 不要在 WATCH/MULTI/EXEC 中做状态机判断——WATCH key 一旦被其他 client 修改就失败,而心跳 key 本来就是高频写,大概率重试爆炸
真正难的不是写几个 SET 命令,而是让所有 watcher 对“什么算离线”“什么算恢复”有一致的时序解释。时钟偏移、网络分区、脚本执行延迟,都会让同一时刻不同 watcher 看到不同快照。得靠带版本号的状态 key 或单独的 lease key 来锚定决策依据。
立即学习“go语言免费学习笔记(深入)”;


















