分布式锁工程实践
多个实例同时改同一条数据,本地 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 续上。
但它不是保险箱,要注意两点:
- 续期线程依赖 JVM/进程存活。若发生长时间 Full GC 或线程被挂起,续期中断、锁过期,业务恢复后仍会继续写——锁丢了,业务并不知道。
- 因此续期只能降低概率,不能消除并发风险。涉及正确性的操作必须再叠一层幂等或数据库约束(见最后一节)。
Redlock 之争
Redis 官方提出的 Redlock 需要 N 个相互独立的 Redis 主节点,在多数派上加锁成功才算成功。围绕它有一场著名争论:
| 观点 | 要点 |
|---|---|
| 支持方(Redis 作者) | 多数派加锁 + 时钟偏移容忍,可提升单节点故障下的安全性 |
| 反对方(分布式系统学者) | 依赖时钟假设;GC 停顿可让两个客户端同时持锁;没有 fencing token 无法阻止过期持有者继续写 |
务实结论:
- 对正确性有硬要求的场景(扣款、发券),不要把 Redis 锁当作唯一保障。
- 需要严格互斥时,用带 fencing token 的方案(每次加锁返回递增序号,存储侧拒绝序号更小的写),或干脆用数据库行的乐观锁/唯一约束。
- Redlock 适合“减少重复工作、提高效率”这类场景,而不是“防重复扣款”这类正确性场景。
与 ZooKeeper / etcd 对比
| 维度 | Redis | ZooKeeper | etcd |
|---|---|---|---|
| 一致性 | 主从异步复制,故障切换可能丢锁 | 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。