分布式锁

目录

 

什么是分布式锁

为什么要使用分布式锁

分布式锁应该具备哪些条件

分布式锁的三种实现方式

基于数据库

基于缓存实现

Spring AOP 简化分布式锁

基于Zookeeper的实现方式

总结


 

  • 什么是分布式锁

        要介绍分布式锁,首先要提到与分布式锁相对应的是线程锁,进程锁。 线程锁:主要用来给方法,代码块枷锁。当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或改代码段。线程锁只在统一JVM中有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state)。 进程锁:为了控制统一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过synchronized等线程锁实现进程锁。 分布式锁:当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问。

  • 为什么要使用分布式锁

        目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法满足一致性(Consistency),可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项”。所以很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间在用户可以接受的范围内即可。在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务,分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。 有这样一个情境,线程A和线程B都共享某个变量X。 如果是单机情况下(单JVM),线程之间共享内存,只要使用线程锁就可以解决并发问题。 如果是分布式情况下(多JVM),线程A和线程B很可能不是在同一JVM中,这样线程锁就无法起到作用了,这时候就要用到分布式锁来解决。

分布式锁_第1张图片

  • 分布式锁应该具备哪些条件

  1. 只有一个机器的一个线程能获得。在分布式系统环境下,即系统运行在不同的物理机器上,锁在同一时间只能被一个机器上的一个线程获得。
  2. 高可用,高性能的获取锁和释放锁。加锁和释放锁的逻辑必须稳定可靠,不会对正常的业务逻辑产生影响。可以在短时间内大量的加锁,释放锁。
  3. 具备可重入特性,具备失效机制。同一线程可以重复获取锁,锁有一定时间的生命周期,防止出现死锁,所有线程长时间等待。
  4. 具备非阻塞或阻塞特性。根据业务需求,非阻塞锁没有获取到锁将直接返回获取锁失败,阻塞锁会继续等待直到获取锁。
  • 分布式锁的三种实现方式

  1. 基于数据库实现分布式锁(数据库乐观锁,数据库悲观锁,创建锁表)
  2. 基于缓存实现分布式锁(redis,memcached,tair)
  3. 基于Zookeeper实现分布式锁(Zookeeper的临时有序节点)
  • 基于数据库

  1. 1.1 数据库乐观锁

应用系统层面和数据的业务逻辑层次上的(实际上并没有加锁,只不过大家一直这样叫而已),利用程序处理并发,它假定当某一个用户去读取某一个数据的时候,其他的用户不会来访问修改这个数据,但是在最后进行事务提交的时候会进行版本检查,以判断在该用户的操作过程中,没有其他用户修改了这个数据。

异常实现流程:
-- 可能会发生的异常情况
-- 线程1查询,当前left_count为1,则有记录
select * from t_bonus where id = 10001 and left_count > 0
 
-- 线程2查询,当前left_count为1,也有记录
select * from t_bonus where id = 10001 and left_count > 0
 
-- 线程1完成领取记录,修改left_count为0,
update t_bonus set left_count = left_count - 1 where id = 10001
 
-- 线程2完成领取记录,修改left_count为-1,产生脏数据
update t_bonus set left_count = left_count - 1 where id = 10001

通过乐观锁实现:
-- 添加版本号控制字段
ALTER TABLE table ADD COLUMN version INT DEFAULT '0' NOT NULL AFTER t_bonus;
 
-- 线程1查询,当前left_count为1,则有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
 
-- 线程2查询,当前left_count为1,有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
 
-- 线程1,更新完成后当前的version为1235,update状态为1,更新成功
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
 
-- 线程2,更新由于当前的version为1235,udpate状态为0,更新失败,再针对相关业务做异常处理
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
  1. 1.2 悲观锁

悲观锁完全依赖于数据库锁的机制实现的,在数据库中可以使用Repeatable Read的隔离级别(可重复读)来实现悲观锁。它认为当某一用户在读取某一数据的时候,其他用户也会对改数据进行访问,所以在读取的时候就对数据进行加锁。

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排它锁,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。

分布式锁_第2张图片

悲观锁的特点是先获取锁,再进行业务操作,即“悲观”的认为获取锁是非常有可能失败的,因此要先确保获取锁成功再进行业务操作。通常所说的“一锁二查三更新”即指的是使用悲观锁。通常来讲在数据库上的悲观锁需要数据库本身提供支持,即通过常用的select … for update操作来实现悲观锁。 当数据库执行select for update时会获取被select中的数据行的行锁,因此其他并发执行的select for update如果试图选中同一行则会发生排斥(需要等待行锁被释放), 因此达到锁的效果。select for update获取的行锁会在当前事务结束时自动释放,因此必须在事务中使用。 这里需要注意的一点是不同的数据库对select for update的实现和支持都是有所区别的,例如oracle支持select for update no wait,表示如果拿不到锁立刻报错,而不是等待, mysql就没有no wait这个选项。另外mysql还有个问题是select for update语句执行中所有扫描过的行都会被锁上,这一点很容易造成问题。 因此如果在mysql中用悲观锁务必要确定走了索引,而不是全表扫描。

 

  1. 1.3 创建锁表

直接创建一张锁表,然后通过操作表中的数据来实现。当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

分布式锁_第3张图片

当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。。当我们想要锁住某个方法时,执行以下SQL:

因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:

这种方案存在的问题: 1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。 2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。 3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。 4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

解决方案:数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。非阻塞的?搞一个while循环,直到insert成功再返回成功。非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。这样又会引出一系列的问题。

  • 基于缓存实现

相比较于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点,而且很多缓存是可以集群部署的,可以解决单点问题。 目前有很多成熟的缓存产平,包括redis和memcached,有一些成熟的框架和算法可以直接使用,如redission。 因为redis使用场景比较多,所以我们主要学习下基于redis的分布式锁。

  1. 基于redis实现

1.主要使用命令介绍: (1)SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1; 若key存在,则什么都不做,返回0。 (2)expire key timeout:为key设置一个超时时间,单位为second,超过这个 时间锁会自动释放,避免死锁。 (3)delete key:删除key,用来解锁。

2.实现思想: (1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,所得value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。 (2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。 (3)释放锁的时候,通过UUID判断是不是该所,若是该所,则执行delete进行释放锁,防止误删其他锁。

分布式锁_第4张图片

  1. 加锁

分布式锁_第5张图片

加锁代码:jedis.set(String key, String value, String nxxx, String expx, int time),方法共有五个形参:  第一个为key,我们使用key来当锁,因为key是唯一的。  第二个为value,传的是requestId,分布式锁要满足解铃还须系铃人,通过给value赋值为requestId,就知道这把锁是哪个请求加的,在解锁的时候就可以有依据,requestId可以使用UUID.randomUUID().toString()方法生成。 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作; 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。 第五个为time,与第四个参数相呼应,代表key的过期时间。 执行结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。

  1. 解锁

分布式锁_第6张图片

一个简单的Lua脚本代码,将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。 首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。 执行eval()方法可以确保原子性,源于Redis的特性: 简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

  1. Redission简介

Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。【Redis官方推荐】 其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong,CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) 。 Redisson在基于NIO的Netty框架上,充分的利用了Redis键值数据库提供的一系列优势,在Java实用工具包中常用接口的基础上,为使用者提供了一系列具有分布式特性的常用工具类。 使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。

  1. 1.可重入锁:Redisson的分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口,同时还支持自动过期解锁; RLock lock = redisson.getLock(“anyLock”); // 最常见的使用方法 : lock.lock(); // 支持过期解锁功能,10秒钟以后自动解锁 ,无需调用unlock方法手动解锁 lock.lock(10, TimeUnit.SECONDS); // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁 boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); lock.unlock();

  2. 2.公平锁:Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock接口的一种RLock对象。在提供了自动过期解锁功能的同时,保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。 RLock fairLock = redisson.getFairLock("anyLock");

分布式锁_第7张图片

分布式锁_第8张图片

分布式锁_第9张图片

分布式锁_第10张图片

  • Spring AOP 简化分布式锁

分布式锁_第11张图片

分布式锁_第12张图片

分布式锁_第13张图片

分布式锁_第14张图片

  • 基于Zookeeper的实现方式

基于zookeeper临时有序节点可以实现的分布式锁。 大致思想:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下, 生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。 Zookeeper能不能解决前面提到的问题。 1, 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候, 客户端会在ZK中创建一个临时节点, 一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。 其他客户端就可以再次获得锁。 2, 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器, 一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的, 如果是,那么自己就获取到锁,便可以执行业务逻辑了。 3,不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候, 把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。 4,单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活, 就可以对外提供服务。  

分布式锁_第15张图片   

Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。 使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。 因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上。 而且,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。 这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。 (所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)

分布式锁_第16张图片

  • 总结

  1. 三种方案的比较:哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
  2. 从理解的难易程度角度(从低到高):数据库 > 缓存 > Zookeeper
  3. 从实现的复杂性角度(从低到高):Zookeeper >= 缓存 > 数据库
  4. 从性能角度(从高到低):缓存 > Zookeeper >= 数据库
  5. 从可靠性角度(从高到低):Zookeeper > 缓存 > 数据库     

你可能感兴趣的:(分布式)