本文首发于“雨夜随笔”公众号,欢迎关注。
现实生活中,当我们需要保护一样东西的时候,就会使用锁。例如门锁,车锁等等。很多时候可能许多人会共用这些资源,就会有很多个钥匙。但是有些时候我们希望使用的时候是独自不受打扰的,那么就会在使用的时候从里面反锁,等使用完了再从里面解锁。这样其他人就可以继续使用了。
这个就设计到编程中非常重要的概念--锁。当我们有多个进程或者线程需要共享资源时,都不可避免的使用锁来防止资源被篡改。
对于锁的使用,编程中有两种方式,一种是乐观锁,一种是悲观锁。就像心理学中的描述一样,乐观和悲观并没有谁好谁坏。同样乐观锁和悲观锁也只是适用的场景不一样,并没有说谁一定好,谁一定坏。
乐观锁:总是假设最好的情况,即自己取资源时认为别人不会修改。然后在更新的时候判断一下别人有没有更新这个资源。
悲观锁:总是假设最坏的情况,每次去资源时都认为别人会修改。所以每次取资源时都要给资源上锁。
乐观锁其实并没有使用锁,因为乐观锁在操作时总是乐观的认为不会有资源争抢问题,所以也不会上锁。相比于悲观锁来说,乐观锁减少了加锁和解锁的步骤,也不会在操作时阻塞,一直占用着资源。比较适合“读”比较多的场景。乐观锁的实现方式常见的有两种:版本号和 CAS (Compare and Swap):
版本号:在资源表加上一个版本号,表示资源被修改的次数。这样当需要更新时可以通过检查版本号来判定是否被别人更新。
CAS算法:全称是 Compare and Swap,是一种无锁算法。其内容是当需要更新的值 V 达到期望的值 A 后将其更新成 B。而且CAS的比较和替换是一个原子操作,所以能够保证并发安全。
乐观锁存在着一些问题,比如常见的“ABA”问题:
ABA问题:资源的值一样并不代表资源没有被修改过,比如一个值开始读到的值是A,然后被进程a修改一段时间,这段时间被另一个进程b修改成B,然后又修改成A。a修改完成后再去读,发现值还是A,就认为值没有被修改过。但其实值已经被修改过了。所以即使操作最后成功了,但是并不能保证整个过程是没有问题的。
除此之外,如果应用突然“写”操作很多的话,更新就会一直不成功,然后进程就会不断的尝试,这会给CPU带来较大的开销,当然乐观锁只能保证一个变量的原子操作。所以需要涉及到多个变量时,就会出现问题。当然这些问题都可以得到解决,比如Java,针对ABA问题,可以保存变量的版本,这样即使修改后值一样也能知道值已经被其他进程修改了。而针对CPU开销,JVM提供了pause指令,能够使效率得到提升。而多个变量,Java 1.5之后提供了AtomicReference类保证引用对象的原子性,这样就可以把多个变量放到一个对象中了。但最为重要的是我们要在适当的场景中使用乐观锁,在“读”较多的应用中,使用乐观锁,可以提高吞吐量。
那么在“写”较多的应用中,我们就可以使用悲观锁了。Java中的 synchronized 就是典型的悲观锁,通过 Lock-Free 的队列,先阻塞,然后释放后再重新竞争锁。在资源冲突严重的情况下,悲观锁的性能更为强大。
那么说完锁之后,就来到我们这篇文章的主题--分布式锁。想谈这个的原因是因为最近看 Kubernetes 的时候发现了Kubernetes中为了高可用也实现了自己的资源锁机制,和现在主流的不一样。所以就开始了解一下资源锁的不同实现方式。那么在开始说到Kubernetes的资源锁机制时,我们先看一下主流的分布式锁机制。
分布式锁的一般实现原理就是大家先抢锁,抢到的人成为Leader(Leader选举机制),然后Leader定期更新状态,以不被其他人把锁抢走。如果失去Leader,则剩下的人继续抢锁。
无论分布式锁如何实现,都必须要满足一定的条件才能保证资源的同步,这些条件包括:
互斥性:在任意时刻,只有一个客户端能够持有锁。 避免死锁:即使客户端在持有锁的时候发生崩溃而没有主动解锁,也要保证后续其他客户端能够获取到锁。 容错性:只要大部分节点正常,客户端就能正常加解锁。 统一性:加锁和解锁必须是同一个客户端,客户端不能解开别人的锁。
目前主流的分布式锁有三种方式:数据库,缓存和Zookeeper等中间件。我们分别来看一下:
数据库:有两种实现方式,基于数据库表的增删和数据库自带的排他锁。优点和缺点都很明显,优点是借助数据库,易于理解和使用。缺点也是因为借助数据库,操作上会有一定的开销,存在一定的性能问题。 缓存:缓存的方式其实也很简单,就是把锁的信息保存到缓存中,比如Redis,会保存锁的ID,锁的拥有者ID和设置锁不存在时才能进行设置。同时设置锁的过期时间,在过期时间没有更新的话,就会重新争抢锁。 Zookeeper:Zookeeper作为典型的分布数据一致性解决方案,在分布式锁的实现上也更为成熟。首先Zookeeper专门设计了一种支持崩溃恢复的原子广播协议Zab (Zookeeper Atomic Broadcast)。其中就定义了如何进行Leader选举,在Zab协议中,只要有半数以上的服务端成功,则认为数据成功。每个客户端会监听自己注册节点序号之前以为的子节点信息,如果前一个节点是Leader节点,那么当Leader节点被删除后,该节点就会监听到这个事件。而且由于创建的是临时节点,所以即使Leader节点崩溃,那么Zookeeper也会删除掉Leader节点,其他节点可以继续竞争Leader。Zookeeper的数据都保存在内存中,所以具有高吞吐量和低延迟的特点。
那么分析完主流的分布式锁,我们看一下Kubernetes的实现方式,Kubernetes的实现方式和上面的都不同,Kubernetes采用自己的Resource来保存锁的信息,目前实现的有ConfigMap和Endpoint。并且并没有提供删除锁信息的方法。因为Kubernetes采用的是抢到锁的节点只需要更新就行了,不需要之前的节点删除锁信息。具体的方法就是,首先大家都去争抢锁,争抢锁的时候会检查锁是否存在,如果存在是否还在有效期,如果不存在或者已经不再有效期时,则认为自己获取到锁,争抢到锁的节点把自己的信息更新上去,然后在一定时间定期更新自己的信息就可以一直拥有锁。而如果没有在规定时间内更新自己的信息,则认为失去锁,其他节点可以去争抢锁。
简单来说,整体过程就是抢到锁的节点会将自己的标记设为锁的持有者,其他人则需要通过对比锁的更新时间和持有者来判断自己是否能成为新的 leader ,而 leader 则可以通过更新信息来确保持续保有该锁。
其实看起来Kubernetes资源锁的实现方式并不是很优雅,因为其他节点并没有第一时间获取到Leader失去锁的信息。但是却解决了资源争抢的问题,因为它满足分布式锁的四个条件。同时也为我们提供了一种新的思路。总体来说,Kubernetes的实现方式还是蛮有意思的。
完整内容限于篇幅不在此展示,可以关注下方的公众号获取