第一次看到“他有两个吸引子!”这句话,如果只看字面,很容易当成一句神秘学描述。但在系统稳定性排查的语境里,它其实指向一种很常见的现象:同一套服务、同一段代码,在不改任何业务逻辑的情况下,可能落在两种完全不同的运行状态里。一种是健康稳定,另一种是故障但依然稳定。你不需要把系统彻底打死,它也能在错误状态里持续运行下去,表现为响应时间极高、线程池占满、调用方疯狂重试,但进程还活着,指标曲线也“稳定”在了一个让人难受的位置。
如果你见过这样的服务,就会明白为什么我会用“吸引子”这个词。它并不是一个形容词,而是一个很贴近动力系统本质的判断:系统最终会趋向哪里,不只看你当前的参数设置,还看它所在的“状态空间”里存在哪些稳定点。很多稳定性问题之所以反复出现,核心不是某一行代码写错了,而是系统内部天然存在两个稳定点。一个是我们想要的,另一个是我们不想要的。这篇文章想聊的,就是怎么认识这个“双吸引子”结构,并围绕它做有效的治理。
1. 为什么正常和故障会呈现“两个挡位”,而不是一条渐变曲线
1.1 用状态变化率理解系统去向
在工程里,一个服务是否健康,通常看几个连续指标:线程池活跃数、队列长度、P99延迟、错误率、吞吐量。如果只盯单个指标看,会发现系统在故障前后并不是平滑变坏,而是像换挡一样跳变。
我以前处理过一个类似场景:一个订单查询接口,平时 CPU 不高,P99 稳定在几十毫秒;某一天下游依赖开始变慢,接口 P99 突然从几十毫秒跳到两秒以上,吞吐量反而往下掉。更奇怪的是,重启进程后能得到一小段正常时间,但流量一上来,又立刻进入慢速状态。当时团队以为是 SQL 或缓存出了问题,反复查代码,没有结果。
后来我们把线程池活跃数、队列长度和调用方超时重试事件放在一起看,才意识到这更像是排队模型里的“拥塞平衡”。
可以做一个简化推导。请求到达率是 λ,系统正常处理能力是 μ。当 λ 小于 μ 时,队列很短,响应时间低,系统处于一个健康平衡点。一旦 λ 大于 μ,队列会开始堆积。如果服务端没有做有界等待或快速失败,那么后面来的请求可能全部排在一个很深的队列里。客户端等不到响应就超时,超时后重试,重试请求又作为新流量打到服务端,等于把 λ 进一步放大。最终系统会到达一个新的平衡:每秒到达的请求和每秒能处理完的请求大致相等,但队列深、线程忙、响应时间长。
这个“新的平衡”并不是系统挂掉,而是它进入了另一个稳定状态。如果只调某个代码逻辑而不改变资源边界和流量控制方式,它始终会被吸向那个状态。
1.2 有界资源是第二个稳态的温床
第二个稳定点的出现,往往不是因为代码有 Bug,而是因为系统资源是有界的。线程池有上限,连接池有上限,队列有上限,内存和 CPU 也有上限。当某个依赖变慢时,线程不会立刻释放,而是长时间等待 I/O。等到线程池被占满,新请求只能进队列。队列如果也有界,排不进去的请求就会失败,调用方看到失败后会发起重试,重试又占住新的线程。
这个过程会形成典型的正反馈:慢导致等待,等待导致线程占用,线程占用导致队列堆积,队列堆积导致超时重试,重试反过来抬高到达率。最后系统陷入一个吞吐很低、延迟很高、资源居高不下的“故障稳定态”。
用动力系统的话说,横轴可以看作线程池占用率或队列长度,纵轴可以看作它的变化速率。曲线与横轴相交的点,有的稳定,有的不稳定。当系统同时存在两个稳定点时,中间的临界点往往不稳定。只要有一点扰动越过临界点,系统就会快速滑向其中一个稳定点,看起来就是“两挡跳变”。
这也是为什么很多故障看起来“来得突然,走也突然”。真正的原因不是玄学,而是系统在两个稳定点之间发生了转移。
1.3 为什么常规排查一直找不到问题
常规排查习惯是顺着失败链找根因:先看慢 SQL,再看缓存穿透,再看代码异常。但在双稳态结构里,代码可能没有异常。问题是系统层面的协同效应:一个下游慢调用触发了资源占用,加上调用方的重试策略,再加上有限线程池的排队机制,最后把服务推入了错误吸引子的范围内。
所以你会看到一种很典型的现场:日志里有超时,但代码逻辑没 bug;CPU 没跑满,线程池却已经排满了;请求量并没有暴涨,但服务就是变慢了。重启之所以有效,是因为它把内存中的线程数、队列长度、连接状态全部清零,让系统重新回到了另一个稳定点的“吸引范围”里。但下次扰动一来,它又会滑过去。
理解这一点之后,排查思路就会变化:不再纠结于“哪行代码引起的”,而是先判断“这个系统是否有两个甚至多个稳定点,我目前落在哪一个范围里”。
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 做治理设计时要避免两个新误区
一个误区是“控制层越多越好”。保护机制一旦过多,彼此之间可能打架。比如入口限流拦住了流量,服务端熔断又拦住了放进来的一部分请求,结果整体可用性被过度压低。实际落地时,要清楚每一层保护的目标是谁:限流保护的是系统总容量,熔断保护的是依赖故障,隔离保护的是故障爆炸半径。它们针对的场景不一样,不能混在一起瞎调。
另一个误区是“只设触发阈值,不设恢复阈值”。系统在进入保护状态后,如果没有任何恢复路径,就会一直停留在低吞吐状态。这种状态虽然稳定,但显然不是业务想要的。设计时一定要记住:触发阈值和恢复阈值要分开,恢复条件要滞后于触发条件,让系统具备“主动爬出错误吸引子”的能力。
其实写完这些,我最想留下的经验只有一句话:很多看似复杂的问题,不是某个局部坏了,而是系统本身就存在几个不同的稳定点。你能不能发现“他有两个吸引子”,决定了你是去修代码,还是会去重新设计系统的边界。后一种思路,往往才是解决这类问题的真正起点。