分布式锁

对于使用分布式架构的项目, 不可避免的要处理一个问题,即对于多系统之间的数据一致性的问题。因为在单点环境中,对于数据的抢占问题,都是由事务来进行控制的, 但是在分布式环境下,普通的单点事务无法保证数据安全的。如电商项目中,多台服务器节点为用户抢购商品出现超卖等。

分布式锁四个特点:
1、互斥性:在同一时刻,只能有一个客户端持有锁。
2、避免死锁:即使有一个客户端在持有锁的时候崩溃并且没有释放锁, 也能保证后续客户端可以加锁。
3、容错性:只要大部分客户端正常运行, 就能进行加锁与解锁。
4、唯一性:一个客户端只能去释放自己的锁, 不能去操作其他客户端的锁。

实现方式
  基于数据库实现
  基于redis实现
  基于zookeeper实现

数据库实现分布式锁
方式一:基于悲观锁-排他锁

悲观锁(Pessimistic Concurrency Control,PCC)的特点是先获取锁,在进行业务操作。即悲观地认为获取锁式非常有可能失败的,因此要先确保获取锁成功再进行业务操作。这点和java 中的 synchronized 和相似。所以悲观锁需要消耗比较多的时间。悲观锁是数据库自己实现了的。

创建一张锁表:

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for lock
-- ----------------------------
DROP TABLE IF EXISTS `lock`;
CREATE TABLE `lock`  (
  `id` bigint(32) NOT NULL,
  `method_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `uni_methd_name`(`method_name`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

SET FOREIGN_KEY_CHECKS = 1;

加锁:

public boolean lock(){
    connection.setAutoCommin(false);
    while(true){
        try{
            result = select * from lock where method_name=xxx for update;
            if(result == null){
                return true;
            }
        }catch(Exception e){

        }
        sleep(2000);
    }
    return false;
}

  在查询语句后面加上 for update之后,数据库会在查询过程中给表中查询的记录添加上排他锁,当某条记录被加上排他锁之后,其他线程就无法再继续在该条记录上添加排他锁。接着就可以认为获得排他锁的线程就相当于获得了分布式锁。

解锁:

public boolean unlock(){
      result = select * from lock where method_name=xxx for update;
      if(result != null){
        delete;
          connection.commit();
          return true;
    }
      return false;
}

最主要的点:for update   服务加上唯一的索引。

当for update的字段为索引或者主键的时候,只会锁住索引或者主键对应的行。
而当for update的字段为普通字段的时候,Innodb会锁住整张表。

Q:当加完锁但服务器宕机,是否会出现死锁?
  不会,使用这样方式,当服务器宕机,数据库会自己释放掉锁。

优点:
  是一种比较安全的实现方法。
缺点:
  在高并发的场景下,对于性能的开销非常大并且很容易出现死锁连接池堆满等情况。并且mysql在查询操作的时候会自动进行优化,如果你当前表中的数据很少,会自动采用全表扫描不会去使用索引。此时mysql会自动使用表锁而不是行锁。


方式二:基于乐观锁

乐观锁(Optimistic Concurrency Control,OCC)
假设数据在一般情况下不会发生冲突,只有在提交更新的时候才会对数据是否冲突进行检测,则给用户返回错误消息。
通过记录数据版本进行实现。当读取数据的时候,会将版本号一同读出, 当要进行数据更新的时候,会对版本号进行加一的操作。当提交数据的时候,会判断数据表中对应记录的当前版本信息与第一次取出来的版本号是否相同,如果当前版本号与第一次取出来的version相等。则执行更新。否则认为是过期数据不进行更新。

实现
场景:多个用户在进行买票抢座
构建一张座位表(seat)   -->version字段即上述版本号。  status=0 表示可以购买。1不可购买。

id     1
seat_no   78774898
status    0
version    0

无版本号控制
当有A,B两个用户同时来进行选座并且争抢 id=1 的这个座位的时候. 执行顺序如下
1、A用户-查询:select * from seat where id=1 --> result : id=1,seat_no=78774898,status=0,version=0
2、B用户-查询:select * from seat where id=1 --> result : id=1,seat_no=78774898,status=0,version=0
3、A用户-修改:update seat set status=1 where id=1
4、B用户-修改:update seat set status=1 where id=1

基于上述操作,A,B两个用户在查询的时候都得到该座位是未被购买的,可以进行update操作进行购买,那么最终两者都执行了update,出现脏数据,也就是所谓的超卖

基于版本号控制  --->引入版本号的概念
当有A,B两个用户同时来进行选座并且争抢 id=1 的这个座位的时候. 执行顺序如下
1、A用户-查询:select * from seat where id=1 --> result : id=1,seat_no=78774898,status=0,version=0
2、B用户-查询:select * from seat where id=1 --> result : id=1,seat_no=78774898,status=0,version=0
  上述,符合购买条件(两者同时开始购买)  --->根据 status判定两人都可购买。
3、A用户-修改:update seat set status=1,version=version+1 where id=1 and version=0
  行锁的特性(同时只能一个事务操作一行数据)
4、B用户-修改:update seat set status=1,version=version+1 where id=1 and version=0;

  根据 mysql 行级锁的特点,两个人是不可能同时修改同一条数据。是存在先后顺序的。只有头一个人执行完update后,后一个人才能进行update操作。
  乐观锁可以作为分布式锁来使用。
此时 B 用户的修改操作的失败的, 因为 A 用户已经对该座位完成购买并且已经将版本号改为了1。所以 B 用户购买失败,这就是通过版本号实现乐观锁的方式。

Q:是否会产生死锁
 会,当在两个事务中执行更新的记录行相同且交叉执行的时候,会出现死锁。
事务1

update seat set status =1 where id=1
update seat set status =0 where id=2

事务2

update seat set status=1 where id=2
update seat set status=0 where id=1   //执行到这里的时候,事务1中的id=1的操作正在执行,根据行级锁的特点是被锁住的。事务1的第二句操作同理。陷入
锁的状态。

  对于以上两个事务操作,当事务1在修改 id=1 的记录的时候,事务2也在修改 id=2 的记录,此时这两条记录都是被锁住的状态,但是当1与2两个事务继续向下执行,事务1修改 id=2 的记录,事务2修改 id=1 的记录时,此时操作的都是对方事务中两条被锁住的记录,形成死锁

Q:如何解决:
  修改执行顺序。
事务1

update seat set status =1 where id=1
update seat set status =0 where id=2

事务2

update seat set status=0 where id=1
update seat set status=1 where id=2

  事务1在执行第一个update的时候 id=1 的记录被锁住,此时当事务2想修改 id=1 的记录时只能等到 A 执行完成。这样就不会产生死锁了。

小结:
  该方式是通过表中的记录来确定锁是否存在。
  优点:是性能上优于悲观锁的,不容易出现死锁。
  缺点:是乐观锁只能对一张表的数据进行加锁,如果需要对多张表的数据操作加锁,乐观锁是无法办到的。

  基于数据库来实现分布式锁是最容易理解的一种实现方式,但是不管出于性能的考虑还是可靠性的考虑,数据库实现的方式都是最差的。并且在解决问题的过程中,会使整个方案变的越来越复杂。


redis实现分布式锁
加锁
1、新建maven工程,导入jedis依赖

<dependency>
   <groupId>redis.clients</groupId>
   <artifactId>jedis</artifactId>
   <version>2.9.0</version>
 </dependency>

2、正确实现

public class RedisUtils {
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "EX";
    /**
     * @param jedis   redis客户端
     * @param lockKey  锁
     * @param requestId  请求标识
     * @param expireTime  过期时间
     * @return
     */
    public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime){
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        if (result.equals(LOCK_SUCCESS)){
            return true;
        }
        return false;
    }
}

基于这段代码就可以完成基于redis加锁,是不是感觉非常简单,看着就一句话,一个set方法。但是这个set方法中有五个参数,这里其实需要重点理解这五个参数的作用。
1、lockKey:作为 redis 中的 key,这里会使用 key 来当锁,因为 key 在 redis 中是唯一的。
2、requestId:作为 redis 中相对应的 value , 这里传的是 requestid 。传这个值主要是为了保证第四点,唯一性,谁加的锁,就由谁来将这把锁释放掉。那么将 value 设置为 requestid,我们在解锁的时候就这把锁是由哪个请求加的了。requestid的值可以直接使用uuid操作。
3、第三个参数为nx xx。对于这个参数可以使用两个值, 分别为 NX 与 XX 。NX代表如果当前 key 不存在时,会执行set方法,XX代表当这个key存在的时候,会执行set方法。本方法中使用NX。
4、第四个参数为expx。 这个参数是用来设置第五个参数的时间单位的,有两个值,EX与PX,EX代表秒,PX代表毫秒。本方法中使用EX。

总结来说,当执行了上面这个方法只会存在两个结果。
1、当没有锁 (key不存在的时候),会将value设置为客户端标识,进行加锁操作并设置锁的有效时长。
2、当锁存在 (key存在的时候),不进行任何操作。

1、通过设置 nx 属性值,保证了一个客户端只能存在一把锁,保证了互斥性(当key存在的时候,不进行任何操作,当key不存在,进行set操作)
2、通过设置过期时间满足了避免死锁。因为就算在持有锁的阶段崩溃了没有释放掉锁,但是时间到了之后一样会自动销毁。
3、通过将 clientid 设置为value,作为客户端标识,那么当客户端在解锁的时候,就可以基于这个值来判断是否为同一个客户端,满足了唯一性。

加锁错误案例一:

public static void wrongGetLock1(Jedis jedis,String lockKey,String clientId,int expireTime){

        Long result = jedis.setnx(lockKey, clientId);
        if (result == 1){
       //加锁成功 。。程序突然崩溃-->死锁 jedis.expire(lockKey,expireTime); }
//这种方式看着感觉和上面的实现方式是一样的, 但是有一点需要注意, 这里加锁与设置过期时间是两行代码实现的. 换句话说 //就是通过两条命令来完成, 不具备原子性操作, 那么当存储完锁之后, 但是要准备对锁设置过期时间的时候, 服务器突然崩溃了 //那么就会对当前的锁没有设置过期时间, 会导致死锁. }

 加锁错误案例二:
思路:
使用 setnx() 进行加锁,将 lockKey 作为 key , 将过期时间作为 value,如果锁不存在,则加锁成功,如果锁已经存在,则将过期时间与当前时间比较,如果过期时间小于当前时间,锁过期,重置锁的过期时间。

public static  boolean wrongGetLock2(Jedis jedis,String lockKey,int expireTime){
        long expire = System.currentTimeMillis() + expireTime;
        String currentExpireTime = String.valueOf(expire);

        //锁不存在
        if (jedis.setnx(lockKey,currentExpireTime) == 1){
            return true;
        }

        //锁存在
        String lockExpireTime = jedis.get(lockKey);
        if (lockExpireTime !=null && Long.valueOf(lockExpireTime)< System.currentTimeMillis()){
            //代表锁已经过期
            //将当时新的过期时间设置给相对应的lockkey, 并得到老的过期时间
            String oldExpireTime  = jedis.getSet(lockKey,currentExpireTime);
            if (oldExpireTime!=null && oldExpireTime.equals(currentExpireTime)){
                return true;
            }
        }

        return false;
        /**
         * 1. 客户端的时间都是由每一个客户端自己生成的, 所以强一致性的要求每一个客户端的时间必须相同
         * 2. 当锁过期的时候, 基于上述代码虽然可以保证多个客户端同时访问只有一个客户端可以加锁, 但是锁的时间有可能被覆盖
         * 3. 锁不具备拥有者的标识, 任何客户端都可以去解锁
         */
    }

  由于 String currentExpireTime = String.valueOf(expire); 当前的时间是由客户端自己来生成的。
  1、那么就需要在分布式环境下每一个客户端的时间必须强一致性的同步。这样会对你的服务器早成非常大的压力。
  2、而且当前的操作没有客户端标识的,那么当锁过期的时候,多个客户端同时执行jedis.getSet(lockKey,currentExpireTime);方法的时候,虽然最终只有一个客户端可以进行加锁,但是你客户端的锁的过期时间,很容易被其他客户端锁抢占,然后进行一个值的覆盖。
  3、当前在进行加锁操作的时候,没有设置客户端标识,那么在解锁的时候,任何一个客户端都可以进行解锁的操作。

解锁

public class RedisTool {

    private static final Long RELEASE_SUCCESS = 1L;
    /**
     * 释放分布式锁 
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String clientId) {
        //确保这个操作是具有原子性的
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
       //执行lua代码的时候 ,会被当做一个命令去执行。
     Object result
= jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(clientId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }

  'get', KEYS[1]) == ARGV[1]  判断加解锁是都是同一个。 相等则 return redis.call('del', KEYS[1]) else return 0 end" 。   'del', KEYS[1])解锁操作。 return 0 end 解锁失败。
  基于上述的两行代码就可以完成redis解锁的操作,第一行是一段Lua脚本,代表当redis将 KEYS[1] 的值作为 key 去执行 get 方法之后,得到的值会与 ARGV[1] 进行对比,如果相等的话则将 KEYS[1] 的值作为 key 执行 del 操作, 否则返回 0。删除 key 的操作就相当于解锁的操作。第二行表示调用eval()方法执行Lua脚本,将lockKey的值作为KEYS[1]。将clientId的值作为ARGV[1]的值。基于 eval() 执行 Lua 脚本还可以确保当前操作的原子性,因为eval()会将当前Lua脚本作为一个命令去执行,并且只有当执行完毕才可以执行其他命令。
  
错误案例1:

public static void wrongReleaseLock1(Jedis jedis, String lockKey) {

    jedis.del(lockKey);

}

  这种方式没有判断当前锁是属于谁的,会导致任意客户端都可以去解锁,并且无法保证这把锁是否属于当前客户端,违背了唯一性。

错误案例2:

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String clientId) {

    // 判断加锁与解锁是不是同一个客户端
    if (clientId.equals(jedis.get(lockKey))) {

        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }
}

  上述代码的操作与正确案例基本相同,只是将解锁的操作分为了两步,错误问题在于如果调用 del() 的时候,这把锁已经不属于当前客户端-->锁到期了。那么就会将其他客户端的锁给解锁,违背了唯一性。比如说:客户端A加锁之后过了一段时间解锁,在执行del()之前,锁过期了。此时客户端B加锁成功加锁。当客户端A再去执行del()的时候,会将客户端B解锁。

小结:
  上述操作完成了使用redis实现分布式锁的代码实现,基于redis完成分布式操作并不难,并且性能上要优于数据库的实现方式。


zookeeper实现分布式锁
zookeeper节点特性
1、事件监听:
当读取数据的时候,可以同时对节点设置监听,当节点数据或结构发生变化时,zookeeper就会动态的向客户端发出通知。
对于zookeeper中事件的类型有四种:
  1、创建节点
  2、删除节点
  3、节点数据修改
  4、子节点变更
2、有序节点:
zookeeper对节点提供了一个可供选择的有序特性,假如当前有一个根节点 "/lock",那么当在该根节点下创建节点 (/lock/lock-) 时,zookeeper会在生成子节点的时候,根据当前子节点的数量为当前子节点自动添加整数序号,如果是第一个则为 /lock/lock-00。下一个为01。以此类推。
3、临时节点:
客户端可以创建临时节点,当前会话结束或超时时,zookeeper会自动删除该节点。

实现思路
1、第一个客户端连接zookeeper,并在根节点 /lock 下创建一个有序且临时的节点。自动被zookeeper命名为 /lock/lock-00。当第二个客户端连接zookeeper后,重复上述步骤且编号递增。
2、当前客户端获取根目录 /lock 下所有子节点,并判断此时自己的节点序号是否为节点列表中最小的。如果是,则判定为获得锁。否则持续监听当前节点序号 -1 的子节点删除的消息,当获取到节点变更的消息,则继续判断。
3、执行业务代码,执行完毕,删除当前节点(释放锁)。

Q:为什么创建临时节点而不是永久节点?
  可以避免死锁。比如当客户端 A 创建的子节点为序号最小的节点,此时客户端 A 相当于获取到了锁,假设在客户端 A 还没有释放锁的时候突然宕机,那么zookeeper在一定时间内没有收到客户端的心跳包的话,就是自动删除该节点,避免死锁的产生。但是如果当前节点为永久节点,则永久不会被删除,出现死锁。

Q:基于监听机制是否会出现永久等待?
  假设客户端 A 创建子节点为 /lock/lock-00,客户端 B 创建子节点为 /lock/lock-01,此时客户端 B 获取子节点列表发现不是最小的,则进行设置监听,但在监听没有设置完之前,客户端A执行完且删除子节点 /lock/lock-00 释放锁。对于客户端B来说,则丢失了获取锁的机会,处于永久等待
  上述情况不会发生,因为zookeeper对于获取子节点列表进行判断操作与设置监听是原子性操作,从而保证不会丢失事件,出现永久等待。

Q:当没有获取到锁,为什么监听当前节点序号 -1 的节点,而不是监听整个子节点列表?
  完成性能优化,如果监听整个子节点列表,当获得锁的节点将锁释放,则会唤醒这个子节点列表中的每一个节点。这种效果称为"羊群效应"。在此效应中 zookeeper 会通知整个子节点列表,从而阻塞其他操作。因此只唤醒新的最小节点对应的客户端即可。

代码实现
  利用Apache提供的zookeeper客户端 Curator 可以非常方便的实现加锁解锁的操作。
添加依赖:

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>4.0.0</version>
</dependency>
public static void main(String[] args) throws Exception {
    //创建zookeeper的客户端
    RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
    CuratorFramework client = CuratorFrameworkFactory.newClient("192.168.25.121:2181,192.168.25.122:2181,192.168.25.123:2181", retryPolicy);
    client.start();
    //创建分布式锁, 锁空间的根节点路径为/curator/lock
    InterProcessMutex mutex = new InterProcessMutex(client, "/curator/lock");
    mutex.acquire();
    //获得了锁, 进行业务流程
    //完成业务流程, 释放锁
    mutex.release();
    //关闭客户端
    client.close();
}

小结:
  zookeeper实现分布式锁主要通过对节点进行添加、删除以及设置节点监听来完成。有较好的性能和可靠性。

这里讲解了实现分布式锁的几种方式.
使用分布式锁的场景:
  效率:使用分布式锁可以避免不同节点重复相同工作,避免资源浪费,提高性能,比如用户下单之后有不同节点发出相同短信。
  正确性:分布式锁可以保证数据的正确性,如果两个节点在同一条数据上面操作,很有可能出现脏数据。

从实现复杂度的讨论:数据库>redis>zookeeper. 
从性能角度:redis>zookeeper>数据库. 
从可靠性角度:zookeeper>redis>数据库

需要根据不同的项目,不同的业务场景来选择使用哪一种锁。

posted @ 2019-02-23 17:28  payn  阅读(432)  评论(0)    收藏  举报