最近遇到一个有意思的关于分布式锁的问题,期间产生了有很多有意思的问题和讨论,这里记录一下。

在大多数场景下很多程序员都喜欢使用redis来做分布式锁,但是公司内最近缓存服务为了推行标准化禁用了lua脚本,使得原有的分布式锁实现都要另谋出路,最后选择了zk来做分布式锁,因为go-zookeeper只支持阻塞锁,做了一些改造使其支持非阻塞和待失效时间的锁,有类似需求的同学也可以参考下

https://github.com/zhaocheng-dev/go-zookeeper

为什么都喜欢用redis做分布锁

其实大家都知道redis是会丢数据的,用redis来做分布式锁是有风险的,但是为何都喜欢用redis呢?我理解其实2个原因

  • 因为本身服务已经用redis做了一些缓存,如果用zk之类的虽然安全可靠,但是还要引入一套zk,开发成本和运维成本都很高。用db一般还要建表,而且不支持非阻塞的锁之类场景支持。
  • redis虽然不可靠,但99%的场景不会出错,而且速度很快。很多业务不得不说对可靠性要求没有这么高,偶发的错误可以修一修数据成本还是可接受的。

为什么要去lua脚本

lua脚本的问题在于不可控,缓存对延迟是很敏感的,一个大的事务可能会阻塞缓存的服务响应其他请求,因此禁用lua脚本也是合理的。但是具体到分布式锁的场景,其实我不太赞同缓存直接拒绝该类服务,有两类基本的思路其实可以解决问题:

  • 一类是如果缓存带有前置的proxy节点的缓存服务可以提供特定的自定义协议作为lua脚本分布式lock和unlock的功能,因为2个操作是非常普遍的需求,而且不是大事务。
  • 另一个思路使用带版本号的更新/删除操作,也一样可以实现分布式锁,现在很多缓存服务都提供类似的功能。

为何选择zk

技术选型是一个蛋疼的问题,必须要有取舍,首先要看下需求的场景是什么。
我负责的是一系列发起异步流程的服务,当有外部服务调用我的系统的时候我会将数据先落库,然后再通过异步协程捞起进行处理。这里如果希望数据不被重复处理必然就需要分布式锁。

当然如果仅仅针对这一个场景来说用db来做也是可以的,或者改成消费队列也可以,不过redis的锁也是一个必须要解决的问题。_

简单抽象一下我们其实希望有一堆计算能力去对待计算的资源做争抢,所以我其实希望的是有一个带有超时和重试功能的非阻塞锁。

其次看下公司内的选型和当前有的资源

  • 最理想的其实我是希望有待版本号的缓存资源,可惜并没有。
  • zk和etcd功能上都是ok的,毕竟go与etcd更配,问题在于etcd没有基础组件的人维护,因此zk基本成了比较好的选择。
  • 期间还尝试调研了一种类似的tikv的分布式kv存储,可惜这种数据库面对热点会有问题,不太适合作为分布式锁。

go-zookeeper的问题

zk当然是可以做分布式锁的,问题在于和go并不搭配,调研一下市面上的go的zk客户端,见到比较好的就go-zookeeper,但是其实这个库已经挺久没更新了。最后一次在19年8月份,而且只支持阻塞锁,因此对这些功能做了一些改造。顺便复习了一下zk实现分布式锁的基本原理

zk的分布式锁实现基本原理

zk的分布式锁的基本原理可以分为几步:

  1. 创建一个临时顺序节点node
  2. 获取当前节点的所有node的子节点
  1. 如果自己创建的节点是所有node子节点中的顺序号最小,则说明获取了当前的锁,
  2. 如果没有获取到则需要监听当前节点的前一个节点,当其消失时则获取了锁。
    通过上面的机制zk避免了获取锁的羊群效应。

重试、非阻塞、超时机制

上小节的内容实际上go-zookeeper已经完成了。问题在于不支持一些小的功能,我在这上面做了一些小的改造具体可以看代码哈。

  • 重试
    原始的重试策略是固定3,改成了指定参数。
  • 非阻塞
    在上述第二步的时候当前节点的不是最小的子节点则直接放弃。
  • 超时机制
    在监听过程中select当前context的done的chan。

go有没有可重入锁?

期间还有一个有意思的问题,java做锁的时候还有一个概念叫可重入锁,但是到go的场景下我觉得似乎失效了。

主要原因是go使用的是协程,协程与操作系统的线程不是一一对应的关系,而java是线程,与操作系统的线程是一一对应的关。

在实现可重入锁的时候,java的策略通常是根据当前线程来判定的,如果是当前线程就维护一个信号量+1。但是go里面就不能用当前协程来实现了,只是现在我没听过goroutineid的概念。如果在每次创建go routine的时候设置一个uuid到context中似乎可以,不过依赖于项目的改造。

除此之外我还在想,以前java的可重入是不是有问题?因为如果有一个线程在逻辑A获取锁之后进行了线程切换,当前线程被分配到其他逻辑B中那是不是也可以重入之前工作逻辑A中的锁?这样似乎也是有问题的?似乎最好的方法就是不用可重入锁。