分布式锁实现原理之深入redis、浅谈zookeeper与mysql
文章目录
回顾
在上一篇中https://blog.youkuaiyun.com/rekingman/article/details/104035265应用篇中,我们讲解了如何使用分布式锁。但是很多东西会用是一回事,我们还要弄懂原理,才能用得更好。
redis分布式锁
redis分布式锁的发展
起初
- 使用setnx命令,设置一个key和一个value
- 如果key对应的value不存在,设置value,则获得锁
- 如果value存在,则获取锁失败或者采用自旋去获取
- 存在问题
- 如果一个进程获取lock之后,由于宕机,并未执行del命令,那么锁不能有限时间释放,会炸
引入expire
- 在最初的基础上引入了expire机制,即键值对超时失效机制
- 如果超过有效时间,那么会自动释放
- 存在问题
- 起初setnx与expire是分开设置的,是不具备原子性的
- 意味着在setnx之后,如果系统宕机,那么expire执行不了,仍然出现上述问题
- 起初setnx与expire是分开设置的,是不具备原子性的
redis 支持lua
- redis在2.6之后支持lua脚本,但是lua脚本的执行是原子性的,刚好可以满足上述的要求
- 存在问题
- 锁过期问题,即能否在有效时间内执行完任务,否则待锁自动释放,则会出现实例不安全问题。
- 还有一个问题是性能的问题,即由于redis是单线程多路IO复用设计,lua脚本执行进行解析和原子调用,是会阻塞整个redis实例的,当然,基于内存使得它的问题不是那么大。
redis pub/sub机制
- 发布/订阅机制
- 通过订阅某个channel,然后当某个锁释放的时候,由redis直接通知那些等待获得锁的进程、线程,提高性能;而不是让那些期待获取锁的线程去做死循环等待。
- 原理问题
- publish只占用一个发布连接
- sub会占用多个连接,除非在应用层进行多路复用,因此,使用这种机制要求连接数要够高。
讲了redis作为分布式锁的发展过程,接下来讲讲redission组件中分布式锁的实现原理。
redission分布式锁原理
redission是目前比较稳定且社会活跃度高的java-redis组件,从性能和功能上都是比较完整、丰富的。
可重入独占锁
- redission的锁是基于java锁接口规范去实现的,无论使用或者阅读源码都是比较容易的
以下是尝试获取锁的代码实现。
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
//尝试获取锁,如果没取到锁,则获取锁的剩余超时时间
//之所以需要threadId,是因为需要判断是否是当前线程持有,实现重入锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
current = System.currentTimeMillis();
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(threadId);
return false;
}
try {
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
//进入死循环,反复去调用tryAcquire尝试获取锁,ttl为null时就是别的线程已经unlock了
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired:other thread release lock.
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
//如果锁被释放,那么redis就会publish到对应的channel,所有的subscribe就会给信号量加1,激活同一JVM内的线程去竞争信号量,获取到信号量的线程就能够解除阻塞,去竞争锁。
if (ttl >= 0 && ttl < time) {
//这里是采用semaphore机制,如果获取不到信号量,则会阻塞
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
//这里是采用semaphore机制,如果获取不到信号量,则会阻塞
getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
- redisLUA脚本-获取与释放锁的实现
//尝试获取锁
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
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));
}
//lua脚本释放重入锁
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"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.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
//lua脚本强制释放锁,直接删除key
@Override
public RFuture<Boolean> forceUnlockAsync() {
cancelExpirationRenewal(null);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('del', KEYS[1]) == 1) then "
+ "redis.call('publish', KEYS[2], ARGV[1]); "
+ "return 1 "
+ "else "
+ "return 0 "
+ "end",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE);
}
- 总结
- 独占式可重入锁的实现还是相对简单的,就是redis有限时间键的操作,以及利用hash结构
- 如果key不存在,则设置key : val(uuid:threadId) :counter
- 如果key存在,则利用hincrby 更新对应key下的val与counter,并刷新时间
- 为了提高性能,利用信号量与pub/sub机制,当锁释放,则发布信息,所有订阅连接就会激活对应的lock的信号量,去激活阻塞线程竞争锁。
- 为何要采用信号量机制
- 如果不采用信号量机制,意味着每个阻塞的线程都必须去订阅redis的通道,但是我们都知道,每个订阅都会消耗一个redis连接,在这种情况下,如果不利用复用的思想,那么redis的连接数毫无疑问支撑不了大型的分布式系统。
- 为了实现安全性
- redis会在心跳时间内启动定时的renew任务去刷新锁的期限,避免任务执行未完全从而出现的安全问题。
- 独占式可重入锁的实现还是相对简单的,就是redis有限时间键的操作,以及利用hash结构
读写锁
- 读写锁也是基于redis的hash结构去实现的,但是由于需要实现读写分离,因此,逻辑上的处理会更加复杂一些,下面进行源码的分析
读锁实现
@Override
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
//key[1]=resourcesName
//key[2]= suffixName(getName(), getLockName(threadId)) + ":rwlock_timeout";
//argv[2]=uuid:threadId
//argv[1]=expireTime
//argv[3]=uuid:threadId:write
"local mode = redis.call('hget', KEYS[1], 'mode'); " +
"if (mode == false) then " +
"redis.call('hset', KEYS[1], 'mode', 'read'); " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
//设置一个{lock}:6ebc68ab-79ce-42d7-9c7d-00401fab055e:1:rwlock_timeout:1 对象,注意结尾是:1
"redis.call('set', KEYS[2] .. ':1', 1); " +
"redis.call('pexpire', KEYS[2] .. ':1', ARGV[1]); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (mode == 'read') or (mode == 'write' and redis.call('hexists', KEYS[1], ARGV[3]) == 1) then " +
//如果当前是读锁,或者写锁也是由当前实例当前线程获取的写锁
"local ind = redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"local key = KEYS[2] .. ':' .. ind;" +
"redis.call('set', key, 1); " +
"redis.call('pexpire', key, ARGV[1]); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end;" +
"return redis.call('pttl', KEYS[1]);",
Arrays.<Object>asList(getName(), getReadWriteTimeoutNamePrefix(threadId)),
internalLockLeaseTime, getLockName(threadId), getWriteLockName(threadId));
}
- 分析
- 获取读写锁key下的mode对应的val
- 如果是false,则可以设置锁
- 如果是read模式,或者是write模式并且是当前线程的write模式,则允许实现重入。
- 获取读写锁key下的mode对应的val
写锁实现
@Override
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"local mode = redis.call('hget', KEYS[1], 'mode'); " +
"if (mode == false) then " +
"redis.call('hset', KEYS[1], 'mode', 'write'); " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (mode == 'write') then " +
//当前客户端同线程 即 ARGV[2]为uuid:threadId
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"local currentExpire = redis.call('pttl', KEYS[1]); " +
"redis.call('pexpire', KEYS[1], currentExpire + ARGV[1]); " +
"return nil; " +
"end; " +
"end;" +
"return redis.call('pttl', KEYS[1]);",
Arrays.<Object>asList(getName()),
internalLockLeaseTime, getLockName(threadId));
}
- 分析
- 根据key去获取模式
- 如果没有存在,则设置写锁
- 如果存在,且是当前客户端同线程,则实现重入,并且利用pttl获取剩余时间更新返回
- 根据key去获取模式
- 总结
- 写锁实现相对简单,直接获取模式,如果不存在则设置写锁,类似于可重入独占锁
- 读锁复杂一些
- 如果是读锁,那么则对读锁对应的uuid:threadId进行重入(不存在则是加1)
- 如果是写锁,那么如果是对应的uuid:threadId,则也可以进行重入操作
应用
- 读锁
- 多个实例中的多个线程都可以对资源进行“读”共享访问
- 或者当前实例中的当前线程在对资源获取了“写”独占访问之后允许获取用获取读锁的方式再次获取该写锁(获取之后全局仍然是写锁)
- 写锁
- 只允许一个实例中的一个线程对资源进行“写”独占访问,可重入
一个应用场景就是分布式应用层的缓存锁,读共享,写独占。另外一个应用就是数据库的指定用户资源数据读写分离锁,当对某个用户的资料进行更新,获取对应用户的写锁;浏览资料时,获取对应用户的读锁。
联锁
- 将几个锁分组联合操作管理,是红锁的父类。
红锁
- 假设有5个redis节点,这些节点之间既没有主从,也没有集群关系。客户端用相同的key和随机值在5个节点上请求锁,请求锁的超时时间应小于锁自动释放时间。当在3个(超过半数)redis上请求到锁的时候,才算是真正获取到了锁。如果没有获取到锁,则把部分已锁的redis释放掉。
public class RedissonRedLock extends RedissonMultiLock {
/**
* 一个红锁由多个独立实例的锁创建。
* Creates instance with multiple {@link RLock} objects.
* Each RLock object could be created by own Redisson instance.
*
* @param locks - array of locks
*/
public RedissonRedLock(RLock... locks) {
super(locks);
}
@Override
protected int failedLocksLimit() {
return locks.size() - minLocksAmount(locks);
}
protected int minLocksAmount(final List<RLock> locks) {
return locks.size()/2 + 1;
}
@Override
protected long calcLockWaitTime(long remainTime) {
return Math.max(remainTime / locks.size(), 1);
}
@Override
public void unlock() {
unlockInner(locks);
}
}
- 应用场景
- 红锁可以避免异步数据丢失、脑裂问题
- 性能消耗更大,但是更加稳定
- 红锁可以避免异步数据丢失、脑裂问题
公平锁
- 实现原理是利用redis的列表存放实例线程请求,然后按照请求顺序获取锁(如果线程超时,则从列表中移出)
- 应用场景
- 用于避免某节点出现饥饿问题
浅谈mysql、zk分布式锁
以上我们深入了解了redis实现分布式锁的原理,接下来我们将继续讲解mysql、zk实现分布式锁的方式。
mysql
- 利用数据库表,按照redis实现的方式,同样存储锁名、客户端线程名称、重入数量等来实现分布式锁。
- 由于db的操作是I/O操作,速度上比内存操作要慢很多,因此并不常用。
zookeeper
- 利用zk创建node的分布式一致性特征,来实现分布式锁
- 以前zk与redis相比,有一个区别就是zk可以通过注册监听器来监听节点删除(锁释放时间),但是现在redis也可以通过pub/sub机制来实现。不过呢,如果redis锁的一个实例挂掉了,只能等待超时释放锁;而zk的客户端挂掉,由于创建的节点类型属于临时节点,那么会立刻释放锁(也存在一个问题,如果出现网络波动,那么很明显就会出现安全性问题了)
总之,凡事皆有利弊,至于如何取舍,则需要根据具体的项目情况去选择了。