如需转载,请根据 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 许可,附上本文作者及链接。
本文作者: 执笔成念
作者昵称: zbcn
本文链接: https://1363653611.github.io/zbcn.github.io/2019/12/23/fbs_01-%E4%BA%92%E6%96%A5%E9%97%AE%E9%A2%98/
分布式系统
- 特点
- 可扩展性:可通过横向水平扩展提高系统的性能和吞吐量。
- 高可靠性:高容错,即使系统中一台或几台故障,系统仍可提供服务。
- 高并发性:各机器并行独立处理和计算。
- 廉价高效:多台小型机而非单台高性能机。
问题
- 其环境的复杂度、网络的不确定性会造成诸如时钟不一致、“拜占庭将军问题”(Byzantine failure)等
- 存在于集中式系统中的机器宕机、消息丢失等问题也会在分布式环境中变得更加复杂
总结出来的典型问题:
- 互斥性问题。
- 幂等性问题。
互斥问题
互斥性问题用通俗的话来讲,就是对 共享资源的抢占问题 。如果不同的请求对同一个或者同一组资源读取并修改时,无法保证按序执行,无法保证一个操作的原子性,那么就很有可能会出现预期外的情况。因此操作的互斥性问题,也可以理解为一个需要保证时序性、原子性的问题。
传统服务
在传统的基于数据库的架构中,对于数据的抢占问题往往是通过 数据库事务(ACID)来保证的。
分布式
在分布式环境中,出于对性能以及一致性敏感度的要求,使得分布式锁成为了一种比较常见而高效的解决方案。
多线程
事实上,操作互斥性问题也并非分布式环境所独有,在传统的多线程、多进程情况下已经有了很好的解决方案。因此在研究分布式锁之前,我们先来分析下这两种情况的解决方案,以期能够对分布式锁的解决方案提供一些实现思路。
多线程环境解决方案及原理
基本上所有的并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案。 - 《Thinking in Java》
- 在多线程环境中,线程之间因为公用一些存储空间,冲突问题时有发生。解决冲突问题最普遍的方式就是用互斥锁把该资源或对该资源的操作保护起来。
- Java JDK中提供了两种互斥锁Lock和synchronized。不同的线程之间对同一资源进行抢占,该资源通常表现为某个类的普通成员变量。因此,利用ReentrantLock或者synchronized将共享的变量及其操作锁住,即可基本解决资源抢占的问题。
多进程的解决方案
在多道程序系统中存在许多进程,它们共享各种资源,然而有很多资源一次只能供一个进程使用,这便是临界资源。
多进程中的临界资源大致上可以分为两类:
- 一类是物理上的真实资源,如打印机;
- 一类是硬盘或内存中的共享数据,如共享内存等。
临界区: 而进程内互斥访问临界资源的代码被称为临界区。
多进程的情况下,主要还是利用操作系统层面的进程间通信原理来解决临界资源的抢占问题。比较常见的一种方法便是使用信号量(Semaphores)。
信号量在POSIX标准下有两种,分别为有名信号量和无名信号量。无名信号量通常保存在共享内存中,而有名信号量是与一个特定的文件名称相关联。信号量是一个整数变量,有计数信号量和二值信号量两种。对信号量的操作,主要是P操作(wait)和V操作(signal)。
我们可以概括出解决互斥性问题,即资源抢占的基本方式为:对共享资源的操作前后(进入退出临界区)加解锁,保证不同线程或进程可以互斥有序的操作资源。
分布式环境下的解决方案——分布式锁
分布式锁条件
- 基本条件:
- 需要有存储锁的空间,并且锁的空间是可以访问到的
- 锁需要被唯一标识
- 锁要有至少两种状态。
存储空间
锁是一个抽象的概念,锁的实现,需要依存于一个可以存储锁的空间。在多线程中是内存,在多进程中是内存或者磁盘。更重要的是,这个空间是可以被访问到的。多线程中,不同的线程都可以访问到堆中的成员变量;在多进程中,不同的进程可以访问到共享内存中的数据或者存储在磁盘中的文件。但是在分布式环境中,不同的主机很难访问对方的内存或磁盘。这就需要一个都能访问到的外部空间来作为存储空间。
- 分布式锁的存储空间
- 数据库 (行锁、version乐观锁)
- 缓存 (Redis、Tair、Memcached、Mongodb)
- 分布式协调服务Zookeeper
- 甚至是另一台主机
只要可以存储数据、锁在其中可以被多主机访问到,那就可以作为分布式锁的存储空间。
唯一标识
不同的共享资源,必然需要用不同的锁进行保护,因此相应的锁必须有唯一的标识。
- 在多线程环境中: 锁可以是一个对象,那么对这个对象的引用便是这个唯一标识
- 多进程环境中:信号量在共享内存中也是由引用来作为唯一的标识。
- 在分布式环境中:只要给这个锁设定一个名称,并且保证这个名称是全局唯一的,那么就可以作为唯一标识。
至少两种状态
为了给临界区加锁和解锁,需要存储两种不同的状态。如ReentrantLock中的status,0表示没有线程竞争,大于0表示有线程竞争;信号量大于0表示可以进入临界区,小于等于0则表示需要被阻塞。因此只要在分布式环境中,锁的状态有两种或以上:如有锁、没锁;存在、不存在等等,均可以实现。
基于数据库实现锁
伪代码
1 | lock = mysql.get(id); |
2 | while(lock.status == 1) { |
3 | sleep(100); |
4 | } |
5 | mysql.update(lock.status = 1); |
6 | doSomething(); |
7 | mysql.update(lock.status = 0); |
- 问题:(以上的方式即可以实现一个粗糙的分布式锁,但是这样的实现,有没有什么问题呢?)
- 锁状态判断原子性无法保证 从读取锁的状态,到判断该状态是否为被锁,需要经历两步操作。如果不能保证这两步的原子性,就可能导致不止一个请求获取到了锁,这显然是不行的。因此,我们需要 保证锁状态判断的原子性。
- 网络断开或主机宕机,锁状态无法清除 假设在主机已经获取到锁的情况下,突然出现了网络断开或者主机宕机,如果不做任何处理该锁将仍然处于被锁定的状态。那么之后所有的请求都无法再成功抢占到这个锁。因此,我们需要在持有锁的主机宕机或者网络断开的时候,及时的释放掉这把锁。
- 无法保证释放的是自己上锁的那把锁 在解决了问题2的情况下再设想一下,假设持有锁的主机A在临界区遇到网络抖动导致网络断开,分布式锁及时的释放掉了这把锁。之后,另一个主机B占有了这把锁,但是此时主机A网络恢复,退出临界区时解锁。由于都是同一把锁,所以A就会将B的锁解开。此时如果有第三个主机尝试抢占这把锁,也将会成功获得。因此,我们需要在解锁时,确定自己解的这个锁正是自己锁上的。
进阶条件
如果分布式锁的实现,还能再解决上面的三个问题,那么就可以算是一个相对完整的分布式锁了。然而,在实际的系统环境中,还会对分布式锁有更高级的要求。
可重入
线程中的可重入,指的是外层函数获得锁之后,内层也可以获得锁,ReentrantLock和synchronized都是可重入锁
分布式环境中:一般仍然指的是线程的可重入,在绝大多数分布式环境中,都要求分布式锁是可重入的。
惊群效应(Herd Effect):
在分布式锁中,惊群效应指的是,在有多个请求等待获取锁的时候,一旦占有锁的线程释放之后,如果所有等待的方都同时被唤醒,尝试抢占锁。但是这样的情况会造成比较大的开销,那么在实现分布式锁的时候,应该尽量避免惊群效应的产生。公平锁和非公平锁: 不同的需求,可能需要不同的分布式锁。非公平锁普遍比公平锁开销小。但是业务需求如果必须要锁的竞争者按顺序获得锁,那么就需要实现公平锁。
阻塞锁和自旋锁::针对不同的使用场景,阻塞锁和自旋锁的效率也会有所不同。阻塞锁会有上下文切换,如果并发量比较高且临界区的操作耗时比较短,那么造成的性能开销就比较大了。但是如果临界区操作耗时比较长,一直保持自旋,也会对CPU造成更大的负荷。