
一. 分布式锁概念
当我们在单机环境下进行应用开发,涉及到并发同步时,说到如何保证线程的安全,相信大家一定能想到采用synchronized或者Lock的方式,来解决多线程间的代码同步问题。这时多线程的运行都是在同一个JVM之下,我们只要保证锁对象的唯一性,就不会产生任何问题。
关注Java架构栈,干货天天都不断!

但当我们的应用是分布式集群环境,此时就属于多JVM下的工作环境,跨越了多个JVM,不同进程中的锁对象是不一样的,此时就无法通过多线程的锁来解决同步问题。

那么在这种环境下,我们就需要给大家介绍一种更加高级的锁机制,来处理种跨机器多进程之间的数据同步问题——这就是分布式锁。

二. zookeeper分布式锁的实现原理
为了方便大家理解,我给大家梳理了zookeeper实现分布式锁的核心原理,其核心思想就是:
当客户端要获取锁,则创建节点,使用完锁或者客户端异常退出,则删除该节点。接下来我们再把该思想细分出多个具体的实现步骤,如下:
客户端获取锁时,在lock节点下创建临时顺序 + 监听节点;
获取lock下面的所有子节点。客户端获取到所有的子节点之后,如果发现自己创建的子节点序号最小,那么就认为该客户端获取到了锁。使用完锁后,将该节点删除;
如果发现自己创建的节点,并非是lock节点下所有子节点中最小的,则说明自己还没有获取到锁。此时客户端需要找到比自己小的那个节点,同时对其注册事件监听器,监听删除事件;
如果发现比自己小的那个节点被删除,则客户端的Watcher会收到相应通知。此时再次判断自己创建的节点,是否是lock子节点中序号最小的。如果是,则获取到了锁,如果不是,则重复以上步骤继续获取,直到比自己小的一个节点并注册监听。
创建临时节点的好处是,当客户端因为异常挂掉或者正常执行业务关闭后,这个节点会自动删除,不存在其他进程获取不到锁的问题。而顺序节点的好处是,当所有进程过来加锁后,可以利用顺序号进行排队。所以后续节点只会监听它的上一个节点,而不是他们的父节点。如果监听的是父节点,某个节点删除,则其他所有的节点都会参与抢锁操作,这种现象我们称之为“惊群效应”,会带来性能开销。

三. 分布式锁的应用
为了加深大家对Zookeeper的理解,接下来我就带着大家来模拟一个多进程环境下的卖票程序。
1.准备环境
我们先准备一个数据库,脚本如下,大家可以导入数据库创建自己的表格。
DROP TABLE IF EXISTS `ticket`;
CREATE TABLE `ticket` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`count` bigint(20) DEFAULT NULL,
`identifier` varchar(32) DEFAULT NULL,
`start` varchar(32) DEFAULT NULL,
`end` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
/*Data for the table `ticket` */
insert into `ticket`(`id`,`count`,`identifier`,`start`,`end`) values (1,0,'HZD-001','杭州东站','九江站'),(2,100,'HZD-002','杭州东站','南昌站');
2.创建Web项目
接下来我们利用SpringBoot来创建一个Web项目。
2.1 引入依赖
在这里引入一些关键的依赖包。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.26</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.8</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.8</version>
</dependency>
<dependency>
<groupId>com.github.hiwepy</groupId>
<artifactId>curator-spring-boot-starter</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>
</dependencies>2.2 添加配置
在application.yml文件中进行必要的配置。
# 第一个进程配置
server:
port: 81
spring:
datasource:
url: jdbc:mysql:///db3
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
winnum: 1
curator:
connect-string: localhost:2181
session-timeout-ms: 5000
---
# 第二个进程配置
server:
port: 82
winnum: 2
spring:
profiles: win22.3 编写Ticket实体类
我们还要封装一个实体类,用于信息的封装。
@Data
public class Ticket {
private Integer id;
private Integer count;
private String identifier;
private String start;
private String end;
}2.4 编写Mapper接口
这是基于Mybatis-Plus实现的dao层操作接口。
public interface TicketMapper extends BaseMapper<Ticket> {
}2.5 编写Controller访问接口
最后我们还要编写Controller层代码,实现一个简单的秒杀接口。
@RestController
@RequestMapping("/ticket")
public class TicketController {
@Autowired
private TicketMapper ticketMapper;
@Value("${winnum}")
private Integer winnum;
@GetMapping("/sell/{id}")
public void sell(@PathVariable("id") Integer id) throws Exception {
while (true){
Ticket ticket = ticketMapper.selectById(id);
//查询是否有票
if (ticket.getCount() > 0) {
//有票
System.out.println(winnum + "窗口正在卖出第" + ticket.getCount() + "张票");
//模拟卖票耗时
Thread.sleep(1000);
ticket.setCount(ticket.getCount() - 1);
//将新的票数设置到数据库
ticketMapper.updateById(ticket);
System.out.println(winnum + "窗口卖出票后,剩余票数为: " + ticket.getCount());
} else {
//没票
break;
}
}
}
}至此,我们这个Web项目的核心代码就已经全部完成了,接下来我们需要把项目运行部署起来,测试一下抢购接口是否可用。
3.启动两个进程
因为我们要模拟多进程的环境,所以这里需要启动两个进程,这里的启动环境配置如下图所示:

最终两个进程启动效果如下:

4.分别访问两个进程开始卖票
访问进程1:http://localhost:81/ticket/sell/1
访问进程2:http://localhost:82/ticket/sell/1

通过观察上面的运行结果,大家可以看到这个卖票程序出现了问题!即两个程序一直在卖同一张票,这就出现了冲突,在现实场景下,这是不允许的!那么接下来,明明老师就带着大家,利用zookeeper的分布式锁将这个问题解决掉。
5.zookeeper分布式锁解决卖票问题
我们把上面的Controller接口进行改造,引入Zookeeper代码,重新编写一下售票程序。
@RestController
@RequestMapping("/ticket")
public class TicketController {
@Autowired
private TicketMapper ticketMapper;
@Value("${winnum}")
private Integer winnum;
@Autowired
private CuratorZkTemplate zkTemplate;
public static final String LOCK_PREFIX = "/LOCK-";
@GetMapping("/sell/{id}")
public void sell(@PathVariable("id") Integer id) throws Exception {
while (true){
//通过分布式锁控制多进程间共享数据的操作安全
InterProcessLock lock = zkTemplate.getSharedReentrantLock(LOCK_PREFIX+id);
try {
//加锁
lock.acquire();
Ticket ticket = ticketMapper.selectById(id);
//查询是否有票
if (ticket.getCount() > 0) {
//有票
System.out.println(winnum + "窗口正在卖出第" + ticket.getCount() + "张票");
//模拟卖票耗时
Thread.sleep(1000);
ticket.setCount(ticket.getCount() - 1);
//将新的票数设置到数据库
ticketMapper.updateById(ticket);
System.out.println(winnum + "窗口卖出票后,剩余票数为: " + ticket.getCount());
} else {
//没票
break;
}
}finally {
lock.release();
}
}
}
}在加锁时大家要注意:只有在卖相同列次的票时才需要进行互斥操作。对于多个进程,如果卖的是不同列次的票,是不需要互斥等待的,所以加锁的键一定要以票的id为标识。
代码修改后,我们再次来看看执行效果。

此时大家可以看到,上面的问题我们已经解决了,现在两个进程在卖票时,已经不会再出现同一张票被卖给多人的情况了。至此,问题解决,现在你学会Zookeeper的作用及其用法了吗?如果你还有其他疑问,可以在评论区留言哦。
扫码咨询了解课程详情,获取课程配套资料!

扫码或者点击文末左下角【阅读原文】快速进入学习!

点击【阅读原文】,免费开始学习Zookeeper视频课程!
原文作者:Java架构栈;如有侵权,请联系管理员删除。