分布式所解决并发的三种实现方式(数据库、Redis缓存、Zookeeper)

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

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间只能被桶一个线程执行。
分布式锁一般有三种实现方式:
1)数据库锁
2)基于Redis的分布式锁
3)基于ZooKeeper的分布式锁

分布式锁应该是怎么样的??

  • 互斥性,可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
  • 这把锁要是一把可重入锁(避免死锁)。
  • 不会发生死锁,有一个客户在持有锁的过程中崩溃而没有解锁,也能保证其他客户端能够加锁。
  • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)。
  • 有高可用的说去锁和释放锁功能
  • 获取锁和释放锁性能要好

1.数据库锁

基于数据库表

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

在这里插入图片描述
当我们想锁住某哥方法时,执行以下SQL:
在这里插入图片描述
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据可的haul,数据库会保证只有一个操作可以成功,那么我们可以认为操作成功的那个线程获得了该方法的锁,可以执行方法内容

当方法执行完毕后,想要释放锁的话,需要执行以下SQL:
在这里插入图片描述
上面这种简单的实现有一下几个问题:

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

当然,我们也可以有其他的方式解决上面的问题:

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

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

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

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

我们可以认为获得排他锁的线程即可获得分布式说,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

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

通过connection.commit()操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

  • 阻塞锁?for updata语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,知道成功。
  • 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但是还是无法直接结局数据库单点和可重入问题。

总结:
总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。

数据库实现分布式锁的优点:直接借助数据库,容易理解。
数据库实现分布式锁的缺点:会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
操作数据需要一定的开销,性能问题需要考虑。

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

实现方式:

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

性能对比

1)悲观锁实现方式是独占数据,其他线程需要等待,不会出现修改的冲突,能够保证数据的一致性,但是一来数据库的实现,且在线程较多时出现等待造成效率降低的问题。一般情况下,对于数据很敏感且读取率较低的场景,可以采用悲观锁的方式。
2)乐观锁可以多线程同时读取,若出现冲突,也可以依赖上层逻辑修改,能够保证高并发下的读取,适用于读取数据频率很高而修改频率较少的场景。
3)由于库存回写数据属于敏感数据且读取频率适中,所以建议使用悲观锁优化。


基于Redis的分布式锁

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现的性能方面表现更好一些。而且很多缓存是可以集群部署的,可以解决单点问题。

首先,为了确保分布式可用,我们至少要确保锁的实现同时满足一下四个条件:

  • 1)互斥性:在任意时刻,只有一个客户端能持有锁。
  • 2)不会发生死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  • 3)具有容错性:只要大部分的Redis节点能正常运行,客户端就可以加锁和解锁。
  • 4)解铃还须系铃人:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个参数:

  • 第一个为key,二面使用key来当锁,因为key是唯一的。
  • 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了啊,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过value赋值为requestId,二面就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据,requestId可以使用UUID.randomUUID().toString()方法生成。
  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作。
  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加上一个过期的设置,具体时间由第五个参数决定。
  • 第五个为time,与第四个参数相呼应,代表key的过期时间。

总的来说上面set()方法就只会导致两种结果:1.当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2、已有所存在,不做任何操作。

加锁代码满足我们可靠性里描述的三个条件。首先,set()加入NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后序发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时间就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。

错误实例:
使用jedis.setnx()和jedis.expire()组合实现加锁
在这里插入图片描述
setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面set()方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。

  • 解锁:首先获取锁对应的value值,检车是否与requestId相等,如果相等则删除锁(解锁)。
  • 使用缓存实现分布式锁的优点:性能好,实现起来较为方便。
  • 使用缓存实现分布式锁的缺点:通过超时时间来控制锁的失效时间并不是十分靠谱。

总结:
可以使用缓存来代替数据库来实现分布式锁,这个可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。并且很多缓存服务都提供了可以用老实现分布式锁的方法,比如redis的setnx方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。


基于Zookeeper实现分布式锁

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

完成业务流程后,删除对应的子节点释放锁。

来看一下Zookeeper能不能解决前面提到的问题。

  • 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
  • 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
  • 不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
  • 单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

可以直接使用Zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。

Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。

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

使用Zookeeper实现分布式锁的优点:有效的解决了单点问题,不可重入问题,非阻塞问题以及所无法释放问题。实现起来较为简单。

使用Zookeeper实现分布式锁的缺点:性能上㐈使用缓存实现分布式锁。需要对ZK的原理有所了解。
在这里插入图片描述


三种方案的比较

  • 从理解难以角度(从低到高):数据库> 缓存>Zookeeper
  • 从实现的复杂度角度(从低到高):Zookeeper>=缓存>数据库
  • 从性能方面(从高到低):缓存>Zookeeper>=数据库
  • 从可靠性角度(从高到低):Zookeeper>缓存>数据库
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值