基于Redis实现分布式锁以前,这些坑你必定得知道

做者简介java

陈寒立,一个不误正业的程序员。前后在物流金融组、物流末端业务组和压力平衡组打过杂,技术栈从Python玩到了Java,依然没学会好好写业务代码,梦想着用抽象的模型拯救业务于水火之中。linux

开头

基于Redis的分布式锁对你们来讲并不陌生,但是你的分布式锁有失败的时候吗?在失败的时候可曾怀疑过你在用的分布式锁真的靠谱吗?如下是结合本身的踩坑经验总结的一些经验之谈。git

你真的须要分布式锁吗?

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

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

引入分布式锁势必要引入一个第三方的基础设施,好比MySQL,Redis,Zookeeper等,这些实现分布式锁的基础设施出问题了,也会影响业务,因此在使用分布式锁前能够考虑下是否能够不用加锁的方式实现?不过这个不在本文的讨论范围内,本文假设加锁的需求是合理的,而且偏向于上面的第二种状况,为何是偏向?由于不存在100%靠谱的分布式锁,看完下面的内容就明白了。github

从一个简单的分布式锁实现提及

分布式锁的Redis实现很常见,本身实现和使用第三方库都很简单,至少看上去是这样的,这里就介绍一个最简单靠谱的Redis实现。redis

最简单的实现

实现很经典了,这里只提两个要点?算法

  1. 加锁和解锁的锁必须是同一个,常见的解决方案是给每一个锁一个钥匙(惟一ID),加锁时生成,解锁时判断。
  2. 不能让一个资源永久加锁。常见的解决方案是给一个锁的过时时间。固然了还有其余方案,后面再说。

一个可复制粘贴的实现方式以下: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是否相等再操做。

靠谱吗?

这样的实现有什么问题呢?

  1. 单点问题。上面的实现只要一个master节点就能搞定,这里的单点指的是单master,就算是个集群,若是加锁成功后,锁从master复制到slave的时候挂了,也是会出现同一资源被多个client加锁的。
  2. 执行时间超过了锁的过时时间。上面写到为了避免出现一直上锁的状况,加了一个兜底的过时时间,时间到了锁自动释放,可是,若是在这期间任务并无作完怎么办?因为GC或者网络延迟致使的任务时间变长,很难保证任务必定能在锁的过时时间内完成。

如何解决这两个问题呢?试试看更复杂的实现吧。

Redlock算法

对于第一个单点问题,顺着redis的思路,接下来想到的确定是Redlock了。Redlock为了解决单机的问题,须要多个(大于2)redis的master节点,多个master节点互相独立,没有数据同步。

Redlock的实现以下:

  1. 获取当前时间。
  2. 依次获取N个节点的锁。 每一个节点加锁的实现方式同上。这里有个细节,就是每次获取锁的时候的过时时间都不一样,须要减去以前获取锁的操做的耗时,
  • 好比传入的锁的过时时间为500ms,
  • 获取第一个节点的锁花了1ms,那么第一个节点的锁的过时时间就是499ms,
  • 获取第二个节点的锁花了2ms,那么第二个节点的锁的过时时间就是497ms
  • 若是锁的过时时间小于等于0了,说明整个获取锁的操做超时了,整个操做失败
  1. 判断是否获取锁成功。 若是client在上述步骤中获取到了(N/2 + 1)个节点锁,而且每一个锁的过时时间都是大于0的,则获取锁成功,不然失败。失败时释放锁。
  2. 释放锁。 对全部节点发送释放锁的指令,每一个节点的实现逻辑和上面的简单实现同样。为何要对全部节点操做?由于分布式场景下从一个节点获取锁失败不表明在那个节点上加速失败,可能实际上加锁已经成功了,可是返回时由于网络抖动超时了。

以上就是你们常见的redlock实现的描述了,一眼看上去就是简单版本的多master版本,若是真是这样就太简单了,接下来分析下这个算法在各个场景下是怎样被玩坏的。

分布式锁的坑

高并发场景下的问题

如下问题不是说在并发不高的场景下不容易出现,只是在高并发场景下出现的几率更高些而已。 性能问题。 性能问题来自于两个方面。

  1. 获取锁的时间上。若是redlock运用在高并发的场景下,存在N个master节点,一个一个去请求,耗时会比较长,从而影响性能。这个好解决。经过上面描述不难发现,从多个节点获取锁的操做并非一个同步操做,能够是异步操做,这样能够多个节点同时获取。即便是并行处理的,仍是得预估好获取锁的时间,保证锁的TTL > 获取锁的时间+任务处理时间。
  2. 被加锁的资源太大。加锁的方案自己就是会为了正确性而牺牲并发的,牺牲和资源大小成正比。这个时候能够考虑对资源作拆分,拆分的方式有两种:
  3. 从业务上将锁住的资源拆分红多段,每段分开加锁。好比,我要对一个商户作若干个操做,操做前要锁住这个商户,这时我能够将若干个操做拆成多个独立的步骤分开加锁,提升并发。
  4. 用分桶的思想,将一个资源拆分红多个桶,一个加锁失败当即尝试下一个。好比批量任务处理的场景,要处理200w个商户的任务,为了提升处理速度,用多个线程,每一个线程取100个商户处理,就得给这100个商户加锁,若是不加处理,很难保证同一时刻两个线程加锁的商户没有重叠,这时能够按一个维度,好比某个标签,对商户进行分桶,而后一个任务处理一个分桶,处理完这个分桶再处理下一个分桶,减小竞争。

重试的问题。 不管是简单实现仍是redlock实现,都会有重试的逻辑。若是直接按上面的算法实现,是会存在多个client几乎在同一时刻获取同一个锁,而后每一个client都锁住了部分节点,可是没有一个client获取大多数节点的状况。解决的方案也很常见,在重试的时候让多个节点错开,错开的方式就是在重试时间中加一个随机时间。这样并不能根治这个问题,可是能够有效缓解问题,亲试有效。

节点宕机

对于单master节点且没有作持久化的场景,宕机就挂了,这个就必须在实现上支持重复操做,本身作好幂等。

对于多master的场景,好比redlock,咱们来看这样一个场景:

  1. 假设有5个redis的节点:A、B、C、D、E,没有作持久化。
  2. client1从A、B、C 3个节点获取锁成功,那么client1获取锁成功。
  3. 节点C挂了。
  4. client2从C、D、E获取锁成功,client2也获取锁成功,那么在同一时刻client1和client2同时获取锁,redlock被玩坏了。

怎么解决呢?最容易想到的方案是打开持久化。持久化能够作到持久化每一条redis命令,但这对性能影响会很大,通常不会采用,若是不采用这种方式,在节点挂的时候确定会损失小部分的数据,可能咱们的锁就在其中。 另外一个方案是延迟启动。就是一个节点挂了修复后,不当即加入,而是等待一段时间再加入,等待时间要大于宕机那一刻全部锁的最大TTL。 但这个方案依然不能解决问题,若是在上述步骤3中B和C都挂了呢,那么只剩A、D、E三个节点,从D和E获取锁成功就能够了,仍是会出问题。那么只能增长master节点的总量,缓解这个问题了。增长master节点会提升稳定性,可是也增长了成本,须要在二者之间权衡。

任务执行时间超过锁的TTL

以前产线上出现过由于网络延迟致使任务的执行时间远超预期,锁过时,被多个线程执行的状况。 这个问题是全部分布式锁都要面临的问题,包括基于zookeeper和DB实现的分布式锁,这是锁过时了和client不知道锁过时了之间的矛盾。 在加锁的时候,咱们通常都会给一个锁的TTL,这是为了防止加锁后client宕机,锁没法被释放的问题。可是全部这种姿式的用法都会面临同一个问题,就是没发保证client的执行时间必定小于锁的TTL。虽然大多数程序员都会乐观的认为这种状况不可能发生,我也曾经这么认为,直到被现实一次又一次的打脸。

Martin Kleppmann也质疑过这一点,这里直接用他的图:

unsafe-lock.png

  1. Client1获取到锁
  2. Client1开始任务,而后发生了STW的GC,时间超过了锁的过时时间
  3. Client2 获取到锁,开始了任务
  4. Client1的GC结束,继续任务,这个时候Client1和Client2都认为本身获取了锁,都会处理任务,从而发生错误。

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在同一时刻访问同一个资源的状况。

fencing-tokens.png
在客户端获取锁的同时,也获取到一个资源的token,这个token是单调递增的,每次在写资源时,都检查当前的token是不是较老的token,若是是就不让写。对于上面的场景,Client1获取锁的同时分配一个33的token,Client2获取锁的时候分配一个34的token,在client1 GC期间,Client2已经写了资源,这时最大的token就是34了,client1 从GC中回来,再带着33的token写资源时,会由于token过时被拒绝。这种作法须要资源那一边提供一个token生成器。 对于这种fencing的方案,我有几点问题:

  1. 没法保证事务。示意图中画的只有34访问了storage,可是在实际场景中,可能出如今一个任务内屡次访问storage的状况,并且必须是原子的。若是client1带着33token在GC前访问过一次storage,而后发生了GC。client2获取到锁,带着34的token也访问了storage,这时两个client写入的数据是否还能保证数据正确?若是不能,那么这种方案就有缺陷,除非storage本身有其余机制能够保证,好比事务机制;若是能,那么这里的token就是多余的,fencing的方案就是画蛇添足。
  2. 高并发场景不实用。由于每次只有最大的token能写,这样storage的访问就是线性的,在高并发场景下,这种方式会极大的限制吞吐量,而分布式锁也大可能是在这种场景下用的,很矛盾的设计。
  3. 这是全部分布式锁的问题。这个方案是一个通用的方案,能够和Redlock用,也能够和其余的lock用。因此我理解仅仅是一个和Redlock无关的解决方案。

系统时钟漂移

这个问题只是考虑过,但在实际项目中并无碰到过,由于理论上是可能出现的,这里也说下。 redis的过时时间是依赖系统时钟的,若是时钟漂移过大时会影响到过时时间的计算。

为何系统时钟会存在漂移呢?先简单说下系统时间,linux提供了两个系统时间:clock realtime和clock monotonic。clock realtime也就是xtime/wall time,这个时间时能够被用户改变的,被NTP改变,gettimeofday拿的就是这个时间,redis的过时计算用的也是这个时间。 clock monotonic ,直译过来时单调时间,不会被用户改变,可是会被NTP改变。

最理想的状况时,全部系统的时钟都时时刻刻和NTP服务器保持同步,但这显然时不可能的。致使系统时钟漂移的缘由有两个:

  1. 系统的时钟和NTP服务器不一样步。这个目前没有特别好的解决方案,只能相信运维同窗了。
  2. clock realtime被人为修改。在实现分布式锁时,不要使用clock realtime。不过很惋惜,redis使用的就是这个时间,我看了下Redis 5.0源码,使用的仍是clock realtime。Antirez说过改为clock monotonic的,不过大佬尚未改。也就是说,人为修改redis服务器的时间,就能让redis出问题了。

总结

本文从一个简单的基于redis的分布式锁出发,到更复杂的Redlock的实现,介绍了在使用分布式锁的过程当中才踩过的一些坑以及解决方案。

参考

Distributed locks with Redis

clock_gettime(2) - Linux man page





阅读博客还不过瘾?

欢迎你们扫二维码经过添加群助手,加入交流群,讨论和博客有关的技术问题,还能够和博主有更多互动

博客转载、线下活动及合做等问题请邮件至 yidong.zheng@ele.me 进行沟通
相关文章
相关标签/搜索