小Cの已经记不起来的博客

分布式锁:写给初学者的通俗解释

前不久上线了一个领优惠券的功能,规则很简单,每个用户限领一张。结果运营半夜找过来,说有个用户领了三张。我翻日志翻到凌晨,代码里的判断写得明明白白,if (received >= 1) return;,逻辑挑不出任何毛病。后来才反应过来:服务是三个实例一起跑的,三个请求分别打到了三台机器上,每台机器都自己判断了一遍“他还没领过”,然后就各发各的了。

这事之后我才明白,以前理解的“加锁”,只在单机世界里成立。

为什么代码里的锁不管用了

刚工作那会儿我以为锁就是 synchronized 和 ReentrantLock,这俩在单个 JVM 里确实好使。但一旦服务部署了两个以上的实例,麻烦就来了:几个进程各自有各自的内存,A 实例上的锁,B 实例根本看不见。

线程锁锁的是同一个进程里的线程,而现在竞争的双方压根不在一个进程里,甚至不在一台机器上。所以你需要一个大家都够得着的公共地方,把锁放在那里,谁抢到算谁的。

这个公共地方,最常用的就是 Redis,也可以是数据库、ZooKeeper、etcd,思路都一样。

分布式锁本质上就是一次抢占

拿 Redis 举例,所谓的加锁,其实就是执行这么一条命令:

SET lock:coupon:1001 uuid-8f3a NX EX 30

拆开看:NX 表示 key 不存在才会设置成功,EX 30 是 30 秒后自动过期。返回 OK 说明你抢到了锁,返回 nil 说明别人正拿着呢。

是不是有点像会议室门口挂个牌子写“占用中”?谁先挂上算谁的,开完会摘下来。这玩意儿看着简单,坑全在细节里。

第一个坑:锁到期了,活还没干完

上面那条命令里我写了 EX 30,为什么必须有过期时间?因为万一拿到锁的进程崩了,没人去释放锁,其他所有人就得永远等下去。有过期时间,至少保证锁不会变成死锁。

但坑也在这儿:假如你的任务要跑 40 秒,锁 30 秒就过期了。第 31 秒 B 实例抢到锁开始干活,第 40 秒你干完去释放锁,这时候释放的是 B 的锁,B 还一脸懵地继续跑,俩人又在并发了。

网上大把教程演示 setnx 的时候都不带过期时间,也不知道是故意省略还是没踩过这个坑...

第二个坑:别把别人的锁给删了

顺着上面的问题,释放锁的时候不能无脑 DEL,得先判断这把锁是不是自己的。所以加锁时要在 value 里塞一个唯一标识(UUID 就行),释放前先比对一下。

但“比对”和“删除”是两步操作,中间一样可能被插进来。所以这两步必须原子完成,惯用做法是塞一段 Lua 脚本给 Redis:

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Redis 执行 Lua 脚本时不会穿插别的命令,原子性就有了。

偷懒方案:让现成的库帮你兜底

其实还有个更隐蔽的问题:任务跑 40 秒,锁 30 秒过期,能不能让锁“续命”?可以,就是所谓的看门狗机制——拿到锁之后起一个后台线程,隔一段时间检查活儿干完没,没干完就给锁续期。

这些细节自己手写真的容易漏,我的建议是直接用 Redisson(我这里用的是 Java,其他语言也都有对应的库,思路是一样的):

RLock lock = redisson.getLock("lock:coupon:" + userId);
lock.lock();
try {
    // 干活
} finally {
    lock.unlock();
}

看门狗、可重入、释放校验,它都帮你处理好了。顺便说一句,释放锁一定要放在 finally 里,不然代码一抛异常,锁就挂在那儿了。

不用 Redis 行不行

行。量不大的场景,数据库就够用:

ZooKeeper 和 etcd 也常被拿来做锁,靠的是“临时节点”,客户端一断连,节点自动消失,锁自动释放,对崩溃场景天生友好。至于 Redis 的 RedLock 到底靠不靠谱,网上两位大佬吵了很多年,初学者先不用掺和,知道有这回事就行。

写在最后

绕了一大圈,其实分布式锁就一句话:在一个所有进程都能看到的地方,用原子的方式抢占一个标记。

记住三个坑:锁必须带过期时间、释放前必须校验身份、比对和释放必须原子。以及,动手之前先想一下,这个并发问题是不是一条数据库唯一约束就能解决——能简单就别复杂,锁这东西,用好了你基本感知不到它的存在,出问题的时候才会想起来它。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放