写在前面
究竟什么样的锁才能更好的支持高并发场景呢?今天,我们就一起解密高并发环境下典型的分布式锁架构,结合【高并发】专题下的其他文章,学以致用。
锁用来解决什么问题呢?
在我们编写的应用程序或者高并发程序中,不知道大家有没有想过一个问题,就是我们为什么需要引入锁?锁为我们解决了什么问题呢?
在很多业务场景下,我们编写的应用程序中会存在很多的 资源竞争 的问题。而我们在高并发程序中,引入锁,就是为了解决这些资源竞争的问题。
电商超卖问题
这里,我们可以列举一个简单的业务场景。比如,在电子商务(商城)的业务场景中,提交订单购买商品时,首先需要查询相应商品的库存是否足够,只有在商品库存数量足够的前提下,才能让用户成功的下单。下单时,我们需要在库存数量中减去用户下单的商品数量,并将库存操作的结果数据更新到数据库中。整个流程我们可以简化成下图所示。
很多小伙伴也留言说,让我给出代码,这样能够更好的学习和掌握相关的知识。好吧,这里,我也给出相应的代码片段吧。我们可以使用下面的代码片段来表示用户的下单操作,我这里将商品的库存信息保存在了Redis中。
RequestMapping("/submitOrder")public String submitOrder()
{
int stock = Integer.parseInt (stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0){stock -= 1;
stringRedisTemplate.opsForValue( ).set("stock", String.valueOf(stock));
logger.debug("库存扣减成功,当前库存为:{}", stock);
}
else
{
logger.debug("库存不足,扣减库存失败");
throw new OrderException("库存不足,扣减库存失败");
}
return "success";
}
注意:上述代码片段比较简单,只是为了方便大家理解,真正项目中的代码就不能这么写了。
上述的代码看似是没啥问题的,但是我们不能只从代码表面上来观察代码的执行顺序。这是因为在JVM中代码的执行顺序未必是按照我们书写代码的顺序执行的。即使在JVM中代码是按照我们书写的顺序执行,那我们对外提供的接口一旦暴露出去,就会有成千上万的客户端来访问我们的接口。所以说,我们暴露出去的接口是会被并发访问的。
试问,上面的代码在高并发环境下是线程安全的吗?答案肯定不是线程安全的,因为上述扣减库存的操作会出现并行执行的情况。
我们可以使用Apache JMeter来对上述接口进行测试,这里,我使用Apache JMeter对上述接口进行测试。
在Jmeter中,我将线程的并发度设置为3,接下来的配置如下所示。
以HTTP GET请求的方式来并发访问提交订单的接口。此时,运行JMeter来访问接口,命令行会打印出下面的日志信息。
库存扣减成功,当前库存为:49
库存扣减成功,当前库存为:49
库存扣减成功,当前库存为:49
这里,我们明明请求了3次,也就是说,提交了3笔订单,为什么扣减后的库存都是一样的呢?这种现象在电商领域有一个专业的名词叫做 “超卖” 。
如果一个大型的高并发电商系统,比如淘宝、天猫、京东等,出现了超卖现象,那损失就无法估量了!架构设计和开发电商系统的人员估计就要通通下岗了。所以,作为技术人员,我们一定要严谨的对待技术,严格做好系统的每一个技术环节。
JVM中提供的锁
JVM中提供的synchronized和Lock锁,相信大家并不陌生了,很多小伙伴都会使用这些锁,也能使用这些锁来实现一些简单的线程互斥功能。那么,作为立志要成为架构师的你,是否了解过JVM锁的底层原理呢?
JVM锁原理
说到JVM锁的原理,我们就不得不限说说Java中的对象头了。
Java中的对象头
每个Java对象都有对象头。如果是?数组类型,则?2个字宽来存储对象头,如果是数组,则会?3个字宽来存储对象头。在32位处理器中,?个字宽是32位;在64位虚拟机中,?个字宽是64位。
对象头的内容如下表 。
Mark Work的格式如下所示。
可以看到,当对象状态为偏向锁时, Mark Word 存储的是偏向的线程ID;当状态为轻量级锁时, Mark Word 存储的是指向线程栈中 Lock Record 的指针;当状态为重量级锁时, Mark Word 为指向堆中的monitor对象的指针 。
有关Java对象头的知识,参考《深入浅出Java多线程》。
JVM锁原理
简单点来说,JVM中锁的原理如下。
在Java对象的对象头上,有一个锁的标记,比如,第一个线程执行程序时,检查Java对象头中的锁标记,发现Java对象头中的锁标记为未加锁状态,于是为Java对象进行了加锁操作,将对象头中的锁标记设置为锁定状态。第二个线程执行同样的程序时,也会检查Java对象头中的锁标记,此时会发现Java对象头中的锁标记的状态为锁定状态。于是,第二个线程会进入相应的阻塞队列中进行等待。
这里有一个关键点就是Java对象头中的锁标记如何实现。
JVM锁的短板
JVM中提供的synchronized和Lock锁都是JVM级别的,大家都知道,当运行一个Java程序时,会启动一个JVM进程来运行我们的应用程序。synchronized和Lock在JVM级别有效,也就是说,synchronized和Lock在同一Java进程内有效。如果我们开发的应用程序是分布式的,那么只是使用synchronized和Lock来解决分布式场景下的高并发问题,就会显得有点力不从心了。
synchronized和Lock支持JVM同一进程内部的线程互斥
synchronized和Lock在JVM级别能够保证高并发程序的互斥,我们可以使用下图来表示。
但是,当我们将应用程序部署成分布式架构,或者将应用程序在不同的JVM进程中运行时,synchronized和Lock就不能保证分布式架构和多JVM进程下应用程序的互斥性了。
synchronized和Lock不能实现多JVM进程之间的线程互斥
分布式架构和多JVM进程的本质都是将应用程序部署在不同的JVM实例中,也就是说,其本质还是多JVM进程。
分布式锁
我们在实现分布式锁时,可以参照JVM锁实现的思想,JVM锁在为对象加锁时,通过改变Java对象的对象头中的锁的标志位来实现,也就是说,所有的线程都会访问这个Java对象的对象头中的锁标志位。
我们同样以这种思想来实现分布式锁,当我们将应用程序进行拆分并部署成分布式架构时,所有应用程序中的线程访问共享变量时,都到同一个地方去检查当前程序的临界区是否进行了加锁操作,而是否进行了加锁操作,我们在统一的地方使用相应的状态来进行标记。
可以看到,在分布式锁的实现思想上,与JVM锁相差不大。而在实现分布式锁中,保存加锁状态的服务可以使用MySQL、Redis和Zookeeper实现。
但是,在互联网高并发环境中, 使用Redis实现分布式锁的方案是使用的最多的。 接下来,我们就使用Redis来深入解密分布式锁的架构设计。
Redis如何实现分布式锁
Redis命令
在Redis中,有一个不常使用的命令如下所示。
SETNX key value
这条命令的含义就是“SET if Not Exists”,即不存在的时候才会设置值。
只有在key不存在的情况下,将键key的值设置为value。如果key已经存在,则SETNX命令不做任何操作。
这个命令的返回值如下。
命令在设置成功时返回1。
命令在设置失败时返回0。
所以,我们在分布式高并发环境下,可以使用Redis的SETNX命令来实现分布式锁。假设此时有线程A和线程B同时访问临界区代码,假设线程A首先执行了SETNX命令,并返回结果1,继续向下执行。而此时线程B再次执行SETNX命令时,返回的结果为0,则线程B不能继续向下执行。只有当线程A执行DELETE命令将设置的锁状态删除时,线程B才会成功执行SETNX命令设置加锁状态后继续向下执行。
引入分布式锁
了解了如何使用Redis中的命令实现分布式锁后,我们就可以对下单接口进行改造了,加入分布式锁,如下所示。
/*** 为了演示方便,我这里就简单定义了一个常量作为商品的id* 实际工作中,这个商品id是前端进行下单操作传递过来的参数*/
public static final String PRODUCT_ID = "100001";
@RequestMapping ("/submitOrder")
public String submitOrder()
{
//通过stringRedisTemplate来调用Redis的SETNX命令,key为商品的id,value为字符串“binghe”//实际上,value可以为任意的字符换Boolean isLocked = stringRedisTemplate.opsForValue( ).setIfAbsent (PRODUCT_ID, "binghe");
//没有拿到锁,返回下单失败if(!isLock)
{
return "failure";
}
int stock = Integer.parseInt (stringRedisTemplate.opsForValue(