主题
微服务治理与架构决策
一句话:架构决策不是选「最先进」的组件,而是在可逆性、故障半径、团队成本之间做有边界的取舍;写清楚「什么信号出现时推翻它」,比结论本身更重要。
适用版本:Spring Boot 3.2.x / Spring Cloud 2023.0.x。与 01 的分工是:01 讲组件职责,本文只给可执行的决策表。
为什么需要
同样的争论会反复出现:同步还是异步、要不要拆服务、用 Nacos 还是 Apollo、上不上 Service Mesh。没有决策记录时,每次换一个人就重新吵一遍,最后往往按「上次谁声音大」决定。
决策表的作用不是给标准答案,而是把判断条件、默认选择、代价和反转信号写下来,让后来的人能复查。
核心机制
决策的四要素
| 要素 | 要回答的问题 |
|---|---|
| 可逆性 | 这个决定多久能撤回?撤回成本多大? |
| 故障半径 | 选错时,影响一个接口、一个服务,还是全站? |
| 团队成本 | 团队是否有人能维护它?值班时能不能排障? |
| 反转信号 | 出现什么指标/事件时,必须重新评估? |
优先做「可逆、故障半径小」的决定;不可逆或全站级的决定,先做小范围验证。
决策一:同步调用还是异步消息
| 判据 | 同步(Feign/Dubbo) | 异步(MQ) |
|---|---|---|
| 调用方是否需要立即拿到结果 | 是 | 否 |
| 下游失败是否必须让上游失败 | 是 | 否,可补偿 |
| 是否能接受最终一致 | 否 | 是 |
| 典型场景 | 查库存、扣款、鉴权 | 发短信、写日志、通知、削峰 |
默认:核心链路同步,非核心旁路异步。反转信号:同步链路 P99 随下游线性上升、下游故障导致上游大面积失败,说明该异步化或加熔断;异步链路出现「消息丢了/重复」且没有对账,说明补偿机制没建好。
反例:下单成功后用 MQ 通知「订单已创建」,用户立刻点支付却查不到订单。这是把「必须同步确认」做成了异步;正确做法是本地事务提交后再发消息,或让支付页容忍短暂延迟。
决策二:API 版本怎么管
| 方式 | 示例 | 优点 | 代价 |
|---|---|---|---|
| URL 版本 | /api/v1/order | 直观、网关好路由 | URL 膨胀 |
| Header 版本 | X-API-Version: v2 | URL 稳定 | 排查时容易漏看 |
| 参数版本 | ?version=v2 | 网关友好 | 污染业务参数 |
默认:对外 API 用 URL 版本;内部 RPC 用「新增字段兼容 + 不改变已有字段语义」,尽量不升版本。
兼容规则:
- 新增可选字段:兼容,直接上线。
- 改字段含义/类型:不兼容,新建版本。
- 删除字段:先标记废弃,观察调用量归零,再下线。
反转信号:旧版本调用量长期不降、或调用方无法升级,说明下线计划要考虑合同/外部依赖,不能只按技术判断。
决策三:拆不拆微服务
| 判据 | 倾向单体/模块化 | 倾向拆分 |
|---|---|---|
| 团队规模 | < 5 人 | 多个团队按业务域负责 |
| 业务边界 | 还在频繁变化 | 边界稳定,能独立演进 |
| 发布节奏 | 一起发版可接受 | 需要独立发布 |
| 扩容需求 | 整体扩容够用 | 某模块需要独立扩容 |
| 一致性 | 大量跨模块强一致 | 跨服务最终一致可接受 |
| 运维能力 | 手工部署、监控弱 | 有 CI/CD、可观测性 |
默认:边界不清时先模块化,不要拆进程。反转信号:一个模块的故障/发布/扩容已经阻塞其他模块,且团队能承担网络与分布式事务成本,再拆。
决策四:Spring Cloud 还是 Service Mesh
| 判据 | Spring Cloud(SDK) | Service Mesh(Sidecar) |
|---|---|---|
| 语言 | Java 为主 | 多语言 |
| 治理需求 | 注册、配置、限流、熔断 | 细粒度流量、mTLS、多集群 |
| 团队 | 熟悉 Spring | 有 K8s/平台团队 |
| 成本 | 框架升级要动所有服务 | Sidecar 资源与网络跳数 |
默认:Java/Spring 团队用 Spring Cloud;多语言、强平台能力再评估 Mesh。反转信号:治理逻辑在多个语言重复实现、框架升级无法推动,才考虑下沉。
决策五:自建还是买/用托管
| 判据 | 自建 | 托管/云服务 |
|---|---|---|
| 团队有中间件运维能力 | 是 | 否 |
| 数据合规要求 | 必须自控 | 可接受厂商边界 |
| 规模 | 大到摊薄成本 | 中小规模 |
| 故障责任 | 自己兜底 | 有 SLA,但受厂商影响 |
默认:注册/配置/网关这类基础设施,优先用成熟开源 + 托管;只有规模或合规逼到一定程度才自建。
实战场景
一、把“必须同步确认”做成了异步:支付页查不到订单
现象:订单服务改为“本地事务提交后发 MQ,由订单查询服务落库”。上线后客服收到投诉:用户下单成功,立刻点支付却提示“订单不存在”,约 3 分钟后自动恢复。按 MQ 监控看,消费延迟 P99 只有 200ms,看不出问题。
排查:这类问题不在中间件指标里,在业务时序里。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 谁依赖这条消息 | 列出“下单成功后 1 秒内会读订单”的所有入口(支付页、订单详情、客服) | 有同步读取依赖 → 该链路不能异步 |
| 实际延迟分布 | 消息发送时间戳与查询服务落库时间戳的差值分布,取 P99 与最大 | 出现过分钟级 → 有积压、重投或消费失败 |
| 失败是否可见 | 消费端异常是否只写日志、有无重试与死信 | 无对账即可能永久缺单 |
| 事务与发送顺序 | 发消息是在本地事务内还是 AFTER_COMMIT | 事务内发送 → 回滚后消息仍被消费,出现幽灵订单 |
| 用户侧兜底 | 支付页是否容忍“订单处理中” | 不容忍就必须同步确认 |
根因是把决策表里的“调用方是否需要立即拿到结果”回答错了:需要,但实现选了异步。
对症做法:
java
// 1. 写路径回到同步确认:订单主记录在下单请求内本地事务落库
@Transactional(rollbackFor = Exception.class)
public Order create(CreateReq req) {
Order o = orderRepo.save(req); // 同步可见
outbox.save(new OrderCreatedEvent(o.id()));// 同事务写本地消息表
return o;
}
// 通知、风控留痕、搜索索引继续异步,由 outbox 投递
// 2. 消息只在事务提交后发出,避免“回滚了但消息已消费”
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)text
3. 如果确实要异步(跨服务写),必须同时具备:
- 用户可感知的中间态(“订单处理中”,支付页轮询/查询兜底)
- 对账任务:下单流水 vs 订单表,缺单自动补或告警
- 死信与人工队列,消费失败不能只 log.warnADR 要补上代价那一栏:把“支付页在 3 分钟内查不到订单”写成可接受的代价并获得业务方签字,才是完整决策;没写过代价的决策,出事时没人认。
复盘要点:判据可以标准化——凡“同一次用户操作内的读写”不允许跨异步边界,异步只用于旁路(通知、统计、索引)。这条应成为评审检查项,配一个日级对账作为兜底信号。
二、版本混用与“别人家的选型”:升级半途停在最贵的位置
现象:一个团队照搬兄弟公司的技术栈,把注册中心从 Eureka 换成 Nacos、Gateway 换成 4.x,但 Boot 还停在 2.7、Seata 客户端是 1.4、Sentinel 是 1.7。灰度环境正常,生产首个发布批次里 3 个服务启动失败:NoSuchMethodError、bootstrap 不加载配置、Nacos 客户端连不上 9848。
排查:这类故障的特征是“启动期或第一次调用期暴露”,而不是运行期。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 兼容矩阵 | Boot ↔ Cloud ↔ SCA ↔ 中间件客户端的版本对应关系(以官方兼容表为准) | 任一项不在同一世代即为混用 |
| 实际依赖树 | mvn -q dependency:tree -Dincludes=org.springframework.cloud,com.alibaba.cloud | 出现 spring-cloud-starter-netflix-ribbon/hystrix → 迁移未完成 |
| 配置加载方式 | Spring Cloud 2020+ 默认不加载 bootstrap | 老写法继续用 bootstrap 会静默不生效 |
| 网络前置 | 9848/9849 是否放通 | 客户端 2.x 走 gRPC,与 1.x 假设不同 |
| 谁在维护 | 值班手册、告警接手人、故障复盘记录里有没有该组件 | 无 owner 的组件等于没有能力 |
按四要素复盘:这次决策的故障半径是“全站入口”,可逆性差(配置与客户端已改),团队成本被低估(没人排障过 Nacos 2.x)——三项都不满足,却直接做了。
对症做法:
text
1. 先把版本收进一个 BOM,禁止子模块自带中间件客户端版本
dependencyManagement 引入 Spring Cloud / SCA BOM,CI 里断言依赖树无 Netflix 旧件
2. 拆成可逆步骤:先升 Boot → 再换注册中心 → 再换网关 → 最后换治理组件
每步都有回滚方案与验证口径(启动成功 + 一条端到端用例)
3. 迁移期间双写:新旧注册中心并行注册,按调用方分批切流,随时能退回
4. 组件必须有 owner 与演练:没有演练过的容错方案不算高可用方案bash
# CI 门禁:依赖世代一致性检查(示例口径,按团队 BOM 实现)
mvn -q dependency:tree -Dincludes=io.github.openfeign | grep -E "feign-core:12\.|11\." && \
{ echo "feign 版本混用"; exit 1; }复盘要点:兄弟公司的选型可以借鉴结论,不能跳过他们的团队成本评估。ADR 里“团队成本”要具体到“谁能值班排这个组件的故障”,写不出人名就说明还没具备上线条件。反转信号也要写:例如“同类启动失败再次发生”即触发重新评估是否回退到原方案。
三、反转信号没接监控:决策过期了三年没人复查
现象:一条 2023 年的 ADR 写着“库存与订单用 Seata AT,反转信号是热点行锁冲突”,阈值 0.1%。三年里没人看过这个指标;大促当天锁冲突率实际到 12%,才在故障复盘里发现当初设计的方案早已不再适用,且回滚成本已经很高(6 个服务都依赖全局事务语义)。
排查:决策失效通常是静默的,需要专门检查。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 反转信号是否存在 | ADR 里有没有可度量的阈值与观察周期 | 只写“性能不好时重评”等于没有信号 |
| 信号是否接进监控 | 该指标在告警规则里能否搜到 | 搜不到 → 决策不会自动被复查 |
| 谁负责看 | 指标 owner 与值班职责是否对应 | 无人负责 → 指标有数也没人看 |
| 决策当前形态 | 依赖该决策的服务数、数据量、外部合同 | 依赖越广,撤回成本越高,越需要提前验证 |
| 最近一次演练 | 故障演练记录里是否覆盖这条决策的失败场景 | 没演练过 → 高可用假设只是纸面 |
对症做法:
md
# ADR-0014:热点 SKU 从 Seata AT 改为分桶库存 + 本地消息表
- 状态:已接受(2026-10),取代 ADR-0003
- 触发:AT 全局锁冲突率连续 7 天 > 0.1%(监控项 lock_conflict_ratio,实际值 12%)
- 决策:stock 按 bucket_no 拆 16 行,扣减随机选桶;跨库写改本地消息表 + 对账
- 代价:库存不再是单行强一致,需日级对账兜底;对账差异有人工处理流程
- 回滚:保留 AT 分支代码与 undo_log 配置,按 SKU 灰度切换,可 1 天内回退
- 复查:每季度看 lock_conflict_ratio、对账差异笔数、超卖投诉数yaml
# 反转信号必须是可执行告警(Prometheus 规则口径示例)
- alert: GlobalLockConflictHigh
expr: seata_lock_conflict_ratio{service="order-service"} > 0.001
for: 7d
labels: { action: "review-adr-0003" }ADR 的复查节奏与 owner 写在文档头部,季度架构评审只过“已被告警触发的 ADR”,不做全量翻阅。
复盘要点:这份专栏里所有决策类内容的共同纪律是——没有反转信号的决策等于没有决策,接不进监控的反转信号等于没写。把“新 ADR 必须附带告警规则 ID”作为评审通过条件,就能让决策在数据出现变化时自动回到桌面,而不是等故障逼出来。
最小上手
把决策写成 ADR,放进仓库,和代码一起评审:
md
# ADR-0001:订单与库存之间使用同步调用
- 状态:已接受(2026-10)
- 背景:下单必须立即知道库存是否足够;库存服务 P99 80ms。
- 决策:Feign 同步调用,readTimeout=500ms,关闭重试。
- 代价:库存服务故障会直接导致下单失败;需要 Sentinel 熔断 + 降级。
- 反转信号:库存不可用导致下单失败率连续一周 > 0.1%,或 P99 > 500ms。检查依赖是否混入旧组件:
bash
mvn -q dependency:tree -Dincludes=org.springframework.cloud,com.alibaba.cloud输出里如果还有 spring-cloud-starter-netflix-ribbon、spring-cloud-starter-netflix-hystrix,说明迁移没完成。
生产动作
- 每次架构评审留 ADR:背景、决策、代价、反转信号四段,不写「因为先进」。
- 依赖收敛:用
dependency:tree+ BOM 对齐版本;混用不同世代的 Spring Cloud 组件,问题往往在启动时才暴露。 - 故障演练验证决策:杀掉下游、断开注册中心、压满线程池,看降级/熔断/缓存是否符合预期。
- 把反转信号接进监控:ADR 里写的阈值要变成告警,否则永远没人复查。
- 定期复查:业务量、团队、合规变化后,旧决策可能不再成立;ADR 要能更新,不是一次性文档。
边界
- 决策表不是标准答案:同样的表,不同团队/合规/规模会得出不同结论;表的作用是让争论有共同语言。
- 不要为了「技术先进性」上 Mesh/Serverless:运维复杂度是真实成本,尤其是值班和故障定位。
- 不要用微服务解决组织问题:团队协作问题先改组织/流程,拆服务只会把问题变成网络问题。
- 不要过度设计:能用单体 + 模块化撑两年,就不要提前付分布式事务的成本。
讲清楚
架构决策的核心不是选型,而是把「可逆性、故障半径、团队成本、反转信号」写清楚。同步还是异步,看调用方是否需要立即结果、失败是否必须传导;API 版本优先 URL 版本,字段只加不改;拆不拆微服务,看团队、边界、发布节奏和运维能力,而不是看公司大小;Spring Cloud 和 Service Mesh 是 SDK 与 Sidecar 的取舍,Java 团队默认前者;基础设施优先成熟开源 + 托管。每个决定都写一份 ADR,把代价和「什么信号出现时推翻」写进去,并接到监控上。没有反转信号的决策,等于没有决策。