开发一个电商库存系统时,我们最担心的就是高并发和防超卖了
电商库存系统场景
前提:分布式系统,高并发
商品A只有100库存,现在有1000或者更多的用户购买。如何保证商品库存在高并发的场景下是安全的
高并发场景下,商品展示页上面的信息,除了库存的其他信息属于静态数据,静态数据是可以缓存的。动态数据只有库存
电商项目对并发数据处理要求较高
预期结果:1、不超卖;2、不少卖;3、下单响应快;4、用户体验好
设计思路
1、下单时生成订单,减库存,同时记录库存流水,在这里需要先进行库存操作再生成订单数据,这样库存修改成功,响应超时的特殊情况也可以通过第四步定时校验库存流水来完成最终一致性
2、支付成功删除库存流水,处理完成删除可以让库存流水数据表数据量少,易于维护
3、未支付取消订单,回滚库存+删除库存流水
4、定时校验库存流水,结合订单状态进行响应处理,保证最终一致性
(退单有单独的库存流水,申请退单插入流水,退单完成删除流水+回滚库存)
设计流程图
扣减库存时机方案
- 方案一:加购时减库存
- 方案二:确认订单页减库存
- 方案三:提交订单时减库存
- 方案四:支付时减库存
扣减库存需要注意的点
1、剩余库存要大于等于(>=)当前需要扣减的库存,不允许超卖
2、对于同一个商品,用户并发扣减时,要保证并发的一致性
3、保证可用性和性能,性能至少是秒级
4、一次扣减包含多个商品
5、扣减多个商品时,一个不成功则全部不成功,需要回滚
6、下单时必须产生了扣减,退款时才能回滚库存,回滚库存的数量必须加回去,不能丢失
7、下单时的一次扣减库存,可以多次回滚
8、回滚库存时需要保证幂等
分析:
- 方案一:在这个时间内加入购物车并不代表用户一定会购买,如果这个时候处理库存,会导致想购买的用户显示无货。而不想购买的人一直占着库存。显然这种做法是不可取的。唯品会购物车锁库存,但是他们是另一种做法,加入购物车后会有一定时效,超时会从购物车清除。
- 方案二:确认订单页用户有购买欲望,但是此时没有提交订单,减库存会增加很大的复杂性,而且确认订单页的功能是让用户确认信息,减库存不合理,希望大家对该方案发表一下观点,本人暂时只想到这么多。
- 方案三:提交订单时减库存。用户选择提交订单,说明用户有强烈的购买欲望。生成订单会有一个支付时效,例如半个小时。超过半个小时后,系统自动取消订单,还库存。
- 方案四:支付时去减库存。比如:只有100个用户可以支付,900个用户不能支付。用户体验太差,同时生成了900个无效订单数据。
综上所述:
选择方案三比较合理
重复下单问题
- 用户点击过快,重复提交
- 网络延时,用户重复提交
- 网络延时高的情况下某些框架自动重试,导致重复请求
- 用户恶意行为
解决办法
- 前端拦截,点击后按钮置灰
- 后台:
(1)redis 防重复点击,在下单前获取用户token,下单的时候后台系统校验这个 token是否有效,导致的问题是一个用户多个设备不能同时下单
//key , 等待获取锁的时间 ,锁的时间
redis.lock("shop-oms-submit" + token, 1L, 10L);
redis的key用token + 设备编号 一个用户多个设备可以同时下单。
//key , 等待获取锁的时间 ,锁的时间
redis.lock("shop-oms-submit" + token + deviceType, 1L, 10L);
(2)防止恶意用户,恶意攻击 : 一分钟调用下单超过50次 ,加入临时黑名单 ,10分钟后才可继续操作,一小时允许一次跨时段弱校验。使用reids的list结构,过期时间一小时
/**
* @param token
* @return true 可下单
*/
public boolean judgeUserToken(String token) {
//获取用户下单次数 1分钟50次
String blackUser = "shop-oms-submit-black-" + token;
if (redis.get(blackUser) != null) {
return false;
}
String keyCount = "shop-oms-submit-count-" + token;
Long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));
//每一小时清一次key 过期时间1小时
Long count = redis.rpush(keyCount, String.valueOf(nowSecond), 60 * 60);
if (count < 50) {
return true;
}
//获取第50次的时间
List<String> secondString = redis.lrange(keyCount, count - 50, count - 49);
Long oldSecond = Long.valueOf(secondString.get(0));
//now > oldSecond + 60 用户可下单
boolean result = nowSecond.compareTo(oldSecond + 60) > 0;
if (!result) {
//触发限制,加入黑名单,过期时间10分钟
redis.set(blackUser, String.valueOf(nowSecond), 10 * 60);
}
库存扣减逻辑
1、用户提交订单时库存预扣
【1】查询redis当前的库存
如果库存数量大于等于购买数量n,则继续后续操作
如果小于,则库存扣减失败,订单创建失败
【2】调用redis的原子方法(increment),执行扣减操作stock = increment(skuID,-n );
如果stock>=0,则代表扣减成功,则库存预扣成功,订单创建成功
否则库存扣减失败,订单创建失败,再次调用increment(skuID,+n ),重要的一步是将redis库存回填
2、用户支付完成时扣除实际库存
为什么要在用户支付完成后才实际的扣减库存呢?而不是下单时直接扣减实际库存呢?
优点:
防止用户支付前取消订单,进行库存回填的时候,还得操作实际库存,增加库存不一致的风险
为了提高并发,因为特别是在并发量比较大的时候,如果在下单时直接操作数据库库存,会导致创建订单花费的时间更长
防止用户恶意下单,不支付,占用库存**(主要原因)**
缺点:
用户支付时,有可能会出现库存不足,给用户造成不好的购物体验。
3、订单取消/下单失败,库存回滚
这里其实需要分为不同场景:
订单未支付前:订单取消或下单失败,则只需要回填redis库存
订单已经支付完成:订单取消/下单失败,则需要回填redis和数据库库存,并执行退款
4、运营更新商品,操作库存
redis库存增加:使用increment(skuID,+n );原子操作更新库存
数据库库存增加:使用乐观锁进行更新
每日凌晨定时维护redis与数据库的库存数量
为了防止redis和数据的库存出现不一致的情况,每天都需要进行检查;库存以数据库中实际库存为主,将数据库中的库存减去未支付订单扣减的库存,更新到redis中
安全扣减库库存
(一)纯MySQL乐观锁,事务扣减库存
扣减业务完全依赖MYSQL数据库来实现,不依赖中间件或缓存
原理
1、基于数据库乐观锁方式保证并发扣减的强一致性
2、基于数据库的事务实现批量扣减失败进行回滚
实现
流程图
一次完整的流程就是先进行数据校验,做接口开发的时候,要保持一个不信任原则,一切数据都不要相信,都要做校验判断,其次还可以进行库存扣减的前置校验,如果库存只有8个,用户要买10个,此时的数据校验中,可以拦截,减少对数据库的写操作。纯读不会加锁,性能较高
关键sql
sql:update sku_stock set stock = stock - num where sku_code = '' and stock - num > 0;
用户每次扣减的时候,需要传入一个uuid作为流水号,全局唯一:
当用户退单时,传回此编号,用来标识属于历史上的哪次扣减
进行幂等控制,用户调用扣款接口时,出现超时,不知道成功了没,可以通过此编号进行重试或反查,重试时可通过此标识防重
优点
逻辑简单,开发部署成本低。
缺点
无法支持高并发,单机数据库并发1000,2000压力就非常大了,如果AB两个用户同时购买同一个商品,校验通过,后续购买时,只会有一个人成功,导致另外一个人失败,数据库也就多了一次查询,降低性能
高并发场景下,假设库存只有 1件 ,两个请求同时进来,抢购该商品.
数据库层面会限制只有一个用户扣库存成功。在并发量不是很大的情况下可以这么做。但是如果是秒杀,抢购,瞬时流量很高的话,压力会都到数据库,可能拖垮数据库
MYSQL架构升级
根据场景分析,读库操作一般是浏览商品时产生,扣减库存是在购买时产生,用户购买请求的业务价值大,要保障写操作。
读写分离
根据二八原则,80%为读流量,主库压力降低了80%,但采用读写分离会导致读取的数据不准确,不过库存本身就在变,短暂差异,业务上可以允许,最终的扣减会保证数据的准确性。
再次升级
初次升级支持并发并不太高,我们可以引入缓存
加缓存reids,高并发,单机redis每秒支持并发可在3,4W
代码实现:version做控制之类的,其实用不上,我们只需要
update where id and 库存>0.
下单失败了,给你返回执行的行数就是0。
if==0
return 下单失败
else
下单成功
(二)Redis单线程,强制串行处理
/**
* 缺点并发不高,同时只能一个用户抢占操作,用户体验不好!
*
* @param orderSkuAo
*/
public boolean subtractStock(OrderSkuAo orderSkuAo) {
String lockKey = "shop-product-stock-subtract" + orderSkuAo.getOrderCode();
if(redis.get(lockKey)){
return false;
}
try {
lock.lock(lockKey, 1L, 10L);
//处理逻辑
}catch (Exception e){
LogUtil.error("e=",e);
}finally {
lock.unLock(lockKey);
}
return true;
}
利用Redis 分布式锁,强制控制同一个商品处理请求串行化,缺点并发不高 ,处理比较慢,不适合抢购,高并发场景。用户体验差,但是减轻了数据库的压力
redis的hash结构不支持多个key批量操作,我们可采用redis+lua脚本来实现批量扣减单线程。
升级成纯redis实现扣减也会有问题
redis挂了,如果还没执行到redis扣减挂了,直接返回前端失败; 如果执行到redis扣减后,挂了,接口返回的失败,redis扣减成功了,但是没有触发异步更新逻辑,数据库不会扣减,数据库是准确的,这个时候需要一个对账程序,通过对比redis和数据库库存是否一致,并结合扣减日志表,发现扣减失败了,将数据库库存比redis多的库存加回到redis中。
redis扣减完成,异步刷新数据库失败了,redis此时是准的,数据库库存是多的,结合扣减日志,将数据库比redis多的库存数据在数据库中进行扣减。
(三)Redis + MQ + MySQL
在磁盘写数据时,向文件末尾不断追加写入的性能远大于随机修改。对于传统机械硬盘来说,每次随机更新都需要磁头寻址,向文件末尾追加数据,只需要寻址一次。
对固态硬盘来说,虽然避免了磁头移动,但依然存在寻址过程。对文件的随机更新和数据库表更新比较类似,都存在加锁带来的性能消耗
与纯缓存架构的区别是,写入任务数据库不是异步,而是在扣减的时候同步写入,用的是顺序写,不是update做数据库数量的更改,所以性能更好。
insert任务数据库,只记录操作,不进行真实扣减。
扣减流程
/**
* 扣库存操作,秒杀的处理方案
* @param orderCode
* @param skuCode
* @param num
* @return
*/
public boolean subtractStock(String orderCode,String skuCode, Integer num) {
String key = "shop-product-stock" + skuCode;
Object value = redis.get(key);
if (value == null) {
//前提 提前将商品库存放入缓存 ,如果缓存不存在,视为没有该商品
return false;
}
//先检查 库存是否充足
Integer stock = (Integer) value;
if (stock < num) {
LogUtil.info("库存不足");
return false;
}
//不可在这里直接操作数据库减库存,否则导致数据不安全
//因为此时可能有其他线程已经将redis的key修改了
//redis 减少库存,然后才能操作数据库
Long newStock = redis.increment(key, -num.longValue());
//库存充足
if (newStock >= 0) {
LogUtil.info("成功抢购");
//TODO 真正扣库存操作 可用MQ 进行 redis 和 mysql 的数据同步,减少响应时间
} else {
//库存不足,需要增加刚刚减去的库存
redis.increment(key, num.longValue());
LogUtil.info("库存不足,并发");
return false;
}
return true;
}
insert是顺序写,将update异步化,所以可以很大提高并发,这样会用到数据库事务来进行redis中的数据修改,所以不会出问题,不会出现少卖的问题
利用Redis increment 的原子操作,保证库存安全,利用MQ保证高并发响应时间。但是事需要把库存的信息保存到Redis,并保证Redis 和 Mysql 数据同步。缺点是redis宕机后不能下单。
increment 是个原子操作
综上所述:
方案三满足秒杀、高并发抢购等热点商品的处理,真正减扣库存和下单可以异步执行。在并发情况不高,平常商品或者正常购买流程,可以采用方案一数据库乐观锁的处理,或者对方案三进行重新设计,设计成支持单订单多商品即可,但复杂性提高,同时redis和mysql数据一致性需要定期检查
参考
java - 这个是真的厉害,高并发场景下的订单和库存处理方案,讲的很详细了! - 架构人生 - SegmentFault 思否