常见思路
8.锁粒度避免过粗
synchronized
在高并发场景,为了防止超卖等情况,我们经常需要加锁来保护共享资源。但是,如果加锁的粒度过粗,是很影响接口性能的。
什么是加锁粒度呢?
其实就是就是你要锁住的范围是多大。比如你在家上卫生间,你只要锁住卫生间就可以了吧,不需要将整个家都锁起来不让家人进门吧,卫生间就是你的加锁粒度。
不管你是synchronized加锁还是redis分布式锁,只需要在共享临界资源加锁即可,不涉及共享资源的,就不必要加锁。这就好像你上卫生间,不用把整个家都锁住,锁住卫生间门就可以了。
比如,在业务代码中,有一个ArrayList
因为涉及到多线程操作,所以需要加锁操作,假设刚好又有一段比较耗时的操作(代码中的slowNotShare
方法)不涉及线程安全问题。反例加锁,就是一锅端,全锁住:
//不涉及共享资源的慢方法 private void slowNotShare() { try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { } } //错误的加锁方法 public int wrong() { long beginTime = System.currentTimeMillis(); IntStream.rangeClosed(1, 10000).parallel().forEach(i -> { //加锁粒度太粗了,slowNotShare其实不涉及共享资源 synchronized (this) { slowNotShare(); data.add(i); } }); log.info("cosume time:{}", System.currentTimeMillis() - beginTime); return data.size(); }
正例:
public int right() { long beginTime = System.currentTimeMillis(); IntStream.rangeClosed(1, 10000).parallel().forEach(i -> { slowNotShare();//可以不加锁 //只对List这部分加锁 synchronized (data) { data.add(i); } }); log.info("cosume time:{}", System.currentTimeMillis() - beginTime); return data.size(); }
对于锁的更细致使用来说,在java中提供了synchronized
关键字给我们的代码加锁。
通常有两种写法:在方法上加锁
和 在代码块上加锁
。
先看看如何在方法上加锁:
public synchronized doSave(String fileUrl) { mkdir(); uploadFile(fileUrl); sendMessage(fileUrl); }
这里加锁的目的是为了防止并发的情况下,创建了相同的目录,第二次会创建失败,影响业务功能。
但这种直接在方法上加锁,锁的粒度有点粗。因为doSave方法中的上传文件和发消息方法,是不需要加锁的。只有创建目录方法,才需要加锁。
我们都知道文件上传操作是非常耗时的,如果将整个方法加锁,那么需要等到整个方法执行完之后才能释放锁。显然,这会导致该方法的性能很差,变得得不偿失。
这时,我们可以改成在代码块上加锁了,具体代码如下:
public void doSave(String path,String fileUrl) { synchronized(this) { if(!exists(path)) { mkdir(path); } } uploadFile(fileUrl); sendMessage(fileUrl); }
这样改造之后,锁的粒度一下子变小了,只有并发创建目录功能才加了锁。而创建目录是一个非常快的操作,即使加锁对接口的性能影响也不大。
最重要的是,其他的上传文件和发送消息功能,任然可以并发执行。
当然,这种做在单机版的服务中,是没有问题的。但现在部署的生产环境,为了保证服务的稳定性,一般情况下,同一个服务会被部署在多个节点中。
同时它也带来了新的问题:synchronized只能保证一个节点加锁是有效的,但如果有多个节点如何加锁呢?
这就需要使用:分布式锁了。目前主流的分布式锁包括:redis分布式锁 和 数据库分布式锁。
redis分布式锁
在分布式系统中,由于redis分布式锁相对于更简单和高效,成为了分布式锁的首先,被我们用到了很多实际业务场景当中。
public void doSave(String path,String fileUrl) { try { String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { if(!exists(path)) { mkdir(path); uploadFile(fileUrl); sendMessage(fileUrl); } return true; } } finally{ unlock(lockKey,requestId); } return false; }
跟之前使用synchronized
关键字加锁时一样,这里锁的范围也太大了,换句话说就是锁的粒度太粗,这样会导致整个方法的执行效率很低。
其实只有创建目录的时候,才需要加分布式锁,其余代码根本不用加锁。
于是,我们需要优化一下代码:
public void doSave(String path,String fileUrl) { if(this.tryLock()) { mkdir(path); } uploadFile(fileUrl); sendMessage(fileUrl); } private boolean tryLock() { try { String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } } finally{ unlock(lockKey,requestId); } return false; }
上面代码将加锁的范围缩小了,只有创建目录时才加了锁。这样看似简单的优化之后,接口性能能提升很多。
但是Redis锁也存在着一些弊端情况,如下共有八条总结性建议:
非原子操作
使用redis的分布式锁,我们首先想到的可能是setNx
命令。
if (jedis.setnx(lockKey, val) == 1) { jedis.expire(lockKey, timeout); }
这段代码确实可以加锁成功,但你有没有发现什么问题?
加锁操作和后面的设置超时时间是分开的,并非原子操作。
假如加锁成功,但是设置超时时间失败了,该lockKey就变成永不失效。假如在高并发场景中,有大量的lockKey加锁成功了,但不会失效,有可能直接导致redis内存空间不足。
忘了释放锁
上面说到使用setNx
命令加锁操作和设置超时时间是分开的,并非原子操作。
而在redis中还有set
命令,该命令可以指定多个参数。
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false;
其中:
lockKey
:锁的标识requestId
:请求idNX
:只在键不存在时,才对键进行设置操作。PX
:设置键的过期时间为 millisecond 毫秒。expireTime
:过期时间
set
命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。
使用set
命令加锁,表面上看起来没有问题。但如果仔细想想,加锁之后,每次都要达到了超时时间才释放锁,会不会有点不合理?加锁后,如果不及时释放锁,会有很多问题。
分布式锁更合理的用法是:
- 手动加锁
- 业务操作
- 手动释放锁
- 如果手动释放锁失败了,则达到超时时间,redis会自动释放锁。
那么问题来了,如何释放锁呢?
try{ String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false; } finally { unlock(lockKey); }
只在finally中释放锁,就够了吗?
释放了别人的锁
在多线程场景中,可能会出现释放了别人的锁的情况。
假如线程A和线程B,都使用lockKey加锁。线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间。这时候,redis会自动释放lockKey锁。此时,线程B就能给lockKey加锁成功了,接下来执行它的业务操作。恰好这个时候,线程A执行完了业务功能,释放了锁lockKey。这不就出问题了,线程B的锁,被线程A释放了。
那么,如何解决这个问题呢?
不知道你们注意到没?在使用set命令加锁时,除了使用lockKey锁标识,还多设置了一个参数:requestId,为什么要需要记录requestId呢?
答:requestId是在释放锁的时候用的。
if (jedis.get(lockKey).equals(requestId)) { jedis.del(lockKey); return true; } return false;
在释放锁的时候,先获取到该锁的值(之前设置值就是requestId),然后判断跟之前设置的值是否相同,如果相同才允许删除锁,返回成功。如果不同,则直接返回失败。
换句话说就是:自己只能释放自己加的锁,不允许释放别人加的锁。
当然在这里也需要保证 判断 和 删除的原子性问题
自旋锁
上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。
此外,还有一种场景:
比如,有两个线程同时上传文件到sftp,上传文件前先要创建目录。假设两个线程需要创建的目录名都是当天的日期,比如:20210920,如果不做如何控制,这样直接并发的创建,第二个线程会失败。
有同学会说:这还不容易,加一个redis分布式锁就能解决问题了,此外再判断一下,如果目录已经存在就不创建,只有目录不存在才需要创建。
try { String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { if(!exists(path)) { mkdir(path); } return true; } } finally{ unlock(lockKey,requestId); } return false;
只是加redis分布式锁是不够的,因为第二个请求如果加锁失败了,接下来,是返回失败呢?还是返回成功呢?
显然肯定是不能返回失败的,如果返回失败了,这个问题还是没有被解决。如果文件还没有上传成功,直接返回成功会有更大的问题。头疼,到底该如何解决呢?
答:使用自旋锁
try { Long start = System.currentTimeMillis(); while(true) { String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { if(!exists(path)) { mkdir(path); } return true; } long time = System.currentTimeMillis() - start; if (time>=timeout) { return false; } try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } } finally{ unlock(lockKey,requestId); } return false;
在规定的时间,比如500毫秒内,自旋不断尝试加锁(说白了,就是在死循环中,不断尝试加锁),如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。
锁重入问题
我们都知道redis分布式锁是互斥的。如果我们对某个key加锁了,如果该key对应的锁还没失效,再用相同key去加锁,大概率会失败。
假设在某个请求中,需要获取一颗满足条件的菜单树或者分类树。我们以菜单为例,这就需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。
需要注意的是菜单不是一成不变的,在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加redis分布式锁。
加redis分布式锁的思路是对的。但接下来问题来了,在递归方法中递归遍历多次,每次都是加的同一把锁。递归第一层当然是可以加锁成功的,但递归第二层、第三层…第N层,不就会加锁失败了?
递归方法中加锁的伪代码如下:
private int expireTime = 1000; public void fun(int level,String lockKey,String requestId){ try{ String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { if(level<=10){ this.fun(++level,lockKey,requestId); } else { return; } } return; } finally { unlock(lockKey,requestId); } }
如果你直接这么用,看起来好像没有问题。但最终执行程序之后发现,等待你的结果只有一个:出现异常。
因为从根节点开始,第一层递归加锁成功,还没释放说,就直接进入第二层递归。因为requestId作为key的锁已经存在,所以第二层递归大概率会加锁失败,然后返回到第一层。第一层接下来正常释放锁,然后整个递归方法直接返回了。
那么这个问题该如何解决呢?
答:使用可重入锁。
我们以redisson框架为例,它的内部实现了可重入锁的功能。
伪代码如下:
private int expireTime = 1000; public void run(String lockKey) { RLock lock = redisson.getLock(lockKey); this.fun(lock,1); } public void fun(RLock lock,int level){ try{ lock.lock(5, TimeUnit.SECONDS); if(level<=10){ this.fun(lock,++level); } else { return; } } finally { lock.unlock(); } }
接下来,聊聊redisson可重入锁的实现原理。
加锁
- 先判断如果锁名不存在,则加锁。
- 然后判断判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次就加1。
- 如果锁名存在,但值不是requestId,则返回过期时间。
解锁
- 先判断如果锁名和requestId值不存在,则时间返回。
- 如果锁名和requestId值存在,则重入锁减1。
- 如果减1后,重入锁的value值还大于0,说明还有引用,则重试设置过期时间。
- 如果减1后,重入锁的value值还等于0,则可以删除锁,然后发消息通知等待线程抢锁。
锁竞争问题
如果有大量写入的场景,使用普通的redis分布式锁是没有问题的。
但如果有些业务场景,写入的操作比较少,反而有大量读取的操作。直接使用普通的redis分布式锁,性能会不会不太好?
我们都知道,锁的粒度越粗,多个线程抢锁时竞争就越激烈,造成多个线程锁等待的时间也就越长,性能也就越差。
所以,提升redis分布式锁性能的第一步,就是要把锁的粒度变细。
读写锁
众所周知,加锁的目的是为了保证,在并发环境中读写数据的安全性,即不会出现数据错误或者不一致的情况。
但在绝大多数实际业务场景中,一般是读数据的频率远远大于写数据。而线程间的并发读操作是并不涉及并发安全问题,我们没有必要给读操作加互斥锁,只要保证读写、写写并发操作上锁是互斥的就行,这样可以提升系统的性能。
我们以redisson框架为例,它内部已经实现了读写锁的功能。
读锁的伪代码如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); RLock rLock = readWriteLock.readLock(); try { rLock.lock(); //业务操作 } catch (Exception e) { log.error(e); } finally { rLock.unlock(); }
写锁的伪代码如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); RLock rLock = readWriteLock.writeLock(); try { rLock.lock(); //业务操作 } catch (InterruptedException e) { log.error(e); } finally { rLock.unlock(); }
将读锁和写锁分开,最大的好处是提升读操作的性能,因为读和读之间是共享的,不存在互斥性。而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。
下面总结一个读写锁的特点:
- 读与读是共享的,不互斥
- 读与写互斥
- 写与写互斥
后端接口性能优化分析-程序结构优化(中):https://developer.aliyun.com/article/1413672