公平锁需FIFO队列与原子调度,ZSET以毫秒时间戳为score实现有序排队,加锁解锁必须用Lua脚本保证原子性,并配合PUBLISH唤醒下一个等待者,同时处理超时清理与崩溃收敛。

直接用 SET key value NX EX 不能实现公平锁——它不排队,只抢,谁快谁得。 真正的公平锁必须保证先调用 Lock() 的客户端,一定比后调用的更早拿到锁(FIFO),这需要显式维护等待队列和原子调度逻辑。
为什么 ZSET 是公平队列的最优选
Redis 的 ZSET 天然支持按 score 排序,适合做带优先级的等待队列:把毫秒级时间戳作为 score,客户端唯一 ID(如 uuid.NewString())作为 member,就能用 ZRANGE lock:queue 0 0 精确拿到最早排队的那个。
-
score必须用毫秒时间戳(time.Now().UnixMilli()),避免浮点精度导致排序错乱 - 不能用
LPUSH + BRPOP模拟队列——它无法跳过已失效的等待者,容易饿死 - 每次加锁都
ZADD入队,但真正获锁前不删自己;解锁后才由 Lua 脚本统一清理并唤醒下一个 - 需配合定期扫描任务或
ZREMRANGEBYSCORE lock:queue 0 (now - 30000)清理超时残留节点
加锁流程必须原子执行:Lua 脚本是唯一可靠方式
判断“我在不在队首”+“锁是否空闲”+“设 holder”三步必须在一个 Lua 脚本里完成,否则中间被其他客户端插入就会破坏 FIFO。
- 脚本接收
KEYS[1](队列名)、KEYS[2](holder key)、ARGV[1](client_id)、ARGV[2](TTL)、ARGV[3](当前毫秒时间戳) - 先
ZADD入队,再ZRANGE ... 0 0拿队首,若匹配自己且GET holder为空,则SET holder client_id EX ttl - 若不满足条件,返回特殊状态(如
-1),Go 层据此决定是否阻塞等待PUBLISH/PSUBSCRIBE或轮询 - 别在 Go 里拆成多次
Do()调用——网络延迟+并发写入会让队列和 holder 状态不同步
解锁后必须主动唤醒下一个,否则会饿死
删掉 lock:holder 后,如果不通知下一个等待者,它就永远卡在 BRPOP 或轮询里,锁资源实质上不可用。
立即学习“go语言免费学习笔记(深入)”;
- 解锁 Lua 脚本里做完
DEL holder后,立即ZRANGE queue 0 0取下一个client_id - 若存在,执行
PUBLISH lock:notify:{client_id} "go",对应客户端用redis.PSubscribe监听自己的频道 - 注意频道名要带 client_id,避免所有等待者一起被唤醒(羊群效应)
- 唤醒失败(比如目标 client 已断连)不能静默吞掉——得 fallback 到定时轮询机制,否则锁彻底不可用
公平锁最难的部分不是写对 ZSET 和 Lua,而是处理客户端崩溃后的状态收敛:ZSET 里残留的节点、未收到唤醒的等待者、续期 goroutine 泄漏……这些边界情况一旦漏掉一个,整个 FIFO 就会退化成随机抢占。生产环境建议把队列清理、唤醒重试、锁持有者心跳全部做成可配置的策略,而不是硬编码进脚本。


















