做者简介java
陈寒立,一个不误正业的程序员。前后在物流金融组、物流末端业务组和压力平衡组打过杂,技术栈从Python玩到了Java,依然没学会好好写业务代码,梦想着用抽象的模型拯救业务于水火之中。linux
基于Redis的分布式锁对你们来讲并不陌生,但是你的分布式锁有失败的时候吗?在失败的时候可曾怀疑过你在用的分布式锁真的靠谱吗?如下是结合本身的踩坑经验总结的一些经验之谈。git
用到分布式锁说明遇到了多个进程共同访问同一个资源的问题, 通常是在两个场景下会防止对同一个资源的重复访问:程序员
引入分布式锁势必要引入一个第三方的基础设施,好比MySQL,Redis,Zookeeper等,这些实现分布式锁的基础设施出问题了,也会影响业务,因此在使用分布式锁前能够考虑下是否能够不用加锁的方式实现?不过这个不在本文的讨论范围内,本文假设加锁的需求是合理的,而且偏向于上面的第二种状况,为何是偏向?由于不存在100%靠谱的分布式锁,看完下面的内容就明白了。github
分布式锁的Redis实现很常见,本身实现和使用第三方库都很简单,至少看上去是这样的,这里就介绍一个最简单靠谱的Redis实现。redis
实现很经典了,这里只提两个要点?算法
一个可复制粘贴的实现方式以下:c#
加锁api
public static boolean tryLock(String key, String uniqueId, int seconds) {
return "OK".equals(jedis.set(key, uniqueId, "NX", "EX", seconds));
}
复制代码
这里实际上是调用了 SET key value PX milliseoncds NX
,不明白这个命令的参考下SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]服务器
解锁
public static boolean releaseLock(String key, String uniqueId) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
return jedis.eval(
luaScript,
Collections.singletonList(key),
Collections.singletonList(uniqueId)
).equals(1L);
}
复制代码
这段实现的精髓在那个简单的lua脚本上,先判断惟一ID是否相等再操做。
这样的实现有什么问题呢?
如何解决这两个问题呢?试试看更复杂的实现吧。
对于第一个单点问题,顺着redis的思路,接下来想到的确定是Redlock了。Redlock为了解决单机的问题,须要多个(大于2)redis的master节点,多个master节点互相独立,没有数据同步。
Redlock的实现以下:
以上就是你们常见的redlock实现的描述了,一眼看上去就是简单版本的多master版本,若是真是这样就太简单了,接下来分析下这个算法在各个场景下是怎样被玩坏的。
如下问题不是说在并发不高的场景下不容易出现,只是在高并发场景下出现的几率更高些而已。 性能问题。 性能问题来自于两个方面。
重试的问题。 不管是简单实现仍是redlock实现,都会有重试的逻辑。若是直接按上面的算法实现,是会存在多个client几乎在同一时刻获取同一个锁,而后每一个client都锁住了部分节点,可是没有一个client获取大多数节点的状况。解决的方案也很常见,在重试的时候让多个节点错开,错开的方式就是在重试时间中加一个随机时间。这样并不能根治这个问题,可是能够有效缓解问题,亲试有效。
对于单master节点且没有作持久化的场景,宕机就挂了,这个就必须在实现上支持重复操做,本身作好幂等。
对于多master的场景,好比redlock,咱们来看这样一个场景:
怎么解决呢?最容易想到的方案是打开持久化。持久化能够作到持久化每一条redis命令,但这对性能影响会很大,通常不会采用,若是不采用这种方式,在节点挂的时候确定会损失小部分的数据,可能咱们的锁就在其中。 另外一个方案是延迟启动。就是一个节点挂了修复后,不当即加入,而是等待一段时间再加入,等待时间要大于宕机那一刻全部锁的最大TTL。 但这个方案依然不能解决问题,若是在上述步骤3中B和C都挂了呢,那么只剩A、D、E三个节点,从D和E获取锁成功就能够了,仍是会出问题。那么只能增长master节点的总量,缓解这个问题了。增长master节点会提升稳定性,可是也增长了成本,须要在二者之间权衡。
以前产线上出现过由于网络延迟致使任务的执行时间远超预期,锁过时,被多个线程执行的状况。 这个问题是全部分布式锁都要面临的问题,包括基于zookeeper和DB实现的分布式锁,这是锁过时了和client不知道锁过时了之间的矛盾。 在加锁的时候,咱们通常都会给一个锁的TTL,这是为了防止加锁后client宕机,锁没法被释放的问题。可是全部这种姿式的用法都会面临同一个问题,就是没发保证client的执行时间必定小于锁的TTL。虽然大多数程序员都会乐观的认为这种状况不可能发生,我也曾经这么认为,直到被现实一次又一次的打脸。
Martin Kleppmann也质疑过这一点,这里直接用他的图:
Martin Kleppmann举的是GC的例子,我碰到的是网络延迟的状况。无论是哪一种状况,不能否认的是这种状况没法避免,一旦出现很容易懵逼。
如何解决呢?一种解决方案是不设置TTL,而是在获取锁成功后,给锁加一个watchdog,watchdog会起一个定时任务,在锁没有被释放且快要过时的时候会续期。这样说有些抽象,下面结合redisson源码说下:
public class RedissonLock extends RedissonExpirable implements RLock {
...
@Override
public void lock() {
try {
lockInterruptibly();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Override
public void lock(long leaseTime, TimeUnit unit) {
try {
lockInterruptibly(leaseTime, unit);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
...
}
复制代码
redisson经常使用的加锁api是上面两个,一个是不传入TTL,这时是redisson本身维护,会主动续期;另一种是本身传入TTL,这种redisson就不会帮咱们自动续期了,或者本身将leaseTime的值传成-1,可是不建议这种方式,既然已经有现成的API了,何须还要用这种奇怪的写法呢。 接下来分析下不传参的方法的加锁逻辑:
public class RedissonLock extends RedissonExpirable implements RLock {
...
public static final long LOCK_EXPIRATION_INTERVAL_SECONDS = 30;
protected long internalLockLeaseTime = TimeUnit.SECONDS.toMillis(LOCK_EXPIRATION_INTERVAL_SECONDS);
@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);
// lock acquired
if (ttl == null) {
return;
}
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {
while (true) {
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
break;
}
// waiting for message
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
}
} finally {
unsubscribe(future, threadId);
}
// get(lockAsync(leaseTime, unit));
}
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
return get(tryAcquireAsync(leaseTime, unit, threadId));
}
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS, TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.addListener(new FutureListener<Long>() {
@Override
public void operationComplete(Future<Long> future) throws Exception {
if (!future.isSuccess()) {
return;
}
Long ttlRemaining = future.getNow();
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
private void scheduleExpirationRenewal(final long threadId) {
if (expirationRenewalMap.containsKey(getEntryName())) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
future.addListener(new FutureListener<Boolean>() {
@Override
public void operationComplete(Future<Boolean> future) throws Exception {
expirationRenewalMap.remove(getEntryName());
if (!future.isSuccess()) {
log.error("Can't update lock " + getName() + " expiration", future.cause());
return;
}
if (future.getNow()) {
// reschedule itself
scheduleExpirationRenewal(threadId);
}
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
task.cancel();
}
}
...
}
复制代码
能够看到,最后加锁的逻辑会进入到org.redisson.RedissonLock#tryAcquireAsync中,在获取锁成功后,会进入scheduleExpirationRenewal,这里面初始化了一个定时器,dely的时间是internalLockLeaseTime / 3。在redisson中,internalLockLeaseTime是30s,也就是每隔10s续期一次,每次30s。 若是是基于zookeeper实现的分布式锁,能够利用zookeeper检查节点是否存活,从而实现续期,zookeeper分布式锁没用过,不详细说。
不过这种作法也没法百分百作到同一时刻只有一个client获取到锁,若是续期失败,好比发生了Martin Kleppmann所说的STW的GC,或者client和redis集群失联了,只要续期失败,就会形成同一时刻有多个client得到锁了。在个人场景下,我将锁的粒度拆小了,redisson的续期机制已经够用了。 若是要作得更严格,得加一个续期失败终止任务的逻辑。这种作法在之前Python的代码中实现过,Java尚未碰到这么严格的状况。
这里也提下Martin Kleppmann的解决方案,我本身以为这个方案并不靠谱,缘由后面会提到。 他的方案是让加锁的资源本身维护一套保证不会因加锁失败而致使多个client在同一时刻访问同一个资源的状况。
这个问题只是考虑过,但在实际项目中并无碰到过,由于理论上是可能出现的,这里也说下。 redis的过时时间是依赖系统时钟的,若是时钟漂移过大时会影响到过时时间的计算。
为何系统时钟会存在漂移呢?先简单说下系统时间,linux提供了两个系统时间:clock realtime和clock monotonic。clock realtime也就是xtime/wall time,这个时间时能够被用户改变的,被NTP改变,gettimeofday拿的就是这个时间,redis的过时计算用的也是这个时间。 clock monotonic ,直译过来时单调时间,不会被用户改变,可是会被NTP改变。
最理想的状况时,全部系统的时钟都时时刻刻和NTP服务器保持同步,但这显然时不可能的。致使系统时钟漂移的缘由有两个:
本文从一个简单的基于redis的分布式锁出发,到更复杂的Redlock的实现,介绍了在使用分布式锁的过程当中才踩过的一些坑以及解决方案。
clock_gettime(2) - Linux man page
阅读博客还不过瘾?
欢迎你们扫二维码经过添加群助手,加入交流群,讨论和博客有关的技术问题,还能够和博主有更多互动
博客转载、线下活动及合做等问题请邮件至 yidong.zheng@ele.me 进行沟通 ![]()