分布式锁

相关链接

超卖事故

一、什么是分布式锁

  在多线程模型中,可以使用同步机制实现安全访问数据。但是多个服务器访问时,会导致数据最终不一致问题。常见分布式锁可以基于数据库/Redis/Zookeeper实现。

  分布式锁应该具备以下条件:

  1.一个方法在同一时间只能被一个机器的一个线程执行(互斥)。

  2.高可用的获取锁与释放锁(高可用)。

  3.具备锁失效机制,防止死锁。没有获取到锁将直接返回获取锁失败(健壮性)。

  4.加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给释放了,自己持有的锁也不能被其他客户端释放(唯一性)。

二、基于数据库--乐观锁

  在数据库中创建一张表,表里包含方法名等字段,并且在方法名字段上面创建唯一索引,执行某个方法需要使用此方法名向表中插入数据,成功插入则获取锁,执行结束则删除对应的行数据释放锁。

reate table `lock_table` (
    `id` int(11) unsigned NOT NULL auto_increment comment '主键',
    `resource_id` varchar(128) NOT NULL comment '标识资源',
    `desc` varchar(128) default NULL comment '描述',
    `ctime` bigint(20) NOT NULL COMMENT '创建时间',
    `utime` bigint(20) NOT NULL COMMENT '更新时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `unq_resource` (`resource_id`)
) engine=InnoDB default charset=utf8mb4

tryLock

insert into lock_table (resource_id, desc) values ('resource_name1', 'desc1')

unLock

delete from lock_table where resource_id = 'resource_name1'

  存在问题:不具有可重入性、没有锁失效设置、不具备阻塞特性。

1. 阻塞:使用while循环

2. 可重入锁:变量名判断--如果客户端id和数据库resourse_id相等,则判定是同一个锁

3. 锁失效设置:后台定时任务扫描超时的任务

小结:原理简单。实现复杂,需要自己考虑多种情况。不需要引入额外中间件。

三、基于缓存数据库Redis

1.加锁

setnx(key , value)

  key是锁的唯一标识,value应设置满足唯一性。如果执行setnx返回1,说明key原本不存在,该线程成功获得锁。反之,失败。

2.解锁

del(key)

  释放锁之后其他线程就可以继续执行setnx命令。

3.锁超时

expire(key, time)

  del因为故障没有执行的话,会出现死锁现象。此时使用expire定时释放锁。

4.三大问题

  (1)setnx和expire中间也会出现故障,此时使用set命令代替。

set(key, value, time, NX)

  (2) 任务A没有在规定时间内完成,到时间之后就释放锁。然后任务B重新获得锁,但是A执行完后执行del方法把任务B的锁给删除了。解决:value设置具有唯一性,不同线程执行不同的锁。

  (3)任务没有在规定时间完成,给锁的线程开启一个守护线程,用来给快要过期的锁续航。即expire延长过期时间。由于守护线程和锁线程在同一个进程,当锁线程宕机或者其他故障,守护线程也会失败,到期后锁会自动释放。

5. 实现可重入锁-ThreadLocl+Redis

public class RedisReentrantLock {
    private ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<>();
    private Jedis jedis;

    public RedisReentrantLock(Jedis jedis) {
        this.jedis = jedis;
    }

    public boolean lock(String key, int expires) {
        Map<String, Integer> lockCountMap = getLockCountMap();
        Integer count = lockCountMap.get(key);
        if (count != null) {
            lockCountMap.put(key, count + 1);
            return true;
        }
        String res = jedis.set(key, "", SetParams.setParams().nx().ex(expires));
        if (res == null) return false;
        lockCountMap.put(key, 1);
        return true;
    }

    public boolean unLock(String key) {
        Map<String, Integer> lockCountMap = getLockCountMap();
        Integer count = lockCountMap.get(key);
        if (count == null) return false;
        count--;
        if (count > 0) lockCountMap.put(key, count);
        else {
            lockCountMap.remove(key);
            jedis.del(key);
        }
        return true;
    }

    private Map<String, Integer> getLockCountMap() {
        Map<String, Integer> lockCountMap = lockers.get();
        if (lockCountMap != null) return lockCountMap;
        lockers.set(new HashMap<>());
        return lockers.get();
    }
}

测试

public static void main(String[] args) {
    RedisReentrantLock lock = new RedisReentrantLock(new Jedis("127.0.0.1", 6379));
    final String LOCK_KEY = "lock_a";

    Thread thread1 = new Thread(()->{
        try {
            while (!lock.lock(LOCK_KEY, 5)) {
                System.out.println("thread1:wait lock");
                Thread.sleep(500);
            }
            System.out.println("thread1:get lock");
            Thread.sleep(200);
            lock.unLock(LOCK_KEY);
            System.out.println("thread1:release lock");
        } catch (InterruptedException e) {
        }
    });

    Thread thread2 = new Thread(()->{
        try {
            while (!lock.lock(LOCK_KEY, 5)) {
                System.out.println("thread2:wait lock");
                Thread.sleep(500);
            }
            System.out.println("thread2:get lock");
            Thread.sleep(200);
            lock.unLock(LOCK_KEY);
            System.out.println("thread2:release lock");
        } catch (InterruptedException e) {
        }
    });

    thread1.start();
    thread2.start();

    while (Thread.activeCount() > 0)
        Thread.yield();
}

输出

thread2:get lock

thread1:wait lock

thread2:release lock

thread1:get lock

thread1:release lock

  存在问题:

  1. 锁超时后提前释放导致失去互斥性:获取锁的任务由于执行时间过长,超过了expires指定的时间,这时候redis会自动释放锁,其他节点就可能获得该锁,最后违背了互斥性属性。
  2. 锁在主备不一致导致失去互斥性:在redis主备模式下,当客户端a从主获取到锁后,但在将主复制到slave结点前主挂了,备提升为主,此时锁不存在,客户端b可能会获取锁,
改进方案——RedLock

RedLock算法描述

  • 部署5个Redis实例。
  • 客户端获取当前时间current timestamp,单位是毫秒。
  • 客户端用同样的key和value依次尝试在N个实例上建立锁,超时时间要短,如果一个实例上获取不到立即尝试下一个实例。
  • 客户端计算获取锁的用时time elapsed,只有当在大多数实例上获取到锁,且获取锁的用时小于锁的有效时间,才认为获取锁成功。
  • 如果获取到锁,锁的实际有效时间 = 初始有效时间 – time elapsed
  • 如果客户端没有在多数节点上获取锁,或者锁的实际有效时间是负数,则在所有实例上都执行释放锁操作

Redlock的问题

    • 如果只是为了效率,根本没有必要设置5个Redis实例
    • Redlock本身也不够安全(租约到期lease expired,锁失效导致数据写入冲突)

优化1—— 引入fencing token
    • Lock service需要提供自增的token
    • Storage server在每次写入时检查token

优化2—— Redisson

  Redisson是通过不断续租来解决锁超时问题,Redisson 中有一个 Watchdog 的概念,它会在你获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s。如此一来就算一直持有锁也不会出现 Key 过期了其他线程获取到锁的问题了(不断续租,不会任务未执行完不会出现租约到期情况)。

  Redisson 的“看门狗”逻辑保证了没有死锁发生。(如果机器宕机了,看门狗也就没了。此时就不会延长 Key 的过期时间,到了 30s 之后就会自动过期了,其他线程可以获取到锁)

四、基于Zookeeper--使用临时顺序节点

1.获取锁

  首先在Zookeeper当中创建一个持久节点ParentLock。当地一个客户端想要获得锁时,在ParentLock这个节点下面创建一个临时顺序节点Lock1,之后Client1查找ParentLock下面的所有的临时顺序节点并排序,判断自己创建的节点Lock1是不是顺序最靠前的一个,如果是则成功获得锁。这时候,如果再有一个客户端 Client2 /3前来获取锁,则在 ParentLock 下载再创建一个临时顺序节点 Lock2/3。

  Client2 向排序仅比它靠前的节点 Lock1 注册 Watcher,用于监听 Lock1 节点是否存在。这意味着 Client2 抢锁失败,进入了等待状态。

于是,Client2 向排序仅比它靠前的节点 Lock1 注册 Watcher,用于监听 Lock1 节点是否存在。这意味着 Client2 抢锁失败,进入了等待状态。这样一来,Client1 得到了锁,Client2 监听了 Lock1,Client3 监听了 Lock2。这恰恰形成了一个等待队列。

2.释放锁

  (1)任务完成,客户端显示释放

  任务完成时,Client1会显示调用删除节点Lock1的指令。

  (2)任务执行过程中,客户端崩溃

  获得锁的 Client1 在任务执行过程中,如果崩溃,则会断开与 Zookeeper 服务端的链接。根据临时节点的特性,相关联的节点 Lock1 会随之自动删除。


 五、Redis和Zookeeper分布式锁比较

参考链接

原文地址:https://www.cnblogs.com/qmillet/p/12487401.html