这里以一个最基础的库存问题引入:在高并发下下单会造成库存数据异常情况。
数据表:就一个最基础的库存表和一个基础的数据。
2. 新建SpringBoot2.7.3项目并引入相关依赖:
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
<version>3.5.1version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
dependencies>
public interface StockMapper extends BaseMapper<Stock> {
}
@Service
public class StockServiceImpl implements StockService {
@Resource
private StockMapper stockMapper;
@Override
public Integer deStock() {
Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>()
.eq("product_code", "1001"));
if (!Objects.isNull(stock)
&& stock.getCount() > 0){
stock.setCount(stock.getCount() -1);
stockMapper.updateById(stock);
}
return stockMapper.selectById(stock.getId()).getCount();
}
}
controller
@RestController
@RequestMapping("/stock")
public class StockController {
@Resource
private StockService stockService;
@GetMapping
public String deStock(){
return "库存剩余:" + stockService.deStock();
}
}
这里直接不测试了,肯定可以解决。
修改service减库存方法:
synchronized
@Override
public synchronized Integer deStock() {
Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>()
.eq("product_code", "1001"));
if (!Objects.isNull(stock)
&& stock.getCount() > 0){
stock.setCount(stock.getCount() -1);
stockMapper.updateById(stock);
}
return stockMapper.selectById(stock.getId()).getCount();
}
ReentrantLock
显示锁
@Override
public Integer deStock() {
ReentrantLock reentrantLock = new ReentrantLock();
try {
reentrantLock.lock();
Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>()
.eq("product_code", "1001"));
if (!Objects.isNull(stock)
&& stock.getCount() > 0){
stock.setCount(stock.getCount() -1);
stockMapper.updateById(stock);
}
return stockMapper.selectById(stock.getId()).getCount();
}finally {
reentrantLock.unlock();
}
}
JVM锁缺陷:
只有是单例的stockService
对象下才能保证锁进行成功,在多例情况下每个对象都拥有自己的锁,不需要等待其他线程释放锁就可以执行。
在开启spring事务的情况下锁也可能(有一定几率)失效:由于spring的锁是使用AOP的方式来进行增强,如果同时A、B两个用户分别发送两个请求事务也会开启两个,但是由于只能一个用户(线程)才能够获得锁,完成之后才会释放锁;此时A用户的事务并没有提交,但是B用户已经可以获取当前方法的锁,那么就会造成一个数据不一致的问题,B获取的库存是A提交前的数量。如图:
当然可以通过设置Spring事务中的隔离级别为读未提交来解决,但这种隔离级别就完全不满足系统需求了。
集群模式下:在不同的服务中的stockService
肯定也不是一个相同的对象,存在与第一中失效方式相似的问题。
总结来说:上诉缺陷除了能避免使用多例模式,其他两种在系统构建上是无法进行取代的,因此需要使用其他的方式来进行并发数据的处理。
使用MySQL中自带的锁去解决:MySQL在执行更、删、改操作时会自动对当前语句加锁,也就是说我们只要能够使用一条sql来实现当前功能就可以避免数据问题。
新增mapper接口方法:
@Update("update db_stock set count = count - #{count} " +
"where product_code = #{productCode} and count >= #{count}")
Integer deduct(@Param("productCode") String productCode, @Param("count") Integer count);
修改service实现类方法:
@Override
public Integer deStock() {
return stockMapper.deduct("1001", 1);
}
使用Jmeter来测试,可以发现不会出现数据异常问题。
解决: 很明显的可以看出一条sql中携带的锁可以完美的解决上方JVM锁失效的问题,但是真的那么?
发现问题:
使用select ... for update
,可以对当前查询出的数据加上行级锁,即其他事务无法对已经查询出的数据进行修改删除等操作。
但是想要改为行级锁就必须满足以下要求:
新增mapper
中方法:
@Select("select * from db_stock where product_code = #{productCode} for update")
List<Stock> selectStockForUpdate(String productCode);
改造service
中方法:
@Transactional
@Override
public Integer deStock() {
List<Stock> stocks = stockMapper.selectStockForUpdate("1001");
if (Objects.isNull(stocks) || stocks.isEmpty()){
return -1;
}
// 假设存在多仓库情况,默认扣减第一个仓库
Stock stock = stocks.get(0);
if (!Objects.isNull(stock) && stock.getCount() >= 1){
stock.setCount(stock.getCount() - 1);
}
return stockMapper.updateById(stock);
}
使用Jmeter测试,可以看出悲观锁也可以实现数据异常的问题。
优缺点:
select ... for update
,要么都select
,一个有锁一个没有锁指定会出现数据冲突问题。乐观锁:默认对IO属性操作不加锁,在执行完毕对数据中的版本号或者其他属性进行判断,确定当前数据执行前后是否被其他的事务更改。也就是CAS思想。
CAS:Compare and Swap,比较并交换,其实就是有用一个属性,在更新后判断当前属性是否有变化,有变化就放弃更改,无变化就更改。
修改service
方法:
@Override
public Integer deStock() {
List<Stock> stocks = stockMapper.selectList(
new QueryWrapper<Stock>()
.eq("product_code", "1001"));
if (Objects.isNull(stocks) || stocks.isEmpty()){
return -1;
}
// 假设存在多仓库情况,默认扣减第一个仓库
Stock stock = stocks.get(0);
Integer version = 0;
if (!Objects.isNull(stock) && stock.getCount() >= 1){
version = stock.getVersion();
stock.setCount(stock.getCount() - 1);
stock.setVersion(version + 1);
}
QueryWrapper<Stock> queryWrapper = new QueryWrapper<>();
queryWrapper
.eq("id", stock.getId())
.eq("version", version);
int update = stockMapper.update(stock, queryWrapper);
// 更新失败递归重试
if (update == 0){
try {
// 避免一直重试导致栈内存溢出
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
return deStock();
}
return update;
}
通过Jmeter今天压力测试,发现可以满足数据一致的问题。
问题分析:
version
字段可能已经被其他线程改过了,但是又改了回来,就导致当前线程无法判断出已经被修改,也就是仍然会任务当前可以进行更新。