ARTICLE DETAIL

资讯详情

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

分布式系统三大坑:网络不可靠、一致性误解与故障恢复实战解析

分布式系统三大坑:网络不可靠、一致性误解与故障恢复实战解析 干了这么多年分布式系统从早期做数据同步、消息队列到后来折腾实时计算引擎和微服务治理前前后后也踩了无数个坑。如果非要让我挑三个最典型、最隐蔽、而且几乎每个团队都会遇上的问题那一定是网络不可靠、一致性误解、以及故障恢复想当然。这三件事看起来各自独立实际在真实项目里经常串在一起发作一次故障能让你排查到怀疑人生。这篇内容主要面向正在做分布式任务调度、数据处理管道、微服务调用链的开发和运维同学也适合刚接触分布式计算、想知道“大家都说难到底难在哪”的人。我会把自己踩过的坑、调过的参数、重构过的代码全部拆开讲清楚。1. 为什么分布式计算里最容易踩的是这三个坑1.1 三个坑的共同特征都是在边界条件下爆发很多人在做单体应用的时候写代码只需要关心“正常路径”数据库连得上、接口有响应、文件就在本机。到分布式环境下单机的假设被彻底打破任何一次远程调用都可能超时、乱序、丢失任何一台机器都可能卡顿、宕机、网络隔离。这三个坑的共同点在于日常开发时它们极少暴露所有单元测试都是通过状态所有联调环境也都表现完美真正的爆炸点全在流量峰值、节点故障、网络抖动这些边界条件下。我做过的某个数据同步项目就是这样平时每天同步几千万条数据一点问题没有赶上大促流量翻了几倍消费者服务处理变慢同步任务直接超时重试重试又加剧了消费服务压力形成典型的“重试风暴”。最后整个同步链路雪崩上下游全被拖垮数据库连接池被打满单机应用时代根本想象不到这种连锁反应。1.2 这三个坑为什么容易反复踩网络、一致性、故障恢复分别对应分布式系统的通信、数据、可用性三个维度。这三个维度的核心矛盾都指向同一个词不确定性。单体应用里一个函数调用要么成功要么失败结果完全确定。分布式环境下一次远程调用可能成功、可能失败、也可能“服务端已经执行但响应没回来”这个第三种状态是最难处理的。很多人第一次接触分布式系统时直觉上是把远程调用当成普通函数调用去写。结果就是很多代码里超时时间随便填一个数重试机制没有做幂等保护节点下线也没有主动摘流量。这些问题在设计评审时往往不被重视因为代码评审看的是业务逻辑对不对没人会问“如果这个接口3秒没响应怎么办”也没人验证“两个节点同时拿到任务后会不会重复执行”。所以这三个坑不是技术难度高而是容易被忽略一忽略就完蛋。1.3 选这三个坑的判断标准我选这三个坑不是随便挑的有两个硬标准第一必须是分布式计算里高频发生、且破坏力大的问题第二必须是可以提前设计规避、而不是只能事后补救的问题。网络不可靠可以通过超时控制、重试退避、熔断降级来缓解一致性误解可以通过幂等设计、状态机、分布式事务模式来纠正故障恢复想当然则可以通过健康检查、优雅上下线、故障演练来改进。如果一个问题只能靠运维半夜爬起来手工处理那就是我们设计阶段没做好这个问题本质上就是前三个坑中的某一种。2. 第一大坑把网络当成本地调用来写2.1 远程调用的三种结果很多人只想到了两种先看最基础的模型。本地函数调用结果只有两个返回成功或者抛出异常。远程调用不一样调用方发出的请求要经过网络传输到服务端服务端处理完再把结果传回来。在这条链路中网络包可能丢失、可能延迟、可能被重传于是远程调用实际有三种结果成功、明确失败、以及“未知”。比如客户端把请求发出去了等了3秒没收到响应客户端超时了但服务端可能在2.9秒时已经处理完了只是响应慢了一步。这种“未知”状态为什么致命因为它让“重试”这个动作从简单的补救措施变成了可能引发重复执行的定时炸弹。如果你没有对接口做幂等处理超时后重试一次业务数据就可能被处理两次资金支付、库存扣减、积分发放这类场景就直接出生产事故。“未知”状态不是小概率事件在跨机房、跨区域、链路抖动频繁的网络环境下它是常态。2.2 超时设置不是拍脑袋填个数字几乎每个项目里都能看见这种代码HTTP客户端设置connectionTimeout为5秒readTimeout为5秒至于为什么是5秒没人说得清楚。超时时间设置不合理会造成两类问题超时太短正常请求被误杀系统不停重试白白消耗资源超时时间太长链路里某一环卡住了整个请求堆积在调用方线程池被打满新请求全部排队等待。超时时间应该怎么定我的经验是先从链路下游的实际响应时间数据出发。正常情况下接口P99的响应时间如果是800毫秒那么调用方的超时时间可以设置为2秒左右也就是给足缓冲余量。如果链路是A调用B、B调用C那每层的超时时间必须逐层递减。比如A调用B的超时时间是2秒B调用C的超时时间就要设成1.5秒留出0.5秒给B做处理。否则B等待C超时的时间比A能等B的时间还长A已经放弃B还在傻等连接和线程都没法及时释放。2.3 重试必须带退避否则就是自爆超时之后要重试这个思路本身没错错的是很多人重试时不知道加退避。服务端处理不过来的时候你越疯狂重试服务端越慢服务端越慢客户端越容易超时超时后继续重试这个循环下去就是雪崩。正确的做法是采用指数退避第一次失败后等200毫秒再试第二次等400毫秒第三次等800毫秒并且要给退避时间设置上限防止等待时间过长影响业务。真正生产级的重试策略还需要考虑两个细节一是重试次数不能太多通常1到2次就够再多就没有意义了反而放大下游压力二是要判断哪些错误值得重试。连接超时可以重试因为请求可能还没到达服务端读超时要谨慎重试因为请求可能已经被处理了业务校验失败绝对不能重试比如“余额不足”这种你重试一万次结果也一样。2.4 熔断器给下游一个喘气的机会重试解决的是“偶尔抖一下”的场景但如果下游真的挂了或者严重过载重试就完全失效了。这时候需要熔断器。熔断器的逻辑不复杂连续失败达到一定阈值后熔断器打开后续请求直接快速失败不再发送到下游让下游有机会恢复。经过一个冷却时间窗口后熔断器进入半开状态放少量请求试探下游是否恢复。我在实践中发现很多人理解了熔断的原理却忽视了熔断器的两个关键参数失败阈值和冷却时间。阈值设得太低偶发抖动就触发熔断用户体验受损设得太高熔断失去了保护意义。冷却时间一般建议设置为下游平均恢复时间的2到3倍具体要根据真实故障的恢复速度来调整。另外熔断器打开期间调用方应当有一个降级策略比如返回缓存数据、返回默认值而不是直接把异常抛给上层这才叫完整的容错设计。3. 第二大坑把数据一致性想得太简单3.1 分布式事务为什么难CAP的取舍单体应用里一个业务操作可以在一个本地事务中完成原子性、隔离性都由数据库保证。到了分布式环境业务操作被拆到多个服务里每个服务有自己的数据库跨库跨服务的事务就超出了单机数据库的能力范围。要保证强一致就要引入分布式事务协调器让所有参与者同步提交或回滚。听起来简单但协调器本身也是分布式系统也会宕机、分区协调器的不确定性让事情变得非常复杂。所以工程实践上绝大多数分布式系统放弃强一致转而采用最终一致性。最终一致性的核心思想是允许系统在某个时间窗口内数据不一致但在一段时间后通过补偿、对账、重试等手段达到最终一致。这个思路说起来轻松落地时最难的地方在于补偿逻辑本身也面临同样的结果未知问题。比如你扣了库存通知订单服务“扣库存成功”的消息丢了订单服务一直以为没扣业务就无法继续。补偿方案设计得好不好直接决定你的分布式系统能不能真正稳定运行。3.2 幂等设计最终一致性的地基最终一致性系统里消息推送给消费者消费者处理成功后还没来得及提交消费位点进程崩溃了再次启动后消息又被投递。推消息、拉消息、定时任务扫描、人工重试任何一个环节都可能让同一个业务操作被执行多次。这时候唯一能兜底的就是幂等设计。幂等的意思很简单同一个操作执行一次和执行多次结果是一样的。实现幂等最常见的方式是唯一键约束。比如创建订单时客户端生成一个全局唯一的业务ID服务端在处理请求前先去订单表查一下这个ID是否已存在如果存在就直接返回已有结果不再新建。这里有一个容易被忽略的细节判断ID是否存在和插入数据这两个动作必须放在同一个数据库事务里或者依赖数据库的唯一索引来兜底。如果先查再插但两者不在同一事务中并发请求下两个线程可能同时查到“不存在”然后同时插入数据就重复了。我在项目里吃过这个亏后来统一改成“直接插入捕获唯一键冲突异常”既能保证并发安全代码还更简单。3.3 状态机思维管理分布式流程的正确姿势分布式系统里很多业务对象有多个状态订单有创建、支付、发货、完成、取消。每一个状态流转背后可能涉及多个服务之间的交互。很多人写状态流转时直接在代码里判断“当前状态是A且事件是E就改成B”这种写法在并发场景下很容易出问题。两个请求同时读取到订单状态是“待支付”一个请求要取消另一个请求要支付完成两个都通过了状态校验然后分别更新状态先后覆盖最终状态被后更新的人决定很有可能是错误的。正确的做法是使用状态机加数据库乐观锁。状态机用于定义“哪些状态允许发生哪些流转”比如“已取消”状态下不能流转到“已支付”。数据库乐观锁则通过版本号确保并发安全更新时带上“当前状态待支付”的条件如果更新影响行数为0说明状态已经被别人修改本次操作拒绝执行。这套方案实现成本低、可靠性高我在多个项目里都用它来处理订单状态、任务状态、审批流程等场景。3.4 分布式事务的几种落地模式如果业务确实需要跨服务保持数据一致可选方案有可靠消息最终一致性、最大努力通知、TCC、Saga等。可靠消息最终一致性是最实用的一种业务操作和消息发送放在同一个本地事务里然后通过消息表把消息可靠地投递出去消费者消费成功后回调确认生产者定期扫描未确认消息并重发。这个方案的核心是“本地消息表”我第一次接触时觉得有点土但实际用下来发现它就是最稳的因为不依赖额外的中间件也不会被分布式事务协调器的故障拖累。TCC则适用于需要强实时性的场景Try阶段预留资源Confirm阶段确认操作Cancel阶段回滚。TCC的难点在于Cancel逻辑要写得很完善能处理各种中间状态很多团队写着写着发现Cancel的代码量比正常业务代码还多成本很高。我的个人建议是能通过最终一致性解决的业务场景就不要上TCC只有在资金、库存这类强一致性压力极大的场景才值得引入。3.5 对账兜底不要相信所有消息都能成功送达不管用哪种方案消息丢失、重复、乱序都可能发生。为了保证数据最终一致最稳妥的做法是设计定期对账任务。比如每天跑一个脚本比对A表里的“已处理订单”和B表里的“已支付记录”找出对不上的数据自动重发或人工介入。对账任务听起来很原始但它就是整个系统的最后一道保险。我见过很多团队把可靠性完全寄托在消息中间件的高可用上结果中间件一抖动数据就悄悄丢一批没有对账根本发现不了。4. 第三大坑把故障恢复想成了自动的4.1 节点故障的真实面貌不是“断线”而是“亚健康”很多人在设计分布式系统时对节点故障的理解停留在“某个节点挂了其他节点顶上”这个层面。真实情况远比这复杂。节点卡顿但没挂就像一个人醒着但动不了。网络分区一部分节点能互相通信另一部分节点之间断开了但每个节点各自认为自己还是集群的一部分。磁盘快满了、内存泄漏导致GC越来越频繁、CPU被打满这些亚健康状态让节点对外表现是时而响应慢、时而不响应、时好时坏。这种模糊状态比硬性故障难处理得多。因为故障检测机制需要基于“心跳”或“健康检查”来判定节点状态但心跳超时并不代表节点真的死了可能只是它正在忙。如果集群贸然把任务转移到其他节点原节点却恢复过来继续执行就会出现两个节点同时处理同一个任务的情况造成重复计算。在分布式计算里这种重复计算的后果很严重。4.2 心跳机制为什么不能完全信任心跳是分布式系统中最常用的故障检测手段但它有两个天然缺陷。第一心跳只能证明节点曾经“活着”不能证明节点当前的可用性。两个节点之间心跳正常不代表节点能正常处理请求有可能进程的线程池已经耗尽所有业务线程都在排队而负责发心跳的线程恰好不在排队队列里心跳照发业务全挂。第二心跳的判定阈值很难设置。设置得太短网络偶发抖动就被误判为节点故障引发不必要的任务迁移设置得太长节点真的挂了系统需要很长时间才能发现导致任务恢复过慢。我这里有个实际的教训。某个定时任务集群节点数不多当初设的心跳超时是3秒有一次网络交换机升级引起所有节点之间偶发丢包心跳超时误判集群以为多个节点同时下线触发了大规模的任务重新分配几万个任务被重新调度大量任务重复执行业务侧收到满屏告警。从那以后我再也不敢把心跳超时设置得过短而是在心跳机制之外增加应用层健康检查。4.3 应用层健康检查比TCP端口检查更可靠健康检查是故障恢复中最关键的一环但很多系统的健康检查做得非常敷衍只是检测端口是否在监听TCP能连上就认为节点健康。这个检查方式连“亚健康”都发现不了。一个节点如果线程池满了TCP端口依然是通的因为连接建立在内核层面就完成了根本不需要应用程序响应。正确做法是提供应用层健康检查接口比如Spring Boot Actuator的/health接口。这个接口不仅要返回“UP”或“DOWN”还需要把关键的依赖是否可用、线程池使用率、最近处理请求的耗时等指标暴露出来。负载均衡器或服务发现组件可以通过这些信息做更智能的摘流量决策。实际项目中我采用的做法是把数据库连接池使用率、消息队列堆积情况、JVM内存使用率都纳入健康检查结果一旦某项指标超过阈值就返回“OUT_OF_SERVICE”让流量调度系统把节点从可用列表里摘掉给节点留出恢复时间。4.4 脑裂处理和fencing机制当网络分区发生时两个节点都认为自己是主节点就会形成“双主”状态也就是脑裂。脑裂在分布式计算中的后果很严重两个主节点同时调度任务、同时写数据数据就乱了。解决脑裂的常见手段包括仲裁机制、fencing机制。仲裁机制是让多个节点投票选出主节点只有获得超过半数票的节点才能成为主。fencing机制是在主节点确定后给旧主节点发一个隔离指令让它放弃所有资源。实现fencing时有一个重要细节隔离指令本身也要做到即使网络不可达也能生效。比如在数据库方案中主节点第一次激活时会往数据库插入一条包含自己标识的记录其他节点每次升主之前必须先检查这条记录是否存在如果存在就说明集群已有主自己不能升。这种情况下网络分区导致的两边同时写入会被数据库的唯一约束拦住只有一边能成功这样就能在很大程度上避免脑裂。4.5 故障演练把“想当然”变成“验证过”故障恢复能力不好验证这是它能成为“坑”的原因之一。很多系统的故障恢复逻辑只存在于代码评审的PPT里从没被真实触发过。等到生产环境中真的发生节点宕机才发现任务卡在“待重新调度”状态整整一个小时没人处理因为“自动恢复”逻辑里有个隐藏的bug。这种问题只有通过故障演练才能暴露出来。混沌工程的思想是主动在生产环境中注入故障验证系统的韧性。对于分布式计算系统故障演练可以从几个基本场景开始杀掉一个计算节点观察任务是否能在预期时间内被重新调度执行给某个节点注入5秒的人工延迟观察调用方是否会超时、是否会触发熔断、是不是有预案兜底把数据库连接数跑满观察服务是否还能健康检查通过。每做完一次演练记录下从故障发生到系统完全恢复的时间找出恢复链路中的瓶颈修正后再练直到恢复时间达标为止。5. 常见问题与排查技巧实录5.1 重试风暴压垮整个链路的排查过程有一年大促期间订单服务的响应时间整体上升下游支付回调的P99从800毫秒涨到了3秒。支付回调的消费方在调用下游服务时读超时设置是2秒于是大量请求在2秒时超时消费方开始重试。重试是等2秒一次、再等2秒一次连续重试3次导致下游收到4倍于正常流量的请求。下游被压垮后更慢超时更多重试更猛形成了一个正反馈循环最终整个支付链路雪崩。排查时我先看了下游服务的QPS曲线发现支付回调的QPS比正常值高了三四倍而且每个请求都伴随大量“连接被拒绝”的错误。再回头看上游调用端的日志确认重试次数异常增多。根治方案分三步第一把重试策略改为指数退避加随机抖动退避上限5秒重试次数从3次降为1次第二超时时间改为从下游P99响应时间推导出来而不是拍脑袋定2秒第三增加熔断器配置当下游连续失败率达到阈值后直接降级不再重试。这套方案上线后类似的链路故障再也没出现过。5.2 消息重复消费导致库存多扣的定位与修复某次线上事故中库存数据突然不对排查后发现是消息重复消费导致。消息中间件在消费者处理消息、还没提交消费位点的时候消费者进程发生了重启。位点没有提交重启后消息被重新投递。正常的重复消费场景只要接口做了幂等设计就不会有问题但当时这个消费者接口没有幂等保障每次消费都直接扣库存一重放就多扣了。修复方案有两种如果接口能改造就在数据库表加唯一业务键用唯一索引来保证同一个业务请求最多只落一条数据如果接口不能大改则引入幂等表每次在处理业务数据前先往幂等表插入一条包含业务ID的记录插入成功则继续处理插入失败说明已经处理过直接跳过。但这里要注意幂等表和业务表必须在同一个数据库事务里否则可能出现业务数据更新成功、幂等记录没有写入或者反过来导致幂等判断失效。注意分布式系统的排查思路和单机系统很不一样。单机系统问题通常可以靠日志、断点、堆栈定位分布式系统里的问题往往需要结合调用链追踪、指标监控、链路日志三方面数据才能还原完整的时间线看到哪个环节出了故障。排查任何分布式问题时第一步永远是收集三样东西全链路追踪ID、上下游监控指标、各个节点的应用日志三管齐下才不会被假线索带偏。5.3 分布式锁失效导致定时任务被重复执行的案例有一个定时任务在集群环境中频繁重复执行检查代码后确认已经加了分布式锁理论上同一时刻只有一个节点能抢到锁。但观察监控发现每隔几小时就会出现两个节点同时执行任务的情况。深挖下去问题出在分布式锁的锁过期时间上。代码里设置的锁过期时间是30秒而任务实际执行时间经常超过30秒。第一个节点执行到一半锁因过期自动释放第二个节点立刻拿到了锁导致同一任务被并发执行。解决这个问题有一个非常实用的技巧锁续期。也就是在获取锁之后启动一个后台线程定期给锁续期比如每10秒续一次30秒的有效期任务执行完再主动释放锁。这个方案虽然要额外维护一个续期线程但可靠性好很多。如果使用的分布式锁中间件本身不支持续期也可以用另一种方式把锁的有效期设置得足够长让它一定不会在任务执行期间过期但要设置好兜底策略防止拿到锁的节点明确崩溃后锁永久不释放引发任务卡死。5.4 健康检查接口导致的“假活”问题某个服务被负载均衡摘除了流量但监控显示CPU使用率仍然很高。后来发现是因为健康检查接口只检查了进程是否存活没有检查线程池状态。大量业务请求被打到该节点线程池全部占满新请求长时间排队等待中的请求超时后客户端重试又产生新的请求。修复是在健康检查接口中增加对线程池活跃度、请求队列深度的判断当活跃线程数超过线程池最大线程数的80%、或者队列深度超过一定阈值时健康检查返回不健康状态负载均衡器自动把该节点摘除。这套方案上线后节点过载时的流量调度变得聪明了很多不会再把流量压向一个已经处理不过来的节点。5.5 常用问题排查速查表问题现象可能原因优先排查方向常用解法请求超时频发重试后仍然失败超时设置不合理或下游过载查看下游P99响应时间、线程池状态调整超时时间、加入熔断降级数据重复、订单重复创建接口未做幂等处理或分布式锁失效查看业务日志里的唯一键冲突、锁持有时间加唯一索引、实现幂等表、锁续期节点无响应但健康检查正常健康检查只检查端口未检查业务线程池查看健康检查接口内容、线程池状态增加应用层健康检查指标两个节点同时执行同一任务分布式锁过期或脑裂查看锁过期时间、节点选举机制锁续期、fencing机制消息积压处理速度上不去消费逻辑慢或下游限流查看消费者日志、下游依赖耗时消费并发度调整、异步化、批量处理节点瞬间被大量请求打垮重试风暴或负载均衡策略不健康查看调用端重试次数、LB健康检查配置指数退避、熔断、摘除不健康节点6. 一些实际经验与设计建议6.1 设计阶段就要把“失败”当成默认路径在评审设计文档时我习惯问自己几个问题如果这个接口调不通业务会发生什么如果这条消息被重复投递数据会乱吗如果这台机器在任务执行到一半时宕机任务重调度后会不会重复执行如果这些问题在评审时答不上来这个设计就是有漏洞的需要补方案再评审。把失败当成默认路径而不是异常路径这是分布式系统设计与单体系统设计最本质的区别。6.2 做好可观测性再把业务写复杂分布式系统的复杂度决定了它不可能靠“猜”来排查问题。可观测性的三根支柱是日志、指标、链路追踪。日志不是只在出错时打印关键路径上的入参、出参、耗时都应该有记录。指标要覆盖核心业务量、系统资源、中间件状态。链路追踪则必须贯穿所有服务调用确保每个请求都有一个全局唯一的traceId。没有可观测性的分布式系统就像没有仪表盘的飞机飞得再高也让人心里发慌。6.3 没有银弹只有权衡你可能注意到了这篇文章里多次提到“要看场景”“要根据实际情况”。分布式系统设计的核心就是权衡没有哪个方案是万能的。强一致性和可用性不可兼得高性能和高可靠性需要取舍自动恢复和误操作风险需要平衡。我的建议是能满足业务需求的前提下方案越简单越好。分布式事务能力做得很重最终却发现业务用最终一致性就能满足需求那这部分复杂度就是纯粹的负债不是资产。成熟的系统设计者不是把所有高级技术都堆上去的人而是能把复杂度控制在系统确实需要的水准上的人。6.4 最后分享一个低成本提升可靠性的技巧如果项目没有余力做复杂的治理体系我建议优先做好一件事所有核心接口都实现幂等。这个动作成本最低、收益最直接。幂等做好以后超时可以放心重试消息可以放心重复投递任务调度可以放心重新执行三大坑里至少一半的风险被自动规避掉了。我在多个项目里验证过幂等设计是分布式系统里性价比最高的可靠性投资值得每个团队优先落地。
返回列表