分布式锁必备知识点(荣耀典藏版)

目录

前言

一、分布式锁基本条件

二、分布式锁的典型实现

1、基于MySQL数据库

2、基于ZK实现的分布式锁

3、基于Redis实现的分布式锁

4、基于etcd实现的分布式锁

拓展


大家好,我是月夜枫,因为疫情原因也没有及时更新,希望各位博友,粉丝,一起学习进步的小伙伴们,身体健康。

今天和大家分享一下分布式锁必备的知识点,直接上干货。

前言

在讨论分布式锁原理的时候,我们带着如下思考进入今天的主题:

根据业务场景如何选择分布式锁模型:CP还是AP?
读锁、加锁是否是原子操作?
锁能否正常释放,避免死锁(如添加过期时间)
保证分布式锁高可用:如何保证锁资源不丢失(保存锁的机器可能宕机)
锁的本质就是对共享资源的串行化处理。在单进程环境中,Java JDK提供了两种互斥锁实现:Lock和Synchronized。这两种锁对共享资源的操作前后加解锁,保证不同线程可以互斥有序的操作共享资源。

在分布式环境下,由于不同主机之间无法直接访问共享资源,所以就需要我们自己来实现分布式锁,保证不同JVM、不同主机之间不会出现资源抢占。

一、分布式锁基本条件


实现分布式锁有以下基本条件:

存储空间:锁的实现需要有一个可以存储锁的空间。在多线程中可以使用内存保存锁;多进程中可以用共享的内存或者磁盘中的文件当做锁;分布式环境中,不同主机无法访问对方的内存或磁盘文件,所以要使用外部存储空间来存储锁。常见的有数据库、缓存如Redis、MongoDB、ZK等;
唯一标识:分布式环境中要保证锁的名称是全局唯一的;
两种状态:锁需要有两种不同的状态,如加锁、解锁;存在、不存在等;
最简单的实现可以用数据库实现一个简单的分布式锁:

建表:
 

CREATE TABLE `tb_lock` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `resource` bigint(64) NOT NULL COMMENT '锁定的资源',
  `status` tinyint(2) NOT NULL DEFAULT '0' COMMENT '锁资源状态:(0:解锁,1:加锁)',
  `desc` varchar(120) NOT NULL DEFAULT "" COMMENT '描述',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

伪代码:

// 1、读锁 (resourceId对应数据库resource字段,可以为用户编号、订单编号等其他场景)
lock = mysql.getByResourceId(resourceId);
// 2、判断锁状态(lock.status = 1是否为加锁状态 ,是表示锁被占用, sleep后再尝试)
while(lock.status == 1) {
    sleep(100);
}
// 3、加锁(更新lock.status = 1,表示占有锁)
mysql.update(lock.status = 1);
// 4、执行业务逻辑
doBizSomething();
// 5、解锁(更新lock.status = 1,表示解锁)
mysql.update(lock.status = 0);

以上是基于数据库简单实现的分布式锁。这样的分布式锁可能会有什么问题呢?

加锁非原子操作:假设请求1 执行第1、2步读锁且发现 lock.status != 1表示锁未被占用,接着准备执行第3步准备加锁(还未执行),与此同时假设请求2也执行完第1、2步读锁、发现锁未被占用,此时请求2也准备执行第3步加锁逻辑;接着请求1执行第3步操作将lock.status = 1表示加锁成功,此时请求2也执行第3步加锁成功,这样就同时有两个请求获得锁,违背分布式锁的规则。根本原因就是读锁和加锁这两步操作不是原子操作,所以存在同一把锁会被多个请求占用的情况。
加锁能否及时释放:假设请求1执行完第3步成功获取分布式锁之后,执行完第4步处理业务逻辑时服务宕机,未能执行第5步将锁释放。这样这个分布式锁status就一直是被占用的状态。所以我们需要考虑持有锁的主机或服务在发生宕机或者异常时能够及时释放锁,保证后续请求能够正常获取锁,确保锁的公平性。
解锁的正确性:
如何保证存储的锁资源不丢失:假设请求1成功获取分布式锁的时候,数据库宕机之后恢复后数据丢失,请求2又能成功获取到同样的分布式锁;
解决这种情况我们就要考**虑使用集群来存储分布式锁资源,同步锁资源数据防止数据丢失。**但是这样又可能存在数据不一致的场景,这就涉及到使用AP还是CP的分布式锁。


二、分布式锁的典型实现


1、基于MySQL数据库


在讨论分布式锁的实现方案时,一般不会考虑到基于MySQL数据库来实现分布式锁,原因是依赖数据库,数据需要落到硬盘上,频繁读取数据会导致IO开销大,性能不高,适用于并发量低、性能要求低的业务场景。优点是不需要引入ZK、Redis等第三方组件。

选用何种分布式锁的实现都不能脱离业务场景的前提,所以基于MySQL数据库实现的分布式锁可以应用在一些对数据一致性要求不是很高、请求量不大、对性能要求不高的场景。举个例子,之前做过一个给运营的后台系统,其中一个功能要求当有一个运营编辑某个页面时,别的运营就不可以进行编辑。此种场景下并发量不高,对数据一致性要求也不高,就可以基于MySQL数据库实现一个简单的分布式锁来实现,简单易懂。

基于MySQL的乐观锁:

乐观锁任务大部分情况下不会发生冲突,只有在更新数据的时候与预期数据进行比较,如果一致则更新数据,否则返回失败。

乐观锁为每个数据增加一个版本字段,读取的时候读取版本号(version 1),更新的时候先读出待更新数据的版本号(version 2),对两个版本号进行比较,没有发生变化则更新,否则更新失败。

我们看下乐观锁的简单实现,在业务表中加上version字段

CREATE TABLE `tb_lock` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `goodId` bigint(64) NOT NULL COMMENT '商品id',
  `count` bigint(64) NOT NULL COMMENT '商品数量',
  `version` bigint(20) NOT NULL DEFAULT '0' COMMENT '版本号',
  `desc` varchar(120) NOT NULL DEFAULT "" COMMENT '描述',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

引入了version字段后,具体操作如下:

线程1获取锁:Select resource,version from tb_lock where resource = xxx;(假设此时version = 0)
这个阶段可能有线程2获取到商品id=2的锁,并对商品id=2的数量+1,执行完后version+1,此时version = 1;
线程1打算也对商品id= 2的商品数量+1,执行语句为 ​​update tb_lock set count= count+1 where goodId=2 and version = 0​​ ,由于goodId=2这条记录的version被线程2更新过,所以线程1更新失败。
以上就是乐观锁的简单实现思路,当然除了用version字段也可以使用时间戳:更新时判断获取的更新时间戳是否与待更新的数据更新时间戳一致。

乐观锁的优点是不需要依赖第三方组件,也不依赖数据库本身锁机制,对请求的性能影响较小,产生并发时直接失败即可。

缺点是:业务表需要添加额外字段,增加数据库冗余;并发量高的时候,大量请求可能会请求同一记录的行锁,岁数据库产生较大读写压力。

所以数据库适合并发量小、写操作不频繁的场景。

2、基于ZK实现的分布式锁


Zookeeper(简称ZK)是一个高性能、高可用的分布式协调服务,可以用来解决分布式数据一致性问题。基于ZK可以实现诸如数据发布/订阅、负载均衡、命名服务、集群管理、分布式锁等功能。

ZK集群是CP模型,具有原子性、可靠性、顺序一致性等特点。

基于ZK实现的分布式锁中每个数据节点就代表一个锁,客户端请求成功创建的临时顺序节点就代表成功获取到锁;当客户端与ZK集群断开链接时该节点会自动被删除,代表释放锁。以下是ZK分布式锁(共享锁)的实现逻辑:

锁的获取

所有客户端尝试调用 create() 方法在,在​​/distributed-locks/lockname/​​节点下尝试创建临时顺序子节点:如​​/distributed-locks/order/00000001​​​ 、​​/distributed-locks/order/00000002​​;
所有客户端会调用 getChildren(“lockname”) 方法,获取​​/distributed-locks/lockname/​​节点下所有已经创建的子节点;
客户端获取到所有子节点的path后,如果发现自己再步骤1中创建的节点是所有子节点序号最小的,就代表这个客户端获取到了锁(读、写请求会有区分);否则就watch比自己序号小的最大节点,进入等待,直到下次监视的节点发生变更时再进行如上步骤;
锁的释放

上述客户端在获取锁时创建的节点(如:​​/distributed-locks/order/00000001​​​)是一个临时顺序节点,释放锁时删除该临时顺序节点并通知到​​/distributed-locks/​​节点下所有注册的子节点。子节点收到变更通知后,会再次发起上述获取锁的流程。

锁的释放有以下两种情况:

获取到锁的客户端机器由于网络中断或发生宕机,该客户端在ZK集群上创建的临时顺序子节点会被删除,锁被释放;
客户端获取到锁并正常执行完业务逻辑后,主动将创建的临时顺序子节点删除,释放锁;
总结

ZK的临时节点可以避免客户端与ZK集群因为网络中断或客户端主机宕机导致锁无法释放的问题;

ZK的顺序节点可以避免羊群效应(ZK服务器短时间向客户端发送大量事件通知),每个锁请求者只会watch序号比他小的最大节点,当锁释放时只会有一个锁请求者会被通知到。

3、基于Redis实现的分布式锁


Redis是一种基于内存的缓存,其分布式缓存特性使其成为分布式锁较为常见的实现之一。

Redis实现分布式锁的原理是:多个进程并发设置同一个key,只有一个进程能够设置成功代表该进程获取到锁,其余进程设置失败继续尝试。

(1) 如何保证读锁、加锁是原子操作?

Redis提供了SetNX命令,表示在指定的key不存在时为key设置指定值,设置成功返回1代表加锁成功;设置失败返回0表示加锁失败。

这个过程是原子操作的。

(2) 如何保证锁能够被正常释放,避免死锁?

当客户端出现网络异常或宕机时,Redis无法像ZK那样清除锁状态,而是提供了锁过期时间的方式来保证锁能够及时被释放,确保锁的公平性,这样其他客户端才能有机会申请到这把锁。

Redis 2.6.12及以上版本中提供了​​set key value NX PX milliseconds​​的保证加锁和设置锁过期时间两步操作能够同时执行成功。

NX表示指定key不存在时设置才指定的值value;PX表示锁的过期时间,单位为ms。

key : 唯一值,用来当锁的,如couponPool.id
value:相当于锁的owner,解锁的时候只有是该ovwner才可以解锁,防止A解了B的锁。
NX:set if not exist。即当key不存在的时候,加锁&设置有效期;存在,不操作;
PX:表示锁的过期时间,单位为ms;
milliseconds:过期时间的值
(3) 如何保证容错性,即锁资源不丢失?

如果采用Redis单点模式,假设服务S1和服务S2同时申请锁lock1,Redis分布式锁会保证只有1个服务申请到锁,另外一个申请失败。

如果此时Redis宕机,内存中锁全部丢失,再次启动后服务S2重新申请锁成功,而业务上S1仍然持有锁。这样就出现同一把锁被多个客户端占有的局面。

解决Redis单机模式数据丢失的问题是通过Redis集群的主从模式。Redis主从集群会将主节点的数据异步同步给Redis从节点。当Redis主节点宕机后,Redis集群的Sentinel哨兵机制和主从切换机制能够保证从节点选举为Redis集群的主节点,继续对外提供锁服务。

但是Redis集群模式实现的分布式锁存在这样一个问题:服务S1在主节点上获取到锁,此时服务S2无法再获取到锁。极端情况下,主节点发生宕机,刚好此时主节点上的锁数据还未来得及同步到从节点上。接着Redis Sentinel哨兵机制选举从节点变为新的主节点,但是新的主节点上没有之前的锁数据,导致服务S2获取锁成功。这样就出现了同一时刻两个客户端拥有同一把锁。

从架构层面分享,分布式锁是CP模型,任何情况要保证所有节点上的数据一致。但是Redis集群的主从模式实现的分布式锁是AP模型,所以就会出现上述问题。

4、基于etcd实现的分布式锁


etcd是一个高可用的分布式KV系统,采用一致性算法raft协议,基于Go语言实现,可以用来实现各种分布式协同服务。

etcd 支持以下功能,正是依赖这些功能来实现分布式锁的:

  • Lease机制:即租约机制(TTL,Time To Live),etcd可以为存储的kv对设置租约,当租约到期,kv将失效删除;同时也支持续约,keepalive
  • Revision机制:每个key带有一个Revision属性值,etcd每进行一次事务对应的全局Revision值都会+1,因此每个key对应的Revision属性值都是全局唯一的。通过比较Revision的大小就可以知道进行写操作的顺序
  • 在实现分布式锁时,多个程序同时抢锁,根据Revision值大小依次获得锁,避免“惊群效应”,实现公平锁
  • Prefix机制:也称为目录机制,可以根据前缀获得该目录下所有的key及其对应的属性值
  • watch机制:watch支持watch某个固定的key或者一个前缀目录,当watch的key发生变化,客户端将收到通知

基于etcd的分布式锁的实现过程

  • 步骤 1: 准备

客户端连接 Etcd,以 /lock/mylock 为前缀创建全局唯一的 key,假设第一个客户端对应的 key="/lock/mylock/UUID1",第二个为 key="/lock/mylock/UUID2";客户端分别为自己的 key 创建租约 - Lease,租约的长度根据业务耗时确定,假设为 15s;

  • 步骤 2: 创建定时任务作为租约的“心跳”

当一个客户端持有锁期间,其它客户端只能等待,为了避免等待期间租约失效,客户端需创建一个定时任务作为“心跳”进行续约。此外,如果持有锁期间客户端崩溃,心跳停止,key 将因租约到期而被删除,从而锁释放,避免死锁。

  • 步骤 3: 客户端将自己全局唯一的 key 写入 Etcd

进行 put 操作,将步骤 1 中创建的 key 绑定租约写入 Etcd,根据 Etcd 的 Revision 机制,假设两个客户端 put 操作返回的 Revision 分别为 1、2,客户端需记录 Revision 用以接下来判断自己是否获得锁。

  • 步骤 4: 客户端判断是否获得锁

客户端以前缀 /lock/mylock 读取 keyValue 列表(keyValue 中带有 key 对应的 Revision),判断自己 key 的 Revision 是否为当前列表中最小的,如果是则认为获得锁;否则监听列表中前一个 Revision 比自己小的 key 的删除事件,一旦监听到删除事件或者因租约失效而删除的事件,则自己获得锁。

  • 步骤 5: 执行业务

获得锁后,操作共享资源,执行业务代码。

  • 步骤 6: 释放锁

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

拓展

一、为了确保分布式锁的可用性,需要确保锁在任意时刻,能同时满足以下四个条件
1.互斥性:在任意时刻,只有一个客户端能持有锁
2.不会发送死锁,即时有一个客户端在持有锁期间崩溃而没有主动解锁,也需要保证其他客户端能加锁
3.具有容错性,只要大部分的Redis节点正常运行,客户端就可以加锁解锁
4.加锁和解锁必须是同一个客户端

二、Redis分布式锁和Java锁的区别
1. 如果是分布式部署的话,那么Java锁是锁当前机器上的请求,无法对其他机器的请求进行加锁,因为Java锁用的是jvm的机制,只在本机生效
2. 姑且说项目就是单机部署的,那么在加锁的时候,Java锁的粒度会更大,Java锁会对接口的其他请求全部阻塞,但是分布式锁,只会对接口的某一个key进行请求的时候,加锁;因为Redis分布式锁有key的入参,如果同时key1和key2都来请求接口,那么Redis分布式锁可以同时执行,但是Java锁,不行,会阻塞key2的请求

三、Redis分布式锁和zk分布式锁怎么选择:
1.Redis采用的是AP模式,zk采用的是CP模式
2.Redis分布式锁简单粗暴,获取不到锁,就直接不断的重试,比较消费资源、性能
3.Redis本身的设计就不是强一致性的,所以,在一些极端的场景下,会出现问题,但是大部分情况下,是不会遇到所谓的极端复杂场景,所以,使用Redis锁也是一个选择
4.zk的设计就是强一致性,如果获取不到锁,就添加一个监听,不用一直轮询,但是zk也有其缺点,如果有较多的客户端频繁的申请加锁解锁,对zk集群的压力比较大

Redis是典型的AP模型,也就是保证高可用,和数据的最终一致性,基于这个模型,在使用Redis分布式锁的时候,就有可能会出现问题

redisson分布式锁:
1.线程A加锁,加锁成功,然后master节点将数据同步到slave,在同步的时候,出现异常,导致master宕机,但是数据也没有同步到slave
2.此时slave升级为master,那此时的话,线程B继续对同一个key加锁,会加锁成功,因为此时的master,没有这把锁记录

redLock:
要求多个节点独立部署,在加锁的时候,会依次去多个节点上加锁,只要有半数以上的节点,加锁成功,就会认为当前线程加锁成功,那基于这个前提,我们实际举例
1.当前是三台机器集群部署,线程A加锁,加入此时对1、2、3三台机器加锁成功,
2.线程B也过来加锁,对3、4、5机器进行加锁,但是3这台机器会加锁失败
3.假如出现极端场景,线程A在释放锁的时候,先释放了3这台机器,然后在去释放1和2的时候,由于网络或者其他原因,导致线程暂停,此时线程B会加锁成功
4.线程B加锁成功之后,会去持有锁,执行自己的业务逻辑,但是在线程A唤醒的一瞬间,当前三台Redis机器上,会同时有两个线程同时持有同一把锁

四、分布式锁加锁思路

1.进入方法之后,首先查询缓存,如果缓存命中,return
2.如果缓存未命中,则查询数据库,但是在查询数据库之前,进行加锁
3.加锁之后,再查询一遍缓存,这里查询缓存,是为了防止第二个阻塞在加锁这里的线程,在获取到锁之后,直接查询数据库
4.如果缓存命中,就返回,如果未命中,查询数据库,并放入到缓存中
5.最后释放锁


String value = redisTemplate.get(key);
if(value != null){
    return value;
}
redisson.lock();
try{
  /**
  * 这里加锁之后,还是再次查询Redis,防止这种场景:
  * A和B连个线程同时执行该方法,A线程获取到锁,B线程阻塞到redisson.lock()这里;那么A在从数据库中查询到数据之后,会将数据写入Redis,在A释放了锁之后。B获取了执行权限,在B获取执行权限的时候,A已经将数据写入了Redis,此时,B就无需再次查询数据库
  */
  String valueTemp = redisTemplate.get(key);
  if(valueTemp != null){
        return valueTemp;
    }
  OerChannelEntity operChannelEntity = operChannelDao.selectById(key);
  redisTemplate.set(key,operChannelEntity.getName);
  return operChannelEntity.getName;
}finally{
  redisson.unlock();
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值