目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案。
针对分布式锁的实现,目前比较常用的有以下几种方案:
1. 基于数据库实现分布式锁
2. 基于缓存(redis)实现分布式锁 (推荐)
3. 基于Zookeeper实现分布式锁
基于数据库表
要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。
当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。
优点:
缺点:
会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
操作数据库需要一定的开销,性能问题需要考虑。
使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。
相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以主从部署的,可以解决单点问题。
目前主流Redis实现分布锁:
(1)使用redis的 setnx方法
(2)使用Redisson 基于Redis的lua脚本实现 稳定、可靠、可重入、重试
Redisson是redis官网推荐的java语言实现分布式锁的项目。当然,redisson远不止分布式锁, redisson实现了分布式和可扩展的java数据结构,支持的数据结构有:List, Set, Map, Queue, SortedSet, ConcureentMap, Lock, AtomicLong, CountDownLatch。并且是线程安全的,底层使用Netty 4实现网络通信。
Redis的setnx命令 实现分布式锁需要配合get set以及事务来完成,这样才能比较好的避免死锁问题,而redisson使用lua脚本,可以避免使用事务以及操作多个redis命令。
Redisson 扩展了java.util.concurrent标准并发接口Lock
扩展了很多方法,常用的主要有:强制锁释放,带有效期的锁,还有一组异步的方法。其中前面两个方法主要是解决标准lock可能造成的死锁问题。比如某个线程获取到锁之后,线程所在机器死机,此时获取了锁的线程无法正常释放锁导致其余的等待锁的线程一直等待下去。
可重入主要考虑的是性能,同一线程在未释放锁时如果再次申请锁资源不需要走申请流程,只需要将已经获取的锁继续返回并且记录上已经重入的次数即可,与jdk里面的ReentrantLock功能类似。重入次数靠hincrby命令来配合使用
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));
}
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));
}
#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);
}
Redisson 实现分布锁 lock() 的基本用法
RLock lock = redissonClient.getLock("foobar"); // 1.获得锁对象实例
lock.lock(); // 2.获取分布式锁
try {
// do something.
} finally {
lock.unlock(); // 3.释放锁
}
通过 RedissonClient 的 getLock() 方法取得一个 RLock 实例。
lock() 方法尝试获取锁,如果成功获得锁,则继续往下执行,否则等待锁被释放,然后再继续尝试获取锁,直到成功获得锁。
unlock() 方法释放获得的锁,并通知等待的节点锁已释放。
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