分布式锁(1) —— 方案

应用场景

用到分布式锁说明遇到了多个进程共同访问同一个资源的问题, 一般是在两个场景下会防止对同一个资源的重复访问:

  1. 提高效率。比如多个节点计算同一批任务,如果某个任务已经有节点在计算了,那其他节点就不用重复计算了,以免浪费计算资源。不过重复计算也没事,不会造成其他更大的损失。也就是允许偶尔的失败。
  2. 保证正确性。这种情况对锁的要求就很高了,如果重复计算,会对正确性造成影响。这种不允许失败。

这里考虑下保证正确性的场景,电商、供应链场景之中对于库存减少的时候,我们调用库存中心服务进行减库存。通常的sql操作如下:

update store set num = $num where id = $ id

但是在并发场景下会出现丢失更新的问题,通常的解决办法为mysql乐观锁(当然悲观锁也行):

$query_num  = select num from store where id = $id
update store set num = $num where id = $id and num = $query_num

但是这种场景之下只是更新一个表,如果是更新多个表呢?我们希望和这个单子关联的所有的表同一时间只能被一个线程来处理更新。在这种多表多线程的场景下,我们看看悲观锁和乐观锁存在什么样的问题:

  • 悲观锁:多个线程按照不同的顺序去更新同一个单子关联的不同数据,出现死锁的概率比较大。

  • 乐观锁:在高并发的场景下乐观锁的尝试成本也是不可忽略的。

我们的服务都是多机器部署的,要保证多进程多线程同时只能有一个进程的一个线程去处理,这个时候我们就需要用到分布式锁。对于非敏感的数据,我们也没有必要去都加乐观锁处理,也可以考虑使用分布式锁来进行处理。

在设计分布式锁上,最完美的是高效实现单机锁的所有功能。但是实际上分布式锁存在很多的矛盾,并且分布式锁不可能是100%可靠的。

设计考虑点

  • 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行(保证互斥性,基本要求)。

  • 这把锁要是一把可重入锁

  • 具备失效机制,防止死锁(但是这个又和续期是矛盾的)

  • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)

  • 有高可用的获取锁和释放锁功能

  • 获取锁和释放锁的性能要好

实现方式

分布式锁主要有三种实现方式:

  • 基于数据库实现分布式锁;
  • 基于key/value缓存(Redis、Tair等)实现分布式锁;
  • 基于Zookeeper(etcd)实现分布式锁;

数据库实现

唯一性约束

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='锁定中的方法';

获取锁:

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

主要就是使用了数据库表的UNIQUE KEY, 这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功。

解锁就是删除该项记录:

DELETE FROM method_lock WHERE method_name = '$methodName'

乐观锁&排他锁

另外的实现就是使用数据库的排他锁和乐观锁来实现,这里略过。

优缺点

缺点:

  1. 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  3. 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

解决方案:

  1. 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。但是这个会引入一个问题,数据库的同步是百毫秒级,这种情况下并不能排除在切换的时候数据没有同步完。
  2. 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  3. 非阻塞的?搞一个while循环,直到insert成功再返回成功。
  4. 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

基于key/value缓存实现

最常见的基于key/value缓存实现的分布式锁是基于redis的分布式锁。实现中我们通常是使用开源框架Redisson来实现,这里为了讲解原理我们先基于redis的api来自己实现redis分布式锁。

加锁

Redis实现分布式锁使用的主要是set命令的NX、PX参数:

public class RedisTool {
 
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
 
    /**
     * 尝试获取分布式锁
     * 实现的redis命令为: set key value NX [PX milliseconds]
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int milliseconds) {
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, milliseconds);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }
}
  • 第一个为key,使用key来表示锁,用到了redis key的唯一性。
  • 第二个为value,用于表示加锁者。标识加锁者的作用是锁只能被加锁者解锁,这是锁的基本要求,想一下java中ReentrantLock如果是其他线程去解锁,抛出IllegalMonitorStateException。通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
  • 第三个参数是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
  • 第四个参数为PX,意思是我们要给这个key加一个过期的设置。
  • 第五个参数为过期时间的毫秒数。保证在如果客户端down掉之后能够自动解锁。(这里其实也有个问题,如果客户端执行较慢,那么还没有执行玩的时候锁过期如何处理?)

错误示例

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
 
    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }

}
/**
* 执行过程:
* 1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。
* 2. 如果锁已经存在则获取锁的过期时间,和* * 当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。
*/
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
 
    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);
 
    // 如果当前锁不存在,返回加锁成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }
 
    // 如果锁存在,获取锁的过期时间
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        }
    }
    // 其他情况,一律返回加锁失败
    return false;
}

/**那么这段代码问题在哪里?
 * 1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 
 * 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,
 *    但是这个客户端的锁的过期时间可能被其他客户端覆盖。
 * 3. 锁不具备拥有者标识,即任何客户端都可以解锁。
*/

解锁

/**
* 首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
* 为什么要用Lua脚本来执行? 单线程模式redis。脚本相当于命令是阻塞型的原子操作。
*/
public class RedisTool {
 
    private static final Long RELEASE_SUCCESS = 1L;
 
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
 
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }
}

错误示例

public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
    // 可以解除其他线程加的锁
    jedis.del(lockKey);
}
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {    
    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 非原子操作
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }
}

现在考虑一个问题,上述的做法是使用单机实现的,同时由于redis同步使用的是异步的模式,所以无论是用主从、哨兵、集群任何模式都会存在主从切换期间锁不可靠的问题。

redis官方提出了RedLock算法来解决这个问题,后面的文章我们再讲RedLock。

Redission

参考后续文章

Tair 实现

通过 Tair 来实现分布式锁和 Redis 的实现核心差不多,不过 Tair 有个很方便的 api,感觉是实现分布式锁的最佳配置,就是 Put api 调用的时候需要传入一个 version,就和数据库的乐观锁一样,修改数据之后,版本会自动累加,如果传入的版本和当前数据版本不一致,就不允许修改。代码如下:

public boolean trylock(String key) {
  	ResultCode code = ldbTairManager.put(NAMESPACE, key, "This is a Lock.", 2, 0);
  	if (ResultCode.SUCCESS.equals(code)) {
    		return true;
  	} else {
    		return false;
  	}
}

public boolean unlock(String key) {
  	ldbTairManager.invalid(NAMESPACE, key);
}

以上实现方式同样存在几个问题:

  1. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在tair中,其他线程无法再获得到锁。

  2. 这把锁只能是非阻塞的,无论成功还是失败都直接返回。

  3. 这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在tair中已经存在。无法再执行put操作。

解决方式:

  • 没有失效时间?tair的put方法支持传入失效时间,到达时间之后数据会自动删除。
  • 非阻塞?while重复执行。
  • 非可重入?在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。

但是,失效时间我设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。这个问题使用数据库实现分布式锁同样存在

基于Zookeeper、etcd实现

基于zookeeper临时有序节点可以实现的分布式锁。

大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

来看下Zookeeper能不能解决前面提到的问题。

  • 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
  • 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
  • 不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
  • 单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

可以直接使用zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。

public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
  	try {
      	return interProcessMutex.acquire(timeout, unit);
    } catch (Exception e) {
      	e.printStackTrace();
    }
  	return true;
}

public boolean unlock() {
  	try {
      	interProcessMutex.release();
    } catch (Throwable e) {
      	log.error(e.getMessage(), e);
    } finally {
      	executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
    }
  	return true;
}

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

使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

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

总结

使用Zookeeper实现分布式锁的优点

有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。

使用Zookeeper实现分布式锁的缺点

性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。

Curator

参考:Curator开源代码之中的example

三种方案的比较

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

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

Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)

缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)

Zookeeper > 缓存 > 数据库

posted @ 2020-07-23 18:57  江舟  阅读(251)  评论(0)    收藏  举报