分布式锁

1 为什么要有分布式锁

服务通常部署多个节点,一个前端请求会被随机发到其中一个节点上去执行。

在并发场景下可能会有问题,比如管理员在管理端页面创建一个学生账号的场景:在页面填好学生信息后,连续快速多次点提交按钮,可能会导致多个请求发往不同节点去分别处理从而创建了多个信息一样的账号。即使页面做了防止重提交处理,通过Postman等直接调接口也会有该问题。怎么解决?借助分布式锁——接口实现上,先获得分布式锁成功后再去执行添加账号的逻辑。

 

2 分布式锁的实现方案及问题分析

2.1 方案

多种实现方案,本质上都是抢占位置,谁占到锁归谁。 目前主要方案有:

通过Redis的原子操作实现。最简单,但存在一定的问题。小公司这个用得比较多。有基于Redis单节点的也有基于多节点的实现。

通过zookeeper、consul、Chubby等分布式协调服务实现。比较完善,但比较笨重。

通过支持事务的数据库实现,如MySQL。 可行但很少用。

 

2.2 方案演进及问题分析

(详情强烈推荐参阅这两篇文章:基于Redis的分布式锁到底安全吗(上)基于Redis的分布式锁到底安全吗(下),是分布式系统专家 Martin Kleppmann 和Redis的作者 antirez 之间关于Redis分布式锁安全问题的一系列讨论的总结,了解这个后在不同场景下该用哪个方案就心里有底了)

 

2.2.1 Redis 单节点下的分布式锁

加解锁过程

基于Redis的原子性命令setnx来加锁: SET resource_name my_random_value NX PX 30000 ,基于Lua脚本来原子性解锁: if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end 。

关键点

以原子操作来完成“key不存在才设置value”、以原子性操作完成“value一样则删除key”。

须原子方式设置过期时间,防止持锁者因代码忘记或或服务故障导致锁没释放,否则别人就得不到锁了。

须设置random_value以防止释放的是别人的锁。即可在通过del key解锁时可校验key是删除者设置的,否则可能del别人设置的key。例如当持锁者故障时长超过加锁时长再恢复后去解锁时锁已经不是持锁者了。

存在的问题

1 加锁时长不好确定。过短可能导致在业务操作完成前锁就自动释放从而不同线程去并发修改资源,过长则在持锁者故障时需要很久才能自动释放。

2 持有过期的锁的问题。三种情况(服务端返回锁给客户端延迟超过锁有效期、客户端获得锁后故障一段时间然后恢复间隔超过锁有效期、客户端持锁后业务操作耗时超过锁有效期)使得客户端持的是过期的锁,此时其他人可同时获得锁这显然有问题。收锁前延迟、收锁后延迟、操作耗时三者。

3 无法得知别人释放锁的问题。等待申请锁者无法得知别人释放了锁,只能轮询,此时效率比较低。

4 时钟跳跃导致锁提前失效,从而其他人可同时获得锁。解决不了但可缓解:取决于基础设置和运维保证(ntp不要有时钟跳跃、运维不要改电脑时间)

5 Redis单节点故障:故障后所有客户端都无法获得锁。

 

2.2.2 Redis 多实例下的分布式锁(RedLock)

Redis 作者实现了 RedLock 来解决单节点故障问题,它是基于多个Redis节点的锁。

通常是部署5个互相独立的Redis节点。不是Redis Cluster而是5个简单的Redis实例,当然为了高可靠,每个实例可以是哨兵集群。

加解锁过程:众数持锁、所有释锁(各节点上的加解锁都同上述单节点的加解锁命令)

加锁:记录当前时间 -> 依次向各节点发带超时参数的加锁请求 -> 记录当前时间 -> 两时间相减得到加锁耗时,若加锁成功的节点数超过一半且加锁耗时小于锁有效期,则加锁成功并更新业务剩余可用加锁时长;否则加锁失败,向各节点发起解锁命令。

运行Redlock算法的客户端依次执行下面各个步骤,来完成获取锁的操作:

获取当前时间(毫秒数)。

按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。

计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。

如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。

如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。

当然,上面描述的只是获取锁的过程,而释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。
View Code

解锁:向各节点发送解锁解锁命令即可。须向所有节点发解锁命令,因为有些节点可能加锁时超时了客户端认为未成功,但实际加上锁了。

存在的问题:为解决单节点故障实现了该锁,更复杂、分布式环境下难免地存在了更多问题(解决了一个、新引入了一个)

1 加锁时长不好确定。

2 持有过期的锁的问题。三种情况:

收锁前延迟:RedLock不存在此问题,因为加锁流程中这种响应不包含在众数中。

收锁后延迟 或 操作耗时:RedLock仍存在此问题,实际上所有分布式锁(如zk、Chubby等)都有此问题。这种问题理论上的一种解决方案是fencing token机制,详见下面讨论。

3 无法得知别人释放锁的问题。

4 时钟跳跃导致锁提前失效,从而其他人可同时获得锁。

5-1 Redis单节点故障:故障后所有客户端都无法获得锁。已解决

5-2 服务端主从复制延迟(Redis同步是弱一致性,zk则是强一致性的故不会有此问题)或持久化延迟导致主节点故障时从节点无主节点的加锁数据,从而别人也可同时获得锁。例如有A B C D E五节点,客户端1锁住了A B C从而加锁成功、接着C故障恢复丢失了数据、然后客户端2就可锁住C D E从而加锁成功。解决不了但可缓解:尽可能延迟重启以使数据来得及恢复。

可见,RedLock解决了单节点Redis分布式锁的单节点故障问题和一部分的持有过期锁的问题。RedLock严格来说不正确,“neither fish nor fowl (非驴非马)”

 

注:

1 “过期时间不好设置”的问题:可通过用一个守护线程定期去检查ttl若快要过期时业务操作还未完成则重新设置过期时间来在一定程度上解决(因为并不可完全解决题)。Java Redission 客户端就是这么干的,该客户端还封装了可重入锁、乐观锁、公平锁、Redlock等。

2  fencing token机制 用于解决客户端获得锁后【因故障或操作耗时较长导致持有的】锁过期了却不自知并还去修改资源的问题,是种理论方案,实际上不好实现,RedLock无提供该机制。

原理:Redis返回锁时同时返回一个fencing token(唯一即可,不一定是数字),修改资源前对资源标记为该token、修改时比较该token一致(或者是更大的数字)才可写。在该流程下,当客户端因【故障很久后又恢复、或者业务操作时长大于加锁时长】导致丢锁时,再去修改资源会发现资源的token标记已变了,从而不能继续修改数据。可见,fencing机制是解决丢锁后还能去修改资源的问题,该机制需要被写的资源服务器配合,但资源服务器如何配合是个难题。这是所有分布式锁都有的问题。

正确吗?fencing token 采用 unique value 或 递增number,需要资源服务器配合,但这样服务器本身就提供了排它功能,那为何还要分布式锁?

Martin为我们留下了不少疑问,尤其是他提出的fencing token机制。他在blog中提到,会在他的新书《Designing Data-Intensive Applications》的第8章和第9章再详加论述。

Zookeeper 中leader选举采用Quorum机制,避免了brain split问题。leader假死 -> 选出新leader -> 原leader是否还有效?Zk采用epoch来解决,其是递增数值,每选出一个新leader就产生新epoch值、各节点只认最新的epoch故收到原leader数据请求时会拒绝。这本质上与上述fencing token一样。

 

2.2.3 基于Google Chubby

实现的尝试:为了应对锁失效问题,Google Chubby提供三种处理方式:CheckSequencer()检查、与上次最新的sequencer对比、lock-delay,它们对于安全性的保证是从强到弱的。而且,这些处理方式本身都没有保证提供绝对的正确性(correctness)。但是,Chubby确实提供了单调递增的lock generation number,这就允许资源服务器在需要的时候,利用它提供更强的安全性保障。

 

2.2.4 基于Zookeeper

基于Zookeeper实现分布式锁的多种方式详见下节。

普通的方式:

客户端调用create()方法创建名为“locknode/guid-lock-”的节点,需要注意的是,这里节点的创建类型需要设置为EPHEMERAL_SEQUENTIAL。
客户端调用getChildren(“locknode”)方法来获取所有已经创建的子节点,同时在这个节点上注册上子节点变更通知的Watcher。
客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点是所有节点中序号最小的,那么就认为这个客户端获得了锁。
如果在步骤3中发现自己并非是所有子节点中最小的,说明自己还没有获取到锁,就开始等待,直到下次子节点变更通知的时候,再进行子节点的获取,判断是否获取锁。
View Code

该方式存在羊群效应问题,即目录下子节点变化时会收到大量的watch通知。可加以改进使得只watch比自己小的节点从而减少收到的通知(见解决Zookeeper羊群效应问题):

客户端调用getChildren(“locknode”)方法来获取所有已经创建的子节点,注意,这里不注册任何Watcher。
客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点序号最小,那么就认为这个客户端获得了锁。
如果在步骤3中发现自己并非所有子节点中最小的,说明自己还没有获取到锁。此时客户端需要找到比自己小的那个节点,然后对其调用exist()方法,同时注册事件监听。
之后当这个被关注的节点被移除了,客户端会收到相应的通知。这个时候客户端需要再次调用getChildren(“locknode”)方法来获取所有已经创建的子节点,确保自己确实是最小的节点了,然后进入步骤3。
View Code

前面的方案存在的问题在这里的情况:

1 加锁时长不好确定。已解决

2 持有过期的锁的问题。三种情况。收锁前延迟、收锁后延迟、操作耗时都可导致服务端收不到客户端心跳认为客户端挂了从而删掉znode,客户端就持有过期锁了。

3 无法得知别人释放锁的问题。已解决

4 时钟跳跃导致锁提前失效,从而其他人可同时获得锁。已解决

5-1 Redis单节点故障:故障后所有客户端都无法获得锁。已解决

5-2 服务端主从复制延迟或持久化延迟导致主节点故障时从节点无主节点的加锁数据,从而别人也可同时获得锁。已解决

5-3 羊群效应 herd effect 问题,稍微改进就可解决

 

可见,Zookeeper仍存在持有过期锁的问题,但解决了大部分问题。如何解决的?

Zookeeper的不同之处:

没有加锁时长的概念:获得锁后可操作任意时长,锁时长由session维护(客户端定时发心跳包来维持session)。故不存在问题1、4。

客户端断开时自动删除临时型znode从而自动释放锁,watch机制使得在别人释放锁时候收到通知而不用轮询。故不存问题3。

Zookeeper自身是分布式服务,且各节点数据同步是强一致性的。故不存在问题5-1、5-2。

 

2.3 方案总结与选择

方案总结:最简单的是单机版的Redis分布式锁,最完整的是基于Zookeeper的分布式锁。上面所列的分布式锁方案(Redis单节点、Redis主从的RedLock、基于Chubby、基于Zookeeper)没有哪一种是完全正确的,正确性依次递增、复杂性依次递增,故是正确性和复杂性的权衡,可根据应用场景选取一种使用

方案选择使用分布式锁通常有两种用途:一个是出于效率考虑,防止一个耗时工作重复做,这种场景下锁失效不会有“恶性”后果。例如邮件重复发送;另一种是出于正确性考虑,不能容忍锁失效,例如数据插入操作。按照锁的两种用途,如果仅是为了效率(efficiency),那么你可以自己选择你喜欢的一种分布式锁的实现。当然,你需要清楚地知道它在安全性上有哪些不足,以及它会带来什么后果。而如果你是为了正确性(correctness),那么请慎之又慎。在本文的讨论中,我们在分布式锁的正确性上走得最远的地方,要数对于ZooKeeper分布式锁、单调递增的epoch number以及对分布式资源进行标记的分析了。

 

 

 

 

============  以下为实现简介 ============

zookeeper

分布式排他锁:公平或不公平均可

分布式读写锁:

读锁

写锁

 可参阅:https://www.cnblogs.com/z-sm/p/5691752.html

 

redis

分布式排他锁:用Redis的setnx来实现排它锁,本质就是谁先占到坑谁拿到锁,故是公平的。

这里说明下分布式锁实现的演进过程。

实际场景描述:

场景1:对于添加实验的功能,前端填完信息后点添加,此时调用添加实验的接口。由于在接口响应前没禁用添加按钮,所以可以重复快速多次点击添加(也可能是因后台操作耗时或网络延迟等原因使用户误以为没响应,并重复点击),它们发起请求时传参完全一样,从而导致产生重复数据。因此应加锁以防止这种情况,即保证同时刻下添加实验的接口最多只被一个线程执行(串行执行)。

场景2:对于修改实验的功能,产品界面上看,在用户点编辑后进入内容编辑状态,编辑一些内容后点提交。在这期间该实验要求不能被其他人同时编辑,因此刚进入编辑状态时应加锁以防止多人同时编辑实验;此外点提交时也可能存在场景1的多次点击问题(接口非幂等时,例如编辑实验时添加了个步骤),因此也用应保证同时刻下修改实验的接口最多只被一个线程执行(串行执行)。

 

方案分析:

前端:对于重复点击的问题,前端界面应该在一次请求结束前禁用按钮。虽然这方案不治本,但体验上却好很多,且能在用户操作上阻止连续点击问题的产生。

后端:若后端服务是单节点部署(只有一个进程),则用Java或JDK的各种锁机制都可达到目的,如方法上加synchronized等,然而在多节点场景下(后端服务有多个进程)就不好使了,比如场景1中参数完全一样的两个请求发到了后端的两个进程上,显然两个进程的处理互不相关,无法达到排他锁的目的。因此要引入分布式锁,目的是在多进程场景下能达到单节点时的加锁效果(效果不完全一样,视具体需求而定)。

无法支持排他锁的可重入锁的可重入是指持有资源的锁的线程可以继续申请持有该资源的锁,即同一线程对同一个资源多次加锁。但不论在上述场景1、2中,多个请求到服务端时是由线程池的不同线程分别处理的,即使是场景2中刚进入编辑状态及点提交时发的两个请求通常也是被服务端不同线程处理的,故分布式锁无法支持真正的“可重入”。

支持伪可重入

对于重复点击的问题,分布式锁支持排他性即可;

对于场景2中防止多人进入编辑的问题、防止提交按钮被重复点击的问题,若把两者视为对同一个资源(这里是实验)的加锁,则要求同一个用户能对该资源加锁两次(进入编辑状态时一次,前端显式调用加锁接口对实验加锁;点提交时后端接口为做到串行而在自身内部对实验加锁),不妨称为“伪可重入”。当然,场景2中若把两者视对不同资源的加锁(例如抽象“实验编辑权”、“实验保存权”),则不需要支持所谓的“伪可重入”。然而,支持“伪可重入”会导致一个接口可被多个线程同时执行,产生场景1、2中的重复提交问题。。

方案实现:

由上述分析可知,严格来说,不管是单进程还是多进程,后端的非幂等更新接口(添加、修改)都应该加排他锁,否则会存在多个线程并发更新问题。

若后端只有单进程则用Java或JDK自身的同步工具即可;若有多进程则需要分布式锁,有了分布式锁就不用语言自身的同步工具了。

注意关键字非幂等,添加接口通常非幂等、修改接口通常是幂等的但也有非幂等,视具体实现而定。

实现分布式锁的核心在于实现排他性,以达到使接口只能被串行化执行的目的,因此也称为分布式排他锁。至于支不支持“伪可重入”,则视具体场景而定了,一般来说还是不要支持了。

借助redis的setnx原子操作实现分布式排他锁。代码:

public class CourseExpEditDistributedLockUtil {

    public static enum LockedEntityTypeEnum {
        COURSE, EXPERIMENT, CATALOG, COURSE_TRANSLATION, EXPERIMENT_TRANSLATION, CATALOG_TRANSLATION;
    }

    /** 当前线程获取锁,在开始编辑课程或实验前调用。 */
    public static boolean lock(String entityId, LockedEntityTypeEnum entityType, String curUserId) {
        // 尝试加锁并设置持有该锁的最长时间
        return DistributedLockUtil_old.lock((1 * 3600), entityType, entityId, curUserId);
    }

    /** 当前线程释放锁,已持有锁的线程方可释放。在课程或实验编辑完后调用。 */
    public static boolean unLock(String entityId, LockedEntityTypeEnum entityType, String curUserId) {
        return DistributedLockUtil_old.unLock(entityType, entityId, curUserId);
    }

}

@Slf4j
class DistributedLockUtil_old {

    /**
     * redis原子操作。功能为将指定key对应的value增加指定的数,同时设置过期时间。若增加后超过指定的最大值则取消该次增加操作
     * 
     * @param key        redis key
     * @param max        最大值,正数
     * @param increment  增加的量,正数
     * @param ttlSeconds 过期时间
     * @return 增加完成后的值,若成功,则值肯定是正数。若增加失败,则返回0。
     */
    private static long atomicIncryByAndSetTtl(String key, int max, int increment, int ttlSeconds) {
        if (max <= 0 || increment <= 0 || increment > max) {
            throw new RuntimeException("max and increment should be positive, and the latter should not be greater than the former");
        }
        String lua_script_to_increment_and_set_ttl = "local max=tonumber(ARGV[1])\n" + "local inc=tonumber(ARGV[2])\n" + "local ttl=tonumber(ARGV[3])\n" + "if max < 0 then\n" + "  return 0\n"
                + "end\n" + "local n\n" + "if redis.call('exists', KEYS[1]) == 0 then\n" + "  n=0\n" + "else\n" + "  n=tonumber(redis.call('get', KEYS[1]))\n" + "end\n" + "n= n + inc\n"
                + "if n > max then\n" + "  return 0\n" + "else\n" + "  redis.call('set', KEYS[1], n)\n" + "  redis.call('expire', KEYS[1], ttl)\n" + "  return n\n" + "end";// IDE格式化代码导致错乱。字符串里缩进勿动!!
//        System.err.println(lua_script_to_increment_and_set_ttl);
//        System.err.println("eval \""+lua_script_to_increment_and_set_ttl.replaceAll("\n", "\\\\n")+"\" 1 name 100 2 200");//redis cli in

        // eval返回的数值为long类型
        return (long) JedisPoolClientUtil.eval(lua_script_to_increment_and_set_ttl, 1, key, max + "", increment + "", ttlSeconds + "");
    }

    // 伪可重入(不要求重入者是同一个线程,而是对应同一key即可):同一个资源可被重复加锁的最大次数。
    // 例如制作平台进入实验编辑状态前前端调加锁接口加锁一次,添加一个实验步骤完后点保存时后端接口会再加锁一次(为了防止连续连点保存引发数据库产生重复步骤),共两次
    // 伪可重入的问题:对于“不同线程或不同节点上的请求(本质也是不同线程)同时来对同一个资源(即参数一模一样)加锁”的场景会导致不同线程都能获取到锁从而存在并发修改资源的问题
    private static final int MAX_RELOCK_COUNT = 2;

    /** 当前线程尝试获取对指定资源的锁,返回值表示是否获取到。所传参数(除了超时时间外)将组合在一起作为资源的id */
    public static boolean lock(int timeOutSeconds, Enum<?> optType, @Nullable String... domainId) {
        try {
            String redisKey = generateDistributedLockKey(optType, domainId);
            {
                // 通过Redis setnx原子操作设置分布式锁。为防止获得锁后没释放,设置锁的最长持有时间。不支持伪可重入
//                return null != JedisPoolClientUtil.set(redisKey, "", "nx", "ex", timeOutSeconds);
                // 为了支持伪可重入,用自定义的lua脚本执行
                return atomicIncryByAndSetTtl(redisKey, MAX_RELOCK_COUNT, 1, timeOutSeconds) > 0;
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            return false;
        }
    }

    /** 当前线程释放对指定资源的锁。不管重复获取到了几次锁,全部释放。所传参数将组合在一起作为资源的id。结果表示是否解锁成功,只有拥有了锁才能解锁成功 */
    public static boolean unLock(Enum<?> optType, @Nullable String... domainId) {
        try {
            String redisKey = generateDistributedLockKey(optType, domainId);
            return JedisPoolClientUtil.del(redisKey) > 0;
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            return false;
        }
    }

    /** 当前线程是否持有对指定资源的锁。所传参数将组合在一起作为资源的id */
    public static boolean isLock(Enum<?> optType, @Nullable String... domainId) {
        try {
            String redisKey = generateDistributedLockKey(optType, domainId);
//            return null != JedisPoolClientUtil.get(redisKey);
            return JedisPoolClientUtil.exists(redisKey);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            return false;
        }
    }

    private static String generateDistributedLockKey(Enum<?> optType, @Nullable String... domainId) {
        // 可重入是指同一个线程的可重入
        // 在key中加入当前线程uuid id以支持可重入
        String prefix = REENTRANCE_ENABLED ? "LK_@" + getUUIDOfCurrentThread() : "LK";// DistributedLock mark
        String joinedDomainId = (null == domainId) ? "" : Stream.of(domainId).map(e -> null == e ? "" : e).collect(Collectors.joining("_"));
        return String.format("%s_%s_%s", prefix, optType.name(), joinedDomainId);
    }

    // 锁是否可重入。(可重入是同一线程的可重入,实验多人同时编辑场景下,先获取到锁者进入编辑状态、保存时释放锁,两者不是同一个线程,故此时不能启用可重入)
    private static final boolean REENTRANCE_ENABLED = false;
    private static final String INSTANCE_UUID = UUID.randomUUID().toString().replace("-", "");

    private static String getUUIDOfCurrentThread() {
        // 对于不同进程下的所有线程,确保每个线程id唯一。单后者无法确保,故加上"进程id"
        return INSTANCE_UUID + Thread.currentThread().getId();
    }
}
View Code

 这里实现了两个版本:借助Redis setnx的排他锁 和 借助Redis Lua脚本的支持伪可重入的版本。后者的可重入次数设为1就相当于不支持可重入的排他锁了,效果相当于前者。

 

posted @ 2018-12-02 15:25  March On  阅读(168)  评论(0编辑  收藏  举报
top last
Welcome user from
(since 2020.6.1)