分布式锁实现方案

一. 常见的锁--线程锁/进程锁/分布式锁

  • 线程锁:比如synchronize,主要用来给方法、代码块加锁.当某个方法或者代码块使用锁时,那么在同一时刻至多仅有有一个线程在执行该段代码.当有多个线程访问同一对象的加锁方法/代码块时,同一时间只有一个线程在执行,其余线程必须要等待当前线程执行完之后才能执行该代码段.但是,其余线程是可以访问该对象中的非加锁代码块的.

  • 进程锁:是为了控制同一操作系统中多个进程访问一个共享资源,只是因为程序的独立性,各个进程是无法控制其他进程对资源的访问的,但是可以使用本地系统的信号量控制(操作系统基本知识).

  • 分布式锁:当多个进程不在同一个系统之中时,使用分布式锁控制多个进程对资源的访问.

线程锁,进程锁,分布式锁的作用都是一样的,只是作用的范围大小不同.
范围大小:分布式锁——大于——进程锁——大于——线程锁.

能用线程锁,进程锁情况下使用分布式锁也是可以的,能用线程锁的情况下使用进程锁也是可以的,只是范围越大技术复杂度就越高.

二. 分布式锁出现的原因

我们在进行单机应用开发的时候,如果需要对某一个共享变量进行多线程同步访问的时候,可以使用Java多线程的各种API进行处理,比如synchronized关键字(被动同步锁标记)、lock(主动加锁)、volatile(可见性)、concurrent工具包(原子类).

在单机应用里面,我们所有的请求都会被分配到当前服务器的JVM内部,然后再映射为操作系统的线程来进行处理,此时如果有共享变量,也只是在这个JVM内部的一块内存空间!

在一个高并发业务的服务集群中,大量的业务请求被分流到集群中不同的进程中进行处理,会出现运行在多个JVM中的相同业务逻辑对同一业务对象进行访问和操作.这种情况下,无法使用JDK提供的并发api来保证数据的一致性.

但是如果我们的应用是分布式系统,可能会有各种集群,一个应用被部署到多台机器上然后做负载均衡,大致如下图:
Day07_08_分布式教程之分布式锁实现方案_加锁_02

上图可以看到,变量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️⃣. 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败.

五. 分布式锁的特点

  • 互斥性: 和我们本地锁一样,互斥性是最基本的特性,但是分布式锁是需要保证在不同节点上不同线程的互斥;

  • 可重入性: 同一个节点上的同一个线程如果获取了锁之后,也可以再次获取这个锁;

  • 锁超时: 和本地锁一样支持锁超时,防止死锁;

  • 高效/高可用: 加锁和解锁需要保证高效,同时也需要保证高可用,防止分布式锁失效,可以增加降级;

  • 支持阻塞和非阻塞: 和 ReentrantLock 一样支持 lock 和 tryLock 以及 tryLock(long timeOut);

  • 支持公平锁和非公平锁(可选): 公平锁是指要按照请求加锁的顺序来获得锁,非公平锁则相反,它是无序的.这个一般来说实现的比较少.

六. 分布式锁的实现方案

  • 1️⃣. 基于数据库(MySQL等)乐观锁实现分布式锁;

  • 2️⃣. 基于缓存(Redis等)实现分布式锁;

  • 3️⃣. 基于Zookeeper实现分布式锁;

  • 4️⃣. 自研发的分布式锁: 如谷歌的Chubby.

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

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

七. 基于数据库实现排他锁

1. 方案1

表结构
Day07_08_分布式教程之分布式锁实现方案_分布式锁_04

获取锁

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

因为对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功.

2. 方案2

表结构

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 '锁定的方法名',
`state` tinyint NOT NULL COMMENT '1:未分配;2:已分配',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`version` int NOT NULL COMMENT '版本号',
`PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

获取锁的信息

select id, method_name, state,version from method_lock where state=1 and method_name='methodName';

占有锁

update method_lock set state=2, version=2, update_time=now() where method_name='methodName' and state=1 and version=2;

如果没有更新影响到任何一行数据,则说明这个资源已经被别人占位了.

3. 数据库实现的缺点

  • 1️⃣. 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用;

  • 2️⃣. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁;

  • 3️⃣. 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错.没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作;

  • 4️⃣. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁,因为数据库中数据已经存在了.

4. 解决办法:

  • 1️⃣. 解决数据库是单点的问题

    弄两个数据库,操作数据之前双向同步,一旦挂掉快速切换到备库上.

  • 2️⃣. 解决锁没有失效时间的问题

    做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍;

  • 3️⃣. 解决锁非阻塞的问题

    写一个while循环,直到insert成功再返回成功.

  • 4️⃣. 解决锁非重入的问题

    在数据库的表中加个字段,用于记录当前获得锁的机器的主机信息和线程信息,在下次该机器再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库中可以查到的话,直接把锁分配给它.

八. 基于redis实现分布式锁

1. 获取锁的命令:

SET resource_name my_random_value NX PX 30000

2. 代码实现

2.1 创建一个Redis连接池

public class RedisPool {
 
    private static JedisPool pool;//jedis连接池
     
    private static int maxTotal = 20;//最大连接数
     
    private static int maxIdle = 10;//最大空闲连接数
     
    private static int minIdle = 5;//最小空闲连接数
     
    private static boolean testOnBorrow = true;//在取连接时测试连接的可用性
     
    private static boolean testOnReturn = false;//再还连接时不测试连接的可用性
 
     static {
        initPool();//初始化连接池
     }
     
     public static Jedis getJedis({
        return pool.getResource();
     }
     
     public static void close(Jedis jedis){
        jedis.close();
     }
     
     private static void initPool({
         JedisPoolConfig config = new JedisPoolConfig();
         config.setMaxTotal(maxTotal);
         config.setMaxIdle(maxIdle);
         config.setMinIdle(minIdle);                

config.setTestOnBorrow(testOnBorrow);
         config.setTestOnReturn(testOnReturn);
         config.setBlockWhenExhausted(true);
         pool = new JedisPool(config, "127.0.0.1", 6379, 5000, "syc");
         }
}

2.2 对Jedis的api进行封装

public class RedisPoolUtil {
 
    private RedisPoolUtil(){}
     
    private static RedisPool redisPool;
     
    public static String get(String key){
         Jedis jedis = null;
         String result = null;
         try {
             jedis = RedisPool.getJedis();
             result = jedis.get(key);
         } catch (Exception e){
            e.printStackTrace();
         } finally {
         
         if (jedis != null) {
            jedis.close();
         }
         return result;
         }
     }
     
    public static Long setnx(String key, String value){
         Jedis jedis = null;
         Long result = null;
         try {
             jedis = RedisPool.getJedis();
             result = jedis.setnx(key, value);
         } catch (Exception e){
            e.printStackTrace();
         } finally {
         if (jedis != null) {
            jedis.close();
         }
         return result;
         }
     }
     
    public static String getSet(String key, String value){
         Jedis jedis = null;
         String result = null;
         try {
             jedis = RedisPool.getJedis();
             result = jedis.getSet(key, value);
         } catch (Exception e){
            e.printStackTrace();
         } finally {
         if (jedis != null) {
            jedis.close();
         }
         return result;
         }
     }
     
    public static Long expire(String key, int seconds){
         Jedis jedis = null;
         Long result = null;
         try {
             jedis = RedisPool.getJedis();
             result = jedis.expire(key, seconds);
         } catch (Exception e){
            e.printStackTrace();
         } finally {
         if (jedis != null) {
            jedis.close();
         }
         return result;
         }
     }
     
    public static Long del(String key){
         Jedis jedis = null;
         Long result = null;
         try {
             jedis = RedisPool.getJedis();
             result = jedis.del(key);
         } catch (Exception e){
            e.printStackTrace();
         } finally {
         if (jedis != null) {
            jedis.close();
         }
         return result;
         }
     }
}

2.3 分布式锁简单实现

public class DistributedLockUtil {
 
    private DistributedLockUtil(){}
     
    public static boolean lock(String lockName){//lockName可以为共享变量名,也可以为方法名,主要是用于模拟锁信息
        
        System.out.println(Thread.currentThread() + "开始尝试加锁!");
        
        Long result = RedisPoolUtil.setnx(lockName, String.valueOf(System.currentTimeMillis() + 5000));
        
        if (result != null &&result.intValue() == 1){
            System.out.println(Thread.currentThread() + "加锁成功!");
            RedisPoolUtil.expire(lockName, 5);
            
            System.out.println(Thread.currentThread() + "执行业务逻辑!");
            
            RedisPoolUtil.del(lockName);
            return true;
        } else {
            String lockValueA = RedisPoolUtil.get(lockName);
            if (lockValueA != null && Long.parseLong(lockValueA) >= System.currentTimeMillis(){
            String lockValueB = RedisPoolUtil.getSet(lockName, String.valueOf(System.currentTimeMillis() + 5000));
            if (lockValueB == null || lockValueB.equals(lockValueA)){
             System.out.println(Thread.currentThread() + "加锁成功!");
             
            RedisPoolUtil.expire(lockName, 5);
            System.out.println(Thread.currentThread() + "执行业务逻辑!");
            RedisPoolUtil.del(lockName);
            return true;
        } else {
            return false;
        }
    }
}

3. redis实现的缺点

在这种场景(主从结构)中存在着明显的竞态:

客户端A从master中获取到锁,在master中将锁同步到slave之前,master宕掉了;
slave节点被晋级为master节点,客户端B取得了同一个资源上的另外一个锁,导致安全策略失效.

九. 基于Zookeeper实现分布式锁

让我们来回顾一下Zookeeper节点的概念:
Day07_08_分布式教程之分布式锁实现方案_分布式锁_06

Zookeeper的数据存储结构就像一棵树,这棵树由N个节点组成,这种节点被称为ZNode.

1. ZNode节点的四种类型

  • 1️⃣. 持久节点(PERSISTENT)

默认的节点类型,创建节点的客户端与Zookeeper断开连接后,该节点依旧存在.

  • 2️⃣. 持久顺序节点(PERSISTENT_SEQUENTIAL)

所谓的顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号.

  • 3️⃣. 临时节点(EPHEMERAL)

    和持久节点相反,当创建节点的客户端与Zookeeper断开连接后,临时节点会被删除.

Day07_08_分布式教程之分布式锁实现方案_其他_07

  • 4️⃣. 临时顺序节点(EPHEMERAL_SEQUENTIAL)

顾名思义,临时顺序节点结合和临时节点和顺序节点的特点:在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与Zookeeper断开连接后,临时节点会被删除.

2. Zookeeper分布式锁的原理

Zookeeper实现分布式锁是使用了临时顺序节点,具体如何实现的呢?让我们来看一看详细步骤:

2.1 获取锁

首先,在Zookeeper当中创建一个持久节点ParentLock,当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1.
Day07_08_分布式教程之分布式锁实现方案_分布式锁_09

之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个,如果是第一个节点,则成功获得锁.
Day07_08_分布式教程之分布式锁实现方案_加锁_11

这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2.
Day07_08_分布式教程之分布式锁实现方案_其他_13

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

于是,Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在,这意味着Client2抢锁失败,进入了等待状态.
Day07_08_分布式教程之分布式锁实现方案_分布式锁_15

这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3.
Day07_08_分布式教程之分布式锁实现方案_分布式锁_17

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

于是,Client3向排序仅比它靠前一个的节点Lock2注册Watcher,用于监听Lock2节点是否存在,这意味着Client3同样抢锁失败,进入了等待状态.
Day07_08_分布式教程之分布式锁实现方案_其他_19

这样一来,Client1得到了锁,Client2监听了Lock1,Client3监听了Lock2,这就形成了一个等待队列,很像是Java当中ReentrantLock.

2.2 释放锁

释放锁分为两种情况:

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

当任务完成时,Client1会显示调用删除节点Lock1的指令.
Day07_08_分布式教程之分布式锁实现方案_分布式锁_21

  • 2️⃣. 任务执行过程中,客户端崩溃

获得锁的Client1在任务执行过程中,如果突然崩溃了,就会断开与Zookeeper服务端的链接.根据临时节点的特性,相关联的节点Lock1会随之自动删除.
Day07_08_分布式教程之分布式锁实现方案_加锁_23

由于Client2一直监听着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知,这时候Client2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前排序最小的节点,如果是最小,则Client2顺理成章获得了锁.
Day07_08_分布式教程之分布式锁实现方案_加锁_25

同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知.
Day07_08_分布式教程之分布式锁实现方案_加锁_27

最终,Client3成功得到了锁.

3. Zookeeper实现分布式锁方案:

可以直接使用Zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务.
Day07_08_分布式教程之分布式锁实现方案_分布式锁_29

Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁.

Curator项目连接:https://github.com/apache/curator/

4. Zookeeper实现方案的缺点:

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

其次,使用Zookeeper也有可能带来并发问题,只是并不常见而已.假如在网络抖动的情况下,客户端和ZK集群的session连接断了,那么ZK以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了,就可能产生并发问题.这个问题不常见是因为ZK有重试机制,一旦ZK集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略,多次重试之后还不行的话才会删除临时节点(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡).

十. 分布式锁的安全问题

  • 1️⃣. 长时间的 GC pause;

  • 2️⃣. 时钟发生跳跃;

  • 3️⃣. 长时间的网络 I/O;

  • 4️⃣. GC 的 STW;

  • 5️⃣. 时钟发生跳跃;

  • 6️⃣. 长时间的网络 I/O.

十一. 总结

下面的表格总结了Zookeeper和Redis分布式锁的优缺点:
Day07_08_分布式教程之分布式锁实现方案_分布式锁_31

三种方案的比较

上面几种方式,哪种方式都无法做到完美.就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以应该根据不同的使用场景选择最适合的方案.

理解的难易程度(从低到高)

数据库 < 缓存 < Zookeeper

实现的复杂性(从低到高)

数据库 < 缓存 <= Zookeeper 

性能角度(从低到高)

数据库 <= Zookeeper < 缓存

可靠性(从低到高)

数据库< 缓存 < Zookeeper