一. 概述
1.1 锁的概念
- 在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。Java的单机并发同步手段是synchronized和java.util.concurrent包。
- 而同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。
- 不同地方实现锁的方式也不一样,只要能满足所有线程都能看得到标记即可。如 Java 中 synchronize 是在对象头设置标记,Lock 接口的实现类基本上都只是某一个 volitile 修饰的 int 型变量其保证每个线程都能拥有对该 int 的可见性和原子修改,linux 内核中也是利用互斥量或信号量等内存数据做标记。
- 除了利用内存数据做锁其实任何互斥的都能做锁(只考虑互斥情况),如流水表中流水号与时间结合做幂等校验可以看作是一个不会释放的锁,或者使用某个文件是否存在作为锁等。只需要满足在对标记进行修改能保证原子性和内存可见性即可。
原理:多个访问方对同一个资源进行操作,需要进行互斥,通常是利用一个这些访问方同时能够访问到的lock来实施互斥的。
场景一
在同一个进程内,多个线程的互斥,我们可以通过加锁来进行串行化访问。
步骤:
(1)多个线程同时抢锁
(2)只一个线程抢到,未抢到的阻塞,或下次再来抢
(3)抢到锁的线程操作临界资源
(4)操作完临界资源后释放锁
画外音:锁是进程内的一个数据结构,将临界资源的冲突转变为对锁结构的冲突。
场景二
在分布式环境下,进程内的锁结构就无法作用于进程外了,所以多进程情况下怎么进行临界资源的保护呢?
结合进程内锁的机制,我们可以得出几点条件:
(1)需要有一个特殊的数据结构,每个进程都能访问
(2)同时只能一个进程访问成功
(3)访问成功的进程可以访问临界资源
画外音:问题的关键在于找到同时只有一个进程访问成功的外部存储结构。
1.2 分布式场景
分布式的 CAP 理论告诉我们:
任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。
目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。基于 CAP理论,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。
分布式场景
此处主要指集群模式下,多个相同服务同时开启.
在许多的场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务
、分布式锁
等。很多时候我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,通过 Java 提供的并发 API 我们可以解决,但是在分布式环境下,就没有那么简单啦。
- 分布式与单机情况下最大的不同在于其不是多线程而是***
多进程
***。 - 多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
1.3分布式锁的概念
- 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
- 与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。(我觉得分布式情况下之所以问题变得复杂,主要就是需要考虑到网络的延时和不可靠。。。一个大坑)
- 分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如 Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。
1.4 设计分布式锁的目标
- 互斥性:任意时刻只能有一个客户端拥有锁,不能被多个客户端获取,即可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器-上的一个线程执行。
- 这把锁要是一把可重入锁(避免死锁),说白了,获取锁的客户端因为某些原因而宕机,而未能释放锁,其它客户端也就无法获取该锁,需要有机制来避免该类问题的发生
- 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
- 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)
- 有高可用的获取锁和释放锁功能,当部分节点宕机,客户端仍能获取锁或者释放锁
- 获取锁和释放锁的性能要好
1.5 方案汇总
分布式的CAP理论告诉我们任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。一般情况下,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性,只要这个最终时间是在用户可以接受的范围内即可。在很多时候,为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。这里我们主要介绍对象分布式锁,分布式锁的的具体实现方案主要如下三种:
- 基于数据库的实现
- 基于缓存(redis)的实现
- 基于zookeeper的实现
二. 基于数据库的实现
2.1 基于数据库实现的版本号(乐观锁)
比如,有个商品表t_goods,有一个字段left_count用来记录商品的库存个数。在并发的情况下,为了保证不出现超卖现象,即left_count不为负数。乐观锁的实现方式为给商品表增加一个版本号字段version,默认为0,每修改一次数据,将版本号加1。
无版本号并发超卖示例:
-- 线程1查询,当前left_count为1,则有记录
select * from t_goods where id = 10001 and left_count > 0
-- 线程2查询,当前left_count为1,也有记录
select * from t_goods where id = 10001 and left_count > 0
-- 线程1下单成功库存减一,修改left_count为0,
update t_goods set left_count = left_count - 1 where id = 10001
-- 线程2下单成功库存减一,修改left_count为-1,产生脏数据
update t_goods set left_count = left_count - 1 where id = 10001
有版本号的乐观锁示例:
-- 线程1查询,当前left_count为1,则有记录,当前版本号为999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 线程2查询,当前left_count为1,也有记录,当前版本号为999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 线程1,更新完成后当前的version为1000,update状态为1,更新成功
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;
-- 线程2,更新由于当前的version为1000,udpate状态为0,更新失败,再针对相关业务做异常处理
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;
可以发现,这种和CAS的乐观锁机制是类似的,所不同的是CAS的硬件来保证原子性,而这里是通过数据库来保证单条SQL语句的原子性。顺带一提CAS的ABA问题一般也是通过版本号来解决。
2.2 基于数据库实现的排他锁(悲观锁)
在查询语句后面增加for update
,数据库会在查询过程中给数据库表增加排他锁 (***注意:InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。***这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()
操作来释放锁。
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。
- 阻塞锁?
for update
语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。 - 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
但是还是无法直接解决数据库单点和可重入问题。
这里还可能存在另外一个问题,虽然我们对方法字段名使用了唯一索引,并且显示使用 for update 来使用行级锁。但是,MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。
还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
2.3 小结
数据库排他锁可能出现的问题及解决思路:
1.没有失效时间, 一旦解锁失败,会导致锁记录一直在数据库中,其他线程无法再获得锁。
可通过定时任务清除超时数据来解决
2.是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。
可通过增加字段记录当前主机信息和当线程信息,
3.这个锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的在线程并不会进入阻塞队列,需要不停自旋直到获得锁,相对耗资源。
总的来说,基于数据库的分布式锁,能够满足一些简单的需求,好处是能够少引入依赖,实现较为简单,缺点是性能较低,且难以满足复杂场景下的高并发需求。会有各种各样的问题(操作数据库需要一定的开销,使用数据库的行级锁并不一定靠谱,性能不靠谱)
三. 基于redis的实现
3.1 基本实现思路
一个简单的分布式锁机制是使用setnx、expire 、del 三个命令的组合来实现的。setnx命令的含义为:当且仅当key不存在时,value设置成功,返回1;否则返回0。另外两个命令,见名知意,就不多做解释了。
# 加锁,设置锁的唯一标识key,返回1说明加锁成功,返回0加锁失败
setnx key value
# 设置锁超时时间为30s,防止死锁
expire key 30
# 解锁, 删除锁
del key
使用步骤
1、setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
2、expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
3、执行完业务代码后,可以通过 delete 命令删除 key。
这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。
这种思路存在的问题:
- setnx和expire的非原子性:如果加锁之后,服务器宕机,导致expire和del均执行不了,会导致死锁。比如,如果在第一步 setnx 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题.
- del导致误删:A线程超时之后未执行完, 锁过期释放;B线程获得锁,此时A线程执行完,执行del将B线程的锁删除。
- 锁过期后引起的并发:A线程超时之后未执行完, 锁过期释放;B线程获得锁,此时A、B线程并发执行会导致线程安全问题。
对应的解决思路:
1.将加锁和设置锁过期时间做成一个原子性操作
在Redis 2.6.12版本之后,set命令增加了NX可选参数,可替代setnx命令;增加了EX可选参数,可以设置key的同时指定过期时间
或者将两个操作封装在lua脚本中,发送给Redis执行,从而实现操作的原子性。
2.将key的value设置为线程相关信息,del释放锁之前先判断一下锁是不是自己的。(释放和判断不是原子性的,需要封装在lua脚本中)
3.启动一个守护线程,在后台自动给自己的锁’'续期“,执行完成,显式关掉守护进程
3.2 redssion框架
3.1方案还是可能存在**「锁过期释放,业务没执行完」**的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
当前开源框架Redisson提供了一个完善的解决方案,解决了锁续期,锁竞争,锁释放等常见问题。我们一起来看下Redisson底层原理图吧:
只要线程一加锁成功,就会启动一个watch dog
看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了**「锁过期释放,业务没执行完」**问题。
3.3 小结
redis集群实现分布式锁保证的是AP,实现最终一致性。
可能会存在写入锁的数据没有及时同步的问题、
四. 基于zookeeper的实现
4.1 基本实现思路
zookeeper 是一个开源的分布式协调服务框架,主要用来解决分布式集群中的一致性问题和数据管理问题。zookeeper本质上是一个分布式文件系统,由一群树状节点组成,每个节点可以存放少量数据,且具有唯一性。
zookeeper有四种类型的节点:
1.持久节点(PERSISTENT)
默认节点类型,断开连接仍然存在
2.持久顺序节点(PERSISTENT_SEQUENTIAL)
在持久节点的基础上,增加了顺序性。指定创建同名节点,会根据创建顺序在指定的节点名称后面带上顺序编号,以保证节点具有唯一性和顺序性
3.临时节点(EPHEMERAL)
断开连接后,节点会被删除
4.临时顺序节点(EPHEMERAL_SEQUENTIAL)
在临时节点的基础上,增加了顺序性。
基于zookeeper实现的分布式锁主要利用了zookeeper临时顺序节点的特性和事件监听机制。主要思路如下:
- 创建节点实现加锁,通过节点的唯一性,来实现锁的互斥。
如果使用临时节点,节点创建成功表示获取到锁如果使用临时顺序节点,客户端创建的节点为顺序最小节点,表示获取到锁 - 删除节点实现解锁
- 通过临时节点的断开连接自动删除的特性来避免持有锁的服务器宕机而导致的死锁
- 通过节点的顺序性和事件监听机制,大节点监听小节点,形成节点监听链,来实现等待队列(公平锁)
其他思路:
- 不使用监听机制,未获取到锁的线程自旋重试或者失败退出(根据业务决定),可实现非阻塞的乐观锁。
- 不使用临时顺序节点,而使用临时节点,所有客户端都去监听该临时节点,可实现非公平锁。但是会产生"羊群效应",单个事件,引发多个服务器响应,占用服务器资源和网络带宽,需要根据业务场景选用。
4.2 zookeeper分布式锁的缺点
zookeeper分布式锁有着较好的可靠性,但是也有如下缺点:
1.zookeeper分布式锁是性能可能没有redis分布式锁高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。
2.使用zookeeper也有可能带来并发问题,只是并不常见而已。比如,由于网络抖动,客户端与zk集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。
优点:
有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点:
性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器上。还需要对 ZK的原理有所了解。
zookeeper是如何保证“一致性”呢?
当往zookeeper的leader节点写数据时,leader会对剩下的follower节点进行主从数据同步,它必须得同步超过半数的follower节点才给客户端返回写成功的信号。
所以从这点上它是保证了数据一致性,但是却不是强一致性。
另外一点就是,如果由于网络故障或是其他原因导致leader节点挂了,那么这个时候zookeeper集群就得在剩下那些follower接点中重新进行leader的选举,选出一个新leader来。但是别忘了,选举是需要时间的,哪怕30s到120秒,这一段时间之内,zookeeper集群是不能给外部提供服务的,处于不可用的状态,所以从这个角度来讲它丧失了一定的系统可用性(即CAP中的A)。
五. 总结
上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
1.从实现的复杂性角度(从高到低)zookeeper >= redis> 数据库
数据库实现的分布式锁易于理解和实现,且不会给项目引入其他依赖。zookeeper和redis需要考虑的情况更多,实现相对较为复杂,但是都有现成的分布式锁框架curator和redision,用起来代码反而可能会更简洁。
2.从性能角度(从高到低)redis>zookeeper > 数据库
redis数据存在内存,速度很快;zookeeper虽然数据也存在内存中,但是本身维护节点的一致性。需要耗费一些性能;数据库则只有索引在内存中,数据存于磁盘,性能较差。
3.从可靠性角度(从高到低)zookeeper > redis > 数据库
zookeeper天生设计定位就是分布式协调,强一致性,可靠性较高;redis分布式锁需要较多额外手段去保证可靠性;数据库则较难满足复杂场景的需求。
使用分布式锁的注意事项
1、注意分布式锁的开销
2、注意加锁的粒度
3、加锁的方式