1. 什么是分布式锁?
我们先来看这样一个场景,如下图所示,两个用户同时去抢购秒杀商品,当秒杀服务同时收到秒杀请求时,都去进行库存扣减,此时在没有做任何处理的情况下,就会导致库存数量变成负数从而导致超卖现象。
这种情况下如果是单体项目,我们一般会选择加锁的方式来避免并发的问题。但是在分布式场景中,采用传统的锁并不能解决跨进程并发的问题,所以需要引入一个分布式锁,来解决多个节点之间的访问控制。
2. Zookeeper如何实现分布式锁
实现分布式的方式有很多种,本文主要讲述如何使用zookeeper实现分布式锁。我们可以基于zookeeper的两种特性来实现分布式锁,首先我们来看第一种:
2.1 唯一节点特性
我们可以基于唯一节点特性来实现分布式锁的操作,如下图所示。多个应用程序去抢占锁资源时,只需要在指定节点上创建一个 /Lock 节点,由于Zookeeper中节点的唯一性特性,使得只会有一个用户成功创建 /Lock 节点,剩下没有创建成功的用户表示竞争锁失败。
这种方法虽然能达到目的,但是会有一个问题,如下图所示,假设有非常多的节点需要等待获得锁,那么等待的方式自然是使用watcher机制来监听/lock节点的删除事件,一旦发现该节点被删除说明之前获得锁的节点已经释放了锁,那么此时剩下的B、C、D节点会同时收到删除事件从而去竞争锁,这个过程会产生惊群效应。
什么是“惊群效应”呢?简单来说就是如果存在许多的客户端在等待获取锁,当成功获取到锁的进程释放该节点后,所有处于等待状态的客户端都会被唤醒,这个时候zookeeper会在短时间内发送大量子节点变更事件给所有待获取锁的客户端,然后实际情况是只会有一个客户端获得锁。如果在集群规模比较大的情况下,会对zookeeper服务器的性能产生比较的影响。
2.2 有序节点
为了解决惊群效应,我们可以采用Zookeeper的有序节点特性来实现分布式锁。
如下图所示,每个客户端都往指定的节点下注册一个临时有序节点,越早创建的节点,节点的顺序编号就越小,那么我们可以判断子节点中最小的节点设置为获得锁。如果自己的节点不是所有子节点中最小的,意味着还没有获得锁。这个的实现和前面单节点实现的差异性在于,每个节点只需要监听比自己小的节点,当比自己小的节点删除以后,客户端会收到watcher事件,此时再次判断自己的节点是不是所有子节点中最小的,如果是则获得锁,否则就不断重复这个过程,这样就不会导致羊群效应,因为每个客户端只需要监控一个节点。
使用有序节点实现分布式锁的流程大致如下:
3. Curator实现分布式锁
在日常开发种,我们无需自己去实现分布式锁,只需要使用Curator即可实现分布式锁。
为了实现分布式锁,我们先演示一个存在并发异常的场景。
3.1 商品抢购场景
SQL
DROP TABLE IF EXISTS `goods_stock`;
CREATE TABLE `goods_stock` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`goods_no` int(11) NOT NULL COMMENT '商品编号',
`stock` int(11) NULL DEFAULT NULL COMMENT '库存',
`isActive` smallint(6) NULL DEFAULT NULL COMMENT '是否上架(1上,0不是)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
整个项目采用spring boot+mybatis-plus框架,代码一键生成。主要编写controller层代码即可:
这个抢购接口乍一看好像没啥问题,但实际上是存在问题的,因为他不具有原子性,在高并发场景下会造成数据多扣减。
我们可以使用jmeter对这个接口进行压测,用1500个线程,库存数量设置成100,监视数据库中库存的变化发现,整个库存变化过程是非常混乱的。可能一会数字变小,但是一会又变大了…
@RestController
@RequestMapping("/goods-stock")
public class GoodsStockController {
@Autowired
private IGoodsStockService goodsStockService;
@GetMapping("/{goodsNo}")
public String purchase(@PathVariable("goodsNo") Integer goodsNo) throws Exception {
QueryWrapper<GoodsStock> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("goods_no", goodsNo);
GoodsStock goodsStock = goodsStockService.getOne(queryWrapper);
Thread.sleep(new Random().nextInt(1000)); //增加问题出现的频率
if (goodsStock == null) {
return "指定商品不存在";
}
if (goodsStock.getStock().intValue() < 1) {
return "库存不够";
}
goodsStock.setStock(goodsSto