工程设计巧思:可复用的程序与架构模式

工程设计的难点通常不在于找到某个框架,而在于把本来隐含在口头约定、调用顺序和运维经验里的约束,变成类型、数据约束、状态迁移、接口和可观测证据。设计模式不是可直接粘贴的类图;它是一种把变化压缩到少数边界、让错误尽早暴露的组织方式。

本文把常见的工程问题归成十二个可复用模式。每个模式都给出适用信号、最小实现、边界与迁移方向。重点不在模式名称,而在回答一个问题:当需求变化时,哪一条约束必须继续成立,谁负责守住它。

先画出约束落点

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
2
3
4
5
UPDATE inventory
SET available = available - :quantity,
version = version + 1
WHERE sku_id = :skuId
AND available >= :quantity;

受影响行数为 1 才表示预留成功;为 0 时,调用方只能把它解释为库存不足、版本冲突或目标不存在,是否区分要看产品契约。CHECK (available >= 0) 仍有价值:它保护所有写入路径,但不能代替条件更新返回“本次是否成功”的业务结果。

模式提炼:规则贴近权威写入

公式:写入成功 = 条件满足 ∧ 原子提交。

场景 条件 权威位置
库存预留 available >= quantity 库存行
乐观改价 version = expectedVersion 订单行
首次消费 event_id 未处理 去重表唯一键
资源撤销 当前状态可撤销 资源状态行

听到“任何入口都不能破坏”时,应先找权威写入,再决定是用数据库约束、条件更新、事务隔离还是领域对象校验。不同隔离级别给出的保证并不相同;PostgreSQL 的 Read Committed 默认按语句取快照,而 Serializable 会在无法序列化时中止其中一个事务,因此后者必须配合整笔事务重试。PostgreSQL 事务隔离文档明确说明了这两种行为与重试要求。

2. 分开处理身份、值与一次调用状态

“对象相等”至少有三种含义。实体按稳定身份相等,例如同一订单 ID 的两个加载实例;值对象按全部值相等,例如金额和币种;一次调用的上下文只在本次执行中存在,例如锁键、超时、trace ID。把三者混到一起会得到难以复用的 prototype 对象、可变的 Map 键,或一次改价就改变“订单是谁”的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public record LockCommand(String key, Duration wait, Duration lease) {
public LockCommand {
if (key == null || key.isBlank()) throw new IllegalArgumentException("key");
}
}

public final class LockExecutor {
private final LockClient client;

public LockExecutor(LockClient client) {
this.client = client;
}

public <T> T call(LockCommand command, Callable<T> action) throws Exception {
Lock lock = client.acquire(command.key(), command.wait(), command.lease());
try {
return action.call();
} finally {
lock.release();
}
}
}

LockExecutor 只保存可共享依赖,LockCommand 保存每次调用的不可变输入。这样不需要工厂去制造带短命状态的执行器。反过来,如果锁对象本身有不可安全共享的会话状态,不能仅因“单例更省对象”而强行共享。

模式提炼:把短命状态移到边界参数

公式:可复用组件 = 共享依赖 + f(不可变命令)。

这个模式可迁移到邮件发送、数据库事务模板、导入任务、支付调用和批量执行器。适用前提是组件本身不保存调用间可变状态;调用状态若需要跨重试保存,应转为持久化的命令记录,而不是成员变量。

3. 用幂等键表达“同一次意图”

网络超时只说明调用方没有及时收到结果,不说明服务端没有执行。对创建订单、扣款、发券等写操作,重试前先确定“同一次意图”的身份,再决定如何复用结果。把客户端重试次数当作幂等边界无效:网关、SDK、消息系统和人工补单都可能重新发起请求。

1
2
3
4
5
6
7
8
9
CREATE TABLE idempotency_result (
actor_id bigint NOT NULL,
request_key text NOT NULL,
request_hash text NOT NULL,
status text NOT NULL,
response_json jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (actor_id, request_key)
);

处理流程应在同一事务里占位、执行权威写入、保存最终结果。重复请求读到相同 request_key 时,必须校验 request_hash;同一键带不同请求体不是“安全重试”,应拒绝或显式返回冲突。对于执行时间很长的命令,可以先保存 PROCESSING,让重复调用得到同一任务 ID;不要让第二个请求开启第二次副作用。

AWS 的可靠性指导把“先确认变更操作是否幂等”列为实施重试的前置条件,并指出重试、退避和限流必须一起设计。AWS Reliability Pillar的这条约束同样适用于内部服务。

模式提炼:意图标识独立于传输次数

公式:同一业务意图 → 同一幂等键 → 同一可复查结果。

幂等键不等于消息 ID,也不等于数据库自增 ID。它需要覆盖真正的去重范围:用户提交订单通常是“用户 + 客户端请求键”,消息消费通常是“事件 ID + 消费者职责”,外部支付还需要对齐支付方支持的去重语义与保留期。

4. 把状态迁移写成条件,而不是散落的 if

当对象会经过“新建、预留、确认、取消、关闭”等阶段时,状态字段不是日志标签,而是行为边界。每一条迁移都应说明前置状态、触发者、不可逆性、失败后的可重试条件以及副作用何时发生。

1
2
3
4
5
6
7
UPDATE reservations
SET status = 'CONFIRMED',
version = version + 1,
confirmed_at = now()
WHERE reservation_id = :id
AND status = 'PENDING'
AND version = :version;

这个语句同时拒绝重复确认、过期版本和已取消记录。若业务允许确认后撤销,应建一条明确的 CONFIRMED → CANCELLED 迁移,并补偿库存;不能在任意服务里把状态字段直接写成字符串。对不可逆外部副作用,状态变更和副作用完成应使用不同状态,例如 PAYMENT_REQUESTED 与 PAID,否则超时后无法判断“没发起”还是“已发起但未收到回执”。

模式提炼:状态就是允许操作的索引

公式:允许迁移 = 当前状态 ∈ 前置集合 ∧ 版本匹配。

状态机适用于审批、订单、工单、发布、数据迁移和回调处理。只有两三个状态、无并发写入且不涉及外部副作用时,枚举字段加局部校验已足够;为了画状态机而制造大量中间状态,只会模糊责任。

5. 把“写库后发消息”改成可对账的外发箱

数据库事务提交后再调用消息队列,进程可能在两者之间崩溃:订单已创建,事件没有发出。先发消息再提交数据库也不正确:消费者可能看到一个最终回滚的订单。没有跨系统原子事务时,可靠的最小方案是把要发布的事件写进同一个数据库事务,再由独立发布器扫描、投递和标记。

1
2
3
4
5
BEGIN;
INSERT INTO orders (order_id, status) VALUES (:orderId, 'CREATED');
INSERT INTO outbox (event_id, topic, payload, status)
VALUES (:eventId, 'order.created', :payload, 'PENDING');
COMMIT;

发布器至少要能重复投递、记录尝试次数、监控最旧待发事件,并能从 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
2
3
4
5
6
7
8
9
10
11
public interface PaymentPort {
PaymentResult charge(ChargeCommand command);
}

public final class CheckoutService {
private final PaymentPort payments;

public CheckoutService(PaymentPort payments) {
this.payments = payments;
}
}

端口应由业务一侧定义,只暴露业务需要的命令与结果。SDK 适配器负责序列化、鉴权、超时和供应商错误映射;领域层不应知道 HTTP 状态码或第三方 DTO。端口并非每个工具类都要一层接口:没有替换需求、没有测试隔离价值且只有一处调用时,直接依赖更简单。

模式提炼:依赖方向服从业务语言

公式:领域服务 → 业务端口 ← 基础设施适配器。

端口的价值来自隔离变化,而非接口数量。接口的方法粒度应贴近业务动作,不能把供应商 SDK 的几十个方法原样搬进系统。

8. 为异步系统设上限、确认点和重领路径

异步只能把等待移到队列,不能删除容量约束。每个消费者需要定义:何时确认、失败后如何重试、何时进入死信、积压到多大拒绝新工作、重复执行如何收敛。没有这些边界,队列会把短暂故障积累为延迟雪崩。

1
积压时长 = pending work / max(消费速率 - 入队速率, ε)

该公式不能预测真实恢复时间,却足以暴露一个基本事实:消费速率不高于入队速率时,扩容前积压不会自行消失。监控至少应分开新消息、已投递未确认消息、失败重试数、最旧工作年龄和实际副作用失败率;仅看队列长度无法知道瓶颈是在投递、处理还是确认。

模式提炼:先划确认边界,再谈削峰

公式:ACK 发生在可安全重试的最晚位置。

邮件、推送等外部副作用通常在“发送成功后、确认前”失效,因此要以 event_id + recipient 等业务键去重。耗时任务也需要租约;租约过短会把仍在执行的工作提前重领,租约无限长则会让失效工作永久卡住。

9. 用旁路和证据演进,不让一次切换赌上全部流量

稳定链路的替换适合拆成三个问题:新路径是否能得到相同或可接受的结果,何时让它接管流量,发现异常怎样回退。影子计算先比较新旧输出而不影响用户;双写保证新存储有足够历史;读切换必须能按版本回退;数据迁移则要有完成账本和校验,而不是只留一个开关。

开关适合控制路由、实验和降级,不适合替代数据状态机。某个迁移已经写入一半时,仅把开关关掉并不能让新旧数据自动一致。迁移任务应记录每个分片、批次或实体的版本与校验结果,重复执行也能安全收敛。

模式提炼:切换是受控路由,不是一次发布

公式:observe → compare → small rollout → rollback or expand。

影子请求必须注意副作用:只镜像可安全读取的请求,或让影子路径使用隔离的写入目标。新旧结果不完全相同也未必是错误,但差异必须有分类、阈值和处理人,否则比较日志只会变成噪声。

10. 将可观测性视为运行时契约

日志并非“多打几行”。一次用户动作穿越网关、服务、数据库、消息和回调时,需要稳定的关联标识:请求 ID 用于一次传输,业务 ID 用于对象生命周期,幂等键用于同一意图,事件 ID 用于异步传播。它们不能相互替代,却应被一起记录。

证据 回答的问题 不应替代什么
结构化日志 该次动作经历了什么 业务事实存储
指标 哪类问题正在扩大 单次请求追踪
Trace 延迟和调用链在哪一跳 审计历史
审计事件 谁在何时改变了什么 高基数实时指标
对账任务 两边的事实是否一致 在线事务约束

对账是分布式系统里承认“会错过”的工程方式。支付记录、外部回执、outbox 投递和下游消费都可以按稳定业务键周期比对;差异应进入可重试、可审计的修复队列,而不是只写一条告警然后依赖人工回忆。

模式提炼:每条承诺都要留下可检索证据

公式:业务动作 → 业务 ID + 状态变化 + 时间 + 关联 ID。

可观测性设计应在接口和事件模型确定时完成。事故发生后再补日志,最缺的往往正是无法回放的旧请求上下文。

11. 选择实现前的五个判断

模式不是清单式地全选。每遇到一个新需求,先用以下判断压缩设计空间:

  1. 哪条结果必须正确,哪条可以晚到或暂时旧?
  2. 哪份数据有最终裁决权,哪些都能从它重建?
  3. 同一意图会从哪些入口重试、重复或并发到达?
  4. 外部副作用发生在事务的哪一边,失败后能否查询和补偿?
  5. 积压、锁、重试和缓存各自的上限是什么,达到上限时谁被保护、谁被拒绝?

这五项能先排除许多无效方案。例如不允许重复扣款时,单纯增加队列并没有回答幂等;撤销后不得访问时,只增加 CDN 缓存也没有回答权威状态;需要随时回退的数据迁移,仅有功能开关也没有回答已写入数据的去向。

12. 模式速查表

需求关键词 首选模式 最小实现 常见误用
绝不能为负/重复 不变量前置 条件更新、唯一键、约束 只在 Controller 校验
重试不能重复执行 幂等命令 幂等键、结果记录 只依赖 HTTP 重试次数
多步骤有先后关系 显式状态机 条件状态迁移、版本 任意代码直接改字符串
写库后还要通知 外发箱 同事务事件记录、发布器 提交后立即 fire-and-forget
缓存、索引可能错误 派生数据可修复 权威源、版本、重建 把缓存当授权依据
更换 SDK 或供应商 业务端口 业务接口、外围适配器 逐项镜像 SDK
异步积压与重领 有界队列 ACK 边界、租约、死信、背压 只监控队列长度
大改不敢全量切 旁路演进 影子比较、小流量、回退 一个开关覆盖数据迁移
故障无法解释 可观测契约 业务 ID、事件、指标、对账 把日志当审计库

今天的细分推导可继续阅读:设计模式 00:可工作的报价为何难修改、设计模式 01:订单身份和金额值为什么不能混为一谈和系统设计 10:消息与异步处理——确认、重领和背压。本篇提供选择模式的总览;细文分别验证对象建模、数据约束与消息消费中的具体边界。

参考资料