ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微服务架构稳定性实践:服务保护与分布式事务方案对比与选型

微服务架构稳定性实践:服务保护与分布式事务方案对比与选型 做微服务这几年我收到最多的技术问题其实翻来覆去就两类线上服务无缘无故被打垮然后数据账目对不上。前者是服务保护没做好后者是分布式事务没捋清。尤其当你把单体应用拆成十几个微服务之后这两个问题会像影子一样跟着你躲都躲不掉。这篇文章我想把这两块硬骨头放在一起讲原因也很简单它们都是微服务架构从能跑走向稳定必须跨过的坎。服务保护解决的是别让一个故障拖垮全局分布式事务解决的是跨服务的数据别因为故障变成烂账。我会把原理、选型、实操细节和踩坑经验都摊开来说适合正在维护微服务系统、或者刚拆完服务准备上生产的同学参考。如果你是架构师这篇文章也能给你一套现成的方案对比口径。1. 微服务拆完真正的麻烦才刚开始1.1 雪崩是怎么发生的很多人对微服务的理解停留在把大系统拆小块但真正上过生产的人都知道拆完以后最怕的不是某个服务挂了而是挂一个倒一片。举个最常见的订单链路前端请求进来先打到订单服务订单服务要调库存服务扣库存还要调账户服务扣余额。假设账户服务某一天因为慢 SQL 响应从 50ms 涨到了 5 秒订单服务里那些等待响应的线程就会全部堆积。线程池一旦被占满新的请求进不来订单服务也跟着雪崩。紧接着网关发现订单服务不可用开始疯狂重试流量又打回库存服务最后整条链路上的服务全部瘫痪。这就是经典的雪崩效应。它跟多米诺骨牌不一样骨牌倒完就完了服务雪崩会通过调用链不断向上传播即使故障源恢复了错误也未必能立刻收敛回来。我见过最夸张的一次一个底层服务的延迟抖动半小时内把网关后面的所有业务服务全部打挂整个系统直接进入假死状态。生活里怎么理解这件事就像早高峰地铁一个人在地铁口摔倒后面的人不知情还在往前挤最后整个出入口堵死谁也进不去出不来。微服务系统里的服务保护就是给每道地铁口装上闸机和限流护栏让一个人摔倒不至于导致全线瘫痪。1.2 本文要解决的两件事明确了雪崩的成因我们就能划清边界。微服务治理里最核心的两个防御工事需要重点建设一是服务保护包括限流、熔断、降级、隔离核心目标是单个故障局部化别让异常无限传播二是分布式事务核心目标是跨服务的数据操作要么一起成功要么一起失败或者退而求其次保证最终一致性。这两件事还经常是一起发生的。比如分布式事务里某个分支事务执行超时触发熔断降级人一降级数据就写入失败如果事务框架没有做好状态记录就会出现钱扣了库存没减的烂账。所以我的结论是做微服务的人不能只懂其中一样。服务保护是外科手术里的止血钳分布式事务是缝合线缺了哪样手术都做不透。2. 服务保护先保住自己不倒2.1 超时控制最便宜的第一道防线服务保护的第一道防线往往被忽视就是超时控制。很多系统挂了不是服务真的不可用只是响应变慢但调用方又永远在等于是好消息变成坏消息。超时要分成两层看连接超时和读取超时。连接超时解决目标服务到底通不通读取超时解决目标服务要多久才有响应。我见过不少团队图省事把两个超时时间设成一样比如都是 3 秒结果前提条件就错了——连接超时通常应该短1 秒左右就够读取超时看业务类型普通接口 23 秒批量接口可以放宽到 5 秒以上。这里有一个容易被忽略的细节超时时间要按链路的层级别设置不能全局一刀切。网关层超时要短一些因为用户等不起服务间调用的超时次之底层基础服务比如数据库调用的超时要在连接池里单独设。比如网关 2 秒Feign 调用 3 秒MyBatis 连接池超时 4 秒这样层层设防才能保证最外层先掐断不会让流量穿透到最底层。2.2 限流把流量挡在门外超时解决的是已进入系统的请求别拖死我们限流解决的是不合理的流量别进来。常见的限流算法主要有三种固定窗口、滑动窗口、令牌桶和漏桶。我用实际场景来说某商品秒杀活动瞬间涌入 10 万请求订单服务的处理能力只有 1 万 TPS。这时候如果不加限流服务必然被击穿。固定窗口计数器是最简单的实现但存在临界问题——窗口切换的一瞬间可能放进来两倍流量滑动窗口把时间段再切细分能缓解这个问题令牌桶算法允许一定程度的突发因为桶里积攒的令牌可以一次性消耗漏桶则强制匀速处理哪怕来了 10 万个请求我也按每秒 1 万个的速率往外排队。选择建议上常规接口限流用滑动窗口因为它实现简单、精度也够秒杀、促销这类有突发特征的入口用令牌桶允许瞬间冲一波但不会持续打满下游是很弱的依赖比如第三方 API时用漏桶强制限速确保对方不会被冲垮。实际落地用 Sentinel 最省心它把滑动窗口、令牌桶、漏桶都封装好了你只要在控制台里配置规则或者写一段声明式代码就行。2.3 熔断给系统装个跳闸开关限流是站在入口防御熔断是站在调用关系上防御。它的思想跟家用电路的保护开关一模一样电流异常就跳闸断电保护电器不会被烧毁调用下游连续失败就熔断开路后续请求快速失败不再继续消耗资源。熔断器的核心状态机有三个关闭Closed、打开Open、半开Half-Open。关闭状态下请求正常放行但会记录失败率和慢调用比例当失败率超过阈值比如 50%并且调用量达到最小触发数比如 10 次熔断器切换到打开状态后续请求直接拒绝快速返回兜底结果经过一段休眠时间后进入半开状态放一小部分试探流量看看下游恢复了没有恢复正常就切回关闭否则继续打开。配置这一块要把两个参数给够滑动窗口大小和失败率阈值。滑动窗口不能太小太小容易误判比如 5 秒内 5 次失败就熔断某个接口偶尔抖动一下就把主干链路切了属于误伤但也不能太大太大会让熔断反应迟钝系统已经被打到半死都不触发。按我的经验线上接口滑动窗口设 10 秒、失败率阈值 50%、最小调用数 20半开探测放行 1 个请求这套参数在一半以上业务场景里都合理。2.4 降级和隔离保住核心功能降级和隔离是一对组合拳。降级意味着当系统资源不足时主动牺牲一些非核心功能保证核心链路可用。最典型的就是电影票 App 在购票高峰期系统压力过大把评价列表这种非关键接口降级成暂时无法查看但下单支付这种核心功能必须正常服务。降级的关键是在降级逻辑里绝对不要再做耗时操作。我见过有人把兜底逻辑写成调另一个下游服务取缓存数据结果两个服务同时挂了降级逻辑自己也被卡死白白占用线程。真正的降级应该有本地预案比如直接返回默认值、从本地缓存取旧数据或者依赖已加载的静态配置。隔离则是个更底层的防御。把不同依赖关系放在不同的线程池或者信号量隔离舱里避免一个慢调用把整个容器的线程耗尽。线程池隔离的代价更大但隔离最彻底适合关键链路信号量隔离更轻量适合并发不高但需要限制并发数的场景。你可以把服务想象成船上不同的水密舱即使一个舱进水也不会导致整艘船沉没。2.5 工具选型Hystrix、Resilience4j、Sentinel 怎么选现在落到工具层面。Spring Cloud 生态里最出名的三件套Hystrix、Resilience4j、Sentinel。很多同学一上来就问哪个好我的看法是维度HystrixResilience4jSentinel维护状态已停止开发活跃维护活跃维护熔断支持状态机简单支持基于滑动窗口支持策略更丰富限流几乎不擅长不擅长最擅长内置多种算法隔离线程池/信号量线程池/信号量信号量为主线程池需自己做控制台无无有实时监控规则推送上手难度简单简单稍高Hystrix 当年是标杆但已经停更了新项目我不建议再引入。Resilience4j 是官方推荐的轻量替代品适合够用就好的团队库很小集成容易熔断和隔离的 API 写起来也顺手。Sentinel 的优势在限流和可视化监控尤其适合流量特征明显的业务比如电商大促、秒杀、突发流量治理。如果团队对运维监控要求高冲 Sentinel。我现在的默认选型是流量入口和业务性限流场景用 Sentinel服务间调用链上的熔断和线程池隔离用 Resilience4j。二者都能接 Spring Cloud也不冲突。关键在于别把熔断、限流的规则写死在代码里一定要用外部配置中心或者 Sentinel 控制台去管理这样调整策略不需要重新发版。3. 分布式事务数据一致性不能靠运气3.1 为什么要上分布式事务服务保护解决活不活的问题分布式事务解决对不对的问题。单体时代一笔订单从创建到扣库存再到扣款都在一个数据库的本地事务里一个 BEGIN/COMMIT 就能搞定 ACID。拆成微服务后订单库、库存库、账户库各自独立谁也没法再一次性控制三个库的事务。经典的订单-库存-账户模型——准备工作如下订单服务写入订单表调用库存服务写入库存扣减流水再调用账户服务写入扣款流水。如果账户扣款失败前面已扣的库存必须回滚否则账就平不了。关于一致性这里要想清楚一个概念最终一致性不等于脏数据。最终一致性允许中间状态存在片刻比如已扣款但库存还没扣但要求在没有故障干扰的情况下系统会通过重试、补偿等手段最终达到数据一致。强一致性则要求每个时刻都一致代价极高。微服务架构里的分布式事务本质上就是在这条光谱上找位置。3.2 强一致路线2PC 与 XA要聊分布式事务躲不开 2PC两阶段提交协议。它把事务拆成准备阶段和提交阶段协调者先问所有参与者准备好了吗所有人都说 OK 才开始提交只要有一个人说不行全员回滚。XA 是 X/Open 组织定义的基于 2PC 的规范很多数据库原生支持 XA 协议。优点是一致性最强实现也简单缺点是明显的准备阶段资源被锁住阻塞时间很长并发能力下降严重协调者一旦挂了参与者既等不到提交也等不到回滚事务卡死在中间状态。实际生产系统中纯 2PC 用得很少除非你是针对少数几个关键服务、低并发场景。我不建议把 XA 当成微服务分布式事务的默认选项。微服务架构强调独立性和弹性用 XA 会把各个库的资源强耦合在一起本质上是把单体事务拉长跨了服务系统的可用性和扩展能力都会被拖垮。3.3 最终一致路线TCCTCC 是目前业务上用得非常多的一种方案核心思想是把一个完整的事务拆成三个阶段Try预留资源、Confirm确认执行、Cancel取消补偿。以订单扣款为例Try 阶段不是真的扣款而是冻结一笔金额Confirm 阶段把冻结的金额扣除真正完成扣款Cancel 阶段把冻结的金额解冻。这样做的好处是业务控制力很强不一致窗口小坏处是对代码侵入极深——你需要为每个事务操作实现三段逻辑开发量翻倍。TCC 有两个著名的坑我非说不可空回滚和悬挂。先说空回滚。Try 请求因为网络超时丢了Cancel 请求到了但业务上根本没有可取消的资源这时 Cancel 不能报错必须返回成功这就是空回滚。处理办法是在事务记录表里做控制Cancel 发现没有 Try 记录就标记为已取消直接返回成功。再说悬挂。Try 请求因为网络阻塞延迟到达但此时 Cancel 已经执行完等 Try 终于到了资源被预留就会永远卡住形成事务悬挂。解决办法是在 Try 执行前先查事务记录发现已经有 Cancel 记录就直接拒绝执行。这就是 TCC 的复杂度所在框架只能帮你调度三个阶段但对于什么叫空回滚什么叫悬挂这些业务状态必须你自己设计和守护。3.4 Saga 和本地消息表Saga 模式的核心思路是把一个长事务拆成一系列子事务每个子事务有对应的补偿事务。正着执行失败就反着做补偿把已经提交的子事务一个个撤销。它跟 TCC 的区别在于不需要预留资源直接执行业务操作用补偿去找回损失所以实现更简单但隔离性更差——补偿执行前别人可能已经读到了中间状态的数据。Saga 有两种组织方式。编排式Choreography每个服务执行完本地事务后自己发事件触发下一个服务没有中心调度者协调式Orchestration有一个专门的 Saga 协调服务负责告诉每个参与者该干什么、出错了怎么补偿。我建议项目初期用协调式因为流程可视化强、好排查编排式事件链一旦长了出了问题非常难追踪。本地消息表是另一种务实的方案。核心就是先写业务再发消息在事务参与方本地建一张消息表业务操作和消息写入放在同一个本地事务里然后定时任务扫描消息表把未发送的消息投递到 MQ。下游消费成功后标记消息为已发送。这套方案的亮点是理解门槛低不依赖外部框架只要每张表多建一张消息表就能跑。缺点是业务代码和消息表耦合无法做到开箱即用而且如果定时任务没有及时扫描消息的时效性会受影响。我用它处理过一些物流状态同步、用户积分变更这类不追求实时性的场景效果意外地扎实。3.5 事务消息RocketMQ 的方案RocketMQ 提供事务消息算是把本地消息表的思路内化进了中间件。它的执行过程是这样的Producer 先发送一条半消息Half Message这条消息对 Consumer 不可见然后 Producer 执行本地事务本地事务执行成功就提交消息Consumer 才能看到执行失败就回滚消息消息被丢弃。这里最关键的机制是事务状态回查。如果 Producer 在本地事务执行过程中崩了RocketMQ 会反向回查生产者问你那个本地事务到底成没成功生产者通过本地事务表记录的状态回答broker 再决定提交或回滚。事务消息和本地消息表本质是异曲同工但事务消息把状态表和发送逻辑封装进消息中间件里代码侵入少很多。它是目前很多公司在支持 MQ 的前提下首选的最终一致性方案。唯一要注意的是它只保证Producer 本地事务和消息发送一致如果 Consumer 消费失败或者消息被重复投递你依然要在 Consumer 端做幂等处理。3.6 方案对比与选型把几种主流方案摆在一起看方案一致性类型代码侵入性能影响适用场景XA强一致中大资源锁定低并发、且业务和数据强耦合TCC最终一致大三段式开发中高并发、关键链路、业务可控Saga最终一致中中长流程、跨多个微服务业务本地消息表最终一致中较低对实时性要求不高的异步场景事务消息最终一致低较低已有 MQ且依赖清晰可幂等我给非强一致场景的选型口诀是高并发、钱相关、业务规则严格用 TCC流程长、无强隔离要求用 Saga有 MQ、可以异步、时效要求不高用事务消息。但要记住任何最终一致方案都离不开消息不丢、消费幂等这两条底线。消息不丢靠 MQ 的确认机制和本地状态表消费幂等靠业务主键去重比如每条消息带一个全局唯一 ID消费端先查后写。4. Seata 实战订单和库存的落地4.1 Seata 的整体组成聊完理论必须讲落地框架。目前国内用得最广泛的分布式事务开源框架是Seata。它由三个核心角色组成TC事务协调者负责全局事务的注册、提交和回滚决策TM事务管理器负责开启全局事务、发起全局提交或回滚RM资源管理器负责把本地事务纳入全局事务并执行分支提交/回滚。Seata 支持四种事务模式AT、TCC、Saga、XA。其中AT 模式是它的招牌核心卖点是对业务代码侵入极小几乎可以用注解一键开启分布式事务。4.2 AT 模式原理AT 模式是怎么做到侵入小的秘密在于一张undo_log 表和全局锁机制。执行流程是这样的全局事务开启后每个分支事务解析 SQL要先查一遍旧数据前镜像执行 SQL 操作再查一遍新数据后镜像把数据行锁住同时向前镜像和后镜像写入 undo_log 表然后提交本地事务。如果全局事务要提交各分支直接提交本地事务就行如果全局事务要回滚TC 通知每个分支RM 根据 undo_log 里的前镜像反向生成补偿 SQL把数据改回去然后删除 undo_log 记录。这套思路很像数据库的 UNDO 日志只是搬到了业务层。AT 模式的最大优点是业务方几乎无感写一个普通的业务方法加上GlobalTransactional注解就行了。但这也有代价全局锁会导致分布式并发场景的数据行长时间锁定性能有损耗多表操作时需要额外注意锁的粒度和顺序。4.3 核心配置与代码实操之前先把表结构准备好。AT 模式要求每个参与分布式事务的业务库都创建 undo_log 表建表脚本如下MySQL 8 为例CREATE TABLE undo_log ( id BIGINT NOT NULL AUTO_INCREMENT, branch_id BIGINT NOT NULL, xid VARCHAR(128) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINE InnoDB AUTO_INCREMENT 1 DEFAULT CHARSET utf8;然后在业务服务里引入 Seata 依赖配置 application.ymlspring: cloud: alibaba: seata: tx-service-group: my_test_tx_group seata: registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: enabled: true application-id: order-service tx-service-group: my_test_tx_group enable-auto-data-source-proxy: true业务方法上只需要加上一个注解Service public class OrderService { GlobalTransactional public void createOrder(OrderDTO orderDTO) { // 1. 本地事务写入订单表 orderMapper.insert(orderDTO); // 2. 远程调用扣减库存 stockFeignClient.deduct(orderDTO.getSkuId(), orderDTO.getQuantity()); // 3. 远程调用扣减账户余额 accountFeignClient.debit(orderDTO.getUserId(), orderDTO.getAmount()); // 4. 本地事务更新订单状态为已创建 orderMapper.updateStatus(orderDTO.getOrderId(), OrderStatus.CREATED); } }当createOrder方法执行时TM 会向 TC 开启全局事务并拿到全局 XID每次调用远程接口时XID 会通过 RPC 调用透传给库存服务和账户服务库存/账户服务里的数据源代理自动把本地数据操作注册为分支事务。任何一个分支抛异常TC 就能协调所有已注册分支回滚。这段流程看着简单背后是数据源代理、全局锁、ucall 链路透传这些机制在默默工作。也正因为它看起来太简单很多人出了问题时根本无从下手排查。4.4 不回滚的排查思路用 AT 模式最常见的问题就是明明挂了异常数据却不回滚。我的排查清单如下先看全局事务有没有真正开启。加GlobalTransactional只会对当前方法所在 Bean 生效如果方法内部用了本地this调用就是同类里方法直接调方法注解就没生效因为代理没有拦截内部调用。这个坑我至少看到过十次。再看数据源代理有没有生效。Seata 需要代理业务数据源如果配置里关闭了enable-auto-data-source-proxy或者项目里自定义了数据源且没有交给 Seata 代理分支事务根本不会被拦截自然也没有回滚可言。再看 undo_log 表对不对。表名、字段如果和 Seata 要求的不一致回滚时解析前镜像会失败。我建议表结构严格用官方脚本建不要自己在基础表上改字段。然后检查线程和网络。全局事务超时时间如果设置太短比如默认 60 秒不够长事务直接被 TC 强制回滚也会出现莫名回滚的现象。反过来超时太长又影响性能需要针对业务压测后合理配置。5. 常见问题与避坑锦囊5.1 服务保护容易踩的坑第一个坑是熔断参数过于敏感。某次线上监控显示熔断频繁触发排查发现阈值设置得太低慢调用比例超过 20% 就熔断结果一个接口因为促销流量正常变慢直接被熔断核心链路瘫痪。参数不是越小越安全而是要结合实际压测数据调整。第二个坑是降级逻辑里又做了远程调用。前面提过降级是为了快速失败如果降级代码里又去查数据库、调远程服务一旦这个被调的服务也处于故障状态降级线程照样卡死。降级必须走本地预案比如本地缓存、默认值。第三个坑是限流没有对用户维度和接口维度分开。只说接口全局限流某个用户疯狂刷接口其他用户请求全被挤掉。合理做法是针对接口设置总限流再针对用户设置子限流比如每个用户每秒最多 5 次所有用户总和每秒最多 5000 次。5.2 分布式事务容易踩的坑分布式事务的坑比服务保护更深。最常见的还是回滚不生效。我见过一个经典场景库存服务调用成功账户服务调用失败理论上库存应该回滚但实际库存没回滚。排查发现库存服务的 Feign 接口上没有透传 XID导致库存服务的分支事务根本没有注册到全局事务里。一般 Seata 的 RPC 拦截器会自动透传但如果你的参数里手动改了 Feign 请求头、或者用了自定义的RequestInterceptor很可能把 Seata 的透传逻辑覆盖了。第二个高频问题就是重复消费导致的数据不一致。事务消息投递到 MQ 后消费端的消费成功和业务数据落库如果不在一个本地事务里消息重试时就会重复执行业务逻辑。没有幂等设计的场景扣款两次、加积分两次就是这么来的。解决方案是在消费端先查业务幂等表或者利用数据库主键冲突去重。第三个是TCC 的空回滚和悬挂。这块前面详细讲过这里再强调一次TCC 框架本身不会帮你处理空回滚和悬挂业务方必须在事务记录表上自己照顾两种边界状态。第四是长事务和性能的矛盾。AT、XA 都会锁资源全局事务牵扯的服务越多、耗时越长锁竞争就越激烈。我在一个订单场景里见过全局事务跨了 6 个服务高峰期光等全局锁就等出 3 秒延迟直接把接口拖垮。后面把不重要的服务从全局事务里拆出来用本地消息表异步化性能立刻提升一大截。5.3 排查工具与方法排查这些线上问题时我的通用方法论是先找链路再定状态。链路分析靠TraceId。把网关和服务间调用的日志全部统一格式每次请求生成全局 TraceId日志里打出来跨服务追问题就方便了。Seata 的全局事务也带 XID把 XID 和 TraceId 关联起来基本能把业务走到哪一步、事务状态是什么还原出来。状态确认靠日志事务记录表。TCC 方案要实时关注事务表里 Try、Confirm、Cancel 的状态AT 方案要关注 undo_log 表是否存在异常残留——如果某次回滚失败undo_log 里的数据会一直留着这本身就是排查线索。进一步还可以通过监控 MQ 的未消费堆积量、接口的熔断事件数来辅助定位。另外一点每次线上事故处理完我都强烈建议把事故复盘记录沉淀成文档。因为服务保护和分布式事务的问题很多是环境、时间、流量综合作用的结果不会每次都复现但记录下来以后同类问题再出现时排查时间能缩短一半以上。最后再分享一点实际体会我做微服务的真实感受是服务保护和分布式事务哪个单独拿出来都不算难难的是它们交织在一起时的取舍。保护措施太激进可能导致该成功的事务被降级掉、造成数据不一致事务方案太重又会拖垮系统性能和可用性。选型前一定要把业务的容忍度想清楚把钱和核心状态放在 TCC 或强一致方案里把通知、日志、积分这类不敏感的数据大胆丢到异步消息里安全感和性能往往能同时兼顾。你踩过的那些坑最后都会变成你方案落地的底气。
返回列表