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

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

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

先画出约束落点

图 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
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) 仍有价值:它保护所有写入路径,但不能代替条件更新返回“本次是否成功”的业务结果。

用伴随字段暴露异常值

单看一个字段,往往无法分辨“合法的边界值”和“被异常写入的值”。假设 ownerId 原本为 1,后来变成 0;若正常流程在主动清空 ownerId 时一定同时清空 ownerName,那么“ownerId = 0 但 ownerName 仍有值”就是可疑状态。

1
2
3
if (ownerId == 0 && StringUtils.isNotBlank(ownerName)) {
ownerId = 1;
}

这种修复依赖稳定的业务不变式,不能只凭“这两个字段看起来相关”就批量回写。更稳妥的步骤是先统计命中量,抽样排除合法反例,再进行可回滚的分批修复。

非事务多键写入要先定义污染边界

多个 Redis 命令不在同一事务中执行时,中途失败会留下部分写入。写入顺序不存在对所有数据结构都正确的固定答案,至少要守住三条底线:

  • 先标出权威键、索引键和派生键,按“中途失败后仍可修复”安排顺序。
  • 新增一类写入时,同步补齐删除、回滚和对账路径。
  • 使用“数据已污染”标记阻断读写时,TTL 必须覆盖最长修复周期;无法估算修复时间时,由修复流程显式删除标记。

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

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

场景 条件 权威位置
库存预留 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 保存每次调用的不可变输入。这样不需要工厂去制造带短命状态的执行器。反过来,如果锁对象本身有不可安全共享的会话状态,不能仅因“单例更省对象”而强行共享。

这个重构的识别信号很具体:一个 Spring 组件只因为保存锁键或超时时间,就被设成 prototype,调用方还需要通过 ApplicationContext 或工厂每次取新实例。问题不在容器作用域,而在调用状态放错了位置。可以先把键的生成规则收进命令对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public final class LockConfig {
public static final String LOCK_SUFFIX = "-lock";

private final int expireSecs;
private final String lockKey;

public LockConfig(int expireSecs, Object... keyParts) {
this.expireSecs = expireSecs;
this.lockKey = Joiner.on("-").join(keyParts) + LOCK_SUFFIX;
}

public int getExpireSecs() {
return expireSecs;
}

public String getLockKey() {
return lockKey;
}
}

如果底层客户端未指定 leaseTime 时会使用 watchdog 续期,上层命令就应明确区分“等待获锁时间”、“租期”和“依赖 watchdog”这几种语义,不要只保留一个含糊的 expireSecs。无论选哪种 API,锁键都必须稳定且唯一地标识被保护的业务资源。

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

公式:可复用组件 = 共享依赖 + 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,否则超时后无法判断“没发起”还是“已发起但未收到回执”。

用移动代替立即删除

对仍有误删恢复或审计价值的对象,“删除”可以先被建模为可逆状态迁移:移入回收站、归档区或隔离表,保留操作人、时间和恢复所需的原始标识。真正的物理清理由保留期策略另行执行。对法律或隐私要求必须即时清除的数据,则不能用软删除逃避物理清理责任。

任务依赖用门控和条件抢占表达

后台任务只有一个前置依赖时,可以把“发现候选任务”与“判断能否执行”拆开。子任务通过 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
2
3
4
UPDATE task
SET status = 'RUNNING'
WHERE id = :id
AND status IN ('INIT', 'RETRY');

只有影响行数为 1 的执行器获得任务。候选筛选也可以下推到 SQL:

1
2
3
4
5
SELECT child.*
FROM task child
JOIN task parent ON parent.id = child.dependent_id
WHERE child.status IN ('INIT', 'RETRY')
AND parent.status = 'FINISHED';

SQL 下推适合等待任务很多、扫描成本明显的场景;内存门控则更容易表达“前置失败则终止子任务”等分支。无论如何筛选,最后的条件抢占都不能省。依赖发展成多层 DAG,或调度延迟需要进入毫秒级时,应升级为事件驱动的工作流编排,而不是继续向同一张任务表堆状态。

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

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

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

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. 区分权威数据、派生数据和缓存

缓存、搜索索引、报表、读模型和物化视图都应该被视为可重建的派生数据。设计时先写清“哪份数据能裁决争议”:权限、余额、库存、撤销状态通常必须回到权威存储;展示排序、搜索分词和统计聚合则可以延迟并重建。

派生数据至少带两个信息:来源版本或事件位置,以及生成时间。读路径遇到版本落后时,可回源、拒绝或显示旧值,取决于契约。对于“撤销后不得继续访问”的安全边界,缓存命中不能压过权威撤销状态;对于非关键推荐列表,短暂旧值可能是可接受成本。

在规范化与反规范化之间标出责任

规范化模型通过减少重复写入降低一致性成本,代价是查询时可能需要更多关联。以读为主的报表、数仓和搜索模型可以适度反规范化,但每增加一个派生字段,就要同时回答三个问题:权威值在哪里,历史数据如何回填,口径变更后如何重建。

聚合查询也遵循同样的原则。查询原本使用 GROUP BY a, b, day,但最终图表只展示某个固定 a、b 组合在每天的统计值时,a 和 b 属于筛选条件,而不是报表维度:

1
2
3
4
5
SELECT day, count(*)
FROM events
WHERE a = :a
AND b = :b
GROUP BY day;

把固定维度从 GROUP BY 移到 WHERE,不只是 SQL 优化,也是让查询直接表达“对哪个对象,按什么维度统计”。

模式提炼:能重建的,才允许不是权威

公式: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。端口并非每个工具类都要一层接口:没有替换需求、没有测试隔离价值且只有一处调用时,直接依赖更简单。

适配也可以用来保护已稳定的公开类签名。若新回调要求旧类实现 ShutdownListener,直接修改 implements 会扩大对外类型关系。一个私有内部适配器就能把回调转发给原对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public final class DefResponseFutureManager {
private final ShutdownListener shutdownListener = new InternalShutdownListener();

public DefResponseFutureManager(ConfigManager configManager) {
configManager.registerShutdownListener(shutdownListener);
}

ShutdownListener shutdownListenerForTest() {
return shutdownListener;
}

private final class InternalShutdownListener implements ShutdownListener {
@Override
public void onShutdown() {
DefResponseFutureManager.this.stop();
}
}
}

内部类能访问外部对象,回调契约却不会成为外部类的公开身份。测试访问器应尽量保持包可见,避免为了测试又把内部适配器暴露成新 API。

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

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

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

8. 用闭包归一化不同签名的调用,再统一做增强

线程复用场景经常需要在一次处理前后清理 ThreadLocal 状态、设置诊断上下文、记录耗时或统一捕获指标。问题在于,入口函数的签名通常并不相同:MQ 消费可能接收消息列表和上下文,定时任务可能接收任务参数,普通服务调用还可能返回业务结果。若每种签名都复制一份“前置处理、执行、后置清理”,增强逻辑会随着调用点增长。

可以把具体调用先封装成一个无参闭包,再交给只认识一种函数形状的增强器。参数和返回值由闭包捕获,增强器只负责调用时机和边界行为:

1
return GsidUtil.callWithIsolatedGsid(() -> doConsumeMessage(msgList, context));

doConsumeMessage(List<Message> messages, ConsumeContext context) 的两个参数没有消失,它们被绑定在 lambda 的词法环境中。对 GsidUtil 而言,调用已经归一化为 Supplier<T>:执行器不必知道原始方法有几个参数,也不必为每个业务入口声明一个重载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.function.Supplier;

public final class GsidUtil {
private static final ThreadLocal<String> GEN_FALLBACK = new ThreadLocal<>();

private GsidUtil() {
}

/**
* 在线程复用的顶层入口执行一次处理。
*
* 执行前后清理本线程残留的 GEN fallback,不触碰其他上下文对象。
* action 的返回值与异常原样透出。
*/
public static <T> T callWithIsolatedGsid(Supplier<T> action) {
clear();
try {
return action.get();
} finally {
clear();
}
}

/** 适用于没有返回值的处理入口。 */
public static void callWithIsolatedGsid(Runnable action) {
clear();
try {
action.run();
} finally {
clear();
}
}

private static void clear() {
GEN_FALLBACK.remove();
}
}

有返回值的入口使用 Supplier<T>,没有返回值的入口使用 Runnable。二者都属于 Java 8 的函数式接口;lambda 表达式在调用上下文中依据目标函数式接口获得类型,表达式本身的求值不会自动执行 lambda 体,真正的业务调用发生在 get() 或 run() 时。这个区别保证了清理动作位于业务调用的两侧,而不是业务调用发生以后才被动补救。

1
2
3
4
5
6
7
// 不同签名的函数,都在调用点归一化为同一种“延后执行的动作”
return GsidUtil.callWithIsolatedGsid(() -> doConsumeMessage(msgList, context));

GsidUtil.callWithIsolatedGsid(() -> scheduler.process(jobContext));

OrderResult result = GsidUtil.callWithIsolatedGsid(
() -> orderService.handle(orderId, command));

这里的闭包承担适配器职责。它把 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
2
3
4
5
6
7
// 只包住 submit:清理发生在提交线程,不能覆盖 worker 的业务执行
GsidUtil.callWithIsolatedGsid(() -> executor.submit(
() -> doConsumeMessage(msgList, context)));

// 包住 worker 中真正执行的业务动作
executor.submit(() -> GsidUtil.callWithIsolatedGsid(
() -> doConsumeMessage(msgList, context)));

涉及 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
积压时长 = pending work / max(消费速率 - 入队速率, ε)

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

有些链路暂时没有独立的外部对账源,但这不等于只能等故障暴露。消费端可以按消息或业务键持久化处理结果,分开累计成功、失败和重复处理数量。耗时任务则把任务及状态全部持久化,定期扫描长时间未进入终态的悬垂任务,再按状态重试、告警或转人工。这些机制不能替代严格对账,但能把“无法观测的丢失”缩小为“可以定位的状态异常”。

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

公式: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
2
3
4
5
6
7
8
9
10
11
12
13
private List<TagGroup> reorder(List<Long> orderedIds, List<TagGroup> entities) {
Map<Long, Integer> rankById = new HashMap<>();
for (int i = 0; i < orderedIds.size(); i++) {
rankById.putIfAbsent(orderedIds.get(i), i);
}

Comparator<TagGroup> byConfiguredRank = Comparator.comparingInt(
group -> rankById.getOrDefault(group.getTagGroupId(), Integer.MAX_VALUE));
Comparator<TagGroup> byModifiedTime = Comparator.comparing(TagGroup::getLastModifiedTime);

entities.sort(byConfiguredRank.thenComparing(byModifiedTime));
return entities;
}

若未配置 ID 的对象应与已配置对象混合按时间排序,就应直接表达那个规则,不要在比较器遇到任一未命中对象时临时切换比较维度。对不同对象对使用不同排序原则,容易破坏比较器的传递性。

同样,嵌套循环如果只是反复按同一个键查对象,可以先建一次 Map:

1
2
3
4
5
6
7
8
9
Map<Integer, Student> studentById = students.stream()
.collect(Collectors.toMap(Student::id, Function.identity()));

for (int targetId : targetIds) {
Student student = studentById.get(targetId);
if (student != null) {
System.out.println(student.name());
}
}

这会增加一份索引的内存占用,却能把多次线性扫描改成一次建表和多次按键查找。当数据量很小、只查一次时,原始循环反而更直接;索引的收益来自查找次数,不来自 Map 这个容器本身。

13. 属于关系的状态,不要写回单个实体

某个基础资源被多个业务共享时,“可用”往往不是资源自身的绝对属性,而是业务与资源之间的关系。若直接在号码等资源上写一个全局降级标记,某一业务的故障会误伤其他业务。

一种最小拆分是:

  • 用与业务无关的键保存资源身份。
  • 用 degraded:{biz} 之类的 Set 保存某个业务当前禁用的资源集合。
  • 恢复时只删除该业务与资源的关系,不改动资源本体。

同样的思路适用于隐私号码复播。为每一对已接通的通话分配独立 AXB 绑定,不让多组通话共享同一条可变映射。绑定有效期根据实际通话状态续期,通话结束后再经过必要的保留窗口才释放。这里需要建模的也不是“号码是否可用”,而是“这组通话关系的绑定是否仍在有效窗口内”。

这类模型的共同公式是:relationState = f(leftIdentity, rightIdentity, context)。只要状态回答的是“A 对 B 如何”,就应先考虑关系实体、关系表或带业务维度的键,而不是继续向 A 或 B 上堆全局布尔字段。

14. 把环境差异收进参数和版本目录

同一份 Gradle 脚本需要在流水线中选择不同本地 JAR 时,不必复制两份构建文件。先建立候选集合,再由显式项目参数决定排除项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def jars = fileTree(dir: 'additionallib', include: ['*.jar'])
def excludeHighJar = providers.gradleProperty('excludeHighJar')
.map { it.toBoolean() }
.getOrElse(false)

if (excludeHighJar) {
jars = jars.minus(file('additionallib/a.jar'))
} else {
jars = jars.minus(file('additionallib/a.fat.jar'))
}

dependencies {
api files(jars)
}

调用方可用 ../../gradlew clean build -x test -PexcludeHighJar=true 显式切换。参数名应表达业务选择,取值要做布尔解析;直接把字符串 "false" 当真值,会让构建产物与参数名相反。

本地需要维护多个 Go 版本时,也可以用“版本目录 + 当前 Shell 环境指针”的简单模型。下面的 Linux 函数集保留 install、switch、list 三个动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
go_version_manage() {
case "$1" in
install) __go_install_version "$2" ;;
switch) __go_switch_version "$2" ;;
list) __go_list_versions ;;
*)
echo "Usage: go_version_manage [install|switch|list] <version>"
return 1
;;
esac
}

__go_install_version() {
local version="$1"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
echo "Invalid version format. Expected 1.22 or 1.22.0"
return 1
fi

local install_dir="/opt/go-versions/go${version}"
local tar_file="go${version}.linux-amd64.tar.gz"
local url="https://go.dev/dl/${tar_file}"

sudo mkdir -p "$install_dir" || return 1
if ! wget -qO- "$url" | sudo tar -xz -C "$install_dir" --strip-components 1; then
echo "Failed to install Go ${version}"
sudo rm -rf "$install_dir"
return 1
fi

echo "Installed Go ${version} in ${install_dir}"
if [ -n "$ZSH_VERSION" ]; then
rehash
elif [ -n "$BASH_VERSION" ]; then
hash -r
fi
}

__go_switch_version() {
local version="$1"
local go_root="/opt/go-versions/go${version}"

if [ ! -d "$go_root" ]; then
echo "Go ${version} is not installed"
__go_list_versions
return 1
fi

export GOROOT="$go_root"
export PATH="$GOROOT/bin:$PATH"
go version
}

__go_list_versions() {
if [ ! -d /opt/go-versions ]; then
echo "No Go versions installed"
return 0
fi
ls -1 /opt/go-versions | sed 's/^go//' | sort -V
}

自动补全不应在同一函数里混用 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. 选择实现前的五个判断

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

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

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

17. 模式速查表

需求关键词 首选模式 最小实现 常见误用
绝不能为负/重复 不变量前置 条件更新、唯一键、约束 只在 Controller 校验
重试不能重复执行 幂等命令 幂等键、结果记录 只依赖 HTTP 重试次数
多步骤有先后关系 显式状态机 条件状态迁移、版本 任意代码直接改字符串
删除后还要恢复 可逆删除 回收站、保留期、物理清理 软删除永不清理
写库后还要通知 外发箱 同事务事件记录、发布器 提交后立即 fire-and-forget
缓存、索引可能错误 派生数据可修复 权威源、版本、重建 把缓存当授权依据
更换 SDK 或供应商 业务端口 业务接口、外围适配器 逐项镜像 SDK
多种签名都需统一增强 闭包归一化 Supplier、Runnable、自定义函数式接口 把异步提交误当实际执行
异步积压与重领 有界队列 ACK 边界、租约、死信、背压 只监控队列长度
大改不敢全量切 旁路演进 影子比较、小流量、回退 一个开关覆盖数据迁移
故障无法解释 可观测契约 业务 ID、事件、指标、对账 把日志当审计库
反复按键查找 显式索引 名次 Map、对象 Map 在比较器和嵌套循环中重复扫描
同一资源按业务独立降级 关系状态拆分 关系表、带业务维度的键 向资源写全局状态
机器配置需要灰度和回退 不可变版本与绑定 版本表、机器指针、单写者 靠定时任务切 active
工具总在猜参数 正交参数契约 独立字段、schema、搜索后传 ID 把所有输入塞进 Map

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

参考资料