<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Gateway on 潘达窝</title><link>https://daidaij.github.io/tags/gateway/</link><description>Recent content in Gateway on 潘达窝</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><copyright>pandazhangs</copyright><lastBuildDate>Sun, 06 Sep 2026 20:03:25 +0800</lastBuildDate><atom:link href="https://daidaij.github.io/tags/gateway/index.xml" rel="self" type="application/rss+xml"/><item><title>Higress 端点级断路器（二）：全拉黑放行——最反直觉的设计决策</title><link>https://daidaij.github.io/p/higress-endpoint-breaker-all-black/</link><pubDate>Sun, 06 Sep 2026 20:03:25 +0800</pubDate><guid>https://daidaij.github.io/p/higress-endpoint-breaker-all-black/</guid><description>&lt;img src="https://picsum.photos/seed/43e49826/800/600" alt="Featured image of post Higress 端点级断路器（二）：全拉黑放行——最反直觉的设计决策" />&lt;h1 id="higress-端点级断路器二全拉黑放行最反直觉的设计决策">Higress 端点级断路器（二）：全拉黑放行——最反直觉的设计决策
&lt;/h1>&lt;hr>
&lt;blockquote>
&lt;p>系列第二篇。上一篇写了慢故障的统计口径——5s 去重间隔、70s 慢阈值 × 2&lt;del>3 次确认拉黑、TTL 30&lt;/del>60s 惰性恢复，这一篇只讲一个分支：&lt;strong>当所有端点都被拉黑时，插件选择放行、不做屏蔽&lt;/strong>，让请求继续打到所有节点上。第一次推演到这个 case 时，我的直觉答案是&amp;quot;该 503&amp;quot;；把时间轴往下多推一步，就改了主意。&lt;/p>
&lt;/blockquote>
&lt;h2 id="从一次差点全拉黑的推演讲起">从一次&amp;quot;差点全拉黑&amp;quot;的推演讲起
&lt;/h2>&lt;hr>
&lt;p>先把这个最坏场景摆出来。假设网关后面挂着一个 provider 的三个端点，上游网络抖动 30 秒：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">t=0s 抖动开始，请求陆续变慢/超时
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">t=8s 端点 A 慢样本确认满 2~3 次，拉黑 → 流量集中到 B、C
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">t=15s B 被集中流量压垮，拉黑 → 流量全压 C
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">t=22s C 独木难支，也拉黑
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">t=23s 候选集为空 —— 此刻屏蔽过滤要不要生效？
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">t=35s 上游抖动结束，实际已恢复
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>前三步都是正常的断路器行为，问题出在 &lt;code>t=23s&lt;/code>。候选集为空时最自然的写法是返回 503，但注意时间轴上还有 &lt;code>t=35s&lt;/code>：抖动结束了，上游恢复了，然后呢？&lt;/p>
&lt;p>黑名单的解除靠什么？靠 TTL 到期的惰性恢复——端点在 &lt;code>blockUntil&lt;/code> 过期后自动回池。503 方案的问题不是永久锁死（TTL 终究会到期），而是&lt;strong>恢复时刻被 TTL 绑架&lt;/strong>：t=35s 上游已经恢复，可每个端点要等各自的 &lt;code>blockUntil&lt;/code> 过期才回池，这段时间网关在主动 503，白瞎已经恢复的容量。TTL 是按&amp;quot;最坏故障时长&amp;quot;配的，通常远长于一次真实抖动——为了防误判把 TTL 配长，结果真故障短了也要多陪等一整段。而放行的代价只是请求继续打到（可能已恢复的）节点上：打成功就是赚的，打失败也只是维持本来的坏状态。上游明明恢复了，网关却握着屏蔽名单不放，故障没扩散，是屏蔽逻辑替它完成了最后一击。&lt;/p>
&lt;blockquote>
&lt;p>推演到这一步我才意识到，&amp;ldquo;全拉黑&amp;quot;根本不是一个正常的状态转换，而是一个危险信号：它要么说明故障大面积爆发，要么说明统计在误判。两种情况的处理方式殊途同归，下面分开说。&lt;/p>
&lt;/blockquote>
&lt;h2 id="放行的分支设计">放行的分支设计
&lt;/h2>&lt;hr>
&lt;p>先交代状态结构，这段来自我的 failover 设计文档（Go filter 方案那一层；统计形态在不同落地载体上有差异——wasm 里是 SharedData 分片，Go filter 里是进程内全局——这里按设计文档的形态讲），端点级统计就挂在这个全局结构上：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">healthState&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Mutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">failures&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">slidingWindow&lt;/span> &lt;span class="c1">// 每 provider 失败滑动窗口（被动健康）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">probes&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">probeResult&lt;/span> &lt;span class="c1">// 主动探测结果（goroutine ticker 维护）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">quota&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">tokenBucket&lt;/span> &lt;span class="c1">// 配额余量（rate_limiting 降级）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}{}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>屏蔽过滤发生在选端点阶段，分支逻辑用伪代码表达（实现细节从略）：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 候选端点过滤：拉黑名单的放行分支
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">func&lt;/span> &lt;span class="nf">filterEndpoints&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">eps&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Endpoint&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Endpoint&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">healthy&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nx">Endpoint&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">eps&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ep&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">eps&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nf">blacklisted&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ep&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="c1">// 黑名单来自慢阈值判定（70s × 2~3 次确认，5s 去重间隔）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">healthy&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">healthy&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ep&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">healthy&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">eps&lt;/span> &lt;span class="c1">// ← 全拉黑：放弃过滤，全量放行
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">healthy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>用流程图看这个分支在整体调度里的位置：&lt;/p>
&lt;pre class="mermaid" style="visibility:hidden">flowchart TD
A[请求进入] --> B{黑名单过滤}
B -->|部分端点被拉黑| C[返回健康候选集]
B -->|无端点被拉黑| C
B -->|全部被拉黑| D[放弃过滤&lt;br/>全量放行]
C --> E[priority → weight 轮询选端点]
D --> E
E --> F{请求结果}
F -->|成功| G[请求成功&lt;br/>端点按各自 TTL 到期回池]
F -->|失败| H[失败样本继续入账&lt;br/>回池后重新判定是否再拉黑]&lt;/pre>&lt;p>注意 &lt;code>t=23s&lt;/code> 之后发生了什么：放行意味着请求继续打到这些端点上，上游一恢复请求就直接成功；屏蔽名单里的端点则在各自 TTL 到期后惰性回池——恢复路径上没有任何额外的状态转换。而 503 方案里，恢复时刻被 &lt;code>blockUntil&lt;/code> 绑死，TTL 配多长就白等多久。放行不是妥协，是让&amp;quot;上游恢复&amp;quot;这个事实能立刻传导到流量上。&lt;/p>
&lt;p>有一个容易混淆的边界要划清。我的设计文档初稿里，provider 级选择循环的兜底是这样写的：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">p&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">pickProvider&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">providers&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// priority → weight 轮询 → 健康/配额过滤
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">if&lt;/span> &lt;span class="nx">p&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nf">SendLocalReply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">503&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;all providers failed&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>这行 503 我保留，而且认为它是对的——因为它的前提是&lt;strong>每个 provider 都真实尝试过且失败&lt;/strong>。端点级拉黑放行针对的是另一种情况：请求还没发出去，就被统计先验判死了。两者的区别一句话：&lt;strong>试出来的全失败可以 503，统计出来的全失败必须放行&lt;/strong>。&lt;/p>
&lt;blockquote>
&lt;p>这个设计不是我拍脑袋拍出来的。APISIX 的 &lt;code>ai-proxy-multi&lt;/code> 在实例健康过滤上做了完全相同的选择：所有实例均无健康节点时返回 &lt;code>{all_unhealthy=true}&lt;/code>，回退为&amp;quot;全量可用&amp;rdquo;，不直接 503。它的设计取舍表里写得很直白，理由是&amp;quot;避免误判把实例永久打死；宁可试错&amp;quot;。机制有差异——APISIX 的 fail-open 建立在 active health check 判死之上，我的拉黑来自被动耗时统计（5s 去重间隔 + 70s × 2~3 次确认）——但兜底语义一致，说明这是这一类系统共同交过学费换来的结论。&lt;/p>
&lt;/blockquote>
&lt;h2 id="误判从哪来">误判从哪来
&lt;/h2>&lt;hr>
&lt;p>放行的合理性取决于一个概率判断：全拉黑里有多少是真故障、多少是误判。把误判来源列全：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>误判来源&lt;/th>
&lt;th>机制&lt;/th>
&lt;th>为什么会被当成端点故障&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>网络抖动&lt;/td>
&lt;td>网关到 provider 的链路瞬断/丢包&lt;/td>
&lt;td>多端点往往共享同一条出口链路，抖动会&amp;quot;同时&amp;quot;打挂所有端点的统计——这恰恰说明故障在链路不在端点，逐端点拉黑是错误归因&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>provider GC 停顿 / 冷启动&lt;/td>
&lt;td>服务端扩容、发布、JVM GC 导致延迟飙升触发超时&lt;/td>
&lt;td>超时被计入失败样本，但停顿结束即恢复，端点本身没坏&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>样本量小&lt;/td>
&lt;td>低峰期单端点分钟级请求个位数&lt;/td>
&lt;td>两三个失败就能把失败率顶过阈值，统计噪声被放大成&amp;quot;故障&amp;quot;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5s 去重间隔的局限&lt;/td>
&lt;td>间隔内同端点慢样本去重计数，防单次抖动被放大&lt;/td>
&lt;td>但间隔一过同一场故障重新起算——30 秒的抖动能贡献约 6 个样本，足够攒满 2~3 次确认，去重防的是重复计数，防不了跨间隔的持续误判&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>两个分支分别看：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>真的大面积故障&lt;/strong>：屏蔽已经无意义。屏蔽的价值是&amp;quot;把流量从坏节点挪到好节点&amp;quot;，前提是还有健康节点可分流量。全坏时无处可挪，打了也白打——但不打更糟，不打就是主动放弃恢复窗口。&lt;/li>
&lt;li>&lt;strong>统计误判&lt;/strong>：放行是唯一的自愈通道，理由上面已经推演过，不重复。&lt;/li>
&lt;/ul>
&lt;p>有意思的是第二个观察：&lt;strong>全端点同时&amp;quot;故障&amp;quot;，大概率意味着问题不在端点层&lt;/strong>。真正每端点独立坏掉且坏的时间完全同步，本身就是小概率事件；同步失败更常见的解释是共享层出问题——出口链路、账号配额、provider 整体。这时候逐端点拉黑不但无用，还在用屏蔽逻辑制造第二层故障。&lt;/p>
&lt;h2 id="和经典断路器的-half-open-对照">和经典断路器的 half-open 对照
&lt;/h2>&lt;hr>
&lt;p>经典 circuit breaker 是三态机：closed → open（熔断）→ 冷却后进入 half-open → 放少量探测请求 → 成功则回到 closed。对照下来会发现，全拉黑放行相当于跳过 half-open，直接退化成&amp;quot;无熔断直通&amp;quot;：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>经典 half-open&lt;/th>
&lt;th>全拉黑放行&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>探测流量&lt;/td>
&lt;td>限量（如每周期 1 个请求）&lt;/td>
&lt;td>全量，所有请求都是探测&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>恢复判定&lt;/td>
&lt;td>探测成功 N 次才闭合&lt;/td>
&lt;td>TTL 到期惰性解封，真实流量重新判定&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>额外状态&lt;/td>
&lt;td>需要 per-endpoint 状态机 + 冷却计时器&lt;/td>
&lt;td>零，复用已有的滑动窗口&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>上游真死时的代价&lt;/td>
&lt;td>只有探测请求打到死节点&lt;/td>
&lt;td>所有请求都打到死节点，错误率/延迟全暴露&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>第三行是我选后者的现实原因：滑动窗口里已经有一份跨请求的失败统计了，再为 half-open 维护一套状态机和计时器是重复建设。但更本质的是前两行——&lt;strong>half-open 解决的问题是&amp;quot;探测风暴打死正在恢复的上游&amp;quot;，而 LLM 网关的场景里探测流量就是正常流量本身，不存在额外风暴&lt;/strong>；反过来，&amp;ldquo;探测不足导致恢复延迟&amp;quot;才是主要矛盾。&lt;/p>
&lt;p>还有一个领域差异值得记下：LLM 上游的故障很少是&amp;quot;瞬间全好或全坏&amp;rdquo;，更多是逐渐恢复、部分恢复（限流解除一部分、扩容落了一半实例）。half-open 的&amp;quot;限量探测 + N 次成功闭合&amp;quot;假设恢复是离散事件，跟这种渐变恢复对不上；全量放行 + 持续统计天然形成软探测，每一分钟都在试探上游，恢复到什么程度，流量就自然分过去多少。&lt;/p>
&lt;blockquote>
&lt;p>所以这里的结论是：不是 half-open 不好，是它的适用前提（探测流量需要被限制、恢复是离散事件）在这个场景不成立。设计模式照搬之前先核对前提，这个 case 给我上了一课。&lt;/p>
&lt;/blockquote>
&lt;h2 id="反思宁可慢不可全断">反思：宁可慢、不可全断
&lt;/h2>&lt;hr>
&lt;p>全拉黑放行是有代价的，我不打算把它说得像免费午餐：全拉黑期间客户端会真实感受到高延迟和错误率，因为请求确实在打坏节点。但这个代价是&lt;strong>有界的&lt;/strong>——上游一恢复，放行路径上的请求直接成功；拉黑的端点按各自 TTL 到期回池，最坏也只多等一个 TTL 周期。而 503 的代价是&lt;strong>无界的&lt;/strong>——恢复时刻被 &lt;code>blockUntil&lt;/code> 绑死，TTL 配多长就白瞎多久，还可能伴随客户端的 503 重试风暴。&lt;/p>
&lt;p>为网关类组件做设计时，我现在的判断标准就一条：&lt;strong>宁可慢，不可全断&lt;/strong>。慢是上游的病传导过来，断是网关自己在病上加了病。屏蔽、熔断、拉黑这些手段全都只有一个合法目标——在还有健康容量时保护性地挪流量；一旦健康容量归零，它们的使命就结束了，继续执行只会从保护变成伤害。&lt;/p>
&lt;p>还有一条线值得单独展开：流式（SSE）请求的取舍——响应头之前可以换端点重试，流中失败只能截断，APISIX 和我的方案在这做了相同的让步。这篇先不展开。&lt;/p>
&lt;hr>
&lt;blockquote>
&lt;p>一个还没想清楚的问题留在这里：放行分支要不要加一个&amp;quot;全拉黑持续时间&amp;quot;的告警钩子？放行是运行时自愈，但如果全拉黑状态持续几分钟不解除，多半说明真故障或者统计参数需要调了——这个信号现在只落在日志里，值不值得升格成独立指标，我还在犹豫。&lt;/p>
&lt;/blockquote></description></item><item><title>Higress 端点级断路器（一）：本地 Provider 的端点屏蔽与 TTL 控制</title><link>https://daidaij.github.io/p/higress-endpoint-breaker-ttl/</link><pubDate>Sun, 06 Sep 2026 19:59:22 +0800</pubDate><guid>https://daidaij.github.io/p/higress-endpoint-breaker-ttl/</guid><description>&lt;img src="https://picsum.photos/seed/bdbb9d5f/800/600" alt="Featured image of post Higress 端点级断路器（一）：本地 Provider 的端点屏蔽与 TTL 控制" />&lt;h1 id="higress-端点级断路器一本地-provider-的端点屏蔽与-ttl-控制">Higress 端点级断路器（一）：本地 Provider 的端点屏蔽与 TTL 控制
&lt;/h1>&lt;hr>
&lt;blockquote>
&lt;p>上游换成私有化部署的 LLM 端点之后，故障形态变了：不报 429、不吐 5xx，就是慢——请求挂在网关上几分钟才吐结果，用户早走了。这篇记录我基于 ai-load-balancer 做的一层端点级断路器：响应耗时持续突破阈值（2&lt;del>3 次确认）就把端点拉黑，TTL 30&lt;/del>60s 到期自动放回负载均衡池。整套设计里我最满意的是统计层的 5s 去重间隔——同一端点短时间内只计一次，既防高频请求重复统计，也配合确认次数把一次抖动挡在误判之外。&lt;/p>
&lt;/blockquote>
&lt;h2 id="动机慢节点拖尾是私有化部署的特有痛点">动机：慢节点拖尾是私有化部署的特有痛点
&lt;/h2>&lt;hr>
&lt;p>之前写过&lt;a class="link" href="https://daidaij.github.io/p/higress_llm_server_failover_in_router/" >一篇&lt;/a>处理 Higress 网关的端点故障屏蔽，针对的故障是&amp;quot;显式&amp;quot;的：429 限流、配额耗尽、5xx——响应码摆在那，数数就能判定。这次场景换成本地/私有化部署的 provider，玩法完全不同。&lt;/p>
&lt;p>私有化 LLM 端点的典型故障是&amp;quot;慢&amp;quot;：&lt;/p>
&lt;ul>
&lt;li>长上下文推理本身就可能跑几十秒，正常和异常的边界很模糊；&lt;/li>
&lt;li>某台机器显存碎片、批处理排队、GPU 降频，服务还活着，就是越来越慢；&lt;/li>
&lt;li>这些端点经 McpBridge 注册成 Envoy cluster（命名形如 &lt;code>outbound|443||provider.dns&lt;/code>），连接层完全正常。&lt;/li>
&lt;/ul>
&lt;p>麻烦在于平台现有的健康机制全都够不着这类故障。Envoy 原生的 active health check 和 outlier detection 是设计文档里定义的 Level 1 容错，它的局限写得很清楚：只在&amp;quot;节点全挂/被驱逐&amp;quot;时起作用，429 配额耗尽、单 provider 部分降级触发不了。慢节点比这更隐蔽——consecutive_5xx 数不出来，因为根本没有 5xx，只有漫长的等待。&lt;/p>
&lt;blockquote>
&lt;p>一台慢节点拖着不做处理，代价是拖尾：每个打到它的请求都白占一个网关连接槽位，挂到客户端超时为止。网关的连接池、下游的并发额度，都在替一台病机器买单。&lt;/p>
&lt;/blockquote>
&lt;h2 id="端点粒度-vs-路由粒度为什么不上现成的-failover">端点粒度 vs 路由粒度：为什么不上现成的 failover
&lt;/h2>&lt;hr>
&lt;p>第一反应是找现成能力。盘点下来三层，粒度从粗到细：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>层&lt;/th>
&lt;th>机制&lt;/th>
&lt;th>粒度&lt;/th>
&lt;th>为什么不够&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>平台层&lt;/td>
&lt;td>Envoy outlier detection / health check&lt;/td>
&lt;td>节点&lt;/td>
&lt;td>只认连接错误和 5xx，慢故障不可见&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>路由层&lt;/td>
&lt;td>ai-proxy 的 providers failover（failoverOnStatus）&lt;/td>
&lt;td>provider 整机&lt;/td>
&lt;td>切换粒度是整个 provider，且 failover 重发请求有 body 丢失的老 bug（issue #3531）&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>选端层&lt;/td>
&lt;td>★ 本设计的端点断路器&lt;/td>
&lt;td>单个端点&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>路由层整机 failover 不适用的根本原因是场景：私有化部署里一个 provider 后面挂的是自己机房的多台推理机，没有&amp;quot;备用 provider&amp;quot;可切。慢一台就把整个 provider 判死，等于自断服务。正确的粒度是把坏的那台端点摘出去，其余继续扛流量。&lt;/p>
&lt;p>选端层做这件事有个天然优势：判定发生在建立上游连接之前。ai-load-balancer 的三种 LB 策略都走同一条路——先拿候选端点集合，再指定 host 转发：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">GetUpstreamHosts() // fork 扩展：读 cluster 的端点与健康状态
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → 过滤屏蔽名单 // ← 断路器插在这里
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → SetUpstreamOverrideHost(host) // 指定端点转发
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>坏端点在选端阶段就被跳过，故障代价从&amp;quot;一次 70s 超时&amp;quot;降到&amp;quot;一次内存查询&amp;quot;。&lt;/p>
&lt;p>但选端层也有自己的代价，两条 wasm 侧的硬约束：&lt;/p>
&lt;ol>
&lt;li>&lt;code>GetUpstreamHosts&lt;/code> / &lt;code>SetUpstreamOverrideHost&lt;/code> 是 higress fork SDK 的扩展能力，不在标准 proxy-wasm ABI 里；&lt;/li>
&lt;li>&lt;code>SetUpstreamOverrideHost&lt;/code> 指定端点时&lt;strong>必须返回 &lt;code>HeaderStopIteration&lt;/code>&lt;/strong>，否则 override 不生效——这是 ai-load-balancer 三种 LB 策略验证过的统一姿势。&lt;/li>
&lt;/ol>
&lt;blockquote>
&lt;p>粒度选择本质上是&amp;quot;谁能看见故障&amp;quot;的问题。平台层只看得见连接，路由层只看得见 provider，只有选端层同时看得见端点和业务耗时。看得见，才管得了。&lt;/p>
&lt;/blockquote>
&lt;h2 id="统计层5s-去重窗口是整个设计的核心">统计层：5s 去重窗口是整个设计的核心
&lt;/h2>&lt;hr>
&lt;p>断路器需要统计每个端点的响应表现。最朴素的实现是每次上游响应都计入统计，但这里有个私有化场景特有的陷阱：&lt;strong>慢端点会同时拖住一批在途请求&lt;/strong>。&lt;/p>
&lt;p>推理请求耗时本来就长，网关上同一个端点常态挂着几十个在途请求。一旦它开始变慢，这批请求会在相近的时间窗内相继超时。不去重的话：&lt;/p>
&lt;ul>
&lt;li>一次慢抖动被记成 N 份&amp;quot;失败证据&amp;quot;，统计被同一事件重复灌水；&lt;/li>
&lt;li>屏蔽状态走 SharedData（CAS 写），计数爆炸直接放大写竞争；&lt;/li>
&lt;li>观测指标失真——看起来&amp;quot;失败了 40 次&amp;quot;，其实是一个慢节点拖住的一批受害者。&lt;/li>
&lt;/ul>
&lt;p>所以统计层加了去重间隔：&lt;strong>短时间内（5s）同一端点只计算一次&lt;/strong>。设计推演如下（伪代码，非最终实现）：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 伪代码：响应统计 + 去重 + 拉黑判定
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">dedupWindowMs&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">5000&lt;/span> &lt;span class="c1">// 5s 去重间隔
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">slowThreshold&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">70000&lt;/span> &lt;span class="c1">// 慢阈值 70s
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">confirmCount&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">2&lt;/span> &lt;span class="c1">// 确认次数阈值，2~3 次按场景调
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">blockTtlMs&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">45000&lt;/span> &lt;span class="c1">// 拉黑 TTL，30~60s 区间内取值
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">onUpstreamResponse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ep&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">costMs&lt;/span> &lt;span class="kt">int64&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">nowMs&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">st&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">stats&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">ep&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="nx">st&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">lastCountedAt&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="nx">dedupWindowMs&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="c1">// ← 间隔内已计过，这个响应不重复入账
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">st&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">lastCountedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">now&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">costMs&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="nx">slowThreshold&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">st&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">count&lt;/span>&lt;span class="o">++&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">st&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">count&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="nx">confirmCount&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">block&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ep&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="nx">blockTtlMs&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// 慢样本攒够确认次数，拉黑
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>去重带来的第二个收益是防抖动放大误判。没有窗口时，一次网络抖动产生的整批慢响应会被当成多次独立证据，叠加起来很容易越过判定边界；有窗口后，统计的时间分辨率被限制在 5s，抖动只能贡献一个样本，持续性的慢才会持续入账。顺带它还保证了确认次数之间的&lt;strong>最小采样间隔&lt;/strong>——2&lt;del>3 次确认至少跨过 5&lt;/del>10s 的观察期，&amp;ldquo;一次抖动&amp;quot;和&amp;quot;持续变慢&amp;quot;在这个尺度上被区分开。&lt;/p>
&lt;p>多 worker 的一致性问题也要面对。wasm 插件每个 Envoy worker 线程一个 VM，跨 worker 状态不互通——统计视角天然分片，这反而可以接受（每个 worker 是一个独立采样点）；但屏蔽名单必须全局一致，否则 A worker 拉黑了、B worker 还往坏端点上发请求。跨 VM 的写要走 &lt;code>Get/SetSharedData&lt;/code>（CAS），SDK 的语义是并发写同一 key 返回 &lt;code>ErrorStatusCasMismatch&lt;/code>，计数器/状态必须做 Get+Set 重试循环，不能假设一次成功。&lt;/p>
&lt;blockquote>
&lt;p>我考虑过把去重窗口也放 SharedData 做全局一致，后来放弃了：窗口状态是高频热路径，每次响应都 CAS 一轮，写竞争的代价超过收益。5s 窗口本身对精度不敏感，per-worker 各自维护，采样视角还更分散——这算是我为数不多主动选择&amp;quot;不一致&amp;quot;的地方。&lt;/p>
&lt;/blockquote>
&lt;h2 id="阈值与-ttl70s-慢阈值--23-次确认3060s-拉黑">阈值与 TTL：70s 慢阈值 × 2&lt;del>3 次确认，30&lt;/del>60s 拉黑
&lt;/h2>&lt;hr>
&lt;p>判定逻辑压成一句话：耗时突破 70s 的慢样本，按 5s 去重间隔计数，攒够 2~3 次确认就拉黑；拉黑不是永久的，TTL 到期自动恢复参与负载均衡。&lt;/p>
&lt;p>70s 这个数的来源是私有化场景的两头挤压：下限要高于正常推理的长尾——长上下文请求跑三四十秒是常态，阈值太低会把慢请求误判成慢节点；上限受交互式体验约束——LLM 场景用户对首字延迟的容忍就是几十秒的量级，一个响应要 70s 的端点，对用户来说和挂了没有区别。&lt;/p>
&lt;p>确认次数定在 2&lt;del>3 是第三个旋钮：只确认一次，一次长尾抖动（provider GC、瞬时批处理排队）就会误杀健康端点；要求太多次，慢节点又会在池里白吃好几分钟流量。2&lt;/del>3 次配合 5s 去重间隔，等效于要求&amp;quot;慢状态持续 10&lt;del>15s 以上&amp;quot;才动手——误杀率和反应速度都被框在了可接受区间。TTL 取 30&lt;/del>60s，则是在&amp;quot;别把暂时过载的节点锁太久&amp;quot;和&amp;quot;别让真坏节点频繁回来试&amp;quot;之间取的平衡。&lt;/p>
&lt;p>时间线长这样：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">T+0s 请求 A → 端点 E，开始推理
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">T+70s A 耗时突破 70s → E 记第 1 次慢失败（5s 间隔内首次，入账）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">T+71s+ 在途请求 B/C/D 相继超时 → 间隔内全部去重，不计数
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">T+75s+ 后续请求依旧超时 → 跨过间隔，记第 2 次
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → 慢样本攒到确认次数（2~3），E 拉黑，blockUntil = now + TTL(30~60s)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">TTL 内 选端时过滤屏蔽名单，E 被跳过
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">TTL 到期 E 惰性恢复，自动回到候选池
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>恢复机制我选了惰性恢复：不设定时器，选端时检查 &lt;code>blockUntil&lt;/code>，过期即视为恢复。这个思路和 Envoy outlier detection 的 &lt;code>base_ejection_time&lt;/code> 同构，但实现更轻——恢复完全被请求驱动，零后台开销。&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 伪代码：选端过滤
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">candidates&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">filterHealthy&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">getUpstreamHosts&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">candidates&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">rejectBlocked&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">candidates&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nf">nowMs&lt;/span>&lt;span class="p">())&lt;/span> &lt;span class="c1">// blockUntil 已过期的直接放回
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">if&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">candidates&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 不覆盖端点，交给 Envoy 默认选端 —— 下一篇的主题
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nf">setUpstreamOverrideHost&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">pick&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">candidates&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;blockquote>
&lt;p>TTL 恢复是个乐观假设：到期不等于健康。端点如果还在慢，打过去的请求重新开始攒慢样本，再花 2~3 次确认拉黑一轮——相当于用真实流量做探测，代价是每个 TTL 周期有一段糟糕的用户体验。要不要配主动健康检查（用最小模型探活提前解封），我还在权衡，至少当前流量规模下不值当。&lt;/p>
&lt;/blockquote>
&lt;h2 id="实现要点贴着-wasm-的约束走">实现要点：贴着 wasm 的约束走
&lt;/h2>&lt;hr>
&lt;p>骨架就是标准的 wasm-go 插件回调链：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">init&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">wrapper&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetCtx&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;endpoint-breaker&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">wrapper&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ParseConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parseConfig&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="c1">// fail-fast：非法配置直接 error
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">wrapper&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ProcessRequestHeaders&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">onReqHeaders&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="c1">// 读屏蔽名单 + 选端 override
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">wrapper&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ProcessResponseHeaders&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">onRespHeaders&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="c1">// 耗时统计的计时锚点
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>几个关键实现决策，每条背后都是 wasm 执行模型的一条硬约束：&lt;/p>
&lt;p>&lt;strong>回调里不做任何阻塞动作。&lt;/strong> wasm 回调跑在 Envoy worker 线程上，同步做耗时操作会卡住该 worker 的所有请求；wasm 目标没有真线程，goroutine 不可用。好在断路器的判定全是内存操作——读名单、比对时间戳、CAS 写回，没有一处需要异步。&lt;/p>
&lt;p>&lt;strong>屏蔽写回走 CAS 重试。&lt;/strong> SDK 的 SharedData 语义决定了并发写必须循环重试：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;span class="lnt">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 伪代码：SharedData CAS 写回（SDK 语义：冲突返回 ErrorStatusCasMismatch）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="nx">maxRetry&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cas&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">proxywasm&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">GetSharedData&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">updated&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">mergeBlock&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ep&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">blockUntil&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">proxywasm&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetSharedData&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">updated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cas&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">break&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// cas mismatch → 别的 worker 先写了，重读重算再试
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;strong>fail-open 兜底。&lt;/strong> wasm 框架对插件 panic 的兜底语义是 fail-open：wrapper 的 &lt;code>recoverFunc&lt;/code> 捕获 panic 后，回调以零值 &lt;code>ActionContinue&lt;/code> 返回，请求继续正常处理。这对断路器是理想行为——屏蔽逻辑自身崩溃，最坏结果是&amp;quot;不屏蔽&amp;rdquo;，绝不会挡流量。推论是故障完全静默，必须靠日志关键词告警兜住，调试期可以用 &lt;code>WASM_DISABLE_PANIC_RECOVERY=true&lt;/code> 关掉兜底看原始栈。&lt;/p>
&lt;p>&lt;strong>共存纪律。&lt;/strong> &lt;code>SetUpstreamOverrideHost&lt;/code> 一个请求只允许一个策略，断路器和其他 LB 类插件不能同挂；SharedData key 加插件前缀，防互踩。&lt;/p>
&lt;blockquote>
&lt;p>参考实现可以直接看仓库里两个现成插件：ai-load-balancer 的 &lt;code>global_least_request/lb_policy.go&lt;/code>（端点健康读取 + 指定 host 转发的完整姿势）、ai-proxy 的 &lt;code>provider/failover.go&lt;/code>（SharedData 健康状态机 + 探测恢复）。我的屏蔽名单状态机基本是后者换了判定信号。&lt;/p>
&lt;/blockquote>
&lt;h2 id="反思这套设计粗糙但够用的边界在哪">反思：这套设计粗糙但够用的边界在哪
&lt;/h2>&lt;hr>
&lt;p>坦率说，这是一个&amp;quot;单实例自洽、多副本将就&amp;quot;的设计，边界我列清楚：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>阈值是刀切的。&lt;/strong> 70s 对长上下文模型可能太严（正常推理就被拉黑），对轻量模型可能太松（慢节点拖 60s 依然在池里）。按 model 维度配差异化阈值是第一优先级的改进，配置结构上 &lt;code>_rules_&lt;/code> 路由级覆盖直接可用。&lt;/li>
&lt;li>&lt;strong>SharedData 只覆盖单实例。&lt;/strong> 多副本网关部署时，每个副本的屏蔽状态是割裂的，全局一致要走 Redis（key 加插件前缀、&lt;code>{cluster}&lt;/code> hash tag 防 CROSSSLOT）。当前单副本部署下这不是问题，扩副本前必须补。&lt;/li>
&lt;li>&lt;strong>耗时统计的口径很粗。&lt;/strong> 只看单次响应耗时，没有分位数、没有趋势。更精细的做法是滑动窗口内记录 P95，但那会引入事件序列存储——上一篇的设计里已经有窗口上限 20 条的先例，代价是 CAS 竞争显著加剧，我不确定值得。&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>下一篇写拉黑策略的补丁：&lt;strong>全拉黑放行&lt;/strong>——候选集被屏蔽名单清空时，屏蔽逻辑主动退位，把控制权还给 Envoy 默认选端。宁可疑似的端点接到流量，也不主动拒绝请求，这个取舍值得单独一篇展开。&lt;/p>
&lt;blockquote>
&lt;p>断路器做到最后，我体会到的核心不是&amp;quot;怎么拦&amp;quot;，而是&amp;quot;什么时候不拦&amp;quot;——屏蔽判定越激进，兜底路径就越重要。一个只在候选集非空时才起作用的断路器，看起来功能打折，实际上是所有流量的安全带。&lt;/p>
&lt;/blockquote></description></item></channel></rss>