SET key value NX EX 无法实现公平锁,因其仅支持抢占式加锁,缺乏FIFO排队机制;必须结合ZSET有序队列、唯一标识与Lua原子脚本才能保障严格顺序。

单用 SET key value NX EX 无法实现公平锁,必须引入有序队列(如 ZSET)+ 唯一标识 + Lua 原子脚本,否则 FIFO 顺序完全不可控。
为什么 SETNX 或 SET ... NX EX 不是公平锁
它只做“抢占”:谁的请求先到 Redis、网络延迟低、Redis 执行快,谁就赢。A 先调 Lock() 但卡在网关,B 后发却抢到锁,A 超时重试又和 C 冲突——业务逻辑彻底乱序。这不是 bug,是协议层没定义排队语义。
常见错误现象包括:
- 多个 goroutine 并发调
acquireLock,返回成功顺序与调用时间完全不一致 - 锁释放后,下一个拿到锁的不是最早等待的 client,而是随机某个重试成功的 client
- 高并发下出现“跳号”:client_1001 → client_1005 → client_1002,说明队列缺失
用 ZSET 实现可排序等待队列
ZSET 是唯一适合公平锁的 Redis 原生结构:用毫秒级时间戳作 score,client ID 作 member,天然支持 FIFO 排序和范围查询。
立即学习“go语言免费学习笔记(深入)”;
实操要点:
-
score必须用time.Now().UnixMilli(),不能用纳秒(浮点精度导致 ZRANGE 排序错乱) - 加锁时先
ZADD lock:queue <timestamp> client_123</timestamp>,再ZRANGE lock:queue 0 0检查自己是否队首 - 若队首且
GET lock:holder为空,才执行SET lock:holder client_123 NX EX 30 - 若非队首,需阻塞监听专属通知频道(如
BRPOP lock:notify:client_123 5),不能轮询 - 定期用
ZREMRANGEBYSCORE lock:queue 0 <min_timestamp></min_timestamp>清理超时排队请求(比如 5 分钟前的)
加锁和解锁必须用 Lua 脚本原子封装
加锁三步(入队 → 查首 → 占锁)和解锁两步(删 holder → 唤醒下一个)都不能拆开,否则竞态直接破坏公平性。
加锁 Lua 示例关键逻辑:
local queue = KEYS[1]
local holder = KEYS[2]
local client = ARGV[1]
local ttl = ARGV[2]
local now = tonumber(ARGV[3])
redis.call('ZADD', queue, now, client)
local first = redis.call('ZRANGE', queue, 0, 0)
if #first == 1 and first[1] == client then
if redis.call('GET', holder) == false then
return redis.call('SET', holder, client, 'NX', 'EX', ttl)
end
end
return 0
解锁脚本不仅要删 lock:holder,还得:
-
ZRANGE lock:queue 0 0取下一个 client - 若存在,
PUBLISH lock:notify:<next_client> "go"</next_client> - 最后
ZREM lock:queue <next_client></next_client>,避免重复唤醒
自动续期必须绑定持有者身份且及时 cancel
长任务场景下,锁 TTL 不能靠猜:设 30s 就得每 10s 续一次(TTL / 3),太密压 Redis,太疏会掉锁。
续期本身也必须用 Lua 校验 value:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
最容易被忽略的是:业务函数 return 前必须显式 cancel() 续期 goroutine 的 context,否则锁已释放,后台还在给一个不存在的 key 续期——形成“幽灵续期”,干扰后续公平调度。


















