出库重构--完善的最终一致性保障和100倍tps提升

1.业务介绍 业务介绍 1.图书业务中有两类场景,1购买图书,2回收的图书有次品需要寄回给用户,都需要进行出库。图书的isbn根据新旧区分之后,就是图书商品的最小库存单位。出于在一些场景下整理库存以及有些业务方需要分清楚具体是sku下的哪一本书,所以每一本书还有一个sn码进行标示,sn也是图书库存的实际核算依据。所以图书的出库就是在接收到出库请求后,进行sn匹配,匹配到即为有货,然后根据配送地址和业务类型进行打包,调用中台发送出库单。

业务难点 1.每一本书出库时都需要匹配sn,现阶段的方式是采取数据库锁的方式,在某个请求出库时,查询即对相应的isbn进行加锁,然后获得sn,更新数据后才释放锁,如果对相应isbn请求较多,可能导致其它请求阻塞等待时间较长,引发业务方调用超时。

2.业务对一致性的要求很高,购买记录中的出库状态,sn的占用状态,出库记录的状态必须一致,购买记录占用的sn,和sn表中被使用的sn需要一致。任何的不一致情况,都需要大量的相关同学大量的时间和精力去做排查和数据修复。

2.重构方向 1.业务建模方向 1.区分出库业务和购买业务,将业务和出库相关的代码分离,出库只做出库的事情,业务相关的东西丢回到业务方处理。

2.规范出库api层,出库相关的调用只能使用api中的方法,避免出库的代码遭到入侵。

3.修改同步为异步,将之前同步的很多方法拆成最小单元的一步,每一步从指定状态到对应状态,每一步保证自身业务数据的一致性。

2.sn分配方向 sn分配的重构踩坑许多,将在后文详述。

3.一致性方向 在每一步已经被拆到很小的基础上,每一步所做的都是查询出数据,然后进行逻辑计算,然后调用原子层,原子层通过事务包着,保证每一步的数据在可预料的前提下运转。比如sn匹配job,查询出所有待匹配的出库记录详情,每一条数据单独去匹配sn,匹配到的进行更新,修改详情记录和修改sn记录在一个事务内,成功准备由下一个job进行下一步操作,不成功该job会继续进行重试,所有的数据会在我们允许的一致状态中运转。

3.业务架构

4.状态流转

补充:1.sn预订后,可以调用提供的确认接口,状态将会变为待合并。

2.出库回调和取消业务相对复杂,涉及的状态修改分支较多,在图中显示很难清晰表述,以后有机会补充详述。

3.各个状态流转之前的逻辑处理,如合并job的算法等,为了避免文章过于冗长暂且省去。

5.sn分配迭代心路 1.最初的设计版本中,只需要根据状态筛选出需要进行匹配的数据,然后根据isbn取余,然后保证相同isbn的详情只会在唯一的线程中进行匹配。比如isbn为1结尾的详情,会被发到唯一的机器上进行匹配,对应的sn也只会在唯一的一台机器上被查询出来,这样避免了并发的问题,实现了无锁匹配。

2.第一版的修改方案,通过异步,数据分片等方式提高了性能保证了一致性,但是在后续的一次讨论中,我们发现一个较为严重的问题,根据业务背景,如果是第三方接入出库那么会在匹配到sn后才减库存,从查询库存到插入记录再到异步执行sn匹配之间的时间差会很容易导致超卖。比如第三方搞一个图书拼团,很容易出现用户拼好团,下单后才发现没有库存(没有sn可以分配)可以出库。从这个问题还引发一个问题,出库系统只能做到,业务方插入出库请求,有则匹配,无则失败。没有办法在调用出库时同步的告诉调用方,是否可以出库给定数量的书籍。

3.虽然重构的出库系统足够支撑目前的业务场景(图书购买业务的设计本身就是容忍超卖的),但是如果出库系统不能够支持未来第三方需要同步得到结果的场景,那么本次出库重构的意义也将会大打折扣。接下来就是彻夜常思和周末的连续加班。

4.首先想到的方案是,匹配sn的关键是同一个sn只能够被用来和一个出库记录详情进行匹配。那么如果所有的sn都从同一个地方取的话,既可以避免匹配sn的竞争,也无需数据库加锁。首先想到的方案是使用redis的list结构来做,但是这个方案有很大的弊端,1.查询出sn,压入list的过程需要加锁,那么各个线程在获得sn的时候,会在初始化list的地方阻塞,如果选择等待会有最早版本接口超时的风险,难道直接返回失败?2.sn记录也是在不断变化的,需要考虑在sn新增之后,这些sn可以被压入list中,且list中不能有重复的sn。(只保留了主要的思考过程)

5.使用list会极大的增加系统的复杂性,思考后发现可以利用redis的set结构来处理,将每个isbn对应的sn压入每一个set中,set结构不允许重复数据,加上pop的原子性操作,会很好的满足业务场景。使用set后不需要进行加锁,各个业务也不需要阻塞在初始化方法外,但是如果在并发操作下,两个线程对set进行scrad操作都为零,都进入初始化,第一个线程查询出的sn集合第一个sn被pop,第二个线程又查询出来压入set,就会出现不一致。

6.解决的方案是采取了两个set的方式。初始化查出来的数据会压入两个set中,第一个用来做防重复的,第二个是实际消耗的。想要压入第二个set,必须sn不存在于第一个set中。

7.竞争条件解决了,但是放重复的set数据不能一直留着占用内存。所以一开始采取的方案是如果从第二个set中pop出来的数据为空,则删除掉防重复的set。然后引发了新的并发问题,我们假设有三个线程,第一个线程从set中pop出了最后一个sn,调用原子层处理中,第二个线程pop发现sn为null,删除了放重复的set,第三个线程正在查询可用sn,查询到第一个线程用的sn(第一个线程的sn还没有更新完成)压入了set之中。

8.直接说解决方案,防重复set采取zset的方式,当pop出的sn为null时,通过redis延时队列的方式删除六分钟之前的该放重复set的数据,且当调用原子层接口成功时,单独从防重复set中删除该数据,此步骤不保证成功。

(这个过程中还考虑了,通过redis锁避免在数据为空的时候,多次查库的问题,为避免冗长省去)

9.经过此次重构,线上匹配耗时已经稳定在几毫秒内,配合forkjoin框架,每次请求即使为几十个isbn(接口要求每次的idbn一致),耗时也不会有显著变化。每个子订单为一个isbn,粗略估算单机每秒可承受上万笔订单,并且因为架构的调整,可以支持伸缩扩展,希望图书早日tps超过一万。

6.并发场景

为了更加清晰的明确系统内可能存在的并发问题,通过行为状态图的方式,在同一状态下,如果有定时任务和api需要同时进行,就需要考虑并发问题。

7.其它问题 因为图书业务本身是支持用户退货(调用取消出库)和取消退货(再出库)。所以所有的出库记录都要允许正向逆向反复操作,如果用户调用取消接口失败,取消接口调用失败,取消job会稍后重试,过程中如果用户取消退货,这次操作有可能会被取消job的重试覆盖。

如果用户调用取消接口超时,稍后mq重试,再重试之前用户调用了取消退货(再出库),那么最后的业务结果可能与用户的实际行为不同。

类似上面说的问题还有很多很多(在大神上有备注),都是一步步趟过来的。本文主要讲述业务的整体设计思考和sn匹配的具体实现,其它问题以后有机会再对实现过程进行详述。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值