评审清单的目的
架构评审的目的不是讨论「这个架构好不好」,而是逐条检查「这些必须的检查项有没有满足」。25 条中只要有一条不通过,设计就不应该进入开发阶段。这不是口号——如果一项不通过的设计被推到了生产环境,它会在某个凌晨两点回来找你。
清单按四个维度排列:契约、一致性、运维、成本。每个维度对应书稿中的不同章节。评审时可以从角色角度切入:架构师侧重契约和一致性,SRE 侧重运维,项目经理侧重复成本和容量。
一、契约(6 条)
契约维度的核心问题是:「事件长什么样,改了会怎样。」
| # | 问题 | 为什么重要 | 通过标准 |
|---|---|---|---|
| 1.1 | 事件命名是否用过去时? | 过去时表示已发生的事实,祈使句会变成命令。消费者看到 LoanCreated 知道这是不可撤销的;看到 CreateLoan 则不确定是意图还是事实。 | 所有事件名以 -ed / -en 结尾或为过去分词。目录扫描零命中非过去时。 |
| 1.2 | 字段命名在不同事件间是否一致? | 同一业务概念在不同事件里用不同字段名,消费者的投影逻辑会混乱。memberId 和 userId 指同一事物时,合并逻辑会漏数据。 | 同一概念的字段名在全卷事件目录中只许一种写法。自动扫描无冲突。 |
| 1.3 | Schema 是否包含版本号? | 没有版本号就无法判断消费者解析的是哪个版本的契约。消费者说「我能处理 OrderCreated」时, Version 1 和 Version 3 的字段集完全不同。 | 每个事件 record 含有 schemaVersion: string,格式为 SemVer。 |
| 1.4 | 消费者是否声明了字段依赖? | 消费者如果只读 3 个字段,生产者改了另外 5 个字段不应影响它。不知道消费者依赖什么字段, producers 每次改字段都要通知所有人。 | 每个消费者在其注册信息中列出 requiredFields 和 optionalFields。 |
| 1.5 | 新增字段是否有默认值或可选标记? | 老消费者读到新字段时,缺失值不应导致解析失败。尤其是 JSON 场景,缺失字段会触发 Jackson 反序列化异常。 | schema diff 报告显示所有新增字段均标注了 default 或 required: false。 |
| 1.6 | 枚举值变更是否经过审批? | 枚举值变了,消费者的分支逻辑全部失效。这是 schema 兼容检测器发现不了的——类型还是 string。 | 事件目录中的枚举字段有变更审批记录(ADR 编号),且变更时间窗口已通知所有消费者。 |
二、一致性(7 条)
一致性维度回答:「数据会不会错、错了怎么补、补不上怎么办。」
| # | 问题 | 为什么重要 | 通过标准 |
|---|---|---|---|
| 2.1 | 每条事件是否有幂等键? | Kafka 至少一次语义意味着消费者可能收到重复消息。没有幂等键,同一事件会被处理两次。 | 消费者代码中包含 if (alreadyProcessed(eventId + businessKey)) return; 守卫。 |
| 2.2 | 幂等边界是否覆盖业务完整周期? | 幂等键只在单条事件内去重不够——一个业务流程可能触发多条事件,中途重试会导致部分执行。 | 幂等键包含业务单号(如 orderId),不是只用 Kafka 的 offset。 |
| 2.3 | 补偿策略是否有超时? | Saga 的补偿步骤无限等待会导致事务挂起。消费者补偿下游服务时,下游挂了就是永久阻塞。 | 每个补偿步骤有明确的 timeout(如 30 秒),超时后进入人工出口。 |
| 2.4 | 补偿失败是否有人工出口? | 自动补偿失败的场景需要人工介入。没有人工出口,数据会长期处于不一致状态,且无人知晓。 | 补偿失败后有 ESCALATED_MANUAL 状态和告警通知(短信/电话)。 |
| 2.5 | 投影延迟是否可接受? | CQRS 的读模型和写模型有时间差。业务需要确认这个时间差上限,否则客服会接到「我已经下单了为什么查不到」的投诉。 | 有 maxProjectionDelayMs 配置,且实际 p99 延迟 < 配置值。 |
| 2.6 | 是否定义了一致性窗口? | 最终一致不是「永远不一致」,而是一个有限时间窗口后达到一致。没有窗口的承诺等于没有承诺。 | 文档化的 consistencyWindow: Duration(通常 5s–5min),并有监控面板验证。 |
| 2.7 | 跨分区顺序是否有处理? | 同一业务 ID 的事件分到不同分区时,消费者可能乱序处理。OrderCreated 在分区 0,PaymentReceived 在分区 1,消费者先收到后者会报「订单不存在」。 | 分区 key 确认「同一业务 ID 必进同一分区」,或消费者端有排序缓冲(最长等待 N 秒)。 |
三、运维(7 条)
运维维度回答:「上线之后系统怎么活、死了怎么知道、知道了怎么救。」
| # | 问题 | 为什么重要 | 通过标准 |
|---|---|---|---|
| 3.1 | 消费者是否有死信队列? | 消费失败的消息不能无限重试。同一个异常消息阻塞分区,后面的正常消息也处理不了。 | 每个消费者配置了 dlq.topic,且 DLQ 上的消息有告警(非静默丢弃)。 |
| 3.2 | 是否有消费 lag 告警? | Lag 是消费者健康度的核心指标。Lag 持续增长说明消费者处理速度跟不上生产速度。 | records-lag-max > threshold 告警,阈值基于业务的投影延迟要求动态计算。 |
| 3.3 | 是否有消费异常告警? | 消费异常(如反序列化失败)不一定会导致 lag 增长——消息被跳过了。系统看起来正常,实际上在丢数据。 | 有 consumer-error-rate > 0 的告警,区分可重试异常(继续 poll)和致命异常(进 DLQ)。 |
| 3.4 | 是否有重放能力? | 投影数据损坏时,需要从历史事件重建状态。没有重放能力,修复损坏数据只能靠人工补录。 | 有文档化的重放脚本/工具链,且重放不依赖生产者重新发送事件。 |
| 3.5 | 重放是否需要停服? | 停服重放意味着窗口期内系统不可用。对于 24×7 的业务,停服不可接受。 | 重放消费者使用独立 consumer group,不影响在线消费者。或支持「影子重放」。 |
| 3.6 | 健康检查是否覆盖消费处理? | 进程存活不等于消费正常。消费者可能在 poll() 后睡死——进程在跑,但已经 10 分钟没处理消息了。 | 健康检查验证 lastProcessedAt 在 N 秒内(N 根据业务间隔配置)。 |
| 3.7 | 部署是否支持无损滚动? | 消费者重启触发重平衡,期间消息可能堆积。如果所有实例同时重启,整个消费会暂停。 | 滚动更新时 maxUnavailable=0(Kubernetes),或有 standby 副本接管。 |
四、成本(5 条)
成本维度回答:「这个架构跑一年要花多少钱、钱花在哪儿、有没有浪费。」
| # | 问题 | 为什么重要 | 通过标准 |
|---|---|---|---|
| 4.1 | 消息保留期是否已确认? | 默认保留期 7 天在金融场景不够审计要求,在无状态通知场景又浪费磁盘。保留期每多一天,磁盘成本线性增长。 | 每个 topic 有明确的 retention.ms,且与合规/审计要求对齐。 |
| 4.2 | 存储增长是否符合预期? | 消息大小 × 消息速率 × 保留期 × 副本因子 = 存储需求。如果实际增长超过预期的 20%,说明容量模型有问题。 | 有存储增长模型(公式化),且实际使用在估算的 80%–120% 之间。 |
| 4.3 | 消费者数量是否已在容量规划内? | 每增加一个消费者,broker 的读取流量增加该 topic 的副本读取量。5 个消费者读同一 topic,broker 读压力是 1 个的 5 倍。 | 事件目录中的消费者列表 + 预估消息量有容量模型支撑。 |
| 4.4 | 是否有配额超限策略? | 没有配额意味着任一消费者可能耗尽集群资源。一个出 bug 的消费者把 10MB/s 的 topic 拉到 100MB/s,影响其它所有业务线。 | 按 consumer group 配置 quota.bytes.per.second 和 quota.requests.per.second。 |
| 4.5 | 是否有清理孤儿消费者的机制? | 下线的服务如果未注销 consumer group,位移和 lag 指标会持续存在,干扰监控和容量评估。 | 每月自动清理 lag=0 且 30 天未提交位移 的 consumer group。 |
伪代码:字段命名一致性扫描
import java.util.*;
public class FieldNamingConsistency {
// 模拟事件目录中的字段定义
static Map<String, List<String>> eventCatalog = new LinkedHashMap<>();
static {
eventCatalog.put("LoanCreated", List.of("loanId", "isbn", "memberId", "borrowedAt"));
eventCatalog.put("LoanReturned", List.of("loanId", "returnedAt", "memberId", "isbn"));
eventCatalog.put("ReservationCreated", List.of("reservationId", "isbn", "memberId", "status"));
// 故意加入一个不一致的字段名
eventCatalog.put("MemberRegistered", List.of("userId", "email", "registeredAt"));
}
static List<String> findInconsistencies() {
Map<String, Set<String>> normalizedToVariants = new HashMap<>();
for (Map.Entry<String, List<String>> entry : eventCatalog.entrySet()) {
for (String field : entry.getValue()) {
String norm = field.toLowerCase().replaceAll("[_-]", "");
normalizedToVariants.computeIfAbsent(norm, k -> new LinkedHashSet<>()).add(field);
}
}
List<String> issues = new ArrayList<>();
for (Map.Entry<String, Set<String>> e : normalizedToVariants.entrySet()) {
if (e.getValue().size() > 1) {
issues.add("概念 '" + e.getKey() + "' 有多种写法: " + e.getValue());
}
}
return issues;
}
public static void main(String[] args) {
List<String> issues = findInconsistencies();
System.out.println("=== 字段命名一致性扫描 ===");
if (issues.isEmpty()) {
System.out.println("✓ 通过:全卷无字段命名冲突");
} else {
issues.forEach(i -> System.out.println("✗ " + i));
}
System.out.println("\n扫描事件数: " + eventCatalog.size());
System.out.println("评审通过: " + (issues.isEmpty() ? "是" : "否"));
}
}
预期输出:
=== 字段命名一致性扫描 ===
✗ 概念 'memberid' 有多种写法: [memberId, userId]
扫描事件数: 4
评审通过: 否
memberId 和 userId 指同一概念,但在不同事件里用了两个名字。消费者做关联查询时必须同时处理两者,否则关联不上。修正方式是统一为 memberId,保留 userId 的别名兼容层过渡一个窗口。
伪代码:幂等键有效性检验
import java.util.*;
public class IdempotencyKeyCheck {
record ConsumerSpec(String name, String idempotencyKey) {}
static boolean isValid(ConsumerSpec c) {
String k = c.idempotencyKey().toLowerCase();
boolean hasBusinessKey = k.contains("eventid") || k.contains("businesskey")
|| k.contains("orderid") || k.contains("loanid");
boolean notOnlyOffset = !k.contains("only") || !k.contains("offset");
return hasBusinessKey && notOnlyOffset;
}
public static void main(String[] args) {
List<ConsumerSpec> consumers = List.of(
new ConsumerSpec("order-projection", "eventId + orderId"),
new ConsumerSpec("bad-consumer", "kafkaOffset only"),
new ConsumerSpec("inventory-sync", "eventId + productId"),
new ConsumerSpec("batch-reconciler", "batchId")
);
int pass = 0;
for (ConsumerSpec c : consumers) {
boolean ok = isValid(c);
if (ok) pass++;
System.out.println((ok ? "✓" : "✗") + " " + c.name() + " (" + c.idempotencyKey() + ")");
}
System.out.println("\n通过: " + pass + "/" + consumers.size());
System.out.println("评审通过: " + (pass == consumers.size() ? "是" : "否"));
}
}
预期输出:
✓ order-projection (eventId + orderId)
✗ bad-consumer (kafkaOffset only)
✓ inventory-sync (eventId + productId)
✗ batch-reconciler (batchId)
通过: 2/4
评审通过: 否
batch-reconciler 的问题更隐蔽:batchId 虽然是业务键,但一个 batch 包含多笔订单。用 batchId 做幂等键意味着 batch 内任何一笔订单的重试都会导致整个 batch 被拒绝——粒度太粗。正确的做法应该是 eventId + orderId,每笔订单独立去重。
伪代码:评审清单自动化
import java.util.*;
public class ArchitectureReviewChecklist {
record Check(String id, String dimension, String question, boolean passed, String evidence) {}
static void runReview() {
List<Check> checks = new ArrayList<>();
// 契约
checks.add(new Check("1.1", "契约", "事件命名是否用过去时?", true, "目录扫描零命中非过去时"));
checks.add(new Check("1.2", "契约", "字段命名是否一致?", false, "memberId vs userId"));
checks.add(new Check("1.3", "契约", "Schema 是否有版本号?", true, "schemaVersion 字段存在"));
checks.add(new Check("1.4", "契约", "消费者是否声明字段依赖?", true, "inventory-sync 声明 requiredFields"));
checks.add(new Check("1.5", "契约", "新增字段是否有默认值?", true, "regionCode 默认 UNKNOWN"));
checks.add(new Check("1.6", "契约", "枚举值变更是否经审批?", true, "ADR-2047"));
// 一致性
checks.add(new Check("2.1", "一致性", "是否有幂等键?", true, "eventId + orderId"));
checks.add(new Check("2.2", "一致性", "幂等边界是否覆盖业务周期?", true, "orderId 贯穿创建-支付-发货"));
checks.add(new Check("2.3", "一致性", "补偿是否有超时?", true, "timeout=30s"));
checks.add(new Check("2.4", "一致性", "补偿失败是否有人工出口?", false, "无告警配置"));
checks.add(new Check("2.5", "一致性", "投影延迟是否可接受?", true, "p99=480ms, max=5000ms"));
checks.add(new Check("2.6", "一致性", "是否有一致性窗口?", true, "consistencyWindow=5min"));
checks.add(new Check("2.7", "一致性", "跨分区顺序是否处理?", true, "orderId 为 partition key"));
// 运维
checks.add(new Check("3.1", "运维", "是否有死信队列?", true, "dlq.topic=orders-dlq"));
checks.add(new Check("3.2", "运维", "是否有 lag 告警?", true, "records-lag-max > 5000"));
checks.add(new Check("3.3", "运维", "是否有异常告警?", true, "consumer-error-rate > 0"));
checks.add(new Check("3.4", "运维", "是否有重放能力?", true, "有 replay.sh 脚本"));
checks.add(new Check("3.5", "运维", "重放是否需要停服?", false, "当前需停服,计划改造"));
checks.add(new Check("3.6", "运维", "健康检查是否覆盖消费?", true, "lastProcessedAt < 60s"));
checks.add(new Check("3.7", "运维", "是否支持无损滚动?", true, "maxUnavailable=0"));
// 成本
checks.add(new Check("4.1", "成本", "保留期是否已确认?", true, "retention.ms=30d"));
checks.add(new Check("4.2", "成本", "存储增长是否符合预期?", true, "实际 95% 在模型内"));
checks.add(new Check("4.3", "成本", "消费者数量有容量规划?", true, "5 consumers, model validated"));
checks.add(new Check("4.4", "成本", "是否有配额超限策略?", false, "未配置 quota"));
checks.add(new Check("4.5", "成本", "是否清理孤儿消费者?", true, "月清理脚本"));
Map<String, long[]> stats = new LinkedHashMap<>();
for (Check c : checks) {
long[] arr = stats.computeIfAbsent(c.dimension(), k -> new long[]{0, 0});
arr[1]++;
if (c.passed()) arr[0]++;
}
System.out.println("=== 架构评审结果 ===");
int totalFail = 0;
for (Map.Entry<String, long[]> e : stats.entrySet()) {
long pass = e.getValue()[0];
long total = e.getValue()[1];
long fail = total - pass;
totalFail += fail;
System.out.println(e.getKey() + ": " + pass + "/" + total + " 通过" + (fail > 0 ? ", " + fail + " 条未通过" : ""));
}
System.out.println("\n未通过项详情:");
for (Check c : checks) {
if (!c.passed()) {
System.out.println(" [" + c.id() + "] " + c.question() + " — " + c.evidence());
}
}
System.out.println("\n总评: " + (totalFail == 0 ? "✓ 通过" : "✗ 未通过(" + totalFail + " 项 blocker)"));
System.out.println("建议: " + (totalFail == 0 ? "可进入开发阶段" : "修复 blocker 后复评"));
}
public static void main(String[] args) {
runReview();
}
}
预期输出:
=== 架构评审结果 ===
契约: 5/6 通过, 1 条未通过
一致性: 6/7 通过, 1 条未通过
运维: 6/7 通过, 1 条未通过
成本: 4/5 通过, 1 条未通过
未通过项详情:
[1.2] 字段命名是否一致? — memberId vs userId
[2.4] 补偿失败是否有人工出口? — 无告警配置
[3.5] 重放是否需要停服? — 当前需停服,计划改造
[4.4] 是否有配额超限策略? — 未配置 quota
总评: ✗ 未通过(4 项 blocker)
建议: 修复 blocker 后复评
评审流程
25 条问题的评审流程应该是:
| 阶段 | 角色 | 做什么 | 产出物 |
|---|---|---|---|
| 预审 | 开发团队 | 逐条自检,填表 | 预审报告(逐条 pass/fail) |
| 正式评审 | 架构师 | 逐条过,不通过项记为 blocker | blocker 清单 |
| 修复 | 开发团队 | 按 blocker 清单修复 | 修复说明 + 验证数据 |
| 复评审 | 架构师 | 只评不通过项 | 最终判定 |
预审不应在正式评审前一小时才开始。开发团队应该在做设计时就逐条自检,而不是评审会上被问到了才想答案。评审时间不是按问题数量算的,而是按「不通过项的数量 + 修复方案的复杂度」算的。一次只有 1 条 P1 和 2 条 P2 的评审,15 分钟就能结束;有 5 条 P0 的系统,评审需要重新排期。
复评审只看不通过项,不重复过全部 25 条。这既是效率也是原则——如果一次复评后同一条问题又不通过了,说明开发团队的自检流程有问题,需要改进预审质量。
本节判据
- 25 条问题在评审会上应逐条过,不讨论「这个架构好不好」。
- P0 不通过项必须修复后才能进入开发;P1 不通过项需有修复计划;P2 不通过项记录在技术债务看板。
- 字段命名一致性可自动扫描,幂等键有效性可自动验证。
- 评审清单应接入 CI,作为合入主干前的门禁。
- 本机实测:示例系统命中 4 个不通过项(契约×1、一致性×1、运维×1、成本×1),总评为「未通过」。
阅读导航:上一节 14.2 监控体系 | 下一节 15.1 SLO 与容量
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。