从自动机到支付秩序:状态机的一次大历史考察
一、一个枚举字段所处的长时段
今日的软件工程师谈状态机,多半从一张表或者几个枚举开始。订单有 CREATED、PAID、CLOSED,支付有 PROCESSING、SUCCESS、REFUNDED;若有人试图把 REFUNDED 改回 PROCESSING,程序便报出一个错误。照这种理解,状态机不过是一种比较整齐的条件判断,所做之事无非是把散落各处的 if-else 收拢到一处。
这个理解在日常编码中够用,放进更长的时间尺度里就显得不足。若把状态机放回二十世纪以来计算、通信与组织管理的连续变化中观察,它真正处理的不是几个枚举值,而是一个现代系统如何认识时间:一个对象过去发生过什么,其中哪些历史仍然有效,谁有资格改变现状,这次改变成立以后又要留下何种凭据。
先看规模较小的情形。一个小作坊里的机器,操作员可以守在旁边。他知道闸门刚才开过没有,知道材料是否已经装入,也知道某次异常是第一次发生还是刚刚处理过。此时机器的状态虽然客观存在,却寄存在人的记忆与现场秩序之中,不一定要被单独编号。一个地方商铺的赊账也是如此。掌柜认得顾客,知道哪笔钱已付、哪笔尚欠,账本与人情可以互相补充,账目上的空缺由熟人关系填平。
这种安排的成立条件很苛刻:参与者数目有限,事情发生在同一个空间,信息传递几乎没有延迟,出了差错还有一个人可以负责。系统一旦越过某个规模,条件就不再成立。电话交换不可能要求一名总管记住所有线路上一刻的情形;数字控制单元不能在每次输入到来时找工程师询问;一笔跨境支付要经过客户端、支付服务、渠道、消息队列、账务系统和人工对账,更不可能有一个人始终站在全局之外,准确知道每笔交易此刻走到了哪里。
规模扩大以后,现代组织反复在做同一件事:把原先寄存在人脑、现场和默契里的判断,转换成可以编号、记录、传递和审核的事实。仓库把货物分成待收、已上架、已锁定、已出库;银行把一笔交易分成受理、处理中、成功、失败、冲正;政府把申请分成待补正、受理、审核、批准、驳回。分类的名目各不相同,动作却是一致的:先切分阶段,再为阶段命名,最后规定每个阶段允许做什么。状态机正是这种把过程变成可管理数目的技术形式之一。
从这个角度看,状态机与现代官僚制、复式账簿和工业标准之间有一种结构上的亲缘。它们都试图把依赖个别人员经验的连续现实,切成若干可以共同理解的阶段;把“我大概记得已经做过”变成“系统中存在一条持久证据”;把临场处置变成一套任何合格执行者都应遵守的规则。复式账簿让财产的变动可以互相校验,工业标准让零件脱离特定车间仍能装配,状态机让一个对象的生命周期脱离特定经办人仍能推进。它们要对付的都是同一件事:人会忘记、会离职、会看错,而系统还得继续运转。
必须说明的是,这并不意味着状态机起源于行政管理。它的直接源流在数学、神经科学与电路工程,几位关键人物当年考虑的是可计算性的边界、神经活动的逻辑表述和继电器网络的设计方法,与账簿和公文毫无关系。但一项技术能够从自动机理论一路走进通信协议、编译器、游戏、订单与支付,正是因为它切中了现代大系统的共同需要:让时间本身变得可以管理。
同样要说明的是,长时段的压力只提供条件,不直接生产形式。规模逼着组织去外部化记忆,却没有规定这种外部化必须写成状态与迁移。同一时期还出现过流程图、决策表、甘特图、工序卡片,各自解决相近的问题。状态机之所以留了下来,一部分因为它与顺序电路的物理结构天然贴合,一部分因为一批工程师和数学家选择把它形式化到可以计算和验证的程度。技术形式的胜出总有偶然,而它被广泛采用的理由并不偶然。
二、三个问题在二十世纪中叶汇合
状态机没有一张由单一发明人签署的出生证明。它在二十世纪三十年代至五十年代之间,从几条彼此不同的道路上逐渐成形。若只按人名罗列,容易把它写成一串互不相干的发现;若看这些发现各自要解决的问题,脉络就清楚得多。
第一条道路来自计算理论。1936 年,Alan Turing 为了界定机械计算究竟意味着什么,设想了一种可以读取纸带、写入符号并左右移动的机器。这篇论文于 1936 年提交,刊入伦敦数学会会刊 1936—1937 年第 42 卷。机器每一步如何行动,取决于当前读到的符号与自身的内部配置;行动之后,它又进入下一个配置。图灵机拥有理论上无限延伸的纸带,因此不能与有限状态机等同,但它把计算表述成一系列局部迁移:复杂的计算过程不再是一个不可拆解的整体,而是有限规则在时间中的反复执行。这一步的意义在于,行为第一次被写成了可以逐条检查的形式,而不是只能整体理解的技艺。
第二条道路来自对神经系统的逻辑化理解。1943 年,Warren McCulloch 与 Walter Pitts 把神经元抽象为全有或全无的逻辑单元。他们讨论的网络若含有环路,当前行为便可能保留过去活动的影响。没有反馈的结构,比较像输入直接决定输出的组合逻辑;有反馈的结构,则出现了记忆。生物性的神经活动在这里被转写为时间与逻辑可以共同处理的网络问题。他们关心的是心智,留下的却是一个更一般的结论:只要允许反馈,一个由简单单元组成的网络就能够携带过去。
第三条道路来自顺序电路。战后通信设备、电话交换与数字计算迅速扩张,工程师面对的问题比数学定义更直接:如何设计一台带有记忆的开关机器?同样的电信号在启动阶段、等待阶段与完成阶段不能触发相同动作;电路不仅要计算当前输入,还要知道此前已经走到哪一步。规模也在逼近人力的极限。继电器和真空管的数量增长以后,仅凭经验拼接的设计既难以复查,也难以移交。
进入 1950 年代,这三条道路开始彼此接近。Stephen Kleene 研究神经网络与有限自动机所能表示的事件,把正则事件与有限自动机联系起来,这项工作最初是 RAND 在 1951 年的报告,后来收入 1956 年的 Automata Studies。George Mealy 在 1955 年提出顺序电路的系统综合方法,强调以形式程序取代只依赖工程师直觉的设计。Edward Moore 在 1956 年研究有限顺序机器及其可辨识的行为。今天常说的 Mealy Machine 与 Moore Machine,分别把输出关联于当前状态与输入,或者主要关联于当前状态。
后人把这些工作编成一条线索,是叙述上的方便。当事人并不共享同一个目标,图灵想界定可计算性的边界,McCulloch 与 Pitts 想解释神经活动,Mealy 与 Moore 想让电路设计可以交付。他们之所以在相近的年代抵达相近的结构,是因为都被同一个时代要求推着走:机器的行为必须从“有经验的人知道该怎么办”,转成一套可以写下、计算、验证并最终制造出来的规则。
Mealy 论文摘要里的一层意味值得留意:新方法强调 formal procedures,而不是更为人熟悉的 intuitive ones。工程由直觉转向形式程序,并不是说直觉从此无用,而是当电路复杂到一定程度,直觉无法再成为共同而稳定的生产资料。一个老工程师脑中的经验不能被复制到另一个车间,也无法在制造之前接受检查。形式化的状态与迁移则可以传给别人,可以被综合成电路,也可以在动工之前发现矛盾。
状态机的出现因此不只是数学工具多了一件。它标志着一种生产方式的变化:系统行为从匠人的内在知识,变成组织可以共同持有的外部结构。这个变化在别的行业早已发生过。图纸取代口耳相传的做法,公差取代“差不多就行”的手感,工艺卡取代师傅的临场判断。计算与通信设备的形式化只是同一进程在新材料上的重演,区别在于,这一次被形式化的对象是时间中的行为本身。
三、状态是把历史编成号码
状态机最重要的抽象不在箭头,而在状态本身。箭头容易看见,也容易画,真正的判断却发生在决定设立哪些状态的那一刻。
一个对象过去可能经历过无数事件。电话线路可能先被占用又被释放;一笔支付可能经过超时、重试和多次渠道回调;一个订单可能改过地址、取消过又恢复。若每次决策都要重读完整档案,系统虽然忠于历史,却难以行动。档案越完整,读取越慢,判断越不一致。状态的办法,是把长长的历史压缩成一个较短的现状描述。
压缩的依据是行为上的等价。两笔支付的路径可能全然不同,一笔一次成功,另一笔三次超时后才成功;只要它们对以后所有合法事件都应作出相同反应,系统就可以把它们一并归入 SUCCESS。于是 SUCCESS 不是一条历史,而是许多条历史的共同名分。它略去了不再影响未来的细节,只保留仍有制度后果的部分:支付事实已经完成,重复的成功不能再次生效,退款的道路从此打开。
从这一点上说,状态同时有向后与向前两副面孔。它向后总结:哪些重要事实已经发生。它又向前规定:从此还允许发生什么。CREATED 不只是说对象刚被建立,也表示仍然可以发起支付;PROCESSING 不只是说渠道尚未定论,也表示成功与失败都还可能到来;REFUNDED 不只是记录退款完成,也关闭了再次扣款与重复退款的大部分可能。一个状态若只有向后的一面,它就退化成日志;只有向前的一面,它就退化成权限开关。两面同时具备,它才是制度。
现代行政与商业管理经常把连续现实变成分类。身份、等级、账户余额、审批阶段,都是把复杂事实转换成可操作的数目或名目。状态机所做的更进一步:它不仅分类,还规定类别之间的合法道路。因此它不是一张静态的户籍册,而是一部关于对象生命周期的程序法。户籍册回答“现在是什么”,程序法还要回答“可以怎样变成别的什么”。
假如一笔支付可以任意从 CREATED 跳到 REFUNDED,状态名称虽然还在,制度却已不存在。只有当每一次变化都必须说明事件、前置状态与合法目标,状态才成为真正可以依赖的公共事实。这也是为什么业务系统中“直接把 status 改成 SUCCESS”是一种危险的表达。调用方若可以直接指定结果,等于允许每个入口自行解释历史;接口越多,解释越多,最后没有人知道哪一次解释才算数。比较稳妥的方式是提交事件,例如 PAY_SUCCESS,再由状态机根据当前状态和业务条件判断,这个事件是一次合法迁移、一个已经处理过的结果,还是一项必须拒绝的请求。
事件说明发生了什么,状态说明此前制度上承认什么,迁移说明两者结合之后新的公共事实是什么。一套状态机能够长期维持秩序,靠的正是这三者不被混为一谈。混淆之处也很典型:把事件写成状态,于是出现 PAY_SUCCESS_RECEIVED 这样既像消息又像名分的东西;把状态当成事件推送出去,于是下游无法判断这次通知对应的是哪一次变化。
还应看到,哪些历史值得合并、哪些必须区分,从来不是机器能够自行决定的。两笔支付是否应当归入同一个状态,取决于业务上是否真的打算对它们一视同仁。渠道垫资的成功与实时清算的成功,在账务上可能需要区别对待;对客户端来说,两者都只是付款完成。设计者在这里作出的是判断,不是推导。状态表的每一行背后,都有一次关于什么可以被遗忘的取舍。
四、层级与并发:状态图的第二次改造
早期的状态图用于规模有限的顺序机器时,状态与箭头尚可铺在一张纸上。等它进入通信协议、航空电子、控制系统和大型软件,平面图很快暴露出自己的极限。复杂对象可能同时具有若干相对独立的状态维度,又可能在一个大阶段内部包含许多子阶段;若把每一种组合都列为单独状态,图会呈指数膨胀。纸面上的线条先变密,再变成无法阅读的网,最后没有人愿意维护它。
David Harel 在 1987 年提出 Statecharts,针对的正是这种状态爆炸。他在普通状态图之上加入层级、并发与通信:一个大状态可以包含子状态,几个正交区域可以并行演化,事件也可以在结构之间传播。小图因此能够表达更大的反应系统。这不是把复杂性变少,而是把复杂性重新组织,使它落在人可以阅读、机器可以执行的范围之内。
这一改进与现代机构在规模扩大之后形成分层的过程颇为相似。中央若要为每个地方、每种天气、每类人员直接规定一个独立状态,规则数量必然失控;比较可行的办法,是先划分层级与职能,再规定何种事件可以在层级之间传递。层级并没有消灭复杂性,却把复杂性安置在若干可以分别理解的单元里。代价也一样:层级越多,跨层沟通越慢,某些例外会卡在中间无人认领。技术上的对应物是层层嵌套的状态图中那些难以调试的事件传播路径。
软件中的情形亦然。一张订单可能同时具有支付状态、履约状态、退款状态、发票状态与通知状态。若把它们压成一个枚举,五个维度、每个维度四个取值,就能产生一千零二十四种组合。再加上渠道、风控和重试次数,所谓精确状态反而无人能够理解。工程师在这种表格前的通常反应,是只处理自己见过的那几种组合,其余听天由命。
Statecharts 的历史提醒我们,把隐含规则显式化只是第一步,显式规则本身也需要治理。状态机并不是状态越多越好。正确的设计要把共享相同未来的历史合并起来,把确有不同后果的历史区分开来,同时让彼此独立的生命周期保持独立。
支付是否成功与短信是否发送,通常就不应当拼成 SUCCESS_SMS_PENDING 这样一个状态。前者是资金事实,后者是可以重试的通知任务。把二者硬合在一起,等于让一个次要部门的办事进度改变核心账目的名分:短信网关抖动一下,支付的状态字段就要跟着变化,而所有依赖支付状态的下游都要重新判断。比较清楚的结构,是让 payment=SUCCESS 与 sms_job=PENDING 各守自己的状态轴,再通过事件和任务发生联系。
由此可见,状态爆炸并不只是一项画图困难。它常常暴露出组织边界没有分清:哪些事实属于同一个对象,哪些只是下游的执行进度,哪些应该由工作流协调,哪些应该由账务或审计系统独立保存。技术图形之所以混乱,往往是因为现实中的责任已经混乱。一张读不懂的状态图,通常也对应着一场说不清由谁负责的事故复盘。
五、支付系统:现代状态秩序的一个标本
从自动机理论进入支付系统,看似横跨甚远,实际上二者面对的是同一类难题:当前输入的意义取决于此前状态,而执行过程又不能假定存在一位全知的观察者。
一笔网络支付至少可能经过用户客户端、商户服务、支付网关、外部渠道、消息系统、账务服务与对账系统。各方拥有不同的数据库,钟表并不完全一致,网络会延迟,进程会重启,消息会重复或乱序。对渠道而言已经完成的支付,在商户一侧可能仍然显示处理中;商户发起重试的时候,第一次请求也许正在路上;系统刚刚记完账,可能还来不及发送 ACK。每一方看到的都是局部而滞后的世界,却都必须立即作出决定。
这种环境与传统科层中的公文流转有相似之处。信息不会在同一瞬间抵达所有部门,各部门只能依据手中可验证的文件行动。若没有统一编号、当前阶段与处理凭据,同一件事可能被办两次,也可能因为每个人都以为别人会办而无人负责。分布式系统里的重复扣款与消息积压,大体属于同一类失序。
支付状态机因此首先是一部局部的宪章。它规定:
1 | CREATED + START_PAY -> PROCESSING |
这张表所列的不是全部实现步骤,而是对象的合法生涯。CREATED -> REFUNDED 不成立,因为没有支付事实便谈不上退款;SUCCESS -> FAILED 通常不成立,因为迟到的失败消息不能推翻已经确认并且可能已经记账的成功;REFUNDED -> PROCESSING 不成立,因为退款完成之后重新启动原支付,会造成名分与资金事实的冲突。这些禁令看起来是技术约束,实质上是对现实后果的判断:哪一种错误可以事后修正,哪一种错误一旦发生就要动用人工。
然而禁止非法迁移只是最浅的一层。状态机还要保护业务不变量:退款金额不得超过可退金额,关闭的订单不得重新付款,只有有权主体才可以撤销,某些状态只允许在限定期限内迁移。这些条件就是 Guard。若 Guard 散落在不同接口里,各个部门便会各执一套尺度,客服后台放行的操作在开放接口上被拒绝,批量任务又绕过了两者;状态机把它们集中起来,同一个对象才有一部统一的制度。
更深一层,是状态机决定谁有资格触发后续副作用。只有真正赢得 PROCESSING -> SUCCESS 这次迁移的执行者,才应当创建 PaymentSucceeded 事件。重复的回调虽然可以返回成功,却不得再次记账、发券或者增加商户余额。状态机在这里不是错误码工厂,而是后续权力的授予者:它裁定哪一次变化具有产生新事实的资格。理解了这一点,就能明白为什么把状态判断和副作用写在两处,是许多资金事故的共同起点。
六、规则写在代码里,执行权落在数据库里
Java 中的状态机可以写得十分清楚。状态与事件用枚举表示,迁移用表存放,纯函数根据 (currentState, event) 返回决策。它便于测试,也让规则不再散落在各个分支里。一份写得好的迁移表,本身就是一份可以交给业务方审阅的文件。
但一部写得清楚的法律,并不等于现实中已经得到执行。两个服务实例可以同时读到 PROCESSING,都判断 PAY_SUCCESS 合法。若它们随后各自执行普通的更新语句,两边都会认为自己完成了迁移,并且各自产生后续事件。逻辑规则没有矛盾,行政执行却发生了重复。用户看到的是两条扣款记录,账务看到的是两笔分录,而代码审查时那段状态机的实现挑不出毛病。
因此关键迁移必须在持久化层以旧状态为条件:
1 | UPDATE payment |
影响一行,才说明当前执行者取得了执行权;影响零行,就要重新读取事实再作判断。若数据库中已经是 SUCCESS,说明相同结果早已成立,本次不必也不应再次产生副作用;若已经是 CLOSED 或 REFUNDED,则属于需要拒绝或者调查的冲突。这一行 AND status = 'PROCESSING' 看起来微不足道,它承担的却是整套秩序中唯一的裁决动作。
这里可以看到几种经常被混淆的机制,它们分属不同层次:
1 | 幂等键:识别这是不是同一件事 |
分布式锁像一张临时通行证。持证者此刻可以进入临界区,但它不能证明五秒之前同一笔业务是否已经完成。请求 A 拿到锁、支付成功、释放锁;请求 B 稍后重试,仍然可以合法地取得同一把锁。若没有持久的幂等记录与状态事实,临时秩序一结束,历史也随之消失。把锁当成正确性的最终保障,是分布式系统中代价较高的一种误解。
数据库唯一约束与条件更新则更像正式登记。进程可以死亡,锁可以过期,服务器可以更换,持久记录仍然告诉下一位执行者:哪一个业务身份已经存在,哪一次迁移已经生效。Stripe 之类的支付接口把 幂等请求 做成公开契约,正是因为调用方的重试无法避免,只能让同一个请求身份稳定地对应同一个结果。
这就说明了状态机在现代分布式系统中的实际地位。它既不是唯一的正确性来源,也不是可有可无的装饰。它提供规则的统一表达,数据库提供事实的最终裁决,两者合在一起,对象的名分与真实执行才不至于分离。
七、四种“不办”:一种分辨能力
早期的业务程序常把结果分成成功与失败两类,失败则抛出一个错误码。可是成熟的制度很少把所有“没有继续办理”视为同一种情况。材料曾经办过、依法不能办、别人正在办、系统无法确认,后续处置各不相同,混在一起就无从下手。
支付状态机至少应当区分四种迁移结果:
1 | APPLIED 新的合法变化已经生效 |
ALREADY_APPLIED 不是失败。渠道重复发送成功消息,系统发现支付早已成功,应当复用第一次的结果并正常 ACK,不能让消息队列因为所谓异常而无限重试。REJECTED 代表一项有明确规则依据的拒绝,例如尚未支付便要求退款,这类请求重试多少次结果都一样,应当尽快返回并记录。CONCURRENT_CONFLICT 并不直接说明业务非法,只说明当前执行者手中的事实已经过期,需要重新查询之后再作判断,通常适合短暂退避后重试。
这种区分看起来只是返回值设计,背后体现的却是现代管理的一项基本能力:能否分辨重复申请、违法申请、权限竞争与未知结果。若全部归入一个 IllegalStateException,监控就无法判断何者是正常重试,何者是业务攻击,何者是并发热点,何者需要人工处置。表面上的简洁,实际是把不同性质的事实重新混成一团,而混乱迟早会以值班电话的形式回到工程师身上。
状态机因此还承担着可观测性的分类工作。HTTP 层可以把 REJECTED 转成业务错误或者 409 Conflict;消息队列对 ALREADY_APPLIED 正常确认;并发冲突重新读取之后再行分类;理论上不可达的状态组合则发出告警,进入死信或人工队列。这几条处置规则一旦确定,报表上的曲线才开始有意义:重试率上升说明什么,拒绝率上升又说明什么,值班人员据此能作出不同反应。
错误码只是这套秩序的最末端表现。真正重要的,是系统先对事件的制度性质作出准确判断。没有这个判断,错误码再详细,也只是在给混乱编号。
八、迁移之后,还有一条漫长的公文链
支付进入 SUCCESS,并不表示世界已经同步完成改变。记账、商户余额、通知、积分和统计可能由不同服务处理。核心状态只是确定了一个新的事实,这个事实还须沿着组织边界向外传播。
最常见的危险发生在数据库与消息系统之间。若先提交支付状态再发送消息,进程可能在两者之间宕机,事实成立而下游一无所知;若先发送消息再提交数据库,数据库可能回滚而下游已经行动,凭空多出一笔账。两个系统各自可靠,却没有共同事务,这就是双写缺口。
Transactional Outbox 的安排,是在更新业务状态的同一个本地事务里插入一条待发送事件。它像一份与决定同时归档的正式公文:决定既然已经成立,待传递的文件就不应因为经办人离席而消失。后台投递器可以稍后反复扫描并发送,AWS 的设计模式文档 对这一结构有完整说明。
1 | 同一数据库事务 |
然而公文可能重复送达。投递器把消息交给消息队列之后,可能还来不及标记已发送就宕机,恢复以后它会再次投递。消费者若在记账完成之后、发送 ACK 之前宕机,队列也会再次派送同一条消息。这不是某个中间件的缺陷,而是至少一次投递语义的正常表现,Kafka 的交付语义文档 与 RabbitMQ 的可靠性指南 都把边界写得很清楚。
因此下游还须以稳定的 eventId 去重。可以在同一事务里写入 (consumer, eventId) 的 Inbox 记录并完成业务操作,也可以让记账表本身带有 UNIQUE(source_event_id, entry_type) 的约束。前者相当于一张收文登记簿,后者直接让业务账簿承担去重职责。两种做法各有代价:收文登记多一张表和一轮清理,账簿约束则要求业务表的键设计从一开始就考虑到重复投递。
这套体系并不追求消息在物理上只旅行一次。它接受交通会重复、回执会丢失、人员会中途离席,转而要求同一份公文的制度效果只能成立一次。业务上所说的 exactly-once,靠的不是某个消息队列品牌的一句保证,而是上游的事件身份、Outbox、至少一次投递、下游幂等与业务唯一约束共同维持的秩序。任何一环缺失,整条链的承诺就要打折。
状态机在其中所守的是核心对象的名分,Outbox 与 Inbox 则让名分的变化能够穿越机构边界而不失真。再加上重试、死信、对账与补偿,整个分布式系统才真正获得一种跨进程、跨部门的历史连续性。值得注意的是,对账在这条链上的位置最容易被低估:它是唯一一种不依赖链路本身是否正确的检验手段,因而也是最后一道防线。
九、可靠性是一种税,也是一种分配
现代制度不可能对所有事务投入同等成本。普通宣传单遗失与银行账簿遗失,后果显然不同;一条调试日志丢失与一笔退款消息丢失,也不能使用同一种标准。把有限的资源平均分配给所有事项,往往意味着重要的事项得不到足够保护。
Outbox、Inbox、唯一索引、重试、死信、对账、人工队列都会增加成本:更多的数据库写入,更大的表,更复杂的清理与监控,更长的链路,也更高的延迟。这是一笔可靠性税。若所有消息无论轻重一律按最高税率征收,系统可能不堪重负,开发进度也会被拖住;若资金事实却按普通日志处理,节省下来的成本迟早要以事故偿还,而且偿还时的利息通常很高。
一套成熟的设计首先要问几个问题:丢失一次会造成什么损失?重复一次会发生什么?消息迟到之后还有没有价值?能否从权威数据重新生成?对账能否发现差异,发现之后又由谁真正修复?最后一问尤其容易被跳过,许多系统建了对账任务,却没有指定差异的处置人,报表每天照常产出,差异每天照常累积。
据此,同一笔支付之后的消息可以分层:
1 | 记账:不得静默丢失,不得重复生效,可接受有限延迟 |
这里要防止把日志看成单一类别。调试日志可以在压力下采样,审计日志、资金操作日志与安全日志却可能是将来追责和恢复的唯一证据,不能因为文件名都叫 log 就一概舍弃。一次事故之后能不能复原当时的事实,往往取决于半年前有没有人对这几类日志作出区分。
可靠性的选择之所以近似一种政治选择,在于它最终决定风险由谁承担。省去消费者幂等,开发团队短期少写一些代码,重复扣款的风险却转给用户与客服;无限重试可以让开发者不必立即处置,代价则由下游拥塞和运维人员承担;没有人工补偿入口,自动化表面上很完整,异常用户却可能长期停在一个无人负责的状态里。这些取舍很少写进技术方案,却实实在在地分配了痛苦。
状态机同样不能脱离这种成本判断。并非每一个只有启用与停用两种取值的数据表都需要一部正式状态机。只有当生命周期、并发、重试、乱序、不变量与副作用足以形成公共风险时,把规则制度化的收益才会超过维护它的成本。判断失误的两个方向都常见:给简单对象套上重型状态机,团队很快就会绕过它;给资金对象只留一个可以随意赋值的字段,则要等到第一次重复扣款才发现问题。
十、状态爆炸与文牍主义的共同危险
凡制度化都有两面。规则可以减少任意处置,也可以逐渐变成文牍。状态机原本是为了消除隐藏逻辑,若每遇到一种例外就新增一个顶层枚举,最后也会形成一种技术官僚主义:状态名越来越长,迁移表越来越大,却没有人能够说明全局为什么是这个样子。
例如把支付、通知、对账、风控与退款揉进同一个状态字段,很快就会出现这样的东西:
1 | SUCCESS_SMS_PENDING_RECONCILE_UNKNOWN_RISK_REVIEWING |
这并不是精密管理,而是责任边界失守之后的堆叠。每个部门都把自己的进度写进同一张总表,结果任何一处变化都牵动全局。系统为了覆盖所有组合而不断增加状态,正如机构为每种情形新增表格与章程,最终执行者只记得如何绕过规则,却不再理解规则要服务的目的。这不是某一代工程师不够认真,而是每一次局部的合理选择叠加起来的产物:当时新增一个枚举确实比重构对象边界省事。
Statecharts 的层级与并发给出了一种技术回应:把大阶段分成子阶段,把独立维度放进正交区域,让消息在结构之间通信。领域设计给出另一种回应:把支付事实、退款流程、通知任务和对账异常拆成责任清楚的对象,跨对象的长过程交给工作流或者 Saga 协调,而不是要求一个实体的枚举承担整个组织的历史。两种回应针对的是同一个病根,只是一个从图形入手,一个从边界入手。
同时还应保留诚实的异常状态。现实中并非所有结果都能立刻判为成功或失败,尤其在外部渠道超时、跨境清算和人工审核之中,结果未知、待确认、补偿中、待人工处理这些名目,可能比武断地写成 FAILED 更接近事实。把不确定强行归类,短期让报表变得干净,长期却会让真正需要处理的案子失去标记。制度的成熟不在于消灭灰色,而在于给灰色安排边界、责任人与退出的道路。
最后,规则必须能够修订。业务政策会变,退款期限会变,监管要求会变,渠道行为也会变。状态迁移若没有版本与迁移方案,昨天的合法行为可能在今天的代码下无法解释:数据库里躺着一批处于旧状态的对象,新代码不认识它们,值班人员也不敢动。状态机把历史变得可读,同时也必须对自身的历史负责。
因此设计状态机的最高难题,不是如何把所有情况都写进去,而是如何决定哪些历史值得成为核心状态,哪些进度应当另立账簿,哪些例外必须交给人工,哪些规则已经失去效力。形式化可以收拢复杂性,却不能代替对复杂性的判断。这项判断只能由理解业务后果的人来作,也只能由他们承担作错之后的代价。
十一、余论:一个大系统怎样取得共同的过去
回望状态机近一个世纪的演变,可以看出它从来不是孤立的编程技巧。图灵用内部配置把计算分解为步骤;McCulloch 与 Pitts 以反馈网络把时间引入逻辑;Kleene、Mealy 与 Moore 把有限行为转成可以研究和综合的形式;Harel 又在大型反应系统面前,以层级和并发重组已经爆炸的规则。此后,状态机进入协议、编译器、控制设备与商业软件,成为现代系统组织生命周期的一种通用语法。
这些变化背后有一条长线:系统规模越大,行为越不能依赖某个人记得曾经发生过什么。机器与组织必须取得一段共同的过去,而这段过去要足够短,能够迅速用于决策;又要足够准确,不至于把本应区别的历史混为一谈。太长则无法行动,太粗则会出错,这两者之间的取舍构成了状态设计的全部技艺。
状态就是这样一种共同的过去。它不是完整档案,而是档案经过制度筛选后的摘要;不是对象的全部真相,而是当前仍然具有行动后果的那部分真相。迁移规则则把这份摘要变成合法的未来:谁在何种事件之下可以改变它,改变之后哪些权利与义务随之发生。
所以支付系统中的 PROCESSING -> SUCCESS,虽然只是一行条件更新,背后却浓缩了现代管理的几项基本原则:事实要有统一身份,变化要有前置名分,执行要有唯一赢家,重复要能识别,公文要能可靠传递,差异要能对账发现,异常要有人负责恢复。这七件事任何一件失守,系统都会在某个时刻表现出同一种症状——它不再知道自己走到了哪里。
状态机当然不能替人制定政策,也不能保证政策本身合理。它不能决定一笔迟到的回调应当退款还是转人工审核,不能自动补回已经丢失的外部副作用,更不能把社会现实中所有模糊地带压成几个整齐的枚举。它所能做的,是把既定规则从个人经验中抽离出来,使之可以被共同执行、检查与修订。这个限度值得记住:形式化提供的是一致性,不是正确性;提供的是可审查的规则,不是免于判断的许可。
从前,一个熟练操作员以记忆维持机器的连续;后来,一台自动机以内部状态维持计算的连续;今天,一个跨越许多服务与机构的支付系统,以持久状态、事件身份、消息记录和对账凭据维持业务的连续。执行者换了一批又一批,进程生了又死,机房搬了又搬,系统仍然能够知道自己已经走到哪里。这种连续性不是自然生成的,它由一代代工程师在具体的表结构、约束和重试策略中一点一点建立起来。
若要把这种技术的历史意义压缩成一句话,大约可以这样说:
状态机是现代系统对时间实行数目字管理的一种方式:它把相关的过去编成有限状态,把允许的未来写成迁移规则,使一个没有全知者的大系统仍能维持共同秩序。
我们日常看到的只是数据库里一个短短的字段。字段之下,却站着一整套关于记忆、权力、凭据与恢复的制度。读懂它,便会明白状态机何以能从二十世纪的纸带与继电器一路走到今日的支付与订单:机器越来越快,组织越来越大,而它们始终需要回答同一个古老的问题——事情既然已经走到这里,下一步究竟还可以怎样走。
主要资料
- 计算过程的状态迁移基础:Alan M. Turing, On Computable Numbers, with an Application to the Entscheidungsproblem,1936—1937。
- 反馈网络与逻辑行为:Warren S. McCulloch & Walter Pitts, A Logical Calculus of the Ideas Immanent in Nervous Activity,1943。
- 有限自动机与正则事件:Stephen C. Kleene, Representation of Events in Nerve Nets and Finite Automata,初稿为 RAND 1951 年报告,收入 Automata Studies,1956。
- 顺序电路的形式综合:George H. Mealy, A Method for Synthesizing Sequential Circuits,1955;Edward F. Moore, Gedanken-experiments on Sequential Machines,1956。
- 复杂反应系统与层级状态:David Harel, Statecharts: A Visual Formalism for Complex Systems,1987。
- 数据库与消息双写的工程边界:AWS Prescriptive Guidance, Transactional Outbox Pattern。
- 消息系统边界:Apache Kafka, Message Delivery Semantics;RabbitMQ, Reliability Guide。
- 请求身份与结果复用:Stripe, Idempotent Requests。