分布式锁工程实践

多个实例同时改同一条数据,本地 synchronized 和进程内锁都失效了,这时需要一把跨进程的锁。分布式锁看着简单,坑却不少:锁超时了业务还没跑完、释放时删了别人的锁、Redis 主从切换把锁弄丢了。本章讲清楚正确的写法与取舍。

一把合格的分布式锁要满足什么

  • 互斥:任意时刻只有一个持有者。
  • 防死锁:持有者崩溃后锁能自动释放,所以必须有超时时间。
  • 安全释放:只能自己删自己的锁,不能删掉别人的。
  • 可重入(视业务):同一线程重复加锁不应阻塞自己。
  • 高可用:锁服务本身不能是单点。

Redis 加锁的正确姿势

加锁必须用一条原子命令:SET key value NX PX ttl。不要用 SETNX + EXPIRE 两条命令,中间宕机就会产生永不过期的锁。

# 加锁:NX 只在 key 不存在时设置,PX 30000 表示 30 秒后自动过期
redis-cli SET lock:order:1001 "uuid-7f3a" NX PX 30000
# 输出:OK 表示加锁成功;(nil) 表示锁已被别人持有

# 查看当前持有者,排查问题时很有用
redis-cli GET lock:order:1001
# 输出:uuid-7f3a

value 必须是每个客户端唯一的标识(UUID、实例ID:线程ID),它决定了后面能不能安全释放。

释放锁要用 Lua

“先 GET 比较、再 DEL”不是原子操作:比较通过后、删除前锁刚好过期并被别人拿到,就会误删别人的锁。用 Lua 把两步合成一次原子执行:

// 以下片段需放进使用 Redis 客户端的工程中运行
String script =
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "  return redis.call('del', KEYS[1]) " +
    "else return 0 end";
Object r = jedis.eval(script,
    Collections.singletonList("lock:order:1001"),
    Collections.singletonList("uuid-7f3a"));
// 返回值 1:锁是自己的,已删除;返回 0:锁已不属于自己,什么都不做

加锁失败怎么等

拿到锁才干活,拿不到要有策略,不能死等也不能疯狂重试:

策略做法适用
立即失败拿不到直接返回“请稍后重试”用户可感知的重复提交
有限重试固定间隔或指数退避重试若干次一般后台任务
阻塞等待轮询到超时为止强依赖的串行任务
// 以下片段需放入 main 函数中运行
deadline := time.Now().Add(3 * time.Second)
locked := false
for !locked && time.Now().Before(deadline) {
    ok, err := rdb.SetNX(ctx, "lock:order:1001", token, 30*time.Second).Result()
    if err == nil && ok {
        locked = true // 加锁成功
    } else {
        time.Sleep(50 * time.Millisecond) // 间隔重试,避免打爆 Redis
    }
}
if !locked {
    log.Println("获取锁超时,快速失败") // 输出:获取锁超时,快速失败
}

锁粒度与超时时间

  • 粒度:锁越细并发越高。按业务主键分段加锁(lock:order:1001)远好于全局大锁(lock:order),后者会让所有订单串行。
  • 超时:TTL 必须大于“最坏情况下的业务耗时”。估算方式是看 P99 耗时,再留 2~3 倍余量。
  • 写死 TTL 的问题:TTL 太短,业务没跑完锁就过期,别人进来,两个线程同时改数据;TTL 太长,持有者崩溃后别人要等很久。

看门狗与误判

解决“业务没跑完锁过期”的常见办法是自动续期(俗称看门狗):后台线程每隔 TTL/3 检查一次,只要业务还在跑就把 TTL 续上。

但它不是保险箱,要注意两点:

  1. 续期线程依赖 JVM/进程存活。若发生长时间 Full GC 或线程被挂起,续期中断、锁过期,业务恢复后仍会继续写——锁丢了,业务并不知道
  2. 因此续期只能降低概率,不能消除并发风险。涉及正确性的操作必须再叠一层幂等或数据库约束(见最后一节)。

Redlock 之争

Redis 官方提出的 Redlock 需要 N 个相互独立的 Redis 主节点,在多数派上加锁成功才算成功。围绕它有一场著名争论:

观点要点
支持方(Redis 作者)多数派加锁 + 时钟偏移容忍,可提升单节点故障下的安全性
反对方(分布式系统学者)依赖时钟假设;GC 停顿可让两个客户端同时持锁;没有 fencing token 无法阻止过期持有者继续写

务实结论:

  • 正确性有硬要求的场景(扣款、发券),不要把 Redis 锁当作唯一保障。
  • 需要严格互斥时,用带 fencing token 的方案(每次加锁返回递增序号,存储侧拒绝序号更小的写),或干脆用数据库行的乐观锁/唯一约束。
  • Redlock 适合“减少重复工作、提高效率”这类场景,而不是“防重复扣款”这类正确性场景。

与 ZooKeeper / etcd 对比

维度RedisZooKeeperetcd
一致性主从异步复制,故障切换可能丢锁ZAB 多数派,强一致Raft 多数派,强一致
性能最高中等中等
释放方式TTL 到期或主动删除会话断开自动删除临时节点租约到期或主动撤销
排队公平性轮询,不公平监听前驱节点,天然排队租约 + Watch
实现成本

ZooKeeper 的做法是每个竞争者创建临时顺序节点(如 /lock/order/order-0000000012),序号最小者持锁,其余监听自己前一个节点;会话超时节点自动消失,锁随之释放。etcd 则用租约(lease)加事务比较版本来实现。

锁 + 幂等:双保险

真正稳的做法是让锁只负责“减少并发、提升性能”,正确性交给幂等:

-- 唯一约束是最后一道防线:即使锁失效导致并发进入,重复数据也插不进去
ALTER TABLE coupon_record ADD UNIQUE KEY uk_user_activity (user_id, activity_id);
业务模板:
1. 加锁(失败则快速返回)
2. 校验业务状态(是否已处理)
3. 写库:靠唯一约束/状态条件更新兜底
4. 释放锁(Lua 校验持有者)

小结:Redis 锁的正确姿势是 SET NX PX + 唯一 value + Lua 安全释放,必要时加续期;粒度按业务主键细分,TTL 留足余量。Redlock 有争议,别把它当作正确性保证;对正确性敏感的业务用“锁 + 幂等 + 数据库唯一约束”三重防线,需要严格互斥时优先考虑 ZooKeeper / etcd。

笔记加载中…