微服务系列(五)解读分布式锁
首先,锁是一个熟悉的字眼,在单机应用中,我们常常使用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中生效)
于是分布式锁诞生了…
随之而来的是这样几个问题:
- synchronized在同一JVM中生效是因为它利用了JVM中的资源,而多个线程间通过同一JVM中的资源来控制他们的执行顺序,那么分布式系统中也需要一个公共资源来控制线程间的执行顺序,哪里存在一个这样的公共资源呢?
- jdk为我们实现了单机下的方便、健壮的锁,如今分布式环境下必须由我们自己实现一个健壮的分布式锁,需要考虑哪些问题?
对于问题1的解决,我们实际上只需要找到一个具备存储能力、提供读写的系统,在分布式架构中,还需要解决单点问题,那么我们的目标就是找到一个具备存储能力、提供读写、并且解决了单点问题的系统:
数据库系统oracle/mysql/sqlserver…、缓存系统redis/mongo/memcache…、分布式协调系统zookeeper
那么对于问题2,我们需要考虑这样几个问题:
- 分布式架构中,分布式锁应该是比较通用的工具,所以对于锁的操作,代价不应该太大,响应应该及时
- 作为分布式系统中分布式锁的公共资源,组件不应该过重,应该尽可能与业务系统的组件分离
很显然数据库系统就会显得非常臃肿,而且数据库系统一般作为业务系统的核心组件,不应该存储锁信息,并且如果为了分离,而单独部署新的数据库系统,就会显得非常重,运维工作也会变得困难很多,并且对于锁信息的存储,由于需要频繁的读写,更适合放置于缓存系统,而不是每次写都落盘,如果使用数据库系统,其速度也会慢很多。
所以一般而言,我们会选择缓存系统或分布式协调系统zookeeper,个人认为缓存系统更适合作为锁信息的存储系统,zookeeper是具有强一致性的,生产环境中如果需要扩容,将会让分布式锁变的很重,频繁操作,对zookeeper的压力非常大,并且对于使用锁的一方也会出现各种问题(获取锁超时、请求处理能力变弱等),另外,缓存系统一般会提供数据淘汰机制,这也会更加适合我们实现锁超时的效果。
基于缓存系统实现的分布式锁
接下来,以redis为例,实现一个健壮的分布式锁。
- 基于setNx、getSet命令实现分布式锁
功能:分布式场景下的加锁、解锁、判断锁超时、防止死锁
基本思路:value存储锁超时时间&#