欢迎光临汤雪华的博客

一个人一辈子能坚持做好一件事情就够了!坚持是一种刻意的练习,不断寻找缺点突破缺点的过程,而不是重复做某件事情。
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

前言

前面的文章,我介绍了Conference案例的业务、上下文划分、领域模型、架构,以及代码整体流程。接下来想针对案例中一些重要的场景,分别做进一步的分析。本文想先介绍一下Conference案例的核心业务场景 - 订单处理减库存的设计。

下单以及订单处理流程描述

  1. 下单过程
    • 预订者浏览某个已发布的会议;
    • 进入会议的详情页面,该页面显示了所有可预订的座位分类信息;
    • 预订者选择好要预订的座位分类,录入每个分类的预定数量;
    • 预订者点击提交按钮,提交下单请求到Server端;
  2. Server端订单处理过程
    • Server端Controller提交处理订单的命令到分布式消息队列(EQueue),然后后台的Command Processor就可以消费该命令并异步处理订单了。核心处理步骤:
      • 生成订单(初始状态);
      • 扣减库存(内部有预扣逻辑);
      • 更新订单状态;
    • Server端Controller发送命令后,立即重定向页面到查单订单处理结果页面,该页面会以轮训的方式查看订单处理结果;
  3. 用户等待订单处理结果
    • 如果下单成功(库存足够),预订者被导航到支付页面进行支付;预订者可以选择支付,也可以放弃支付;
    • 如果下单失败(库存不足),则提示用户下单失败,因为库存不足;
    • 如果轮训等待超时,则告诉用户暂时无法知道订单处理状态,然后当前页面继续定时(5s)轮训订单处理结果;
  4. 用户支付订单
    • 如果支付成功,则提示预订者订单处理完成,交易完成;
    • 如果拒绝支付,则关闭订单;
    • 如果超过规定时间(15分钟)未支付,则视作订单已过期,系统自动回收订单所预定的座位;
  5. 流程结束;

上面用文字描述了整个下单和订单处理以及支付的过程,而我们实际关心的核心还是服务端对订单处理的过程。所以下面我们就看看如何来进行代码实现。

订单处理Saga流程图

Conference案例中,服务端处理订单是采用CQRS Saga流程的方式实现的。一个Saga流程是一个事件驱动的业务流程,它的周期可能比较长,因为流程中某些步骤需要用户参与的。上图描述了服务端处理订单的正常处理逻辑。为什么说是正常处理逻辑,因为实际的代码比上面的流程图还要复杂一点,上面的流程图中没有画出库存不足、用户拒绝付款、或者付款超时等情况的处理。我觉得这些特殊的情况,只要读者自己看一下代码就能很快理解了。只要我们能够把正常的逻辑搞清楚,那我们心里就对整个订单处理的流程有了解了。

上图中,聚合根之间棕色的箭头表示Command,蓝色的箭头表示Event。Order Process Manager表示一个无状态的Saga流程管理器,它负责协调其他有状态的聚合根,负责整个订单处理的流程控制逻辑。从代码表现上来看,它的任务就是响应Event,然后发出下一个Command。然后Order, Conference, Payment三个聚合根分别表示订单、会议、支付。这三个聚合根分别封装自己的状态和业务规则。

订单处理之减库存的设计思路

库存信息在哪里维护

大家都知道,电子商务系统,订单处理时,核心的环节就是减库存。那我们首先要思考的问题是,库存在哪里维护呢?在我看了微软的Conference案例的代码后,发现它的库存信息是在Registration(订单处理)的上下文中维护的。当ConferenceManagement(会议管理)上下文中,对会议的库存有修改时,会通过事件异步同步到订单处理上下文。我在思考它这样设计的理由是什么,我能想到的唯一理由是,这样的好处是减库存时,就只需要在Registration当前的上下文中处理即可,这样就不需要依赖会议管理上下文了。但代价就是需要从会议管理上下文同步库存信息。

我个人认为,库存信息还是应该在会议管理上下文中维护比较合理,因为会议管理上下文的职责就是维护会议的基本信息以及会议的座位类型的实体信息。如果我们的库存管理没有独立为独立的上下文,那最合理的维护地方就是会议管理上下文。这样,一份数据就只需要在一个地方维护,不需要同步。然后当订单处理上下文需要减库存时,可以通过远程调用或者异步消息通信的方式来实现上下文之间的交互。

但实际的电商系统,比如像淘宝这种,由于库存管理也是一块复杂的业务,所以一般会独立出一个上下文,叫库存中心。然后这个库存中心独立于商品中心以及订单中心。当订单中心要求减库存时,只需要和库存中心进行交互即可。这样的方式,会让系统的职责更明确,商品中心不需要关心商品的库存了,只需要关注商品本身的属性信息即可。然后,本案例由于只是案例,所以没有独立出库存中心,即库存上下文。所以会议座位的库存管理放在会议管理上下文中。

我当初看微软的例子,第一反应就觉得把库存放到订单上下文不合理,因为我没见过这样的设计。然后我看到会议管理上下文里,它也对会议作为的库存做了管理,而且是源头(库存的第一手数据在会议管理上下文产生),另外,会议管理上下文还会发布会议。所以,这些都让我意识到,会议管理就是商品中心和库存中心的结合体。但是让我费解的是,微软自己自相矛盾了,居然为了bc之间尽量解耦,居然把库存信息同步到订单上下文了。这样的设计导致代码非常丑陋,我认为再怎么样也不能把库存放到订单上下文里。所以,最后才有了我的enode的conference这样的bc的划分的考虑。再联想到阿里的电商平台,库存上下文是独立于订单上下文的。而我这里的实现,只是偷懒了(因为只是案例),没有把库存上下文独立出来而已。

所以,库存上下文是合并到订单上下文比起合并到商品中心上下文更不合理。

库存怎么预扣

明确了库存所属的上下文后,我们接下来思考怎么实现减库存。减库存主要的问题是,在并发减库存的情况下,可能会出现超卖的情况。为了解决超卖的问题,一般主流的做法是采用预扣库存的方式,类似分布式事务的二阶段提交的过程。预扣的意思是先预扣库存,如果预扣成功,就可以通知用户下单成功,然后就可以让用户去付款了;如果预扣时发现库存不足,则提示用户库存不足。

然后,虽然是预扣,但因为大家同时预扣同一个会议聚合根的座位库存,所以还是会产生并发问题。但由于我们操作的是同一个聚合根,所以ENode框架帮我们确保不会有并发问题。我们先看看Conference聚合根内部关于座位的库存管理的设计实现。

如上面的代码所示,Conference聚合根里聚合了所有的座位类型子实体,每个座位类型维护了座位的名称、价格、数量;然后Conference聚合根里还维护了所有的预定记录,这个应该不难理解。MakeReservation方法就是Conference聚合根对外提供预定座位支持的方法。该方法接收一些要预定的项,以及一个预定的ID,表示这次预定是谁(实际上就是订单ID)要预定。该方法内部的逻辑是:

  1. 判断当前会议是否没有发布,如果没有发布,那是不允许预定的;
  2. 判断这个预定(reservationId)是否是重复预定,如果重复,也会抛出异常;为什么会出现重复预定,因为订单处理上下文是通过发送命令的方式来通知Conference进行预定的,而由于是分布式消息队列(EQueue),所以命令可能会被重复执行。
  3. 检查预定的座位明细是否为空,如果为空,就认为是无效的预定,抛异常;
  4. 接下来就是循环处理每个预定项,先检查预定项本身需要预定的数量是否无效(小于等于0),如果无效,则抛出异常;再从Conference聚合根里找到当前要预定的座位类型子实体;然后计算当前的座位类型是否有足够的可用库存,GetTotalReservationQuantity方法就是计算当前该座位类型总共已经预定的总数。如果库存不足,则直接抛出异常。当然这里直接抛出异常可能还是太草率了一点。因为真正的电子商务系统,应该会明确提示用户,哪些商品库存不足,是否要修改订单只购买剩余的库存。本案例为了让代码不会太复杂,所以简化了功能。只要被预定的座位类型出现一个库存不足,就认为下单失败了。
  5. 当所有的预定项都处理完成后,就可以产生“已预定”的领域事件了。注意,这里我们产生事件的时候,同时把当前每个座位类型剩余的库存数量也放在领域事件里了。这样的好处是,当Q端的Event Handler在更新Conference的读库时,不需要再计算了,直接用Update语句更新DB即可。这个设计大家可以参考下,这样的设计,体现了Domain中封装了一切数据更新的业务规则判断和逻辑处理,然后通过事件的方式通知Domain外部当前事件发生后,聚合根的当前状态(一定是第一手数据,不会是脏数据)是什么。这样外部的Event Handler的逻辑就非常简单了,都只需要简单的用Update语句更新DB即可(不用动脑子,呵呵)。

并发问题的处理

Domain不会考虑并发这种技术问题,它只关心自己的业务规则和数据一致性,完全从业务角度来写代码。我们可以看到Conference聚合根里封装了很多的规则和逻辑。然后Conference聚合根产生的Event持久化到EventStore时的并发问题,ENode框架会帮我们解决,应用开发者不用担心了。如果大家关心是怎么解决的,可以去看一下ENode我以前写过的一些介绍,核心思路是乐观并发控制(通过聚合根版本号)+ 自动重试的机制,这里我就不展开了。

通过上面的设计,我们知道每次预扣时总是会判断当前可用的库存,并且已经考虑了其他已经预扣了的订单;这就从业务逻辑上保证了不会出现超卖;然后ENode框架解决了并发问题,所以最后我们可以确保一定不会出现超卖的情况。

用户付款后怎么真正减库存

当预扣成功后,用户就会去付款,假如付款成功了,那系统就会自动提交之前的预扣记录,做真正的减库存。我们来看看逻辑是怎么样的:

CommitReservation方法是Conference聚合根用来提供支持提交减库存的方法。它接收一个要提交减库存的reservationId,通过该ID,先找到之前它预定的所有预定项,然后产生一个事件,事件中包含每个预定项所对应的座位类型的扣除后的库存数量,最后产生领域事件。然后聚合根内部会响应领域事件,更新聚合根自己的状态。我们在Commit阶段是不用担心数据有什么问题的,因为肯定是之前预扣过了,只要预扣记录存在,那就可以放心的做减库存逻辑的。这是我们通过业务上的2PC协议保证的。

代码很直接,就是先删除预定记录,并把预定记录的每个明细对应的座位类型的库存更新即可。然后,我们的读库的更新也是这样的逻辑,只是更新的是读库DB而已。

结束语

好了,本文基本把订单处理的核心环节减库存讲了一下,本来还想再结合订单状态的变更讲一下订单状态在这个过程中是如何变化的。但由于今天时间比较完了,不准备讲了。我在前面的领域模型的介绍中,已经基本讲了。