微服务系列(五)解读分布式锁

本文探讨了分布式锁的基本概念,解释了在多线程环境下如何保证并发操作的正确性。通过实例展示了在Java中使用synchronized关键字解决并发问题,并引出了分布式系统中由于JVM限制导致的挑战。接着讨论了分布式锁的必要性,分析了选择数据库、缓存系统或分布式协调系统作为锁资源的优缺点,倾向于使用缓存系统如Redis来实现。最后,提到了两种基于Redis实现分布式锁的方法,包括使用setNx和getSet命令,以及利用Lua脚本确保原子性,并给出了简单的测试案例。
摘要由CSDN通过智能技术生成

微服务系列(五)解读分布式锁

首先,锁是一个熟悉的字眼,在单机应用中,我们常常使用J.U.C等并发工具类来控制多线程读写问题,也会使用ReentrantLock/ReentrantReadWriteLock或是synchronized关键字来给方法或代码块加锁,从而达到同样的目的。

我理解的锁

说到锁,我能想到这样几个关键字:临界区、共享变量、并发问题

从抽象的角度去考虑,锁就是一个能给什么东西加锁和解锁的东西,至于给什么加锁,我们并不关心,于是在jdk源码中存在了这样一个接口java.util.concurrent.locks.Lock

那么在生活中,存在这样一些锁,比如自行车防盗的锁、防盗门的锁,这就是锁的实现,当然了,在这之上还可以有更多的实现,比如全自动锁还是手动锁等。

而在计算机的世界里,就需要这样的一把锁,能帮助我们解决一个通用的资源共享问题。首先我们知道,计算机是cpu、显卡、主板等硬件组成的,其上运行的操作系统,我们之所以能轻松的控制和调度它就是因为这个”全自动的“操作系统,它帮助我们调度cpu、资源分配,让我们不需要关心这些细节。

而操作系统中存在线程的概念,引用维基百科给的定义:

In computer science, a thread of execution is the smallest sequence of programmed instructions that can be managed independently by a scheduler, which is typically a part of the operating system.

线程是操作系统能够进行运算调度的最小单位。

简单的理解就是一个线程的运行,就是一个cpu的调度,并且为了充分利用cpu的资源,一个cpu会同时运行多个线程(按时间片分配调度)。

既然多个线程是可能同时运行的,如果他们的运算需要满足某种先后关系,那么如何保证他们的运算正确性呢?

这又类似了cpu与主存/cache面临的问题,cpu每次计算都会拿cache中的值,而cache中的值和主存中的值并不是每时每刻都保持一致,如果主存中存在一个需要多个cpu运算的变量,那么就必须保证在其中一个cpu运算的时候拿到的是主存中最新的值,并且在另一个cpu获取它并修改它之前将计算后的值回写到主存。

当然,硬件层面的一致性问题被很好的解决了(从总线锁到缓存锁的优化过程,可以看到,锁的概念从计算机最初的发展起就诞生了),这样才会让我们只关心软件层面上的一致性问题。

所以,锁住的东西就是”共享变量“(操作系统中称之为”临界区“,锁则对应了操作系统中的“信号量“)。

分布式锁

分布式锁也是锁,那么必然和我们常常使用的java锁存在很多相同之处,首先,我们需要弄清楚,java锁和分布式锁的联系和区别,再来了解分布式锁是如何使用和构建的。

举一个最简单的例子,应用A的JVM内存中存在一个库存变量x,当应用A初始化时x初始化值为2000,当其他用户调用“库存扣减”接口时,将会提交一个库存扣减的任务给业务线程池(总线程数量大于1),那么当多个用户同时调用“库存扣减”的接口时,就可能存在多个线程同时对库存变量x进行操作。

public class Test {
   

    /**
     * JVM中的库存变量
     */
    private int a = 2000;

    /**
     * 业务线程池
     */
    private final ThreadPoolExecutor executor = new ThreadPoolExecutor(50, 100,
            0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<Runnable>());

    public Future<Integer> mock(int num){
   
        return executor.submit(()->decr(num));
    }

    public synchronized int decr(int num){
   
        a = a - num;
        return a;
    }

    public static void main(String[] args) {
   
        //初始化应用
        Test test = new Test();
        List<Future<Integer>> futures = new ArrayList<>();
        //模拟多个用户同时调用接口
        for(int i = 0; i < 1000; i++){
   
            futures.add(test.mock(1));
        }
        //保证所有请求均处理完成
        for(int i = 0; i < 1000; i++){
   
            try {
   
                futures.get(i).get();
            } catch (InterruptedException e) {
   
                e.printStackTrace();
            } catch (ExecutionException e) {
   
                e.printStackTrace();
            }
        }
        System.out.println("如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=" + test.a + "]");
    }

}

这个程序的5次运行结果如下:

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1007]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1008]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1003]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1011]

这里由于运气不错,有一次调用满足了我们的预期。

这就是一个很常见的并发安全的场景,由于decr()操作并非原子的,decr操作本质上是先读a的值,然后计算a-num的值,并重新赋值给a,最后将a返回;这个过程中,可能存在另一个线程修改了a的值,导致结果不符合预期,比如当a=1800时,线程A读到a的值是1800,然后计算a-1为1799,此时还没有重新赋值给a,线程B读到a的值是1800,然后计算a-1为1799,此时线程A重新赋值给a,a此时的值为1799,线程B此时也计算结束,将1799重新赋值给a,最终导致两次decr后,结果依然是1799。

为了让程序向我们期望的方向进行,我们使用锁来改造一下,为decr方法加上synchronized关键字:

public synchronized int decr(int num){
   
    a = a - num;
    return a;
}

再次运行5次程序:

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

实际上,锁帮助我们达到了这样一个目的,保证了decr内的多个操作聚合成了一个原子操作,也就是在这个原子操作执行过程中,不允许其他的原子操作执行,原子操作之间只能存在先后关系。

根据这个场景,我们可以类比的思考,这个公共的变量可以是一个公共的区域,但不限定为一个变量,可以叫它为资源或是动态数据;decr操作可以是一次业务操作,而业务操作包含了读、写等操作的集合;

于是随着分布式架构的到来,再次出现了过去单机资源共享的问题。

把这个例子这样替换一下:

应用A的JVM内存中存在一个库存变量x,当应用A初始化时x初始化值为2000,当其他用户调用“库存扣减”接口时,将会提交一个库存扣减的任务给业务线程池(总线程数量大于1),那么当多个用户同时调用“库存扣减”的接口时,就可能存在多个线程同时对库存变量x进行操作。

应用A变成了一个分布式系统:由节点A1、A2…组成

JVM内存变成了一个存储系统,假设它是一个数据库系统mysql:由节点M1、M2…组成

库存变量变成了存储系统中的一条记录:表示某个商品的库存值

再假设这个存储系统无法为我们保证decr的原子性,例如我们使用了先查询后更新的方式来进行decr操作

那么这里的多线程就会存在于分布式系统A的多个节点A1、A2上,那么jdk提供的锁如synchronized关键字将会不再有能力保证原子性(仅在同一JVM中生效)

于是分布式锁诞生了…

随之而来的是这样几个问题:

  1. synchronized在同一JVM中生效是因为它利用了JVM中的资源,而多个线程间通过同一JVM中的资源来控制他们的执行顺序,那么分布式系统中也需要一个公共资源来控制线程间的执行顺序,哪里存在一个这样的公共资源呢?
  2. jdk为我们实现了单机下的方便、健壮的锁,如今分布式环境下必须由我们自己实现一个健壮的分布式锁,需要考虑哪些问题?

对于问题1的解决,我们实际上只需要找到一个具备存储能力、提供读写的系统,在分布式架构中,还需要解决单点问题,那么我们的目标就是找到一个具备存储能力、提供读写、并且解决了单点问题的系统:

数据库系统oracle/mysql/sqlserver…、缓存系统redis/mongo/memcache…、分布式协调系统zookeeper

那么对于问题2,我们需要考虑这样几个问题:

  1. 分布式架构中,分布式锁应该是比较通用的工具,所以对于锁的操作,代价不应该太大,响应应该及时
  2. 作为分布式系统中分布式锁的公共资源,组件不应该过重,应该尽可能与业务系统的组件分离

很显然数据库系统就会显得非常臃肿,而且数据库系统一般作为业务系统的核心组件,不应该存储锁信息,并且如果为了分离,而单独部署新的数据库系统,就会显得非常重,运维工作也会变得困难很多,并且对于锁信息的存储,由于需要频繁的读写,更适合放置于缓存系统,而不是每次写都落盘,如果使用数据库系统,其速度也会慢很多。

所以一般而言,我们会选择缓存系统或分布式协调系统zookeeper,个人认为缓存系统更适合作为锁信息的存储系统,zookeeper是具有强一致性的,生产环境中如果需要扩容,将会让分布式锁变的很重,频繁操作,对zookeeper的压力非常大,并且对于使用锁的一方也会出现各种问题(获取锁超时、请求处理能力变弱等),另外,缓存系统一般会提供数据淘汰机制,这也会更加适合我们实现锁超时的效果。

基于缓存系统实现的分布式锁

接下来,以redis为例,实现一个健壮的分布式锁。

  • 基于setNx、getSet命令实现分布式锁

功能:分布式场景下的加锁、解锁、判断锁超时、防止死锁

基本思路:value存储锁超时时间&#

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值