参考文档:《高德打车通用可编排订单状态机引擎设计》

问题背景

日常的业务开发工作中经常会遇到各种状态流转的场景,其中不乏一些状态多、链路长、逻辑复杂的情况,还存在多场景、多类型、多业务维度等业务特性。在保证状态流转稳定性的前提下,可扩展性和可维护性是我们需要重点关注和解决的问题。

当状态、类型、场景,以及其他一些维度组合时,每一种组合都可能会有不同的处理逻辑,也可能会存在共性的业务逻辑,这种情况下代码中各种if-else肯定是无法想象的。如何有效处理这种“多状态+多类型+多场景+多维度”的复杂状态流转业务,同时能保证整个系统的可扩展性和可维护性?

实现方案

要解决“多状态+多类型+多场景+多维度”的复杂状态流转业务,我们从纵向和横向两个维度进行设计。纵向主要从业务隔离和流程编排的角度出发解决问题,而横向主要从逻辑复用和业务扩展的角度解决问题。

一、纵向解决业务隔离和流程编排

状态模式解决多维度组合业务场景的业务隔离

通常我们处理一个多状态或者多维度的业务逻辑,都会采用状态模式或者策略模式来结局,其核心其实可以概括为一个词“分而治之”,抽象一个基础逻辑接口、每一个状态或者类型都实现该接口,业务处理时根据不同的状态或者类型调用对应的业务实现,以达到逻辑相互独立互不干扰、代码隔离的目的。

这不仅仅是从可扩展性和可维护性的角度出发,其实我们做架构做稳定性、隔离是一种减少影响面的基本手段,类似的隔离环境做灰度、分批发布等。

“多状态+多类型+多场景+多维度”组合业务我们通过“state + bizCode + sceneId + event”四个标识用来区分,各个标识的具体含义如下,其中bizCode和sceneId可以根据不同系统的实际业务范畴自行划分和定义。

  • state:当前处理器要处理的状态
  • bizCode:业务类型,如产品或订单类型
  • sceneId:业务场景,如业务形态或来源场景
  • event:触发状态迁移的事件

根据以上的说明,状态机模式可以简单总结为:基于某些特定业务和场景下,根据源状态和发生的事件,来执行下一步的流程处理逻辑,并设置一个目标状态。

状态机模式

状态迁移流程封装

状态流转的流程中,都会有三个流程:校验、业务逻辑执行、数据更新持久化。更加细化则会有:数据准备(prepare) -> 校验(check) -> 获取下一个状态(getNextState) -> 执行业务逻辑(action) -> 持久化(save) -> 后续处理(after)六个阶段。其中校验可以拓展为串行校验(serialCheck)和并行校验(parallelCheck),执行业务逻辑和持久化之间可以拓展出插件逻辑(plugins),用于执行一些公用的额外处理逻辑或是针对特定场景进行特殊化处理的逻辑。通过模版方法将六个阶段方法串联在一起、形成一个有顺序的执行逻辑。这样一来整个状态流程的执行逻辑就更加清晰和简单,可维护性也得到一定的提升。

状态迁移动作处理流程:

状态迁移动作处理流程

  1. 校验器

    任何一个状态的流转甚至接口的调用都少不了一些校验规则,尤其是对于复杂的业务,其校验规则和校验逻辑也会更加复杂。那么对于这些校验规则怎么解耦呢,既要将校验逻辑从复杂的业务流程中解耦出来,同时有需要把复杂的校验规则简单化,使整个校验逻辑更具有可扩展性和可维护性。其实做法比较简单,只需要抽象一个校验器接口checker,把复杂的校验逻辑拆开,形成多个单一逻辑的校验器实现类,状态处理器在调用checker时只需要调用一个接口,由校验器执行多个checker的集合就可以了。将校验器chekcer进行封装之后,加入一个新的校验逻辑就十分简单了,只需要写一个新的checker实现类加入校验器就行,对其他代码基本没有改动。

    考虑到性能问题,多个校验器chekcer串行执行性能肯定比较差,可以使用多线程并行执行多个校验器checker以提高执行效率。但同时需要注意到,有些校验器逻辑可能是有先后依赖的(其实不应该出现),还有些业务流程中要求某些校验器的执行必须有先后顺序,还有些流程不要求校验器的执行顺序但是要求错误时的返回顺序,那怎么在并行的前提下保证顺序呢,此处可以用order + Future进行实现。通过一系列思考和总结,我们把校验器分为参数校验(paramChecker)、同步校验(syncChecker)、异步校验(asyncChecker)三种类型,其中参数校验paramChecker是需要在状态处理器最开始处执行的,因为如果参数都不合法了肯定没有继续向下执行的必要了。

    校验器

    chekcer的定位是校验器,负责校验参数或业务的合法性,但实际编码过程中,checker中可能会有一些临时状态操作,例如在校验之前进行计数或者加锁操作、在校验完成后根据结果进行释放,这里就需要支持统一的释放功能。

  2. 上下文

    整个状态迁移的几个方法都是使用上下文Context对象串联的。Context对象中一共有三类对象:1. 业务领域对象的基本信息(ID、状态、业务属性、场景属性);2. 事件对象(其参数基本就是状态迁移行为的入参);3. 具体处理器决定的泛型类。一般要将数据再多个方法中进行传递有两种方案:一是使用ThreadLocal进行包装,每个方法都可以对当前ThreadLocal进行赋值和取值;另一种是使用一个上下文Context对象作为每个方法的入参传递。

    两种方案都有一些优缺点,使用ThreadLocal其实是一种“隐式调用”,虽然可以在“随处”进行调用,但是对使用方其实是不明显的,在中间件中会大量使用,在开发业务代码中是需要尽量避免的;而使用Context作为参数在方法中进行传递,可以有效减少“不可知”的问题。

  3. 迁移到的状态判定

    为什么要把获取下一个状态(getNextState)抽象为单独一个步骤,而不是交由业务自己进行设置呢?原因是要迁移到的下一个状态不一定是固定的,可能是根据当前状态和发生的事件,在遇到更加细节的逻辑时也可能会流转到不同的状态。举个例子,当前状态是用户已下单完成,要发生的事件是用户取消订单,此时根据不同的逻辑,订单有可能流转到取消状态、也可能流转到取消待审核状态、甚至有可能流转到取消待支付费用状态。这里要取决于业务系统对状态和事件定义的粗细和状态机的复杂程度,作为状态机引擎,这里把下一个状态的判定交由业务根据上下文对象自己来判断。

状态消息

一般来说,所有的状态迁移都应该发出对应的消息,供下游消费方订阅进行相应的业务处理。

  1. 状态消息内容

    对于状态迁移消息的发送内容通常有两种形式,一个是只发状态发生迁移这个通知,例如只发送“订单ID、变更前状态、变更后状态”等几个关键字段,具体下游业务需要哪些具体内容再调用相应的接口进行反查;还有一种是发送所有字段出去,类似于发一个状态变更后的订单内容快照,下游接到消息后几乎不需要再调用接口进行反查。

  2. 状态消息的时序

    状态迁移是有时序的,因此很多下游依赖方也需要判断消息的顺序。一种实现方案是使用顺序消息(RocketMQ、Kafka等),但基于并发吞吐量考虑很少采用这种方案;一般都是在消息体中加入“消息发送时间”或者“状态变更时间”字段,由消费方自己进行处理。

  3. 数据库状态变更和消息的一致性

    • 状态变更需要和消息保持一致吗?

      很多时候是需要的,如果数据库状态变更成功了,但是状态消息没有发送出去,则会导致一些下游依赖方处理方处理逻辑的缺失。而我们知道,数据库和消息系统是无法保证100%一致的,我们要保证的是数据库状态变更了,消息就要尽量接近100%地发送成功。

    • 怎么保证?

      通常有几种方案:

      • 使用RocketMQ等支持的两段式消息提交方式:

        1. 先向消息服务器发送一条预处理消息
        2. 当本地数据库变更提交之后,再向消息服务器发送一条确认发送的消息
        3. 如果本地数据库变更失败,则再向消息服务器发送一条取消发送的消息
        4. 如果长时间没有向消息服务器发送确认发送的消息,消息系统会回调一个提前约定的接口,来查看本地业务是否成功,以此决定是否真正发送消息
      • 使用数据库事务方案(一)

        1. 创建一个消息发送表,将要发送的消息插入到该表中,同本地业务在一个数据库事务中进行提交
        2. 之后再由一个定时任务来轮询发送,直到发送成功后再删除当前表记录
      • 使用数据库事务方案(二)

        1. 创建一个消息发送表,将要发送的消息插入到该表中,同本地业务在一个数据库事务中进行提交
        2. 向消息服务器发送消息
        3. 发送成功则删除掉当前表记录
        4. 对于没有发送成功的消息(也就是表里面没有被删除的记录),再由定时任务来轮询发送

        数据库事务方案

      • 数据对账,对不一致的数据进行补偿处理,保证数据的最终一致性。

    其实不管使用哪种方案来保证数据库状态变更和消息的一致,数据对账的方案都是“必须”要有的一种兜底方案。

二、横向解决逻辑复用和业务拓展

实现基于“多类型+多场景+多维度”的代码分离治理、以及标准处理流程模版的状态机模型之后,在真正编码的时候会发现不同类型不同维度对于同一个状态的流程处理过程,有时多个处理逻辑中的一部分流程是一样的或者是相似的。例如支付环节不管是采用免密支付还是其他方式,其中核销优惠券的处理逻辑、设置发票金额的处理逻辑等都是一样的;甚至有些时候多个类型间的处理逻辑大部分是相同的而差异是小部分,比如下单流程的处理逻辑基本逻辑都差不多,而出租车对比网约车可能就多了出租车红包、无预估价等个别流程的差异。

对于上面这种情况,其实就是要实现在纵向解决业务隔离和流程编排的基础上,需要支持小部分逻辑或代码段的复用,或者大部分流程的复用,减少重复建设和开发。对此我们在状态机引擎中支持两种解决方案:1. 基于插件化的解决方案;2. 基于代码继承方式的解决方案。

基于插件化的解决方案

插件的主要逻辑是:可以在业务逻辑执行(action)、数据持久化(save)这两个节点前加载对应打的插件类进行执行,主要是对上下文Context对象进行操作、或者根据Context参数发起不同的流程调用,已到达改变业务数据或流程的目的。

插件化解决方案

  1. 标准流程 + 差异化插件

    上面讲到同一个状态模型下、不同的类型或维度有些逻辑或处理流程是一样的小部分逻辑是不同的。于是我们可以把一种处理流程定义为标准的或默认的处理逻辑,把差异化的代码写成插件,当业务执行到具体差异化逻辑时会调用到不同的插件进行处理,这样只需要为不同的类型或维度编写对应有差异逻辑的插件即可、标准的处理流程由默认的处理器执行就行。

  2. 差异流程 + 公用插件

    对于小部分逻辑和代码可以公用的场景,也可以用插件化的方案解决。比如对于同一个状态下多个维修下不同处理器中、我们可以把相同的逻辑或代码封装成一个插件,多个处理器中都可以识别加载该插件进行执行,从而实现多个差异的流程使用想用插件的形式。

基于代码继承方式的解决方案

当发现新增一个状态不同维度的处理流程和当前已存在的一个处理器大部分逻辑是相同的,此时就可以使新写的这个处理器B继承已存在的处理器A,只需要让处理器B覆写A中不同的方法逻辑,实现差异逻辑的替换。这种方案比较好理解,但是需要处理器A已经规划好一些可以扩展的点,其他处理器可以基于这些扩展点进行覆写替换。当然更好的方案是,先实现一个默认的处理器,吧所有标准处理流程和可扩展点进行封装实现,其他处理器进行继承、覆写、替换就好。

三、状态迁移流程的执行流程

状态机引擎的执行过程

状态机引擎通过两个阶段将状态流程编排、业务隔离以及扩展这几个过程串联起来:初始化阶段和运行时阶段。

  1. 初始化阶段

    在系统初始化,所有特定状态处理器都会被Spring管理成为Spring Bean,状态机引擎通过监听Spring Bean的注册(BeanPostProcessor)来将这些状态处理器processor装载到自己管理的容器中。简单来说,这个状态处理器容器其实就是一个多层map实现的,第一层map的key是状态(state),第二层map的key是状态对应的事件(event),一个状态可以有多个要处理的事件,第三层map的key是具体的场景code(bizCode和sceneId的组合),最后的value是抽象状态处理器集合。

  2. 运行时阶段

    经过初始化之后,所有的状态处理器processor都被装载到容器。在运行时,通过一个入口来发起对状态机的调用,方法的主要参数是操作事件(event)和业务入参,新建记录请求需要携带业务(bizCode)和场景(sceneId)信息,如果是已存在记录的更新,状态机引擎会根据记录ID自动获取业务(bizCode)、场景(sceneId)和当前状态(state)。之后引擎会根据state+event+bizCode+sceneId从状态机处理器容器中获取到对应的具体处理器porcessor,从而进行状态迁移处理。

检测到多个状态执行器怎么处理

当根据state+event+bizCode+sceneId信息获取到的是多个状态处理器processor,有可能确实业务需要单纯依赖bizCode和sceneId两个属性无法有效识别和定位唯一processor,那么这里给业务开一个口,由业务决定从多个处理器中选一个适合当前上下文的。

如果通过业务过滤之后,还是有多个状态处理器符合条件,那么只能抛异常处理了。这个需要在开发时,对状态和多维度处理器有详细规划。

状态机引擎处理流程

运行时的状态机执行过程如下:

状态机引擎处理流程

状态处理器的原理

状态处理器的原理

四、其他问题

  1. 状态流转并发问题怎么处理?

    • 在状态机的sendEvent入口处,针对同一业务领域对象加锁(redis分布式锁),同一时间只允许有一个状态变更操作进行,其他请求进行排队等待。
    • 在数据库层对当前state做校验,类似乐观锁方式。最终将其他请求抛出错误,由上有业务进行处理。
  2. 能不能动态实现状态流程的切换和编排?

    可以通过将state、event、bizCode、sceneId、processor通过数据库来保存,初始化时从数据库加载后进行处理器的状态。但一般来说,状态流转是较为核心的业务,一旦因变更导致故障是不可想象的,因此不推荐动态实现状态流程的切换和编排。