微服务架构拆到一定程度,有两个问题一定会浮出水面:服务之间的依赖越来越密,任何一环抖动一下,整条链路上的流量都可能像多米诺骨牌一样连环崩溃;原本在单体应用里由本地事务一把梭搞定的数据一致性,拆成分布式的服务之后就再也没人给你兜底了。这两个问题,正是Spring Cloud微服务实践里绕不开的核心话题——微服务保护和分布式事务。
我最近刚把一个订单项目从单体改造到微服务,期间把保护组件和分布式事务方案从零搭起来、调参、踩坑、最后稳定跑上线。这篇不讲官方文档摘抄,把我自己的操作过程、参数计算逻辑、排查思路完整盘一遍。无论你是刚把服务拆完、正准备接保护组件的同学,还是已经在生产环境被分布式事务坑过好几回的开发者,这篇都值得花十几分钟看完。
1. 先别急着上组件,想清楚这两个东西到底在解决什么
1.1 微服务保护:兜住的是“故障扩散”这条链
微服务拆分之后,一个请求往往要经过网关、订单服务、库存服务、用户服务好几个节点。单体时代出了问题最多是应用整体不可用,重启就好;微服务时代最怕的是单个服务慢了一下,上游在等它的响应,全部线程被占住,然后排队请求越来越多,内存被打满,整条链路跟着雪崩。
我把这个现象比喻成一条路上的红绿灯:一个路口的信号灯坏了,如果不做任何干预,排队车队会一直回溯到上一个路口、上上个路口,最后整片城区瘫痪。微服务保护做的就是“不让故障扩散”这件事,具体手段通常是三板斧:限流、熔断、隔离。
- 限流:在入口控制流量速率,超出设定阈值的请求直接拒绝或排队,防止服务被瞬时流量冲垮。
- 熔断:当某个下游服务错误率达到阈值时,快速失败,不再继续发起真实调用,给下游喘息时间。
- 隔离:通过线程池或信号量把不同服务调用的资源隔开,避免一个下游拖垮整个应用的可用线程。
这三者配合起来,才能形成一套完整的保护网。单独只做限流,下游故障还是会穿透过来;单独只做熔断,流量洪峰时还是会被打满。我在项目里是把三者都接上,再根据每个接口的实际情况分别配置阈值,后面第三节详细说参数怎么定。
1.2 分布式事务:当本地事务不再管用
单体应用里,下单、扣库存、扣余额通常在一个数据库事务里完成,ACID由数据库保证。拆成微服务之后,订单是订单库、库存是库存库、余额是余额库,数据隔离在不同物理存储中,原来的本地事务无从谈起,三个服务之间的数据一致性就成了问题。
这里有个概念必须澄清:分布式事务并不是要让三个库像本地事务一样强一致,而是在性能和一致性之间做工程权衡。常见的落地方案有基于两阶段提交的强一致方案、基于补偿思想的TCC、基于事务消息的最终一致性,以及SAGA长事务模式。我见过不少团队一上来就追求“完美强一致”,结果发现跨服务同步事务把整体吞吐拖到了不能看的程度。先接受“分布式环境下不存在银弹”,再根据业务选择方案,这是做这块的前提。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 微服务保护三板斧:限流、熔断、隔离的落地细节
2.1 三个保护手段是怎么配合的
很多初学者把限流、熔断、隔离混着用,觉得反正都是“防挂”,随便接一个就行。实际生产里它们的职责完全不同,必须相互配合。
限流是事前防御,站在入口处挡住超过承载能力的流量;熔断是事后止损,发现下游已经不行了就立刻绕行;隔离是资源层面的物理阻断,保证一个下游的故障不会把整个应用的内存和线程池吃光。举个例子说明:A请求打到订单服务,订单服务要调库存服务。如果库存服务挂了,没有熔断的话,每次请求都要等到超时才返回错误,订单服务的线程全部阻塞,新请求不断积压。有了熔断,库存服务的失败次数一旦达标,后续请求在几秒内直接返回兜底结果,不再真正调用。有了隔离,假设调用库存的线程池只有20个线程,哪怕这20个全卡死,订单服务处理其他逻辑的基本线程池也不会受影响。
我之前在项目里先开了限流,结果下游一挂,照样把整个服务拖到报警。后来把熔断阈值设成错误比例50%、最小请求数10,再配合线程池隔离,效果立竿见影。所以别指望单点方案解决问题,三者协同才是正确姿势。
2.2 在Spring Cloud里接入Sentinel的实际操作
选择了Sentinel作为保护组件,原因很实际:它既有流量控制、熔断降级、系统保护,又提供了可视化控制台,规则可以动态推送,不用每次改配置都重新发布。接入过程我完整走了一遍,关键步骤记录如下。
第一步,在需要保护的服务里引入依赖。Gradle项目添加:
groovy复制implementation 'com.alibaba.cloud:spring-cloud-starter-alibaba-sentinel'
如果是Maven:
xml复制<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
第二步,配置控制台地址和应用名称。Sentinel客户端会通过心跳把自己注册到控制台,这样就能在界面上直接查看实时流量和配置规则:
yaml复制spring:
application:
name: order-service
cloud:
sentinel:
transport:
dashboard: 127.0.0.1:8858
eager: true
其中eager: true这个参数值得单独说一下。Sentinel的初始化是懒加载的,只有第一个请求进来之后,客户端才会向控制台注册。如果不开启eager,刚启动的服务在控制台里看不到节点,我刚开始就被这个现象误导过,以为依赖没引对。
第三步,在核心接口上定义保护点。用@SentinelResource注解标记资源,同时指定兜底方法:
java复制@SentinelResource(value = "createOrder", blockHandler = "createOrderBlockHandler")
public Order createOrder(OrderDTO dto) {
// 业务逻辑
return orderMapper.insert(dto);
}
public Order createOrderBlockHandler(OrderDTO dto, BlockException ex) {
// 触发限流或熔断后返回兜底
throw new RuntimeException("系统繁忙,请稍后重试");
}
value值就是规则里的资源名,控制台上配置规则时对应的是这个字符串,而不是方法名。曾经因为图省事直接把资源名写成方法名,后来重构方法名时规则全失效了,白排查了半天。
2.3 阈值参数不是拍脑袋定的:一个真实的计算案例
规则的核心是阈值,阈值定不好,保护就是个摆设。我用一个实际接口举例:订单服务的存量商品查询接口,日常峰值QPS大约是2000,线上部署了3台4核8G的实例,单机平均承载约700 QPS,单次请求平均RT(响应时间)是80毫秒。
限流阈值的计算逻辑:考虑系统容错余量,我通常按峰值QPS的1.3到1.5倍设定单机阈值,所以单机限流阈值设在900比较合理。这样既允许一定程度的突发流量通过,又不会让后端线程池在尖峰场景下被彻底打满。需要注意这里的QPS是经过网关转发到当前服务的实际QPS,不是客户端发起的总请求量,网关本身会过滤掉一部分无效请求和重放请求。
熔断阈值的设定更依赖响应时间和错误率。我会把慢调用比例阈值设为50%,最大RT设为300毫秒,最小请求数量设为10,熔断窗口5秒。含义是:在5秒内至少有10个请求进来,其中超过300毫秒的慢请求比例过半,就触发熔断,后续请求快速失败。这个参数的逻辑是,正常运行RT普遍在80毫秒,一旦下游数据库或外部接口开始出现问题,RT会瞬间飙升到几百毫秒甚至几秒,300毫秒这个卡点足以把“异常状态”和“正常波动”区分开。
提示:这里的阈值只是基准值,不是标准答案。每个接口的RT分布、业务容忍度都不一样,上线之后必须根据监控数据持续调整。我的习惯是先按估算值上线,运行一周之后看实际限流触发次数和RT曲线,再修正一次。
把熔断、限流、隔离参数配置融合到一个项目后,我在压测环境里模拟了库存服务宕机的场景:库存服务停止响应,订单服务调用库存的线程池全部阻塞在等待响应中。由于线程池隔离,订单服务主线程池仍然空闲,能继续处理非库存依赖的请求;5秒后熔断器打开,后续订单请求直接返回兜底结果,不再消耗线程资源。整个过程订单服务的CPU和内存保持稳定,没有出现雪崩。这是保护组件正确落地后最直观的效果。
3. 分布式事务:几种主流方案怎么选、怎么真正落地
3.1 方案全景对比:2PC、TCC、SAGA、消息事务
分布式事务方案五花八门,我先把主流的四个方案放在一起对比,大家根据业务特性对号入座:
| 方案 | 一致性类型 | 对吞吐的影响 | 业务侵入程度 | 典型适用场景 |
|---|---|---|---|---|
| 2PC两阶段提交 | 强一致 | 大,全程锁资源 | 较低 | 数据一致性要求极高、并发低的内部系统 |
| TCC补偿模式 | 最终一致 | 中等 | 高,需编写三个方法 | 跨服务操作、对中间状态敏感的业务 |
| SAGA长事务 | 最终一致 | 中等 | 中等 | 长链路业务、允许中间状态存在 |
| 消息事务(本地消息表/事务消息) | 最终一致 | 小,异步解耦 | 中等 | 可容忍延迟、不需要同步返回的业务 |
2PC是理论上最“正统”的方案,协调者向所有参与者分别发送预提交请求,全部成功后统一提交,任何一个失败则全部回滚。听起来完美,但实际工程落地时有一个致命问题:两个阶段之间所有资源都被锁定,一旦参与方网络抖动或响应缓慢,整个事务长时间挂起,吞吐直接崩盘。我见过一个支付对账系统用这种方案,业务低峰期还好,一到高峰期数据库锁等待曲线几乎贴顶。所以除非业务场景确实必须强一致且并发极低,我不推荐直接采用。
TCC把每个事务操作拆成Try、Confirm、Cancel三个阶段。Try阶段预留资源,比如冻结一笔余额;Confirm阶段真正扣减;Cancel阶段释放预留资源。这套方案能把锁的时间缩短到具体业务操作的瞬间,但是要求开发者针对每个分布式事务都编写三段式代码,业务侵入非常明显。SAGA类似TCC,但没有预留动作,是一种“正向操作加反向补偿”的思想。这两种方案是最容易被误解的:很多人以为用了组件就自动完成补偿,实际上补偿的每个步骤都得自己实现,组件只负责编排和调度。
3.2 Seata AT模式实战:从配置到注解
在Spring Cloud项目里做分布式事务,最省事的方案是引入Seata的AT模式。AT模式之所以受欢迎,是因为它对业务代码的侵入很小:底层通过代理数据源拦截SQL,在事务执行前后自动生成快照,提交前先写undo_log表,需要回滚时用快照数据反向补偿。
接入的第一步是配置事务分组。服务端启动TC(事务协调器)后,客户端通过注册中心找到它:
yaml复制seata:
enabled: true
application-id: order-service
tx-service-group: order-tx-group
registry:
type: nacos
server-addr: 127.0.0.1:8848
config:
type: nacos
server-addr: 127.0.0.1:8848
这里关键的地方是tx-service-group,它的命名必须和Seata服务端配置的映射一致,否则客户端找不到事务协调器,全局事务根本开启不了。我遇到过服务启动完全正常、但日志一直提示无法连接TC的情况,最后发现就是分组名对不上。
第二步,在每个参与事务的数据库里创建undo_log表。AT模式回滚依赖这张表,它的结构必须和Seata要求的一致。SQL脚本官方文档里有标准版本,直接执行即可,这里不重复贴。实际容易疏忽的是:多数据源场景下,每个数据源对应的库都要建表,漏掉一个,那个库的事务一旦需要回滚就会失败。
第三步,在事务发起方的业务方法上添加注解:
java复制@GlobalTransactional(name = "order-create-tx", rollbackFor = Exception.class)
public Order createOrder(OrderDTO dto) {
// 本地写订单表
orderDao.insert(dto);
// 远程调用库存服务,扣减库存
stockClient.decrease(dto.getSkuId(), dto.getCount());
// 远程调用账户服务,扣减余额
accountClient.decrease(dto.getUserId(), dto.getAmount());
// 如果后面抛异常,上面所有操作全部回滚
return dto;
}
@GlobalTransactional加在全局事务的入口方法上,不是加在每个被调用服务的方法上。子服务只需要接入Seata并配置好数据源代理,它们执行的SQL会自动被纳入全局事务。我在刚开始接入时理解反了,给库存服务的扣减方法也加了@GlobalTransactional,导致事务嵌套异常,排查了很久。
AT模式有一个非常值得注意的坑:它自动生成快照的前提是数据源必须被Seata代理。如果项目中混用了MyBatis的多数据源配置,或者自己手动创建了DataSource,代理可能没有生效,此时事务执行后不会写入undo_log,回滚时找不到数据。我集成时的做法是在配置类里明确使用Seata的DataSourceProxy包装业务数据源,确保代理链路完整。
3.3 什么业务该上TCC、什么情况直接用消息事务
Seata AT模式虽好,不是万能的。它适合那些“单次操作链路短、并发压力可控、业务可以短时间锁行”的同步场景,比如下单扣库存、订单加积分。但如果一个分布式事务跨了5个以上的服务,链路执行时间本身就长,AT模式全局锁持有时间会成倍增加,这时候我会考虑用SAGA或TCC。
举一个我实际改造过的例子:某个活动系统里有“创建订单、冻结优惠券、扣减会员积分、发送短信通知、写操作日志”这样一条链路。前三个步骤需要保证一致性,后两个步骤即使失败也不影响核心业务。最初整条链路都包在@GlobalTransactional里,一个短信服务超时,整个订单事务都要回滚,用户那边看到的是下单失败,体验很差。后来改造为:订单、优惠券、积分三个核心服务走Seata同步事务,短信通知和日志记录改成事务消息异步发送,彻底解耦。这个调整一上线,订单成功率明显提升,分布式事务的使用范围也收敛到了真正需要保护的核心链路上。
TCC则适合“每一步操作都必须同步确认结果”的场景。比如跨境支付的汇率锁定:先Try冻结汇率和金额,确认支付时再Convert完成资金划转,取消时Cancel释放冻结。这类业务对一致性敏感,又不能让资源被长事务锁住,TCC的业务侵入虽然高,但正确性最可控。
4. 避坑实录:生产环境最典型的几个问题
4.1 Sentinel规则“不生效”的三种常见情况
我见过不少同事接好了Sentinel,控制台上规则也配置了,但压测时流量该限的没限。排查下来发现原因基本是这三种。
第一种是懒加载导致服务没有上报心跳。服务刚启动还没有收到任何请求时,Sentinel客户端不会向控制台注册,控制台里看不到这个应用,自然配不了规则。解决方法是配置spring.cloud.sentinel.eager=true,让应用启动时就主动注册。
第二种是规则存到了控制台内存,但客户端在每次连接时没有拉到最新规则。控制台修改规则后会推送给已经连接的客户端,但如果客户端网络闪断重启,或者规则是直接写入本地文件而没有持久化到Nacos,重启后规则就丢了。生产环境的正确姿势是把规则持久化到配置中心,由配置中心统一推送。
第三种也是最隐蔽的:@SentinelResource注解没有配置blockHandler,导致触发了限流但抛出了BlockException未被捕获,给用户返回了一个500报错而不是兜底结果。实际上限流已经是生效的,只是表现不符合预期。所有标注了@SentinelResource的方法,一定要配上blockHandler和fallback,否则触发保护时用户体验极差。
4.2 分布式事务回滚失败的排查逻辑
Seata AT模式回滚失败,常见原因我按优先级列一下。
第一,undo_log表确实存在但格式不对,最典型的是缺少字段或字段类型不匹配。Seata回滚时要从这张表读取前置镜像,如果表结构错误,回滚SQL无法生成,服务端会一直报分支事务回滚失败。建议搭建初期就做一个“模拟异常回滚”的验证用例,故意在全局事务里抛一个异常,然后检查数据库的数据是否回到了事务开始前的状态。
第二,参与事务的数据库表没有主键。AT模式的回滚依赖主键定位数据行,没有主键的表在生成回滚语句时会直接失败。这个问题在早期设计表时就得注意,所有业务主表必须有清晰的主键。
第三,代理失效。如果项目里手动配置了多数据源,且没有用Seata的DataSourceProxy包装,那么被调用的SQL不会生成undo_log。排查时可以打开Seata的SQL日志,观察是否输出镜像记录语句,没有输出基本就是代理没生效。
第四,事务分组配置不一致,导致客户端根本没连接上TC。这个比较基础,但确实棱角很深。建议把tx-service-group在客户端和服务端统一配置,启动时看客户端日志里是否出现“connect to TC success”的提示。
在线上还遇到过一个高并发下的回滚异常:两个并发事务同时操作同一行库存数据,前一个事务回滚需要恢复快照,但后一个事务已经基于这个快照又做了修改,导致回滚冲突。Seata解决这个问题靠的是全局锁,但也意味着并发越高、事务越容易等待。我在压测时发现,当库存扣减接口QPS到500以上,分布式事务的整体RT会明显上升,这属于正常的工程权衡,不能指望既强一致又不加锁。
4.3 保护与事务叠加之后的性能开销控制
同时接了Sentinel和Seata之后,系统会多出两层开销:Sentinel本身会记录每个请求的指标数据,Seata的AT模式要为每条参与事务的SQL创建前后镜像。在低并发场景下这些开销可以忽略,但到了生产峰值,CPU和内存都会有可感知的上涨。
我在项目里压过一组对比数据:同一台机器上,不接两个组件时接口平均RT约80毫秒,全面接入后RT到了约105毫秒,上涨大约30%。这个涨幅对大多数业务可以接受,换来的是故障不再扩散、数据不再出现不一致,代价不算高。但如果你的系统本来RT就紧贴着SLA上限,就得注意优化了。
我常用的优化手段有三个:一是把Sentinel的资源监控粒度放宽,不针对每个方法做精细统计,而是合并到几个核心资源上;二是把分布式事务的粒度缩小,能用异步消息解耦的服务尽量从全局事务里拆出去;三是对那些确实只读的查询接口,完全不进入Seata代理范围,减小镜像创建的开销。
经验上还有一个容易被忽略的点:保护组件和分布式事务组件都要配置合理的日志级别。Seata在调试阶段会把每条分支事务的提交回滚日志完整打印,量非常大,线上一定要把日志级别调整到WARN或以上,否则光日志I/O就能拖慢服务。我在一个测试环境里因为开了DEBUG日志,压测时应用的吞吐直接掉了三分之一,关掉日志后恢复如初。
5. 最后分享两个我自己的实操习惯
这篇文章写到最后,分享两个我在实际项目中坚持的细节做法。
第一个,每个服务上线前必须做“故障演练”,不能只在测试环境验证一遍就完了。我每次大促前都会在预发布环境人为制造下游故障:把库存服务停掉、把数据库连接数调小、把某个接口RT人为拉高到5秒以上,然后观察保护组件是否按照预期触发。没有经历过真实故障的服务,保护配置就算写对了也没人敢打包票。
第二个,分布式事务的边界要定期复盘。业务迭代过程中经常出现“顺手把某个新调用也放进全局事务”的情况,时间一长,链条越来越长,性能越来越差,回滚的复杂度也在上升。我现在的习惯是每个月把线上所有@GlobalTransactional方法列出来,逐一确认每个分支是否真的必须同步一致,能改成异步消息的一律改掉。这个动作看起来不起眼,但确实帮我避开了好几次因为事务链条过长导致的线上故障。
微服务保护和分布式事务这两个话题,单拿出来都能写很多页,真正难的是在项目里组合起来用、并且用对地方。希望这篇基于个人实战的记录,能让你在动手之前少走几个弯路。
