【每天学一点,总有一天熬成大师】
本质上是需要加锁,不管是什么锁,只要让减库存的操作排队,便可解决超卖问题,核心点就是:加锁排队
同理:解决并发修改数据出错问题,最终也是靠锁解决,比如乐观锁、悲观锁,本质上都是要靠锁,让并发问题排队执行,只是这个锁的范围大小的问题。
言归正传,下面咱们上方案、原理、源码、测试用例,一个都不能少。
-- 商品表
create table if not exists t_goods
(
goods_id varchar(32) primary key comment '商品id',
goods_name varchar(256) not null comment '商品名称',
num int not null comment '库存',
version bigint default 0 comment '系统版本号'
) comment = '商品表';
通过下面sql的执行结果,便可确保超卖问题,重点在于需要在update的where条件中加上库存扣减后不能为0,sql会返回影响行数,如果影响行数为0,表示库存不满足要求,扣减失败了,否则,扣减库存成功。
String goodsId = "商品id";
int num = "本次需要扣减的库存量";
// count表示影响行数
int count = (update t_goods set num = num - #{num} where goods_id = #{goodsId} and num - #{num} >= 0);
// count = 1,表示扣减成功,否则扣减失败
if(count==1){
//扣减库存成功
}else{
//扣减库存失败
}
com.service.GoodsServiceImpl#placeOrder1
===========================解决超卖,方案1 开始执行=======================================
模拟 100 人进行抢购
抢购结束啦............
抢购前,商品库存:10
抢购后,商品库存:0
下单成功人数:10
下单失败人数:90
===========================解决超卖,方案1 执行结束=======================================
需要在库存表加一个version字段,这个version每次更新的时候需要+1,单调递增的。
业务逻辑如下
String goodsId = "商品id";
int num = "本次需要扣减的库存量";
GoodsPo goods = (select * from t_goods where goods_id = #{goodsId});
// 期望数据库中该数据的version值
int expectVersion = goods.getVerion();
//乐观锁更新数据,where条件中必须带 version = #{expectVersion}
int count = update t_goods set num = num - ${num}, version = version + 1 where goods_id = #{goodsId} and version = #{expectVersion}
// count = 1,表示扣减成功,否则扣减失败
if(count==1){
//扣减库存成功
}else{
//扣减库存失败
}
com.service.GoodsServiceImpl#placeOrder2
===========================解决超卖,方案2 开始执行=======================================
模拟 100 人进行抢购
抢购结束啦............
抢购前,商品库存:10
抢购后,商品库存:0
下单成功人数:10
下单失败人数:90
===========================解决超卖,方案2 执行结束=======================================
String goodsId = "商品id";
int num = "本次需要扣减的库存量";
//扣减库存前,查出商品库存数量,丢到变量 beforeGoodsNum 中
GoodsPo beforeGoods = (select * from t_goods where goods_id = #{goodsId});
int beforeGoodsNum = beforeGoods.num;
// 执行扣减库存操作,条件中就只有goodsId,说明这个可能将库存扣成负数,出现超卖,继续向下看,后面的步骤将解决超卖
update t_goods set num = num - ${购买的商品数量} where goods_id = #{goodsId}
//扣减库存后,查出商品库存数量,丢到变量 afterGoodsNum 中
GoodsPo afterGoods = (select * from t_goods where goods_id = #{goodsId});
int afterGoodsNum = afterGoods.num;
// 如下判断,库存扣减前后和期望的结果是不是一致的,扣减前的数据 - 本次需要扣减的库存量 == 扣减后的数量,如果是,说明没有超卖
if(beforeGoodsNum - num == afterGoodsNum){
//扣减库存成功
}else{
//扣减库存失败
}
这种方案虽然看起来很奇怪,但是有些业务场景中,可以解决一些问题,比如批量去修改数据,想判断批量的过程中,数据是否被修改过,可以通过这种方式判断。
com.service.GoodsServiceImpl#placeOrder3
===========================解决超卖,方案3 开始执行=======================================
模拟 100 人进行抢购
抢购结束啦............
抢购前,商品库存:10
抢购后,商品库存:0
下单成功人数:10
下单失败人数:90
===========================解决超卖,方案3 执行结束=======================================
需要添加一张辅助表(t_concurrency_safe),如下,这张表需要有版本号字段,通过这张表的乐观锁,将需要保护的业务方法包起来,解决超卖问题。
create table if not exists t_concurrency_safe
(
id varchar(32) primary key comment 'id',
safe_key varchar(256) not null comment '需要保护的数据的唯一的key',
version bigint default 0 comment '系统版本号,默认为0,每次更新+1',
UNIQUE KEY `uq_safe_key` (`safe_key`)
) comment = '并发安全辅助表';
逻辑如下
String goodsId = "商品id";
int num = "本次需要扣减的库存量";
// 需要给保护的数据生成一个唯一的:safeKey
String safeKey = "GoodsPO:"+商品id;
// 如下:根据 safe_key 去 t_concurrency_safe 表找这条需要保护的数据
ConcurrencySafePO po = (select * from t_concurrency_safe where safe_key = #{safe_key});
// 这条数据不存在,则创建,然后写到 t_concurrency_safe 表
if(po==null){
po = new ConcurrencySafePO(#{safe_key});
// 向 t_concurrency_safe 表写入一条数据
insert into t_concurrency_safe (safe_key) values (#{safeKey});
}
// 下面执行扣减库存的操作,注意,如果用方案4,那么需要保护的数据的修改,均需要放在这个位置来保护,这块大家细品下
{
//扣减库存前,查出商品库存
GoodsPo beforeGoods = (select * from t_goods where goods_id = #{goodsId});
//判断库存是否足够
if(beforeGoods.num == 0){
//库存不足,秒杀失败
return;
}
// 执行扣减库存操作,条件中就只有goodsId,说明这个可能将库存扣成负数,出现超卖,继续向下看,后面的步骤将解决超卖
update t_goods set num = num - ${购买的商品数量} where goods_id = #{goodsId}
}
//对 ConcurrencySafePO 执行乐观锁更新
int update = update t_concurrency_safe set version = version + 1 where id = #{po.id} and version = #{po.version}
// 若update==1,说明被保护的数据,期间没有发生变化
if(update == 1){
//秒杀成功
}else{
//说明被保护的数据,期间发生变化了,下面要抛出异常,让事务回滚
throw new ConcurrencyFailException("系统繁忙,请重试");
}
如果是老的业务,涉及到大量代码,改造复杂,那么可以用此方案将业务代码包裹起来,便可防止并发修改导致数据不一致的问题。
com.service.GoodsServiceImpl#placeOrder4
===========================解决超卖,方案4 开始执行=======================================
模拟 100 人进行抢购
抢购结束啦............
抢购前,商品库存:10
抢购后,商品库存:0
下单成功人数:10
下单失败人数:90
===========================解决超卖,方案4 执行结束=======================================
这几种方案都可以解决超卖的问题,但是方案1最靠谱。
这里说下原因:团队中,涉及到很多人修改代码,那么问题就来了,可能修改库存的地方,有多个口子,那么此时,用其他方案就可能存在风险了,可能会出错。
如果能做到,收敛到一个口子中去修改数据,就是最终修改数据都是一个口子,那么上面的方法都可以,都可确保数据不会出问题,你们觉得呢?
本文介绍了4种方案解决超卖问题,每种方案都有其使用场景,可能感觉有些方案很罕见,但是也许在日后某些场景下,你就会用到。
说了这么多方案,也算是开拓下大家解决问题的思路,有些问题,也许有更多方案,每种方案都有其存在的价值,抱着开放的心态,才能不断精进。
一起加油。