分布式理论04:分布式锁
从重复执行定时任务、库存扣减和并发审批这些场景出发,理解分布式锁的互斥、租约、续期、释放和失效边界。
为什么本地锁不够
单个 JVM 里,synchronized、ReentrantLock 能保护临界区。问题是,服务一旦部署多实例,每个 JVM 只有自己的本地锁。
如果三台订单服务实例同时跑超时关单任务,本地锁只能保证每台机器内部不并发,保证不了三台机器之间只有一个实例执行。
这就是分布式锁要解决的问题:多个进程、多个节点争抢同一份资源时,系统需要一个共同认可的互斥标记。
但分布式锁不是”加一把锁就安全”。它真正难在锁的生命周期:谁拿到、多久过期、能不能续期、谁能释放、释放失败怎么办、锁失效后业务有没有二次保护。
锁应该保护资源,而不是保护代码
很多问题出在锁粒度。比如库存扣减,不应该用一个全局 stock-lock 把所有商品都锁住,而应该按商品或活动维度加锁:
lock:stock:sku-10001
lock:activity:20260623
lock:order-close:shard-03
锁的 key 要和被保护的资源对应。粒度太粗,会把系统吞吐打没;粒度太细,又可能漏掉真正冲突的资源。
项目里我会先问:如果两个请求同时执行,哪一份数据会被写坏?锁就应该围绕这份数据设计。
Redis 锁的四个底线
很多 Java 项目会用 Redis 做分布式锁。常见底线有四个。
| 底线 | 原因 | 错误做法 |
|---|---|---|
| 加锁必须原子 | 不能先 setnx 再单独 expire,中间宕机可能留下死锁 | setnx + expire 分两步 |
| 锁值要有唯一标识 | 释放锁时必须确认这把锁是自己加的,不能误删别人的锁 | 所有客户端用同一个 value |
| 释放锁要原子判断和删除 | 刚判断完锁属于自己,锁过期后被别人拿到,再删除就删了别人的锁 | 先 get 再 del 分两步 |
| 过期时间要结合业务耗时 | 过短会导致业务没执行完锁就过期,过长会让故障恢复变慢 | 凭感觉设一个很大的值 |
这些规则不绑定具体客户端版本,底层意思是:锁必须有租约,释放必须认主人。
再深一点:fencing token
只靠”锁还在不在”并不能覆盖所有异常。假设线程 A 拿到锁后发生长时间 STW,锁过期;线程 B 拿到新锁并完成写入;线程 A 恢复后继续写数据库。此时 A 已经不是合法持有者,但它自己并不知道。
解决这类问题的一种思路是 fencing token。每次成功获得锁时,额外拿到一个递增令牌:
A 获取锁,token=101
A 卡顿,锁过期
B 获取锁,token=102
B 写数据库,last_token=102
A 恢复后尝试写入 token=101,被数据库拒绝
数据库侧用条件判断保护:
update resource
set value = ?, last_token = ?
where id = ?
and last_token < ?;
这样即使旧持有者恢复执行,也不能覆盖新持有者的结果。
不是所有场景都必须做到这一步,但如果锁保护的是资金、库存、调度主节点这类关键资源,就不能只相信客户端”我还拿着锁”。
锁的续期也有边界
很多客户端会提供 watchdog 续期机制,看起来能避免业务执行时间超过锁过期时间。它确实有用,但不能把业务变成无限执行。
续期至少要有三个限制:
- 最大持锁时长:超过就报警或中断,避免锁永久占用
- 业务可取消:拿着锁的任务要能感知关闭信号
- 监控持锁时间:长时间持锁通常意味着业务逻辑、下游调用或死循环有问题
如果一个任务经常需要续期很多次,应该反过来审视任务是否需要拆分、是否应该用队列串行化,或者是否应该改成状态机推进,而不是继续加大锁过期时间。
Redis vs ZooKeeper / etcd
| 方案 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|
| Redis 锁 | 最终一致,极端情况可能丢失 | 高 | 高频业务写操作、库存扣减、任务去重 |
| ZooKeeper / etcd | 强一致,会话语义好 | 相对低 | 主节点选举、集群调度、任务分片 |
ZooKeeper、etcd 这类协调系统通常用临时节点、租约和有序节点来实现锁。它们更强调一致性和会话语义,适合对锁正确性要求更高的场景。代价是复杂度和性能成本更高,不适合把所有高频业务写操作都塞进去。
所以选 Redis 还是 ZooKeeper,不是看哪个名字更高级,而是看业务对吞吐、延迟、一致性和故障语义的要求。
锁失效后业务还要兜底
分布式锁最重要的现实判断是:锁可能失效。
比如一个线程拿到锁后发生长时间 GC,锁过期了,另一个线程拿到新锁开始执行。第一个线程恢复后继续写数据库。此时如果只靠锁,数据就可能被写坏。
所以关键业务不能只靠锁,还要有数据库约束或状态机兜底:
update coupon
set status = 'USED'
where id = ? and status = 'LOCKED'
这类条件更新能保证即使锁失效,状态也不会被非法推进。锁负责降低并发冲突,数据库条件和唯一约束负责最终正确性。
常见误区
误区一:用锁解决所有并发问题
锁会降低并发度,还会引入等待和死锁风险。能用唯一索引、乐观锁、状态机解决的,不一定要上分布式锁。
误区二:锁住代码块,却没有锁住资源
不同入口操作同一份数据,如果 key 不一致,锁就形同虚设。
误区三:释放锁不校验 owner
这个坑很隐蔽,线上偶发数据错乱时很难查。
误区四:认为 RedLock 这个名字一出现就万事大吉
分布式锁方案一直有不同观点和适用边界,项目里要明确自己接受的故障模型,而不是只背算法名。
项目里怎么判断要不要用锁
可以按这个顺序想:
- 是否真的存在跨节点并发写同一资源?
- 能不能用数据库唯一约束、乐观锁或状态机解决?
- 如果用锁,锁 key 是否和资源一一对应?
- 业务执行时间是否可控,租约怎么设置?
- 锁过期后继续写,数据库层有没有兜底?
- 获取锁失败时,是等待、快速失败,还是稍后重试?
分布式锁不是正确性的全部,它只是并发控制的一层。