这个话题听起来比较抽象,但是事实上在我们的日常做设计和业务建模的时候是经常用到的。最直观的例子就是订单系统里1)我们需要维护订单的不同状态,以及2)我们需要一些外部“机制”来改变订单的状态。比如订单有“未付款”,“已付款”,“派送中”,“已送达” 等状态,而我们需要根据供应链的状态来改变订单的状态。那前者就对应一个状态机而后者就是一个工作流。另外如果我们看 etcd 或者是使用 etcd 来实现分布式存储也是一样的,etcd 里的 raft 状态机就是一个工作流,会给我们的存储提供不同的指令;同时我们自己的存储就是一个状态机,根据 etcd 传来的指令来转换自己的状态(或者是做不同的事情)。
上面举了一下状态机和改变状态的例子。我们在改变状态的时候不一定是一个完全同步的操作。还是用订单来做简单的举例。如果我们想把订单从“已支付”改成“已退款”,那么其实要先把状态变成“退款中”,然后去调用支付系统的 API,那个系统可能跟银行打交道,可能跟其它的中台交互,很有可能我们没法得到一个确切的退款成功或者失败的信息,那就需要我们启动一个类似于定时任务的进程,定期地去支付系统里获取这次退款的状态,然后再更新订单的状态。这个查询退款的定时任务,我们就叫做工作流。所以这个地方先总结一下,如果状态机的状态需要根据外部而做出改动的话,我们就可以使用一个工作流来管理。当然这个工作流可能在执行的时候出现各种问题,比如 host 工作流的服务器挂了,比如调用的系统挂了了,甚至是自己本身超时了。因此我们希望可以重新启动这个工作流来继续维护状态机的状态变化。因此我们需要“启动这个工作流”的操作是幂等的,并且重新启动的工作流可以跑到上回失败的地方继续运行,直到成功或者下一次的失败。也就是说,这个工作流是可重入的。这个“外部”可以指别的系统,也可以是时间等一些客观因素。比如说订单超过 30min 还没有付款就转入超时状态,之后就无法付款。
* 如果举个根据内部做出状态改动的例子,你可以想象一个一主多从的集群,集群的状态从正常到选主再到正常是内部节点互相通信来完成的,并不是根据外部做出改动。因此我们的模型并不适用。
* 我们为什么需要一个“退款中”的状态,一般情况下这种方法可以给用户一个更好的提示以及业务方一个更好的统计。而在我们的设计里,这相当于一个订单级别的锁,来处理并发问题。
上面介绍了状态机,工作流和重入的概念,下面还剩下最后一个 term,就是可扩展。这里主要是指,状态机和工作流的实现可以以最小的代价来实现状态机里状态的增加和状态转化的变更。
我们终于把问题给定义清楚了,下面就来看看需要怎么解决这个问题。扣一下题,“实现可扩展的状态机和可重入的工作流”。
从状态机入手
第一步还是把对应的状态机给画出来
然后我们要根据业务来确定都有哪些场景会做状态变更。例如,付款,投递,退款。以付款为例,业务场景就是说,客户下单以后我们要轮训下游的支付结果,直到结果为已支付/已失败/已超时。考虑到支付失败的场景我们会触发告警而且不属于业务状态,因此涉及的状态变更如下,
那么我们的 workflow 就应该是如下,
我们定义一个工作流的成功,就是指它走到结束状态。但是中间任何一个节点它都可能挂掉,同时订单的状态也可能被别的工作流所改变。想象一种场景,工作流在设置订单为已支付而没有结束的时候就挂掉了,而且同时别的工作流把订单的状态改为了退款中或者是投递中,那我们在重启这个工作流,当这个工作流重新走到失败的那一步时,我们更新订单状态的时候就需要额外地谨慎,以避免订单状态回滚为已支付。我们采用的办法是根据状态机来定义一个 changeState 方法,这个方法需要做 conditional put,也就是说,在改变订单状态之前,先判断当前的状态是不是合法的前置状态。还有我们在 bootstrap workflow 的时候也需要进行状态检查,逻辑就是订单的状态属于这个 workflow 里出现的状态,才会进入 workflow。
另外要说明的一点是,如果订单超时后支付状态变成已支付的话会怎么样呢?我们的处理方法就是避免这种场景的发生,以保证“只要订单超时了,那么支付一定是超时的状态”。要实现这个保证,我们需要把支付的超时时间设置得短于订单的超时时间。我们在工程里的实践是留出20%-30%左右的缓冲,如果订单超时是 30min 的话,支付的超时就设置为 20min。
另外一个要注意的点就是我们在把订单设置为已支付之后需要去记账。考虑到工作流的可重入性,记账的操作需要做成幂等的。
定义一个工作流总是简单的,但事实上我们针对订单需要定义不同的工作流来更改状态。比如当订单变为已支付后,我们就需要启动一个新的工作流来轮训投递状态。如果收到了客户那边的退款请求,我们还要去触发一个退款的工作流。多个工作流可能会同时存在来修改订单状态。因此我们下一个要考虑的问题就是,并发。
我们不去实现每个具体的工作流了,而是从状态机上分析一下需要注意什么以及怎么解决。
投递涉及到的状态
退款涉及到的状态
我们可以看到,已支付和投递中这两个状态是两个工作流都会涉及到的,因此这两个状态就会存在 data race 的问题。针对退款这边的操作,我们是加入了一个状态叫 “退款中”,相当于是加了一个 order 级别的分布式锁。这样就可以保证,一旦订单变为退款中,那么订单肯定不会变为已投递。如果没有这个状态的话,可能支付系统还在退款的过程,那边的投递系统就已经把状态设置为已投递了。另一方面,在投递系统这边确实没有办法从系统层面完全解决这边已投递,那边触发退款的 data race。考虑到我们提供的是虚拟产品,我们跟业务部门约定好,假如商品投递的 SLA 是 5h 的话,我们只允许在 3h 内退款,以最大限制避免 data race。另一个解决办法是把 delivered -> in refund 用线给连起来,但是这样的话对我们系统的影响更大。因此没有采用这种方法。
总结
- 画出状态机并从状态机入手设计工作流
- 状态机的状态变更要用 conditional put
- 主动触发的 data race 可以通过添加中间状态来实现分布式锁
- 轮训外部系统触发的 data race 通过时间长短不一而留出 buffer,或者跟 stakeholder 讨论变更需求
- 调用的下游系统需要幂等
- 调用下游系统和修改状态没有一个严格的先后次序,取决于业务需求