架构与思维:分布式锁方案分析

1 介绍

前面的文章我们介绍了分布式系统和它的CAP原理:一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)参考这篇《分布式事务

我们知道,一个分布式系统无法同时满足三个特性,所以在设计系统之初,就有一个特性要被妥协和牺牲,因为分区容错性的不可或缺性,一般我们的选择是AP或者CP,这就要求我们要么舍弃强一致性,要么舍弃高可用。

为了达到数据的一致性,或者说至少达到数据的最终一致性,我们需要一些额外的方法来保证,比如分布式事务,分布式锁等等。

2 关于分布式锁

在单体系统中,我们经常会遇到很多高并发的场景,比如热点数据、热点缓存,短时间会有大量的请求进行访问,当多个线程同时访问共享资源的时候,就可能产生数据不一致的情况

为了保证操作的顺序性、原子性,所以我们需要辅助,比如在线程间中加锁,当某个线程得到资源的时候,就对当前的资源进行加锁,等完成操作之后,进行释放,其他线程就可以继续使用了。

Java在多线程实现中,专门提供了一些锁机制来保障线程的互斥同步(synchronized/ReentrantLock)等。

 1 synchronized(object:this){
 2    // todo 业务逻辑
 3 }
 4 ====================================
 5 Lock lock = new ReentrantLock(); 
 6 Condition condition = lock.newCondition(); 
 7 lock.lock(); 
 8 try { 
 9  while(这边是条件表达式) { 
10    condition.wait(); 
11    // todo 业务逻辑 
12   } 
13  } finally { 
14     lock.unlock(); 
15 }

这种方式对于同一个module里面的操作是没什么问题,但是在分布式系统中,就没什么用了,比如很典型的支付场景、跨行转账场景,均属于多系统之间的资源操作。

所以,为了解决这个问题,我们就必须引入分布式锁,来保障多个不同系统对共享资源进行互斥访问。

分布式锁需要解决的问题一般包含如下:

1、排他性:分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行

2、避免死锁:锁在执行一段有限的时间之后,会被释放(正常释放或异常导致自动释放),并且可以被重入,即当前线程可重复获取。

3、高可用/高性能:获取锁和释放锁具备高可用;获取和释放锁的性能优良。

3 分布式锁的实现方案

分布式锁的实现,比较常见的方案有3种:

1、基于数据库实现分布式锁

2、基于缓存(Redis或其他类型缓存)实现分布式锁

3、基于Zookeeper实现分布式锁

这三种方案,从实现的复杂度上来看,从1到3难度依次递增。而且并不是每种解决方案都是完美的,它们都有各自的特性,还是需要根据实际的场景进行抉择的。

3.1  基于数据库实现

3.1.1 乐观锁的实现方式

乐观锁机制其实就是在数据库表中引入一个版本号(version)字段来实现。如下,再表上添加了一个version字段,并且设置为bigint类型:

1 CREATE TABLE `t_pay` (
2 `id` BIGINT ( 20 ) NOT NULL AUTO_INCREMENT,
3 `pay_id` BIGINT (8) NOT NULL COMMENT '支付id',
4 `pay_count` BIG (8) DEFAULT 0 not NULL COMMENT '支付次数',
5 `balance` DECIMAL (6,2) DEFAULT 0 not NULL COMMENT '总额度',
6 `version` BIGINT (10) DEFAULT 0  NOT NULL COMMENT '版本号',
7 PRIMARY KEY ( `id` )
8 ) ENGINE = INNODB AUTO_INCREMENT = 137587 DEFAULT CHARSET = utf8 COMMENT = '用户支付信息表'

在每次进行数据库表之前先查询一下当前记录信息,然后执行更新语句并且让指定字段进行自增,即 version = version+1 (因为MySQL同一张表只支持一个自增键,这边已经被id用了)。

修改完将新的数据与新的version更新到数据表中,更新的同时检查目前数据库里version值是不是之前的那个version,如果是,则正常更新。

如果不是,则更新失败,说明在这个执行间隙有其它的进程去更新过数据了,这时候如果强行更新进去,支付次数和总额度就不对了。操作如下:

1 -- 先查询数据信息
2 select pay_id,pay_count,balance,version from t_pay where id= #{id}
3 -- 判断当前表中的version 是否与刚才查出的version一致,是的话正常更新
4 update t_pay set pay_count=paycount + 1, balance = balance + '具体消费额度' ,version = version+1 where id=#{id} and version= #{version};

根据返回修改记录条数来判断当前更新是否生效,如果改动的是0条数据,说明version发生了变更,导致改动无效,这时候可以根据自己业务逻辑来判断是否回滚事务。

下面图例分析一下:

 

举例如图,你跟你老婆用同一个账户在支付,你支付燃气费,你老婆够买手表,如果没有锁机制,在并发的情况下,可能会出现同时被扣25和8000,导致最终余额的不正确。

但是如果使用乐观锁机制,当两个请求同时到达的时候,需要获取到账号信息包括版本号信息,不管是A操作(支付燃气费)还是B操作(购买手表),都会将版本号加1,即version=2,

那么另外一个操作执行的时候,发现当前版本号变成了2,不再是之前读取的 1,则更新失败。

通过上面这个例子可以看出来,使用「乐观锁」机制,必须得满足:

a)锁服务要有递增的版本号 version

b)每次更新数据的时候都必须先判断版本号对不对,然后再写入新的版本号

3.1.2 悲观锁的实现方式

悲观锁也叫作排它锁,在MySQL中是基于 for update  语法来实现加锁的,下面用伪代码来演示,例如:

 1 // 锁定的方法
 2 public boolean lock(){
 3     connection.setAutoCommit(false)
 4     while(true){
 5         result = 
 6         select * from t_pay where 
 7         id = 100 for update;
 8         if(result){
 9          // 结果不为空,
10          // 则说明获取到了锁
11             return true;
12         }
13         // 没有获取到锁,继续获取
14         sleep(1000);
15     }
16     return false;
17 }
18 
19 // 释放锁
20 connection.commit(); 

上面的示例中,user表中,id是主键,通过 for update 操作,数据库在查询的时候就会给这条记录加上排它锁。(需要注意的是,在InnoDB中只有检索字段加了索引的,才会是行级锁,否者是表级锁,所以这个id字段要加索引),

当这条记录加上排它锁之后,其它线程是无法操作这条记录的。

那么,这样的话,我们就可以认为获得了排它锁的这个线程是拥有了分布式锁,然后就可以执行我们想要做的业务逻辑,当逻辑完成之后,再调用上述释放锁的语句即可。 

3.1.3 数据库锁的优缺点

直接使用数据库,容易理解、操作简单

但是会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。操作数据库需要一定的开销,性能问题需要考虑,特别是高并发场景下

使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。 

3.2  基于Redis实现

3.2.1 基于缓存实现分布式锁

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

基于Redis实现的锁机制,主要是依赖redis自身的原子操作,例如:

1 # 判断是否存在,不存在设值,并提供自动过期时间
2 SET key value NX PX millisecond
3 
4 # 删除某个key
5 DEL key [key …] 

NX:只在在键不存在时,才对键进行设置操作,SET key value NX 效果等同于 SETNX key value
PX millisecond:设置键的过期时间为millisecond毫秒,当超过这个时间后,设置的键会自动失效

如果需要把上面的支付业务实现,则需要改写如下:

1 # 设置账户Id为17124的账号的值为1,如果不存在的情况下,并设置过期时间为500ms
2 SET pay_id_17124 1 NX PX 500
3 
4 # 进行删除
5 DEL pay_id_17124 

上述代码示例是指,

当redis中不存在pay_key这个键的时候,才会去设置一个pay_key键,键的值为 1,且这个键的存活时间为500ms。

当某个进程设置成功之后,就可以去执行业务逻辑了,等业务逻辑执行完毕之后,再去进行解锁。而解锁之前或者自动过期之前,其他进程是进不来的。

实现锁机制的原理是:这个命令是只有在某个key不存在的时候,才会执行成功。那么当多个进程同时并发的去设置同一个key的时候,就永远只会有一个进程成功。

解锁很简单,只需要删除这个key就可以了。

另外,针对redis集群模式的分布式锁,可以采用redis的Redlock机制。

需要注意的是,如何设置恰当的超时时间,如果设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就要多等一段时间。这个问题使用数据库实现分布式锁同样存在。

总结:可以使用缓存来代替数据库来实现分布式锁,会提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。

并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如redis的setnx方法。并且,缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。

3.2.2 缓存实现分布式锁的优缺点 

优点是性能好,实现起来较为方便。缺点是通过超时时间来控制锁的失效时间并不是十分的靠谱。

3.3 基于Zookeeper实现

3.3.1 实现过程

基于zookeeper临时有序节点可以实现分布式锁。

其原理如下:

1、每个请求的客户端,都去Zookeeper上的某个指定节点的目录下(比如是对某个对象的操作),去生成一个唯一的临时有序节点

2、然后判断自己是否是这些有序节点中序号最小的一个,如果是,则算是获取了锁

3、如果不是最小序号,则说明没有获取到锁,那么就需要在序列中找到比自己小的那个节点,对其注册事件监听(调用exits()方法确认节点在不在)比如下面图中,client-3 生成 node-3,并监听node-2。

4、当监听到这个节点被删除了,那就再去判断一次自己当初创建的节点是否变成了序列中最小的。如果是,则获取锁,如果不是,则重复上述步骤。

 1 //创建子节点
 2 private String createSaNode() throws KeeperException, InterruptedException {
 3 // 如果根节点不存在,则创建根节点
 4 Stat stat = zk.exists(ZNODE, false);
 5 if (stat == null) {
 6 zk.create(ZNODE, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
 7 }
 8 
 9 String hostName = System.getenv("HOSTNAME");
10 // 创建EPHEMERAL_SEQUENTIAL类型节点
11 String saPath = zk.create(ZNODE + "/" + SA_NODE_PREFIX,
12 hostName.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE,
13 CreateMode.EPHEMERAL_SEQUENTIAL);
14 return saPath;
15 }

完整的实现方案可以参考:https://blog.csdn.net/liyiming2017/article/details/85063868

根据上诉的步骤,Zookeeper实际解决了如下问题:

  • 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。

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

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

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

下面图例说明:

 

Locker Object 是对需要竞争的资源进行持久的节点,下面的node-1到node-n 就是上面说的有序子节点,由不同进程的client去创建。

当进来一个客户端需要去竞争资源的时候,就跑到持久化节点下去按顺序创建一个直接点,然后看一下是不是最小的一个。

如果是最小的就获取到锁,可以继续后面的资源操作了。如果不是则监听比自己序号小的节点,比如client-3 订阅的是 node-2。

如果node-2被删除,自己被唤醒,再次判断自己是不是序列中最小的,如果是,则获取锁。

3.3.2 zk实现分布式锁的优缺点 

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

缺点:性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。

3.4 三种方案的对比总结

上面几种方式,并不是都能做到十全十美,就像CAP一样,在复杂性、可靠性、性能 三方面无法同时满足一样。所以,更多的是根据不同的应用场景选择最合适的方案。

 

特性
实现复杂度角度
性能角度
可靠性角度
数据库
缓存
Zookeeper

 

posted @ 2022-01-14 15:16  Hello-Brand  阅读(1045)  评论(0编辑  收藏  举报