一篇文章彻底搞明白既熟悉又陌生的状态机

1,322 阅读16分钟

最近五阳在重构一个业务系统,状态机方案整理完成后有一些感悟,分享给大家。

1. 状态机规范

  1. 满足任意一个场景时,即可新增状态,否则不能增加。 1)该状态具备业务含义 2)该状态用于流程驱动 3)开始或结束状态。
  2. 状态含义要足够明确,避免一个状态有多个含义,发生歧义。(增加系统实现和理解成本)
  3. 用户前台状态机、系统实现状态机可以独立设计。即用户感知的状态机和系统实现的状态机可以独立设计,确保技术上增加 无业务含义的状态时,不影响用户状态机。系统实现时,可以使用两个状态属性或两个模型。(评判标准: 状态机转移复杂,无业务含义的状态多,且持续新增)
  4. 状态机需具备状态流水能力。即状态机转移时,需要记录状态转移,如前后状态和时间等。
  5. 状态间的转移关系需要全部收敛到状态机。如该退款前,校验状态是否可退款,此类逻辑收敛到状态机。
  6. 代码中不得引用状态的数值,只能引用状态机的枚举值。
  7. 业务代码中发生状态转移时,需要引用 状态转移常量 。如订单从支付完成转移到履约中,则引用常量PAY_SUCCESS_2_PERFORMING。方便梳理状态转移和流程的关系。
  8. 有状态机转移图或状态机登记文档 维护状态机逻辑。(二选一)
    • 状态机转移图:登记各个状态如何转移。如A状态可执行哪些流程,执行完成后,什么条件会进入到什么状态。
    • 具备状态机登记文档,方便后续梳理。文档中需说明所有状态的含义,如具备业务含义或者用于流程控制等,当前状态可执行的流程。哪些流程执行后,可达到本状态。

2. 何时增加一个状态

首先要明确,一个有效的状态势必符合这3类要求。符合其中之一,即可新增状态,否则不能增加。

  • 具备业务含义。用户感知的状态,或者其他具备业务含义场景,如具备业务含义的流程中修改了业务模型,可在流程执行前后增加两个状态,如退款流程前后修改订单状态 履约完成 -> 售后完成,即可增加两个状态。
  • 用于流程驱动
    • 中间状态,标识当前模型正在执行中的流程,处于数据不一致状态,失败需要再次重试。
    • 待XX状态,标识模型要执行的下一流程。
  • 初始或结束状态

2.1 什么是具备业务含义?

一个判断是否具备业务含义的简单方式,产品经理和用户能否理解状态的文案。例如订单状态处于已下单、已支付、已发货、配送中、已收货、已退款等等状态是明显具备业务含义的,产品经理都能明白。

具备业务含义的状态主要作用是标识模型所处的状态,无论技术上需要这个状态与否,在业务上肯定是需要这些状态的。所以这部分状态是业务逻辑的一部分。

2.2 什么是用于流程驱动?

一些状态的业务含义不太明显,但技术上出于流程控制,需要这个状态标识当前处于哪个流程节点。

在可重试的流程、异步执行的长周期流程中,需要有状态辅助流程控制。为什么呢?设想一个可重试且存在多个子流程的长流程,如果处理失败,系统第二次重试执行时,如何标识当前长流程处于的系统节点呢?一般会在各个子流程节点设置状态,标记当前所处流程节点。

如会员售后流程需要 冻结会员身份、冻结各类权益、冻结各类红包、触发退款、优惠资源退回、库存退回等多个子流程,当遇到偶发的超时情况系统再次重试时,会通过状态继续从失败的节点重新执行,避免从源头出发二次执行导致增加系统设计难度。

此外还有一些状态用于流程驱动。如下图所示,骑手派单流程中,配送单有一个单独的状态是待派单状态。

image.png

2.2.2 为什么拒单后转移到待派单而不是初始化状态?

骑手拒绝接单后,订单需要重新进行派送流程。为了让系统能够识别出需要进行派单的订单,我们可以增加一个新的待派送状态。另外,当我们仔细观察下图的初始化状态时,可以发现在这种状态下也可以触发派单流程。那么为什么在订单被拒绝后我们不将其回到初始状态,而是单独提出一个待派单状态呢?

首先,我们需要 确保状态机具备开始和结束状态。当状态机从开始状态转移后,通常不应再次回到初始状态。因为初始状态并没有业务含义,它只是作为创建数据库订单时的一个暂时状态。 根据业务状态机规范和流程执行,订单通常会立即转移到下一个状态。

其次,单独提出一个待派送状态可以让系统在处理时具备更明确的流程。这样更容易理解和维护。例如,在订单处于待派送状态时,系统可以自动触发派单事件,并将骑手分配给订单。通过这个明确的状态标识,我们可以更方便地进行相关处理。

2.3 为什么需要中间态

在分布式事务中,多个系统数据难以处于强一致性。在流程执行中如何标识双方的状态?可以增加进行中状态,标识处于数据不一致的短暂状态。待流程执行结束,进行中状态进入 成功或失败状态,系统又处于一个明确的状态。

若本地事务场景,可以通过数据库保持强一致性事务,则无需进行中状态。 如扣减库存和增加库存流水,无需库存扣减中状态、也无需库存流水增加中状态。而分布式事务则很难弄做到强一致性,则通过中间态表明当前系统处于数据不一致,需要或重试或回滚,让系统达成最终一致性。

系统重试场景,可以通过进行中标识当前系统处于的流程节点,例如冻结红包流程,增加冻结中状态,可以标识系统处于 冻结红包的流程,若冻结超时等情况,下次重试时,系统可以 定位到当前处于 冻结红包流程。而无需从头开始重试,可以极大简化系统设计。

3. 如何评估状态是全面的?

  1. 梳理业务模型的所有执行流程和当前的所有状态
  2. 画出状态转移图
  3. 按照状态机规范,逐一评估是否存在不满足规范的场景。

可能存在的问题

3.1 状态有多个含义存在歧义,职责不清晰

如骑手拒单后,系统需要重新派单,如系统将扭转到初始化,那么初始态就存在多种含义。1)待首次派单 2)待路由到供应商 3)可取消。

支付失败后,如果履约失败,系统需要自动发起售后。为了明确表示系统任务要对其发起售后,可以新增一个“待自动售后”状态。这样做的好处是将是否要发起售后和定时执行售后分离开,使系统设计更加简洁。如果未来某种履约失败的场景不自动发起售后,那么无需修改自动售后任务,仅需要修改状态机即可。

3.2 具备业务含义的状态不满足产品业务逻辑

如售后完成状态的业务含义是否符合产品业务逻辑。例如产品需要的售后完成态是 支付系统已经赔付用户,但是系统处理时,可能调用支付退款后,就转移到售后完成,因为支付处理一般都是异步的,所以技术实现的售后完成和产品预期的不一致。

一定要明确业状态的业务含义。

缺失中间状态

参考为什么需要中间状态。

4. 流程驱动状态,状态决定流程

状态机和流程驱动的关系是什么呢?

回归到业务,我们最关心的是业务系统状态机的这三类问题。

  • 当前状态,可执行哪些流程,例如订单列表中,根据订单状态,展示当前可对订单执行哪些操作。如订单已收货,可以发起售后、发起评价等。
  • 什么状态下能执行此类流程。当收到售后请求时,系统会检查当前订单的状态能否发起售后,系统就需要知道发起售后的前置状态是什么,当前订单是否满足售后前置状态。
  • 流程执行后进入什么状态。 当发起售后,流程执行完毕后,系统需要明确模型的下一状态是什么。

状态机驱动流程 VS 流程驱动状态机,我认为应该是流程驱动状态机的变化,但状态机定义了流程的合法执行顺序。

何谓流程驱动状态机,即在流程执行前后,数据模型一般处在两个状态A和B,流程执行完成后,状态从A转移到B。一般业务系统都是如此设计的,我认为也是合理且正确的。

所以我们说 流程驱动状态转移,状态决定了流程能否执行,即 流程驱动状态、状态决定流程

所谓状态机的核心能力,就是完成如上的三种验证,要把状态机的业务逻辑全部收敛到状态机Service中。

  1. 当前状态,可执行哪些流程
  2. 什么状态下能执行此类流程
  3. 流程执行后进入什么状态

5. 多业务模型的状态机如何保证数据一致

如果一次业务请求修改了多个数据模型的状态,那么一定要注意多个模型状态的数据一致性。

5.1 明确数据一致性规范

首先应该先根据业务特点,明确整体业务处理的数据一致性程度。

狭义上的数据一致是指:数据完全相同,在数据库主从延迟场景,主从数据一致是指:主数据副本和从数据副本,数据完全相同,客户端查询主库和查询从库得到的结果是相同的,也就是一致的。

除数据多副本场景使用数据一致性的概念之外,扩展后其他场景也使用这个概念。例如分布式事务中,多个事务参与者各自维护一种数据,当多种数据均处于合法状态且符合业务逻辑的情况下,那就可以说整体处于数据一致了。(并不像副本场景要求数据完全相同)

5.2 什么是强一致性

在分布式事务场景,强一致性是指:任何一个时刻,看到各个事务参与者的数据都是一致的。系统不存在不一致的情况。

值得一提的是,CAP理论指出,数据存在多副本情况下,要保证强一致性(在一个绝对时刻,两份数据是完全一致的)需要牺牲可用性。

也就是说系统发现自身处于不一致状态时,将向用户返回失败状态。直至数据一致后,才能返回最新数据,这将牺牲可用性。

为了保证系统是可用的,可以返回旧的数据,但是无法保证强一致性。

5.3 业务上一般追求最终一致性

确定最终一致性保障方案,可选方案包含有限重试、人工兜底、异常回滚等方案。一般情况下如果异常概率较低,可以考虑有限重试+人工兜底方式。

5.4 流程执行一定要保证幂等。

幂等机制有很多,我这里强调一个点,若插入失败、更新失败不要立即失败,应该再次检查是否是否已经插入成功或更新成功,若成功,系统应该按照幂等成功处理,不应立即失败。

5.5 如何实现系统可靠的重试

参考这篇文章,如何可靠的重试

聊一聊故障管理平台的建设

5.6 数据核对快速发现不一致

如何快速发现最终数据不一致的情况? 各个公司一般均实现了数据实时核对平台。

大致原理是,业务上当A模型处于状态x时,B模型应该处于状态y。 数据核对平台会监听A、B模型的binlog数据,如果在一定时间内没有关联上,那么数据就处于不一致,核对平台就发出警告。

6. 状态机和状态流水

状态流水记录了,某个订单何时 由何种状态转移到另一个状态。

记录状态流水有如下好处

  • 具备业务含义的状态流水。如用户可以查看订单何时出库、何时揽收、何时配送、配送节点等等,这依赖状态流水
  • 方便排查问题。通过查看用户订单的状态流水,可以查看用户的操作时间点,方便排查问题。
  • 系统业务逻辑的需要。

6.1 业务逻辑需要状态流水

如果业务定义履约成功和履约失败均可以转移到售后完成。如果系统需要知道当前订单是否履约成功过,因为当前状态为售后完成,也没有状态流水,所以无法知道这笔订单是否履约成功过。如果有状态流水,就可以查状态流水,判断订单是否履约成功过。

6.2 如何设计状态流水

状态流水的字段应该包含模型唯一键、转移前状态、转移后状态、转移时间。

但是往往系统存在某个模型均需要状态流水,是否重复建设多张状态流水表呢? 不需要重复建设,仅需要建设一张通用的表。通过增加类型字段,区分当前转移记录是A模型还是B模型即可。

6.3 何时新增状态流水?

一般情况下模型状态转移时记录状态流水即可,这样做有两个坏处

  1. 多个模型共用流水表,流水表的插入性能可能成为瓶颈。
  2. 多个模型共用流水表,可能引起死锁,影响其他模型。
  3. 修改状态和记录流水在同一个事务中,增加开发成本,性能也难以保障。

因此可以通过binlog异步增加状态流水,以上三个不足迎刃而解。

6.4 状态流水表还能做什么

新增状态流水时,可以检查系统是否发生了非法的状态转移,若发生了,记录异常日志,上报监控,发出告警。

7. 总结

  • 设计好业务系统的状态机并不是一个简单的事情
  • 架构师们需要保证状态机:含义正确、易于扩展、容易理解。
  • 要有明确的状态机规范,指导组内小伙伴一起达成三个目标。

我的开源项目

最后夹带一点私货,五阳最近花了3个月的时间完成一个开源项目。

开源3周以来,已有近 230 多个关注和Fork

Gitee:gitee.com/juejinwuyan…

GitHub github.com/juejin-wuya…

开源平台上有很多在线商城系统,功能很全,很完善,关注者众多,然而实际业务场景非常复杂和多样化,开源的在线商城系统很难完全匹配实际业务,广泛的痛点是

  • 功能堆砌,大部分功能用不上,需要大量裁剪;
  • 逻辑差异点较多,需要大量修改;
  • 功能之间耦合,难以独立替换某个功能。

由于技术中间件功能诉求较为一致,使用者无需过多定制化,技术中间件开源项目以上的痛点不明显,然而电商交易等业务系统虽然通用性较多,但各行业各产品的业务差异化极大,所以导致以上痛点比较明显

所以我在思考,有没有一个开源系统,能提供电商交易的基础能力,能让开发者搭积木的方式,快速搭建一个完全契合自己业务的新系统呢?

  • 他们可以通过编排和配置选择自己需要的功能,而无需在一个现成的开源系统上进行裁剪
  • 他们可以轻松的新增扩展业务的差异化逻辑,不需要阅读然后修改原有的系统代码!
  • 他们可以轻松的替换掉他们认为垃圾的、多余的系统组件,而不需要考虑其他功能是否会收到影响

开发者们,可以择需选择需要的能力组件,组件中差异化的部分有插件扩展点能轻松扩展。或者能支持开发者快速的重新写一个完全适合自己的新组件然后编排注册到系统中?

memberclub 就是基于这样的想法而设计的。 它的定位是电商类交易系统工具箱, 以SDK方式对外提供通用的交易能力,能让开发者像搭积木方式,从0到1,快速构建一个新的电商交易系统!

image.png

具体介绍可参见

Gitee开源地址gitee.com/juejinwuyan…

GitHub开源地址 : github.com/juejin-wuya…

在这个项目中你可以学习到 SpringBoot 集成 以下框架或组件。

  1. Mybatis、Mybatis-plus 集成多数据源
  2. Sharding-jdbc 多数据源分库分表
  3. redis/redisson 缓存
  4. Apollo 分布式配置中心
  5. Spring Cloud 微服务全家桶
  6. RabbitMq 消息队列
  7. H2 内存数据库
  8. Swagger + Lombok + MapStruct

同时你也可以学习到以下组件的实现原理

  1. 流程引擎的实现原理
  2. 扩展点引擎实现原理
  3. 分布式重试组件实现原理
  4. 通用日志组件实现原理 参考:juejin.cn/post/740727…
  5. 商品库存实现原理: 参考:juejin.cn/post/731377…
  6. 分布式锁组件: 参考:
  7. Redis Lua的使用
  8. Spring 上下文工具类 参考: juejin.cn/post/746927…