聊一聊高并发场景下锁的使用技巧,聊聊为什么要使用锁,有哪些锁可以使用。Java 中的重量级锁 synchronize、Java 中的轻量级锁和CA、 数据库行锁、悲观锁、乐观锁以及分布式锁,这些锁之间有什么区别呢?我们也一起来探讨一下理清楚这些概念之后,我们会通过一个非常具备说明性的例子,库存扣减或余额扣减的场景来分别讨论前面我们提的那些锁,它们是如何使用的,又有哪些缺点呢?
1、为什么要使用锁。
2、了解锁的分类。
3、锁的使用场景。
为什么要使用锁
了解这些之前我们先要来看看为什么要使用锁,如何确保一个方法或者一块代码在高并发情况下,同一时间只能被一个线程执行。单体应用,可以使用 Java 并发处理相关的 API 进行控制,但是单体应用架构演变为分布式微服务架构后,跨 JVM 或跨进程的实力部署就有没有办法通过 Java 的锁机制来控制并发了?在这种情况下,为了解决跨 JVM 并发访问的问题,就需要一种跨 JVM 的互斥机制来控制共享资源的访问,这时候就需要引入分布式锁。
锁分类
所刚才讲到演变场景中出现的锁可以总结为几类。
Java 重量级锁synchronize
java 轻量级锁 volatile
CAS 算法等
数据库行锁
redis锁
Zookeeper 锁
先进先出的分布式队列等
数据库版号
乐观锁
接下来我们一起看一下这些所有哪些是乐观锁,哪些是悲观锁。先理解一下什么是乐观锁,什么是悲观。
乐观锁
乐观锁就好比说是你是一个生活态度乐观积极向上的人,总是往最好的情况去想。比如您每次去获取共享数据的时候,会认为别人不会修改,所以不会上锁。但是在更新的时候,你会判断这期间有没有人去更新这个数据。有这么两种方法可以判断,一种是数据库的版本号机制,一种是CAS 算法实。
悲观锁
悲观锁是怎么理解呢?相比乐观锁而言,悲观锁是反过来的,总是假设最坏的情况,假设你每次拉数据的时候会被其他人修改,所以你在每次拿共享数据的时候,会对他加一把锁,等你使用完了,释放了锁,再给别人使用数据。因此我们可以看出 Java synchronize 是重量级锁,也是悲观锁。
Java 中通过 CAS 的思想来实现的类都是乐观锁的机制,比如 java.util.concurrent.atomic 类。数据库行所属于悲观锁,数据库版本号属于乐观锁,volatile 相比 synchronize 是一种轻量级的同步机制。
案例
我们刚刚聊过了,为什么要使用所有哪些锁?以及这些所有哪些区别?接下来更近了就不讲这里我想举一个非常常见的例子,在高并发情况下余额扣减,或者类似商品库存扣减的例子,也可以是资金账户的余额扣减,我们一起来看一下,如果是扣减操作会发生什么问题呢?
reduce(){
select total_amount from table_1
if (total_amount < amount) {
return failed .
}
//其他业务逻辑..
update total_amount = total_amount - amount;
}
很容易可以看到可能会发生的问题是扣减导致的超卖,也就是扣减成了负数。举个例子,比如我的库存数据有 100 个,并发情况下,第一笔请求卖出 100个,第二笔请求卖出 100 个,但是总库存只有 100 个就会发生,第二笔在获取库存的时候发现有 100 个,可是当时扣减的时候,已经被第一个请求扣减掉了,导致当前的库存扣减为负数。
解决的方案
synchronize 同步锁方案
这时候很容易想到最简单的方案,同步排它锁 synchronize ,但是排它所的缺点很明显,其中一个缺点是线程串行导致的性能问题,性能消耗比较大,另一个缺点是无法解决分布式部署情况下跨进程、跨 JVM 问题。
数据库行锁 select for update
既然我们再深入想一下,可能会想到。那用数据库行锁 select for update 来锁住这条数据,这种方案相比 synchronize 排它锁解决了跨进程的问题,但是依然有缺点,我们来一起看一下。其中一个缺点就是性能问题,在数据库层面 select for update 会一直阻塞直到事务提交。这里也是串行执行。
再来看第二个缺点,需要注意设置事务的隔离级别是 Read committed,否则并发情况下,另外的是无法看到其他的数据,依然会导致超卖问题。
缺点三是其实就在于容易打满数据库连接,现象是如果不小心在这个失误注解的方法类除了有数据库操作,还有第三方的接口交互动作的话,由于第三方接口交互都网络连接超时的可能性会导致这个事物的连接一直被阻塞,打满数据库连接。
来看看最后一个缺点,容易产生交叉死锁,如果多个业务的加锁顺序控制不好,就会发生 AB 两条记录的交叉死锁。
首先,事务1 和事务2 同时分别取得的记录1 和记录2 的排它锁,然后事务1 又取得了记录 2 的排它锁,这时候等待事务2释放记录2的排它锁,但是事务2 释放记录2 的前提是必须事物1 释放记录1 的排它锁,这样1和2两条记录就会因为相互锁等待产生死锁。
Redis 分布式锁
使用redis 分布式锁,前面的方案本质上是把数据库当做分布式锁来使用,所以同样的道理,Redis、Zookeeper 等都相当于数据库的一种锁。其实当遇到枷锁问题,代码本身无论是 synchronize 或者各种 Lock 使用起来都比较复杂,所以思路是把代码处理一致性的问题难题交给一个能够帮助你处理一致性的问题的专业组件,比如数据库,比如 Redis,比如 Zookeeper 等。
这里我们来讨论一下 Redis 做分布式锁这种方案的优缺点,引入 Redis 分布式锁可以避免大量对数据库排它锁的争用,提高系统的响应能力,但是 Redis 分布式锁也有一些小的缺点,我们一起来看一看 Redis 锁的使用以及应该注意的几个问题:
设置锁和设置超时时间的原子性。
不设置超时时间的缺点。
服务宕机或线程阻塞超时的情况。
超时时间设置不合理的情况。
Redis 加锁的命令是 setnx, 设置锁的过期的时间是expire,解锁的命令是del,但是 Redis 2.6.12之前的版本中,由于加锁命令和设置锁过清明的是两个操作,不具备原子性。
如果 setnx 命令设置完 key-value 之后,没有来得及使用 expire 命令来设置过期时间,当前线程挂掉了或者线程阻塞了,会导致当前线程设置的 key 一直有效,后续的线程无法正常使用 setnx 获取锁造成死锁,针对这个问题,Redis 2.6.12 以上的版本为 set 命令增加了可选的参数。可以在加锁的同时设置 key 的过期时间,保证了加锁和过期动作是原子性的,但是即使解决了 Redis 加锁和过期动作原子性的问题。
但是业务上同样会遇到一些极端的问题,比如分布式环境下,线程A 获取到了锁,但是在获取到锁之后,因为线程A 的业务代码耗时过长,导致锁的超时时间所自动失效,后续线程B 就意外的持有了锁之后,线程A 再次恢复执行,直接用 del 命令释放锁,这样就错误的将线程B 同样可以的锁误删除了,代码耗时过长还是比较常见的场景,假如你的代码中有外部通用的接口调用,就容易产生这样的场景。
刚才讲到的线程超时阻塞的情况,那么如果不设置超时时间,当然也不行。如果线程A 在持有锁的过程中突然服务当机了,这样锁就永远无法失效了。同样的也存在所超时时间设置是否合理的问题,如果设置锁持有时间过长,会影响性能,如果设置超时,时间过短,有可能业务阻塞没有处理完成,是否可以合理的设置锁的超时间,这是一个很不容易解决的问题。
不过有一个办法能解决这个问题,那就是续命锁,我们可以先给所设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间之后重新去设置这个锁的超时时间。续命锁的实现过程就是写一个守护线程,然后去判断对象锁的情况。过一段时间锁快失效的时候,再次进行续命加锁,但是一定要判断所的对象是否同一个,不能乱选。同样的主线程业务执行完了,守护线程也需要销毁,避免资源浪费。使用续命锁的方案相对比较而言更复杂。所以如果业务比较简单,可以根据经验类比,合理的设置所得超时时间就行。
数据库乐观锁
数据库乐观锁加锁的一个原则就是尽量想办法减少锁的范围,锁的范围越大,性能越差。数据库乐观锁就是把锁的范围减少到了最小,把之前的代码中的 update 修改成这样的形式。
reduce() {
select total_amount from table_1if (total_amount < amount) {
return failed.
}
//其他业务逻辑..
update total_amount = total_amount - amount ;
}
我们可以先看看修改前的代码是没有 where 条件的,修改后增加 where 条件。
update total_amount = total_amount - amount where total_amount>=amount;
判断总库存大于将被扣减的库存,如果更新条数返回 0,说明在执行过程中被其他线程抢先执行的扣减,并且避免了扣减为负数。但是这种方案还会涉及一个问题,如果在 update 之前的代码中以及其他的业务逻辑中,还有一些其他的数据库写操作的话,那这部分数据如何回滚呢?我的建议是这样的。你可以选择下面这两种写法,我们再来讨论第一个写法,先看利用事务回滚的写法,给业务方法增加事务方法,在扣减库存影响条数为 0 的时候扔出一个异常,这样 update 之前的业务代码也会回滚。
@Transactional
reduce() {
select total_amount from table_1if (total_amount < amount) {
return failed.
}
// business logic...
int num = update total_amount = total_amount - amount wheretotal_amount>= amount;
if(num==0) throw Exception;
}
我们再来讨论第二个写法,首先执行 update 业务逻辑。如果 update 执行成功了,再去执行 business logic 其他数据库的操作,这种方案是我相对比较建议的方案,在并发情况下对共享资源扣减操作可以使用这种方案。
reduce() {
// business logic...
int num = update total_amount = total_amount - amount wheretotal_amount>= amount;
if(num==1){
// business logic...
}else{
throw Exception;
}
}
但是这里需要引出一个问题,比如说万一其他业务逻辑中的业务因为特殊原因失败了该怎么办呢?比如说在扣减过程中服务 OOM了怎么办?我只能说这些非常极端的情况,比如突然宕机,中间数据都丢了,这种极少数的情况下只能人工介入,如果所有的极端情况都考虑到也不现实。我们讨论的重点是并发情况下共享资源的操作如何加锁的问题。
总结
如果你可以非常熟练的解决这类问题,第一时间肯定想到的是数据库版本号解决方案或者分布式锁的解决方案。但是如果你是一个初学者,相信你一定会第一时间考虑到 Java 中提供的同步锁或者数据库行锁,今天讨论锁的目的就是希望把这几种场景中的锁放到一个具体的场景中,逐步去对比和分析,让你能够更加全面体系的了解使用锁这个问题的来龙去脉。
最后
获取学习资料,进群:712334882