记忆的最小形状:状态机如何让系统理解时间
一条消息,四个都说得通的答案
先从一件很具体的麻烦事说起。
周一早上,工单系统里躺着一条投诉。用户说自己只买了一次会员,账单上却出现了两笔扣款;商家那边也来了消息,说同一笔订单的入账收到了两次。你打开日志,一路往回翻,最后发现事情平淡得几乎有点扫兴:支付渠道在一次网络抖动之后重发了同一条成功回调,而系统老老实实地把它当成一件新发生的事,又走了一遍完整流程——改状态、记账、发短信、给商家入账。
这类事故最让人别扭的地方在于,没有哪一行代码算错了。每一行都在忠实执行它被要求执行的事:收到成功消息,就把支付置为成功,然后记账、通知、入账。真正的问题发生在更早的地方——这段逻辑被写成了一个没有记忆的函数。它只看见眼前这条消息,看不见这条消息之前,世界已经变成什么样了。
现在把场景抽干净,只留下一条消息:
1 | PAY_SUCCESS |
如果只看这条消息,答案似乎很简单:把支付改成成功,然后记账、发通知、给商家入账。
可真正的支付系统不能这样做。它必须先问一个看起来多余、实际上决定一切的问题:这笔支付现在处于什么状态?
如果它处于 PROCESSING,这条消息意味着支付刚刚被渠道确认,可以迁移到 SUCCESS。如果它已经是 SUCCESS,这可能只是渠道重发的同一条回调,正确动作不是再次记账,而是返回“已经处理”。如果它已经 REFUNDED,这条迟到的成功消息不能把退款事实覆盖掉,需要记录异常并按业务规则处置。如果它已经 CLOSED,系统甚至要进一步判断:这是非法消息、渠道迟到,还是需要触发退款或人工处理的不确定结果。
同一个输入,至少产生了四种不同答案:
1 | 正常迁移 |
四个答案都说得通,因为它们回答的其实不是同一个问题。区别不在消息本身,而在消息到来之前发生过什么。
这就是状态机所处理的那个最朴素的问题:一个系统对当前输入的反应,常常取决于它的过去。没有记忆的函数可以写成:
1 | 输出 = f(当前输入) |
有历史的系统则必须写成:
1 | 下一状态, 输出 = f(当前状态, 当前输入) |
两行之间只多了一个参数,但这个参数改变了函数的性质。第一行描述的是一次计算,第二行描述的是一个会随时间演化的东西。你不再只是把输入映射到输出,你还在同时决定这台机器接下来变成什么。
这种结构其实到处都是。电梯如此,交通灯如此,自动售货机如此,网络协议如此,订单和支付也如此。同样是按下一个按钮,电梯停在一楼、正在上行、门正在关闭和处于检修状态时,机器应有不同反应。同样是投入一枚硬币,售货机在余额不足、余额足够、商品售罄和正在退币时,也不可能执行同一动作。我们从来不会觉得电梯“不讲道理”,因为我们默认它记得自己在哪儿;可我们写业务代码时,却常常忘了给系统这份记忆。
状态机的第一层美感,来自这个极小的改动:在输入旁边多放一个“当前状态”,一个没有时间感的函数便开始理解过去。
但这还不是最有意思的地方。真正值得追问的是下面这个问题——一笔支付的过去可能长达几十步,为什么区区几个状态就够了?
状态不是历史本身,而是历史的压缩结果
一笔支付从创建到完成,可能经历上百条日志:用户打开页面,选择银行卡,客户端发起请求,服务端生成支付单,渠道返回处理中,网络发生超时,客户端重试,渠道第一次回调失败,第二次回调成功,MQ 重复投递,消费者重启,用户稍后又申请退款。
系统当然可以保存这些事件,重要业务也确实应该保留足够的审计记录。但如果每来一个请求,都必须从头重放全部历史才能决定下一步,日常处理就会变得昂贵而脆弱。历史越长,判断越慢;而且只要重放逻辑里有一处理解偏差,所有判断会一起错。
状态的作用,是把一段长历史压缩成一小块足以指导未来的信息。
值得注意的是压缩的标准。我们不是在挑“重要的事情”留下来——重要与否是个模糊的说法,两个人可以吵一整天。我们是在问一个可以验证的问题:这两段过去,会不会让系统今后的反应有任何不同?
举个例子。假设两笔支付的经历完全不同:一笔第一次请求就成功,另一笔经历三次超时和两次回调。只要它们最后都已经不可逆地记为 SUCCESS,那么面对同一条重复成功消息、面对同一次退款申请、面对同一次对账检查,系统可以给出同一种答案。对于所有这些未来问题,两段历史是等价的。
于是我们得到一个相当好用的判定方法:如果把两个对象的完整历史悄悄互换,而系统今后的一切反应都不会改变,那它们就应当处在同一个状态里。状态机做的事情,就是把所有“今后应当受到相同对待”的历史归进同一个格子。
因此,状态不是过去发生过的一切,而是过去之中仍然会影响未来的那一部分。
这个定义解释了为什么好的状态设计既不能记得太少,也不能记得太多。
记得太少,系统会把本应区别对待的历史混在一起。例如只用一个布尔值 paid=true/false,就无法区分尚未支付、支付处理中、支付失败但允许重试、订单已关闭等情况。这个字段在顺利路径上完全够用,一旦出现迟到消息,系统就没有任何依据判断它能否覆盖现状——因为该依据在设计的那一刻就被丢掉了。很多重复扣款的根因不在并发,而在这里:能区分的信息一开始就没被留下。
记得太多,则会出现另一个方向的失败:状态爆炸。假如把支付渠道、重试次数、通知是否发送、风控结果、退款进度和对账状态全部塞进一个枚举,就会得到诸如:
1 | PAY_SUCCESS_SMS_PENDING_RECONCILE_UNKNOWN_RETRY_2 |
这种名字通常出现在一个系统运行了两年之后。每增加一个独立维度,组合数量都会成倍增长,而不是线性增长。系统没有因此更精确,反而失去了可以推理的结构:没有人能说清这个枚举一共有多少合法取值,也没有人敢删掉其中任何一个。
好的状态像一份“最小充分记忆”:它没有复述完整历史,却保存了决定合法未来所必需的信息。这个视角比“状态就是数据库里的一个枚举字段”重要得多。枚举只是存储形式,压缩相关历史才是它真正的算法意义。
我们还可以从另一个方向理解这件事:状态不仅概括过去,还划定未来。CREATED 意味着仍可发起支付;PROCESSING 意味着可以接受成功或失败结果;SUCCESS 意味着支付阶段已经完成,却打开了退款的可能;REFUNDED 则关闭了再次支付和再次退款的大部分道路。
所以,一个状态同时回答两个问题:
1 | 什么重要的事情已经发生? |
这两个问题必须一起回答,才算一个好状态。只回答第一个,你得到的是一条日志摘要;只回答第二个,你得到的是一份权限表。同时回答两个,你得到的才是状态。
状态机因而不是静态分类表,而是一张时间地图:它标出了系统此刻的位置,也标出了从这个位置出发有哪些路可以走。
它不是被某个人一次性发明出来的
如果状态机只是“给业务对象加一个枚举字段”,它不会有这么长的来历。它的历史很适合说明一个技术思想是怎么形成的——不是某位程序员为了消灭代码里的 if-else 突然发明的,也没有一个可以精确钉住的单一生日。它是计算理论、神经网络、通信与数字电路几条路线在二十世纪中叶逐渐汇合的结果。
1936 年,图灵为了回答“什么是可以机械计算的”,构造了一种极其简朴的抽象机器。机器读取纸带上的符号,根据当前内部配置决定写什么、向哪边移动、进入哪个新配置。图灵机并不是有限状态机,因为它还有一条理论上可以无限延伸的纸带;但它确立了一个影响深远的视角:计算不必被想成一次神秘的整体动作,它可以被分解为一连串局部、确定的状态迁移。图灵的论文于 1936 年提交,并收入 1936—1937 年的《伦敦数学会学报》第 42 卷。
这个视角今天听起来平平无奇,只因为我们已经完全泡在里面了。把一件复杂的事拆成“当前处境 + 一小步动作 + 新的处境”,正是我们排查线上问题时下意识的做法。
1943 年,神经生理学家 Warren McCulloch 与年轻的逻辑学家 Walter Pitts 又从另一条道路靠近同一个问题。他们把神经元抽象为“全有或全无”的逻辑单元,研究神经网络怎样随时间产生行为。没有环路的网络更像组合逻辑:当前输入决定当前结果;包含环路的网络则必须处理过去留下的影响。反馈让网络拥有了某种记忆,时间也因此进入逻辑结构。
值得留意的是这条线索给出的因果顺序:不是先有“存储”,才有“记忆”;而是先有回路,让信号能绕回自身,记忆才作为结构的副产品出现。系统之所以能记住,是因为它的输出能重新成为自己的输入。
到了 1950 年代,理论与工程开始真正咬合。Stephen Kleene 研究神经网络与有限自动机所能表示的事件,由此系统化了正则事件与有限自动机之间的关系。另一边,电话交换、控制设备和数字计算机的工程师需要设计“顺序电路”:同一组输入在不同阶段必须产生不同输出,电路必须记得自己刚才走到了哪里。
这两拨人的处境很不一样。一边在问“哪些语言可以被有限的机器识别”,另一边在问“我这块板子怎样才能不接错线”。但他们说的其实是同一件事。
George Mealy 在 1955 年发表关于顺序电路综合的方法,把输出看作当前状态与当前输入的函数;Edward Moore 在 1956 年研究顺序机器,常见的表述则把输出主要关联到当前状态。今天所谓 Mealy Machine 与 Moore Machine 的区别,正来自这两种安排。它们不是互相排斥的哲学,而是两种可以转换、各有工程取舍的建模方式:前者对输入反应更直接,后者的输出更稳定、更容易与状态绑定。
这个区别在业务系统里也会照面。你把“发送成功通知”绑在 PROCESSING + PAY_SUCCESS 这条迁移上,还是绑在“进入 SUCCESS 状态”这件事上,写出来的代码会明显不同,出问题时的排查方式也不同。前者关心“是哪一步走过来的”,后者关心“现在在哪儿”。
这段历史有一个值得注意的共同背景。数学家要把“计算”说清楚,神经科学家要把“带反馈的行为”说清楚,电路工程师要把“带记忆的开关网络”造出来。他们面对的对象不同,却都被迫回答同一个问题:如何用有限的内部描述,决定一个系统在下一刻做什么。
到了 1980 年代,状态机已经广泛用于通信协议、控制系统与软件设计,但新的困难出现了。普通平面状态图面对真实复杂系统时,很快会被连线淹没。David Harel 在 1987 年提出 Statecharts,引入层级、并发和通信,让多个相关状态可以被嵌套,让相互独立的状态维度可以并行存在,也让复杂反应系统能以更经济的方式表达。
这个发展本身很有意味:状态机解决了隐含行为难以推理的问题,随后又必须解决“显式规则多到无法阅读”的问题。把规则从代码里请出来写在图上,是一次胜利;图大到没人愿意看,是这次胜利带来的新账单。
从图灵的内部配置,到神经网络的反馈,从顺序电路到 Statecharts,状态机历史的主线并不是某种图形记法逐步流行,而是人类不断寻找一种可计算的时间语言:既保留足够的过去,又不被完整历史压垮;既能描述变化,又能让变化接受检查。
五个零件,拼出一台可以被检查的时间机器
回到工程。一个实用状态机通常包含五种东西:状态、事件、迁移、前置条件和迁移后的动作。
1 | State 系统现在处于什么阶段 |
拿支付来说,状态可以是:
1 | CREATED |
事件可以是:
1 | START_PAY |
迁移规则写成一张表:
1 | CREATED + START_PAY -> PROCESSING |
这张表最大的价值,不是比 if-else 少写几行代码。少写几行代码是个很弱的理由,也很容易被反驳——熟练的人用条件判断同样能写对。真正的价值在于:它把允许发生的未来集中到了一个可以检查的地方。
一旦规则集中,一些原本无从下手的问题突然变得可以回答:某个状态是否可达?是否有状态进去后再也出不来?是否有事件在某个状态下根本没有定义?是否存在本不该出现的回路?这些问题不需要灵感,只需要把表读一遍,甚至可以写个小脚本自动跑。
反过来,一旦规则分散在 Controller、定时任务、MQ 消费者和后台管理接口中,同样的问题就没人能整体回答了。你只能一处一处地读代码,而且永远不确定有没有漏掉某个入口。很多系统里那条“谁也不知道为什么会出现”的数据,就诞生于某个被遗忘的后台接口。
Guard 是状态规则之外的条件。例如 SUCCESS + START_REFUND 并不自动意味着可以退款,还可能要求:退款金额不超过可退金额,发起人有权限,支付没有超过退款期限,目标币种与原交易相容。
这些条件放在哪里,是个容易被低估的决定。如果它们被偷偷藏在某个入口里,HTTP 接口、批处理和人工后台就可能各自执行一套不同政策。更糟的是,这种分歧通常不会以报错的形式暴露,而是以“为什么客服后台能退这笔、App 却不能”的形式,在几个月后变成一个没人说得清的历史遗留问题。
Effect 则要格外小心。我们常说“进入成功状态后记账和发消息”,但这句话把两个不同层次混在了一起。状态机可以决定:这次迁移是否有资格产生 PaymentSucceeded 事件;它不能只靠一段内存代码保证消息永远不会丢,也不能保证下游只记一次账。
把这三件事分开写,会清楚很多:
1 | 决定规则:这次变化在业务上是否成立 |
它们相连,却不是同一个问题,也不能由同一段代码承担。这正是状态机在真实工程中最容易被高估、也最值得被正确安放的地方。它是三者中的第一个,而且只是第一个。
并不是所有“没变成功”都叫错误
许多状态机实现只有两种返回:成功,或者抛异常。这在单机、单线程、消息不重复的世界里够用,遇到分布式系统却很快失去分辨力。因为“数据库里的状态没有从旧值改成新值”这一个现象,背后可能藏着完全不同的故事。
重新看开头那条 PAY_SUCCESS:
| 当前状态 | 迁移结果 | 含义 |
|---|---|---|
PROCESSING |
APPLIED |
第一次合法完成迁移 |
SUCCESS |
ALREADY_APPLIED |
同一结果已存在,重复消息应成为 no-op |
CREATED |
REJECTED |
尚未进入支付处理,成功消息不合业务规则 |
| 读取后被别人改走 | CONCURRENT_CONFLICT |
规则可能合法,但本执行者没有赢得修改权 |
这四种结果表面上都可能表现为“数据库没有从旧状态改成新状态”,后续处理却完全不同。
HTTP 请求遇到 REJECTED,可以返回业务错误码或 409 Conflict,让调用方知道这条路本来就走不通,重试也没有意义。MQ 消费者遇到 ALREADY_APPLIED,应当正常 ACK——事情已经办完了,继续重试只会让这条消息在队列里无限打转,最后拖垮消费能力。并发冲突需要重新读取,判断别人完成的是同一结果还是相互矛盾的结果:如果对方也写入了 SUCCESS,那么本次执行可以安心退出;如果对方写入的是 CLOSED,那就是另一回事了。至于理论上不可能出现的状态,则要告警、进入死信或转人工,而不是粗暴地吞掉——正是这类被吞掉的异常,构成了对账时那些无从解释的差额。
如果只有“成功”和“异常”两个返回值,上面这些区别全部消失。系统会用同一种方式对待“这事已经办过了”和“这事根本不该办”,然后要么无谓重试,要么静默丢弃。
这里还有一个微妙但重要的思想:幂等并不意味着强行允许 SUCCESS -> SUCCESS 成为一次真实迁移。
这两种做法在数据库里可能看起来差不多,语义上却相差很远。让 SUCCESS -> SUCCESS 成为合法迁移,等于承认“支付可以成功很多次”,那么每次成功都有理由触发一遍效果。更准确的做法是,第二次成功事件被识别为“目标早已实现”,于是系统复用第一次的结果,不再生成新的业务效果。前者靠下游小心;后者靠模型本身。
所以状态机不是一堵只会说“不”的墙。它更像一个分类器,把到达系统的事件区分为:新的合法变化、已经发生过的变化、业务上不允许的变化,以及因竞争而无法确定的变化。只有把这些语义分开,重试、监控和补偿才知道接下来该做什么。
Java 可以判断规则,数据库才决定谁赢
Java 没有特殊的“状态机语法”。最朴素的实现,不过是两个枚举、一张迁移表和一个纯函数:
1 | TransitionDecision decide(PaymentState current, PaymentEvent event) |
这个函数很有价值。它可以脱离数据库快速测试全部状态—事件组合:七个状态乘七个事件,四十九种输入,一次测试就能全部覆盖,而且跑得飞快。给定同样的输入,它永远返回同样的决策;规则集中、可读,也容易验证。
但它不能单独保证并发正确。
假设两个服务实例几乎同时读取到 PROCESSING,都调用状态机并得到 PROCESSING -> SUCCESS 合法。如果随后都执行不带条件的更新,两边都可能认为自己成功,并分别发送记账消息。状态机在逻辑层面一点错都没犯,两次判断都对;系统却产生了两次副作用。
原因在于,纯函数回答的是“这种变化是否被允许”,而并发场景真正要问的是“这一次具体的变化,是不是由我完成的”。后者不是一个逻辑问题,是一个归属问题,只能在有仲裁能力的地方解决。
因此,迁移还要在持久化边界上原子争夺:
1 | UPDATE payment |
关键在最后那行条件。它把“我读到的时候是 PROCESSING”这个前提,重新带回到写入的那一刻去检验。影响一行,表示当前执行者确实赢得了迁移;影响零行,则必须重新读取并分类:也许另一实例已经完成相同成功结果,此时是 ALREADY_APPLIED;也许支付被关闭或退款,此时是冲突或异常。
于是三层分工就清楚了:
1 | 状态机:业务上允许怎样变 |
分布式锁处在另一个层次,也最容易被误用。它回答的是“此刻谁可以进入临界区”,可以减少多个实例的竞争;它回答不了“五秒前这件业务是否已经做过”。第一次请求拿到锁、支付成功、释放锁之后,一条迟到的重复请求完全可以再次拿到这把锁,而且拿得理直气壮——因为此刻没有任何人跟它竞争。如果没有持久化的幂等记录、唯一约束和状态判断,它仍会把整件事重做一遍。
一句话:锁解决的是同时性,幂等解决的是历史性。用锁去解决历史性问题,就像靠“此刻走廊上没有别人”来判断自己有没有进过这扇门。
状态机恰好站在历史与并发的交界处:它根据已经持久化的过去判断合法未来,但最后必须由数据库把这个判断变成不可争辩的事实。判断可以在内存里做,裁决必须在持久层做。
状态改变了,世界还没有改变完
一笔支付从 PROCESSING 迁移到 SUCCESS,只说明核心支付事实已经被确认。它并不自动意味着记账完成、商户余额更新、短信发出、积分到账、搜索索引刷新,更不意味着所有下游系统在同一瞬间都知道了这个结果。
这条边界非常关键,也非常容易被跨过去。许多系统把状态机写得很漂亮,却在迁移之后直接调用 MQ:
1 | 提交支付状态 |
这两行之间有一道看不见的缝。如果数据库提交成功、进程却在发送消息前崩溃,支付已经成功,下游永远收不到通知;这笔钱在账上是收了的,商家却始终没有入账。反过来,如果先发送消息再提交数据库,消息可能已经被消费掉、账已经记了,数据库事务却回滚了——现在你有了一笔谁也找不到源头的账。
问题不在状态机。问题在于一次业务操作同时写入两个彼此独立的系统,而这两个系统没有共同的原子边界。数据库能保证自己那部分,消息队列能保证自己那部分,中间那道缝没有人负责。
Transactional Outbox 的做法很直接:既然跨系统没有共同事务,那就先别跨系统。在同一个本地事务里完成两件事——更新业务状态,并插入一条待发送事件。事务提交后,即使进程立刻宕机,Outbox 记录仍在,后台投递器可以继续重试。
1 | 本地事务 |
这一步做的是把“要不要发这条消息”这件事,变成和业务状态同生共死的一条数据。事实和它的待办事项被绑在了一起。
不过,Outbox 解决“不丢”时会有意接受“可能重复”。投递器可能已经把消息发给 MQ,却在把 Outbox 标成已发送之前崩溃;恢复后,它会再次发送同一个 eventId。这不是实现得不够仔细,而是这类设计的必然结果:在两个系统之间,你只能选择“可能重复”或“可能丢失”,不存在两个都不选的选项。Outbox 选择了前者,因为重复可以在下游治理,丢失通常不能。
既然选了重复,消费者就必须有自己的幂等边界。常见做法有两种:在同一事务里写入 (consumer, eventId) 的 Inbox 记录并完成记账,让“我处理过这条消息”和“我记了这笔账”一起提交;或者直接让记账表具有 UNIQUE(source_event_id, entry_type) 这样的业务唯一约束,让数据库替你拒绝第二次写入。后者往往更省事,因为它不依赖任何人记得去查重。
于是工程上真正可实现的目标通常不是“这条消息在物理世界里只出现一次”,而是:
消息可以至少投递一次,同一消息的业务效果只发生一次。
把这句话读准很重要。它没有承诺消息不重复,它承诺的是效果不重复。这两者之间的距离,正是消费者幂等要填的那一段。
这也解释了为什么 Kafka 或 RabbitMQ 的 ACK、Confirm 和事务能力不能神奇地覆盖所有外部副作用。消息系统可以确认自己边界内发生了什么,却不知道 MySQL 事务、支付渠道或邮件服务是否已经完成。它管得了自己的院子,管不了院墙外面。
于是整条链路的分工浮现出来:状态机守住的是业务对象的生命周期;Outbox、消费者幂等、重试和对账把这条生命周期向外扩展成一个可恢复的分布式过程。
沿着每条边界,假设机器恰好在这里死去
理解状态机最好的方式之一,不是画一张只有成功路径的漂亮图,而是沿着完整流程逐点假设失败。
这个练习不需要工具,只需要一支笔和一点悲观。先把链路摊开:
1 | 请求 requestId |
然后在每条箭头上问同一个问题:如果进程恰好在这一步之前、之后、或者确认返回之前宕机,系统会停在哪里?谁会发现?靠什么恢复?
答案往往比想象的具体。
业务已经提交但响应没有返回,调用方不知道结果,只能重试;这要求它复用同一个 requestId,也要求服务端能凭这个 ID 返回第一次的结果,而不是重新执行一遍。状态和 Outbox 已经提交但消息尚未发送,投递器应当从持久记录恢复,这正是 Outbox 存在的理由。消息已经发送但 Outbox 未标成功,系统必须允许重复发送,并把去重责任交给消费者。消费者业务已经提交但 ACK 尚未发出,MQ 会重新投递,此时 Inbox 或业务唯一约束要挡住第二次记账。消息持续失败,则不能无限高速重试,需要退避、死信、告警、补偿和人工接管——否则一条毒消息可以在几分钟内把日志和下游一起打满。旧事件晚到,则要靠版本、序列号或状态条件阻止旧事实覆盖新事实,这也是本文开头那条迟到成功消息真正的防线。
这类故障导向设计有一个额外的好处:它把“可靠”从一个形容词变成了可测试的契约。
只要每条边界上的预期行为被写下来,它就可以被验证。我们可以强制重复投递,在事务提交后立刻杀死进程,阻断 MQ,打乱事件顺序,重放几天前的历史消息,然后观察状态机和恢复机制是否把系统带回一个合法状态。这些实验听起来激烈,做起来其实比读一遍代码更让人安心——因为它们检验的是系统真实的行为,而不是我们对系统的印象。
状态机在这里承担的角色,不只是阻止非法操作。它是故障之后的定位坐标。
进程可以消失,内存可以清空,消息可以重复;只要持久化状态和事件身份仍然可信,一个刚刚被拉起来的新执行者就能知道:系统已经走到哪里、哪些动作可以放心重做、哪些结果必须复用、哪些情况需要升级处理。它不需要理解之前发生的一切,只需要读懂那一小块被压缩过的记忆。
从这个角度看,状态还有另一个定义:可恢复计算的检查点。它把一段可能跨越多次进程生死的业务过程,切成了可以接力的离散阶段。前一棒倒下了,后一棒能从地上捡起接力棒继续跑——前提是这根接力棒被稳稳地放在了地上,而不是攥在那只已经消失的手里。
可靠性是要付费的,别给所有数据买同一档保险
状态机和可靠性模式很容易诱发一种“全部上满”的冲动:所有事件都用 Outbox,所有消费者都建 Inbox,每一步都持久化,每一个异常都永久重试。这样看起来非常严谨,也很难被批评,却可能让系统为低价值数据支付过高成本。
同一次支付成功,可能产生三类消息:
1 | 记账消息:不能静默丢失,不能重复生效 |
它们的失败代价相差好几个数量级,却常常被同一套机制平等对待。
Outbox 和 Inbox 不是免费的。它们会增加数据库写入、增加索引、带来扫描或 CDC 的负担,需要表清理,需要监控,还会引入额外的处理延迟。这是一笔实实在在的“可靠性税”。只有先弄清楚失败的代价,才谈得上判断这笔税值不值得交。
设计一类事件之前,可以固定问这几个问题:丢一次会损失多少钱,或者破坏什么承诺?重复一次会产生什么副作用?晚到之后还有没有价值?能不能从权威数据库重新生成或全量重建?万一出问题,能否通过对账、补偿或人工恢复?
这几个问题里,第四个最容易被忽略,也最省钱。缓存刷新和搜索索引即使偶尔丢失,也可以通过全量重建恢复,那么为它们建一整套 Outbox 往往不划算;普通埋点可以采样,丢掉百分之一不影响任何结论。但同样要小心相反的错误:审计日志、资金流水和安全日志经常被“日志可以丢”这句笼统判断误伤,而它们恰恰是事后唯一能说清事实的东西。
状态机本身也要服从这个原则。并非每个普通 CRUD 对象都值得建一套正式状态机。如果一个对象只有两个显然的阶段,没有并发、没有重试、没有乱序、没有复杂不变量、也没有重要副作用,那么一条清晰的条件判断可能更便宜,也更容易读懂。
抽象的价值来自它减少的混乱,而不是它听起来像不像架构。
当状态多到画不下,问题可能不在画布
平面状态机最常见的失败仍然是状态爆炸。
假设一个订单同时有支付状态、履约状态、退款状态、发票状态和通知状态。如果把这五个维度做笛卡尔积,哪怕每个维度只有四种取值,也会得到一千零二十四种组合。再为每个组合画迁移,这张图很快会比当初那堆散落的 if-else 更难理解——而它本来是为了取代那堆 if-else 才画的。
这时候的第一反应通常是换工具:换个更好的绘图库,或者把图拆成几页。但根因不在画布上,而在建模上:我们把几条本来各自独立的时间轴,硬拧成了一条。
Harel 的 Statecharts 之所以重要,正在于它承认复杂系统经常同时具有层级与并发。一个“支付中”状态内部可以包含“等待渠道”“结果未知”“重试等待”等子状态,外部只需要知道它还在支付中;支付与通知则可能属于两个正交区域,各自演化,而不必为“支付成功且短信未发”“支付成功且短信已发”分别制造一个顶层枚举。
从这里可以引出几条相当实用的拆分判断。
第一条是最容易违反的:不要把独立的生命周期硬压成一个字段。支付是否成功与短信是否发送,通常不在同一条状态轴上,它们只是碰巧被同一次业务操作触发而已。
第二条是区分业务事实与执行任务。payment=SUCCESS 是一个业务事实,一旦成立就不再变化;sms_job=PENDING 是一个可重试的任务,它的失败不应该反过来污染事实。把它们混进一个枚举,就等于让一次短信发送失败去动摇一笔支付的定论。
第三条回到本文最开始的那个标准:只有会改变对象合法未来的历史,才值得进入核心状态。重试了几次、走的哪个渠道、通知发没发,这些信息可能很值得记录,但它们通常不改变“接下来允许做什么”。记录它们,别让它们进枚举。
第四条是关于规模的:当需要跨多个对象协调一段长事务时,可能需要工作流、Saga 或流程编排器,而不是不断膨胀某一个实体的状态枚举。一个实体的状态机描述的是它自己的生命周期,不是整个业务流程的剧本。
状态机还有一个更隐蔽的风险:把暂时的政策固化成永恒的结构。
业务异常不可能被穷举完,尤其在涉及资金、物流和人工审核的系统里。总会出现某个从未设想过的组合:渠道返回了一个文档里没写的状态码,用户在退款途中又发起了申诉,对账发现一笔双方都不承认的流水。一个成熟的状态机必须为“结果未知”“待人工处理”“补偿中”留下诚实的位置,也要允许规则本身版本化。否则它只是在图上假装世界没有灰色地带,而真实的灰色地带会以工单和事故的形式如期到来。
所以好的模型不是状态越少越好,也不是越多越精确。它应当把真正共享同一组未来的历史合并,把确实具有不同后果的历史分开,并让彼此独立的时间轴保持独立。
它能执行政策,但不能替你决定政策
值得把状态机的边界说清楚,否则它很容易被当成一件万能工具。
它可以精确执行“支付成功后才允许退款”,却不能替业务决定退款期限是七天还是三十天;它可以拒绝 REFUNDED -> PROCESSING,却不能决定一条迟到的成功消息应该自动原路退回还是进入人工审核;它可以证明某条迁移在图上未被定义,却不能证明整套业务政策公平、合理或符合法规。它保证的是一致性,不是正确性——一个被清晰执行的错误政策,依然是错误政策。
它也不能自动解决所有一致性问题。这一路下来出现的那些机制,各自守着一段不同的边界:
数据库条件更新保证一次迁移只有一个赢家。Outbox 保证后续事件不会因为双写窗口而静默丢失。消费者幂等保证重复消息不会产生额外效果。对账负责发现自动链路遗漏的差异。补偿与人工处理负责把差异真正修回来。
这些机制彼此连接,但没有哪一个可以单独冒充全套答案。少了任何一段,问题只是换个地方出现:没有条件更新,你会有两个赢家;没有 Outbox,你会有沉默的丢失;没有消费者幂等,你会有重复的账;没有对账,你会有一直没人发现的差额。
那么状态机最适合处理什么?大致是这样一类问题:对象具有清晰的生命周期;相同事件在不同阶段含义不同;非法迁移会破坏重要不变量;系统需要面对重试、并发或乱序;不同入口必须遵循同一套规则;失败之后需要知道过程停在哪里。支付几乎每一条都占了,所以它是状态机最经典的舞台。
反过来,也有它不该硬上的场合。如果问题的核心是连续数值控制,普通的有限状态未必是最佳模型。如果对象的真实来源是一条不可变的事件流,那么当前状态可能只是事件溯源的一个投影,而不是权威本身。如果流程主要跨越许多服务和人工节点,一个显式的工作流引擎,可能比把所有责任塞进某个领域对象更合适。
状态机是一种语言,不是一种宗教。它的价值在于让时间规则变得可见、可执行、可质疑,而不是让每段代码都长得像一张状态图。
结语:给机器一段足够短的过去
回到最初那条 PAY_SUCCESS,以及那位被扣了两次钱的用户。
消息本身没有变,真正改变答案的是系统记住了什么。它不需要在每次判断时重温整段支付史,只需要一个足够准确的当前状态、一组稳定的事件身份,以及那些仍会影响未来的不变量。借助这份被压缩的过去,它可以分辨眼前这条消息究竟是:这是第一次成功、一次无害的重复、一条危险的迟到消息,还是一场需要重新读取的并发冲突。
四种答案,四种处理方式。而区分它们所需的全部信息,可以少到只有一个枚举值和一个事件 ID。
这也许是状态机最漂亮的地方。它让机器拥有记忆,却不要求机器背负全部历史;它让系统进入时间,却把时间切成可以推理的离散步骤;它承认消息会重复、进程会死亡、网络会把顺序打乱,又试图让每一次恢复都从一个可信的位置继续。
状态机并不是为了管理几个枚举值而发明的。它描述的是具有记忆、随时间演化的系统,并把原本隐含在代码、人员经验和事故教训里的生命周期规则,转成一种共同可见的结构。那些规则一直都在——它们过去写在老员工的直觉里,写在复盘文档的第三段里,写在某个没人敢删的 if 判断里。状态机做的,是把它们请到台面上。
我们可以把整篇文章压缩成一句话:
状态,是决定未来所需的最小过去;状态机,是规定这段过去如何变成下一段未来的算法。
理解了这一点,再看支付、订单、协议、电梯和自动售货机,便会发现它们共享的不是某个框架,而是一种对时间的组织方式。业务对象本来就在变化,从来不需要谁批准。状态机所做的,不过是让这种变化第一次能够被命名、被检查,也被可靠地继续下去。
参考资料
- Alan M. Turing, On Computable Numbers, with an Application to the Entscheidungsproblem,提交于 1936 年,刊于 Proceedings of the London Mathematical Society 第 42 卷。
- 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,收入 Automata Studies,1956;其前身为 1951 年 RAND 研究报告。
- George H. Mealy, A Method for Synthesizing Sequential Circuits,Bell System Technical Journal,1955。
- Edward F. Moore, Gedanken-experiments on Sequential Machines,收入 Automata Studies,1956。
- David Harel, Statecharts: A Visual Formalism for Complex Systems,Science of Computer Programming,1987。
- AWS Prescriptive Guidance, Transactional Outbox Pattern。
- Apache Kafka, Design: Message Delivery Semantics。
- RabbitMQ, Reliability Guide。
- Stripe, Idempotent Requests。