分布式 - 分布式锁的场景与实现

学习完整课程请移步 互联网 Java 全栈工程师

使用场景

首先,我们看这样一个场景:客户下单的时候,我们调用库存中心进行减库存,那我们一般的操作都是:

update store set num = $num where id = $id

这种通过设置库存的修改方式,我们知道在并发量高的时候会存在数据库的丢失更新,比如 a, b 当前两个事务,查询出来的库存都是 5,a 买了 3 个单子要把库存设置为 2,而 b 买了 1 个单子要把库存设置为 4,那这个时候就会出现 a 会覆盖 b 的更新,所以我们更多的都是会加个条件:

update store set num = $num where id = $id and num = $query_num

即乐观锁的方式来处理,当然也可以通过版本号来处理乐观锁,都是一样的,但是这是更新一个表,如果我们牵扯到多个表呢,我们希望和这个单子关联的所有的表同一时间只能被一个线程来处理更新,多个线程按照不同的顺序去更新同一个单子关联的不同数据,出现死锁的概率比较大。对于非敏感的数据,我们也没有必要去都加乐观锁处理,我们的服务都是多机器部署的,要保证多进程多线程同时只能有一个进程的一个线程去处理,这个时候我们就需要用到分布式锁。分布式锁的实现方式有很多,我们今天分别通过数据库,Zookeeper, Redis 以及 Tair 的实现逻辑。

数据库实现

加 xx 锁

更新一个单子关联的所有的数据,先查询出这个单子,并加上排他锁,在进行一系列的更新操作

begin transaction;
select ...for update;
doSomething();
commit();

这种处理主要依靠排他锁来阻塞其他线程,不过这个需要注意几点:

  1. 查询的数据一定要在数据库里存在,如果不存在的话,数据库会加 gap 锁,而 gap 锁之间是兼容的,这种如果两个线程都加了gap 锁,另一个再更新的话会出现死锁。不过一般能更新的数据都是存在的
  2. 后续的处理流程需要尽可能的时间短,即在更新的时候提前准备好数据,保证事务处理的时间足够的短,流程足够的短,因为开启事务是一直占着连接的,如果流程比较长会消耗过多的数据库连接的

唯一键

通过在一张表里创建唯一键来获取锁,比如执行 saveStore 这个方法

insert table lock_store ('method_name') values($method_name)

其中 method_name 是个唯一键,通过这种方式也可以做到,解锁的时候直接删除改行记录就行。不过这种方式,锁就不会是阻塞式的,因为插入数据是立马可以得到返回结果的。

那针对以上数据库实现的两种分布式锁,存在什么样的优缺点呢?

优点

简单,方便,快速实现

缺点

  • 基于数据库,开销比较大,性能可能会存在影响
  • 基于数据库的当前读来实现,数据库会在底层做优化,可能用到索引,可能不用到索引,这个依赖于查询计划的分析

Zookeeper 实现

获取锁

  1. 先有一个锁跟节点,lockRootNode,这可以是一个永久的节点
  2. 客户端获取锁,先在 lockRootNode 下创建一个顺序的瞬时节点,保证客户端断开连接,节点也自动删除
  3. 调用 lockRootNode 父节点的 getChildren() 方法,获取所有的节点,并从小到大排序,如果创建的最小的节点是当前节点,则返回 true,获取锁成功,否则,关注比自己序号小的节点的释放动作(exist watch),这样可以保证每一个客户端只需要关注一个节点,不需要关注所有的节点,避免羊群效应。
  4. 如果有节点释放操作,重复步骤 3

释放锁

只需要删除步骤 2 中创建的节点即可

使用 Zookeeper 的分布式锁存在什么样的优缺点呢?

优点

  • 客户端如果出现宕机故障的话,锁可以马上释放
  • 可以实现阻塞式锁,通过 watcher 监听,实现起来也比较简单
  • 集群模式,稳定性比较高

缺点

  • 一旦网络有任何的抖动,Zookeeper 就会认为客户端已经宕机,就会断掉连接,其他客户端就可以获取到锁。当然 Zookeeper 有重试机制,这个就比较依赖于其重试机制的策略了
  • 性能上不如缓存

Redis 实现

我们先举个例子,比如现在我要更新产品的信息,产品的唯一键就是 productId

简单实现 1

public boolean lock(String key, V v, int expireTime){
        int retry = 0;
        //获取锁失败最多尝试10次
        while (retry < failRetryTimes){
            //获取锁
            Boolean result = redis.setNx(key, v, expireTime);
            if (result){
                return true;
            }

            try {
                //获取锁失败间隔一段时间重试
                TimeUnit.MILLISECONDS.sleep(sleepInterval);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }

        }

        return false;
    }
    public boolean unlock(String key){
        return redis.delete(key);
    }
    public static void main(String[] args) {
        Integer productId = 324324;
        RedisLock<Integer> redisLock = new RedisLock<Integer>();
        redisLock.lock(productId+"", productId, 1000);
    }
}

这是一个简单的实现,存在的问题:

  1. 可能会导致当前线程的锁误被其他线程释放,比如 a 线程获取到了锁正在执行,但是由于内部流程处理超时或者 gc 导致锁过期,这个时候b线程获取到了锁,a 和 b 线程处理的是同一个 productId,b还在处理的过程中,这个时候 a 处理完了,a 去释放锁,可能就会导致 a 把 b 获取的锁释放了。
  2. 不能实现可重入
  3. 客户端如果第一次已经设置成功,但是由于超时返回失败,此后客户端尝试会一直失败

针对以上问题我们改进下:

  1. v 传 requestId,然后我们在释放锁的时候判断一下,如果是当前 requestId,那就可以释放,否则不允许释放
  2. 加入 count 的锁计数,在获取锁的时候查询一次,如果是当前线程已经持有的锁,那锁技术加 1,直接返回 true

简单实现 2

private static volatile int count = 0;
public boolean lock(String key, V v, int expireTime){
    int retry = 0;
    //获取锁失败最多尝试10次
    while (retry < failRetryTimes){
        //1.先获取锁,如果是当前线程已经持有,则直接返回
        //2.防止后面设置锁超时,其实是设置成功,而网络超时导致客户端返回失败,所以获取锁之前需要查询一下
        V value = redis.get(key);
        //如果当前锁存在,并且属于当前线程持有,则锁计数+1,直接返回
        if (null != value && value.equals(v)){
            count ++;
            return true;
        }

        //如果锁已经被持有了,那需要等待锁的释放
        if (value == null || count <= 0){
            //获取锁
            Boolean result = redis.setNx(key, v, expireTime);
            if (result){
                count = 1;
                return true;
            }
        }

        try {
            //获取锁失败间隔一段时间重试
            TimeUnit.MILLISECONDS.sleep(sleepInterval);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }

    }

    return false;
}
public boolean unlock(String key, String requestId){
    String value = redis.get(key);
    if (Strings.isNullOrEmpty(value)){
        count = 0;
        return true;
    }
    //判断当前锁的持有者是否是当前线程,如果是的话释放锁,不是的话返回false
    if (value.equals(requestId)){
        if (count > 1){
            count -- ;
            return true;
        }
        
        boolean delete = redis.delete(key);
        if (delete){
            count = 0;
        }
        return delete;
    }

    return false;
}
public static void main(String[] args) {
    Integer productId = 324324;
    RedisLock<String> redisLock = new RedisLock<String>();
    String requestId = UUID.randomUUID().toString();
    redisLock.lock(productId+"", requestId, 1000);
}

这种实现基本解决了误释放和可重入的问题,这里说明几点:

  1. 引入 count 实现重入的话,看业务需要,并且在释放锁的时候,其实也可以直接就把锁删除了,一次释放搞定,不需要在通过 count 数量释放多次,看业务需要吧
  2. 关于要考虑设置锁超时,所以需要在设置锁的时候查询一次,可能会有性能的考量,看具体业务吧
  3. 目前获取锁失败的等待时间是在代码里面设置的,可以提出来,修改下等待的逻辑即可

错误实现

获取到锁之后要检查下锁的过期时间,如果锁过期了要重新设置下时间,大致代码如下:

public boolean tryLock2(String key, int expireTime){
    long expires = System.currentTimeMillis() + expireTime;

    // 获取锁
    Boolean result = redis.setNx(key, expires, expireTime);
    if (result){
        return true;
    }

    V value = redis.get(key);
    if (value != null && (Long)value < System.currentTimeMillis()){
        // 锁已经过期
        String oldValue = redis.getSet(key, expireTime);
        if (oldValue != null && oldValue.equals(value)){
            return true;
        }
    }
    
    return false;
}

这种实现存在的问题,过度依赖当前服务器的时间了,如果在大量的并发请求下,都判断出了锁过期,而这个时候再去设置锁的时候,最终是会只有一个线程,但是可能会导致不同服务器根据自身不同的时间覆盖掉最终获取锁的那个线程设置的时间。

Tair 实现

通过 Tair 来实现分布式锁和 Redis 的实现核心差不多,不过 Tair 有个很方便的 api,感觉是实现分布式锁的最佳配置,就是 Put api 调用的时候需要传入一个 version,就和数据库的乐观锁一样,修改数据之后,版本会自动累加,如果传入的版本和当前数据版本不一致,就不允许修改。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,907评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,546评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,705评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,624评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,940评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,371评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,672评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,396评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,069评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,350评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,876评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,243评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,847评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,004评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,755评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,378评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,266评论 2 259

推荐阅读更多精彩内容