分布式锁:看起来简单,其实不简单

作者:树懒先生 发布时间: 2025-11-15 阅读量:19 评论数:0

那天早上运营找过来,说有个用户的额度被扣了两次。我看了一眼日志,两条扣减记录时间戳差 40 毫秒,来自两个不同的实例。加锁的代码明明就在那儿。

后面几个月我把这个东西从头到尾踩了一遍,记录一下每一层是怎么塌的。

第一层:只加锁,不设过期

最朴素的版本:用一个原子的「不存在才写入」操作占位,成功就是拿到锁,处理完删掉。

问题在于进程可能死在中间。一旦实例在持锁期间被重启或者被杀掉,这把锁就永远留在那里了。我们那次是发布时正好赶上,结果某个用户的操作被卡了整整一晚上,第二天靠人工删键才恢复。

第二层:设了过期,但删错了锁

加过期时间是必须的,但它引入了新问题:业务执行时间可能超过过期时间

设想过期 10 秒,A 拿到锁开始处理,因为下游变慢处理了 12 秒。第 10 秒锁自动释放,B 拿到了锁开始处理。第 12 秒 A 处理完,执行删除——它删掉的是 B 的锁。于是 C 又能进来了。这时候你有两个人同时在临界区里,锁形同虚设。

修复方式是加身份标识:写入时带一个随机值,删除前先检查值是不是自己的。

第三层:检查和删除不是原子的

上面那个修法有个更隐蔽的漏洞。检查通过之后、执行删除之前,还有一段时间窗口,锁可能正好在这个窗口里过期并被别人拿走。窗口很小,但线上流量足够大的时候,小概率每天都会发生。

正确的做法是把检查和删除放在一段服务端脚本里原子执行。这一步之后,我以为终于稳了。

第四层:锁过期了,业务还在跑

身份校验解决的是「不误删别人的锁」,但没解决「我的锁提前没了」。业务还在临界区里跑,锁已经归别人了,照样并发。

常见解法是续期:起一个后台任务,每隔过期时间的三分之一检查一次,如果业务还在跑就把过期时间往后推。这确实管用,但它也带来复杂度——续期任务本身要处理网络失败、要能感知主逻辑已结束、还不能因为续期失败就默默继续跑。

第五层:那些你控制不了的东西

然后是几个真正让我改变想法的问题:

  • 主从切换。锁写在主节点上,还没同步到从节点,主节点挂了,从节点被提升。新主上没有这把锁,另一个客户端顺利拿到。这个场景没有简单解法。
  • 进程暂停。运行时的垃圾回收停顿、虚拟机被挂起、宿主机负载过高,都可能让你的进程在两行代码之间停上几秒。停顿期间锁过期了,恢复后你的代码毫不知情,继续往下写数据。
  • 时钟。过期依赖时间,而不同机器的时间不一定一致。

我最后的结论

分布式锁降低冲突的概率,但不能消除冲突。任何把正确性完全押在锁上的设计,在足够长的时间尺度上都会出问题。

所以真正救了我们的不是锁,是这两样东西:

  1. 数据库唯一约束。扣减操作带一个业务流水号,流水号上有唯一索引。重复请求进来会直接撞索引失败,这是数据库保证的,不依赖任何分布式协调。
  2. 状态机的条件更新。更新语句写成「当状态是待处理时才更新为处理中」,用受影响行数判断自己是不是抢到了。这一步在数据库层面天然是串行的。

有了这两层之后,锁的定位就变了:它不再是正确性的保证,而是一个性能优化——避免大量请求同时冲到数据库上互相撞索引。这个定位一变,我对它的容忍度就高了很多,也不再纠结要不要上那些更复杂的多节点算法。

顺带说个不太光彩的细节:我们最早那个重复扣减的 bug,锁本身写得没问题,问题是加锁的键里拼了用户 ID 和一个会变的时间片,两次请求跨了时间片边界,用的根本不是同一把锁。写了半天防御,输在拼字符串上。

评论