分布式锁解决并发的三种实现方式

https://www.jianshu.com/p/350a5f891f11

0、前言

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案:

分布式锁一般有三种实现方式:

  1. 数据库锁;

  2. 基于Redis的分布式锁;

  3. 基于ZooKeeper的分布式锁。

分布式锁应该是怎么样的(6点)

  • 互斥性:可以保证在分布式部署的应用集群中,同一方法同一时间只能被一台机器上的一个线程执行。

  • 可重入锁避免死锁

  • 不会发生死锁:持有锁的客户端由于崩溃而没有解锁,也能保证其他客户端能够加锁

  • 阻塞锁(根据业务需求考虑要不要这条

  • 高可用获取锁释放锁

  • 获取锁释放锁性能要好

1、数据库锁

1.1 基于数据库表

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

获得锁:当我们要锁住某个方法资源时,我们就在该表中增加一条记录

insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

method_name做了唯一性约束,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

释放锁:当方法执行完毕之后,想要释放锁的话,删除这条记录即可:

delete from methodLock where method_name ='method_name'

数据库的可用性?获得锁的客户端宕机?可重入?阻塞?

1、数据库是单点,宕机后,不可用?搞两个数据库,数据库之间双向同步,一旦挂掉快速切换到备库上。

2、没有失效时间,解锁失败,其他线程不能获得锁?做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍

3、非阻塞的,其他线程插入失败,直接报错,没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作?搞一个while循环,直到insert成功再返回成功。

4、非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息 和 线程信息,那么下次再获取锁的时候,先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

1.2 基于数据库的排它锁

除了可以通过增 删操作数据表中的记录以外,其实还可以借助数据库中自带的锁来实现分布式的锁。

我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。

获得锁:在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

释放锁:commit或者事务异常

public void unlock(){   
   connection.commit();
} 

优点

  • 实现阻塞for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态直到成功

  • 服务器宕机:服务由于宕机而不能释放锁,数据库 会自己 把锁释放掉

缺点

  • 数据库单点故障
  • 锁重入

基于数据库表的排他锁 总结


总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表

  • 一种是通过表中的记录的存在情况确定当前是否有锁存在

  • 另外一种是通过数据库的排他锁for update来实现分布式锁。

优点:直接借助数据库,容易理解。

缺点:操作数据库需要一定的开销性能问题需要考虑。

1.3 乐观锁

乐观锁假设认为数据一般情况下不会造成冲突,只有在进行数据的提交更新时,才会检测数据的冲突情况,如果发现冲突了,则返回错误信息。

实现方式:

时间戳(timestamp)记录机制实现:给数据库表增加一个时间戳字段类型的字段,当读取数据时,将timestamp字段的值一同读出,数据每更新一次timestamp也同步更新。当对数据做提交更新操作时,检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,若相等,则更新,否则认为是失效数据。若出现更新冲突,则需要上层逻辑修改,启动重试机制。

同样也可以使用version的方式。

性能对比

(1) 悲观锁实现方式是独占数据,其它线程需要等待,不会出现修改的冲突,能够保证数据的一致性,但是依赖数据库的实现,且在线程较多时出现等待造成效率降低的问题。一般情况下,对于数据很敏感读取频率较低的场景,可以采用悲观锁的方式

(2) 乐观锁可以多线程同时读取数据,若出现冲突,也可以依赖上层逻辑修改,能够保证高并发下的读取,适用于读取频率很高修改频率较少的场景

(3) 由于库存回写数据属于敏感数据读取频率适中,所以建议使用悲观锁优化

2、基于redis的分布式锁

加锁jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

  • 第一个为key,我们使用key来当锁,因为key是唯一的

  • 第二个为value,传的是requestId,为什么还要用到value?原因就是加锁和解锁必须是同一个人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。

  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作保证互斥

  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。

  • 第五个为time,与第四个参数相呼应,代表key的过期时间避免死锁:保证持有锁的客户机由于宕机而没有主动解锁,其他客户端也能加锁。

解锁:首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁

错误实例:

使用jedis.setnx()jedis.expire()组合实现加锁

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {  
   Long result = jedis.setnx(lockKey, requestId);   
 if (result == 1) { 
       // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁        
       jedis.expire(lockKey, expireTime);   
     }
 }

setnx()方法作用就是SET IF NOT EXISTexpire()方法就是给锁加过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间,那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。

优点

  • 基于缓存的分布式锁的性能会更好
  • redis可以集群部署,只要大部分的Redis节点正常运行,就不会出现单点机器宕机不可用的问题。

缺点

  • 通过超时时间来控制锁的失效时间并不是十分的合适。

3、基于Zookeeper实现分布式锁

实现原理:基于zookeeper临时有序节点可以实现的分布式锁。大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应指定节点的目录下,生成一个唯一的 瞬时 有序 节点。 判断是否获取锁的方式很简单,只需要判断有序节点中 序号最小的一个。 当释放锁的时候,只需将这个瞬时节点 删除即可。

3.1 获取锁 与 释放锁 过程

转自
https://blog.csdn.net/wuzhiwei549/article/details/80692278#t2

3.1.1 获取锁

首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面,创建一个临时顺序节点 Lock1
「每日分享」如何用Zookeeper实现分布式锁

之后,Client1查找ParentLock下面所有的临时顺序节点排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。
在这里插入图片描述

这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。
在这里插入图片描述

Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。

于是,Client2向排序仅比它靠前的节点Lock1 注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败进入了等待状态
在这里插入图片描述

这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。
在这里插入图片描述

Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。

于是,Client3向排序仅比它靠前的节点Lock2 注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。
在这里插入图片描述

这样一来,Client1得到了锁Client2监听了Lock1Client3监听了Lock2。这恰恰形成了一个等待队列,很像是Java当中ReentrantLock所依赖的

3.1.2 释放锁

释放锁分为两种情况:

1.任务完成,客户端显示释放

当任务完成时,Client1会显示 调用删除节点Lock1的指令。
在这里插入图片描述
2.任务执行过程中,客户端崩溃

获得锁的Client1在任务执行过程中,如果Duang的一声崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之 自动删除
在这里插入图片描述

由于Client2一直监听着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知。这时候Client2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小,则Client2顺理成章获得了锁。
在这里插入图片描述

同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知。
在这里插入图片描述

最终,Client3成功得到了锁。

优点

  • 客户端由于宕机而 不能释放锁问题:在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后 突然挂掉(Session连接断开),那么这个临时节点 就会 自动删除掉。其他客户端就可以再次获得锁。

  • 非阻塞:使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中 序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。

  • 不可重入:使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点参与排队

  • 单点问题:使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活就可以对外提供服务

缺点

  • 性能上,没有redis实现的性能高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁 瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上。

4、三种方案的比较

理解的难易程度角度(从低到高):数据库 > 缓存 > Zookeeper

实现的复杂性角度(从低到高):Zookeeper >= 缓存 > 数据库

性能角度(从高到低):缓存 > Zookeeper >= 数据库

可靠性角度(从高到低):Zookeeper > 缓存 > 数据库

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值