凌晨任务要给用户结算积分。服务 A 和服务 B 同时发现任务未完成,各自在自己进程里加 sync.Mutex,仍会各执行一次,因为两把本地锁互不认识。分布式锁把竞争状态放到所有实例都能访问的协调服务中。
一、先看业务:本机 Mutex 为什么管不住两台服务器
二、获取锁:SET、NX、PX 必须是一个原子动作
SET lock:order:42 token-A NX PX 30000 的含义是:键不存在时才写入(NX),并同时设置 30000 毫秒到期时间(PX)。TTL(Time To Live,存活时间)就是租约距离自动过期还剩多久;token-A 是本次请求的唯一身份。
SET lock:order:42 8fd2-A NX PX 30000 -- 成功返回 OK:持有一个最长约 30 秒的租约 -- 返回 nil:别人仍持有,调用方应退避、超时或失败
先 SETNX 再 EXPIRE
两条命令之间进程崩溃会留下没有到期时间的锁,其他实例可能永久无法进入。
一个 SET 带 NX 与 PX
创建和到期在 Redis 侧作为一个命令执行,不存在“写了键却没设超时”的中间状态。
三、安全释放:为什么 GET 后 DEL 仍然会误删
释放必须同时满足“键还存在”和“值仍是我的 token”,并把比较与删除放在同一个原子脚本中。
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
不能在客户端先 GET 再 DEL:两条命令之间锁仍可能过期并被别人重新取得。Lua 脚本在 Redis 中原子执行,封住这段竞态窗口。
四、续期不是万能药:旧持有者仍可能写入业务系统
自动续期会在任务还活着时延长 TTL,降低慢任务中途失锁的概率;但进程长时间暂停、网络分区或续期失败后,租约仍会过期。锁服务无法强行停止已经进入临界区的旧任务。
Fencing token(栅栏令牌)是每次成功取得锁时生成的单调递增序号;真正写入资源的下游只接受比历史序号更新的请求,因此能够拒绝已经过期却晚到的旧持有者。
五、主从切换:为什么异步复制会破坏互斥
- A 在主节点写入锁,主节点返回成功。
- 锁记录尚未复制到副本,主节点故障。
- 缺少这条记录的副本被提升为新主。
- B 在新主上也获取同一锁;A、B 都以为自己成功。
Redis 默认复制是异步的,因此“一个主节点加副本”不能在所有故障下保持强互斥。可以接受偶发冲突的效率型任务,与绝不能出现双持有者的资金或主控任务,选型不同。
六、Redlock:多数派租约建立在什么假设上
Redlock 是 Redis 官方描述的多独立实例锁算法。客户端向 N 个互相独立的节点请求相同资源,只有在有效期内取得过半节点成功,才认为拿到锁。
- 记录本地开始时间。
- 用远小于租约的连接与命令超时,依次或并行尝试 N 个独立节点。
- 例如 N=5,至少成功 3 个。
- 总耗时必须小于初始租约;剩余有效期要扣除已耗时与时钟漂移余量。
- 失败时尽快释放所有尝试过的节点,不能只释放自己记得成功的节点。
| 得到什么 | 依赖什么 | 仍没有什么 |
|---|---|---|
| 比单主故障切换更强的多数派租约 | 独立故障、有限时钟漂移、任务在有效期内完成 | 不会自动终止过期客户端 |
| 部分节点不可用时仍可能成功 | 多数节点可达且总耗时够短 | 网络分区下没有无限可用性 |
七、选型:有时根本不需要分布式锁
| 需求 | 更直接的工具 | 原因 |
|---|---|---|
| 订单号不能重复 | 数据库唯一约束 | 最终写入点直接拒绝重复 |
| 库存大于零才扣减 | 条件 UPDATE | 把检查与修改合成原子语句 |
| 消息重复消费 | 幂等表/业务状态机 | 重试是常态,锁不能保留历史 |
| 短时避免重复刷新缓存 | Redis 单实例租约 | 偶发重复通常可接受 |
| 严格主节点选举 | 共识协调系统与栅栏 | 需要更明确的一致性模型 |
// 伪代码:所有等待都必须有总超时
token := randomID()
ok := redis.SetNX(ctx, key, token, 30*time.Second)
if !ok { return ErrBusy }
defer compareAndDelete(ctx, key, token)
fence := nextSequence()
return storage.WriteIfNewer(ctx, resource, fence, result)
八、坑点、面试话术与应用场景
高频坑点
- SETNX 与 EXPIRE 分两条命令。
- 所有客户端用相同 value,无法确认所有权。
- 释放时直接 DEL,误删后继者的锁。
- 租约过期后仍把旧任务结果无条件写入下游。
- 失败重试没有随机退避,形成惊群。
- 业务异常忘记释放,或无限续期掩盖卡死任务。
应用场景
缓存重建、低风险定时任务去重、短时资源协调适合 Redis 租约,因为偶发重复通常可由业务兜底。能由唯一约束、条件更新或消息幂等直接保证结果的场景,以及要求严格线性一致的核心账务,不应硬用 Redis 锁。