分布式锁选型研究

一、分布式锁使用场景

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

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案。

针对分布式锁的实现,目前比较常用的有以下几种方案
1. 基于数据库实现分布式锁
2. 基于缓存(redis)实现分布式锁 (推荐)
3. 基于Zookeeper实现分布式锁

二、分布式锁特性:

  1. 互斥: 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行,未获取锁的线程被阻塞等待。
  2. 避免死锁:如果一个线程获得锁,然后挂了,并没有释放锁,致使其他节点(线程)永远无法获取锁,这就是死锁。分布式锁必须做到避免死锁。
  3. 高可用:有高可用的获取锁和释放锁功能。
  4. 性能:获取锁和释放锁的性能要好。
  5. 锁特性:考虑到复杂的场景,分布式锁不能只是加锁,然后一直等待。实现如Java Lock的一些功能如:锁判断,超时设置,可重入性等。

三、基于数据库实现分布式锁

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

优点:

  1. 直接借助数据库,容易理解。

缺点:

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

会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
操作数据库需要一定的开销,性能问题需要考虑。
使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。

四、基于缓存实现分布式锁(推荐)(基于Redis的开源分布式锁实现 Redisson)

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以主从部署的,可以解决单点问题。

目前主流Redis实现分布锁:
(1)使用redis的 setnx方法
(2)使用Redisson 基于Redis的lua脚本实现 稳定、可靠、可重入、重试

1. Redisson实现分布式锁原理

1.1 简介

Redisson是redis官网推荐的java语言实现分布式锁的项目。当然,redisson远不止分布式锁, redisson实现了分布式和可扩展的java数据结构,支持的数据结构有:List, Set, Map, Queue, SortedSet, ConcureentMap, Lock, AtomicLong, CountDownLatch。并且是线程安全的,底层使用Netty 4实现网络通信。

1.2 redis setnx VS redisson

Redis的setnx命令 实现分布式锁需要配合get set以及事务来完成,这样才能比较好的避免死锁问题,而redisson使用lua脚本,可以避免使用事务以及操作多个redis命令。

1.3 Redisson 扩展

Redisson 扩展了java.util.concurrent标准并发接口Lock
扩展了很多方法,常用的主要有:强制锁释放,带有效期的锁,还有一组异步的方法。其中前面两个方法主要是解决标准lock可能造成的死锁问题。比如某个线程获取到锁之后,线程所在机器死机,此时获取了锁的线程无法正常释放锁导致其余的等待锁的线程一直等待下去。

1.4 可重入机制

可重入主要考虑的是性能,同一线程在未释放锁时如果再次申请锁资源不需要走申请流程,只需要将已经获取的锁继续返回并且记录上已经重入的次数即可,与jdk里面的ReentrantLock功能类似。重入次数靠hincrby命令来配合使用

1.5 加锁流程源码分析

lock()的基本用法:

RLock lock = redissonClient.getLock("foobar"); // 1.获得锁对象实例
lock.lock(); // 2.获取分布式锁
try {
    // do sth.
} finally {
    lock.unlock(); // 3.释放锁
}

原理
通过 RedissonClient 的 getLock() 方法取得一个 RLock 实例。
lock() 方法尝试获取锁,如果成功获得锁,则继续往下执行,否则等待锁被释放,然后再继续尝试获取锁,直到成功获得锁。
unlock() 方法释放获得的锁,并通知等待的节点锁已释放。

获取锁getLock()方法源码分析:

@Override
public RLock getLock(String name) {
    return new RedissonLock(connectionManager.getCommandExecutor(), name);
}

解释
构造 RedissonLock 的参数
commandExecutor: 与 Redis 节点通信并发送指令的真正实现。需要说明一下,Redisson 缺省的 CommandExecutor 实现是通过 eval 命令来执行 Lua 脚本,所以要求 Redis 的版本必须为 2.6 或以上,否则你可能要自己来实现 CommandExecutor。关于 Redisson 的 CommandExecutor 以后会专门解读,所以本次就不多说了。
name: 锁的全局名称,例如上面代码中的 “foobar”,具体业务中通常可能使用共享资源的唯一标识作为该名称。
id: Redisson 客户端唯一标识,实际上就是一个 UUID.randomUUID()。

加锁lock() 方法源码分析:
调用的核心方法是lockInterruptibly() 调用tryAcquire()方法 调用tryAcquireAsync()方法 再调用tryLockInnerAsync()方法 最后 使用 EVAL 命令执行 Lua 脚本获取锁

Redisson 使用 EVAL 命令执行Lua 脚本加锁流程:
如果通过 exists 命令发现当前 key 不存在,即锁没被占用,则执行 hset 写入 Hash 类型数据 key:全局锁名称(例如共享资源ID), field:锁实例名称(Redisson客户端ID:线程ID), value:1,并执行 pexpire 对该 key 设置失效时间,返回空值 nil,至此获取锁成功。
如果通过 hexists 命令发现 Redis 中已经存在当前 key 和 field 的 Hash 数据,说明当前线程之前已经获取到锁,因为这里的锁是可重入的,则执行 hincrby 对当前 key field 的值加一,并重新设置失效时间,返回空值,至此重入获取锁成功。
最后是锁已被占用的情况,即当前 key 已经存在,但是 Hash 中的 Field 与当前值不同,则执行 pttl 获取锁的剩余存活时间并返回,至此获取锁失败。

源码:

@Override
public void lock() {
    try {
        lockInterruptibly();
 } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
 }
}
@Override
public void lockInterruptibly() throws InterruptedException {
    lockInterruptibly(-1, null);
}
@Override
 public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
        long threadId = Thread.currentThread().getId();
 Long ttl = tryAcquire(leaseTime, unit, threadId); //// 1.尝试获取锁
 // lock acquired
 if (ttl == null) { // // 2.获得锁成功
            return;
 }

        // // 3.等待锁释放,并订阅锁
        RFuture<RedissonLockEntry> future = subscribe(threadId);
  commandExecutor.syncSubscription(future);

 try {
            while (true) {
             // 4.重试获取锁   
             ttl = tryAcquire(leaseTime, unit, threadId);
  // lock acquired // 5.成功获得锁
  if (ttl == null) {
                break;
  }

                // waiting for message // 6.等待锁释放
  if (ttl >= 0) {
               getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
  } else {
               getEntry(threadId).getLatch().acquire();
  }
           }
        } finally {
           //// 7.取消订阅 
           unsubscribe(future, threadId);
 }
 // get(lockAsync(leaseTime, unit));
 }
 

private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    //// 1.将异步执行的结果以同步的形式返回
    return get(tryAcquireAsync(leaseTime, unit, threadId));
}
 

private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, final long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
 }
    // 2.用默认的锁超时时间去获取锁
    RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
 ttlRemainingFuture.addListener(new FutureListener<Boolean>() {
        @Override
 public void operationComplete(Future<Boolean> future) throws Exception {
            if (!future.isSuccess()) {
                return;
 }

            Boolean ttlRemaining = future.getNow();
 // lock acquired // 成功获得锁
 if (ttlRemaining) {
                // 3.锁过期时间刷新任务调度
                scheduleExpirationRenewal(threadId);
 }
        }
    });
 return ttlRemainingFuture;
}
 

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
//使用EVAL 命令 执行redis lua脚本
 return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
 "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; " +
              "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; " +
              "return redis.call('pttl', KEYS[1]);",
 Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
1.6 加锁流程源码分析

Redisson 使用 EVAL 命令执行 Lua 脚本来释放锁流程:
key 不存在,说明锁已释放,直接执行 publish 命令发布释放锁消息并返回 1。
key 存在,但是 field 在 Hash 中不存在,说明自己不是锁持有者,无权释放锁,返回 nil。
因为锁可重入,所以释放锁时不能把所有已获取的锁全都释放掉,一次只能释放一把锁,因此执行 hincrby 对锁的值减一。
释放一把锁后,如果还有剩余的锁,则刷新锁的失效时间并返回 0;如果刚才释放的已经是最后一把锁,则执行 del 命令删除锁的 key,并发布锁释放消息,返回 1。
上面执行结果返回 nil 的情况(即第2中情况),因为自己不是锁的持有者,不允许释放别人的锁,故抛出异常。
执行结果返回 1 的情况,该锁的所有实例都已全部释放,所以不需要再刷新锁的失效时间。

@Override
public void unlock() {
    Boolean opStatus = get(unlockInnerAsync(Thread.currentThread().getId()));
 if (opStatus == null) {
        throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
 + id + " thread-id: " + Thread.currentThread().getId());
 }
    if (opStatus) {
        cancelExpirationRenewal();
 }
}

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    //使用eval命令 执行redis lua脚本
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
 "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            "else " +
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
 Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}

2. Redisson常用配置

#redisson lock单机模式
redisson:
 database: 3 #配置尝试连接redis 数据库编号
 address: redis://10.210.210.33:21330 #redis连接地址
 password: #redis密码
 timeout: 10000 #毫秒 默认 3000 等待节点回复命令的时间。该时间从命令发送成功时开始计时。
 connectionPoolSize: 64 #连接池大小 默认 64 连接池的连接数量自动弹性伸缩。
 connectionMinimumIdleSize: 10 # 最小空闲连接数 默认 32 最小保持连接数(长连接)。长期保持一定数量的连接有利于提高瞬时写入反应速度。
 idleConnectionTimeout: 10000 # 连接空闲超时,单位:毫秒 默认 10000 如果当前连接池里的连接数量超过了最小空闲连接数,而同时有连接空闲时间超过了该数值,那么这些连接将会自动被关闭,并从连接池里去掉。时间单位是毫秒。
 pingTimeout: 1000
  connectTimeout: 10000 # 连接超时,单位:毫秒 默认 10000 同节点建立连接时的等待超时。时间单位是毫秒。
 retryAttempts: 3 # 命令失败重试次数 默认 3次 如果尝试达到 retryAttempts(命令失败重试次数) 仍然不能将命令发送至某个指定的节点时,将抛出错误。如果尝试在此限制之内发送成功,则开始启用 timeout(命令等待超时) 计时。
 retryInterval: 1500 # 命令重试发送时间间隔,单位:毫秒 默认 1500 在一条命令发送失败以后,等待重试发送的时间间隔。时间单位是毫秒。
 subscriptionsPerConnection: 5 # 单个连接最大订阅数量 默认5
 clientName: null # 客户端名称 默认null 在Redis节点里显示的客户端名称。
 subscriptionConnectionMinimumIdleSize: 1 # 发布和订阅连接的最小空闲连接数 默认1 用于发布和订阅连接的最小保持连接数(长连接)。Redisson内部经常通过发布和订阅来实现许多功能。长期保持一定数量的发布订阅连接是必须的。
 subscriptionConnectionPoolSize: 50 # 发布和订阅连接池大小 默认 50 用于发布和订阅连接的连接池最大容量。连接池的连接数量自动弹性伸缩。
/**
 * 单机模式自动装配
 *
 * @param
 * @return
 * @author 冯赵杨
 */
@Bean(destroyMethod = "shutdown")
RedissonClient redissonSingle() {
    Config config = new Config();
 SingleServerConfig serverConfig = config.useSingleServer()
            .setAddress(address)
            .setDatabase(database)
            .setTimeout(timeout)
            .setConnectionPoolSize(connectionPoolSize)
            .setConnectionMinimumIdleSize(connectionMinimumIdleSize)
            .setIdleConnectionTimeout(idleConnectionTimeout)
            .setPingTimeout(pingTimeout)
            .setConnectTimeout(connectTimeout)
            .setRetryAttempts(retryAttempts)
            .setRetryInterval(retryInterval)
            .setSubscriptionsPerConnection(subscriptionsPerConnection)
            .setClientName(clientName)
            .setSubscriptionConnectionMinimumIdleSize(subscriptionConnectionMinimumIdleSize)
            .setSubscriptionConnectionPoolSize(subscriptionConnectionPoolSize)
            .setDnsMonitoringInterval(dnsMonitoringInterval);
 if (StringUtils.isNotBlank(password)) {
        serverConfig.setPassword(password);
 }

    return Redisson.create(config);
}

3. Redisson使用方式

Redisson 实现分布锁 lock() 的基本用法

RLock lock = redissonClient.getLock("foobar"); // 1.获得锁对象实例
lock.lock(); // 2.获取分布式锁
try {
    // do something.
} finally {
    lock.unlock(); // 3.释放锁
}

通过 RedissonClient 的 getLock() 方法取得一个 RLock 实例。
lock() 方法尝试获取锁,如果成功获得锁,则继续往下执行,否则等待锁被释放,然后再继续尝试获取锁,直到成功获得锁。
unlock() 方法释放获得的锁,并通知等待的节点锁已释放。

4. 总结

Redisson是redis分布式方向落地的产品,不仅开源免费,而且内置分布式锁,分布式服务等诸多功能,是基于redis实现分布式的最佳选择。

参考文档:
Redisson 封装使用
http://git.intra.weibo.com/bop/bop-fms-group/fms/tree/develop/bop-fms-business/bop-fms-subaccount/src/main/java/com/weibo/bop/fms/subaccount/support/distributedlock
Redisson 官网
https://github.com/redisson/redisson/wiki/Redisson项目介绍
Redisson 教程
https://blog.csdn.net/u014042066/article/details/72778440
Redisson 锁类型
https://blog.csdn.net/l1028386804/article/details/73523810
阿里云专访Redisson作者Rui Gu:构建开源企业级Redis客户端之路
https://yq.aliyun.com/articles/603575

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