工程设计巧思:可复用的程序与架构模式
工程设计巧思:可复用的程序与架构模式
工程设计的难点通常不在于找到某个框架,而在于把本来隐含在口头约定、调用顺序和运维经验里的约束,变成类型、数据约束、状态迁移、接口和可观测证据。设计模式不是可直接粘贴的类图;它是一种把变化压缩到少数边界、让错误尽早暴露的组织方式。
本文把常见的工程问题整理成一组可复用模式。每个模式都给出适用信号、最小实现、边界与迁移方向。重点不在模式名称,而在回答一个问题:当需求变化时,哪一条约束必须继续成立,谁负责守住它。
先画出约束落点
图 1:一条业务规则在类型、存储、状态与运行证据中的落点
flowchart TD
R[业务规则] --> I[不变量]
I --> T[类型与接口]
I --> D[数据库约束与事务]
I --> S[状态机与幂等键]
I --> O[日志 指标 审计]
T --> C[调用方]
D --> C
S --> C
O --> V[故障时可验证]
同一条业务规则往往需要多个落点。例如“订单只能扣减一次”不能只靠前端按钮禁用:接口重试、消息重领、并发请求和人工补偿都可能绕过前端。更完整的设计是:请求携带幂等键,权威写入由唯一约束或条件更新保护,异步消费者用业务键去重,日志保留相关 ID。
| 模式 | 听到的需求信号 | 先问的问题 | 最小落点 |
|---|---|---|---|
| 不变量前置 | “这个字段永远不能……” | 哪些状态不合法? | 构造校验、数据库约束、条件更新 |
| 值与实体分离 | “改价格后对象还能找回吗?” | 什么定义身份,什么只是属性? | 不可变值、稳定 ID |
| 短命状态参数化 | “为何每次都要 new 一个执行器?” | 状态属于组件还是一次调用? | 方法参数、不可变命令 |
| 幂等命令 | “超时后要不要重试?” | 同一意图如何识别? | 幂等键、结果记录 |
| 显式状态机 | “失败后还能撤销吗?” | 合法迁移有哪些? | 状态表、版本或条件更新 |
| 事务外发箱 | “提交后消息丢了怎么办?” | 权威写入与事件如何对账? | 同事务 outbox、可重放发布器 |
| 派生数据可修复 | “缓存错了怎么恢复?” | 哪份数据是权威的? | 主数据、版本、重建任务 |
| 端口倒置 | “替换支付方为何改了业务?” | 谁拥有业务语义? | 业务侧端口、适配器 |
| 闭包归一化 | “不同签名也要统一增强?” | 增强器关心调用时机还是业务参数? | lambda、Supplier、Runnable |
| 有界队列 | “异步后是否更可靠?” | 积压到哪一步必须拒绝? | 容量、超时、背压 |
| 旁路演进 | “能否不停机切换?” | 新旧结果如何比对与回退? | 双写/影子读、开关、迁移账本 |
| 可观测契约 | “线上发生过什么?” | 一次业务动作能否串起来? | correlation ID、事件与指标 |
| 显式索引 | “这个循环为何越来越慢?” | 是否在反复按同一个键查找? | 顺序表、Map、预建索引 |
| 关系状态拆分 | “同一资源为何不能按业务独立降级?” | 状态属于资源,还是属于关系? | 关系表、业务维度键 |
| 参数契约 | “工具为何总在猜参数?” | 独立概念是否被塞进了一个 Map? | 正交字段、枚举、搜索接口 |
| 复合验收 | “测试绿了就能上线吗?” | 哪些结论被实际证据支持? | 单测、集成、演练、监控 |
1. 先让不变量有执行者
不变量是无论从哪个入口修改数据都必须成立的规则,例如库存不得为负、一个外部订单号只能对应一条订单、已撤销的资源不能再对外返回。规则若只写在服务层,很容易被批处理、脚本、异步消费者或新接口绕过。
以库存扣减为例,先读出数量、在应用内判断、再写回,是典型的竞态窗口:两个请求可以都读到 1,然后都认为自己能扣减。把检查和修改压成一条条件更新,数据库才有机会成为并发边界:
1 | |
受影响行数为 1 才表示预留成功;为 0 时,调用方只能把它解释为库存不足、版本冲突或目标不存在,是否区分要看产品契约。CHECK (available >= 0) 仍有价值:它保护所有写入路径,但不能代替条件更新返回“本次是否成功”的业务结果。
用伴随字段暴露异常值
单看一个字段,往往无法分辨“合法的边界值”和“被异常写入的值”。假设 ownerId 原本为 1,后来变成 0;若正常流程在主动清空 ownerId 时一定同时清空 ownerName,那么“ownerId = 0 但 ownerName 仍有值”就是可疑状态。
1 | |
这种修复依赖稳定的业务不变式,不能只凭“这两个字段看起来相关”就批量回写。更稳妥的步骤是先统计命中量,抽样排除合法反例,再进行可回滚的分批修复。
非事务多键写入要先定义污染边界
多个 Redis 命令不在同一事务中执行时,中途失败会留下部分写入。写入顺序不存在对所有数据结构都正确的固定答案,至少要守住三条底线:
- 先标出权威键、索引键和派生键,按“中途失败后仍可修复”安排顺序。
- 新增一类写入时,同步补齐删除、回滚和对账路径。
- 使用“数据已污染”标记阻断读写时,TTL 必须覆盖最长修复周期;无法估算修复时间时,由修复流程显式删除标记。
模式提炼:规则贴近权威写入
公式:写入成功 = 条件满足 ∧ 原子提交。
| 场景 | 条件 | 权威位置 |
|---|---|---|
| 库存预留 | available >= quantity |
库存行 |
| 乐观改价 | version = expectedVersion |
订单行 |
| 首次消费 | event_id 未处理 |
去重表唯一键 |
| 资源撤销 | 当前状态可撤销 | 资源状态行 |
听到“任何入口都不能破坏”时,应先找权威写入,再决定是用数据库约束、条件更新、事务隔离还是领域对象校验。不同隔离级别给出的保证并不相同;PostgreSQL 的 Read Committed 默认按语句取快照,而 Serializable 会在无法序列化时中止其中一个事务,因此后者必须配合整笔事务重试。PostgreSQL 事务隔离文档明确说明了这两种行为与重试要求。
2. 分开处理身份、值与一次调用状态
“对象相等”至少有三种含义。实体按稳定身份相等,例如同一订单 ID 的两个加载实例;值对象按全部值相等,例如金额和币种;一次调用的上下文只在本次执行中存在,例如锁键、超时、trace ID。把三者混到一起会得到难以复用的 prototype 对象、可变的 Map 键,或一次改价就改变“订单是谁”的错误。
1 | |
LockExecutor 只保存可共享依赖,LockCommand 保存每次调用的不可变输入。这样不需要工厂去制造带短命状态的执行器。反过来,如果锁对象本身有不可安全共享的会话状态,不能仅因“单例更省对象”而强行共享。
这个重构的识别信号很具体:一个 Spring 组件只因为保存锁键或超时时间,就被设成 prototype,调用方还需要通过 ApplicationContext 或工厂每次取新实例。问题不在容器作用域,而在调用状态放错了位置。可以先把键的生成规则收进命令对象:
1 | |
如果底层客户端未指定 leaseTime 时会使用 watchdog 续期,上层命令就应明确区分“等待获锁时间”、“租期”和“依赖 watchdog”这几种语义,不要只保留一个含糊的 expireSecs。无论选哪种 API,锁键都必须稳定且唯一地标识被保护的业务资源。
模式提炼:把短命状态移到边界参数
公式:可复用组件 = 共享依赖 + 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,否则超时后无法判断“没发起”还是“已发起但未收到回执”。
用移动代替立即删除
对仍有误删恢复或审计价值的对象,“删除”可以先被建模为可逆状态迁移:移入回收站、归档区或隔离表,保留操作人、时间和恢复所需的原始标识。真正的物理清理由保留期策略另行执行。对法律或隐私要求必须即时清除的数据,则不能用软删除逃避物理清理责任。
任务依赖用门控和条件抢占表达
后台任务只有一个前置依赖时,可以把“发现候选任务”与“判断能否执行”拆开。子任务通过 dependent_id 指向前置任务;定时扫描只找候选集,执行入口再检查前置状态。
图 2:依赖任务从周期发现到条件抢占的状态路径
flowchart TD
A[周期扫描子任务] --> B[读取 dependent_id]
B --> C{前置状态}
C -->|FINISHED| D[条件更新子任务为 RUNNING]
C -->|FAIL| E[标记 PREV_FAIL]
C -->|其他状态| F[保留原状态]
D --> G{影响行数是否为 1}
G -->|是| H[当前执行器获得任务]
G -->|否| I[其他执行器已抢占]
F --> A
真正开始执行前,仍要使用条件更新解决多实例并发扫描:
1 | |
只有影响行数为 1 的执行器获得任务。候选筛选也可以下推到 SQL:
1 | |
SQL 下推适合等待任务很多、扫描成本明显的场景;内存门控则更容易表达“前置失败则终止子任务”等分支。无论如何筛选,最后的条件抢占都不能省。依赖发展成多层 DAG,或调度延迟需要进入毫秒级时,应升级为事件驱动的工作流编排,而不是继续向同一张任务表堆状态。
模式提炼:状态就是允许操作的索引
公式:允许迁移 = 当前状态 ∈ 前置集合 ∧ 版本匹配。
状态机适用于审批、订单、工单、发布、数据迁移和回调处理。只有两三个状态、无并发写入且不涉及外部副作用时,枚举字段加局部校验已足够;为了画状态机而制造大量中间状态,只会模糊责任。
5. 把“写库后发消息”改成可对账的外发箱
数据库事务提交后再调用消息队列,进程可能在两者之间崩溃:订单已创建,事件没有发出。先发消息再提交数据库也不正确:消费者可能看到一个最终回滚的订单。没有跨系统原子事务时,可靠的最小方案是把要发布的事件写进同一个数据库事务,再由独立发布器扫描、投递和标记。
1 | |
发布器至少要能重复投递、记录尝试次数、监控最旧待发事件,并能从 event_id 重放。消费者仍要幂等,因为“发布器收到 broker 确认前失效”会导致投递不确定;Outbox 解决的是权威写入与待发布事实的一致性,不会神奇地让外部邮件、HTTP 调用或支付副作用自动精确一次。
Kafka 的幂等生产者和事务能把 Kafka 内部的读、状态更新和写入组合为更强的语义。其精确一次保证仍有边界:Kafka Streams 文档把它限定为输入 offset、状态存储和输出 topic 的原子完成;外部系统副作用仍需自身的幂等协议。Kafka Streams Core Concepts对此作出限定。
模式提炼:把跨边界的不确定性存成事实
公式:权威变更事务 = 领域数据 + 待投递事件。
只要未来需要回答“这个事件到底有没有发过、能否重发、谁处理过”,就需要一个可查询记录。它可以是 outbox 表、事务日志或具备等价语义的存储,但不能只存在进程内内存队列。
6. 区分权威数据、派生数据和缓存
缓存、搜索索引、报表、读模型和物化视图都应该被视为可重建的派生数据。设计时先写清“哪份数据能裁决争议”:权限、余额、库存、撤销状态通常必须回到权威存储;展示排序、搜索分词和统计聚合则可以延迟并重建。
派生数据至少带两个信息:来源版本或事件位置,以及生成时间。读路径遇到版本落后时,可回源、拒绝或显示旧值,取决于契约。对于“撤销后不得继续访问”的安全边界,缓存命中不能压过权威撤销状态;对于非关键推荐列表,短暂旧值可能是可接受成本。
在规范化与反规范化之间标出责任
规范化模型通过减少重复写入降低一致性成本,代价是查询时可能需要更多关联。以读为主的报表、数仓和搜索模型可以适度反规范化,但每增加一个派生字段,就要同时回答三个问题:权威值在哪里,历史数据如何回填,口径变更后如何重建。
聚合查询也遵循同样的原则。查询原本使用 GROUP BY a, b, day,但最终图表只展示某个固定 a、b 组合在每天的统计值时,a 和 b 属于筛选条件,而不是报表维度:
1 | |
把固定维度从 GROUP BY 移到 WHERE,不只是 SQL 优化,也是让查询直接表达“对哪个对象,按什么维度统计”。
模式提炼:能重建的,才允许不是权威
公式:projection = rebuild(authoritative history, version)。
缓存失效策略不是“设置一个 TTL”就结束。TTL 只限制旧数据最长存活时间,不能保证主动撤销立即生效;需要版本化 key、失效事件、回源校验或安全请求绕过缓存中的一种或多种机制。
7. 让业务拥有端口,适配器留在外围
支付、短信、对象存储和消息队列是技术实现;“收取款项”“通知收件人”“保存附件”是业务能力。若业务服务直接依赖某家 SDK 的请求对象、异常与回调,替换供应商会把改动扩散到规则层,单元测试也被迫启动真实客户端。
1 | |
端口应由业务一侧定义,只暴露业务需要的命令与结果。SDK 适配器负责序列化、鉴权、超时和供应商错误映射;领域层不应知道 HTTP 状态码或第三方 DTO。端口并非每个工具类都要一层接口:没有替换需求、没有测试隔离价值且只有一处调用时,直接依赖更简单。
适配也可以用来保护已稳定的公开类签名。若新回调要求旧类实现 ShutdownListener,直接修改 implements 会扩大对外类型关系。一个私有内部适配器就能把回调转发给原对象:
1 | |
内部类能访问外部对象,回调契约却不会成为外部类的公开身份。测试访问器应尽量保持包可见,避免为了测试又把内部适配器暴露成新 API。
模式提炼:依赖方向服从业务语言
公式:领域服务 → 业务端口 ← 基础设施适配器。
端口的价值来自隔离变化,而非接口数量。接口的方法粒度应贴近业务动作,不能把供应商 SDK 的几十个方法原样搬进系统。
8. 用闭包归一化不同签名的调用,再统一做增强
线程复用场景经常需要在一次处理前后清理 ThreadLocal 状态、设置诊断上下文、记录耗时或统一捕获指标。问题在于,入口函数的签名通常并不相同:MQ 消费可能接收消息列表和上下文,定时任务可能接收任务参数,普通服务调用还可能返回业务结果。若每种签名都复制一份“前置处理、执行、后置清理”,增强逻辑会随着调用点增长。
可以把具体调用先封装成一个无参闭包,再交给只认识一种函数形状的增强器。参数和返回值由闭包捕获,增强器只负责调用时机和边界行为:
1 | |
doConsumeMessage(List<Message> messages, ConsumeContext context) 的两个参数没有消失,它们被绑定在 lambda 的词法环境中。对 GsidUtil 而言,调用已经归一化为 Supplier<T>:执行器不必知道原始方法有几个参数,也不必为每个业务入口声明一个重载。
1 | |
有返回值的入口使用 Supplier<T>,没有返回值的入口使用 Runnable。二者都属于 Java 8 的函数式接口;lambda 表达式在调用上下文中依据目标函数式接口获得类型,表达式本身的求值不会自动执行 lambda 体,真正的业务调用发生在 get() 或 run() 时。这个区别保证了清理动作位于业务调用的两侧,而不是业务调用发生以后才被动补救。
1 | |
这里的闭包承担适配器职责。它把 List<Message> + ConsumeContext、JobContext 和 orderId + command 收进统一协议:调用一次,得到一个结果。增强器因此可以集中处理清理、计时、日志、指标、审计或限流,不需要理解业务参数。
返回值、异常与执行时机
Supplier<T> 的泛型返回值使增强器能够保持业务结果的静态类型。action.get() 抛出的运行时异常会沿调用栈继续向上传播,finally 中的清理仍然执行;这比在每个调用点分别写 try/finally 更容易保持一致。
受检异常是另一条边界。Supplier<T> 的 get() 没有声明 throws Exception,因此抛出受检异常的方法不能直接塞进该接口。常见处理方式有两种:在 lambda 内转换为领域异常,或者定义项目自己的 ThrowingSupplier<T, E extends Exception>,由增强器明确声明异常传播规则。不能为了适配接口而捕获后静默吞掉异常。
闭包只延后执行,不改变执行线程。若业务调用在异步任务中,增强器包住的是提交动作还是实际执行动作,语义完全不同:
1 | |
涉及 ThreadLocal、事务、租户标识或 RPC 上下文时,必须先明确上下文的所有权。闭包不会自动把提交线程的上下文搬到工作线程,也不会自动隔离第三方上下文;它只保证增强器包围的那一次同步调用遵守既定的前后置协议。
这组写法组合了哪些模式
这个模式不是单独套用某一个 GoF 名称,而是几种机制在同一个边界上的组合:
| 组成 | 在代码中的位置 | 解决的问题 |
|---|---|---|
| Adapter | () -> doSomething(args) |
把不同参数签名转换成统一函数形状 |
| Command / Thunk | lambda 对业务调用的延后执行 | 把“一次调用”变成可传递的值 |
| Decorator | callWithIsolatedGsid(action) |
在原调用前后附加横切行为 |
| Template Method | clear → action → clear |
固定执行骨架,只替换中间动作 |
因此,背后的通用模式可以概括为:先把变化归一化为可调用对象,再让稳定的边界协议承载增强逻辑。它适用于参数个数不同、返回值不同但生命周期相同的一组动作。只要增强器真正关心的是“何时开始、何时结束、异常如何离开”,就不需要把业务签名暴露给增强器。
模式提炼:把调用形状归一化,再装饰执行边界
公式:fᵢ: Aᵢ → Rᵢ → () → Rᵢ → decorate(action)。
| 场景 | 原始函数 | 归一化方式 | 可复用增强 |
|---|---|---|---|
| 消息消费 | consume(messages, context) |
() -> consume(messages, context) |
清理上下文、记录消费耗时 |
| 定时任务 | process(jobContext) |
() -> process(jobContext) |
诊断标识、失败指标、审计 |
| 服务调用 | handle(orderId, command) |
() -> handle(orderId, command) |
事务边界、日志、重试计数 |
| 无返回值回调 | notify(event) |
() -> notify(event) |
统一异常处理和资源释放 |
听到“多个入口都要执行同一段前后置逻辑”,应先检查它们是否只是函数签名不同。如果业务语义和生命周期相同,优先在调用点捕获参数,再由统一增强器承载协议。若增强器开始检查具体参数、分辨业务类型或处理不同的业务异常,说明归一化边界放得过早,应该退回业务端口或拆分更小的增强器。
使用边界
闭包适配并不等于万能包装。至少有四个边界需要保留:
- 不要提前执行:
callWithIsolatedGsid(doConsumeMessage(msgList, context))传入的是结果,不是动作,前置清理已经错过调用时机。 - 不要遮蔽语义差异:返回值、异常、重试和事务语义不同的动作,不应只因为都能写成
Supplier<T>就共用同一个增强器。 - 不要把异步提交当成异步执行:增强器必须放在真正执行任务的线程边界内,除非目标确实只是保护提交过程。
- 不要让清理范围失控:
ThreadLocal.remove()只清理当前工具拥有的状态;上游 RPC 上下文、事务和租户上下文应由各自的生命周期管理者负责。
9. 为异步系统设上限、确认点和重领路径
异步只能把等待移到队列,不能删除容量约束。每个消费者需要定义:何时确认、失败后如何重试、何时进入死信、积压到多大拒绝新工作、重复执行如何收敛。没有这些边界,队列会把短暂故障积累为延迟雪崩。
1 | |
该公式不能预测真实恢复时间,却足以暴露一个基本事实:消费速率不高于入队速率时,扩容前积压不会自行消失。监控至少应分开新消息、已投递未确认消息、失败重试数、最旧工作年龄和实际副作用失败率;仅看队列长度无法知道瓶颈是在投递、处理还是确认。
有些链路暂时没有独立的外部对账源,但这不等于只能等故障暴露。消费端可以按消息或业务键持久化处理结果,分开累计成功、失败和重复处理数量。耗时任务则把任务及状态全部持久化,定期扫描长时间未进入终态的悬垂任务,再按状态重试、告警或转人工。这些机制不能替代严格对账,但能把“无法观测的丢失”缩小为“可以定位的状态异常”。
模式提炼:先划确认边界,再谈削峰
公式:ACK 发生在可安全重试的最晚位置。
邮件、推送等外部副作用通常在“发送成功后、确认前”失效,因此要以 event_id + recipient 等业务键去重。耗时任务也需要租约;租约过短会把仍在执行的工作提前重领,租约无限长则会让失效工作永久卡住。
10. 用旁路和证据演进,不让一次切换赌上全部流量
稳定链路的替换适合拆成三个问题:新路径是否能得到相同或可接受的结果,何时让它接管流量,发现异常怎样回退。影子计算先比较新旧输出而不影响用户;双写保证新存储有足够历史;读切换必须能按版本回退;数据迁移则要有完成账本和校验,而不是只留一个开关。
开关适合控制路由、实验和降级,不适合替代数据状态机。某个迁移已经写入一半时,仅把开关关掉并不能让新旧数据自动一致。迁移任务应记录每个分片、批次或实体的版本与校验结果,重复执行也能安全收敛。
配置版本保持不变,机器绑定负责切换
机器级策略如果在配置记录上直接切换 inactive 和 active,就需要另一个定时任务在准确时刻改状态,多环境回退也很容易混淆。更稳定的模型是把“配置内容”和“哪台机器当前使用哪个版本”分开:
- 策略表按配置键和版本号保留不可变版本。
- 机器表保存机器、机房等稳定属性。
- 绑定表保存每台机器当前指向的配置版本。
- 业务服务可以缓存自己的绑定,管理界面则读数据库的权威指针。
图 3:不可变配置版本与机器激活指针的分层
flowchart LR
C[中控程序] -->|update pointer| B[机器与版本绑定]
M[机器注册信息] --> B
V1[配置版本 v1] --> B
V2[配置版本 v2] --> B
B -->|read own binding| S[业务机器]
S --> L[本地缓存]
B --> A[管理界面的权威状态]
机器可以周期上报注册信息,但根据注册信息计算全局拓扑时需要单写者,例如通过选主确保同一时刻只有一个节点修改拓扑。同样的 JSON 内容在不同环境中仍可保留独立版本身份,以便分开记录发布链路、操作历史和回退目标。
新旧模板先影子比对,再改正式路由
模板或策略切换时,可以用一个指针记录当前正式版本。运行时先按正式版本生成结果,再异步加载另一个版本作影子计算,仅记录差异。新模板未生效时,线上结果是它的比较基线;切换后,旧模板又可以变成回归基线。
对已稳定的插件链,也可先在旧插件前挂载一个优先级更高的新插件。新插件使用新存储,未命中或被关闭时回落旧插件。这种旁路扩展适合旧链路仍能作降级路径、新旧数据模型兼容的改造;若底层模型已不兼容或必须保证强一致,则需要专门的迁移协议。
模式提炼:切换是受控路由,不是一次发布
公式:observe → compare → small rollout → rollback or expand。
影子请求必须注意副作用:只镜像可安全读取的请求,或让影子路径使用隔离的写入目标。新旧结果不完全相同也未必是错误,但差异必须有分类、阈值和处理人,否则比较日志只会变成噪声。
11. 将可观测性视为运行时契约
日志并非“多打几行”。一次用户动作穿越网关、服务、数据库、消息和回调时,需要稳定的关联标识:请求 ID 用于一次传输,业务 ID 用于对象生命周期,幂等键用于同一意图,事件 ID 用于异步传播。它们不能相互替代,却应被一起记录。
| 证据 | 回答的问题 | 不应替代什么 |
|---|---|---|
| 结构化日志 | 该次动作经历了什么 | 业务事实存储 |
| 指标 | 哪类问题正在扩大 | 单次请求追踪 |
| Trace | 延迟和调用链在哪一跳 | 审计历史 |
| 审计事件 | 谁在何时改变了什么 | 高基数实时指标 |
| 对账任务 | 两边的事实是否一致 | 在线事务约束 |
对账是分布式系统里承认“会错过”的工程方式。支付记录、外部回执、outbox 投递和下游消费都可以按稳定业务键周期比对;差异应进入可重试、可审计的修复队列,而不是只写一条告警然后依赖人工回忆。
模式提炼:每条承诺都要留下可检索证据
公式:业务动作 → 业务 ID + 状态变化 + 时间 + 关联 ID。
可观测性设计应在接口和事件模型确定时完成。事故发生后再补日志,最缺的往往正是无法回放的旧请求上下文。
12. 把反复查找改成显式索引
一个对象列表需要按另一个 ID 列表排序时,直接在比较器中反复调用 indexOf 会把排序变成大量线性扫描。可以先把 ID 列表建成名次 Map,再定义兜底规则:已指定顺序的对象在前,未出现的对象按修改时间排序。
1 | |
若未配置 ID 的对象应与已配置对象混合按时间排序,就应直接表达那个规则,不要在比较器遇到任一未命中对象时临时切换比较维度。对不同对象对使用不同排序原则,容易破坏比较器的传递性。
同样,嵌套循环如果只是反复按同一个键查对象,可以先建一次 Map:
1 | |
这会增加一份索引的内存占用,却能把多次线性扫描改成一次建表和多次按键查找。当数据量很小、只查一次时,原始循环反而更直接;索引的收益来自查找次数,不来自 Map 这个容器本身。
13. 属于关系的状态,不要写回单个实体
某个基础资源被多个业务共享时,“可用”往往不是资源自身的绝对属性,而是业务与资源之间的关系。若直接在号码等资源上写一个全局降级标记,某一业务的故障会误伤其他业务。
一种最小拆分是:
- 用与业务无关的键保存资源身份。
- 用
degraded:{biz}之类的 Set 保存某个业务当前禁用的资源集合。 - 恢复时只删除该业务与资源的关系,不改动资源本体。
同样的思路适用于隐私号码复播。为每一对已接通的通话分配独立 AXB 绑定,不让多组通话共享同一条可变映射。绑定有效期根据实际通话状态续期,通话结束后再经过必要的保留窗口才释放。这里需要建模的也不是“号码是否可用”,而是“这组通话关系的绑定是否仍在有效窗口内”。
这类模型的共同公式是:relationState = f(leftIdentity, rightIdentity, context)。只要状态回答的是“A 对 B 如何”,就应先考虑关系实体、关系表或带业务维度的键,而不是继续向 A 或 B 上堆全局布尔字段。
14. 把环境差异收进参数和版本目录
同一份 Gradle 脚本需要在流水线中选择不同本地 JAR 时,不必复制两份构建文件。先建立候选集合,再由显式项目参数决定排除项:
1 | |
调用方可用 ../../gradlew clean build -x test -PexcludeHighJar=true 显式切换。参数名应表达业务选择,取值要做布尔解析;直接把字符串 "false" 当真值,会让构建产物与参数名相反。
本地需要维护多个 Go 版本时,也可以用“版本目录 + 当前 Shell 环境指针”的简单模型。下面的 Linux 函数集保留 install、switch、list 三个动作:
1 | |
自动补全不应在同一函数里混用 zsh 的 words 和 Bash 的 COMPREPLY。两种 Shell 应分别注册补全函数,switch 从 /opt/go-versions 读已安装版本,install 再从 https://go.dev/VERSION?m=text 取可用版本。脚本若用于生产机或共享开发机,还应在解压前校验官方校验和,并避免无限向 PATH 前部叠加历史 GOROOT/bin。
15. 让工具参数和知识召回都有明确契约
MCP 工具若用一个宽泛 map 或 object 承载全部参数,参数语义就被推迟到运行时。平台无法生成精确表单和校验,模型也只能根据描述猜测键名、值类型与组合约束。独立概念应拆成独立字段,为枚举、必填项、格式和取值范围提供明确 schema。
合法参数需要先查询才能确定时,提供专门的搜索工具,再让后续工具接收稳定 ID。多轮调用的价值在于“先发现,再执行”,不在于弥补单个工具的参数歧义。
知识库也是一种契约边界。典型 RAG 流程会根据当前输入检索知识库,取 topK 个片段加入本轮模型上下文。对话历史和召回片段的生命周期不同:用户与模型消息通常进入历史,召回片段只是本轮临时参考。上一轮命中的片段不会必然在下一轮继续可见。
图 4:必备上下文与按需召回资料进入模型的不同路径
flowchart LR
U[当前用户输入] --> Q[检索查询]
K[可检索知识库] --> R[关键词或向量召回]
Q --> R
R --> T[topK 片段]
H[对话历史] --> C[本轮上下文]
M[必备规则与依赖材料] --> C
T --> C
U --> C
C --> L[模型调用]
多份资料存在严格串行依赖时,例如阶段 5 必须同时读取阶段 2 的模板,单纯依赖 topK 会有缺项风险。必备资料能稳定放入窗口时,应整体注入;超过窗口时,则显式区分“必备上下文”和“可检索参考资料”,不让两者一起参与相似度竞争。
检索策略还需要区分三组概念:
- 关键词召回依赖精确文本匹配;向量召回用语义距离匹配不同措辞。
- 分段召回以 chunk 为单位,更省 token 且命中更精确;全文召回适合内容小且高度内聚的文档。
- 阈值方向取决于平台暴露的是“距离”还是“相似度”:距离通常越小越相关,相似度通常越大越相关。
分段大小、topK、检索方式、重排和阈值没有普遍最优的固定组合。调整时应使用带标注的真实问答集,同时观察召回率、准确率和最终答案质量,不能只根据字段名或 0—1 取值范围猜阈值方向。
16. 选择实现前的五个判断
模式不是清单式地全选。每遇到一个新需求,先用以下判断压缩设计空间:
- 哪条结果必须正确,哪条可以晚到或暂时旧?
- 哪份数据有最终裁决权,哪些都能从它重建?
- 同一意图会从哪些入口重试、重复或并发到达?
- 外部副作用发生在事务的哪一边,失败后能否查询和补偿?
- 积压、锁、重试和缓存各自的上限是什么,达到上限时谁被保护、谁被拒绝?
这五项能先排除许多无效方案。例如不允许重复扣款时,单纯增加队列并没有回答幂等;撤销后不得访问时,只增加 CDN 缓存也没有回答权威状态;需要随时回退的数据迁移,仅有功能开关也没有回答已写入数据的去向。
17. 模式速查表
| 需求关键词 | 首选模式 | 最小实现 | 常见误用 |
|---|---|---|---|
| 绝不能为负/重复 | 不变量前置 | 条件更新、唯一键、约束 | 只在 Controller 校验 |
| 重试不能重复执行 | 幂等命令 | 幂等键、结果记录 | 只依赖 HTTP 重试次数 |
| 多步骤有先后关系 | 显式状态机 | 条件状态迁移、版本 | 任意代码直接改字符串 |
| 删除后还要恢复 | 可逆删除 | 回收站、保留期、物理清理 | 软删除永不清理 |
| 写库后还要通知 | 外发箱 | 同事务事件记录、发布器 | 提交后立即 fire-and-forget |
| 缓存、索引可能错误 | 派生数据可修复 | 权威源、版本、重建 | 把缓存当授权依据 |
| 更换 SDK 或供应商 | 业务端口 | 业务接口、外围适配器 | 逐项镜像 SDK |
| 多种签名都需统一增强 | 闭包归一化 | Supplier、Runnable、自定义函数式接口 |
把异步提交误当实际执行 |
| 异步积压与重领 | 有界队列 | ACK 边界、租约、死信、背压 | 只监控队列长度 |
| 大改不敢全量切 | 旁路演进 | 影子比较、小流量、回退 | 一个开关覆盖数据迁移 |
| 故障无法解释 | 可观测契约 | 业务 ID、事件、指标、对账 | 把日志当审计库 |
| 反复按键查找 | 显式索引 | 名次 Map、对象 Map | 在比较器和嵌套循环中重复扫描 |
| 同一资源按业务独立降级 | 关系状态拆分 | 关系表、带业务维度的键 | 向资源写全局状态 |
| 机器配置需要灰度和回退 | 不可变版本与绑定 | 版本表、机器指针、单写者 | 靠定时任务切 active |
| 工具总在猜参数 | 正交参数契约 | 独立字段、schema、搜索后传 ID | 把所有输入塞进 Map |
相关的细分推导可继续阅读:设计模式 00:可工作的报价为何难修改、设计模式 01:订单身份和金额值为什么不能混为一谈和系统设计 10:消息与异步处理——确认、重领和背压。本篇提供选择模式的总览;细文分别验证对象建模、数据约束与消息消费中的具体边界。
参考资料
- PostgreSQL 18:Transaction Isolation:事务隔离、并发异常与序列化失败后的重试语义。
- AWS Well-Architected:Control and limit retry calls:重试应以幂等变更操作为前提。
- Apache Kafka:Streams Core Concepts:Kafka 内部精确一次处理语义的边界。
- Java SE 8
Supplier<T>Javadoc:无参结果供应函数的接口契约。 - JLS 8 §15.27 Lambda Expressions:lambda 的目标类型、求值与执行时机。

