共计 4112 个字符,预计需要花费 11 分钟才能阅读完成。
1. 背景
最近在批改 Seata
线程并发的一些问题,把其中一些经验总结给大家。先简略形容一下这个问题,在 Seata
这个分布式事务框架中有个全局事务的概念,在大多数状况下,全局事务的流程根本是程序推动不会呈现并发问题,然而当一些极其的状况下,会呈现多线程拜访导致咱们全局事务处理不正确。如上面代码所示:在咱们全局事务 commit
阶段,有一个如下代码:
` if (status == GlobalStatus.Begin) {
globalSession.changeStatus(GlobalStatus.Committing);
}`
代码有些省略,就是先判断 status 状态是否 Begin 状态,而后扭转状态为 Committing。
在咱们全局事务 rollback 阶段,有一个如下代码:
if (status == GlobalStatus.Begin) {globalSession.changeStatus(GlobalStatus.Rollbacking);
}
同样的也省略了局部代码,这里先判断 status 状态是否为 begin,而后扭转为 Rollbacking。这里再 Seata 的代码中并没有做一些线程同步的伎俩,如果这两个逻辑同时执行(个别状况下不会,然而极其状况下可能会呈现),会让咱们的后果呈现不可意料的谬误。而咱们所要做的就是解决这种极其状况下来的并发呈现的问题。
2. 乐观锁
对于这种并发呈现问题我置信大家第一工夫想到的必定是加锁,在 Java 中咱们咱们个别采纳上面两个伎俩进行加锁:
Synchronized
ReentrantLock
咱们能够利用 Synchronized 或者 ReentrantLock 进行加锁,能够将代码批改成上面的逻辑:
synchronized:
`synchronized(globalSession){
if (status == GlobalStatus.Begin) {globalSession.changeStatus(GlobalStatus.Rollbacking);
}
}
`
ReentrantLock 进行加锁:
`reentrantLock.lock();
try {
if (status == GlobalStatus.Begin) {globalSession.changeStatus(GlobalStatus.Rollbacking);
}
}finally {reentrantLock.unlock();
}
`
对于这种加锁比较简单,在 Seata 的 Go-Server 中目前是这样实现的。然而这种实现场景疏忽了咱们下面所说的一种状况,就是极其状况下,也就是有可能 99.9% 的状况下可能不会呈现并发问题,只有 %0.1 的状况可能导致这个并发问题。尽管咱们乐观锁一次加锁的工夫也比拟短,然而在这种高性能的中间件中还是不够,那么就引入了咱们的乐观锁。
3. 乐观锁
一提起乐观锁,很多敌人都会想到数据库中乐观锁,设想一下下面的逻辑如果在数据库中,并且没有利用乐观锁去做,咱们会有如下的伪代码逻辑:
`select * from table where id = xxx for update;
if(status == begin){
//do other thing
update table set status = rollbacking;
}
`
上述代码在咱们很多的业务逻辑中都能看见,这段代码有两个小问题:
1,事务较大,因为咱们一上来就对咱们数据加锁,那么必然在一个事务中,咱们的查问和更新之间如果交叉了一些比拟耗时的逻辑那么咱们的事务就会导致较大。因为咱们的每一个事务都会占据一个数据库连贯,那么在流量较高的时会很容易呈现数据库连接池不够的状况。
2,锁定数据工夫较长,在咱们整个事务中都是对这条数据加了行锁,如果有其余事务想对这个数据进行批改那么会长工夫阻塞期待。
所以为了解决下面的问题,在很多如果竞争不大的场景下,咱们就采纳了乐观锁的办法,咱们在数据库中加一个字段 version 代表着版本号,咱们将代码批改成如下所示:
`select * from table where id = xxx ;
if(status == begin){
//do other thing
int result = (update table set status = rollbacking where version = xxx);
if(result == 0){throw new someException();
}
}
`
这里咱们的查问语句不再有 for update,咱们的事务也只放大到 update 一句,咱们通过咱们第一句查问进去的 version 来进行判断,如果咱们的更新的更新的行数为 0,那么就证实其余事务对他进行了批改。这里能够抛出异样或者做一些其余的事。
从这里能够看出咱们应用乐观锁将事务较大,锁定较长这两个问题都解决,然而对应而来的老本就是如果更新失败咱们可能就会抛出异样或者做一些其余补救的措施,而咱们的乐观锁在执行业务之前都曾经限制住了。所以咱们这里应用乐观锁肯定只能在对某条数据并发解决的状况比拟小的状况下。
3.1 代码中的乐观锁
咱们下面讲述了在数据库中的乐观锁,很多人就在问,没有数据库,在咱们代码中怎么去实现乐观锁呢?相熟 synchronized 的同学必定晓得 synchronized 在 Jdk1.6 之后对其进行了优化,引入了锁收缩的一个模型:
1,偏差锁: 顾名思义偏差某个线程的锁,实用于某个线程能长期获取到该锁。
2,轻量级锁:如果偏差锁获取失败,那么会应用 CAS 自旋来实现,轻量级锁实用于线程交替进入临界区。
3,重量级锁:自旋失败之后,会采取重量级锁策略咱们线程会阻塞挂起。
下面的级种锁模型中轻量级锁所实用的线程交替进入临界区很适宜咱们的场景,因为咱们的全局事务一般来说不会是某个单线程始终在解决该事务(当然也能够优化成这个模型,只是设计会比较复杂),咱们的全局事务再大多数状况下都会是不同线程交替进入解决这个事务逻辑,所以咱们能够借鉴轻量级锁 CAS 自旋的思维,实现咱们代码级别的自旋锁。这里也有敌人可能会问为什么不必 synchronized 呢?这里通过实测在交替进入临界区咱们本人实现的 CAS 自旋性能是最高的,并且 synchronized 没有超时机制,不不便咱们解决异常情况。
`class GlobalSessionSpinLock {
private AtomicBoolean globalSessionSpinLock = new AtomicBoolean(true);
public void lock() throws TransactionException {
boolean flag;
do {flag = this.globalSessionSpinLock.compareAndSet(true, false);
}
while (!flag);
}
public void unlock() {this.globalSessionSpinLock.compareAndSet(false, true);
}
}
// method rollback
void rollback(){
globalSessionSpinLock.lock();
try {if (status == GlobalStatus.Begin) {globalSession.changeStatus(GlobalStatus.Rollbacking);
}
}finally {globalSessionSpinLock.unlock();
}
}
`
下面咱们用 CAS 简略的实现了一个乐观锁,然而这个乐观锁有个小毛病就是一旦呈现竞争不能收缩为乐观锁阻塞期待,并且也没有过期超时,有可能大量占用咱们的 CPU,咱们又持续进一步优化:
`public void lock() throws TransactionException {
boolean flag;
int times = 1;
long beginTime = System.currentTimeMillis();
long restTime = GLOBAL_SESSOION_LOCK_TIME_OUT_MILLS ;
do {restTime -= (System.currentTimeMillis() - beginTime);
if (restTime <= 0){throw new TransactionException(TransactionExceptionCode.FailedLockGlobalTranscation);
}
// Pause every PARK_TIMES_BASE times,yield the CPU
if (times % PARK_TIMES_BASE == 0){
// Exponential Backoff
long backOffTime = PARK_TIMES_BASE_NANOS << (times/PARK_TIMES_BASE);
long parkTime = backOffTime < restTime ? backOffTime : restTime;
LockSupport.parkNanos(parkTime);
}
flag = this.globalSessionSpinLock.compareAndSet(true, false);
times++;
}
while (!flag);
}
`
下面的代码做了如下几个优化:
引入了超时机制,一般来说一个要做好这种对临界区域加锁肯定要做好超时机制,尤其是在这种对性能要求较高的中间件中。
引入了锁收缩机制,这里没循环肯定次数如果获取不到锁,那么会线程挂起 parkTime 工夫,挂起之后又持续循环获取,如果再次获取不到,此时咱们会对咱们的 parkTime 进行指数退却模式的挂起,将咱们的挂起时间逐步增长,直到超时。
总结
从咱们对并发管制的解决来看,想要达到一个目标,要实现它办法是有多种多样的,咱们须要依据不同的场景,不同的条件,抉择适合的办法,抉择最高效的伎俩来实现咱们的目标。本文没有对乐观锁的原理做太多的论述,这里有趣味的能够下来自行查阅材料,读完本文如果你只能记住一件事,那么请记住实现线程并发平安的时候别忘记思考乐观锁。
学习更多 Java 基础知识能够退出我的:Java 学习园地,更适宜小白。