Software Architect · 模块 09
大多数棘手的 bug 不是来自算法,而是来自隐式的状态以及状态之间的转换。
State machine · lifecycle · concurrency · consistency
如果一个对象有生命周期,就把它设计成 state machine,而不是堆一堆 boolean flag。
状态必须显式
交通灯能被理解,是因为它的状态有限,且转换被规定清楚了。如果所有灯一起亮,司机就会在路口争论起来。
Order、payment、delivery、subscription、ticket、deployment——这些都是带 lifecycle 的实体。它们有状态和转换:draft、pending_payment、paid、cancelled、refunded。架构师要做的事,就是定义允许的转换以及每个转换的归属。
当 state machine 被 isPaid、isCancelled、isRefunded、isArchived 这类 flag 取代时,系统很快就会出现不可能的组合:同时既已支付又已取消、未付款却已发货、没有 capture 却出现了 refund。
Concurrency 是模型的一部分
如果两个人同时编辑一份文档,又没有规则,赢的不是有理的那位,而是最后保存的那位。
真实系统里,多个进程可能修改同一份状态:用户、worker、webhook、admin、retry。你需要 optimistic locking、version field、transactions、unique constraints 或 compare-and-swap 操作。
架构师必须问清楚:谁有资格修改这个状态、竞态怎么解决、哪些操作幂等、什么算最终状态。
好的状态可以用一张图讲清楚。坏的状态只能靠一长串例外来解释。
正例:payment lifecycle
一笔银行操作有它的阶段。你不能先退款,然后再决定钱有没有被扣过。
Payment 可以从 created 进入 authorized,再走向 captured 或 voided。Refund 只能在 captured 之后发生。Provider 的 webhook 不会随意写字段,而是触发一次带当前状态和 version 校验的状态转换。
这种做法把 edge case 变成了模型的一部分,而不是散落在代码各处的一堆 if。
反例:用 flag 代替生命周期
一份有十个勾选框的表单看起来很灵活,直到两个勾互相矛盾。
Order 有 paid_at、cancelled_at、failed_at、refunded_at、completed_at,但没有一个清楚的状态字段。不同模块对组合的解读不一样。客服看到的是一个版本,财务看到的是另一个版本,API 又是第三个版本。
这不是灵活,是一台没有规则的隐式 state machine。
- 这个实体有哪些状态? - 哪些转换是被禁止的? - 谁拥有这个转换:用户、系统,还是外部 provider? - 我们怎么防止重试和竞态?