在我们日常的编写的业务代码中经常会出现多个线程同时运行一段代码或者操作共同数据的情况,这时就会存在“线程安全”问题(多个线程同时运行同一段代码,如果每次运行和单线程运行的结果相同,就是线程安全的),也就是在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。在数据库中MYSQL默认的事务处理级别是’REPEATABLE-READ’,也就是可重复读,这算是比较高的事务隔离级别了(这里暂时避而不谈事务的一些特性),但在大规模并发场景,使用MYSQL会影响并发效率,不推荐使用。本文是以“抢购”为场景,出现“超发”的问题。我们经常会听说有些电商搞抢购活动,买家成功拍下后,商家不承认订单有效,拒绝发货,这可能并不代表商家是“奸商”,而是系统技术层面超发的情况导致。
超发原因:
假设某个抢购活动中,一共准备了10000个商品,在最后还剩一件商品的时候,系统发来多个并发请求,这批请求读到的商品数都是还剩1个,继而都通过了余量判断,最终导致超发。
在上面这张图中,就导致用户B也抢购成功,多让一个人获得了商品,这种情况在高并发的情况下非常容易出现。
**
解决方案
**
1、悲观锁思路
悲观锁,就是对抢购流程进行加锁的机制,同一时刻只有一个线程可获得该锁,其他请求发来时就必须等待。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.StampedLock;
public class FlashSale {
private static volatile int count = 10000;
private final Lock lock = new ReentrantLock();
private final Lock fairLock = new ReentrantLock(true);
private final StampedLock stampedLock = new StampedLock();
//悲观锁机制
public String pessimisticPurchase(String username){
lock.lock();
try {
if(count > 0){
process(username);
count--;
return "success";
}else{
return "fail";
}
}finally{
lock.unlock();
}
}
//此方法为抢购成功的业务逻辑
public void process(String username){
}
//此方法为抢购失败时的回滚方法
public boolean rollback(String username){
return true;
}
}
虽然采用“悲观锁”机制解决了线程安全问题,但是,我们面对的场景是“高并发”,每个请求都要去等待锁释放才能进入,某些线程可能永远都没机会抢到这个锁,这样请求就会死在那里。同时,这种请求会很多,瞬间增大了系统平均响应速度(其实也就是因为加了同步锁改成了单线程导致,由原先多核执行改成了单核依次执行),结果可能导致系统连接数被耗尽,陷入异常。
2、FIFO队列思路
如果我们稍晚修改一下上面的场景,我们将请求放到队列中,采用FIFO(first input first out,先进先出)原则,这样的话就不会导致某些请求永远获取不到锁(其实看到这里也是有点强行将多线程变成单线程运行的感觉,哈哈)
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.StampedLock;
public class FlashSale {
private static volatile int count = 10000;
private final Lock lock = new ReentrantLock();
private final Lock fairLock = new ReentrantLock(true);
private final StampedLock stampedLock = new StampedLock();
//FIFO机制
public String fifoPurchase(String username){
if(count>0) {
try {
if (fairLock.tryLock(10, TimeUnit.SECONDS)) {
process(username);
count--;
return "success";
}else{
return "fail";
}
} catch (InterruptedException e) {
return "fail";
}
}else{
return "fail";
}
}
//此方法为抢购成功的业务逻辑
public void process(String username){
}
//此方法为抢购失败时的回滚方法
public boolean rollback(String username){
return true;
}
}
这段代码中我使用的是new ReentrantLock(true)构造方法,并且使用的是tryLock(long time, TimeUnit unit)进行加锁,这样能保证每个线程获取的是公平锁(也就是按照请求顺序放到锁的等待队列中,后续依次获取锁执行同步代码块)。然而,我们解决了锁的问题是采用“先进先出”的队列方式来处理的。但是,我们面对的场景是高并发,因为请求很多,有可能一瞬间队列内存“撑爆”,然后系统又会陷入异常状态,或者可以设计一个极大的内存队列也是一种解决方案,但是系统处理队列中每个请求速度远远无法赶上“疯狂涌入”的请求速度,还是会使队列中数目越来越多,最终导致Web平均相应速度大幅下降,系统依然可能陷入异常。
3、乐观锁思路
这时,我们可以讨论一下“乐观锁”的思路。乐观锁就是相对于悲观锁采取更为宽松的加锁机制,一般都是通过版本号来实现的。具体实现就是:每个请求都有资格去修改数据,修改前会获得一个版本号,修改时如果版本号变了则回滚操作,没变则修改成功,这样我们就不需要考虑队列的问题了,不过他会增加CPU开销(也就是CPU多核同时执行这批抢购线程),但是综合来说是个不错解决方法。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.StampedLock;
public class FlashSale {
private static volatile int count = 10000;
private final Lock lock = new ReentrantLock();
private final Lock fairLock = new ReentrantLock(true);
private final StampedLock stampedLock = new StampedLock();
//乐观锁机制
public String optimisticPurchase(String username){
if(count > 0){
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
process(username);
count--;
if(stampedLock.validate(stamp)){
stampedLock.unlockRead(stamp);//释放读锁,在这里其实就是修改一下版本号
return "success";
}else{
rollback(username);
return "fail";
}
}else{
return "fail";
}
}
//此方法为抢购成功的业务逻辑
public void process(String username){
}
//此方法为抢购失败时的回滚方法
public boolean rollback(String username){
return true;
}
}
这里采用的是JAVA自带的StampedLock进行版本号的获取和更改(也可以自行设计),使用tryOptimisticRead()获取一个乐观读锁,然后进行抢购业务逻辑处理,之后用validate(long stamp)判断当前版本号是否改变,如果没改变则修改成功,改变了则回滚操作。但是依然可能导致一个问题:
如上图,假如商品数还剩2个,此时A、B、C三个请求同时通过剩余量判断并获取相同的版本号,此后A请求进行完业务逻辑处理判断版本号符合并修改之后,那么这时候商品数还剩1个,B或C再进行版本号判断显然是不符合的,进而回滚,最终导致即便还有剩余商品也会导致客户抢购失败。其实通过乐观锁思路对于很多场景是能够兼顾数据安全和并发效率的,只不过在抢购场景中还存在这样一个漏洞。
4、采用乐观锁中加悲观锁思路
既然上述问题是在版本号判断时出的问题,那么我们可以直接将商品剩余数作为版本号,并对判断版本号之后的代码块进行加锁
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.StampedLock;
public class FlashSale {
private static volatile int count = 10000;
private final Lock lock = new ReentrantLock();
private final Lock fairLock = new ReentrantLock(true);
private final StampedLock stampedLock = new StampedLock();
//乐观锁改进版
public String purchase(String username){
if(count > 0){
process(username);
lock.lock();
try {
if (count > 0) {
count--;
return "success";
} else {
rollback(username);
return "fail";
}
}finally{
lock.unlock();
}
}else{
return "fail";
}
}
//此方法为抢购成功的业务逻辑
public void process(String username){
}
//此方法为抢购失败时的回滚方法
public boolean rollback(String username){
return true;
}
}
上述代码实现方案是先进行余量判断然后进行业务逻辑处理,然后把再次判断商品余量和更新商品数量作为同步代码块,其实这个思路的本质还是加了相对宽松的悲观锁实现的。
所以,正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常。
参考文献:1、https://www.cnblogs.com/zqyanywn/p/8663394.html
2、https://www.cnblogs.com/weixuqin/p/11424688.html
3、https://www.cnblogs.com/lr393993507/p/5909804.html