分布式锁还有这个坑~

262 阅读6分钟

这是一篇3分钟就能阅读完的文章,相信对你有极大的实战帮战

hi,大家好,我是康师傅,最近看到一个比较有意思的问题,关于分布式锁的,自己平时在工作中也有用到分布式锁,但是确实也没注意到一些临界值的问题,说白了就是没有进行深度思考。关于这个标题,你可能会比较诧异,锁还能怎么优雅删除?直接一个 delete 不就完了,不然还怎么删除。

首先啊,我们先简单说下为什么要分布式锁,现在基本上都是分布式系统,应该没有什么系统是部署的单节点吧,因为单节点风险比较大,如果节点宕机,那么整个应用就起不来,如果是多节点,那么好处就多多了,可以做到负载均衡,高可用,即使一个节点挂了,还有其他节点可用,服务整体依然可用。

分布式虽好,但是也带来很多问题,比如对于临界资源的保护,在分布式系统下,自然而然离不开分布式锁的使用,分布锁的实现可以用 redis、zookeeper 等,但这些不是我们今天讨论的重点。

以 redis 为例,我们可以使用 redis 的 setnx key value来简单实现一个分布式锁,它是原子性的,只有当 value 不存在的时候,才会设置成功,因此用它来实现分布式锁再好不过。

但是啊,这里有个问题,代码的业务逻辑复杂,很多地方有 return ,那么对应的我们是不是要在所有 return 的地方也解锁。

setnx lock 1
if xx {
  delete(lock)
	return 
}
if xx {
  delete(lock)
	 return 
}
...
doSomething()
delete(lock)
return 

如果我们不小心在某个地方忘记了删除(解锁),那么这个锁就永远无法解开了,这就会导致线上事故了。于是我们可以对这个锁加个过期时间:setex key timeout value,这样就可以类似做个保底的操作,即使忘了删除锁,也可以通过过期时间来降低风险。

因此问题来了,如果锁时间到了,但是还没执行完逻辑,最后处理完逻辑再正常删除会导致什么问题?

  1. 刚开始 A、B 节点都去争抢锁 setex lock 1 a
  2. A 成功获得锁,并且锁的有效时间是 1s,然后 B 可能自旋等待获取锁
  3. 这时 A 由于一些网络状况,导致本来应该很快结束的逻辑,超过了1s才完成,这时锁自动失效,B获得锁(A、B 同时在临界区)
  4. 由于 A 执行完毕逻辑,然后执行删除锁 del lock,这时导致删除了 B 的锁,然后 C 获得锁 (B、C 同时在临界区)

这时是不是发现了问题所在,造成这个问题的根本原因就是节点 A 删错了锁,把 B 的锁给删了,那如何避免呢?

延长

我们可以这样想,A 的业务逻辑还没走完,就不要放 B 进来,哪怕 A 花了很长时间,所以我们的锁可以不加过期时间,这样的话锁就不会自动消失,但是你要承担异常带来的风险:比如我们上面说到的在某个分支判断处忘了删除,或者程序还没走到解锁的时候异常退出,这些风险还是挺高的,那有什么办法让锁有过期时间,也不会在业务逻辑还没走完的时候自动失效呢?答案就是自动延长,比如起一个监听线程,这个线程干两件事:

  1. 监听锁的剩余时间
  2. 如果锁的剩余时间没多少了,但是业务的处理的进度还剩的比较多,尝试延长时间

当然这个只是个想法,真正实现起来,我觉得相对还是比较不好把握这个“度”的,比如剩余多少时间开始尝试延长,每次延长多少是个问题,如果延长的时间比较短,那么可能还要几次延长,延长的比较长可能还比较好,因为可以自己删除。

唯一

我们再来看看另一个更简单的方法,我们这次不考虑延长锁的时间了,失效就失效了,只不过我们要确认要删除的锁是不是一开始我们上的那一把?那如何确认呢?我们只要在一开始上锁的时候设置一个唯一 ID 来替代呆板的“a” (setex lock 1 a),这样下次准备删除的时候我们先 check 下这个 value 是不是我们一开始设置的唯一 ID,如果是的话,说明是我们自己上的那一把,如果不是的话,那么说明锁在我们执行期间失效了,然后给别人上了,我们忽略就行,不需要删除。

val = uuid()
set lock 1 val
dosomething()
if redis.get("lock") == val {
	del("lock")
}

看着好像没毛病,但是我们千万不要忘记原子性这个东西,我们知道 redis 本身处理命令是个单线程,单个指令不可分割,可以保证原子性,但是在此例子中,发现没有~我们先 get 判断了下值,然后再删除,这整个过程其实不是原子性的,我们看下下面的例子:

  1. 用户 A 生成 uuid
  2. 用户 A 获得锁,并且锁的 value 就是 A 的 uuid
  3. 然后 A 处理完自己的逻辑,获取锁的 value,发现是自己的 value
  4. 用户 B 进来 生成 uuid
  5. 正好锁的时间到了,锁自动失效
  6. 用户 B 上锁成功
  7. 用户 A 删除锁,尴尬了,还是删错了锁

造成这个问题的根本原因,就是用户 A 在读取 lock 和删除 lock 这个时间期间被用户 B 正好插入了进来,从而造成了误删,那如何解决这个问题呢?其实也很简单,redis 提供了 lua 脚本,lua 脚本会被 redis 当成一个整体,从而保证原子性,我们只需要把 if get 和 del 用 lua 实现即可,具体 lua 怎么编写,这里就不细说了。

ok,这次要说的就这么多,现在回想起来我以前用的很多分布式锁他们的 value 都是简单的 1,既没有考虑到误删也没有考虑到原子的问题,不知道你们有没有踩过同样的坑。

最后如果你喜欢我的文章,觉得我的文章对你有帮助的话,点个赞~

微信公众号 假装懂编程

往期精彩: