工程设计巧思:可复用的程序与架构模式
工程设计巧思:可复用的程序与架构模式
工程设计的难点通常不在于找到某个框架,而在于把本来隐含在口头约定、调用顺序和运维经验里的约束,变成类型、数据约束、状态迁移、接口和可观测证据。设计模式不是可直接粘贴的类图;它是一种把变化压缩到少数边界、让错误尽早暴露的组织方式。
本文把常见的工程问题归成十二个可复用模式。每个模式都给出适用信号、最小实现、边界与迁移方向。重点不在模式名称,而在回答一个问题:当需求变化时,哪一条约束必须继续成立,谁负责守住它。
先画出约束落点
flowchart TD
R[业务规则] --> I[不变量]
I --> T[类型与接口]
I --> D[数据库约束与事务]
I --> S[状态机与幂等键]
I --> O[日志 指标 审计]
T --> C[调用方]
D --> C
S --> C
O --> V[故障时可验证]
同一条业务规则往往需要多个落点。例如“订单只能扣减一次”不能只靠前端按钮禁用:接口重试、消息重领、并发请求和人工补偿都可能绕过前端。更完整的设计是:请求携带幂等键,权威写入由唯一约束或条件更新保护,异步消费者用业务键去重,日志保留相关 ID。
| 模式 | 听到的需求信号 | 先问的问题 | 最小落点 |
|---|---|---|---|
| 不变量前置 | “这个字段永远不能……” | 哪些状态不合法? | 构造校验、数据库约束、条件更新 |
| 值与实体分离 | “改价格后对象还能找回吗?” | 什么定义身份,什么只是属性? | 不可变值、稳定 ID |
| 短命状态参数化 | “为何每次都要 new 一个执行器?” | 状态属于组件还是一次调用? | 方法参数、不可变命令 |
| 幂等命令 | “超时后要不要重试?” | 同一意图如何识别? | 幂等键、结果记录 |
| 显式状态机 | “失败后还能撤销吗?” | 合法迁移有哪些? | 状态表、版本或条件更新 |
| 事务外发箱 | “提交后消息丢了怎么办?” | 权威写入与事件如何对账? | 同事务 outbox、可重放发布器 |
| 派生数据可修复 | “缓存错了怎么恢复?” | 哪份数据是权威的? | 主数据、版本、重建任务 |
| 端口倒置 | “替换支付方为何改了业务?” | 谁拥有业务语义? | 业务侧端口、适配器 |
| 有界队列 | “异步后是否更可靠?” | 积压到哪一步必须拒绝? | 容量、超时、背压 |
| 旁路演进 | “能否不停机切换?” | 新旧结果如何比对与回退? | 双写/影子读、开关、迁移账本 |
| 可观测契约 | “线上发生过什么?” | 一次业务动作能否串起来? | correlation ID、事件与指标 |
| 复合验收 | “测试绿了就能上线吗?” | 哪些结论被实际证据支持? | 单测、集成、演练、监控 |
1. 先让不变量有执行者
不变量是无论从哪个入口修改数据都必须成立的规则,例如库存不得为负、一个外部订单号只能对应一条订单、已撤销的资源不能再对外返回。规则若只写在服务层,很容易被批处理、脚本、异步消费者或新接口绕过。
以库存扣减为例,先读出数量、在应用内判断、再写回,是典型的竞态窗口:两个请求可以都读到 1,然后都认为自己能扣减。把检查和修改压成一条条件更新,数据库才有机会成为并发边界:
1 | |
受影响行数为 1 才表示预留成功;为 0 时,调用方只能把它解释为库存不足、版本冲突或目标不存在,是否区分要看产品契约。CHECK (available >= 0) 仍有价值:它保护所有写入路径,但不能代替条件更新返回“本次是否成功”的业务结果。
模式提炼:规则贴近权威写入
公式:写入成功 = 条件满足 ∧ 原子提交。
| 场景 | 条件 | 权威位置 |
|---|---|---|
| 库存预留 | available >= quantity |
库存行 |
| 乐观改价 | version = expectedVersion |
订单行 |
| 首次消费 | event_id 未处理 |
去重表唯一键 |
| 资源撤销 | 当前状态可撤销 | 资源状态行 |
听到“任何入口都不能破坏”时,应先找权威写入,再决定是用数据库约束、条件更新、事务隔离还是领域对象校验。不同隔离级别给出的保证并不相同;PostgreSQL 的 Read Committed 默认按语句取快照,而 Serializable 会在无法序列化时中止其中一个事务,因此后者必须配合整笔事务重试。PostgreSQL 事务隔离文档明确说明了这两种行为与重试要求。
2. 分开处理身份、值与一次调用状态
“对象相等”至少有三种含义。实体按稳定身份相等,例如同一订单 ID 的两个加载实例;值对象按全部值相等,例如金额和币种;一次调用的上下文只在本次执行中存在,例如锁键、超时、trace ID。把三者混到一起会得到难以复用的 prototype 对象、可变的 Map 键,或一次改价就改变“订单是谁”的错误。
1 | |
LockExecutor 只保存可共享依赖,LockCommand 保存每次调用的不可变输入。这样不需要工厂去制造带短命状态的执行器。反过来,如果锁对象本身有不可安全共享的会话状态,不能仅因“单例更省对象”而强行共享。
模式提炼:把短命状态移到边界参数
公式:可复用组件 = 共享依赖 + f(不可变命令)。
这个模式可迁移到邮件发送、数据库事务模板、导入任务、支付调用和批量执行器。适用前提是组件本身不保存调用间可变状态;调用状态若需要跨重试保存,应转为持久化的命令记录,而不是成员变量。
3. 用幂等键表达“同一次意图”
网络超时只说明调用方没有及时收到结果,不说明服务端没有执行。对创建订单、扣款、发券等写操作,重试前先确定“同一次意图”的身份,再决定如何复用结果。把客户端重试次数当作幂等边界无效:网关、SDK、消息系统和人工补单都可能重新发起请求。
1 | |
处理流程应在同一事务里占位、执行权威写入、保存最终结果。重复请求读到相同 request_key 时,必须校验 request_hash;同一键带不同请求体不是“安全重试”,应拒绝或显式返回冲突。对于执行时间很长的命令,可以先保存 PROCESSING,让重复调用得到同一任务 ID;不要让第二个请求开启第二次副作用。
AWS 的可靠性指导把“先确认变更操作是否幂等”列为实施重试的前置条件,并指出重试、退避和限流必须一起设计。AWS Reliability Pillar的这条约束同样适用于内部服务。
模式提炼:意图标识独立于传输次数
公式:同一业务意图 → 同一幂等键 → 同一可复查结果。
幂等键不等于消息 ID,也不等于数据库自增 ID。它需要覆盖真正的去重范围:用户提交订单通常是“用户 + 客户端请求键”,消息消费通常是“事件 ID + 消费者职责”,外部支付还需要对齐支付方支持的去重语义与保留期。
4. 把状态迁移写成条件,而不是散落的 if
当对象会经过“新建、预留、确认、取消、关闭”等阶段时,状态字段不是日志标签,而是行为边界。每一条迁移都应说明前置状态、触发者、不可逆性、失败后的可重试条件以及副作用何时发生。
1 | |
这个语句同时拒绝重复确认、过期版本和已取消记录。若业务允许确认后撤销,应建一条明确的 CONFIRMED → CANCELLED 迁移,并补偿库存;不能在任意服务里把状态字段直接写成字符串。对不可逆外部副作用,状态变更和副作用完成应使用不同状态,例如 PAYMENT_REQUESTED 与 PAID,否则超时后无法判断“没发起”还是“已发起但未收到回执”。
模式提炼:状态就是允许操作的索引
公式:允许迁移 = 当前状态 ∈ 前置集合 ∧ 版本匹配。
状态机适用于审批、订单、工单、发布、数据迁移和回调处理。只有两三个状态、无并发写入且不涉及外部副作用时,枚举字段加局部校验已足够;为了画状态机而制造大量中间状态,只会模糊责任。
5. 把“写库后发消息”改成可对账的外发箱
数据库事务提交后再调用消息队列,进程可能在两者之间崩溃:订单已创建,事件没有发出。先发消息再提交数据库也不正确:消费者可能看到一个最终回滚的订单。没有跨系统原子事务时,可靠的最小方案是把要发布的事件写进同一个数据库事务,再由独立发布器扫描、投递和标记。
1 | |
发布器至少要能重复投递、记录尝试次数、监控最旧待发事件,并能从 event_id 重放。消费者仍要幂等,因为“发布器收到 broker 确认前失效”会导致投递不确定;Outbox 解决的是权威写入与待发布事实的一致性,不会神奇地让外部邮件、HTTP 调用或支付副作用自动精确一次。
Kafka 的幂等生产者和事务能把 Kafka 内部的读、状态更新和写入组合为更强的语义。其精确一次保证仍有边界:Kafka Streams 文档把它限定为输入 offset、状态存储和输出 topic 的原子完成;外部系统副作用仍需自身的幂等协议。Kafka Streams Core Concepts对此作出限定。
模式提炼:把跨边界的不确定性存成事实
公式:权威变更事务 = 领域数据 + 待投递事件。
只要未来需要回答“这个事件到底有没有发过、能否重发、谁处理过”,就需要一个可查询记录。它可以是 outbox 表、事务日志或具备等价语义的存储,但不能只存在进程内内存队列。
6. 区分权威数据、派生数据和缓存
缓存、搜索索引、报表、读模型和物化视图都应该被视为可重建的派生数据。设计时先写清“哪份数据能裁决争议”:权限、余额、库存、撤销状态通常必须回到权威存储;展示排序、搜索分词和统计聚合则可以延迟并重建。
派生数据至少带两个信息:来源版本或事件位置,以及生成时间。读路径遇到版本落后时,可回源、拒绝或显示旧值,取决于契约。对于“撤销后不得继续访问”的安全边界,缓存命中不能压过权威撤销状态;对于非关键推荐列表,短暂旧值可能是可接受成本。
模式提炼:能重建的,才允许不是权威
公式:projection = rebuild(authoritative history, version)。
缓存失效策略不是“设置一个 TTL”就结束。TTL 只限制旧数据最长存活时间,不能保证主动撤销立即生效;需要版本化 key、失效事件、回源校验或安全请求绕过缓存中的一种或多种机制。
7. 让业务拥有端口,适配器留在外围
支付、短信、对象存储和消息队列是技术实现;“收取款项”“通知收件人”“保存附件”是业务能力。若业务服务直接依赖某家 SDK 的请求对象、异常与回调,替换供应商会把改动扩散到规则层,单元测试也被迫启动真实客户端。
1 | |
端口应由业务一侧定义,只暴露业务需要的命令与结果。SDK 适配器负责序列化、鉴权、超时和供应商错误映射;领域层不应知道 HTTP 状态码或第三方 DTO。端口并非每个工具类都要一层接口:没有替换需求、没有测试隔离价值且只有一处调用时,直接依赖更简单。
模式提炼:依赖方向服从业务语言
公式:领域服务 → 业务端口 ← 基础设施适配器。
端口的价值来自隔离变化,而非接口数量。接口的方法粒度应贴近业务动作,不能把供应商 SDK 的几十个方法原样搬进系统。
8. 为异步系统设上限、确认点和重领路径
异步只能把等待移到队列,不能删除容量约束。每个消费者需要定义:何时确认、失败后如何重试、何时进入死信、积压到多大拒绝新工作、重复执行如何收敛。没有这些边界,队列会把短暂故障积累为延迟雪崩。
1 | |
该公式不能预测真实恢复时间,却足以暴露一个基本事实:消费速率不高于入队速率时,扩容前积压不会自行消失。监控至少应分开新消息、已投递未确认消息、失败重试数、最旧工作年龄和实际副作用失败率;仅看队列长度无法知道瓶颈是在投递、处理还是确认。
模式提炼:先划确认边界,再谈削峰
公式:ACK 发生在可安全重试的最晚位置。
邮件、推送等外部副作用通常在“发送成功后、确认前”失效,因此要以 event_id + recipient 等业务键去重。耗时任务也需要租约;租约过短会把仍在执行的工作提前重领,租约无限长则会让失效工作永久卡住。
9. 用旁路和证据演进,不让一次切换赌上全部流量
稳定链路的替换适合拆成三个问题:新路径是否能得到相同或可接受的结果,何时让它接管流量,发现异常怎样回退。影子计算先比较新旧输出而不影响用户;双写保证新存储有足够历史;读切换必须能按版本回退;数据迁移则要有完成账本和校验,而不是只留一个开关。
开关适合控制路由、实验和降级,不适合替代数据状态机。某个迁移已经写入一半时,仅把开关关掉并不能让新旧数据自动一致。迁移任务应记录每个分片、批次或实体的版本与校验结果,重复执行也能安全收敛。
模式提炼:切换是受控路由,不是一次发布
公式:observe → compare → small rollout → rollback or expand。
影子请求必须注意副作用:只镜像可安全读取的请求,或让影子路径使用隔离的写入目标。新旧结果不完全相同也未必是错误,但差异必须有分类、阈值和处理人,否则比较日志只会变成噪声。
10. 将可观测性视为运行时契约
日志并非“多打几行”。一次用户动作穿越网关、服务、数据库、消息和回调时,需要稳定的关联标识:请求 ID 用于一次传输,业务 ID 用于对象生命周期,幂等键用于同一意图,事件 ID 用于异步传播。它们不能相互替代,却应被一起记录。
| 证据 | 回答的问题 | 不应替代什么 |
|---|---|---|
| 结构化日志 | 该次动作经历了什么 | 业务事实存储 |
| 指标 | 哪类问题正在扩大 | 单次请求追踪 |
| Trace | 延迟和调用链在哪一跳 | 审计历史 |
| 审计事件 | 谁在何时改变了什么 | 高基数实时指标 |
| 对账任务 | 两边的事实是否一致 | 在线事务约束 |
对账是分布式系统里承认“会错过”的工程方式。支付记录、外部回执、outbox 投递和下游消费都可以按稳定业务键周期比对;差异应进入可重试、可审计的修复队列,而不是只写一条告警然后依赖人工回忆。
模式提炼:每条承诺都要留下可检索证据
公式:业务动作 → 业务 ID + 状态变化 + 时间 + 关联 ID。
可观测性设计应在接口和事件模型确定时完成。事故发生后再补日志,最缺的往往正是无法回放的旧请求上下文。
11. 选择实现前的五个判断
模式不是清单式地全选。每遇到一个新需求,先用以下判断压缩设计空间:
- 哪条结果必须正确,哪条可以晚到或暂时旧?
- 哪份数据有最终裁决权,哪些都能从它重建?
- 同一意图会从哪些入口重试、重复或并发到达?
- 外部副作用发生在事务的哪一边,失败后能否查询和补偿?
- 积压、锁、重试和缓存各自的上限是什么,达到上限时谁被保护、谁被拒绝?
这五项能先排除许多无效方案。例如不允许重复扣款时,单纯增加队列并没有回答幂等;撤销后不得访问时,只增加 CDN 缓存也没有回答权威状态;需要随时回退的数据迁移,仅有功能开关也没有回答已写入数据的去向。
12. 模式速查表
| 需求关键词 | 首选模式 | 最小实现 | 常见误用 |
|---|---|---|---|
| 绝不能为负/重复 | 不变量前置 | 条件更新、唯一键、约束 | 只在 Controller 校验 |
| 重试不能重复执行 | 幂等命令 | 幂等键、结果记录 | 只依赖 HTTP 重试次数 |
| 多步骤有先后关系 | 显式状态机 | 条件状态迁移、版本 | 任意代码直接改字符串 |
| 写库后还要通知 | 外发箱 | 同事务事件记录、发布器 | 提交后立即 fire-and-forget |
| 缓存、索引可能错误 | 派生数据可修复 | 权威源、版本、重建 | 把缓存当授权依据 |
| 更换 SDK 或供应商 | 业务端口 | 业务接口、外围适配器 | 逐项镜像 SDK |
| 异步积压与重领 | 有界队列 | ACK 边界、租约、死信、背压 | 只监控队列长度 |
| 大改不敢全量切 | 旁路演进 | 影子比较、小流量、回退 | 一个开关覆盖数据迁移 |
| 故障无法解释 | 可观测契约 | 业务 ID、事件、指标、对账 | 把日志当审计库 |
今天的细分推导可继续阅读:设计模式 00:可工作的报价为何难修改、设计模式 01:订单身份和金额值为什么不能混为一谈和系统设计 10:消息与异步处理——确认、重领和背压。本篇提供选择模式的总览;细文分别验证对象建模、数据约束与消息消费中的具体边界。
参考资料
- PostgreSQL 18:Transaction Isolation:事务隔离、并发异常与序列化失败后的重试语义。
- AWS Well-Architected:Control and limit retry calls:重试应以幂等变更操作为前提。
- Apache Kafka:Streams Core Concepts:Kafka 内部精确一次处理语义的边界。

