本文是上一篇文章 分布式锁可以这么简单? 的续篇,主要是记录分析在封装过程中碰到的难点以及对应的解决方案。
注:阅读本文需要有一定的 面向切面编程 的基础。
源码
准备工作
要把问题暴露出来,需要先把部分代码注释一下。
1. 注释 TransactionEnhancerAspect 的注入代码
把 DistributionLockAutoConfiguration
中的以下几行代码注释掉:
2. DistributedLockAspect 不实现接口 Ordered
分布式锁注解不生效?
先来看一个测试用例:
public class TestItemService {
// 省略其他
@DistributedLock(
lockName = "#{#testItem.id}",
lockNamePre = "item",
checkBefore = "#{#root.target.checkbefore(#testItem)}"
)
@Transactional(rollbackFor = Throwable.class)
public Integer testInoperative(TestItem testItem) {
sleep(100L);
TestItem item = this.getById(testItem.getId());
Integer stock = item.getStock();
System.out.println(String.format("current thread: %s, current stock: %d", getCurrentThreadName(), item.getStock()));
if (stock > 0) {
stock = stock - 1;
item.setStock(stock);
this.saveOrUpdate(item);
} else {
stock = -1;
}
return stock;
}
public void checkbefore(TestItem testItem) {
TestItem item = this.getById(testItem.getId());
System.out.println(String.format("current thread: %s, check before. stock: %d", getCurrentThreadName(), item.getStock()));
}
}
public class DistributedLockTests {
@Test
public void testInoperative() {
Consumer consumer = testItem -> {
Integer stock = testItemService.testInoperative(testItem);
if (stock >= 0) {
System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
} else {
System.out.println(Thread.currentThread().getName() + ": sold out.");
}
};
commonTest(consumer);
}
}
启动测试用例,结果类似如下:
可以看到,所有线程都正常扣库存了,但全部跑完后,数据库的库存还剩下 7,这还得了?
其中 check before. stock: 8
是在方法 checkbefore
中打印的,该方法在获得锁之前就会被执行。
那为什么会出现这种情况呢?明明已经加了锁了呀。
在回答这个问题之前,需要知道一个数据库的知识点,即数据库事务的隔离级别。一共有4种隔离级别,分别为:读未提交(Read uncommitted)、读已提交(Read committed)、可重复读(Repeatable read)、串行读(Serializable),由于这不是本文的重点,这里就不展开说了。
因为本文使用的是 Mysql
,所以就拿它来说明。Mysql
的默认隔离级别为 可重复读,该隔离级别有什么特点呢?先看下面的图:(比较熟悉可以跳过这部分)
在数据库的可视化界面新开2个查询窗口,分别对应上图的 Session A
,Session B
,然后按步骤,一次在2个窗口中执行。根据执行的结果,可以看出,事务B
开启后,无论 事务A
如何操作,id = 1
的库存一直都为 10,直到 事务B
结束后,才能看到 事务A
提交的数据库操作结果。
明白了这点之后,我们再来看另一张图:
上面的图,是将上面测试用例的重点流程可视化后的结果(只画出前2个线程)。当有多个切面时,遵循的是先进后出的原则,比如上图中有2个切面,一前一后分别是 Transactional Aspect
、DistributedLock Aspect
,所以进入对应的方法时,会先执行 Transactional Aspect
的相关逻辑,再执行 DistributedLock Aspect
的相关逻辑。
上图中,比较有争议的地方是 步骤12,为什么获取到的库存是 8?线程1 明明已经把库存更新为 7,且事务已经提交。
其实,可以看到,线程2 在等待锁的时候,已经开启了一个新事务,再根据 可重复读 隔离级别的特点,可以轻松得出:无论线程1 怎么操作数据库并提交,无论增删改,只要 线程2 的事务没有提交,对于 线程2 来说都是不可见的,所以 线程2 获取到的库存是 8。
这样一分析下来,很明显,获得锁的逻辑不能放在 事务开启 之后,即 DistributedLock Aspect
要在 Transactional Aspect
之前,这样一来,都是拿到分布式锁后,才去开启事务,这样才不会出现上面测试用例的情况。
问题找到了,那要怎么保证这两个切面的先后顺序呢?Spring AOP
已经考虑到这点,可以让切面类通过实现接口 org.springframework.core.Ordered
,该接口需要实现一个方法 int getOrder()
,Spring AOP
会根据返回的数值去对切面进行排序,数值越小,优先级越高,即越先执行。
所以我们只需要在构造 DistributedLock Aspect
的时候,动态获取 Transactional Aspect
的 order
数值,然后返回一个比它小的数值即可。问题来了,如何动态获取 Transactional Aspect
的 order
数值?要解决这个问题,需要对 Spring 事务
的源码有一定了解,文末有分析源码的相关文章,有兴趣的可以去研究研究。
Spring 事务
的开启,涉及到一个注解 @EnableTransactionManagement
,该注解有一个属性 order
,该 order
就是我们想要的,下面来看看如何在 Spring 容器
启动阶段,动态获取 order
数值。
public class DistributedLockAspect implements ApplicationContextAware, Ordered {
// ... 省略其他
public int getOrder() {
try {
int minOrder = Integer.MAX_VALUE;
Map beansWithAnnotation = applicationContext.getBeansWithAnnotation(EnableTransactionManagement.class);
for (Map.Entry entry : beansWithAnnotation.entrySet()) {
Object value = entry.getValue();
Class> proxyTargetClass = ClassUtils.getUserClass(value);
EnableTransactionManagement annotation = proxyTargetClass.getAnnotation(EnableTransactionManagement.class);
int order = annotation.order();
minOrder = Math.min(order, minOrder);
}
return Math.min(0, minOrder) - 1;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
简单解释一下上面的代码,首先,applicationContext.getBeansWithAnnotation
可以获取 Spring
容器中,所有被给定注解标记过的 Bean
;将这些 Bean
找出来后,接着遍历所有 Bean
,因为 Spring
容器中的 Bean
大多都是代理对象,所以这里使用 ClassUtils.getUserClass
来获取所代理的类对象,最后即可找出标记 EnableTransactionManagement
注解的时候,设置的 order
数值,一般都为默认值。最后,我们只要保证比它的 order
数值小并返回就行。
将上面的代码补充到 DistributedLockAspect
后,再重新启动上面的测试用例,可以看到类似如下:
终于正常了。
锁被动释放后,数据无法正常回滚
先来看一个新的测试用例:
public class TestItemService {
@DistributedLock(
lockName = "#{#testItem.id}",
lockNamePre = "item",
checkBefore = "#{#root.target.checkbefore(#testItem)}"
)
@Transactional(rollbackFor = Throwable.class)
public Integer testRollbackWhenLostTheLock(TestItem testItem) {
TestItem item = this.getById(testItem.getId());
Integer stock = item.getStock();
int ci = i.getAndDecrement();
if (ci == 10) {
System.out.println(String.format("current thread: %s, got the lock first, now sleep a few seconds.", getCurrentThreadName()));
sleep(6000L);
}
System.out.println(String.format("current thread: %s, current stock: %d", getCurrentThreadName(), item.getStock()));
if (stock > 0) {
stock = stock - 1;
item.setStock(stock);
this.saveOrUpdate(item);
} else {
stock = -1;
}
return stock;
}
}
public class DistributedLockTests {
@Test
public void testRollbackWhenLostTheLock() {
Consumer consumer = testItem -> {
Integer stock = testItemService.testRollbackWhenLostTheLock(testItem);
if (stock >= 0) {
System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
} else {
System.out.println(Thread.currentThread().getName() + ": sold out.");
}
};
commonTest(consumer);
}
}
启动测试用例,类似如下:
不出意外的话,这时数据库中的库存为 7,很明显,这又有问题。
上面的测试用例是什么逻辑呢?第1个获得锁的线程(线程1),获得数据后,休眠了 6s
,而锁的有效时间为 5s
,也就是说休眠恢复后,锁已经被动释放,且被其他线程获得,这个时候 线程1 还以为锁还是它所持有,然后继续剩余的流程,最后把库存更新为 7,但其实这个时候的库存已经为 0 了,所以这个时候如果把数据更新到数据库,那大概率是错误的。
针对这种情况,应该怎么办呢,既然提交的数据已经不可信了,那肯定是不能让它正常 commit
,必须让它回滚掉。
我们都知道,加上注解 @Transactional(rollbackFor = Throwable.class)
后,如果方法执行过程中抛异常的话,会回滚数据。因此,我们可以借助这一特性,进行一系列改造,让其在 commit
之前,先检查一下锁是否由当前线程持有,如果是,则可以放心 commit
,如果不是,那就大胆回滚吧。
聪明的你,这时候可能注意到了控制台打印的一行文字:锁释放失败, 当前线程不是锁的持有者
,如果把打印逻辑换成 抛异常,这样能否达到我们想要的效果呢?可以尝试一下,按照下图进行改造,改造的类名为 RedisDistributedLockTemplate
:
再次运行测试用例,可以看到类似如下:
异常倒是能正常抛出,但看了下数据库,好像没达到预想中的效果,库存还是为 7,为啥呢?
在解决前一个问题的时候,我们已经保证了一点—— 分布式锁定切面的优先级高于 事务切面,加了这个改造后,会对数据的回滚有什么影响呢?先来看一张图:
这是改造后的流程图,可以看到 释放锁 的时候,事务其实已经 commit
了,那这个时候去抛异常,显然是没办法触发回滚的,那怎么办呢?我们先来简单分析一下:
这两个切面的顺序肯定是不能调整的,又必须得在事务 commit
前判断锁是否由当前线程持有,然后两个切面的逻辑也是非常独立的,既然这样,直接改 Transactional Aspect
的相关源码肯定是行不通的,那就只能找找 Transactional Aspect
有没有什么可以扩展的地方了。
在解决这个问题之前,又得先了解几个知识点,一个是 当有多个切面的时候,每个切面的 Advice
的详细执行顺序,这一点,下面的图就能很明显看出来了(其中蓝色的 Method
是目标业务方法):
如果把 DistributedLock Aspect
和 Transactional Aspect
套进去的话,切面A 就是 DistributedLock Aspect
,切面B 就是 Transactional Aspect
。
另一个是 事务是在什么时间点开启和结束的,这里先直接说答案,是在 @Arround
阶段就开启的,而且也是在该阶段 commit
的;
最后一个是 Spring 事务 提供的一个接口 TransactionSynchronization
,这个接口有一个方法 void beforeCommit(boolean readOnly)
,该方法会在事务提交前被调用,当然,前提是 在事务开启后需要将该接口的实现类注册到 TransactionSynchronizationManager
中,注册成功后会被存储在一个 ThreadLocal
中,且仅限当前事务有效。详细的可研究推荐阅读给出的参考链接:Spring 事务源码分析。
将这3个知识点关联起来,基本就可以得出一个解决方案:只有在下图圈起来的 @Before Advice
将 TransactionSynchronization
的实现类注册到 TransactionSynchronizationManager
中。具体原因可参考附录:为什么增强逻辑只能在 Before Advice
。
接下来看具体实现方法。
TransactionEnhancerAspect
定义一个切面类
@Aspect
public class TransactionEnhancerAspect {
private final DistributedLockTemplate distributedLockTemplate;
public TransactionEnhancerAspect(DistributedLockTemplate distributedLockTemplate) {
this.distributedLockTemplate = distributedLockTemplate;
}
@Before(value = "@annotation(transactional)")
public void doBefore(JoinPoint jp, Transactional transactional) {
// 是否开启了 可写的事务
if (!isWithinWritableTransaction()) {
return;
}
DistributedLock annotation = PointCutUtils.getAnnotation(jp, DistributedLock.class);
if (annotation == null) {
return;
}
// 该 context 在创建锁的时候初始化的,并把 lock 对象设置进去
DistributedLockContext context = DistributedLockContextHolder.getContext();
Object lock = context.getLock();
UnlockFailureProcessor unlockFailureProcessor = new UnlockFailureProcessor(distributedLockTemplate, lock);
TransactionSynchronizationManager.registerSynchronization(unlockFailureProcessor);
}
/**
* 是否在一个可写的事务中
*
* @return
*/
private boolean isWithinWritableTransaction() {
boolean isTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
boolean isTransactionReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
return isTransactionActive && !isTransactionReadOnly;
}
}
UnlockFailureProcessor
定义一个 TransactionSynchronization
实现类
public class UnlockFailureProcessor implements TransactionSynchronization {
private final Object lock;
private final DistributedLockTemplate distributedLockTemplate;
public UnlockFailureProcessor(DistributedLockTemplate distributedLockTemplate, Object lock) {
this.distributedLockTemplate = distributedLockTemplate;
this.lock = lock;
}
@Override
public void beforeCommit(boolean readOnly) {
boolean heldByCurrentThread = distributedLockTemplate.isHeldByCurrentThread(lock);
if (!heldByCurrentThread) {
ResponseEnum.LOCK_NO_MORE_HOLD.assertFailWithMsg("释放锁时, 当前线程不是锁的持有者");
}
}
}
可以看到,在 commit
之前,判断是否当前线程持有锁,如果不是,抛异常,让数据回滚。
其他
上面介绍的类中,涉及到的 DistributedLockContextHolder
、DistributedLockContext
因为比较简单也不影响整体代码逻辑,就不贴源码了。
最后,只需要在 DistributionLockAutoConfiguration
配置类中,加入 TransactionEnhancerAspect
的注入 Spring 容器
逻辑。
测试验证
经过这一番改造,基本即可以解决问题,下面再运行一下测试用例。(最好把之前在 RedisDistributedLockTemplate
中添加的抛异常去掉,不然可能会看到抛2个异常)
启动测试用例,可以看到类似如下:
可以看到,第一个拿到锁的线程,最后抛异常了,符合预期,再看看数据库的库存,如果不出意外,看到的是 0,那就代表成功了。
结语
至此,已经把我认为比较有挑战的难点列出,并拆解、分析、解决了,至于其他,各位看官如果觉得哪里有疑惑的,可以评论留言,一起讨论、学习。
谢谢~~
推荐阅读
统一异常处理介绍及实战
分布式锁可以这么简单?(上篇)
Spring 事务源码分析
附录
为什么增强逻辑只能在 Before Advice
源码 TransactionAspectSupport#invokeWithinTransaction
上图中,第一个圈中的地方,是创建一个新事务(有必要的话);
第二个圈中的地方,注释的内容简单翻译一下,这里是一个 Around Advice
,当前的位置只是拦截器链中的某一个,需要继续触发下一个拦截器;
第三个圈中的地方,是目标方法返回后, commit
事务。
所以,Transactional Aspect
在 around advice
阶段完成后,事务的相关逻辑基本都 完成了,之后的 After Advice
、AfterReturning Advice
这2个阶段都已不在事务中了,所以能注册 TransactionSynchronization
的阶段就只有 Before Advice
了。