java分布式锁

1、什么是锁?

在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。
同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,后续线程发现已有标记,则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。
不同地方实现锁的方式也不一样,只要满足所有线程都能看到标记即可。如synchronized是在对象头设置标记,Lock接口的实现类基本上都只是某一个 volitile修饰的int型变量,其保证每个线程都能拥有对该int的可见性和原子修改,linux内核中也是利用互斥量或信号量等内存数据做标记。
除了利用内存数据做锁,任何互斥的都能做锁,如流水表中流水号与时间结合做幂等校验可以看作是一个不会释放的锁,或使用某个文件是否存在作为锁等。只要满足在对标记进行修改能保证原子性和内存可见性即可。

2、什么是分布式?

分布式的CAP理论:
任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。
基于CAP理论,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。
分布式场景:
在许多的场景中,为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。很多时候我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,可以通过Java提供的并发API解决,但在分布式环境下,就没有那么简单了。

  • 分布式与单机情况下最大的不同在于其不是多线程而是多进程。
  • 多线程可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
3、什么是分布式锁?
  • 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
  • 锁不仅需要保证进程可见,还要考虑进程与锁之间的网络问题。
  • 分布式锁可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如Redis、Memcache。利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。
4、我们需要怎样的分布式锁?
  • 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
  • 这把锁要是一把可重入锁(避免死锁)
  • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
  • 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)
  • 有高可用的获取锁和释放锁功能
  • 获取锁和释放锁的性能要好
5、基于数据库做分布式锁

利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。
缺点:

  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  • 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
  • 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
  • 在MySQL中采用主键冲突防重,在大并发情况下有可能会造成锁表现象。
    解决方式:
  • 数据库是单点?搞两个数据库,数据之间双向同步,一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个while循环,直到insert成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
  • 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
  • 比较好的办法是在程序中生产主键进行防重。
6、基于表字段版本号做分布式锁

这个策略源于mysql的mvcc机制,缺点是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断sql每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销较大。

7、基于数据库排他锁做分布式锁

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁 (注意:InnoDB引擎在加锁的时候,只有通过索引进行检索时才会使用行级锁,否则会使用表级锁。希望使用行级锁,就要给要执行的方法字段名添加索引,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题)。当某条记录被加上排他锁之后,其他线程无法在该行记录上增加排他锁。
可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()操作来释放锁。
这种方法可以有效的解决无法释放锁和阻塞锁的问题,但无法直接解决数据库单点和可重入问题。

  • 阻塞锁?for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
  • 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
    虽然我们对方法字段名使用了唯一索引,并且显示使用for update来使用行级锁。但MySQL会对查询进行优化,即便在条件中使用了索引字段,是否使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的,如果MySQL认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下InnoDB将使用表锁,而不是行锁。
    另外,使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多,就可能把数据库连接池撑爆。
8、基于Redis做分布式锁
  • 基于REDIS的SETNX()、EXPIRE() 方法做分布式锁
    setnx的含义是SET if Not Exists,其主要有两个参数setnx(key, value)。该方法是原子的,如果key不存在,则设置当前key成功,返回1;如果当前key已存在,则设置当前key失败,返回0。
    expire设置过期时间,setnx命令不能设置key的超时时间,只能通过expire()对key设置。
    使用步骤:
    1)setnx(lockkey, 1) 如果返回0,占位失败;如果返回1,占位成功;
    2)expire()命令对lockkey设置超时时间,避免死锁问题。
    3)执行完业务代码后,可以通过delete命令删除key。
    如果在第一步setnx执行成功后,在expire()执行成功前,发生了宕机,依然会出现死锁问题。如果要对其进行完善的话,可以使用 redis 的 setnx()、get()和 getset()方法来实现分布式锁。

  • 基于 REDIS 的 SETNX()、GET()、GETSET()方法做分布式锁
    这个方案的背景主要是在setnx()和expire()的方案上针对可能存在的死锁问题,做了一些优化。
    getset()命令主要有两个参数getset(key,newValue)。该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。假设key原来是不存在的,那么多次执行这个命令,会出现下边的效果:
    getset(key, “value1”) 返回null,此时key的值会被设置为value1
    getset(key, “value2”) 返回value1,此时key的值会被设置为value2
    使用步骤:
    1)setnx(lockkey, 当前时间+过期超时时间),如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向2。
    2)get(lockkey)获取值oldExpireTime,并将这个value值与当前系统时间比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向3。
    3)计算newExpireTime = 当前时间+过期超时时间,然后getset(lockkey, newExpireTime)会返回当前lockkey的值currentExpireTime。
    4)判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
    5)在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行delete释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

9、基于REDLOCK做分布式锁

Redlock基于N个完全独立的Redis节点(通常情况下N可以设置成5)。
算法的步骤如下:
1)客户端获取当前时间,以毫秒为单位。
2)客户端尝试获取N个节点的锁,(每个节点获取锁的方式和前面说的缓存锁一样),N个节点以相同的key和value获取锁。客户端需要设置接口访问超时,接口超时时间需要远小于锁超时时间,比如锁自动释放的时间是10s,那么接口超时大概设置5-50ms。这样可以在有redis节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。
3)客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤一获取的时间,只有客户端获得了超过3个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。
4)客户端获取的锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。
5)如果客户端获取锁失败了,客户端会依次删除所有的锁。
使用Redlock算法,可以保证在挂掉最多2个节点时,分布式锁服务仍然能工作,相比之前的数据库锁和缓存锁大大提高了可用性,由于redis的高性能,分布式缓存锁性能并不比数据库锁差。

10、基于ZooKeeper做分布式锁

ZooKeeper 锁相关基础知识:
1)zk一般由多个节点构成(单数),采用zab一致性协议。因此可以将zk看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。
2)zk的数据以目录树的形式,每个目录称为znode, znode中可存储数据(一般不超过1M),还可以在其中增加子节点。
3)子节点有三种类型:序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。临时节点,一旦创建这个znode的客户端与服务器失去联系,这个znode也将自动删除。最后就是普通节点。
4)Watch机制,client可以监控每个节点的变化,当产生变化会给client产生一个事件。
ZK 基本锁:
原理:利用临时节点与watch机制。每个锁占用一个普通节点/lock,当需要获取锁时在/lock目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。
缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。
ZK 锁优化:
原理:上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,只是其序号不同。只有序号最小的可以拥有锁,如果这个节点序号不是最小的则watch序号比本身小的前一个节点 (公平锁)。
步骤:
1)在 /lock节点下创建一个有序临时节点 (EPHEMERAL_SEQUENTIAL)。
2)判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后watch序号比本身小的前一个节点。
3)当取锁失败,设置watch后则等待watch事件到来后,再次判断是否序号最小。
4)取锁成功则执行代码,最后释放锁(删除该节点)。
优点:
有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点:
性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上。

原文地址:https://www.cnblogs.com/seesun2012/p/9214653.html

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