分布式理论04:分布式锁

从重复执行定时任务、库存扣减和并发审批这些场景出发,理解分布式锁的互斥、租约、续期、释放和失效边界。

字数 1660 阅读时长 ≈ 5 分钟 2026-5-31 2026-7-12
分布式理论04:分布式锁

为什么本地锁不够

单个 JVM 里,synchronizedReentrantLock 能保护临界区。问题是,服务一旦部署多实例,每个 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 这个名字一出现就万事大吉

分布式锁方案一直有不同观点和适用边界,项目里要明确自己接受的故障模型,而不是只背算法名。

项目里怎么判断要不要用锁

可以按这个顺序想:

  1. 是否真的存在跨节点并发写同一资源?
  2. 能不能用数据库唯一约束、乐观锁或状态机解决?
  3. 如果用锁,锁 key 是否和资源一一对应?
  4. 业务执行时间是否可控,租约怎么设置?
  5. 锁过期后继续写,数据库层有没有兜底?
  6. 获取锁失败时,是等待、快速失败,还是稍后重试?

分布式锁不是正确性的全部,它只是并发控制的一层。