2026/9/5 14:56:13

双吸引子:系统为何会稳定地停在故障状态?

双吸引子:系统为何会稳定地停在故障状态? 第一次看到“他有两个吸引子”这句话如果只看字面很容易当成一句神秘学描述。但在系统稳定性排查的语境里它其实指向一种很常见的现象同一套服务、同一段代码在不改任何业务逻辑的情况下可能落在两种完全不同的运行状态里。一种是健康稳定另一种是故障但依然稳定。你不需要把系统彻底打死它也能在错误状态里持续运行下去表现为响应时间极高、线程池占满、调用方疯狂重试但进程还活着指标曲线也“稳定”在了一个让人难受的位置。如果你见过这样的服务就会明白为什么我会用“吸引子”这个词。它并不是一个形容词而是一个很贴近动力系统本质的判断系统最终会趋向哪里不只看你当前的参数设置还看它所在的“状态空间”里存在哪些稳定点。很多稳定性问题之所以反复出现核心不是某一行代码写错了而是系统内部天然存在两个稳定点。一个是我们想要的另一个是我们不想要的。这篇文章想聊的就是怎么认识这个“双吸引子”结构并围绕它做有效的治理。1. 为什么正常和故障会呈现“两个挡位”而不是一条渐变曲线1.1 用状态变化率理解系统去向在工程里一个服务是否健康通常看几个连续指标线程池活跃数、队列长度、P99延迟、错误率、吞吐量。如果只盯单个指标看会发现系统在故障前后并不是平滑变坏而是像换挡一样跳变。我以前处理过一个类似场景一个订单查询接口平时 CPU 不高P99 稳定在几十毫秒某一天下游依赖开始变慢接口 P99 突然从几十毫秒跳到两秒以上吞吐量反而往下掉。更奇怪的是重启进程后能得到一小段正常时间但流量一上来又立刻进入慢速状态。当时团队以为是 SQL 或缓存出了问题反复查代码没有结果。后来我们把线程池活跃数、队列长度和调用方超时重试事件放在一起看才意识到这更像是排队模型里的“拥塞平衡”。可以做一个简化推导。请求到达率是 λ系统正常处理能力是 μ。当 λ 小于 μ 时队列很短响应时间低系统处于一个健康平衡点。一旦 λ 大于 μ队列会开始堆积。如果服务端没有做有界等待或快速失败那么后面来的请求可能全部排在一个很深的队列里。客户端等不到响应就超时超时后重试重试请求又作为新流量打到服务端等于把 λ 进一步放大。最终系统会到达一个新的平衡每秒到达的请求和每秒能处理完的请求大致相等但队列深、线程忙、响应时间长。这个“新的平衡”并不是系统挂掉而是它进入了另一个稳定状态。如果只调某个代码逻辑而不改变资源边界和流量控制方式它始终会被吸向那个状态。1.2 有界资源是第二个稳态的温床第二个稳定点的出现往往不是因为代码有 Bug而是因为系统资源是有界的。线程池有上限连接池有上限队列有上限内存和 CPU 也有上限。当某个依赖变慢时线程不会立刻释放而是长时间等待 I/O。等到线程池被占满新请求只能进队列。队列如果也有界排不进去的请求就会失败调用方看到失败后会发起重试重试又占住新的线程。这个过程会形成典型的正反馈慢导致等待等待导致线程占用线程占用导致队列堆积队列堆积导致超时重试重试反过来抬高到达率。最后系统陷入一个吞吐很低、延迟很高、资源居高不下的“故障稳定态”。用动力系统的话说横轴可以看作线程池占用率或队列长度纵轴可以看作它的变化速率。曲线与横轴相交的点有的稳定有的不稳定。当系统同时存在两个稳定点时中间的临界点往往不稳定。只要有一点扰动越过临界点系统就会快速滑向其中一个稳定点看起来就是“两挡跳变”。这也是为什么很多故障看起来“来得突然走也突然”。真正的原因不是玄学而是系统在两个稳定点之间发生了转移。1.3 为什么常规排查一直找不到问题常规排查习惯是顺着失败链找根因先看慢 SQL再看缓存穿透再看代码异常。但在双稳态结构里代码可能没有异常。问题是系统层面的协同效应一个下游慢调用触发了资源占用加上调用方的重试策略再加上有限线程池的排队机制最后把服务推入了错误吸引子的范围内。所以你会看到一种很典型的现场日志里有超时但代码逻辑没 bugCPU 没跑满线程池却已经排满了请求量并没有暴涨但服务就是变慢了。重启之所以有效是因为它把内存中的线程数、队列长度、连接状态全部清零让系统重新回到了另一个稳定点的“吸引范围”里。但下次扰动一来它又会滑过去。理解这一点之后排查思路就会变化不再纠结于“哪行代码引起的”而是先判断“这个系统是否有两个甚至多个稳定点我目前落在哪一个范围里”。2. 现场识别怎么判断你的系统是不是存在“两个吸引子”2.1 先从现象里找三条线索不是所有性能抖动都是双稳态但有几种现象非常典型。第一条延迟曲线不是平滑上升而是跳变。比如 P99 从 100ms 突然跳到 3s恢复时也不是缓慢下降而是过了一段时间后突然回到 100ms。第二条吞吐量出现“悬崖”。正常情况下随着并发增加吞吐量会先上升然后进入平台期。但如果存在故障吸引子吞吐量可能过了某个点以后不升反降甚至掉到一个明显更低的位置。这个状态持续下去系统也能维持运行但资源效率极低。第三条资源占用率卡在某个高位且不回落。比如线程池活跃数长期接近最大值队列长度一直大于某个阈值即使外部流量已经不再增长指标也不会下降。单条线索出现时不一定有意义三条同时出现就值得怀疑系统正在被某个故障吸引子捕获。2.2 按输入、环境、依赖、参数的顺序逐层确认如果要做系统性验证我一般会按下面的顺序去排查。这个顺序不是从上到下执行一遍就完而是要关联到同一段故障时间线上看。表格形式整理会比较清楚排查层核心检查项典型判断线索输入层调用方 QPS、重试次数、超时时间、并发数到达率是否被重试放大请求来源是否集中在某些调用方环境层CPU、内存、线程池、队列、连接池、网络带宽是否存在资源有界且已经逼近上限重启后指标是否短期恢复依赖层下游服务延迟、错误率、连接池使用率是否有单个慢依赖长时间占住线程依赖故障是否触发重试瀑布参数层最大线程数、队列长度、熔断阈值、降级开关触发保护动作的阈值是否设置合理恢复阈值是否出现过近或过远框架/组件层语言运行时的线程模型、连接池回收策略、超时语义组件本身是否会在高负载下排队而不是拒绝请求只要看完这张表后你发现多个指标指向同一个区间而且这些现象能够在压测或故障复现中被稳定触发那基本可以确认系统存在一个“低质量稳定点”。2.3 用一个可回滚的压测或摘流量实验验证“迟滞”验证双稳态最有效的方式是观察系统在“加压”和“减压”两个方向上是否存在迟滞现象。简单说你慢慢给系统加压当超过某个临界点后延迟和线程池占用突然跳到高位此时你再慢慢减压力系统并不会立刻回到原来的低延迟状态而是要等流量下降到更低的一个点后才恢复。这说明系统存在两个吸引子而且它们的边界并不重合。压测时如果看到这样的迟滞环基本可以确认不是单纯的性能瓶颈而是结构性的稳态切换。如果线上允许也可以做一次小范围摘流量实验。把某台处于故障状态实例的流量摘掉一部分观察它的线程池活跃数、队列长度、CPU 占用是否能够自行下降。如果能够恢复说明它当时只是落在了一个可以由资源边界拉回的故障吸引子如果不能恢复说明还存在更深层的资源泄漏或外部依赖问题。注意这类实验要在低峰期做并且提前设计好回滚方案。不要为了验证判断把一个线上实例直接拉到更大的风险里。3. 真正要改的不是症状而是“盆域”的形状3.1 先理解吸引子旁边的“盆”在动力系统里每个吸引子都自带一个“流域”或“盆域”意思是只要系统初始状态落在哪个盆域内最终就会流向哪个吸引子。两个吸引子之间会有一个分界线工程上往往对应某一个资源阈值或某一个指标临界值。很多治理方法只盯着临界值本身。比如“当线程池占用超过 80% 时告警”这句话只能告诉你快到边界了但不能改变盆域的形状。真正有效的治理是要让不想要的稳定点变浅、变小甚至直接消失。这样才能避免系统一再滑过去。3.2 如何让“故障吸引子”不再稳定要让故障吸引子消失核心是打断正反馈循环。正反馈越强故障态越稳定一旦系统滑进错误吸引子靠自己爬出来就越难。常见的做法可以概括成五步第一限制重试放大。调用方必须有清晰的超时时间和重试次数重试之间要加随机抖动避免所有调用者在同一时刻发起重试。服务端也要考虑拒绝已明显超时的请求而不是让它们继续排队。第二做到“有界等待”。线程池和队列都要有上限更重要的是设计队列满之后的动作。如果队列满了快速失败并返回“系统繁忙”比让请求无限等待更安全。无限等待看起来是友好实际上是在为故障吸引子积累势能。第三做线程或信号量隔离。把不同的下游调用拆到独立的线程池或信号量里。一个慢依赖最多把自己那一份资源耗尽不应该有机会拖垮其他没有依赖它的请求。第四引入熔断机制。当下游错误率或者慢调用比例超过阈值时上游服务应该主动断开走降级逻辑而不是继续把请求发给已经生病的一方。熔断的意义是保护系统不要持续处在“半连接、半超时”的消耗状态。第五在网关入口层做背压。如果内部已经开始过载入口层应该主动拒绝或者限流。把压力挡在外围好过让每一个请求都进到服务内部排队再超时。这五步合起来其实是在重新绘制系统状态图。故障吸引子之所以存在是因为系统允许“慢请求—占线程—积累队列—超时重试”一路正反馈下去。熔断和限流是在链路上切了几个口子让它不再具备形成稳定故障态的条件。3.3 更关键要制造一个“恢复吸引子”清掉故障吸引子还不够你还需要让系统在经历故障后能回到正常状态。很多团队只做了限流却没有设计恢复路径结果系统在故障之后又被卡在另一个低吞吐状态里。一个好的恢复流程应该是这样熔断打开后先拒绝一部分流量等待本就积压的请求逐渐消化熔断进入半开状态后放少量试探请求如果成功比例足够再逐步放开流量放开过程中持续观察线程池活跃数和 P99 延迟一旦延迟再次抬头就重新收敛放量节奏。这本质上是在系统里人为制造一个指向健康态的“吸引子”。系统不是靠运气逃出故障而是被一个明确的路径牵引回来。在做多个控制策略时不要把所有保护都一次性打开。比较好的顺序是先限制超时和重试再接线程池隔离然后是熔断降级最后才做网关层限流。每多一层控制都会增加误伤正常流量的可能性。4. 反过来利用把“第二个吸引子”变成可控策略4.1 有些系统刻意保留第二个稳定态说完了治理再看另一个角度第二个吸引子并不一定是坏东西。在削峰填谷、异步处理、离线批量场景里我们经常希望系统能够停在一个“暂时积压但不会崩溃”的稳定态。最典型的是消息队列消费者。当生产速度大于消费速度时Kafka 或其他的消息队列会出现 lag 持续增长。只要磁盘和网络没有被打满消费者进程不会死但它和生产者之间会形成一个错位的平衡。这个平衡本身就是一种保护避免了消息量突增直接把下游系统打挂。这时候你的目标不是消灭 bad attractor而是给系统挑选一个可以接受的稳态并围绕它建立监控和自动扩缩容策略。比如 lag 低于某个阈值时不做干预lag 超过第二个阈值时就扩容消费者或降级其他非核心任务。这种做法的本质是把第二个吸引子变成一个可观测、可调控的状态而不是放任它失控。4.2 基于“目标稳态”做控制规则工程上可以沉淀出一个通用框架我把它叫做“双稳态护栏”。不管你是做微服务治理还是做异步任务调度都可以这样套用第一步定义正常稳态。至少选一到两个指标例如吞吐量达到预期P99 延迟在可接受范围线程池活跃率低于 70%队列长度趋于平稳。第二步定义故障稳态。例如线程池占用率持续 100%队列长度保持高位且不回落P99 延迟超过调用方超时时间自动扩容也无法让指标下降。第三步设计触发保护的动作。触发点要选在“进入故障吸引子之前”而不是已经进入之后。常见信号是等待线程数增加、请求在队列中平均等待时间变长、下游慢调用比例升高。看到这些信号就主动做拒绝、熔断或摘流量。第四步设计退出动作。系统进入保护状态之后要有一套清晰的恢复步骤比如熔断半开试探、逐步放开流量、在低延迟下重新预热缓存。第五步持续观测是否出现“控制器竞争”。如果熔断和限流同时生效流量可能被打得过低如果恢复阈值和触发阈值设置太近系统会在两个状态之间反复震荡。比较好的做法是让恢复阈值比触发阈值更保守留出足够的滞回空间。这套框架并不复杂但很实用。它最大的价值不是预测系统“下一秒会怎样”而是先承认系统存在多个稳定点然后围着这些稳定点做控制。5. 这个方法适合什么场景不适合什么场景5.1 别把“双吸引子”当成万能解释使用这个思路时要先确认场景是否匹配。它最适合的是有较多并发请求、存在排队或资源竞争、有超时和重试机制的服务系统。这类系统在理论上天然具备多稳态条件用吸引子模型去理解通常能比线性思维更贴近真实。但如果你的故障是持久化数据损坏、配置写错、代码逻辑分支异常、磁盘空间耗尽导致的不可逆问题那么用双稳态模型去解释意义不大。那些问题是确定性的错误不是系统状态在多个稳定点之间的转移。另外很多性能抖动其实来自更简单的因素比如 GC 暂停、某个热点 key 分流不均、新版本发布导致缓存命中率下降。这类问题首先应该做数据分析和调用链定位不要看到一个“双峰曲线”就说存在两个吸引子。概念再漂亮也得落在可验证的事实上。5.2 做治理设计时要避免两个新误区一个误区是“控制层越多越好”。保护机制一旦过多彼此之间可能打架。比如入口限流拦住了流量服务端熔断又拦住了放进来的一部分请求结果整体可用性被过度压低。实际落地时要清楚每一层保护的目标是谁限流保护的是系统总容量熔断保护的是依赖故障隔离保护的是故障爆炸半径。它们针对的场景不一样不能混在一起瞎调。另一个误区是“只设触发阈值不设恢复阈值”。系统在进入保护状态后如果没有任何恢复路径就会一直停留在低吞吐状态。这种状态虽然稳定但显然不是业务想要的。设计时一定要记住触发阈值和恢复阈值要分开恢复条件要滞后于触发条件让系统具备“主动爬出错误吸引子”的能力。其实写完这些我最想留下的经验只有一句话很多看似复杂的问题不是某个局部坏了而是系统本身就存在几个不同的稳定点。你能不能发现“他有两个吸引子”决定了你是去修代码还是会去重新设计系统的边界。后一种思路往往才是解决这类问题的真正起点。