引言
我们反复围绕着线程安全相关问题在对Java的并发编程进行阐述,但前叙的都是基于单体架构的Java程序进行分析的,而如今单体的程序远不足以满足日益渐增的用户需求,所以一般目前Java程序都是通过多机器、分布式的架构模式进行部署。那么在多部署环境下,之前我们分析的CAS无锁、隐式锁、显式锁等方案是否还有效呢?答案是无效。
一、单体架构下的锁迁移分布式架构分析
这两种都是属于可以解决线程安全问题的锁,一种是Java原生的关键字,通过Java对象进行加锁的隐式锁,另一种则是JUC提供的显式锁方案。在开发过程中使用它们都能够保证多线程的安全问题。但把它们放在多机器/多应用部署的环境下,也能保证相同的效果吗?先来看一个案例:
两个服务:[订单服务:8000端口]、[商品服务:8001、8002端口]
数据库:订单库[db_order]、商品库[db_shopping]
订单服务中提供了一个下单接口,用户下单后会调用它,调用下单接口后,会通过restTemplate进行RPC调用商品服务的扣库存接口,实现库存扣减下单操作。
源码如下:
// 订单服务
@RestController
@RequestMapping("/order")
public class OrderApi{
// 库存服务的RPC前缀
private static final String REST_URL_PREFIX =
"http://SHOPPING/inventory";
@Autowired
private OrderService orderService;
@Autowired
private RestTemplate restTemplate;
// 下单接口
@RequestMapping("/placeAnOrder")
public String placeAnOrder(){
// 模拟商品ID
String inventoryId = "82391173-9dbc-49b6-821b-746a11dbbe5e";
// 生成一个订单ID(分布式架构中要使用分布式ID生成策略,此处是模拟)
String orderId = UUID.randomUUID().toString();
// 模拟生成订单记录
Order order = new
Order(orderId,"黄金竹子","88888.88",inventoryId);
// RPC调用库存接口
String responseResult = restTemplate.getForObject(
REST_URL_PREFIX + "/minusInventory?inventoryId="
+ inventoryId, String.class);
System.out.println("调用后库存接口结果:" + responseResult);
Integer n = orderService.insertSelective(order);
if (n > 0)
return "下单成功....";
return "下单失败....";
}
}
// 库存服务
@RestController
@RequestMapping("/inventory")
public class InventoryApi{
@Autowired
private InventoryService inventoryService;
@Value("${server.port}")
private String port;
// 扣库存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
// 查询库存信息
Inventory inventoryResult =
inventoryService.selectByPrimaryKey(inventory.getInventoryId());
if (inventoryResult.getShopCount() <= 0) {
return "库存不足,请联系卖家....";
}
// 扣减库存
inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
int n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
if (n > 0)
return "端口-" + port + ",库存扣减成功!!!";
return "端口-" + port + ",库存扣减失败!!!";
}
}
复制代码
观察上述源码,存在什么问题?线程安全问题。按照之前的做法我们会对扣库存的接口使用Synchronized或ReetrantLock加锁,如下:
// 库存服务 → 扣库存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
int n;
synchronized(InventoryApi.class){
// 查询库存信息
Inventory inventoryResult =
inventoryService.selectByPrimaryKey(inventory.getInventoryId());
if (inventoryResult.getShopCount() <= 0) {
return "库存不足,请联系卖家....";
}
// 扣减库存
inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
System.out.println("库存信息-剩余库存数量:" +
inventoryResult.getShopCount());
}
if (n > 0)
return "端口:" + port + ",库存扣减成功!!!";
return "端口:" + port + ",库存扣减失败!!!";
}
复制代码
是不是感觉没问题了?看测试:
通过JMeter压测工具,1秒内对下单接口进行八百次调用,此时出现了一个比较有意思的现象,测试结果如下:
订单服务控制台日志:[端口:8000]
......
调用后库存接口结果:端口-8001,库存扣减成功!!!
调用后库存接口结果:端口-8002,库存扣减成功!!!
......
调用后库存接口结果:端口-8001,库存扣减成功!!!
调用后库存接口结果:端口-8001,库存扣减成功!!!
调用后库存接口结果:端口-8002,库存扣减成功!!!
......
商品服务控制台日志:[端口:8001]
......
库存信息-剩余库存数量:999
......
库存信息-剩余库存数量:788
库存信息-剩余库存数量:787
.....
商品服务控制台日志:[端口:8002]
......
库存信息-剩余库存数量:998
库存信息-剩余库存数量:996
库存信息-剩余库存数量:993
......
库存信息-剩余库存数量:788
.....
复制代码
注意观察如上日志,在两个商品服务[8001/8002]中都出现了库存信息-剩余库存数量:788这么一条日志记录,这代表第799个商品被卖了两次,还是出现了线程安全问题,导致了库存超卖。可我们不是已经通过Class对象加锁了吗?为什么还会有这样的问题出现呢?下面我们来依次分析。
问题分析
在关于Synchronized关键字原理剖析的文章中已经得知:Synchronized关键字是依赖于对象做为锁资源进行加锁操作的,每个对象都会存在一个伴生的Monitor监视器,Synchronized则是通过它进行上锁,加锁过程如下:
OK~,有了上述基础知识之后,再接着分析前面的问题。为什么会出现线程安全问题?
实际上这个问题也不难理解,前面的案例中,我们是通过InventoryApi.class类对象进行上锁的,如果在单体程序中确实没有任何问题,因为Class对象是唯一的,当多条线程抢占一把Class锁时,同一时刻只会有一条线程获取锁成功,这样自然而然也不存在线程安全问题了。但目前的问题就出在这里,InventoryApi.class对象在单体程序中确实只存在一个,但目前商品服务是属于多应用/多机器部署的,目前商品服务有两个进程,那这就代表着存在两个不同的Java堆,在两个不同的堆空间中,都存在各自的InventoryApi.class对象,也就是代表着“此时Class对象并不是唯一的,此刻出现了两把锁”。而正是因为这个问题才最终导致了线程安全问题的出现。如下图:

还是回到最开始叙述线程安全的起点,同一时刻满足三要素:“多线程”、“共享资源”以及“非原子性操作”便会产生线程安全问题,而Synchronized或ReetrantLock都是从破坏了第一个要素的角度出发,以此解决了线程安全问题。但目前在分布式架构中,Synchronized或ReetrantLock因为多进程导致的多个Java空间堆出现,所以不管是Synchronized还是ReetrantLock都已经无法保证同一时刻只有一条线程对共享资源进行非原子性操作,最终线程安全问题的出现已经无法避免。
二、分布式锁思想及其实现过程推导
经过前面的分析,已经得知了分布式架构下的线程安全问题产生的根本原因,那目前又该如何解决这个问题呢?可能学习过分布式锁的小伙伴会直接回答:分布式锁。但此刻请你抛下之前的记忆,我们完全从零开始对分布式锁进行推导。
大家可以先代入一个角色:假设市面上还没有成熟的解决方案,你是第一位遇到这个问题的人,那么你又该如何去解决这类问题呢?OK~,接下来我们一起逐步推导。
先思考思考前面的Synchronized、ReetrantLock是如何解决单体程序的线程安全问题的呢?
- Synchronized: 依赖堆中对象的Monitor监视器 通过操作监视器中的_count字段实现互斥
- ReetrantLock: 依赖于AQS同步器 通过操作AQS中volatile修饰的state成员实现互斥
它们有什么共同点呢?都存在一个互斥量,并且互斥量都是所有线程可见的。
OK~,明白了这两点之后,再反过来思考一下,分布式环境中又是因为什么原因导致的安全问题复发了呢?答案是:互斥量所在的区域对于其他进程中的线程来说是不可见的。比如Synchronized关键字通过某个Class对象作为锁对象,一个堆空间中的Class对象对于当前进程中的所有线程来说是可见的,但是对于其他进程的线程是不可见的。ReetrantLock也是同理,volatile修饰的state变量,对于当前进程中的所有线程可见,但对于另外进程中的线程是不可见的。
那么此时想要解决分布式情况下的线程安全问题的思路是不是明了啦?
我们只需要找一个多个进程之间所有线程可见的区域实现这个互斥量即可。
比如:在一台服务器的同一路径下创建一个文件夹。获取锁操作则是创建文件夹,反之,释放锁的逻辑则是删除文件夹,这样可以很好的实现一把分布式锁,因为OS特性规定,在同一路径下,相同名字的文件夹只能创建一个。所以当两条线程同时执行获取锁逻辑时,永远只会有一条线程创建成功,成功创建文件夹的那条线程则代表获取锁成功,那么可以去执行业务逻辑。当这条线程执行完业务后,再删除掉文件夹,代表释放锁,以便于其他线程可以再次获取锁。
上述的这种方式确实可以实现一把最基本的分布式锁,但问题在于:这样实现的话一方面性能会比较差,第二个也不能具备锁重入的功能,第三方面也没有具备合理的锁失效机制以及阻塞机制。而一个优秀的分布式锁的实现方案应该满足如下几个特性:
- ①在分布式环境中,可以保证不同进程之间的线程互斥
- ②在同一时刻,同时只允许一条线程成功获取到锁资源
- ③保存互斥量的地方需要保证高可用性
- ④要保证可以高性能的获取锁与释放锁
- ⑤可以支持同一线程的锁重入性
- ⑥具备合理的阻塞机制,竞争锁失败的线程也有处理方案
- ⑦支持非阻塞式获取锁,获取锁失败的线程可以直接返回
- ⑧具备合理的锁失效机制,如超时失效等,可以确保避免死锁情况出现
那么目前市面上对于分布式锁的成熟方案有哪些呢?
- ①基于DB实现
- ②基

1297




被折叠的 条评论
为什么被折叠?



