前两天看了大佬 why 的一篇博客 几行烂代码,我赔了16万。 感觉挺有意思的,直接把spring里面的Transaction直接撕开了一道口子,干货满满,受益匪浅!
这个问题的起源是Segmentfault上面的一个问题,描述是这样的。
这个问题,ReentrantLock + Transaction 结果还是超卖了!
看到这个问题的一瞬间,我首先想到的是,哦,现在都玩分布式了,你还用ReentrantLock?活该你超卖!
使用分布式锁不就好了?安全又可靠!
第二个问题就是,你这里加了事务,哪怕ReentrantLock不起作用,MySQL里面的innerDB引擎还有默认的行锁啊,直接操作扣除商品数量,咋还超卖了呢?
看了下具体的代码实现,然后:
先看下我的想法:
@Override
@Transactional(rollbackFor = Exception.class)
public Integer saleGoods(Integer num) {
lock.lock();
try {
// num 是扣减数量,假设就一条数据
//修改库存
Integer flag = goodsMapper.updateGoodsNum(num);
// 插入数据
if (flag > 0 && num >= 0){
orderMapper.insert(Order.builder().name("保温杯")
.num(num)
.build()
);
return num;
}
} finally {
lock.unlock();
}
return null;
}
修改操作updateGoodsNum:
@Update("update goods set count = count-#{num} where id = 1 and count >= #{num}")
Integer updateGoodsNum(Integer num);
这样哪怕锁失效,也不会出现超卖啊,毕竟行锁在那兜着呢!这边事务没提交,想修改这条数据,没戏!
然而,现实却是这样的:
Goods goods = goodsMapper.selectByPrimaryKey(1);
Integer count = goods.getCount();
if (count > 0 && (count - num >0)){
goods.setCount(count - num);
goodsMapper.updateByPrimaryKeySelective(goods);
orderMapper.insert(Order.builder().name("保温杯")
.num(num)
.build()
);
return num;
好家伙!先把数量加载到JVM内存,修改设置,然后再去修改!这就相当于在应用程序中缓存了一份数据,这个时候如果缓存数据和数据库不一致,那肯定...凉凉啊!
我们知道,spring事务分为声明式事务和编程式事务,一般情况下,我们都是直接加个@Transactional注解,然后就开启事务了。
那事务什么时候开始什么时候结束呢?why大佬分析的很明确了,源码就不在这里扯了。
如果不开启事务,那么每个SQLSession都会重新获取一个连接,然后作为一个事务进行处理,然后直接提交。
事务开启之后,自动提交自然是关闭了,声明式事务中,当调用数据库连接的时候,事务开启,进行数据操作。
那么什么时候关闭呢?当然是方法执行完毕啊!AOP么,进入方法之前,自动提交其实就关闭了,然后离开方法之后,事务结束,确定是否commit!
那么在 lock.unlock(); 之后,就有的操作喽,锁就失效了呗,然后事务没提交,goods的库存数据还缓存在应用程序的缓存中,然后新的线程重新去获取库存,并发问题不就来了。
画个图看看:
蓝色圈住的那段时间,当前事务的库存修改还没有提交,再有事务进来,查询的库存肯定是之前未修改的库存,再去扣减,肯定是有问题了。
接着看呗,上面说到了使用ReentrantLock!分布式环境,用啥ReentrantLock啊,分布式锁他不香么?
改!用分布式锁!
/**
* desc: 简单定义个redis配置
*/
@Configuration
public class MyRedissonConfig {
//注册RedissonClient对象
@Bean(destroyMethod="shutdown")
public RedissonClient redisson() throws Exception {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer saleGoods(Integer num) {
RLock lock = redissonClient.getLock("myGooodsLock");
lock.lock();
try {
// num 是扣减数量,假设就一条数据
//修改库存
Goods goods = goodsMapper.selectByPrimaryKey(1);
Integer count = goods.getCount();
System.out.println("剩余数量"+count);
if (count > 0 && (count - num >= 0)) {
goods.setCount(count - num);
goodsMapper.updateByPrimaryKeySelective(goods);
orderMapper.insert(Order.builder().name("保温杯")
.num(num)
.build()
);
System.out.println("出售保温杯"+ num + "个");
} else {
System.out.println("库存不足");
}
} finally {
lock.unlock();
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
改成分布式锁又怎样?逻辑图还不是和之前一样?当然,分布式锁的话上锁解锁肯定是没有ReentrantLock快的,所以并发就没有那么尖锐,加个睡眠时间处理,问题依然是可以重现!
所以么,刨根问底就很有必要了,你看这个,幂等性方法没用,数据加载缓存;事务+锁 位置错乱;真是完美避开了行锁,ReentrantLock锁以及事务的处理。如果这里使用声明式事务,那还能有啥子问题?
@Override
public Integer saleGoods(Integer num) {
lock.lock();
try{
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
//修改库存
Goods goods = goodsMapper.selectByPrimaryKey(1);
Integer count = goods.getCount();
System.out.println("剩余数量"+count);
if (count > 0 && (count - num >= 0)) {
goods.setCount(count - num);
goodsMapper.updateByPrimaryKeySelective(goods);
orderMapper.insert(Order.builder().name("保温杯")
.num(num)
.build()
);
System.out.println("出售保温杯"+ num + "个");
} else {
System.out.println("库存不足");
}
}
});
} finally {
lock.unlock();
}
return null;
}
把事务包裹在 锁的中间就行了呗,事务提交了,锁才解锁,事务的并发问题?独占锁已经帮忙解决了!当然,如果是分布式部署的话,最好还是使用分布式锁处理。
当然,编程式事务不想使用的话,就重新抽出一个方法呗,当然,要注意下,如果在同一个方法中,小心AOP代理不生效,容器注入,方法调用,还是要牢记滴。
来碗鸡汤吧:
勤学似春起之苗,不见其增,日有所长;辍学如磨刀之石,不见其损,日有所亏。 ---陶渊明
no sacrifice,no victory~