现在大家总是聊一些分布式的问题,比如分布式事务、分布式框架、zookeeper、springcloud等等。今天我们先回顾一下锁的概念和使用,然后来聊一下分布式锁,并且用redis来实现分布式锁。
首先,我们先来回顾一下我们工作学习中的锁的概念。
为什么要先讲锁再讲分布式锁呢?
我们都清楚,锁的作用是要解决多线程对共享资源的访问而产生的线程安全问题,那么我们平时生活中用到锁的情况其实并不多,可能有些朋友对锁的概念和一些基本的使用不是很清楚,所以我们先看锁,再深入分布式锁。
我们通过一个卖票的小案例来看一下,比如大家去抢dota2 ti9门票
首先我们先看下如果不加锁的话会出现什么问题,代码如下:
package Thread;
import java.util.concurrent.TimeUnit;
public class Ticket {
/**
* 初始库存量
* */
Integer ticketNum = 8;
public void reduce(int num){
//判断库存是否够用
if((ticketNum - num) >= 0){
try {
TimeUnit.MILLISECONDS.sleep(200);
}catch (InterruptedException e){
e.printStackTrace();
}
ticketNum -= num;
System.out.println(Thread.currentThread().getName() + "成功卖出"
+ num + "张,剩余" + ticketNum + "张票");
}else {
System.err.println(Thread.currentThread().getName() + "没有卖出"
+ num + "张,剩余" + ticketNum + "张票");
}
}
public static void main(String[] args) throws InterruptedException{
Ticket ticket = new Ticket();
//开启10个线程进行抢票,按理说应该有两个人抢不到票
for(int i=0;i<10;i++){
new Thread(() -> ticket.reduce(1),"用户" + (i + 1)).start();
}
Thread.sleep(1000L);
}
}
代码分析:这里我们有8张ti9门票,然后设置了10个线程(也就是模拟10个人)去并发抢票,如果抢成功了显示成功,抢失败的话显示失败。
按理说应该有8个人抢成功了,2个人抢失败,下面我们看下运行结果:
我们发现运行结果和我们预期的情况不一致,居然10个人都买到了票,也就出现了线程安全的问题,那么是什么原因导致的呢?
原因就是因为多个线程之间产生了时间差
如图所示,只剩一张票了,然后两个线程都看到,诶,还有一张票,哈哈,然后他们读到的票余量都是1,也就是说线程B还没有等到线程A改库存就已经抢票成功了。
那么,我们怎么解决呢?想必大家都知道,加个synchronized关键字就可以了,在一个线程进行reduce方法的时候,其他线程则阻塞在等待队列中,这样就不会发生多个线程对共享变量的竞争问题。
举个例子:
比如我们去健身房健身,如果好多人同时用一台机器,同时在一台跑步机上跑步,就会发生很大的问题,大家会打的不可开交。那么我们加了一把锁在健身房门口,只有拿到锁的人可以进去锻炼,其他人在门外等候,这样就可以避免大家对健身器材的竞争。代码如下:
public synchronized void reduce(int num){
//判断库存是否够用
if((ticketNum - num) >= 0){
try {
TimeUnit.MILLISECONDS.sleep(200);
}catch (InterruptedException e){
e.printStackTrace();
}
ticketNum -= num;
System.out.println(Thread.currentThread().getName() + "成功卖出"
+ num + "张,剩余" + ticketNum + "张票");
}else {
System.err.println(Thread.currentThread().getName() + "没有卖出"
+ num + "张,剩余" + ticketNum + "张票");
}
}
运行结果:
果不其然,有两个人没有成功抢到票,看来我们的目地达成了。
但是按照我们对日常生活的理解。不可能整个健身房只有一个人在运动,那所有人运动完估计都第二天早上了。
所以我们只需要对某一台机器加锁就可以了,比如一个人在跑步,另一个人可以去做其他的运动。
对于我们的票务系统来说,我们只需要对库存的修改操作的代码加锁就可以了,别的代码还是可以并行进行的,这样会大大减少锁的持有时间,代码修改如下:
public void reduceByLock(int num){
boolean flag = false;
synchronized (ticketNum){
if((ticketNum - num) >= 0){
ticketNum -= num;
flag = true;
}
}
if(flag){
System.out.println(Thread.currentThread().getName() + "成功卖出"
+ num + "张,剩余" + ticketNum + "张票");
}
else {
System.err.println(Thread.currentThread().getName() + "没有卖出"
+ num + "张,剩余" + ticketNum + "张票");
}
if(ticketNum == 0){
System.out.println("耗时" + (System.currentTimeMillis() - startTime) + "毫秒");
}
}
这样做的目的是充分利用cpu的资源,提高代码的执行效率
这里我们对两种方式的时间做个打印
public synchronized void reduce(int num){
//判断库存是否够用
if((ticketNum - num) >= 0){
try {
TimeUnit.MILLISECONDS.sleep(200);
}catch (InterruptedException e){
e.printStackTrace();
}
ticketNum -= num;
if(ticketNum == 0){
System.out.println("耗时" + (System.currentTimeMillis() - startTime) + "毫秒");
}
System.out.println(Thread.currentThread().getName() + "成功卖出"
+ num + "张,剩余" + ticketNum + "张票");
}else {
System.err.println(Thread.currentThread().getName() + "没有卖出"
+ num + "张,剩余" + ticketNum + "张票");
}
}
果然,我们只对部分代码加锁会大大提供代码的执行效率。
所以,在解决了线程安全的问题后,我们还要考虑到加锁之后的代码执行效率问题
这里我们有两场电影,是最近刚上映的魔童哪吒和蜘蛛侠两场电影,然后我们模拟一个支付购买的过程,让方法等待,加了一个CountDownLatch的await方法,然后我们看一下运行结果
package Thread;
import java.util.concurrent.CountDownLatch;
public class Movie {
private final CountDownLatch latch = new CountDownLatch(1);
//魔童哪吒
private Integer babyTickets = 20;
//蜘蛛侠
private Integer spiderTickets = 100;
public synchronized void showBabyTickets() throws InterruptedException{
System.out.println("魔童哪吒的剩余票数为:" + babyTickets);
//购买
latch.await();
}
public synchronized void showSpiderTickets() throws InterruptedException{
System.out.println("蜘蛛侠的剩余票数为:" + spiderTickets);
//购买
}
public static void main(String[] args) {
Movie movie = new Movie();
new Thread(() -> {
try {
movie.showBabyTickets();
}catch (InterruptedException e){
e.printStackTrace();
}
},"用户A").start();
new Thread(() -> {
try {
movie.showSpiderTickets();
}catch (InterruptedException e){
e.printStackTrace();
}
},"用户B").start();
}
}
执行结果:
魔童哪吒的剩余票数为:20
我们发现买哪吒票的时候阻塞会影响蜘蛛侠票的购买。但是我们知道这两场电影之间是相互独立的,所以我们需要减少锁的粒度,将movie整个对象的锁变为两个全局变量的锁,修改代码如下:
public void showBabyTickets() throws InterruptedException{
synchronized (babyTickets) {
System.out.println("魔童哪吒的剩余票数为:" + babyTickets);
//购买
latch.await();
}
}
public void showSpiderTickets() throws InterruptedException{
synchronized (spiderTickets) {
System.out.println("蜘蛛侠的剩余票数为:" + spiderTickets);
//购买
}
}
执行结果:
魔童哪吒的剩余票数为:20
蜘蛛侠的剩余票数为:100
哈哈,果不其然,现在两场电影的购票不会互相影响了,这就是第二个优化锁的方式:减少锁的粒度。顺便提一句,java并发包里的ConcurrentHashMap就是把一把大锁变成了16把小锁,通过分段锁的方式达到高效的并发安全。
锁分离就是我们常说的读写分离,我们把锁分成读锁和写锁,读的锁不需要阻塞,而写的锁要考虑并发问题。
这里我们就不一一讲述每一种锁的概念了,大家可以自己去学习一下,锁还可以按照偏向锁、轻量级锁、重量级锁来分类。
了解了锁的基本概念和锁的优化后,我们一起来重点看一下分布式锁的概念
这个是我们搭建的分布式环境,有三个购票项目,对应一个库存,然后每一个系统 会有多个线程,这里,我们和刚才一样,对库存的修改操作加上锁,能不能保证这6个线程的线程安全问题呢?
当然是不能的,因为每一个购票系统都有各自的jvm进程,互相独立,所以加synchronized只能保证一个系统的线程安全,并不能保证分布式的线程安全问题。
所以我们需要一个对于三个系统都是公共的一个中间件来解决这个问题。
这里我们选择Redis来作为分布式锁,多个系统在redis中set同一个key,只有key不存在的时候,才能设置成功,并且该key会对应其中一个系统的唯一标识,当该系统访问资源结束后,将key删除,则达到了释放锁的目的。
在任意时刻只有一个客户端可以获取锁
这个很容易理解,所有的系统中只能有一个系统持有锁
假如一个客户端在持有锁的时候崩溃掉了,没有释放锁,那么别的客户端无法获得锁,则会造成死锁,所以要保证客户端一定会释放锁
redis中我们可以设置锁的过期时间来保证不会发生死锁。
解铃还须系铃人,加锁和解锁必须是同一个客户端,客户端A的线程加的锁必须是客户端A的线程来解锁,客户端不能解开别的客户端的锁
当一个客户端获取对象锁之后,这个客户端可以再次获取这个对象上的锁
加锁需要两步操作,但是我们想一下会有什么问题吗?
假如我们加锁完之后客户端突然挂了呢?那么这个锁就会成为一个没有有效期的锁,接着就可能发生死锁。虽然这种情况发生的概率很小,但是一旦出现问题会很严重,所以我们也要把这两步合为一步。
幸运的是,redis3.0已经把这两个指令合在一起成为一个新的指令
我们看下jedis的官方文档中的源码
public String set(String key, String value, String nxxx, String expx, long time) {
this.checkIsInMultiOrPipeline();
this.client.set(key, value, nxxx, expx, time);
return this.client.getStatusCodeReply();
}
果然这就是我们想要的
解锁也是两步,那么也要保证解锁的原子性,把两步合为一步。
这里我们就无法借助于redis了,只能依靠Lua脚本来实现了
if redis.call("get",key==argv[1])then
return redis.call("del",key)
else return 0 end
这里就是一段判断是否自己持有锁并释放锁的lua脚本。
那么为什么lua脚本是原子性呢?因为lua脚本是jedis用eval()函数执行的,如果执行则会全部执行完成。
public class RedisDistributedLock implements Lock {
//上下文,保存当前锁的持有人id
private ThreadLocal lockContext = new ThreadLocal();
//默认锁的超时时间
private long time = 100;
//可重入性
private Thread ownerThread;
public RedisDistributedLock() {
}
public void lock() {
while (!tryLock()){
try {
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public boolean tryLock() {
return tryLock(time,TimeUnit.MILLISECONDS);
}
public boolean tryLock(long time, TimeUnit unit){
String id = UUID.randomUUID().toString(); //每一个锁的持有人都分配一个唯一的id
Thread t = Thread.currentThread();
Jedis jedis = new Jedis("127.0.0.1",6379);
//只有锁不存在的时候加锁并设置锁的有效时间
if("OK".equals(jedis.set("lock",id, "NX", "PX", unit.toMillis(time)))){
//持有锁的人的id
lockContext.set(id); ①
//记录当前的线程
setOwnerThread(t); ②
return true;
}else if(ownerThread == t){
//因为锁是可重入的,所以需要判断当前线程已经持有锁的情况
return true;
}else {
return false;
}
}
private void setOwnerThread(Thread t){
this.ownerThread = t;
}
public void unlock() {
String script = null;
try{
Jedis jedis = new Jedis("127.0.0.1",6379);
script = inputStream2String(getClass().getResourceAsStream("/redis.lua"));
if(lockContext.get()==null){
//没有人持有锁
return;
}
//删除锁 ③
jedis.eval(script, Arrays.asList("lock"), Arrays.asList(lockContext.get()));
lockContext.remove();
}catch (Exception e){
e.printStackTrace();
}
}
/**
* 将InputStream转化成String
* @param is
* @return
* @throws IOException
*/
public String inputStream2String(InputStream is) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int i = -1;
while ((i = is.read()) != -1) {
baos.write(i);
}
return baos.toString();
}
public void lockInterruptibly() throws InterruptedException {
}
public Condition newCondition() {
return null;
}
}
①. 用一个上下文全局变量来记录持有锁的人的uuid,然后解锁的时候需要将该uuid作为参数传入lua脚本中去,来判断是否可以解锁
②. 要记录当前线程,来实现分布式锁的重入性,如果是当前线程持有锁的话,也是属于加锁成功
③. 用eval函数来执行lua脚本,保证解锁时的原子性
获取锁的时候插入一条数据,解锁时删除数据
加锁时在指定结点的目录下创建一个新节点,释放锁的时候删除这个临时结点。因为有心跳检测的存在,所以不会发生死锁,更加安全
性能一般,没有redis高效
所以:
从性能角度:
redis > zookeeper > 数据库
从可靠性(安全)性角度:
zookeeper > redis > 数据库
今天我们从锁的基本概念出发,首先我们看到了多线程访问共享资源会出现的线程安全问题,然后我们通过加锁的方式去解决线程安全的问题,但是性能会下降,所以我们可以通过: 缩短锁的持有时间、减小锁的粒度、锁分离三种方式去优化锁。
之后我们了解了分布式锁的4个特点: