分布式锁

之前分析了分布式事务及其保证方式,在分布式家族中,自然而然的又想到分布式锁。首先思考几个问题:

 

0、单机环境下的锁有哪些实现方式?单机环境下的锁为何解决不了分布式环境锁相关问题

1、为什么需要分布式锁?

2、分布式事务和分布式锁有关系吗?有什么关系?

3、分布式锁的实现方式有哪些?(哪些方式可以实现分布式锁?)

主要参考文章:

《基于Redis分布式锁的实现》

https://juejin.im/post/5cc165816fb9a03202221dd5#heading-10

 

  • 一、锁的基础知识
    • 1、可重入锁
    • 2、读写锁
    • 3、乐观锁、悲观锁
    • 4、可中断锁
    • 5、公平锁、非公平锁
    • 6、数据库中锁的概念
  • 二、单机环境下锁的实现方式
    • 1、synchronized关键字
    • 2、volatile关键字
    • 3、Lock类
  • 三、分布式环境下锁
    • 1、分布式锁一般面临如下问题:
    • 2、分布式锁的实现方式:
  • 四、Redis如何实现分布式锁
    • 4.1 Redis单节点下可用的锁方式
      • 4.1.1 加锁过程
        •  
        • 1、setnx + expire命令 实现分布式锁代码实现(错误的实现方式)
        • 2、使用Lua脚本来保证原子性(包含setnx和expire两条指令)
        • 3、使用 set key value [EX seconds] [PX milliseconds] [NX | XX] 命令 (redis集群环境存在问题)
      • 4.1.2 释放锁过程
      • 4.1.3 这三种加锁方式特点
    • 4.2 Redlock算法 与 Redisson 实现
      • 1、RedLock算法
      • 2、Redisson实现简单的分布式锁
    • 4.3 可重入加锁的判断
      • 1、setnx 是否是可重入的 (不是可重入的)
      • 2、lua脚本是否可重入(不是可重入的)
      • 3、使用 set key value [EX seconds] [PX milliseconds] [NX | XX] 命令 是否可重入(不是可重入的)
      • 4、红锁(Redission锁)(可重入的)
    • 4.4 业务时间大于过期时间
      • 1、setnx  能否保证  业务时间大于过期时间时锁被延迟释放(不能)
      • 2、lua脚本  能否保证  业务时间大于过期时间时锁被延迟释放(不能)
      • 3、使用 set key value [EX seconds] [PX milliseconds] [NX | XX] 命令 能否保证  业务时间大于过期时间时锁被延迟释放(不能)
      • 4、红锁(Redission锁) 能否保证  业务时间大于过期时间时锁被延迟释放(不能)
      • 5、什么方式可以保证 业务时间大于过期时间时 锁被延迟释放
  • 五、Mysql中如何实现分布式锁
    • 5.1 创建锁表实现分布式锁
    • 5.2 基于 数据库版本标识 做乐观锁
    • 5.3 基于数据库表做悲观锁
  • 六、Zookeeper中实现分布式锁

 

一、锁的基础知识

1、可重入锁

java下的实现方式:java.util.concurrent.locks.ReentrantLock(接口)

可重入是指,任意线程在获取到锁之后能够再次获取该锁,而不会被该锁所阻塞,该特性的实现需要解决两个问题:

1)线程再次获取锁:锁需要去识别获取锁的线程是否是当前占据锁的线程,如果是则再次获取锁成功

2)锁的最终释放:线程重复n次获取了该锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取的次数进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数为0时表示锁已经成功释放。

2、读写锁

读写锁将对一个资源的访问分成了2个锁,如文件,一个读锁和一个写锁。正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突

java下的实现方式:java.util.concurrent.locks.ReadWriteLock(接口)就是读写锁

实现类:ReentrantReadWriteLock,可以通过readLock()获取读锁,通过writeLock()获取写锁。

3、乐观锁、悲观锁

乐观锁:认为数据一般情况下的操作不会造成冲突,在数据进行更新提交的时候再判断是否有其他线程争用共享数据,没有那就操作就成功了;如果共享数据有争用,产生了冲突,那就在采取其他的补偿措施,比如更新失败

悲观锁:总是认为不去做正确的同步措施就肯定会出现问题,所以先对操作对象加锁在进行操作。

4、可中断锁

可中断锁,即可以中断的锁。在Java中,synchronized就不是可中断锁,而Lock是可中断锁
如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。Lock接口中的 lockInterruptibly()方法 就体现了Lock的可中断性。

5、公平锁、非公平锁

公平锁即尽量以请求锁的顺序来获取锁。同时有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该锁,这种就是公平锁。

synchronized是非公平锁,它无法保证等待的线程获取锁的顺序。对于ReentrantLockReentrantReadWriteLock,默认情况下是非公平锁,但是可以设置为公平锁。

6、数据库中锁的概念

数据库中锁有很多不同的划分方式:

数据库锁的范围 行锁、表锁 备注
数据库锁的属性 共享锁(读锁、S锁)、排他锁(写锁、X锁)  
数据库锁的模式 乐观锁、悲观锁  
数据库锁的算法 临间锁、间隙锁、记录锁 不明白
数据库锁的状态 意向共享锁、意向拍他锁 不明白


二、单机环境下锁的实现方式

1、synchronized关键字

这是java中最基本的互斥同步的手段。synchronized关键字经过编译之后,会在同步块的前后形成monitorenter和monitorexit两个字节码指令,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象的参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或者是Class对象作为所对象。

synchronized实现的同步互斥是一种阻塞同步,属于一种悲观的并发策略。

synchronized 是在 JVM 层面上实现的,synchronized在锁定时如果方法块抛出异常,JVM 会自动将锁释放掉,不会因为出了异常没有释放锁造成线程死锁。

2、volatile关键字

volatile在多线程并发编程中synchronized和Volatile都扮演着重要的角色,Volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

关于synchronized和volatile的底层实现,专门整理了一个wiki.

 

3、Lock类

Lock的锁定是通过代码实现的,出现异常时必须在 finally 将锁释放掉,否则将会引起死锁。

在资源竞争不是很激烈的情况下,偶尔会有同步的情形下,synchronized是很合适的。原因在于,编译程序通常会尽可能的进行优化synchronize,另外可读性非常好。

Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)。性能上来说,在资源竞争不激烈的情形下,Lock性能稍微比synchronized差点(编译程序通常会尽可能的进行优化synchronized)。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。

三、分布式环境下锁

1、分布式锁一般面临如下问题:

互斥性 : 同一时刻只能有一个线程拿到锁

锁超时 : 不能无限制时间的拿到一个锁,防止死锁

支持阻塞和非阻塞 : 能够及时从阻塞状态被唤醒

可重入性 : 同一个服务节点上的同一个线程如果获取了一个锁后能够再次获取该锁

高性能、高可用 :加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效

2、分布式锁的实现方式:

1. 数据库乐观锁;2. 基于Redis的分布式锁;3. 基于ZooKeeper的分布式锁。

思考:为什么加锁(并发安全性),redis如何加锁(setnx), 系统抛出异常如何保证锁的正常释放(finally), 宕机如何保证锁的正常释放(超时设置),如何保证A线程不会释放掉B线程的锁(保证线程拿到的锁有该线程的标识),如何保证线程内逻辑在超时时间内完成(延长锁超时时间),一个线程逻辑内多次加锁(可重入锁)。如果解决了所有这些是否就没有问题(No, CAP), 如何基于Redis实现保证分布式的一致性(红锁)

四、Redis如何实现分布式锁

Redis实现分布式锁,主要借助了SETNX命令(set if not exits), setnx key value .    将key设置为value,当键不存在时,才能成功,若键存在,什么也不做,成功返回1,失败返回0 .

分布式锁还需要超时机制,所以我们利用expire命令来设置,setnx + expire命令 是实现分布式锁的主要命令。

4.1 Redis单节点下可用的锁方式

这里说的单节点指的是没有主从的意思,不是说集群只有一个redis服务节点。(But, 既然都存在多个多个服务节点了,肯定是要做主从的?另外我们自己的业务中一般就是单节点而已,因为我们的业务不够复杂)

4.1.1 加锁过程

对于Java用户而言,我们经常使用Jedis,Jedis是Redis的Java客户端。注意,使用Jedis, 需要加入依赖:

    redis.clients

    jedis

    2.9.0

1、setnx + expire命令 实现分布式锁代码实现(错误的实现方式)

import redis.clients.jedis.Jedis;

public class Goods {

    private Jedis jedis = new Jedis();

    public boolean tryLock(String key, String requset, int timeout) {

        Long result = jedis.setnx(key, requset);

        // 拿到锁

        if (result == 1L) {

            System.out.println(jedis.get(key));  -----------------(1)

            return jedis.expire(key, timeout) == 1L;

        } else { // 没有拿到锁

            return false;

        }

    }

}

存在问题:

setnx 和 expire是分两步进行的,不具有原子性,如果在(1)处抛出了异常,锁将无法过期!

改善方式:

使用Lua脚本来保证原子性(包含setnx和expire两条指令)

 

当然还存在其他问题,看本节第3部分。

2、使用Lua脚本来保证原子性(包含setnx和expire两条指令)

// timeout 单位是s

public boolean tryLockWithLua(String key, String requset, int timeout){

    String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then " +

            "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";

    List keys = new ArrayList<>();

    List values = new ArrayList<>();

    keys.add("qin");

    values.add("wenjiang");

    values.add(String.valueOf(timeout));

    Object result = jedis.eval(lua_scripts, keys, values);

    //判断是否成功

    return result.equals(1L);

}

优点:解决了setnx 和 expire两个命令不具备原子性的特点

存在问题:lua脚本阅读不方便

当然还存在其他问题,看本节第3部分。

3、使用 set key value [EX seconds] [PX milliseconds] [NX | XX] 命令 (redis集群环境存在问题)

先来解释下这个命令:

  • EX seconds: 设定过期时间,单位为秒
  • PX milliseconds: 设定过期时间,单位为毫秒
  • NX: 仅当key不存在时设置值
  • XX: 仅当key存在时设置值

set命令的nx选项,就等同于setnx命令。

源码:

public String set(final String key, final String value, final String nxxx, final String expx, final int time)
public Boolean tryLockSet(String key, String requset, int timeout){

    String result = jedis.set(key, requset, "NX", "EX", timeout);

    return "OK".equals(result);

}

优点:解决了setnx 和 expire两个命令不具备原子性的特点;解决lua脚本阅读不方便问题

 

思考:上面一直说到的还存在的问题是什么呢?

在实际业务中,多个客户端同时操作,并发是必然存在的,思考这样一个场景:

1.客户端1获取锁成功

2.客户端1在某个操作上阻塞了太长时间

3.设置的key过期了,锁自动释放了

4.客户端2获取到了对应同一个资源的锁

5.客户端1从阻塞中恢复过来,因为value值是一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题

即如何保证并发情况下A线程不会释放掉B线程的锁

可以让线程在获取某个对象的锁时,设置的value值包含该线程相关的信息,或者包含一个唯一的字符串(UUID来做),在释放锁时对value值进行验证,判断是否是当前线程持有的锁。

(这里说的value值对应上面例子中方法参数中的request参数,在实际业务中获取锁时,目的是为了保证某个对象被锁定,而不是设置value值,当然也有只是为了设置value值的场景,这种下面在思考 )

思考:还存在问题吗?当然存在,上面的例子中都设置了过期时间,如果业务执行时间超过了这个过期时间呢?看下面4.4节

4.1.2 释放锁过程

上面已经分析了,解锁时,需要判断锁是否是当前线程自己的锁,判断方式是基于value值判断

public Boolean releaseLockWityLua(String key,String value){

    String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +

            "return redis.call('del',KEYS[1]) else return 0 end";

    return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);

}

分析:这里使用了lua脚本的方式实现释放锁,

使用 set key value [EX seconds][PX milliseconds][NX|XX] 命令 看上去很OK,实际上在Redis集群的时候也会出现问题,比如说A客户端在Redis的master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,master故障,发生故障转移,一个slave节点升级为master节点,B客户端也可以获取同个key的锁,但客户端A也已经拿到锁了,这就导致多个客户端都拿到锁

思考:为什么lua脚本就能可以呢?看到后面会发现就能功能优秀的红锁底层的实现也是lua脚本的形式,lua脚本这么神奇么?

 

4.1.3 这三种加锁方式特点

事实上这类琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  1. 在Redis的master节点上拿到了锁;

  2. 但是这个加锁的key还没有同步到slave节点;

  3. master故障,发生故障转移,slave节点升级为master节点;

  4. 导致锁丢失。(自认为拿到了锁,实际上没有拿到)

正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。笔者认为,Redlock也是Redis所有分布式锁实现方式中唯一能让面试官高潮的方式。

4.2 Redlock算法 与 Redisson 实现

一种更高级的分布式锁的实现——红锁

1、RedLock算法

假设有5个独立的Redis节点( 这5个节点完全互相独立,不存在主从复制 或者 其他集群协调机制):

  • 获取当前Unix时间,以毫秒为单位
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁,当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应用小于锁的失效时间,例如你的锁自动失效时间为10s,则超时时间应该在5~50毫秒之间,这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间,当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失败时间时,锁才算获取成功。
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)
  • 如果某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)

思考:红锁算法的前提是n个完全独立的节点,这里的完全独立到底什么意思?

我理解的这里的完全独立是相当于部署了n个redis服务(为了区分可以叫为 Redission node),Redission node 节点既可以是单机模式(single),也可以是主从模式(master/salve),哨兵模式(sentinal),或者集群模式(cluster)。这就意味着,不能跟以往这样只搭建 1个 cluster、或 1个 sentinel 集群,或是1套主从架构就了事了,需要为 RedissonRedLock 额外搭建多几套独立的 Redission 节点。 比如可以搭建3个 或者5个 Redission节点,具体可看视资源及业务情况而定。

(为了小数点后的一点点性能的提高,真的是无所不用极其啊!!!!)

 

2、Redisson实现简单的分布式锁

redisson已经有对redlock算法封装。

对于Java用户而言,我们经常使用Jedis,Jedis是Redis的Java客户端,除了Jedis之外,Redisson也是Java的客户端Jedis是阻塞式I/O,而Redisson底层使用Netty可以实现非阻塞I/O,该客户端封装了锁,继承了J.U.C的Lock接口,所以我们可以像使用ReentrantLock一样使用Redisson,具体使用过程如下。

1、首先加入依赖

    org.redisson

    redisson

    3.10.6

2、代码

import java.util.concurrent.CountDownLatch;



import org.redisson.Redisson;

import org.redisson.api.RLock;

import org.redisson.api.RedissonClient;

import org.redisson.config.Config;



public class RedissonTest {

    public static void lockByClient() {

        // redisson配置

        Config config = new Config();

        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);



        // redisson客户端

        RedissonClient redissonClient = Redisson.create(config);

        // 通过名字返回一个锁实例

        RLock lock = redissonClient.getLock("redlock");



        // 获取锁, 如果拿不到则线程被阻塞休眠直到获取到锁

        lock.lock();



        // 结果立即返回,如何可以立即拿到锁返回true, 否则返回false

        Boolean result = lock.tryLock();

        System.out.println("reuslt = " + result);

        try {

            System.out.println("current thread name is:" + Thread.currentThread().getName());

            System.out.println("获取锁成功,实现业务逻辑");

            Thread.sleep(1000);

        } catch (InterruptedException e) {

            e.printStackTrace();

        } finally {

            lock.unlock();

            lock.unlock();

        }

    }



    public static void main(String[] args) {

        final CountDownLatch latch = new CountDownLatch(3);



        Thread thread1 = new Thread(new Runnable() {

            @Override

            public void run() {

                lockByClient();

                latch.countDown();

            }

        });

        Thread thread2 = new Thread(new Runnable() {

            @Override

            public void run() {

                lockByClient();

                latch.countDown();

            }

        });

        Thread thread3 = new Thread(new Runnable() {

            @Override

            public void run() {

                lockByClient();

                latch.countDown();

            }

        });

        thread1.start();

        thread2.start();

        thread3.start();

        try {

            latch.await();

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

    }

}

分析:

1、如果用一个线程去运行,上面加两次锁,两次都可以拿到锁(可重入)

2、如果多个线程去运行,上面加一次锁,多个线程依次拿到锁并释放

3、如果多个线程去运行,上面加两次锁,但是finally中只unlock依次,只有一个线程拿到锁(再次验证了可重入性)

4、如果多个线程去运行,上面加两次锁, finally中unlock两次,多个线程可以依次拿到锁并释放(再次验证了可重入性)

 

Redission重要说明:

1、唯一ID : 实现分布式锁的一个非常重要的点就是set的value要具有唯一性,redisson的value是怎样保证value的唯一性呢?答案是UUID+threadId。通过lock.lock(); 追踪源码可以看到设置的value值

final UUID id;

protected String getLockName(long threadId) {

    return id + ":" + threadId;

}

2、获取锁

 RFuture tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command) {

    internalLockLeaseTime = unit.toMillis(leaseTime);

    // 向redis集群中的n个节点都发送命令

    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,

              //首先分布式锁的KEY不能存在,如果确实不存在,那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1),并通过pexpire设置失效时间(也是锁的租约时间)

              "if (redis.call('exists', KEYS[1]) == 0) then " +

                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +

                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +

                  "return nil; " +

              "end; " +

               

              // 如果分布式锁的KEY已经存在,并且value也匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间

              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +

                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +

                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +

                  "return nil; " +

              "end; " +

              // 获取分布式锁的KEY的失效时间毫秒数

              "return redis.call('pttl', KEYS[1]);",

              // 这三个参数分别对应KEYS[1],ARGV[1]和ARGV[2]

              Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));

}

获取锁的命令中,

  • KEYS[1]就是Collections.singletonList(getName()),表示分布式锁的key,即REDLOCK_KEY;

  • ARGV[1]就是internalLockLeaseTime,即锁的租约时间,默认30s;

  • ARGV[2]就是getLockName(threadId),是获取锁时set的唯一值,即UUID+threadId:
     

从上面这个例子中也看出,redission锁在加锁的时候实现了可重入性

 

3、释放锁

protected RFuture unlockInnerAsync(long threadId) {

    // 向redis集群中的n个节点都发送命令

    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,

            // 如果分布式锁KEY不存在,那么向channel发布一条消息

            "if (redis.call('exists', KEYS[1]) == 0) then " +

                "redis.call('publish', KEYS[2], ARGV[1]); " +

                "return 1; " +

            "end;" +

             // 如果分布式锁存在,但是value不匹配,表示锁已经被占用,那么直接返回

            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +

                "return nil;" +

            "end; " +

            // 如果就是当前线程占有分布式锁,那么将重入次数减1

            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +

            // 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,还不能删除

            "if (counter > 0) then " +

                "redis.call('pexpire', KEYS[1], ARGV[2]); " +

                "return 0; " +

            "else " +

                 // 重入次数减1后的值如果为0,表示分布式锁只获取过1次,那么删除这个KEY,并发布解锁消息

                "redis.call('del', KEYS[1]); " +

                "redis.call('publish', KEYS[2], ARGV[1]); " +

                "return 1; "+

            "end; " +

            "return nil;",

            // 这5个参数分别对应KEYS[1],KEYS[2],ARGV[1],ARGV[2]和ARGV[3]

            Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));

}

从上面这个例子中也看出,redission锁在释放锁的时候实现了可重入性

 

4.3 可重入加锁的判断

1、setnx 是否是可重入的 (不是可重入的)


public boolean tryLockReent(String key, String requset, int timeout) {

    Long result = jedis.setnx(key, requset);

    // 拿到锁

    if (result == 1L) {

        System.out.println("first:" + jedis.get(key));

        Long result2 = jedis.setnx(key, requset + "----" + requset);

        if (result2 == 1L) {

            System.out.println("second" + jedis.get(key));     -----没有打印出

        }

        return jedis.expire(key, timeout) == 1L;

    } else { // 没有拿到锁

        return false;

    }

}

结果:第二次并不能加锁成功

2、lua脚本是否可重入(不是可重入的)

// timeout 单位是s

public boolean tryLockWithLua(String key, String requset, int timeout) {

    String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then " +

            "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";

    List keys = new ArrayList<>();

    List values = new ArrayList<>();

    keys.add(key);

    values.add(requset);

    values.add(String.valueOf(timeout));

    Object result = jedis.eval(lua_scripts, keys, values);

    System.out.println("first reuslt=" + result.equals(1L));     ----------打印first reuslt=true



    List values2 = new ArrayList<>();

    values2.add(requset + "---" + requset);

    values2.add(String.valueOf(timeout));

    Object result2 = jedis.eval(lua_scripts, keys, values2);

    System.out.println("second reuslt=" + result2.equals(1L));   ----------打印second reuslt=false



    //判断是否成功

    System.out.println(jedis.get(key));

    return result.equals(1L);

}

结果:第二次并不能加锁成功

3、使用 set key value [EX seconds] [PX milliseconds] [NX | XX] 命令 是否可重入(不是可重入的)


public Boolean tryLockSet(String key, String requset, int timeout) {

    String result = jedis.set(key, requset, "NX", "EX", timeout);

    System.out.println("first reuslt=" + "OK".equals(result));    ----------打印first reuslt=true



    String result2 = jedis.set(key, requset, "NX", "EX", timeout);

    System.out.println("second reuslt=" + "OK".equals(result2));  ----------打印second reuslt=false

}

4、红锁(Redission锁)(可重入的)

上面的例子中已经明确分析了

4.4 业务时间大于过期时间

至此,还有一个问题是,如果业务时间大于了过期时间,Redis如何保证锁能够不被释放呢?

1、setnx  能否保证  业务时间大于过期时间时锁被延迟释放(不能)

public static void main(String[] args) {

    Goods goods = new Goods();

    Boolean result = goods.tryLock("qin", "wenjing", 10);

    System.out.println(result);

    try {

        Thread.sleep(5000);

    } catch (InterruptedException e) {

        e.printStackTrace();

    }

    Boolean relaseResult = goods.releaseLockWityLua("qin", "wenjing");

    System.out.println("relaseResult=" + relaseResult);

}

分析:如果获取锁时设置的过期时间小于5s, 下面释放锁就返回false, 如果大于5s, 下面释放锁就返回true

2、lua脚本  能否保证  业务时间大于过期时间时锁被延迟释放(不能)

3、使用 set key value [EX seconds] [PX milliseconds] [NX | XX] 命令 能否保证  业务时间大于过期时间时锁被延迟释放(不能)

4、红锁(Redission锁) 能否保证  业务时间大于过期时间时锁被延迟释放(不能)

public static void tryLockTest(){

    // redisson配置

    Config config = new Config();

    config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);

    // redisson客户端

    RedissonClient redissonClient = Redisson.create(config);

    RLock lock = redissonClient.getLock("redlock");

    try {

        Boolean result = lock.tryLock(5, TimeUnit.SECONDS);

        if(result == true){

            System.out.println(Thread.currentThread().getName() +" get lock time is "+System.currentTimeMillis());

            Thread.sleep(10000);

        }else{

            tryLockTest();

        }

    } catch (InterruptedException e) {

        e.printStackTrace();

    }finally {

        lock.unlock();

    }

}

分析:过期时间小于业务执行的时间,会发现多个线程都可以拿到锁,但是当一个线程去释放锁的时候,会报类似错误:

Exception in thread "Thread-0" java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: 40982ea0-0482-405f-aa9f-8ba77cb084c6 thread-id: 10

说明锁已经被释放了,而线程在完成业务逻辑后又去释放了一次导致的错误。

 

5、什么方式可以保证 业务时间大于过期时间时 锁被延迟释放

1、换成zk,zk也是实现分布式锁的一种方式, 使用心跳检测,

2、每次更新过期时间时,Redis 用 MULTI 做 check-and-set 检查更新时间是否被其他线程修改了,假如被修改了,说明锁已经被抢走,放弃这把锁。

五、Mysql中如何实现分布式锁

5.1 创建锁表实现分布式锁

最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。当我们要锁住某个方法或资源的时候,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录

创建一张这样的表:

CREATE TABLE `methodLock` (

  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',

  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',

  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',

  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',

  PRIMARY KEY (`id`),

  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE

)

 ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

想要锁住一个方法时,执行下面SQL:

insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

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

方法执行完毕后,想要释放锁,需要执行下面SQL:

delete from methodLock where method_name ='method_name'

 

存在问题:

1、有重载的方法的话,method_name就不唯一了

解决方式:insert的时候,method_name字段存入的值可以带上方法参数类型

2、这种锁没有失效时间,一旦操作失败导致锁没有释放记录就一直存在于数据库中,其他线程也无法再次拿到这个锁

解决方式:可以用一个定时任务定时清理超过时间限制的记录

3、这种锁依赖数据库的可用性,数据库是个单点

解决方式:那就稿数据库主从复制的模式,数据库之前双向同步,一旦挂掉快速切换到备库上

思考:实际业务中可能为了一个并发锁去做数据库的主从复制吗,我想是不太可能的,因为有更优的并发锁的解决方式

4、这把锁是非阻塞的,因为数据的insert操作,如果一旦插入失败就会直接报错。没有获取到的锁的线程不会进入到排队队列,这种交互也是不好的

解决方式:可以写一个while循环,知道insert成功拿到锁

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

解决方式:可以在数据库表中加一个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库中可以查到的话,就直接把锁分配给它即可。

总结:通过分析上面存在的问题和解决方式,容易发现,这种锁的实现方式,完全依靠开发人员自己实现,可是我们有更优秀的解决分布式锁的方案(比如redis),所以这种方式还没见过在项目中使用过。优点是,既然分布式锁都是开发人员自己实现了,那么分布式锁中面临的各种问题,就可以自己找到解决方案并实现,比如上面一直讨论的执行时间大于过期时间的问题。

5.2 基于 数据库版本标识 做乐观锁

数据库实现乐观锁的方式就是记录数据版本

数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。

实现数据版本有两种方式,第一种是使用版本号(version字段),第二种是使用时间戳

注意点:

乐观锁机制是在我们的系统中实现的,对于数据库的版本字段的修改不要对外暴露修改途径,对外只开放基于此存储过程的数据更新途径。

5.3 基于数据库表做悲观锁

可以通过数据库的排他锁实现分布式锁。在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。

注意 : 

  • InnoDB引擎在加锁的时候,只有 通过索引进行检索 的时候才会使用行级锁,否则会使用表级锁。
  • for update仅适用于InnoDB,且必须在事务块(BEGIN/COMMIT)中才能生效。在进行事务操作时,通过“for update”语句

 

存在问题:

1、即使我们要做的查询其查询条件上加上了索引,并且显示使用for update来使用行级锁。但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。

2、使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。

思考:有时候会碰到说拿不到数据库连接的问题,这时候就需要考虑下业务中是否存在长时间拿着连接不释放的问题(长连接问题)

分析:

1、这种for update加锁的方式属于阻塞锁, 执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。

2、这种for update加锁的方式不可重入

思考:这已经是个sql语句了,一个线程内业务顺序执行,没有需要重入的需求呀

2、锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

3、单点故障,这种锁是无法解决这种问题,需要从架构的方面去考虑了

 

六、Zookeeper中实现分布式锁

你可能感兴趣的:(分布式)