- Java锁的逻辑(结合对象头和ObjectMonitor)
- 还在用饼状图?来瞧瞧这些炫酷的百分比可视化新图形(附代码实现)⛵
- 自动注册实体类到EntityFrameworkCore上下文,并适配ABP及ABPVNext
- 基于Sklearn机器学习代码实战
焦点: 这篇文章,主要是想聊一聊 “超卖” 这个场景,并且借此,串一下整个电商的交易流程,加深对电商交易的认真.
我们先看下电商的交易流程:
不管是同城外卖场景,还是异城的的传统电商购物,主要流程都不会有太多区别.
交易的实体是商品,而商品筛选是最先经历的一环,用户的目的是从无数的商品中,筛选(搜索)出自己想要买的那一款,所以搜索这一环是平台必须提供的能力,而平台的目的,就是在无数的商品中,尽可能的将用户要买的商品摆到用户面前,这也就是所谓的 “推荐“ 。
根据业务场景不同,外卖电商会有很强的商家概念,所谓“很强”,就是会在用户端凸显商家的存在,用户第一选择的主体是商铺,然后才是商铺中的商品。而普通电商并不是没有商家,而是商家不会在用户端被很强的凸显出来.
对交易实体-商品的详细介绍,包括商品图片,商品介绍,商品价格等等,目的是让用户能够对商品本身有个更清晰的认知,一些特殊的技术如:全景,模拟,视频,等都在努力丰富用户对产品的认知.
用户选好商品后准备购买的二次确认操作,无论是选一件商品,还是选多件商品,都需要用户确认,本质上来说,买一件商品和买多件商品并没有区别,但在存储上,根据业务场景不同,会有不同的实现手段,比如外卖电商是只能在一个店铺下进行多商品购买的(方便后面配送履约),普通电商是可以跨店购买商品的(后面的逻辑是拆单,将不同店铺的商品,拆成不同单)。二次确认主要是让用户再次明确:商品价格、优惠政策、配送地址等,也叫预签合同,当用户对此合同支付完成之后,合同便开始生效(创建订单,订单状态开始流转),直到商品配送到用户手里,用户满意后确认收货,合同完成(订单完结) 。
有自主钱包的,可以免跳转第三方支付,没有自主钱包的平台,都会跳转三方支付平台进行支付,用户确认付款后.
商品配送可以分为本地生活类的骑手配送(商家送,自取等),和快递类的全国送(得益于国家强大,基建完善:中国邮政无处不在),履约也有可能有自身的状态流转(比如待接单,接单送出,商品送达等),所以仔细一点的电商 平台都会有区别于“用户单”的“履约单”,履约单又是一大堆的细节,这里不做细节讨论.
超卖本身是一个股票概念,不知道为什么出现在电商交易中,而在电商交易中,超卖指:卖出商品的数量 大于 商品可卖库存.
还原真实场景,看下什么时候,卖出的商品数量 大于 商品可卖库存。打个比方 A是商户,手里有10个苹果,每个1块钱;B、C、D是买家,都要买苹果,不过B、C、D的需求各不相同,用表格来具体一下数据.
| 人 | 类型| 动作| 苹果数量| |:—😐:—😐:—😐:—😐 |A|商户|出售|10| |B|用户|购买|5| |C|用户|购买|4| |D|用户|购买|2| 。
在看几种有意思的交易流程:
场景(一) 顺序交易,先付款,后交货,无逆向 。
场景(二)顺序交易,先交货,后付款,无逆向 。
场景(三)顺序交易,先交货,后付款,有逆向 。
场景(四)并发交易,先交货,后付款,有逆向 。
所以,现实场景中,根本 不会发生 超卖, 由于业务是现实场景的反馈,所以,业务上也 不会发生 超卖 。
业务上不会发生超卖 。
把准备交易比作“创建订单”,把A拿出苹果比作“扣减库存”,把B交钱给A比作“付款”, 那上面的场景可以简化为:
上面说了,业务上不会发生超卖,可大家一直谈论的“交易超卖”是什么呢?还是要分两个方面看:
刚说了“业务上不会发生超卖”,为什么会有“业务超卖”呢? 是因为一些公司,在正常的流程中,人为的改变了一些业务因素,才会导致.
举个例子,拼多多的电商场景,是以用户为主的,原则是大力维护用户利益,在发生交易的时候,如果用户付款之后,平台发现商品已经没有库存了,宁可想办法补调商品(可能会损失平台利益),争取让用户购买到,即特殊情况除外,不会主动发生退款现象(即不会发生场景一,D购买的情况) 。
那,根据拼多多电商场景,默认不主动退款,而支付后扣库存的场景,我们模拟一个超卖的逻辑:
场景(五)并发交易,先付款,后交货,不主动退款 。
所谓的业务超卖,就是在正常交易场景下,因为一些业务原因,导致部分环节不执行,导致超卖 支付后扣库存, 不主动退款的情况下,会超卖 下单后扣库存,不主动拦截下单的情况下,会超卖 其中逻辑,耐人寻味 。
为了变量单一,我们假设业务流程正常,即无业务超卖,单从技术上来看,如何发生超卖?如何避免超卖?
 。
把流程聚焦,在交易链路中,存在两种顺序的扣库存行为,一种是下单之后扣库存,一种是支付之后扣库存,由于这两种场景都属于业务场景,业务场景不发生超卖的前提下,只考虑技术导致超卖,那超卖只会发生在「扣减库存」这个点上.
把「扣减库存」这个点放大,可以看到最简单的扣库存架构,将扣库存流程做到最简化,时序图如下:
 。
假设两种场景:
场景一:顺序操作 初始化条件:商品A有1个库存 。
update goods_stock quantity = quantity - 1 where goods_id = A
之后,quantity 字段为0,下单成功 场景二:并发操作 初始化条件:商品A有1个库存 。
update goods_stock quantity = quantity - 1 where goods_id = A
之后,quantity 字段为-1,两次下单都成功 场景二会导致多卖一件,即发生超卖 。
发生超卖的原因:查询拦截 和 扣减库存 是两个割裂开的动作,并非一个原子操作, 解决办法,也比较简单, 将查询拦截 和 扣减库存 两个动作 做成原子的,有很多办法 。
如果并发比较小(扣库存操作的OPS < 库存DB能够承担的 OPS)最简单的可能是利用sql对数据的影响条数来判断 。
可用sql: update goods_stock quantity = quantity - 1 where goods_id = A and quantity >0 判断依据:影响行数 >0 则说明扣减成功,影响行数 <=0 则说明扣减失败,拦截订单 。
如果并发比较大(扣库存操作的OPS > 库存DB能够承担的 OPS),单用 DB 做原子操作,对DB的压力比较大,所以要想个办法,既能让 库存判断 和 库存扣减 组成一个原子操作,又能承受很高的OPS.
最先想到的就是用Redis来代替DB支持高OPS。但是用Redis就需要考虑如下几个场景:
Redis在高并发场景下的使用姿势基本是固定的,先不考虑 更新DB 和 更新Redis 失败的情况,在保证 更新DB 和 更新Redis 这两个操作都能成功的情况下,更新Redis的 Design Pattern有四种:
简单说下四种模式的区别:
Cache aside,查询:应用程序从cache中取数据,取到后返回; 没有得到,则从数据库中取数据,成功后,放到缓存中。跟新:先把数据存到数据库中,成功后,再让缓存失效.
Read Through Pattern, 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的.
Write Through Pattern,套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作) 。
Write Behind Caching 在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的.
再说明一点:所有 Redis 跟新 Desing Pattern 都是会有问题的,在软件设计上,我们基本上不可能做出一个没有缺陷的设计,软件设计从来都是取舍 。
根据上面四种模式,基本能看出, Write Behind Caching 是我们需要的模式,在请求中,只更新缓存,异步更新数据库,至于异步的逻辑,也有提到(可以是单条处理,可以是批处理) 。
那有个问题,由于库存以redis为准,何时初始化redis数据?redis过期时间怎么处理? 是否全部都以Redis为准?
先思考个问题,是所有商品都会有大并发的扣库存操作(爆品)?还是部分商品是爆品?成为爆品是否可预测 ?
在我了解的电商场景里面,这个几个问题的答案是:不是所有的商品都是爆品,爆品可预测。 经验来看,仅秒杀场景(定时开售,高性价比)商品所产生的库存扣减流量,才会导致DB承受不住扣减库存OPS.
 。
上图是整个操作流程,看图再回答下上面的问题:
再说下这个问题:查询拦截 和 扣减库存 两个操作,如何用Redis做成原子化?
Redis对命令是原子化的(因为对命令处理是单线程的),所以使用lua脚本把 查询拦截 和 扣减库存 做成一个命令,保证原子化.
@Service
public class StockService {
/**
* Redis 客户端
*/
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 执行扣库存的脚本
*/
public static final String STOCK_LUA;
static {
/**
*
* @desc 扣减库存Lua脚本
* 库存(stock)0:表示扣库存失败,没有可用库存
* 库存(stock)1:表示扣库存成功
*/
StringBuilder sb = new StringBuilder();
sb.append("if (redis.call('exists', KEYS[1]) == 1) then");
sb.append(" local stock = tonumber(redis.call('get', KEYS[1]));");
sb.append(" local num = tonumber(ARGV[1]);");
sb.append(" if (stock >= num) then");
sb.append(" redis.call('incrby', KEYS[1], 0 - num);");
sb.append(" return 1;
sb.append(" end;");
sb.append(" return 0;");
sb.append("end;");
sb.append("return 0;");
STOCK_LUA = sb.toString();
}
/**
* 扣库存
*
* @param key 库存key
* @param num 扣减库存数量
* @return true 扣成功;false 扣失败
*/
private boolean decrStock(String key, int num) {
// 脚本里的KEYS参数
List<String> keys = new ArrayList<>();
keys.add(key);
// 脚本里的ARGV参数
List<String> args = new ArrayList<>();
args.add(Integer.toString(num));
long result = redisTemplate.execute(new RedisCallback<Long>() {
@Override
public Long doInRedis(RedisConnection connection) throws DataAccessException {
Object nativeConnection = connection.getNativeConnection();
// 集群模式和单机模式虽然执行脚本的方法一样,但是没有共同的接口,所以只能分开执行
// 集群模式
if (nativeConnection instanceof JedisCluster) {
return (Long) ((JedisCluster) nativeConnection).eval(STOCK_LUA, keys, args);
}
// 单机模式
else if (nativeConnection instanceof Jedis) {
return (Long) ((Jedis) nativeConnection).eval(STOCK_LUA, keys, args);
}
}
});
return result == 1;
}
}
上述均个人观点,欢迎拍砖,有问题可 留言/邮件 讨论.
最后此篇关于聊聊超卖的文章就讲到这里了,如果你想了解更多关于聊聊超卖的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。
跟随这个问题Generate a fictitious stock option price variation 我想模拟价格变化,同时用户发出买入或卖出指令,就像真实的证券交易所一样。 (我做了一个
我是一名优秀的程序员,十分优秀!