set key value nx ex不能直接做公平锁,因为其抢占式机制不维护fifo顺序,需结合zset队列、唯一标识、时间戳排序及原子lua脚本才能实现真正公平。

为什么 SET key value NX EX 不能直接做公平锁
因为 SET 命令本身不维护请求顺序,多个客户端并发发来加锁请求时,谁的网络快、Redis 执行早,谁就抢到——这是抢占式,不是 FIFO。真实业务里常出现:A 先调 Lock() 却因网络延迟慢一步,B 后发却先成功,A 超时重试又撞上 C,最终逻辑乱序执行。这不是 Redis 的 bug,是协议层没提供队列语义。
公平锁必须用 ZSET + Lua 组合实现
核心结构是三个 Redis key:
-
lock:holder:String 类型,存当前持有者 ID(如"client_abc") -
lock:queue:ZSET 类型,score是毫秒级时间戳,member是 client_id,天然支持按排队时间排序 -
lock:notify:{client_id}:Pub/Sub channel,用于唤醒指定等待者
所有关键操作(入队、判头、占锁、唤醒)必须封装进一个 Lua 脚本执行,否则中间状态可能被其他客户端篡改。Redis 7.0 对 Lua 支持更稳,EVAL 和 EVALSHA 都能保证原子性,但注意别用 redis.call("TIME")——它返回的是秒+微秒,精度不够;必须由客户端传入毫秒时间戳(ARGV[4])作为 score。
加锁脚本的关键分支逻辑
典型 Lua 加锁脚本会检查三种情况:
- 锁空闲(
GET lock:holder为空):直接HSET lock:holder client_abc 1并PEXPIRE,同时清空lock:queue(防残留) - 锁被自己持有:仅
HINCRBY lock:holder client_abc 1,刷新过期时间,不入队 - 锁被别人占着:先
ZADD lock:queue <timestamp> client_abc</timestamp>,再ZRANGE lock:queue 0 0看自己是不是队首;不是就BRPOP lock:notify:client_abc 5阻塞等通知
注意:ZRANGE lock:queue 0 0 必须和后续的 SET lock:holder ... NX EX 在同一 Lua 脚本里完成,否则存在“判头成功 → 被别人抢先占锁 → 自己误认为已得锁”的竞态。
解锁时必须主动通知下一个等待者
释放锁不只是删 lock:holder,还要做两件事:
- 用
ZRANGE lock:queue 0 0拿出下一个 client_id - 用
PUBLISH lock:notify:{next_id} "go"唤醒它(接收端需提前SUBSCRIBE)
容易漏的点:ZREM 必须在确认新 client 成功拿到锁之后再执行,否则唤醒后对方发现队列里还有自己,可能重复入队。Redisson 的做法是把 ZREM 放在加锁成功的 Lua 脚本里,而不是解锁脚本中——这样更安全。另外,ZSET 中的过期请求要用 ZREMRANGEBYSCORE lock:queue 0 <min_ts></min_ts> 定期清理,否则队列会无限膨胀。











