2022 年 7 月,入职某防火墙自动化团队约一个月后,接到第一个独立功能需求:在 UI 上显示每条 Rule 的当前状态。
Rule 是用户在系统里下发的防火墙策略。每条策略在系统里会经历一系列流转节点:submit、approve、dryrun、commit、rollback、archive……这些节点对应不同的业务操作,最终需要在界面上展示"这条 Rule 现在处于哪个阶段"。
这是一个看起来不大的需求,但它要求先回答一个设计问题:Rule 的状态存在哪里?
两种方案
方案一:SQL 聚合
不新增字段。系统里本来就有 rule_dryrun、rule_commit 等操作记录表,查询当前状态时通过 JOIN 这些表、判断哪些记录存在来推算出状态。
改动量最小,现有表结构不动,在查询层加逻辑就够了。
方案二:状态归属
在 rules 表增加 status 字段,每次业务状态发生变更时在对应模块里写入新状态。Rule 的状态就存在 Rule 这里,查询时直接读取。
初期需要找到所有状态变更的节点,在分散在多个模块的代码里补齐“更新 Rule 状态”的逻辑,改动范围比方案一大。
讨论
两种方案都在组内讨论过。支持方案一的理由有几个:改动文件少,风险低;项目当时还不确定做多久,不必做太完善;另外我是新入职,对 Python 代码库不熟悉,方案一可以减少上手压力。
但是…我更倾向方案二,核心判断是:Rule 的状态是 Rule 本身的属性,有独立的业务名称和语义,它应该和 Rule 在一起,而不是分散在各操作记录表里通过 JOIN 聚合出来。方案一把复杂度延后了——每增加一个新的查询场景,都要重新写一次聚合逻辑;状态维度越多,维护代价越高。
讨论中有一个具体的质疑:如果用户对一条 Rule 操作三次,rules 表就要被修改三次,会不会太频繁?我的回应是:操作了三次,状态就变了三次,写入三次和业务行为是完全对应的,代码逻辑和业务逻辑是一致的。
讨论下来,同事们接受了这个方向。其中一位熟悉代码库的同事专门指导我:Rule 的状态会在哪些业务节点发生变化,应该在哪些地方插入状态更新代码。这个指导让我能在不熟悉代码库的情况下快速定位到所有需要修改的地方。
执行
功能在一个 sprint(约一个月)内完成开发、测试、上线。改动涉及多个模块,但每个模块的改动量不大,主要工作是把状态抽象出来、并在正确的节点写入。
上线前还准备了一份 migration.sql,对存量的历史 Rule 记录做 backfill——需要根据这些 Rule 在各操作表里的记录,推算出它们当前应该处于哪个状态。这份 SQL 写起来需要复杂的跨表 JOIN,刚好印证了方案一如果长期维护会是什么样子。
回看
这个字段上线后,后续三年里所有 Rule 状态机相关的功能——新的流转流程、状态过滤、统计报表、列表查询里的复杂过滤条件——大多都直接依赖这个 status 字段,没有因为最初的设计需要绕路。后来又陆续引入了更多流程,让 Rule 在更多角色之间流转;这些变化本质上都是状态转移,没有对应的操作历史记录。如果当初用聚合方案,这些新状态根本没有操作记录可以聚合,届时无论如何都要补加 status 字段,区别只是改动量更大、时机更被动。
关于这两种设计的判断依据,可以借用一个简单的问题:
状态是这个实体本身的属性,还是多个操作记录的派生?
如果是实体的固有属性,有独立的业务语义,需要按状态过滤或统计——归属方案更合适。如果状态没有独立含义,本质上就是"某些操作是否存在"的投影,且必须保留完整的操作历史——聚合方案更合适,典型例子是银行账本(余额 = 所有流水的 SUM,不能单独修改)。
Rule 的状态有清晰的业务名称和状态机路径,属于前者。
两种方案的经典案例
归属方案:电商订单状态
淘宝、Amazon 的订单表里有一个 status 字段,直接记录当前状态:
orders (id, user_id, amount, status, ...)
status: pending_payment → paid → processing → shipped → delivered → completed
查询"待发货订单"只需要:
SELECT * FROM orders WHERE status = 'paid'
订单状态是订单本身的属性,有独立的业务名称和流转路径,需要高频过滤(卖家后台、仓库系统)。状态跟着业务行为变更,是合理的修改操作。
聚合方案:银行账户余额
不存余额,只存每一笔流水:
ledger_entries (account_id, amount, type, created_at)
余额 = SELECT SUM(amount) FROM ledger_entries WHERE account_id = ?
余额不能被单独修改,必须从流水推导。直接 UPDATE balance = 100 在金融场景里是数据篡改。监管要求每一笔资金变动可追溯,流水是唯一可信来源,余额只是它的投影。
对比
| 电商订单 | 银行账本 | |
|---|---|---|
| 状态来源 | 直接存在实体行里 | 从操作流水聚合计算 |
| 能否单独修改 | 可以(有业务意义) | 不行(必须通过流水) |
| 核心诉求 | 快速过滤、状态机流转 | 不可篡改、完整审计 |
| 历史记录 | 另建 history 表 | 流水本身就是历史 |
两种方案不是对立的,很多系统两者并存:订单表有 status 字段,同时有 order_status_history 记录每次变更。Rule 的系统也类似——rules.status 负责当前状态查询,rule_dryrun、rule_commit 等表负责操作历史,各司其职。
回头看这件事,它不只是一个设计决策的故事。当时面对的阻力是真实的:项目不确定性、代码库不熟悉、团队信任还没建立。选择方案一在当时看来是更省力的路,代价留在后面。
但状态属于谁、应该存在哪里,这是一个有明确答案的问题。用第一性原理分析:Rule 的状态是 Rule 的属性,不是其他表的派生。这个判断说清楚了,团队接受了。事后的需求变化也印证了,绕过这个问题只是把改动推迟,不是消除它。
不因短期便利或对改动量的顾虑而选一个看起来更简单的方案,把复杂度留到后面——这比设计本身更值得记录。