为什么需要分布式锁
为了保证一个资源再同一时间只有一个线程访问,传统单机部署的模式下,可以使用java自带的并发包来控制线程间的互斥。但是随着业务逐渐发展,原单机部署的系统演化成分布式系统后,使得原单机部署情况下的并发控制策略失效。为了解决这一问题就,就需要提供一种跨JVM,适用于分布式系统的互斥机制,这就是分布式锁要解决的问题。
MySQL分布式锁
假设在我们的项目在创建初期,并没有引入其他中间件。就可以使用MySQL来完成分布式锁。
MySQL分布式锁主要有两种实现方式:
悲观锁
具体实现是我们会加一个数据库级别的select ... for update排他锁。当某个client的事务成功获取到数据后,就代表成功获取锁。并阻塞其他想要获取锁的client事务。
这个做法其实很简单,但缺点也比较明显。首先是高并发环境下,会占用MySQL珍贵的连接资源,耗费CPU。并且如果某个获取锁的client在释放锁前宕机,就会发生死锁,导致其他获取资源的client不可用。而对于MySQL而言,锁的不释放意味着长时间占用mysql连接池资源,影响mysql并发执行效率。
乐观锁
乐观锁是基于版本号以及轮询+CAS的思想创建的锁。
在事务执行前,我们先查出锁的版本。然后执行我们的业务逻辑。最后我们根据事务开始时查出的锁版本,去更新锁的新版本。若版本不一致,则更新失败,代表本次加锁失败。若更新成功,则代表本次加锁成功。
由此可见,乐观锁的实现方式并不是基于数据库的锁来实现的。因此不会产生死锁问题。
但是问题也很明显,我们每个client都会频繁的请求数据库,造成性能的严重消耗。并且由于我们业务代码的耗时时间不一样,因此抢到锁的概率也无法收到保证。因此MySQL实现分布式锁的缺点是相当多的。市面几乎没有公司会去用MySQL实现分布式锁。
Redis分布式锁
Redis最普通的分布式锁
SET resource_name my_random_value NX PX 30000
- NX:表示只有 key 不存在的时候才会设置成功。(如果此时 redis 中存在这个 key,那么设置失败,返回 nil)
- PX 30000:意思是 30s 后锁自动释放。别人创建的时候如果发现已经有了就不能加锁了。
假设有三个服务器,服务器A,B,C。假设服务器A获取到了锁,那么服务器B和C只能通过轮询+cas的方式进行获取锁。
如果A服务器获取到锁,并且因为各种原因导致持有锁的时间超过了我们设置的redis过期时间,那么A此时redis锁就会失效。其他服务器就再次拥有了获取锁资源的资格,假设B服务器此时获取到了锁。在服务器B执行期间,假设服务器A忙忘完了,开始执行主动释放锁操作了。此时就会释放掉服务器B锁持有的redis锁。为了避免这种情况出现,我们会在加锁的时候会设置一个随机值。在释放锁的时候会与加锁时的随机值进行对比。保证每个服务器只负责释放自己加的锁。最后,为了保证操作的原子性,我们的删除锁都会基于lua脚本进行操作。
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
我们发现,这个是基于单机的redis锁的实现。我们知道主从复制,或者集群脑裂都有可能导致数据丢失的情况。为了解决这个问题,redis还有第二种实现分布式锁的方法。
多节点redis实现的分布式锁(RedLock)
这个锁的算法实现了多redis实例的情况,相对于单redis节点来说,优点在于防止了单节点数据丢失的情况。
实现原理是这样的:假设有5个完全独立的redis主服务器
- 获取当前时间戳,单位是毫秒;
- 轮流尝试在每个 master 节点上创建锁,并根据当前时间戳,推算出一个加锁超时时间;
- 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点 n / 2 + 1;
- 如果建立锁的时间小于超时时间,就算建立成功了;
- 要是锁建立失败了,那么就依次之前建立过的锁删除;
- 当某个服务器成功使用ReadLock算法创建了reids分布式锁后,其他服务器就有需要使用轮询+cas的方式去获取锁。
我们发现,这种一redLock算法建立的锁。在集群中的多个master提供了冗余数据,从而保证了锁的可靠性,但是这种锁的实现方式强依赖于redis集群,成本较高。并且在查看了redlock官网后,这个算法貌似本身也是存在问题的。最关键的是无论用任何一种redis的模式来创建分布锁,似乎总太不过未成果获取锁的服务器总需要使用轮询+cas的这种比较消耗CPU的方式去获取锁,也没有办法解决某个持有锁的client宕机后,只有硬等该redis过期后,别的client才能具备获取锁资格问题。
zk分布式锁
zk 分布式锁,其实可以做的比较简单。就是所有的客户端会尝试创建临时 znode,此时创建成功了,就获取了这个锁;(为什么说是临时节点呢?假设某个client持有锁后发生了宕机,那么zk中的临时节点也会随之销毁,从而释放锁。这解决了redis只能通过过期机制来释放锁的问题)。当某个client成功创建锁后,其他client来创建锁会失败,失败不要紧,他们会顺便在zk中注册个监听器监听这个锁。
释放锁就是持有锁的客户端在zk中删除这个 znode。一旦释放掉就会通知其他注册了监听的客户端,然后其他客户端会开始进行抢锁操作。
那么这样就会有一个问题,当锁释放的时候会通知所有注册监听的客户端,假设现在监听的客户端有上千个呢?
这对zk而言就是一个很大的一个挑战,一个释放锁,就可能唤醒大量的客户端来竞争锁。这也是我们常说的羊群效应。会占用服务资源,网络带宽,甚至有可能让服务器宕机的风险。
创建临时顺序节点
因此,我们往往会创建临时顺序节点。也就是如果有一把锁,被多个人给竞争,那么来访问的客户端就会根据访问顺序排队。后面的每个客户端会去监听自己前面的客户端状态。一旦前面的客户端释放锁,那么排在后面的客户端就会被zk通知。我们发现,这种形式由原来的批量唤醒,改为了顺序唤醒。这个模式与多线程AQS框架内部维护的队列有异曲同工之妙。
redis 分布式锁和 zk 分布式锁的对比
- redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
- redis分布式锁,有一个比较致命的缺点,就是万一某个获取锁的服务在自己释放锁前宕机了,那么其他服务器只能等到redis锁过期后,才能获取锁资源。为了解决这个问题,redis还提供了一个RedLock的解决方案。
- zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。
- zk 分布式锁,仅创建临时znode,当某个持有锁的client宕机后,zk的中的临时znode也会随之销毁。避免了长时间无效的占用锁。
同时,普通redis分布式锁的实现方式,如果某个持有锁的client宕机,那么释放锁只能等待redis过期才能释放锁。而redLock算法实现的也需要依赖redis集群实现。因此,基于以上两点,我个人实践认为 zk 的分布式锁比 redis 的分布式锁牢靠、而且模型简单易用。