ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微服务架构设计模式实战盘点:从服务发现到Saga的落地经验

微服务架构设计模式实战盘点:从服务发现到Saga的落地经验

上周团队做技术复盘,有人突然问了一句:“微服务的设计模式,你用了几个?”会议室安静了几秒。大家脑子里冒出来的是单例、工厂、策略那一套,但明显他问的不是这个。他问的是服务发现、熔断、Saga、BFF这些微服务架构层的模式。问完之后我发现,很多团队其实已经用了其中好几个,只是不知道它们叫设计模式,也没有系统想过为什么这么用、什么时候不该用。

这篇文章就当作我自己的一份复盘笔记。我不打算把教科书里的模式定义搬运一遍,而是挑我真实落地过的模式来讲:它们解决什么问题,怎么用,坑在哪里。文章会按基础设施、容错、数据一致性、服务交互这几个维度展开。看完你可以对着自己的系统数一数,看看已经用了几个、缺了哪几个、哪些模式你当前根本不需要。

1. 先盘点:微服务层面到底有哪些设计模式

经典设计模式是面向对象时代的产物,解决的是类与对象之间的关系。微服务把单体拆成多个进程之后,问题域变了——你不再只是处理内存里的对象,而是处理网络调用、分布式数据、进程故障、团队协作。所以微服务有自己的一套模式,我习惯把它们分成四类。

第一类是基础设施类,包括服务发现、配置管理、API网关。任何一个微服务系统只要开始拆服务,就会碰到“服务地址在哪里”“配置怎么管理”“外部请求从哪里进来”这些问题。第二类是容错韧性类,包括超时、重试、熔断、舱壁隔离、限流。网络是不可靠的,服务是会变慢的,没有这些兜底,一个慢接口就能拖垮整条链路。第三类是数据一致性类,包括Saga、CQRS、事件驱动、事务性发件箱。微服务拆了数据库之后,原来单体里一个事务能搞定的事情,现在要跨进程协调,这是最头疼的部分。第四类是交互组合类,包括聚合器、BFF、Sidecar。一个业务功能往往要调用多个服务,怎么组合、怎么编排、怎么让不同端都满意,属于这一层的模式。

我把这些模式列成了一张表,方便对照。

分类模式解决的核心问题我的使用状态
基础设施服务发现服务实例动态变化,调用方如何找到目标地址已用
基础设施配置管理配置与代码解耦、环境差异问题已用
基础设施API网关外部流量统一入口、横切关注点收敛已用
容错韧性超时避免请求无限挂起占满线程已用
容错韧性重试处理网络抖动和瞬时故障已用
容错韧性熔断防止下游故障向上游持续蔓延已用
容错韧性舱壁隔离隔离依赖故障,防止单个依赖拖垮整个服务部分使用
容错韧性限流保护系统免受流量冲击已用
数据一致性Saga跨服务业务的长事务拆分与补偿已用
数据一致性CQRS读写场景差异巨大的模型分离部分使用
数据一致性事件驱动服务间异步解耦已用
数据一致性事务性发件箱保证本地事务与消息发布一致性已用
交互组合聚合器多个服务结果编排后返回已用
交互组合BFF不同前端端点的个性化适配用过之后又拆了
交互组合Sidecar通用能力下沉到基础设施层未用

我数了一下,这张表里我完整用过的大概十个。这个数字不算多,但每一个都是被生产环境逼着用上的,不是看哪个热门就上一个。

2. 服务调用链路的第一步:服务发现、配置管理与网关

2.1 服务发现:别再用静态IP调用别人了

拆微服务之后第一个遇到的问题就是:A服务调用B服务,B的地址写在哪?很多团队早期就是写配置文件,把B服务的IP和端口硬编码到A服务的配置里。服务数量少、变更不频繁的时候还凑合能用,一旦开始做弹性伸缩、故障替换、滚动发布,IP说变就变,配置文件根本跟不上。

服务发现的核心思路是:所有服务启动时把自己的地址注册到一个中心,调用方调用时不再直接拼IP,而是先问中心要一份可用地址列表。听起来非常直接,但落地时有个容易忽略的细节:调用方必须做客户端缓存。如果每次调用都去注册中心查一次,注册中心本身就变成了一个热点点,而且网络开销也上来了。正确做法是把拿到的手服务列表缓存在本地,同时订阅变更推送,增量更新本地缓存。

我早期实现时就是没处理“缓存过期后调用失败”的情况,导致注册中心短暂不可用时全线调用失败。后来加了一版同步逻辑:每次拿到新列表,保留旧地址,当新地址列表里某个实例连不上时,可以临时退回旧列表里的地址重试。这部分逻辑是教科书里写不出来的,只有在真实故障里才能体会到。

补充一句,容器编排平台自身的服务发现能力解决的是“编排平台内部服务互通”的问题。如果你的业务服务需要跨网络、跨集群调用,还是得有一层业务级注册中心来管理服务别名、版本号和标签路由。

2.2 配置管理:环境差异引爆的配置漂移

服务拆分之后,每个服务都有自己的一大堆配置:数据库连接、队列地址、功能开关、阈值参数。如果配置还跟着代码走,每个环境打一个包,发布就会变成一场灾难。配置管理的核心目标是:配置与代码分离,同时具备版本审计和动态变更能力。

配置中心引入之后,最容易发生的其实是“配置漂移”。有一次我们排查一个诡异问题:测试环境功能正常,生产环境同样版本却表现完全不一样。查到最后发现,生产环境的某个参数值被人工改过,但因为没有审计,根本不知道谁改的、为什么改。后来我们把所有配置变更统一收到配置中心,改动历史一查便知,才真正解决了这个问题。

经验是:配置中心建好后,第一件事不是开权限给所有人用,而是先把权限收窄。只有少数人能在生产环境改配置,改完必须走一条带备注的变更记录。这个过程不太酷,但能帮你省掉无数次“哪个环境配置不对”的深夜排查。

2.3 网关:入口收敛和横切关注点下沉

网关是外部流量进入系统的第一道门。很多团队一开始理解网关就是反向代理,把请求转发到后端服务,完了。其实网关的核心价值在于把多个服务共同需要的逻辑统一收进来:身份认证、参数校验、限流、灰度路由、协议转换、调用日志。

我见过一个网关被写成巨型服务的项目,所有业务逻辑都想往网关里塞,理由是“这样前端只调一个接口就行”。这是个明显的误区。网关一旦承载太多业务逻辑,就会变成一个性能瓶颈和发布炸药包——它一挂,所有业务一起挂。

合理的做法是“薄网关”:网关只处理与具体业务无关的横切逻辑,比如鉴权、路由、限流、埋点;涉及具体业务操作,一律下沉到后面的服务或编排层。判断标准很简单——如果某个逻辑换了另一个业务还要复用,就放网关;如果只服务于某一个业务,放在服务内部。

还有一个细节值得提醒:网关层必须做容量评估和限流。它处在流量入口,任何突发流量第一波都会打到它上面。就算下游服务能撑住,如果网关不设限,保护作用就等于零。

3. 容错三件套:超时、重试、熔断是怎么配合使用的

3.1 超时:最简单也最容易被忽略的保命机制

容错这件事里性价比最高的一步,就是给每个网络调用设置超时。可别小看这一行参数。真实案例:某个服务依赖一个第三方库存接口,这个接口平时响应几十毫秒,某天因为对方数据库慢查询,响应时间拖到了三十秒。我们的调用代码恰好没设超时,于是一个慢请求占住一个Tomcat线程长达三十秒,QPS一旦上来,几百个线程全部被占满,服务直接不可用。

设置超时有两个地方要分开设:连接超时和读取超时。连接超时解决的是“对方根本连不上”的问题,一般几百毫秒就够;读取超时解决的是“连上了但对方一直不返回”的问题,需要根据业务特征设定。很多人只设了连接超时,忽略了读取超时,慢响应照样能把你拖垮。

超时值的设定需要根据下游接口的响应分布来定。我的经验是先统计正常情况下的P99延迟,然后在这个基础上乘一个系数。比如P99是200毫秒,超时设在800毫秒到1秒就比较合理。设太短容易造成正常慢请求被误杀,设太长又起不到保护作用。

3.2 重试:不是次数越多越好

网络请求失败是常态,所以很多人都喜欢加重试。但重试有一个隐蔽的风暴效应:当下游服务出现瞬时故障时,上游成百上千个请求同时发起重试,会把这简陋的下游压垮,雪上加霜。

正确做法是给重试加上两个约束。第一个约束是重试次数,不要超过两次,多数情况下一次就够了。第二个约束是重试间隔,不能用固定间隔立即重试,而要做指数退避加抖动:先等100毫秒,不行再等200毫秒,再不行等400毫秒。抖动是故意加一个随机偏移,防止同一时刻所有请求一起重试,造成“惊群”。

比重试更需要前置考虑的是幂等。重试会带来重复请求,如果下游接口不是幂等的,一次重试就可能造成重复下单、重复扣款。线上教训比比皆是。所以凡是可能被重试的接口,都要在设计阶段考虑幂等:用请求号或业务单据号做去重,而不是把希望寄托在“应该不会重试”。

3.3 熔断:每个服务都要有“捏个刹车”的能力

熔断解决的问题是:下游已经故障了,继续调用不仅浪费时间,还会加剧故障。熔断器有三个状态:关闭、打开、半开。关闭状态正常放行,失败率达到阈值就触发打开;打开状态直接快速失败,不再发起实际调用;过一段时间进入半开状态,放少量探测请求,成功率达到要求才回到关闭。

熔断的关键参数有三个:最小请求数、失败率阈值、半开探测时间。最小请求数避免流量很小时误触发,失败率阈值决定敏感程度,半开探测时间决定恢复快慢。我给一组实测参考值:最小请求数设为10,失败率阈值设为50%,半开状态下放5个探测请求,窗口期一般设15秒。具体值要根据业务调整,系统管理后台这类低频但重要的接口可以更敏感,核心交易链路则要谨慎,避免一个误判挡住正常流量。

我踩过一个坑:熔断触发之后,下游服务其实已经恢复了,但因为熔断器还在打开状态,所有请求全部快速失败,用户看到的就是“服务不可用”。后来我在快速失败的响应里加入了熔断状态标识,监控系统实时推送告警,并且在半开状态多放了几个探测请求,恢复速度提升明显。熔断和超时是配套的——先有超时,熔断才能在超时这个维度上统计失败率;否则调用永远不结束,熔断永远数不出失败样本。

3.4 舱壁隔离:避免一个慢依赖拖垮整个服务

舱壁隔离的思想来自船舶设计:船体内部隔成多个仓室,一个仓漏水,只淹没那个仓,船不会沉。放到微服务里就是:一个服务往往要调用多个下游依赖,如果所有调用共用同一组线程池,那么一个下游变慢,占光了线程,所有正常依赖也一起被饿死。

线程池隔离是常见做法:每个重要的下游依赖分配一个独立的线程池,池大小根据该依赖的并发要求单独设置。如果线程池空间已满,请求就直接排队或者快速失败,不再占用核心线程。

线程池隔离的代价是线程开销和资源利用率下降。所以不是每个调用都需要隔离。我的建议是:高价值、高延迟、容易出故障的依赖单独隔离;低频且快速的小依赖可以直接共用默认线程池。

还有一种更轻的重量是信号量隔离:不单独建线程池,只是用信号量限制并发数,超出并发直接拒绝。它的开销小,适合时延要求非常高的缓存类调用。两者区别可以这样理解:线程池隔离管的是“慢”,信号量隔离管的是“多”,前者保护系统不被慢依赖拖垮,后者控制瞬时并发峰值。

4. 数据一致性是漫漫长路:Saga、CQRS与事务性发件箱

4.1 Saga模式:分布式事务的唯一现实解

讲到微服务数据一致性,很多人会先问:能不能直接用分布式事务,比如两阶段提交?我的回答是:除非你的系统规模极其小并且可以接受高昂的协调成本,否则不要。两阶段提交需要所有参与方全程持有数据库锁,微服务环境下网络延迟和故障会把这个方案拖到几乎不可用。

现实可落地的方案是Saga模式。Saga的核心思想是把一个大事务拆成一系列本地事务,每个本地事务在执行后都记录一个补偿操作。如果后面某一步失败了,就向前执行补偿,把前面已经完成的操作回滚回去。

一个订单支付的流程可能长这样:创建订单、扣减库存、扣减账户余额、增加积分。每一步都对应一个本地事务,同时注册对应的补偿动作:订单取消、库存回补、余额退回、积分撤销。如果扣减余额失败,就依次撤销前面两步。

我用过编排式的Saga实现:单独一个流程编排服务,负责按顺序调用各个参与者。编排方式的优点是流程清晰,状态集中管理,谁先谁后一目了然;缺点是编排服务本身需要用状态机保证流程可靠,而且流程编排服务的可用性会影响整个链路。

很多团队会尝试用消息队列把Saga里每一步异步串起来,协同式Saga。这种方式的优点是参与者之间不直接感知对方,依赖事件驱动;缺点是流程散落在各个事件处理器里,出问题时很难看清全局。我的看法是:业务链路复杂度比较高的场景优先用编排式,简单链路才用协同式。

Saga落地过程最痛苦的一点是补偿逻辑往往比正向业务逻辑还要复杂。比如扣减库存之后补偿回补,但这时在下游仓库已经发货了怎么办?很多业务在这种边界上根本没有完美的回滚方案,只能做人工介入标记。所以设计Saga之前,第一步不是写代码,而是梳理业务上哪些步骤允许补偿、哪些步骤只能靠人工处理。

4.2 CQRS:读写分离模型并不是银弹

CQRS的全称是Command Query Responsibility Segregation,把命令写操作和查询读操作分离成不同的模型,甚至使用不同的数据库。微服务拆分后,一个常见问题是:服务对外提供数据给很多方查询,查询逻辑越来越复杂,时常有一个大查询拖垮核心写入库。

CQRS的价值在于:为写模型和读模型分别建模,读模型可以按查询场景做定制化存储和索引,甚至可以投影到只读数据库,避免读写互相干扰。

但我要给CQRS泼一盆冷水。CQRS最大的副作用是复杂度显著提升:原来一个Repository搞定的事情,现在要维护两个模型、两个存储、一套同步机制。很多人用了CQRS之后,最大的产出不是性能提升,而是数据不同步的告警邮件。

我的建议是:只有读写比例严重失衡、读侧需要为特定查询做专门优化时才考虑CQRS,比如报表统计、多维度检索这些场景。一般业务用不上完整的CQRS,可以退一步采用“读写分离”,让读流量指向只读副本,写流量保持原样,效果已经很明显,复杂度可控得多。

4.3 事务性发件箱:保证“数据库操作”和“发消息”的一致性

微服务里经常有这种操作:修改数据库里的订单状态,同时发一条消息告诉其他服务“订单已支付”。这里有个经典的双写问题——如果先改库再发消息,消息发送失败,下游永远不知道订单变了;如果先发消息再改库,下游收到消息去查数据发现还是旧状态,产生消费脏数据。

事务性发件箱模式解决这个问题。核心是做一步本地事务:业务数据和待发送消息在同一个本地事务里写入数据库。业务表更新一条订单状态,同时往outbox表插入一条消息记录。这步操作完全在本地完成,保证要么都成功,要么都失败。

之后由一个后台发布组件扫描outbox表,把状态为待发布的消息发给消息队列,发送成功后再把outbox记录标记为已发送,或者直接删除。整个流程不依赖分布式事务,就用本地事务加一个定时任务就能实现。

这里必须注意一个幂等细节:消息发布组件扫描到一条记录,发送给消息队列之后,消费者处理成功,但发布组件还没来得及更新outbox状态,此时发布组件再次扫描到同一条记录,就会重复发送。所以消息消费者必须做幂等处理,比如用事件ID做去重。我给outbox表通常设定这样一个结构:业务主键、事件ID、事件类型、消息体、状态、创建时间。消费者拿事件ID去重,就能接受消息队列“至少一次”的投递语义。

5. 服务编排与端侧适配:聚合器、BFF和Sidecar模式的取舍

5.1 聚合器模式:并行调用的正确姿势

一个页面需要展示多个服务的数据,到底是前端直接调多个后端接口,还是后端加一个聚合服务统一返回?早期我们的选择是前端自己协调,换来的是App页面加载一次要同时发起七八个请求,网络开销大不说,任何一个接口超时,整个页面就要等待很久。后来我们落了一个聚合层,针对页面场景把多个调用串起来,封装成一个面向场景的接口。

聚合器模式的关键点在于并发编排。多个下游调用如果能并行,就坚决不要串行。比如用户基本信息、订单列表、优惠券信息,彼此之间没有依赖,就应该一次性并行发起。但是并行不是无限并行,要考虑聚合服务自身线程池容量,以及下游服务的承受能力。

另外要格外重视超时预算。假设页面要求在800毫秒内返回,你的聚合服务最多只能等750毫秒。在这个总预算内,要合理分配给每一个下游调用:最重要的数据分配400毫秒,次要数据分配300毫秒,剩下50毫秒留作缓冲。如果某个下游调用超过了它自己的预算,即便其他服务还没返回,整个聚合请求也该切到降级路径,而不是无限等待。

我在这个模块里犯过一个错误:没有控制并行调用的总超时,导致聚合线程长时间被占用,最终聚合服务成为流量瓶颈。后来我给整个聚合流程加了一个统一截止时间,任何一个子请求的剩余时间都动态缩减,效果立竿见影。

5.2 BFF:为前端“量身定做”接口层

BFF的完整说法是Backend for Frontend,意思是为前端服务的后端。同一个后端数据能力,PC网页、H5、App、小程序的展示需求不一样,字段要求、交互逻辑、权限模型也不一样。如果让所有端共用同一个接口,接口就会变成一个“大杂烩”:各端要的字段互相叠加,接口越来越臃肿,谁都不敢动,最后哪个端都不满意。

我曾在一个项目里给Web端和App端各设计了一个BFF层,分别做字段裁剪、数据组装、端上特定逻辑适配。效果确实好——接口定义清爽了很多,前端的联调效率也上来了。但时间一长,问题也来了:BFF层本身开始膨胀,很多人把不属于BFF的通用逻辑也塞进去,变成了一个新的“过渡服务”。

用BFF的时机要克制。如果只有一种前端,完全不需要BFF;如果有两三种差异很明显的前端,可以考虑。BFF里的逻辑要严格控制:只做数据聚合、裁剪、适配,不要写复杂的业务规则,更不要直接访问数据库,业务规则应该留在领域服务里。

5.3 Sidecar模式:把通用能力下沉到平台侧

Sidecar模式本质上是在每个服务实例旁边部署一个伴生代理,流量先经过代理,由代理完成限流、认证、熔断、链路追踪、指标采集这些横切功能,业务进程只关注业务逻辑。这个模式最常见的形式就是服务网格,代理由平台统一管理和升级,业务代码几乎零改动就能获得全套流量治理能力。

这个模式理论上很美好,但我的建议是:中小团队不要一上来就上服务网格。原因有三个:一是运维成本增加,每个服务实例都多了一个代理进程,故障排查的范围扩大了一倍;二是代理转发会带来一定的额外延迟,低延迟场景要重新评估;三是网格各种流量配置项非常多,学习成本和时间成本都不小,团队没有专门的平台支撑很容易玩脱。

如果团队规模足够,或者云原生基础设施已经非常成熟,Sidecar模式确实可以把很多业务代码里的痛点消灭掉。达不到这个条件时,用代码或SDK方式把熔断限流这些功能做成一个统一的基础库,是性价比比较高的选择。判断标准就一条:你到底有没有能力运维这套额外的代理层。

6. 我踩过的反模式,以及一套务实的落地顺序

6.1 这些“做法”看起来没问题,实际上都在坑你

第一反模式是共享数据库。服务是拆了,数据库没有拆,所有服务继续访问同一张订单表、同一张用户表。结果就是,服务边界形同虚设,任何一个服务发布新版本都可能影响其他服务的查询,一个慢SQL就能拖垮所有依赖这张表的服务。数据库拆分是一件工程量大、风险高的事情,但如果不推进,服务拆分就是拆了个寂寞。

第二反模式是每个服务自己做一遍限流熔断。没有统一的限流组件或标准,每个服务各写各的:阈值随意、算法不同、参数靠猜。结果系统里到处是自我保护,但保护策略彼此不兼容,整体效果非常不均衡。正确做法是有一个统一的基础库或平台层来回填这些能力,业务代码只声明业务优先等级即可。

第三反模式是拆分过碎。一个只有几个接口的模块也要拆成独立服务,服务数量超过团队能维护的上限,部署、监控、联调成本全部上升,最终每个服务都在苟活。拆服务应该以业务独立性和团队组织结构为依据,而不是以技术洁癖为依据。

第四反模式是为了用Saga而用Saga。一个操作本质上就是在两个系统里各写一条数据,没有复杂的中间步骤,滥用Saga只会增加大量无用补偿代码。设计模式永远是为问题了生的,不是为了凑数量用的。

6.2 我的模式落地顺序与选择建议

有同事问我,既然有这么多模式,到底应该先做哪个?我的答案很简单:按照你系统当前最痛的地方来选,不要按清单从上往下做。

我把落地顺序整理成了一张表,每个模式对应它最适合介入的阶段。

阶段优先落地的模式原因
刚开始拆服务服务发现、配置管理、网关没有这些,服务根本无法协作和部署
有服务依赖告警超时、重试、熔断至少让故障不要瞬间蔓延
系统出现周期性崩溃限流、舱壁隔离防止瞬时流量和慢依赖拖垮核心链路
跨服务业务链条复杂Saga、事务性发件箱解决分布式数据写入的一致性问题
查询性能与数据耦合严重CQRS、读写分离让读模型按场景自由演进
多端同时接入BFF、聚合器对前端友好,减少不必要的接口膨胀
基础平台能力强且规模大Sidecar把通用能力下沉,减少业务代码侵入

如果你所在的系统刚起步,一上来就想着把CQRS和Saga全部铺上,那多半不是架构师的前瞻,而是给自己挖坑。模式的引入节奏要跟系统演进节奏对上号:先让系统稳定起来,再谈优雅。

我自己的体会是,模式列表放在那里不是让你全点亮,而是当系统出现对应症候时,你脑子里能有一个药箱。很多时候最合适的实践,不是选择一个最炫的模式,而是选择一个当前你能驾驭、能长期维护的方案。用过的模式越多,就越发现“为了不用而不用”和“为了用而用”同样危险。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进