很多团队把“上微服务”当成一次架构升级,评估时算的是机器成本和开发人力,上线后才发现真正的账单开在另一栏:故障开始以不可预期的方式扩散。单体时代一次数据库慢查询最多拖垮一个应用,拆成十几个服务之后,同一个慢查询可能沿着调用链把网关、订单、库存、支付一起拖进超时,最后演变成一次全站不可用。
这篇不谈“要不要拆”,而是谈一个更具体的问题:当服务数量增加时,你靠什么控制一次故障能波及多远? 我把它叫作故障半径。下面先拆解故障半径被放大的机制,再讨论拆分边界该怎么划、哪些能力必须统一收口,最后给一条可以分阶段执行的路径。
需要说明信息边界:本轮没有提供可引用的站内原帖,因此下文不复述任何具体帖子,也不虚构社区案例;分析对象是本次主题标签所覆盖的常见工程问题,例子均为通用机制示例。
先给结论
服务拆分的边界不该按业务名词划,而该按故障域划;组件选型的自由度也不该按团队喜好给,而该按故障传播路径收。
这句话成立的前提是:系统已经存在跨服务的同步调用,并且故障成本高于迭代成本。如果系统只有两三个人维护、日均请求量不高、也没有跨团队协作,那么拆分带来的收益往往抵不过运维复杂度,此时“不拆”是更理性的选择。
核心判断:微服务的成本大头不是多出来的几次网络调用,而是你把一次局部故障变成全局故障的可能性提高了。
判断一次拆分是否值得,可以问三个问题:这条调用链断了,用户看到什么?这个服务挂了,谁会跟着挂?这次故障,你能在几分钟内定位到具体节点?三个问题里有两个答不上来,说明拆分已经超前于治理能力。
故障半径是怎么被放大的
先看一条很普通的链路:网关 → 订单服务 → 库存服务 → 数据库。库存服务因为一条没走索引的查询变慢,P99 从 20ms 涨到 800ms。这时候真正发生的事通常不是“库存服务变慢”,而是一连串连锁反应。
订单服务调用库存的线程被占住,线程池逐渐打满,于是订单服务对网关也不再响应;网关的连接被挂住,接着其他完全无关的接口(比如查用户资料)也开始超时,因为它们共享同一个 Tomcat 线程池或同一个连接池。故障不是沿着业务关系传播的,而是沿着共享资源传播的。 这是拆分时最容易忽略的一点:业务上解耦了,资源上仍然耦合。
三个放大器:重试、超时、连接池
第一是重试。库存变慢后调用方超时,框架自动重试两次,实际打到库存的请求量变成三倍。如果链路上每一层都配了重试,放大是指数级的:三层各重试一次,末端压力就是 8 倍。这就是常说的重试风暴,它的危险在于发生时监控上看到的是“流量上涨”,而不是“某处异常”。
第二是超时倒挂。上层超时 3 秒、中层 5 秒、底层 10 秒,看起来是“给下层留足时间”,实际上中层在上层早已放弃之后还在继续消耗资源,做的是无效功。合理的做法是超时预算逐层递减,而不是递增。
第三是资源池共享。一个服务里所有下游调用共用一个线程池时,慢下游会拖垮快下游。这也是熔断和舱壁隔离要一起用的原因:熔断负责“别再打了”,隔离负责“打挂了也只影响这一格”。
串联系统的可用性是乘法的:
单服务 99.9% 看起来不错,但十个强依赖串起来只剩约 99.0%,一年累计不可用接近 88 小时。这个数字说明:每增加一个强依赖,你都需要额外的冗余、降级或异步化把乘积拉回来,否则拆分就是在持续消耗可用性预算。
拆分边界:按故障域而不是按业务名词
最常见的拆分方式是照着组织架构或业务实体拆:用户服务、订单服务、商品服务、支付服务。这种拆法在团队分工上很自然,但它隐含了一个假设——业务边界等于故障边界,而这个假设经常不成立。订单和支付在业务上是两个概念,在故障上却高度耦合:支付不可用时,订单服务要么阻塞要么降级,二者必须一起设计降级策略。
更实用的判断维度有三个:变更频率、故障耦合度、数据一致性要求。变更频繁且互不影响的模块值得拆开,收益是持续的解耦;而强一致、同生共死的模块硬拆开,只会把本地事务变成分布式事务,把一次数据库提交变成一次需要补偿的跨服务流程。
| 拆分依据 | 收益 | 主要代价 | 适用场景 |
|---|---|---|---|
| 按业务实体 | 团队边界清晰 | 故障域被切碎,跨服务事务增多 | 业务相对独立、读写路径差异明显 |
| 按变更频率 | 发布互不阻塞 | 需要稳定的接口契约和版本管理 | 迭代节奏差异大的团队 |
| 按故障域 | 爆炸半径可控 | 需要先做依赖梳理和压测 | 有明确 SLA、强依赖较多的系统 |
下面这张图描述同一条链路上的故障传播路径。注意故障并不是从“出问题的服务”直接跳到用户,而是逐跳通过共享资源传递:
流程图
渲染中...
按故障域拆分的具体做法,是先画出依赖图,标注每条边是强依赖还是弱依赖、是否可降级、超时和重试预算分别是多少。凡是“不可降级的强依赖”聚集在一起的地方,就应该考虑合并或加一层隔离;凡是“可以异步化”的边,就应该改成消息驱动而不是同步调用。这一步做完,拆分方案往往和最初按业务名词画的那张图差别很大。
必须统一收口的四类能力
拆分给了团队自由度,但有些能力一旦各自为政,故障半径就会失控。以下四类建议在平台层统一,而不是让每个服务自己选型。
第一是入口与流量治理。API 网关负责统一鉴权、限流、灰度路由和协议转换。如果每个服务自己实现限流,规则口径不一致,压测时你会发现总入口放进来 10000 QPS,而各服务自己的阈值加起来只有 6000。统一收口之后,限流策略才有全局意义。
第二是可观测性。链路追踪(SkyWalking、Jaeger、Zipkin)、指标(Prometheus + Grafana)、日志(ELK)三件套的价值不在“有监控”,而在故障时能把半径画出来:哪个服务最先异常、哪条边开始超时、哪个实例的线程池先满。如果 trace id 没有贯穿全链路,排查就只能靠猜。
第三是限流熔断降级的语义。Sentinel、Resilience4j、Hystrix 都能做,但关键是全站统一“什么情况算熔断、熔断后返回什么、降级数据从哪来”。不同服务用不同语义,用户看到的就是一会儿空白页、一会儿旧数据、一会儿报错,体验比单纯不可用还糟。
第四是幂等与事务边界。跨服务写操作必须有幂等键,否则重试就是重复扣款;跨库事务要么用 Seata 这类方案,要么改成事件驱动的最终一致,但两种选择的运维成本和一致性窗口差别很大,不能在一个系统里混着来。
YAML
resilience4j:
timelimiter:
instances:
inventory:
timeoutDuration: 300ms
retry:
instances:
inventory:
maxAttempts: 2
waitDuration: 100ms
上面这段配置的重点不是语法,而是它必须和上游的超时预算对齐:上游给订单服务 1 秒,订单给库存 300 毫秒、重试 1 次,最坏情况仍然落在上游预算内。超时、重试、熔断三个参数是同一份预算的三种表达,分开配置就一定会打架。
分阶段落地路径
治理能力跟不上拆分速度,是故障半径失控的根本原因。比较稳的顺序是先把观测和预算建起来,再谈拆。
- 第一阶段:梳理依赖图,标注强/弱依赖、是否可降级,给出每个服务的可用性目标
- 第二阶段:统一超时、重试、熔断预算,接入链路追踪和核心指标看板
- 第三阶段:把可异步的边改成消息驱动,减少同步强依赖的数量
- 第四阶段:按故障域调整服务边界,合并强耦合模块,隔离共享资源池
- 第五阶段:用压测和故障注入验证爆炸半径,把结论写进发布流程
这套路径不适合所有团队。如果系统还在单体阶段、团队规模小于十人、也没有明确的 SLA 要求,那么前三个阶段的价值远大于后两个——先把可观测性和超时预算做好,单体的故障半径本来就比微服务小得多。
反过来,如果已经拆了二十个服务却没有任何链路追踪,那当务之急不是继续拆,而是补上第二阶段的功课。这时候新增服务的边际收益是负的,因为每多一个服务,你就多了一个无法定位的黑盒,也多了一条会传播故障的边。
总结与讨论
- 拆分的收益是组织效率,成本是可用性预算。 每增加一个同步强依赖,串联可用性就乘一次,必须用冗余、降级或异步化把乘积拉回来。
- 故障沿共享资源传播,不沿业务关系传播。 线程池、连接池、重试策略、超时预算这些“看不见的耦合”,才是爆炸半径的真正来源。
- 治理能力要先于拆分速度。 链路追踪、统一预算、幂等语义、限流口径这四件事没做好,拆得越多越难救。
最后一个问题留给大家讨论:在你的系统里,如果现在有一个核心服务完全不可用十分钟,你能准确说出哪些功能会受影响、哪些不会吗?如果答不上来,你觉得缺的是依赖图、链路追踪,还是压根没人定义过降级策略?