什么是超卖?
当商品库存为1,用户A和用户B同时提交了该商品订单,此时两个用户同时读取库存为1,并发进行内存扣减之后,进行更新数据库,导致库存最终更新为-1,产生超卖。
我们先看一段会产生超卖问题的代码
@Transactional(rollbackFor = Exception.class)
public Integer createOrder(int purchaseProductId,int purchaseProductNum) throws BaseException{
Product product = productMapper.selectById(purchaseProductId);
if (product==null){
throw new BaseException("购买商品:"+purchaseProductId+"不存在");
}
//商品当前库存 & 校验库存
Integer currentCount = product.getCount();
log.info(Thread.currentThread().getName() + "库存数" + currentCount);
if (purchaseProductNum > currentCount){
throw new BaseException("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
}
//计算剩余库存
Integer leftCount = currentCount -purchaseProductNum;
product.setCount(leftCount);
product.setTimeModified(new Date());
productMapper.updateById(product);
return orderCommonService.createOrder(product,purchaseProductNum);
}
在上面的代码中,我们先去查询商品的库存数,然后判断库存数是否充足,如果不足,就抛出异常,如果充足,就创建订单。上面这段代码,在并发下单的情况下,就会产生超卖问题,我们将库存设置成1,然后创建五个线程并发请求一下看看结果
这是输出结果,可以看到,我们库存只有1个,但是下了五个单,这样就发生了超卖。解决超卖问题,我们需要使用锁
1、基于数据库行锁解决超卖问题
如果我们需要使用数据库锁解决超卖问题,一般有两种方案,悲观锁和乐观锁
乐观锁:乐观锁并不是数据库锁机制,而是一种cas思路,一般会在表中添加一个version版本字段,更新数据的时候同时更新版本字段
悲观锁:悲观锁是基于InnoDB引擎的行锁机制(select ... for update)
优点:数据库既实现存储又实现锁,无需额外中间件。若基于乐观锁实现,能实现高效并发。
缺点:基于悲观锁机制,锁存续期间,其他线程无法对数据进行更改,在读请求压力大时,会造成大量线程堵塞,DB压力变高,影响其他正常业务,适合“写多读少”的场景。基于乐观锁机制,获得的锁为非阻塞锁,但没有获得锁的服务并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作,频繁的更新version也会严重影响数据库性能,适合“写少读多”的场景。
下面我们使用悲观锁的方式解决超卖问题
@Transactional(rollbackFor = Exception.class)
public Integer createOrder(int purchaseProductId,int purchaseProductNum) throws BaseException {
Product product = productMapper.selectByIdForUpdate(purchaseProductId);
if (product==null){
throw new BaseException("购买商品:"+purchaseProductId+"不存在");
}
//商品当前库存 & 校验库存
Integer currentCount = product.getCount();
log.info(Thread.currentThread().getName() + "库存数" + currentCount);
if (purchaseProductNum > currentCount){
throw new BaseException("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
}
//在数据库中完成减量操作
productMapper.updateProductCount(purchaseProductNum,new Date(),product.getId());
return orderCommonService.createOrder(product,purchaseProductNum);
}
这里我们只需要改一行代码,就是查询商品信息productMapper.selectByIdForUpdate(purchaseProductId)的sql,我们改成
select * from demo_product where id = #{id} for update
我们将库存设置为2,创建5个线程调用一下
这里我们可以看到只有两个订单创建成功,超卖问题解决
2、基于synchronized关键字解决超卖问题
/**
* 这里不能使用 @Transactional(rollbackFor = Exception.class) 声明式事务,aop会导致synchronized锁失效
*/
public synchronized Integer createOrder(int purchaseProductId,int purchaseProductNum) throws BaseException {
TransactionStatus ts = null;
try {
ts = dataSourceTransactionManager.getTransaction(new DefaultTransactionAttribute());
Product product = productMapper.selectById(purchaseProductId);
if (product == null) {
throw new BaseException("购买商品:" + purchaseProductId + "不存在");
}
//商品当前库存 & 校验库存
Integer currentCount = product.getCount();
log.info(Thread.currentThread().getName() + "库存数" + currentCount);
if (purchaseProductNum > currentCount) {
throw new BaseException("商品" + purchaseProductId + "仅剩" + currentCount + "件,无法购买");
}
//在数据库中完成减量操作
productMapper.updateProductCount(purchaseProductNum, new Date(), product.getId());
Integer order = orderCommonService.createOrder(product, purchaseProductNum);
dataSourceTransactionManager.commit(ts);
return order;
}catch (Exception e) {
if(ts!=null) {
dataSourceTransactionManager.rollback(ts);
}
throw e;
}
}
synchronized使用简单,是最常用的java锁关键字,这里就不多介绍
这里需要注意一点,synchronized不能跟声明式事务@Transactional注解一起使用,原因是声明式事务使用aop处理事务问题,会出现锁释放了但是事务没提交导致锁失效的问题
3、基于ReentrantLock可重入锁解决超卖问题
private Lock lock = new ReentrantLock();
public Integer createOrder(int purchaseProductId,int purchaseProductNum) throws BaseException {
TransactionStatus ts = null;
try{
lock.lock();
ts = dataSourceTransactionManager.getTransaction(new DefaultTransactionAttribute());
Product product = productMapper.selectById(purchaseProductId);
if (product==null){
throw new BaseException("购买商品:"+purchaseProductId+"不存在");
}
//商品当前库存 & 校验库存
Integer currentCount = product.getCount();
if (purchaseProductNum > currentCount) {
throw new BaseException("商品" + purchaseProductId + "仅剩" + currentCount + "件,无法购买");
}
///在数据库中完成减量操作
productMapper.updateProductCount(purchaseProductNum, new Date(), product.getId());
Integer order = orderCommonService.createOrder(product, purchaseProductNum);
platformTransactionManager.commit(ts);
return order;
}catch (Exception e) {
if(ts!=null) {
dataSourceTransactionManager.rollback(ts);
}
throw e;
}finally {
lock.unlock();
}
}
ReentrantLock内部定义了一个final 的抽象类 Sync , RLock很多操作需要借助Sync类完成。
Sync类继承了AQS(AbstractQueuedSynchronizer),因此锁的操作实际是借助AQS进行实现的。
AbstractQueuedSynchronizer:先进先出FIFO等待队列的阻塞锁定 + 相关同步器(信号量&事件)
4、使用redis分布式锁解决超卖问题
原理是使用setnx命令获取锁
private final String ORDER_KEY = "order_key";
public Integer createOrder(int purchaseProductId,int purchaseProductNum) throws BaseException, InterruptedException {
boolean lock;
String key = ORDER_KEY+purchaseProductId;
while (true) {
lock = RedisLockUtil.lock(key);
if (lock) {
try {
Product product = productMapper.selectById(purchaseProductId);
if (product == null) {
throw new BaseException("购买商品:" + purchaseProductId + "不存在");
}
//商品当前库存
Integer currentCount = product.getCount();
log.info(Thread.currentThread().getName() + "库存数" + currentCount);
//校验库存
if (purchaseProductNum > currentCount) {
throw new BaseException("商品" + purchaseProductId + "仅剩" + currentCount + "件,无法购买");
}
//在数据库中完成减量操作
productMapper.updateProductCount(purchaseProductNum, new Date(), product.getId());
return orderCommonService.createOrder(product, purchaseProductNum);
} finally {
RedisLockUtil.releaseLock(key);
}
}else {
Thread.sleep(100);
}
}
}
@Component
public class RedisLockUtil {
private static RedisTemplate redisTemplate;
@Autowired
public void setRedisTemplate(RedisTemplate redisTemplate) {
RedisLockUtil.redisTemplate = redisTemplate;
}
private final static String VALUE = UUID.randomUUID().toString();
public static Boolean lock(String key){
RedisCallback<Boolean> redisCallback = redisConnection -> {
//表示set nx 存在key的话就不设置,不存在则设置
RedisStringCommands.SetOption setOption = RedisStringCommands.SetOption.ifAbsent();
//设置过期时间
Expiration expiration = Expiration.seconds(30);
byte[] redisKey = redisTemplate.getKeySerializer().serialize(key);
byte[] redisValue = redisTemplate.getKeySerializer().serialize(VALUE);
return redisConnection.set(redisKey,redisValue,expiration,setOption);
};
//获取分布式锁
return (Boolean)redisTemplate.execute(redisCallback);
}
public static Boolean releaseLock(String key){
String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
RedisScript<Boolean> redisScript = RedisScript.of(script,Boolean.class);
List<String> keys = Collections.singletonList(key);
return (Boolean) redisTemplate.execute(redisScript,keys,VALUE);
}
}
5、使用redission分布式锁解决超卖问题
redis官网推荐实现分布式锁的三方类库。其功能非常强大,对各种锁都有实现,使用非常简单,使用者能够将更多的关注点放在业务逻辑上。
可以解决redis锁不可重入、锁过期等问题
加锁&释放锁:基于RLock#lock()&RLock#unlock()方法操作即可。
多线程并发获取锁时,当一个线程获取到锁,其他线程则获取不到,并且内部会不断尝试获取锁,当持有锁的线程将锁释放后,其他线程则会继续去竞争锁。
存在问题: 通常,业务执行多久无法确定一个准确值。lock() 虽可设置超时时间,但锁超时后,锁就会自动释放。若此时业务仍在执行,且后续线程又获取到了新的锁,在解锁的时候就会出现异常造成死锁,因为加锁时的唯一标识与解锁时的唯一标识发生了改变。
看门狗机制 - 不对锁key设置超时时间,当超时时间为-1时,启动一个定时任务,在业务释放锁之前,会一直不停的增加这个锁的有效时间,从而保证在业务执行完毕前,这把锁不会被提前释放掉。
开启看门狗机制:将RLock#lock()替换为RLock#tryLock() 即可,默认每隔30秒进行一次续期。
private final String ORDER_KEY = "order_key";
public Integer createOrder(int purchaseProductId,int purchaseProductNum) throws BaseException {
String key = ORDER_KEY+purchaseProductId;
RLock rlock = redissonClient.getLock(key);
rlock.lock(30, TimeUnit.SECONDS);
try {
Product product = productMapper.selectById(purchaseProductId);
if (product==null){
throw new BaseException("购买商品:"+purchaseProductId+"不存在");
}
//商品当前库存
Integer currentCount = product.getCount();
//校验库存
if (purchaseProductNum > currentCount){
throw new BaseException("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
}
//在数据库中完成减量操作
productMapper.updateProductCount(purchaseProductNum, new Date(), product.getId());
return orderCommonService.createOrder(product, purchaseProductNum);
}finally {
rlock.unlock();
}
}
demo源码:cache-demo: 常见各种缓存demo用来超卖问题