本来是不打算写这个文章但是在一个群里面发现又有群友遇到和我一样的问题不知道咋办
知识点
1、并发(勉强)
2、mysql MVCC原理
3、spring 事务机制
起因
这个话题是由最近一次对接第三方商城发现的,该商城执行流程很奇特,流程如下:
1、用户购买,三方平台调用本系统积分扣除接口,返回结果给三方。
2、三方回调本系统商品兑换接口,是否兑换成功,否单独调用三方失败处理接口(有步骤3回调),并返回现有接口结果给三方(有步骤3回调)。
3、三方回调用本系统商品兑换成功/失败接口(确认三方已经收到消息并处理)
ps:步骤2兑换流程 加锁——>查询订单是否存在——>扣积分——>插入订单——>减库存——>赠送金币——>释放锁(由于流程现在无论是否兑换成功都必须保存订单,所以不能在步骤2方法使用事务回滚)
这个流程总体看起来很怪,我也是第一次遇到这样的,不过即使觉得不合理也得按照人家的来。
问题
如果仔细看看上面执行流程就会发现步骤2会带来两次连续的回调,这个连续回调也引发了本文的问题。
在测试兑换失败场景时我这边要把扣的积分返还给用户,操作伪代码如下:
ServiceImpl:
@Transactional
public void dealOrderExchangeNotice(....){
RedisLock lock = null;
try{
lock=new RedisLock(bizId);
if (lock.lock()) {
//查询订单
IntegralShoppingOrder shoppingOrder = selectOne(bizId);
//shoppingOrder.getStatus()==1 代表订单扣积分成功 可以返还积分
if (shoppingOrder != null && shoppingOrder.getStatus() == 1) {
//返还积分
//更新订单状态为 4(订单失败)
}
}catch (Exception e) {
}finally {
if (lock != null) {
lock.unlock();
}
}
}
如果没有出现问题看着上面的代码感觉没有啥问题的.....
测试时发现每次都是给用户返还了两次积分(相当于花100送200了,这哪了得..),刚开始看上面的代码看了好久没有发现问题,加上log后查询服务器日志发现失败订单几乎在同一时间会收到两条回调信息,
(勉强算作一个高并发吧),两个请求都拿到了锁且shoppingOrder的getStatus()都是一样的,感觉到问题了出现重复读了.........
解决过程
两个请求都拿到了锁证明第一个回调请求已经执行完毕了,按道理应该将订单状态更新成4了第二个请求查询到的也应该是4,但是还是出现同样的值说明第二个请求查询时第一个没有提交事务。
这样明确出两个排查方向 重复读(mysql MVCC原理)、事务提交(spring 事务机制)。
mysql MVCC原理
mysql默认事务隔离级别是 RR(Repeatable Read,可重复读),事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。
MVCC的实现,是通过保存数据在某个时间点的快照来实现的。也就是说,不管需要执行多长时间,每个事务看到的数据是一致的。根据事务开始的时间不同,每个事物对同一张表,同一时刻看到的数据可能是不一样的。
由此可以确定第二个请求执行查询时第一个请求事务没有提交,两者的事务版本号是一样的所以查询的值是一样的,因此问题不在数据库了!
小知识:
第一个SELECT执行的时候,当前事务取到了系统版本号n(并不是begin的时候就生成版本号,而是执行事务内第一个语句时生成),系统版本号自增为n+1。此后,其他事务的更新操作能取到的系统版本号最小为n+1,所以当前事务再次SELECT将看不见它们的更新。
spring 事务机制
Spring 事务管理分为编程式和声明式两种。编程式事务指的是通过编码方式实现事务;声明式事务基于 AOP,将具体的逻辑与事务处理解耦。
声明式事务管理使业务代码逻辑不受污染,因此实际使用中声明式事务用的比较多。
小知识:
1、默认配置下 Spring 只会回滚运行时、未检查异常(继承自 RuntimeException 的异常)或者 Error。
2、@Transactional 注解只能应用到 public 方法才有效。
很明显我这边也是采用声明式事务,Aop自动提交事务是在dealOrderExchangeNotice代码块中的方法执行完毕后才执行事务提交工作
ps:在群里面讨论时有一个群友说事务提交是在finally执行之前,这个观点是错误的
因为这个还在一个群里面被人喷了讨论的话题老旧
从上面两个知识点结合之前看的《Mysql45讲》(需要,公众号回复‘Mysql45讲’),我画了一个执行图很清晰的说明了问题所在(不懂千万不要空想动手画一画可能马上明白了)
最后把上面的加锁代码转到controller层后重试没有出现多返积分的问题了
Controller:
public void dealOrderExchangeNotice(....){
RedisLock lock = null;
try{
lock=new RedisLock(bizId);
if (lock.lock()) {
S.dealOrderExchangeNotice(....);
}finally {
if (lock != null) {
lock.unlock();
}
}
}
ServiceImpl:
@Transactional
public void dealOrderExchangeNotice(....){
lock = null;
try{
//查询订单
IntegralShoppingOrder shoppingOrder = selectOne(bizId);
//shoppingOrder.getStatus()==1 代表订单扣积分成功 可以返还积分
if (shoppingOrder != null && shoppingOrder.getStatus() == 1) {
//返还积分
//更新订单状态为 4(订单失败)
}catch (Exception e) {
}
}
类似像这种写法也是错误的
@Service
@Transactional
public class OrderServiceImpl implements OrderService {
@Override
public synchronized int update(Integer id) {
...
...
...
}
}
总结
锁不要加载事务中
由于本人文笔水平有限,文中的描述可能有些不清晰,但是通过问题的排查让我体验到理论结合实际代码的快乐,理论可能不是很高深、很难懂,但是有时木有结合实际也会出现意想不到的问题。