Skip to content

Latest commit

 

History

History
543 lines (347 loc) · 24.1 KB

分布式锁简单入门以及三种实现方式介绍.md

File metadata and controls

543 lines (347 loc) · 24.1 KB

一、为什么要使用分布式锁

我们在开发应用的时候,如果需要对某一个共享变量进行多线程同步访问的时候,可以使用我们学到的Java多线程的18般武艺进行处理,并且可以完美的运行,毫无Bug!

注意这是单机应用,也就是所有的请求都会分配到当前服务器的JVM内部,然后映射为操作系统的线程进行处理!而这个共享变量只是在这个JVM内部的一块内存空间!

后来业务发展,需要做集群,一个应用需要部署到几台机器上然后做负载均衡,大致如下图:

上图可以看到,变量A存在JVM1、JVM2、JVM3三个JVM内存中(这个变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象,例如:UserController控制器中的一个整形类型的成员变量),如果不加任何控制的话,变量A同时都会在JVM分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的!即使不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的!

如果我们业务中确实存在这个场景的话,我们就需要一种方法解决这个问题!

为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!

二、分布式锁应该具备哪些条件

在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件:

1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行; 
2、高可用的获取锁与释放锁; 
3、高性能的获取锁与释放锁; 
4、具备可重入特性; 
5、具备锁失效机制,防止死锁; 
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

三、分布式锁的三种实现方式

目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。

基于数据库实现分布式锁; 
基于缓存(Redis等)实现分布式锁; 
基于Zookeeper实现分布式锁;

尽管有这三种方案,但是不同的业务也要根据自己的情况进行选型,他们之间没有最好只有更适合!

四、基于数据库的实现方式

基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

(1)创建一个表:

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
  `desc` varchar(255) NOT NULL COMMENT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

(2)想要执行某个方法,就使用这个方法名向表中插入数据:

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

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

(3)成功插入则获取锁,执行完成后删除对应的行数据释放锁:

delete from method_lock where method_name ='methodName';

注意:这只是使用基于数据库的一种方法,使用数据库实现分布式锁还有很多其他的玩法!

使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,它有一些问题需要解决及优化:

1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;

2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;

3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;

4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。

5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。

6、衍生:可以用行锁 for update;使用select for update 加锁 保持回话session连接 ,解锁时释放连接

问题:回话连接性能差,还是不可重用

7、衍生:用CAS, update时比较版本,只有相同才更新

五、基于Redis的实现方式

1、选用Redis实现分布式锁原因:

(1)Redis有很高的性能; (2)Redis命令对此支持较好,实现起来比较方便

2、使用命令介绍:

(1)SETNX

SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。

(2)expire

expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。

(3)delete

delete key:删除key

在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。

3、实现思想:

(1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。

(2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。

(3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。

4、 分布式锁的简单实现代码:

/**
- 分布式锁的简单实现代码
- Created by liuyang on 2017/4/20.
*/
public class DistributedLock {        
  private final JedisPool jedisPool;        
  public DistributedLock(JedisPool jedisPool) {
     this.jedisPool = jedisPool;
  }        
  /**
  - 加锁        
  - @param lockName       锁的key
  - @param acquireTimeout 获取超时时间
  - @param timeout        锁的超时时间
  - @return 锁标识
  */
  public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) {
     Jedis conn = null;
     String retIdentifier = null;
     try {
     // 获取连接
        conn = jedisPool.getResource();
        // 随机生成一个value
        String identifier = UUID.randomUUID().toString();
        // 锁名,即key值
        String lockKey = "lock:" + lockName;
        // 超时时间,上锁后超过此时间则自动释放锁
        int lockExpire = (int) (timeout / 1000);
        // 获取锁的超时时间,超过这个时间则放弃获取锁
        long end = System.currentTimeMillis() + acquireTimeout;
        while (System.currentTimeMillis() < end) {
           if (conn.setnx(lockKey, identifier) == 1) {
              conn.expire(lockKey, lockExpire);
              // 返回value值,用于释放锁时间确认
              retIdentifier = identifier;
              return retIdentifier;
           }
           // 返回-1代表key没有设置超时时间,为key设置一个超时时间
           if (conn.ttl(lockKey) == -1) {
               conn.expire(lockKey, lockExpire);
           }
       
           try {
               Thread.sleep(10);
           } catch (InterruptedException e) {
               Thread.currentThread().interrupt();
           }
         }    
       } catch (JedisException e) {
           e.printStackTrace();
       } finally {
           if (conn != null) {
               conn.close();
           }
       }
       return retIdentifier;
    }
   
     /**    
     - 释放锁
     - @param lockName   锁的key
     - @param identifier 释放锁的标识
     - @return
       */
       public boolean releaseLock(String lockName, String identifier) {
       Jedis conn = null;
       String lockKey = "lock:" + lockName;
       boolean retFlag = false;
       try {
           conn = jedisPool.getResource();
           while (true) {
               // 监视lock,准备开始事务
               conn.watch(lockKey);
               // 通过前面返回的value值判断是不是该锁,若是该锁,则删除,释放锁
               if (identifier.equals(conn.get(lockKey))) {
                   Transaction transaction = conn.multi();
                   transaction.del(lockKey);
                   List<Object> results = transaction.exec();
                   if (results == null) {
                       continue;
                   }
               retFlag = true;
           }
           conn.unwatch();
           break;
       }
   } catch (JedisException e) {
       e.printStackTrace();
   } finally {
       if (conn != null) {
           conn.close();
       }
   }
   return retFlag;
   }
   }

5、测试刚才实现的分布式锁

例子中使用50个线程模拟秒杀一个商品,使用–运算符来实现商品减少,从结果有序性就可以看出是否为加锁状态。

模拟秒杀服务,在其中配置了jedis线程池,在初始化的时候传给分布式锁,供其使用。

/**

- Created by liuyang on 2017/4/20.
     */
 public class Service {
   
 private static JedisPool pool = null;
   
 private DistributedLock lock = new DistributedLock(pool);
   
     int n = 500;
   
     static {
         JedisPoolConfig config = new JedisPoolConfig();
         // 设置最大连接数
         config.setMaxTotal(200);
         // 设置最大空闲数
         config.setMaxIdle(8);
         // 设置最大等待时间
         config.setMaxWaitMillis(1000 * 100);
         // 在borrow一个jedis实例时,是否需要验证,若为true,则所有jedis实例均是可用的
     config.setTestOnBorrow(true);
         pool = new JedisPool(config, "127.0.0.1", 6379, 3000);
     }
   
     public void seckill() {
         // 返回锁的value值,供释放锁时候进行判断
         String identifier = lock.lockWithTimeout("resource", 5000, 1000);
         System.out.println(Thread.currentThread().getName() + "获得了锁");
     System.out.println(--n);
     lock.releaseLock("resource", identifier);
 }
 }

模拟线程进行秒杀服务:

public class ThreadA extends Thread {
    private Service service;

	public ThreadA(Service service) {
    	this.service = service;
	}

	@Override
	public void run() {
    	service.seckill();
	}
}

public class Test {
    public static void main(String[] args) {
        Service service = new Service();
        for (int i = 0; i < 50; i++) {
            ThreadA threadA = new ThreadA(service);
            threadA.start();
        }
    }
}

结果如下,结果为有序的:

若注释掉使用锁的部分:

public void seckill() {
    // 返回锁的value值,供释放锁时候进行判断
    //String indentifier = lock.lockWithTimeout("resource", 5000, 1000);
    System.out.println(Thread.currentThread().getName() + "获得了锁");
    System.out.println(--n);
    //lock.releaseLock("resource", indentifier);
}

从结果可以看出,有一些是异步进行的:

注:设值和设过期时间非原子性,存在问题:设完值还没来得及设过期时间,进程死了,那么这个锁就不会过期了,别人就拿不到锁了。setnx指令本身是不支持传入超时时间的,幸好Redis 2.6.12以上版本为set指令增加了可选参数,用一条命令实现,这是CAS解决办法。

5、基于ZooKeeper的实现方式

ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。基于ZooKeeper实现分布式锁的步骤如下:

(1)创建一个目录mylock; (2)线程A想获取锁就在mylock目录下创建临时顺序节点; (3)获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁; (4)线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点; (5)线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

这里推荐一个Apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。

优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。

缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。

注:上面是共享只读锁,还有排他锁和读写锁的实现。

其它实现思路:

假设服务器1,创建了一个节点 /zkjjj,成功了,那服务器1就获取了锁,服务器2再去创建相同的锁,就会失败,这个时候就只能监听这个节点的变化。

等到服务器1处理完业务,删除了节点后,他就会得到通知,然后去创建同样的节点,获取锁处理业务,再删除节点,后续的100台服务器与之类似。

注意这里的100台服务器并不是挨个去执行上面的创建节点的操作,而是并发的,当服务器1创建成功,那么剩下的99个就都会注册监听这个节点,等通知,以此类推。

但是大家有没有注意到,这里还是有问题的,还是会有死锁的情况存在,对不对?

当服务器1创建了节点后挂了,没能删除,那其他99台服务器就会一直等通知,那就完蛋了。。。

这个时候就需要用到临时性节点了,我们前面说过了,临时性节点的特点是客户端一旦断开,就会丢失,也就是当服务器1创建了节点后,如果挂了,那这个节点会自动被删除,这样后续的其他服务器,就可以继续去创建节点,获取锁了。

但是我们可能还需要注意到一点,就是惊群效应:举一个很简单的例子,当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到…

就是当服务器1节点有变化,会通知其余的99个服务器,但是最终只有1个服务器会创建成功,这样98还是需要等待监听,那么为了处理这种情况,就需要用到临时顺序性节点。大致意思就是,之前是所有99个服务器都监听一个节点,现在就是每一个服务器监听自己前面的一个节点。

假设100个服务器同时发来请求,这个时候会在/zkjjj节点下创建100个临时顺序性节点/zkjjj/000000001,/zkjjj/000000002,一直到/zkjjj/000000100,这个编号就等于是已经给他们设置了获取锁的先后顺序了。

当001节点处理完毕,删除节点后,002收到通知,去获取锁,开始执行,执行完毕,删除节点,通知003~以此类推。

6、总结

上面的三种实现方式,没有在所有场合都是完美的,所以,应根据不同的应用场景选择最适合的实现方式。

在分布式环境中,对资源进行上锁有时候是很重要的,比如抢购某一资源,这时候使用分布式锁就可以很好地控制资源。 当然,在具体使用中,还需要考虑很多因素,比如超时时间的选取,获取锁时间的选取对并发量都有很大的影响,上述实现的分布式锁也只是一种简单的实现,主要是一种思想,以上包括文中的代码可能并不适用于正式的生产环境,只做入门参考!

参考文章:

1、https://yq.aliyun.com/articles/60663

2、http://www.hollischuang.com/archives/1716

3、https://www.cnblogs.com/liuyang0/p/6744076.html

作者:徐刘根 来源:CSDN 原文:https://blog.csdn.net/xlgen157387/article/details/79036337 版权声明:本文为博主原创文章,转载请附上博文链接!


其它分布式锁的总结

1、基于数据库的实现

方案一:基于表的唯一索引

(1)创建锁记录table,用锁定的资源作为唯一索引

(2)加锁时执行一条insert插入

(3)完毕后删除 该记录

问题:

非阻塞,无容错(客户端崩溃后锁不能释放),不可重入等等

方案二:基于数据库排他锁

使用select for update 加锁 保持回话session连接 ,解锁时释放连接

问题:

回话连接性能差,还是不可重用

2、基于redis的实现

方案一:新的set命令 和 lua 脚本
- 获取锁(unique_value可以是UUID等)SET resource_name unique_value NX PX 30000
- 释放锁(lua脚本中,一定要比较value,防止误解锁)if redis.call("get",KEYS[1]) == ARGV[1] then    return redis.call("del",KEYS[1])else    return 0end

问题:(1)非阻塞,只能通过死循环实现,通过 Redis 的订阅发布模式来实现通知的成本太高

(2)redis单点,主从切换可能出现锁丢失

方案二:实现redlock算法的redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上

基于redlock算法,根据lua 脚本和watch dog 机制实现了自动延期,可重入 ,还可实现读写锁、公平锁,联锁等等

问题:AP模型,无一致性算法,存在一致性问题

参考文章:

https://juejin.im/post/5bf3f15851882526a643e207

https://blog.csdn.net/turbo_zone/article/details/83422215

https://mp.weixin.qq.com/s?__biz=MzU5ODUwNzY1Nw==&mid=2247484164&idx=1&sn=210397905ef284c1d2756d1cdf73880f

https://mp.weixin.qq.com/s?__biz=MzU5ODUwNzY1Nw==&mid=2247484155&idx=1&sn=0c73f45f2f641ba0bf4399f57170ac9b&scene=21#wechat_redirect

3、 基于zookeeper的实现

基于Curator客户端实现分布式锁

  • Shared Reentrant Lock 可重入锁
  • Shared Lock 共享不可重入锁
  • Shared Reentrant Read Write Lock 可重入读写锁
  • Shared Semaphore 信号量
  • Multi Shared Lock 多锁
方案一:排他锁

创建临时有序节点排序后,watch比自己小1的节点等待获取锁

方案二:读写锁

读锁:又称共享锁,如果前面没有写节点,可以直接上锁;当前面有写节点时,则等待距离自己最近的写节点释放( 删除 )。

写锁:如果前面没有节点,可以直接上锁;如果前面有节点,则等待前一个节点释放( 删除 )

存在问题:

(1)性能上不如使用缓存实现的分布式锁,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能

参考文章:

[利用Zookeeper实现 - 分布式锁](https://mp.weixin.qq.com/s?__biz=MzI1NDU0MTE1NA==&mid=2247483862&idx=1&sn=914d7912c7313123897ef479f0bd7f80&chksm=e9c2eddbdeb564cd58923f395441996332fb9332d8c53fc6368a96a19b6e59297020e6f47537&scene=21&xtrack=1&key=e3977f8a79490c63bdcb6c58fa99576e80eb3c7db7fe393f6039d192362c395566c47cd13a582277c96064472e8271f63879aadb0c8c0de6f81dcc876c06e46d50130699ad56ba62693f29e202a34258&ascene=1&uin=MjI4MTc0ODEwOQ==&devicetype=Windows 7&version=62060719&lang=zh_CN&pass_ticket=HBqvqAg8/E8F0PgXE4QY26oVnramk4zVwRSUT2QD BrblglnRt6GwnH4nA OLM//#wechat_redirect)

4、基于etcd的实现

etcd像是专门为集群环境的服务发现和注册而设计,它提供了数据TTL失效、数据改变监视、多值、目录监听、分布式锁原子操作等功能,可以方便的跟踪并管理集群节点的状态

因为 etcd 使用 Raft 算法保持了数据的强一致性,某次操作存储到集群中的值必然是全局一致的,所以很容易实现分布式锁。锁服务有两种使用方式,一是保持独占,二是控制时序

保持独占,即所有试图获取锁的用户最终只有一个可以得到。etcd为此提供了一套实现分布式锁原子操作CAS(CompareAndSwap)的API。通过设置prevExist值,可以保证在多个节点同时创建某个目录时,只有一个成功,而该用户即可认为是获得了锁。

·控制时序,即所有试图获取锁的用户都会进入等待队列,获得锁的顺序是全局唯一的,同时决定了队列执行顺序。etcd为此也提供了一套API(自动创建有序键),对一个目录建值时指定为POST动作,这样etcd会自动在目录下生成一个当前最大的值为键,存储这个新的值(客户端编号)。同时还可以使用API按顺序列出所有当前目录下的键值。此时这些键的值就是客户端的时序,而这些键中存储的值可以是代表客户端的编号

方案:基于etcd API

尝试拿锁 + 自动续租 + 关闭清理 三个api

拿锁失败 进入等待队列

参考文章:

https://segmentfault.com/a/1190000014297365

问题:没有现成的框架,需要一定自研

综合所有设计要素,发表下自我的看法:

1、如果缺乏自研能力,用redis的redisson框架最好 ,相比zk 性能比较好

2、如果拥有自研能力,基于etcd实现分布式锁是做好的,consul分布式应用不广泛

3、不建议线上业务基于数据库实现,因为基本很难用