【技术方案】常见库存设计方案-各种方案对比总有一个适合你
文章目录
- 一、背景
- 二、功能
- 三、业务流程
- 四、那么最终系统设计要遵循什么原则???
- 五 、具体设计
- 5.1 方案一
- 5.1.1 预占库存
- 5.1.2 扣减库存
- 5.1.3 查询库存
- 5.1.4 超时释放库存
- 5.1.5 问题
- 5.2 方案二(异步操作)
- 5.2.1 预占库存
- 5.2.2 扣减库存
- 5.2.3 查询库存
- 5.2.4 取消库存
- 5.2.5 超时释放库存
- 5.2.6 异步更新动态库存
- 5.2.7 异步比对缓存
- 5.2.8 问题
- 5.3 方案三
- 5.3.1 预占库存
- 5.3.2 扣减库存
- 5.3.3 查询库存
- 5.3.4 取消库存
- 5.3.5 超时释放库存
- 5.3.6 MQ异步更新预占库存
- 5.3.7 问题
一、背景
某个票务系统比如12306占座,演出等, 流量最高的业务场景是在查询座位图和锁座环节,新的票务系统在优化后用了新的扣位占座系统,同时锁座扣位环节用新库存服务支撑,锁座&下单环节分别做预占、扣减库存操作,查询座位图由静态座位图加上实时座位图,静态座位图来自基础数据,实时座位图(预占+已占)来自新库存服务,票务库存与电商库存的区别在于电商库存只要控制加减避免超卖,而票务库存需要精确到座位,关注座位不重卖和少卖。
二、功能
- 查询库存:座位图查询库存
- 预占库存:锁座时预占库存
- 扣减库存:出票时扣减库存
- 释放库存:超时未支付释放&退款时释放库存
- 预留库存:保留座位,预留,或者后边再卖
三、业务流程
预占库存时机
常规电商都是在下单环节预占库存,支付成功后扣减库存,但票务在线选座有个前置环节是选座,所以预占库存可以前置到选座,而不是在下单环节,在支付成功后进行扣减库存操作。
恶意预占库存
如果有用户在开始恶意预占大量库存但不下单,导致票导致后续有大量票没有卖出?预占并不是实际扣减,后台系统会自动释放预占超过15分钟的库存,重新放出来售卖,这种在现有电商系统已经很常见了,但用户还是可以重新恶意预占,这种只能通过风控和反作弊行为来限制,具体方式有很多,用户限制,ip限制,手机PIN限制等。预占失败导致查询库存量变大
在热门场次会出现用户抢座,而抢座失败的用户会高频刷新座位图信息重新选座,导致查询库存的请求量瞬时增加3-5倍。
四、那么最终系统设计要遵循什么原则???
容忍动态座位图短暂不一致,接受最终一致性,必须保障高可用;
五 、具体设计
- 请求量最大的是查询库存,避免查库操作;
- 预占库存需要考虑多并发场景,防止重卖,可以用数据库唯一索引防止重卖。
- 定时释放超时的预占库存,防止少卖;
5.1 方案一
本方案完全基于同步操作
5.1.1 预占库存
- 预占库存不能完全靠mysql索引来防止重卖,需要用redis先做一层防重,这样可以最大保证数据库的请求量等于实际座位数;
- 在redis出问题时导致流量全部击穿到mysql,此时需要在mysql操作上加入流控熔断,宁可部分预占失败,也要保障服务可用;
- 先插入mysql后写入redis,防止写redis成功但插入mysql失败导致少卖;
5.1.2 扣减库存
支付成功后扣减库存,修改库存状态为已占用;
5.1.3 查询库存
查询库存时只查询redis里面的数据,不允许穿透到数据库出现数据;
5.1.4 超时释放库存
- 定时任务扫描状态为预占状态且超过15分钟的库存记录;
- reids操作失败则释放库存失败;
5.1.5 问题
redis和mysql出现不可用情况怎么办?
阶段 | redis不可用 | MySQL不可用 |
---|---|---|
1 | 查询库存 | 部分影响,业务可能出现重卖 |
2 | 预占库存 | 部分影响,业务可能出现重卖 |
3 | 扣减库存 | 无影响,业务正常出票 |
4 | 释放库存 | 部分影响,可能出现少卖 |
mysql不可用的情况是不能容忍的,会完全阻塞业务流程,所以数据库的击穿都需要有流控熔断防范措施;
redis不可用的情况确定可能出现2种业务场景,redis动态座位图数据少了导致部分重卖失败, redis动态座位图多了导致少卖,所以redis的数据准确性至关重要。
-
重卖情况如何保障redis数据的最终一致性?
只有1和2的业务场景下redis不可用时才会出现重卖,重卖底层有数据库唯一索引做保障,一旦出现重卖数据库会抛出索引重复异常(DuplicateKeyException),只要捕捉到异常再将库存补到redis就可以避免下次重卖,数据出现一次重复后就可达到最终一致性,如果没有异常出现但缓存数据一直不一致,也不影响业务,表示该场次没有用户选此座位。 -
少卖情况下如何保障redis数据的最终一致性
少卖会直接带来损失,如何保障不出现少卖至关重要,只有4的业务场景才会出现少卖,只要保障定时task能够重试就可以保障少卖的情况。 -
如何处理场次售罄?
当场次所有座位都预占或扣减,场次状态需要变成售罄,在售罄状态也可以变回售卖状态,此逻辑正常由场次服务负责,但库存可以在库存出现变化时异步周知场次服务。 -
关于查询库存的性能问题?
当前采用redis的set结构来做库存结构缓存,set的的SMEMBERS操作是一个O(N)的操作,在性能上还需要验证,秒杀最大的流量在于查动态座位图,每个场次的座位数在[200,1000]之间,SMEMBERS的性能问题会带来很大隐患,所以暂时废弃使用SMEMBERS查询动态座位图的方案。
5.2 方案二(异步操作)
本方案是基于异步操作设计
基于方案一的SMEMBERS操作性能问题,考虑到异步操作缓存库存来优化查询性能。
5.2.1 预占库存
相对于方案一,预占完成后引入MQ来异步记录已售座位图,避免多线程下修改缓存的同步问题;
5.2.2 扣减库存
类似方案一
5.2.3 查询库存
-
直接redis缓存查出已售座位图数量;
-
缓存动态座位图可能出现少卖情况,所以这里需要触发动态座位图定时更新机制,通过发送MQ异步更新。
5.2.4 取消库存
5.2.5 超时释放库存
相对于方案一,引入MQ来异步释放预占位图;
5.2.6 异步更新动态库存
MQ通过taskId来区分不同的partition(不同任务分组);
5.2.7 异步比对缓存
依赖查询库存触发定时比对动作,从redis拿到库存与数据库做比对,如果出现少卖情况发送mq删除redis中的数据。
5.2.8 问题
- redis和mysql、MQ出现不可用情况怎么办?
阶段 | redis不可用 | MySQL不可用 | MQ不可用 | |
---|---|---|---|---|
1 | 查询库存 | 部分影响,用户看到已售座位是未售状态,实际不出现重卖 | 无影响 | 无影响 |
2 | 预占库存 | 部分锁座流量被拒,实际不出现重卖 | 库存业务不可用 | 部分影响,用户看到已售座位是未售状态 |
3 | 扣减库存 | 无影响 | 库存业务不可用 | 无影响 |
4 | 超时释放库存 | 无影响 | 部分影响,可能出现少卖,重试保障 | 部分影响,可能出现少卖 |
5 | 取消库存 | 部分影响,座位图一定时间内不能售 | 部分影响,可能出现少卖 | 部分影响,可能出现少卖 |
-
异步更新动态座位图,用户看到的实时座位图会有多久的延迟?
每次异步更新座位图需要做setNX → get →set 3次缓存操作,如果每次按照50ms来计算,一个普通任务有200个座位,在秒杀情况下,最后一个用户看到完整实时座位图的耗时是 200*50ms=1s,单个场次秒杀最后一个用户抢座失败点击刷新座位图只要超过1秒就能看到准实时座位图,所以可以通过交互来一些优化,避免用户因为座位图更新不及时多次锁座失败的场景。 -
上述4和5的情况下会出现少卖,如何避免?
可以通过在查询座位图的逻辑,每隔N分钟去校验缓存数据和数据库数据是否一致,只校验少卖的数据,出现缓存中有而数据库没有的座位(少卖),可以发送到MQ移除缓存数据,来释放座位,为什么在查询座位图逻辑重触发?因为没人查询座位图就不会出现少卖的情况。 -
MQ如何保证 add 和 delete 操作的顺序性?
用户下单预占后,取消订单,预占库存和释放库存间隔较短,add和delete操作通过mafka异步同步到动态座位图缓存,无法保证操作顺序性,会有两种情况:1、先add再delete,正确,无影响;2、先delete再add,错误,会导致少卖,因add操作后就无法释放。这种情况通过定时更新机制来做。 -
满座如何做?
在创建对应任务时写入库存总量,每次出票时去修改库存量,当库存为0时主动发送MQ通知到管理系统,提供查询库存余量的接口。
5.3 方案三
本方案基于 异步+MQ
方案二是通过步骤7异步比对来达到缓存和数据库最终一致,从而防止少卖,但整个流程过于复杂,库存的各个步骤之间耦合很严重,不利于系统维护,方案二中会出现少卖情况都是因为预占库存没有释放,而已售库存不会导致少卖,所以是不是可以把缓存分为预占库存和已售库存,缓存的预占库存可以定时失效,从而保证数据定时刷新达到最终一致性。
- redis存入2个key,value分别是已售座位和预占座位信息
- 缓存预占座位信息设置N分钟的过期时间。
5.3.1 预占库存
- 相对于方案二,插入数据库前先从缓存获取已售座位,判断座位是否已售,减少数据库的压力;
- 发送MQ异步修改缓存中的预占库存;
5.3.2 扣减库存
同步修改缓存的已售库存,修改已售库存失败需要上游做轮训保障;
5.3.3 查询库存
查询缓存中的已售库存+预占库存;
5.3.4 取消库存
发送MQ异步更新缓存中的预占库存
5.3.5 超时释放库存
5.3.6 MQ异步更新预占库存
同方案二,预占库存设置了N分钟超时时间,每次更新做一次N分钟续签;
5.3.7 问题
- 预占缓存失效了怎么办?
1、如果是热门任务预占库存的频率会很高,而MQ异步更新预占缓存会做续签操作,可以避免预售场次缓存失效导致大量因动态座位图显示不准确锁座失败的情况;
2、在低峰区,如果用户A预占了场次,N分钟没操作,同时N分钟内也无其他用户预占库存导致释放了库存,此时B预占相同座位会出现预占失败的情况,这种情况刷新座位后该座位就会变成预占状态,所以低峰期会出现小概率的锁座失败。
- 预占缓存N设置几分钟合适?
1、正常预占座位有效时长是15分钟,比如12306的扣位时效是15分钟,如果N设置成15分钟最合理,但要考虑开始抢票前前15分钟会出现少卖case或恶意预占的情况导致场次真实少卖,所以建议N设置越小越好,但设置太小就会导致虚假重卖(用户锁座失败)的情况,伤害用户体验,需要在2者之间权衡,可以根据具体场景摸索设置。
- 如何保障缓存已售座位和数据库最终一致?
2、在退票和扣减库存操作时,保证redis的操作是同步的,操作redis失败就返回扣减、退票失败,由上游系统做重试保证数据最终一致性。
redis和mysql出现不可用情况怎么办?
接口/功能 | redis不可用 | MySQL不可用 | |
---|---|---|---|
接口/功能 | redis不可用 | MySQL不可用 | |
1 | queryStockByTask/查询动态座位图 | 接口不可用,c端展示静态座位图; | 无影响 |
queryStockDetail/下单前查询库存座位信息 | 直接查询db(考虑主从问题) | 缓存可用情况下无影响; | 缓存不可用时,业务不可用 |
2 | lockStock/预占库存 | 重卖问题由db索引保证;(注意db限流) | 业务不可用 |
3 | unlockStock/解锁预占库存 | ||
4 | submitStock/扣减库存 | 现状:获取分布式锁失败;预期:由db保证,业务无影响 | 业务不可用 |
5 | releaseStock/释放库存 | 不可用期间:由db保证购票流程正常短暂不可用:部分影响,缓存未及时更新出现少卖情况 | 接口不可用,恢复后会有少卖情况 |
6 | keepStock/保留库存 | 无影响 | 业务不可用 |
7 | cancelKeepStock/取消保留 | 部分影响,少卖情况 | 不可用期间:业务不可用恢复后,业务恢复正常,无少卖与重卖 |