<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="/rss/rss-styles.xsl"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Lymerin</title>
    <description>Lymerin 的个人技术博客，记录 Java 后端、开源贡献与学习笔记。</description>
    <link>https://lymerinblog.z7.web.core.windows.net</link>
    <language>zh-CN</language>
    <managingEditor>Lymerin</managingEditor>
    <webMaster>Lymerin</webMaster>
    <author>Lymerin</author>
    <pubDate>2026-10-01T08:39:59.683Z</pubDate>
    <lastBuildDate>2026-10-01T08:39:59.683Z</lastBuildDate>
    <generator>Astro Litos Theme</generator>
    <atom:link href="https://lymerinblog.z7.web.core.windows.net/rss.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>Issue 已经修好了，为什么还要补一个 PR：BrowserSkill 后台输入的状态加固</title>
      <link>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/browserskill-pr311</link>
      <guid>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/browserskill-pr311</guid>
      <updated>2026-10-01T07:47:25.000Z</updated>
      <pubDate>2026-10-01T07:47:25.000Z</pubDate>
      <description><![CDATA[原本想修一个后台点击失效的 Issue，却发现它早已解决。继续读代码后，我追到了临时输入唤醒与持久后台 lease 的交互边界，也在 Review 和故障注入中重新理解了状态所有权。]]></description>
      <content:encoded><![CDATA[
<p>这次贡献，是从一个已经修好的 Issue 开始的。</p>
<p>我最初在 Tencent BrowserSkill 的 Issue 列表里看到 <a href="https://github.com/Tencent/BrowserSkill/issues/242" rel="noopener noreferrer" target="_blank">#242</a>：后台执行点击时，CLI 返回成功，页面却没有收到任何点击事件。对自动操作浏览器的 Agent 来说，这比直接报错更麻烦——它以为自己已经点到了，可能就会沿着这个错误的前提继续操作。</p>
<p>我本来准备从这里入手。但查看当前 <code>main</code> 后发现，原问题已经由 <a href="https://github.com/Tencent/BrowserSkill/pull/261" rel="noopener noreferrer" target="_blank">PR #261</a> 修复了。</p>
<p>按以前的想法，我可能会换一个还没解决的 Issue。这次我又往下读了一些：当初为什么这样修？现在的命令还是沿着同一条路径执行吗？结果发现，项目还引入了另一套持久后台运行机制，两层逻辑都会控制同一个浏览器状态，却没有完全对齐彼此的所有权。</p>
<p>最后，这段排查变成了 <a href="https://github.com/Tencent/BrowserSkill/pull/311" rel="noopener noreferrer" target="_blank">PR #311</a>。它不是重新修一遍 #242，而是为已有修复补上一层组合场景下的保护。这个过程里，我第一次比较具体地体会到：<strong>一个 Issue 标记为已解决，不代表围绕它的设计就不需要继续理解了。</strong></p>
<h2>已修复的 Issue，也值得先弄清楚它为什么被修好</h2>
<p><a href="https://github.com/Tencent/BrowserSkill" rel="noopener noreferrer" target="_blank">BrowserSkill</a> 让 AI Agent 通过 CLI 和浏览器扩展操作真实浏览器。这里讨论的后台输入，是让 Agent 在不抢走用户窗口焦点的情况下，仍能完成页面交互。</p>
<p>Issue #242 报告的是 0.2.1：使用 <code>bsk session start --no-focus</code> 启动后台 Agent Window 后，再执行点击，CLI 看起来成功了，坐标也正确，但页面没有收到 <code>pointerdown</code>、<code>mousedown</code>、<code>mouseup</code> 或 <code>click</code>。</p>
<p>问题在于，CDP 接受输入命令，并不等于页面真的处理了输入。原来的链路缺少对页面输入状态的确认。</p>
<p>后来 #261 加入了 <code>withInputReady()</code>。它先读取 <code>document.visibilityState</code>；如果页面是 <code>hidden</code>，就临时打开 <code>Emulation.setFocusEmulationEnabled(true)</code>，等待渲染端准备好，再发送输入，最后清理这次临时开启的状态。</p>
<p>这套输入 readiness 逻辑已经包含在 0.3.0 中，维护者也确认了原问题的修复。因此，我需要先把自己的判断摆正：<strong>接下来发现的问题，不能直接说成“#242 没有修好”。</strong></p>
<h2>临时唤醒和持久运行，不能各自管理同一个状态</h2>
<p>继续看调用链时，我注意到了 <a href="https://github.com/Tencent/BrowserSkill/pull/249" rel="noopener noreferrer" target="_blank">PR #249</a> 引入的 <code>BackgroundExecution</code>。</p>
<p>它处理的是另一个需求：后台页面加载完成后，一些依赖可见性或 <code>requestAnimationFrame</code> 的逻辑仍可能停住。于是，当前 Session 控制的 tab 会获得一份持久后台执行 lease，让相关 override 跨命令保持生效，直到释放控制时再清理。</p>
<p>这里的 lease，可以先理解成“这个 Session 持有这项后台运行状态的控制权”。它与 <code>withInputReady()</code> 的生命周期不同：一层只负责一次输入，另一层需要维持多个命令之间的状态。</p>
<p>真实的工具执行也不是直接进入 click handler。<code>ToolDispatcher</code> 会先准备后台执行、获取持久 lease，再进入 handler 和 <code>withInputReady()</code>。</p>
<p>两套机制各自解决的问题都合理。但放在同一条链路里，就需要回答一个之前没有说清楚的问题：<strong>这个 focus emulation 状态，究竟由谁来关闭？</strong></p>
<h2>缓存还记着 ON，浏览器却已经变成 OFF</h2>
<p>当时 <code>withInputReady()</code> 并不知道当前 Session 是否已经持有 persistent lease。如果 lease 已被持有，页面却仍报告 <code>hidden</code>，它就可能继续进入临时唤醒路径。</p>
<p>先发一次 <code>true</code>，完成输入，再在 <code>finally</code> 里发一次 <code>false</code>。对临时逻辑而言，这像是正常收尾；对持久机制而言，却相当于别人把它负责维持的状态关掉了。</p>
<p>更麻烦的是，临时逻辑直接通过 CDP 修改状态，没有同步更新 <code>BackgroundExecution</code> 的 applied-state cache。于是控制器内部仍记录着“应该开启，也已经开启”，Chrome 里的实际 override 却已经关闭。</p>
<figure class="not-prose">
<figcaption>图 1 · 一次临时清理，让控制器记录与浏览器实际状态分开了</figcaption>
<div>

持久状态与临时清理的交互
获取持久 lease 后，控制器记录和浏览器实际 override 都为开启。在页面仍隐藏的边界状态下，临时 readiness 的清理关闭 override，却没有更新持久控制器的缓存。之后控制器可能跳过重新应用，导致状态持续不一致。






获取 persistent lease
控制器记录 ON · Chrome override ON

页面仍 hidden，进入临时 readiness
再次开启 → 等待就绪 → 执行输入

临时 finally 发送 OFF
没有更新持久控制器的缓存

BackgroundExecution：ON
desired / applied 记录仍一致

Chrome override：OFF
实际状态已被临时清理关闭
之后 synchronize() 可能依据缓存跳过重新应用

</div>
<div>这里展示 lease 已持有、页面仍 hidden 的边界路径，并非每次后台点击都会发生。小屏可在图内左右滑动。</div>
</figure>
<p>后续 <code>ensureAttached()</code> 触发 <code>BackgroundExecution.synchronize()</code> 时，它还可能根据缓存与 attachment 的匹配情况提前返回，认为不需要重新应用 override。这样，状态失配就不只是一次临时关闭，还可能影响后面的命令。</p>
<p>不过，读到这里还不能把它说成所有后台页面都会发生的故障。<a href="https://github.com/Tencent/BrowserSkill/pull/311" rel="noopener noreferrer" target="_blank">Review</a> 特别提醒了这个边界：维护者在 macOS 的 Chrome 153 上观察到，focus emulation 开启后，页面会同步变为 <code>visible</code>，最小化窗口也一样。在这个正常路径上，临时 fallback 根本不会被触发。</p>
<p>我需要保护的，是 <strong>lease 已被持有，页面却仍然 <code>hidden</code></strong> 的不一致状态。把这一点说清楚，也让我没有把“代码里存在的交互缺口”直接等同于“每个平台都能自然复现”。</p>
<h2>持有控制权，不代表页面现在已经准备好</h2>
<p>我最先想到的办法，是让输入层知道持久 lease 的存在。因此新增了一个只读查询：</p>
<pre><code>ownsBackgroundExecution(sessionId, tabId)
</code></pre>
<p>最初我的思路很直接：既然后台执行已经有人负责，<code>withInputReady()</code> 就不要再临时开关 focus emulation，直接发送输入。</p>
<p>这确实避开了两个 owner 互相覆盖的问题，却把另一层保护一起绕过了。Review 指出：<strong>“已经持有 lease，但页面仍然 hidden”恰恰是不能放心发送输入的时候。</strong> 如果不再检查实际可见性，就可能重新回到“命令成功，页面没处理”的状态。</p>
<p>所以最终实现仍然读取 <code>document.visibilityState</code>。ownership 查询回答的是“这个 Session 是否请求持有这份持久 override”，不是“Chrome 此刻一定已经应用了它”。</p>
<p>我原来很容易把这两件事混在一起：有人负责，就应该已经生效。但这次它们之间的差别，正好决定了能不能继续发送输入。</p>
<h2>状态不确定时，明确失败比假装点到了更重要</h2>
<p>最后的处理规则分成了两个维度：先区分是否持有持久 lease，再检查页面是否 <code>hidden</code>。</p>
<p>持有 lease 且页面 <code>visible</code> 时，可以直接进入输入操作，不做临时 focus toggle，也不额外走 readiness screenshot。持有 lease 却仍 <code>hidden</code> 时，则在发送输入前返回明确错误，并且不由输入层私自修改持久状态。</p>
<p>没有持久 lease 的路径，继续保留原来的行为：页面可见就正常输入；页面隐藏时，由 <code>withInputReady()</code> 完成有界的临时唤醒、渲染就绪检查、输入和清理。</p>
<figure class="not-prose">
<figcaption>图 2 · 先确认谁负责状态，再决定能不能发送输入</figcaption>
<div>

PR 311 的输入就绪处理规则
持有 lease 和没有 lease 的页面都检查 visibility。两者在 visible 时都正常输入；持有 lease 但 hidden 时不发送输入、不修改 focus emulation，返回 input_not_ready。没有 lease 且 hidden 时保留临时唤醒、渲染就绪检查、输入和清理路径。本图对应 PR 311 的实现，不包含后续自动恢复机制。








是否持有 persistent lease？

持有

未持有

仍然读取 visibilityState

读取 visibilityState

visible

hidden

visible

hidden

正常输入
不临时开关
持久 override

明确拒绝输入
input_not_ready
effect_state: none
不输入 · 不开关 focus

正常输入
不需要临时唤醒

临时唤醒
ON → 渲染就绪
→ 输入
最后清理临时状态

</div>
<div>图中规则对应 #311，不包含后续自动恢复。小屏可在图内左右滑动。</div>
</figure>
<p>异常分支返回的结果是：</p>
<pre><code>{
  "code": "cdp_failed",
  "data": {
    "reason": "input_not_ready",
    "effect_state": "none"
  }
}
</code></pre>
<p>这里的 <code>effect_state: none</code> 指的是这次输入没有发送出去，而不是说整个工具调用没有做过任何准备工作。</p>
<p>这次选择的是 fail closed：无法确认页面准备好时，先明确拒绝输入。对于会根据结果继续行动的 Agent，我觉得“没有点到，而且清楚告诉你没有点到”，比返回一个不可靠的成功更有价值。</p>
<h2>组合问题，要沿着真实执行链路去测试</h2>
<p>修复时还有一个让我印象比较深的地方：直接测 click handler，并不一定能测到这个问题。</p>
<p>handler 级测试可以覆盖 <code>withInputReady()</code>，但如果绕过 <code>ToolDispatcher</code>，前面就没有获取 persistent lease 的步骤。这样测到的，是临时输入逻辑本身，而不是它和持久后台机制相遇后的行为。</p>
<figure class="not-prose">
<figcaption>图 3 · 同样进入 handler，前面有没有 lease，测到的是不同的场景</figcaption>
<div>

Handler 与 Dispatcher 测试链路的区别
没有先获取 lease 的直接 handler 测试，覆盖的是独立输入 readiness 路径；Dispatcher 级测试先准备后台执行、获取 lease，再进入 handler 和 readiness，因此能覆盖两个机制的组合边界。

直接调用 handler，未预先获取 lease
这次补充的 Dispatcher 级回归






ToolDispatcher
准备后台执行，先获取持久 lease

handleClick()

handleClick()

withInputReady()

withInputReady()

覆盖独立 readiness 路径

覆盖 lease 与输入的组合边界

</div>
<div>区别不在于测试名称，而在于是否真正包含“先获取 lease，再进入输入层”的前置状态。</div>
</figure>
<p>这次补充的 Dispatcher 回归测试，先确认获取 lease、查询 ownership、读取可见性、发送输入的顺序。随后把页面状态改为 <code>hidden</code>，验证返回 <code>input_not_ready</code>，既不发送 <code>Input.dispatchMouseEvent</code>，也不发送临时 focus 命令。再把可见性改回 <code>visible</code>，下一次点击应当能够正常执行。</p>
<p>这组测试检查的是控制流和组件协作，不是让真实浏览器凭空恢复一个已经丢失的 override。把这两种验证分开，我才更清楚每个测试究竟在证明什么。</p>
<h2>故障注入验证的，是安全失败，而不是自动恢复</h2>
<p>Review 还给了一个很具体的验证建议：在接近原 Issue 的环境里，人为制造一次“lease 还在，实际 override 却丢了”的状态。</p>
<p>我最后在 Windows 11、Chrome for Testing 153、DPR 1.5 的环境中启动 <code>--no-focus</code> Session，最小化 Agent Window，再从扩展 Service Worker 主动关闭 focus emulation。</p>
<p>这时，Session 仍持有 lease，浏览器里的 override 已关闭，页面报告 <code>hidden</code>。执行 <code>bsk click</code> 后，返回了 <code>input_not_ready</code> 和 <code>effect_state: none</code>，页面点击计数保持为零。</p>
<p>它没有恢复点击。这个结果乍看像是“还是没点到”，但对 #311 来说，区别已经很明确：以前可能返回一个假的成功，现在会在输入前拒绝，并告诉调用方页面还没准备好。</p>
<p>当时我把自动恢复拆到了 <a href="https://github.com/Tencent/BrowserSkill/issues/355" rel="noopener noreferrer" target="_blank">Issue #355</a>。先守住“不确定时不要报告成功”的边界，再讨论由持久状态的管理者恢复 override，而不是让输入层重新越权处理。</p>
<p>补充到写下这篇文章时：恢复丢失 lease 后再尝试隐藏页面输入的后续改动，已通过 <a href="https://github.com/Tencent/BrowserSkill/pull/374" rel="noopener noreferrer" target="_blank">PR #374</a> 合并。这里记录的规则和故障注入结果，仍然对应我这次 #311 的范围，而不是项目最新版本的全部行为。</p>
<h2>这次让我学会，在 Fixed 后面多问一步</h2>
<p>#311 对我比较特别，是因为我最开始并不是冲着这个改动去的。我只是想找一个自己能处理的 Issue，结果先发现它已经解决，再一点点读到两个状态管理层之间的边界。</p>
<p>以前我更习惯从明确的问题描述开始：找到原因，写修复，等待 Review。这次让我意识到，已有修复同样有值得学习的地方。理解它为什么成立，才能看见项目继续演化之后，哪些前提需要重新确认。</p>
<p>Review 也让我放下了一个很自然、却不够严谨的想法：“既然有 lease，就说明状态已经准备好了。”现在再看到类似代码，我会更愿意分开想：谁声明负责这个状态，谁实际修改它，底层现在又是什么样子。</p>
<p>回头看，这次最具体的收获，是我第一次把 ownership、缓存记录和浏览器实际状态之间的差别，顺着代码、测试和一次故障注入连了起来。</p>
<p>以后再看到一个已修复的 Issue，我想自己会多问一句：</p>
<blockquote><p>这个修复放到项目现在的设计里，它依赖的假设还成立吗？</p></blockquote>
<p>有时候，继续把这个问题弄明白，本身就能成为下一次贡献的起点。</p>]]></content:encoded>
      <author>Lymerin</author>
      <category>BrowserSkill</category><category>TypeScript</category><category>CDP</category><category>浏览器自动化</category><category>状态一致性</category>
    </item>
    <item>
      <title>一个 DELETE 为什么改了 31 个文件：Apache ShenYu 删除链路与一次方案澄清</title>
      <link>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr7289</link>
      <guid>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr7289</guid>
      <updated>2026-10-01T06:50:28.000Z</updated>
      <pubDate>2026-10-01T06:50:28.000Z</pubDate>
      <description><![CDATA[从 discovery upstream 缓存残留，到第一次面对方案级质疑：记录我如何追查删除链路、对照另一个 PR，并用事件来源和测试讲清 instance 与 selector 的边界。]]></description>
      <content:encoded><![CDATA[
<p>刚接下 <a href="https://github.com/apache/shenyu/issues/6479" rel="noopener noreferrer" target="_blank">Issue #6479</a> 时，我以为自己要补的，只是一次遗漏的缓存删除。</p>
<p>Admin 删除了一个绑定服务发现的 selector，Gateway 侧却还留着它的 discovery upstream 状态。看上去，无非是找到少写的那个 <code>remove()</code>，再补一个测试。</p>
<p>但顺着代码往下追，我发现这个“删除”要经过 Admin、同步模块、Subscriber，再交给具体插件。每一层都能收到消息，不代表最后真的有人把状态清掉；不同插件用来找缓存的 key，也不一定是同一个。</p>
<p>最后，<a href="https://github.com/apache/shenyu/pull/7289" rel="noopener noreferrer" target="_blank">PR #7289</a> 改了 31 个文件。可现在回头看，我最记得的反而不是改动范围，而是后来的一次 Review：维护者认为这条删除链路的设计可能不对，建议重新按快照同步来处理。</p>
<p>那一刻我确实有点慌。我还没有遇到过这种针对整个方案的质疑，第一反应就是：是不是我从一开始就理解错了？后来我重新读另一个 PR、追事件来源，还反复跑了很多遍测试。即使越来越觉得问题出在事件范围的理解上，我也没有马上就敢回应。这篇想记录的，是我怎么一边担心自己漏了什么，一边继续查证，最后才鼓起勇气把自己的判断讲清楚。</p>
<h2>收到了删除事件，不代表状态真的被清掉了</h2>
<p>先说这次要解决的场景。这里的 selector 可以理解为一条选择请求、关联后端服务的配置；discovery upstream 则是它通过服务发现得到的后端实例信息。Admin 侧解绑或删除相关配置后，Gateway 不应该继续保留这份运行时状态。</p>
<p>我一开始只盯着同步入口，后来才把整条调用链连起来看。</p>
<p>在最终对照的代码里，path-based sync 已经能从删除节点的 path 里拿到 <code>pluginName</code> 和 <code>selectorId</code>，并调用取消订阅。更明确的断点在后面：<code>CommonDiscoveryUpstreamDataSubscriber#unSubscribe()</code> 原来只有一行 <code>//ignore</code>。</p>
<p>也就是说，删除消息可以传到 Subscriber，却没有继续交给插件清理。HTTP 全量同步还有另一种遗漏：最新快照里不再出现的 selector，也需要被识别出来，而不能只处理这一次还存在的记录。</p>
<p>我这才意识到，排查这类问题不能只问“事件有没有到”。还要继续看：<strong>到了以后，谁负责删？删的是哪一份状态？</strong></p>
<p>这次沿用了已有的 Subscriber → Handler 分发结构，把 selector 级别的 discovery upstream 删除继续传到拥有状态的插件。没有让同步层去判断“这是 Divide 就删这个，是 gRPC 就删那个”。</p>
<figure class="not-prose">
<figcaption>图 1 · 删除消息走到插件，才真正落到状态清理</figcaption>
<div>

selector 级删除责任链
Admin 解绑 selector 级 discovery，发布已有的 DISCOVER_UPSTREAM DELETE。同步模块将资源身份传给 Subscriber，由 Common Subscriber 根据插件名分发给 Handler。Divide 和 WebSocket 清 upstream 缓存，gRPC 清缓存及客户端，TCP 清 upstream 状态并处理 ID 与名称的映射。







Admin 解绑 selector 级 discovery
发布已有的 DISCOVER_UPSTREAM DELETE

同步模块传递删除身份
DiscoveryUpstreamKey

Subscriber.unSubscribe(key)
Common Subscriber 按 pluginName 分发

Handler.removeDiscoveryUpstreamData(key)

Divide / WebSocket
清 upstream 缓存

gRPC
清缓存与客户端

TCP
清 upstream 与身份映射

</div>
<div>图中是 selector 级 discovery 删除，不是单个实例下线。小屏可在图内左右滑动。</div>
</figure>
<p>Divide、WebSocket 主要清理 <code>UpstreamCacheManager</code>；gRPC 还涉及 <code>ApplicationConfigCache</code> 和 <code>GrpcClientCache</code>；TCP 又有自己的 upstream 状态和名称映射。同步层负责把“谁消失了”传清楚，具体怎么清，还是交给各自的 Handler。</p>
<p>改动范围就是这样一点点变大的。补了接口，就要跟着改调用方和插件实现，再把对应测试补上。我原来以为只要找一个遗漏的删除，最后才发现，得沿着整条责任链把它接起来。</p>
<h2>删除需要的是身份，不是一份填不全的数据</h2>
<p>原来的接口是 <code>unSubscribe(DiscoverySyncData data)</code>。可真正删除时，需要的往往只是 <code>pluginName</code>、<code>selectorId</code>，以及部分插件要用的 <code>selectorName</code>。</p>
<p><code>DiscoverySyncData</code> 表达的是一份同步数据。为了删一个资源，却要构造一个很多字段都为空的 DTO，我写着写着就觉得不太顺：到底是在传一份数据，还是只想告诉对方“删掉谁”？</p>
<p>所以这次引入了 <code>DiscoveryUpstreamKey</code>，专门表达删除身份：</p>
<pre><code>public record DiscoveryUpstreamKey(
        String pluginName,
        String selectorId,
        String selectorName) {
}
</code></pre>
<p>这是字段结构的摘录，省略了从同步数据提取 key 的方法。<code>selectorName</code> 可以为空，具体 Handler 再根据自己保存的状态解析。</p>
<p>这也让我第一次比较具体地理解了“接口语义”。不只是给类起一个更好听的名字，而是让调用方不用拿“更新内容”来勉强表达“删除身份”。这个方向后来也得到了 Reviewer 的认可。</p>
<h3>TCP 让我多追了一步：收到的 key 和保存的 key 一样吗？</h3>
<p>TCP 的问题不在删除方法本身有多复杂，而是同步事件主要带着 <code>selectorId</code>，部分 upstream 状态却按 <code>selectorName</code> 保存。</p>
<p>拿到 ID，不代表就能找到按名称存的缓存。于是我补了 <code>selectorId → selectorName</code> 的映射；删除时先查本地映射，找不到再用 key 里带的名称。</p>
<p>这份映射也不能只等普通 selector event 来建立。Gateway 重启后，discovery upstream 数据可能先恢复，所以 <code>TcpUpstreamDataHandler</code> 处理这份数据、确认缓存存在时，也会注册映射。清理 upstream 时，再把对应映射一起移除。</p>
<p>如果我只看 <code>removeDiscoveryUpstreamData()</code> 的几行实现，很容易以为删除已经完整了。继续问“这个 key 从哪里来，重启以后还找不找得到”，才会看到另一个问题。</p>
<h2>同样叫同步，删除信息却不一定长得一样</h2>
<p>沿着各条同步路径排查时，我发现不能要求它们都带着一份完整的“删除数据”。节点都已经删了，payload 很可能也不在了。</p>
<p>ZooKeeper 的 <code>NODE_DELETED</code> 事件里，<code>newData</code> 可以是 <code>null</code>，需要从 <code>oldData</code> 取得原来的 path。项目里已有这层处理，这次我补了 discovery upstream 的回归测试，确认即使没有新 payload，仍能根据旧节点路径传出正确的删除身份。</p>
<p>path-based sync 可以从 <code>.../discoveryUpstream/&lt;plugin&gt;/&lt;selectorId&gt;</code> 的末尾两段恢复身份；node-based sync 则从对应的节点 key 解析。Nacos、etcd、Consul、Polaris、Apollo 也沿用这些共享处理路径，不是每个协议都要再写一套独立的插件清理逻辑。</p>
<p>HTTP 更不同：它拿到的是全量快照，不会为每个消失的 selector 另发一次 DELETE。因此 <code>DiscoveryUpstreamDataRefresh</code> 要保存上一份身份快照，再和当前快照比较。</p>





















<table><thead><tr><th>快照变化</th><th>要处理的状态</th></tr></thead><tbody><tr><td><code>[S1, S2] → [S2]</code></td><td>取消订阅 S1，保留并更新 S2</td></tr><tr><td><code>[S1] → []</code></td><td>取消订阅原来的 S1</td></tr><tr><td>同一 ID，<code>old-name → new-name</code></td><td>先清旧名称对应的状态，再订阅新数据</td></tr></tbody></table>
<p>HTTP 比较用的身份包含 namespace、plugin 和 selector ID，不只是一个裸 ID。名称变化也要单独检查，因为 TCP 的旧名称可能仍然对应着旧缓存。</p>
<p>这些情况最后都收敛到 <code>unSubscribe(DiscoveryUpstreamKey)</code>。我慢慢理解了：同步协议可以用不同方式告诉我“它不在了”，但到了 Subscriber，删除的对象和责任必须明确。</p>
<h2>先弄清楚删的是谁，才能讨论该走 UPDATE 还是 DELETE</h2>
<p>我原来以为，这个 PR 最费劲的部分会是跨模块修改。后来维护者拿它和 <a href="https://github.com/apache/shenyu/pull/7172" rel="noopener noreferrer" target="_blank">PR #7172</a> 对照，提出了一个更根本的问题：discovery upstream 应该用完整快照同步，删除一个实例后重新发布剩余列表，为什么还要补一条 DELETE 链路？</p>
<p>这个担心是有道理的。实例从 <code>[A, B]</code> 变成 <code>[B]</code>，应该发布剩余实例的 UPDATE 快照；最后一个实例没了，也应该是 <code>UPDATE []</code>，而不是把整个 selector 当成不存在。</p>
<p>但我当时最难受的，是这不再是“这里少一个判断”或者“补一个测试”。如果判断成立，前面连起来的整条链路都可能需要重新设计。</p>
<p>我第一反应没有去反驳，而是先想：会不会真的是我把删除语义理解错了？可同时又有一点说不上来的疑问：#7172 和 #7289，删的好像不是同一种东西。</p>
<p>于是我没有立刻照着建议改代码，而是重新去找两个事件的生产者。</p>
<p>#7172 讨论的是 selector 内部的实例变化：Registry 的 <code>ADDED</code>、<code>UPDATED</code>、<code>DELETED</code> 先在 Admin 更新数据库，再查询完整剩余列表，发布 <code>DISCOVER_UPSTREAM UPDATE</code>。这是我认同的实例级快照模型。</p>
<p>而 #7289 接住的，是项目里<strong>原本就存在的 selector 级 discovery 删除事件</strong>：<code>SelectorServiceImpl#unbindDiscovery</code> 调用 <code>DiscoveryProcessor#removeSelectorUpstream</code>，发布 <code>DISCOVER_UPSTREAM DELETE</code>。这次没有把 Registry 的单实例删除改成 DELETE，也没有替换已有的 <code>SELECTOR DELETE</code> 或 <code>PROXY_SELECTOR DELETE</code>。</p>
<p>把两个场景放在一起以后，我才有把握说：我们当时讨论的是两个不同的生命周期对象。</p>
<figure class="not-prose">
<figcaption>图 2 · 两边都有“删除”，但消失的不是同一种对象</figcaption>
<div>

instance 与 selector 生命周期对比
左侧是 PR 7172 的实例级快照方案：selector S1 仍然存在，实例 A 消失后发布剩余列表 B，最后一个实例消失时发布空列表。右侧是 PR 7289：selector 级 discovery 记录移除，已有 DELETE 事件或 HTTP 快照差异触发取消订阅，清理该记录的运行时状态。

#7172 · instance lifecycle
#7289 · selector lifecycle





S1 还在，实例发生变化
[A, B] → [B] / [A] → []

S1 的 discovery 记录移除
解绑，或从 HTTP 快照中消失

Admin 发布完整 UPDATE
剩余实例列表可以为空

已有 DELETE / HTTP 差异
识别被移除的 selector 身份

onSubscribe(snapshot)
按新快照更新实例状态

unSubscribe(key)
插件清理这份 discovery 状态

有记录，但当前没有实例
S1: [] 不是 S1 消失

当前快照已没有这份记录
[S1, S2] → [S2]

</div>
<div>左边是 #7172 的方案模型，不代表该 PR 已合并；两边关注不同层级的状态。</div>
</figure>
<p>最容易混淆的恰好是那个 <code>[]</code>。<code>S1: []</code> 表示 S1 的 discovery 记录还在，只是没有实例；<code>[S1] → []</code> 表示当前 discovery 快照连 S1 这份记录都没有了。外观看起来都是“空了”，后续行为却不能一样。</p>
<p>我也在回复里把边界补清楚：WebSocket 的 MYSELF / REFRESH 重连对账是另一个问题，这个 PR 处理 DELETE，并没有顺便实现一套新的重连 reconciliation 算法。</p>
<h2>把判断查清楚，才有勇气把话说出来</h2>
<p>找到两个生命周期的差别以后，我并没有立刻就有底气去回应。对方比我熟悉项目，而我还是一个学生。我很担心：会不会只是自己读到的那几段代码能对上，放回整个项目里，其实还有我没看到的路径？</p>
<p>所以那次准备答复时，我整理的不只是自己的 diff，还包括另一个 PR、已有的事件生产代码，以及两边的测试。我反复跑了很多遍相关测试，包括 #7172 的 <code>DiscoveryDataChangedEventSyncListenerTest</code>、<code>UpstreamCacheManagerTest</code>，和 #7289 的 <code>DiscoveryUpstreamDataRefreshTest</code>、<code>DivideUpstreamDataHandlerTest</code>。跑通以后，还要回头看断言到底在验证什么，和我准备说出的结论是不是同一回事。</p>
<p>我也借助了 DeepSeek、Gemini、GPT 和 GLM，让不同模型一起辅助审查我的理解和方案。我当时很想确认，自己不是因为写了这段代码，就只看到了支持自己判断的部分。多换几个角度检查，至少能让我继续问：还有没有遗漏的边界？有没有哪一步是我想当然了？</p>
<p>模型的分析帮我多检查了几遍，但真正让我慢慢敢回应的，还是能回到代码里找到事件来源，能把测试结果和具体场景对上。我需要的不只是一个“你的理解没问题”的回答，而是自己也能解释清楚：为什么这里是 selector 级删除，为什么它没有改变实例级 UPDATE 的路径。</p>
<p>我把这些重新整理成几个可以逐项核对的判断：</p>

























<table><thead><tr><th>我需要说明的边界</th><th>对应的证据</th></tr></thead><tbody><tr><td>单个实例删除仍然走 UPDATE</td><td>Registry 事件的生产路径，以及 #7172 的实例快照测试</td></tr><tr><td>selector 级 DELETE 不是这次新造的事件</td><td><code>unbindDiscovery → removeSelectorUpstream</code> 的已有调用链</td></tr><tr><td>HTTP 能识别消失的 discovery 记录</td><td>#7289 的 <code>[S1, S2] → [S2]</code>、<code>[S1] → []</code> 测试</td></tr><tr><td>取消订阅确实落到了插件状态</td><td>对应 Handler 的缓存清理测试</td></tr></tbody></table>
<figure class="not-prose">
<figcaption>图 3 · 不是先决定谁对，而是先把判断查清楚</figcaption>
<div>

方案质疑后的查证与回应
遇到方案级质疑，先重新追事件来源、对照相关 PR 和测试。发现实现确实有问题就修改并补验证，发现上下文理解不同就说明对象和边界。两种回应都要给出可复核的证据，再继续共同评审。









Reviewer 对方案提出疑问

先重新验证自己的判断
事件来源 / 相关 PR / 调用链 / 测试

实现确实有问题
修改，并补对应验证

讨论的上下文不同
说明对象、语义和边界

给出可复核的证据，继续共同评审

</div>
</figure>
<p>准备这些证据，不只是为了让别人更容易复核，也是为了让我自己敢把话说出来。如果只是凭感觉说“这两个 PR 不一样”，我会很不踏实；把调用链和测试放在一起以后，我才觉得自己可以认真解释这个区别。</p>
<p>即便如此，回应时我还是很小心。我不想让讨论变成一句“你理解错了”，也担心自己表达不好，让对方觉得我只是舍不得改已经写好的代码。所以我先说明自己认同实例级完整快照的模型，再解释 #7289 处理的是另一层的删除，把依据和没有覆盖的范围一起写清楚，也同步澄清了 PR 描述。</p>
<p>真正把回复发出去时，我还是有点紧张。只是反复核对之后，我觉得不能一直停在“可能是我错了”这里。如果我查到的事实确实支持这个判断，就应该鼓起勇气说出来，也让别人有机会继续检查它。</p>
<p>后来维护者在<a href="https://github.com/apache/shenyu/pull/7289#issuecomment-5886721417" rel="noopener noreferrer" target="_blank">后续回复</a>里确认，之前对事件范围有误解：instance 变化走完整 UPDATE 快照，#7289 处理的是 selector-scoped DELETE。他会按这个区分重新看 PR。</p>
<p>看到那条回复时，我确实松了一口气。前面一直担心自己是不是漏了什么，也担心这次回应会不会显得不够谨慎。对方愿意按澄清后的边界重新看 PR，让我觉得这番反复查证和认真组织的解释没有白费，原来卡住的讨论终于能继续了。</p>
<h2>失败怎么被看见，也是方案的一部分</h2>
<p>这次 Review 不只有事件范围的争议。有两个工程取舍，也让我记得很清楚。</p>
<h3>批量删除可以失败，但不能让用户不知道发生了什么</h3>
<p>Admin 发布删除事件前，需要解析出 <code>pluginName</code>。名字缺失时，不能继续构造一个地址不完整的 discovery upstream 路径。</p>
<p>我最开始选择直接抛 <code>IllegalStateException</code>。Reviewer 指出，一个 selector 的元数据异常，会让整个批次回滚；如果最后只给用户一个内部异常，他既不知道哪些数据动过，也不知道怎么重试。</p>
<p>讨论里有两种选择：跳过异常 selector，继续处理其他项；或者保留批次原子性，但用能映射到清晰错误响应的异常说明失败。</p>
<p>我最后选了第二种。先校验整个批次，再删除关联数据、发布事件；如果插件名经过补充查询仍无法解析，就抛 <code>ShenyuAdminException</code>，指出有问题的 selector，并说明本批次没有任何 selector 被删除，需要恢复插件名后再试。</p>
<p>以前我很容易觉得“加了异常，安全性就有了”。这次我开始意识到，还要站在使用者那边看一眼：<strong>他看完这个错误，知不知道数据现在是什么状态，下一步该做什么？</strong></p>
<h3>接口更清楚，不代表兼容性成本就消失了</h3>
<p><code>unSubscribe(DiscoverySyncData)</code> 改成 <code>unSubscribe(DiscoveryUpstreamKey)</code>，语义确实更准确。但这是公共 SPI，不是只在一个类里改个私有方法。</p>
<p>仓库内部实现和调用点全部迁移，不能代表下游实现也能直接继续用。这次修改有源代码和二进制兼容性影响，最后明确写进了 <code>RELEASE-NOTES.md</code>。</p>
<p>我保留了这个接口选择，但也需要承认它的代价。方案不能只解释“为什么这样更好”，还要说明“别人要为这个变化做什么”。</p>
<h2>CI 红灯，又把我带到了另一个问题</h2>
<p>维护 #7289 的过程中，我还需要跟进 master、处理冲突和检查 CI。跨模块改动不能只靠自己读一遍代码就放心，Reviewer 也需要能检查的验证结果。</p>
<p>后来 <code>k8s-examples-http</code> 的安装步骤失败，我没有重跑权限，就继续往 workflow 里查。最后发现，<code>curl -sfL ... | sh -</code> 的下载失败可能被 pipeline 的退出状态掩盖，写好的重试并没有接住失败。</p>
<p>我把那个问题拆成了 <a href="https://github.com/apache/shenyu/issues/7379" rel="noopener noreferrer" target="_blank">Issue #7379</a> 和 <a href="https://github.com/apache/shenyu/pull/7380" rel="noopener noreferrer" target="_blank">PR #7380</a>，没有把无关的 CI 修复都塞进 discovery 删除的 diff。</p>
<p>那段经历已经写在<a href="/zh-cn/posts/shenyu-pr7380/">《CI 红了，不一定是代码错了》</a>里。对我来说，它不是完全独立的另一件事，而是维护这个 PR 时，从“怎么又红了”一路追出来的。</p>
<h2>把方案交出去，也要把上下文讲清楚</h2>
<p>这个 PR 之后，我对“把一个改动做好”的理解，多了一点以前没有认真想过的东西。原来我更多盯着自己的代码：问题有没有修掉，测试能不能过，Review 提到的地方有没有改完。后来才发现，把代码写出来以后，还要让别人能理解，我为什么选择这样处理。</p>
<p>这次争议也让我回头看了自己的表达。我顺着代码追了很久，已经习惯把“实例删除”和“selector 级 discovery 删除”分开理解，写说明时却容易默认读者也有同样的上下文。维护者从另一个 PR 的快照模型看过来，关注的就可能是另一种删除。光把调用链列出来，并不一定能让这个区别变得明显。</p>
<p>以后再介绍一个方案，我想先把场景说清楚：这次消失的是什么，哪些状态还在，我的修改负责到哪一步。不是一上来就解释新增了什么接口，而是先让读者知道，为什么这里需要这个接口。以前我觉得这些是写完代码以后的说明，现在觉得，它们本来就是把一个 PR 交出去的一部分。</p>
<p>我对 Review 的感觉也变了一点。以前有点像等人批改作业：对方指出问题，我就想着赶紧改好，别给别人添麻烦。这次讨论让我发现，我也需要把自己掌握的上下文带进去。有人帮我看到没想周全的地方，我也可以补上对方暂时没有看到的部分，最后一起判断这个改动该怎么往前走。</p>
<p>我还是会担心自己经验不够，也不会因为这一次解释清楚了，就觉得以后都能判断准确。但至少，参与讨论不一定要等到自己已经很懂整个项目。对自己说出的判断认真负责，把知道的讲清楚，把不确定的留出来，也是我现在能做的一件事。</p>
<p>回头看，这次让我多了一点信心的，不是“我也能指出维护者的误解”，而是我开始觉得，自己可以认真参与一次技术讨论。还会紧张，还会怕漏掉什么，但不再只把自己放在等着接受修改意见的位置上。这是我想从这次经历里留下来的变化。</p>
<h2>相关链接</h2>
<ul>
<li><a href="https://github.com/apache/shenyu/issues/6479" rel="noopener noreferrer" target="_blank">Issue #6479 · discovery upstream 缓存残留</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7289" rel="noopener noreferrer" target="_blank">PR #7289 · selector 级 discovery upstream 删除处理</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7172" rel="noopener noreferrer" target="_blank">PR #7172 · 实例级完整快照对账方案</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7289#issuecomment-5885571487" rel="noopener noreferrer" target="_blank">关于事件范围的澄清答复</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7289#issuecomment-5886721417" rel="noopener noreferrer" target="_blank">维护者对事件范围的后续确认</a></li>
<li><a href="/zh-cn/posts/shenyu-pr7380/">CI 排查复盘</a></li>
</ul>]]></content:encoded>
      <author>Lymerin</author>
      <category>Apache ShenYu</category><category>Java</category><category>数据同步</category><category>缓存一致性</category><category>Code Review</category>
    </item>
    <item>
      <title>CI 红了，不一定是代码错了：第一次排查 Apache ShenYu CI 故障</title>
      <link>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr7380</link>
      <guid>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr7380</guid>
      <updated>2026-10-01T05:06:56.000Z</updated>
      <pubDate>2026-10-01T05:06:56.000Z</pubDate>
      <description><![CDATA[维护一个 PR 时，我被一次 k3s 安装失败卡住了。从没有重跑权限，到用假的 curl 稳定复现问题，再第一次主动找维护者讨论，记录这次 CI 排查的过程。]]></description>
      <content:encoded><![CDATA[
<p>这次修复，是从另一个 PR 的红灯里找出来的。</p>
<p>当时我正在维护 Apache ShenYu <a href="https://github.com/apache/shenyu/pull/7289" rel="noopener noreferrer" target="_blank">PR #7289</a>。它改动比较多，每次提交都要等 CI 跑不少模块。有一次，<code>k8s-examples-http</code> 又失败了，日志最后只留下了一句：</p>
<pre><code>cat: /etc/rancher/k3s/k3s.yaml: No such file or directory
</code></pre>
<p>这次测试还没跑到业务代码，安装 k3s 的步骤就已经出问题了。我第一反应是想再跑一次看看，但我没有重跑 ShenYu CI 的权限。继续等着，也不知道下一次会不会还是同样的结果。</p>
<p>于是我开始认真看这段 workflow：为什么安装失败了，却只在最后读取文件时才报错？原来写好的重试，又为什么没有运行？</p>
<p>顺着这两个问题，我第一次把 CI 从“提交之后等结果”的黑盒，拆成了自己可以读、可以复现、也可以测试的脚本。最后这次修复单独提交为 <a href="https://github.com/apache/shenyu/pull/7380" rel="noopener noreferrer" target="_blank">PR #7380</a>，并被合并。对我来说，值得记下来的不只是那几行 Bash，还有从“能不能帮我重跑一下”，到自己拿着日志和复现去讨论问题的过程。</p>
<h2>先看失败在哪一层，再回头检查代码</h2>
<p>以前看到 CI 红了，我通常会先打开自己的 diff，找是不是哪里写错了。但这次日志指向的是环境初始化：workflow 要先安装 k3s，再读取 <code>/etc/rancher/k3s/k3s.yaml</code> 作为 kubeconfig。这个文件不存在，后面的 Kubernetes 测试自然也就跑不起来。</p>
<p>CI 里的失败不只有业务代码这一种来源。Runner、下载源、网络、Docker 和依赖仓库，都可能影响一次运行。我开始意识到，红灯只是结果，<strong>先弄清楚它停在哪一步，才知道该往哪里找。</strong></p>
<p>继续读 workflow 时，我发现它其实已经有重试逻辑。把和问题有关的部分摘出来，大致是这样：</p>
<pre><code># 原 workflow 的关键结构，省略版本参数和失败日志
install_k3s() {
  curl -sfL https://get.k3s.io | sh -
}

for attempt in 1 2 3; do
  if install_k3s; then
    break
  fi
  # 第三次失败时退出；前两次分别等待 15、30 秒
done
</code></pre>
<p>第一次失败等 15 秒，第二次失败等 30 秒，第三次再失败就退出。看起来并没有少写重试。</p>
<p>但实际那次 <code>Install k8s</code> 很快就结束了，日志里没有 <code>k3s install failed on attempt 1</code>，也没有等到第一次重试的 15 秒。<a href="https://github.com/apache/shenyu/issues/7379" rel="noopener noreferrer" target="_blank">Issue #7379</a> 里记录的时间和日志，都和“正常执行过重试”对不上。</p>
<p>这让我换了一个方向：会不会不是循环没写好，而是它根本没有收到“安装失败”这个信号？</p>
<h2>写了重试，不代表失败真的会进入重试</h2>
<p>问题就在那条看起来很普通的命令里：</p>
<pre><code>curl -sfL https://get.k3s.io | sh -
</code></pre>
<p>我原来很容易把它理解成“下载脚本，然后执行脚本”，所以直觉上下载失败，整条命令也应该失败。</p>
<p>但没有开启 <code>pipefail</code> 时，Bash 默认取 pipeline <strong>最后一个命令的退出状态</strong>。如果 <code>curl</code> 下载失败，没有把脚本送给后面的 <code>sh -</code>，<code>sh</code> 读到空输入，什么也没执行，却可以正常退出。</p>
<p>结果就是：前面的 <code>curl</code> 失败，后面的 <code>sh</code> 返回 <code>0</code>，整条 pipeline 也返回 <code>0</code>。<code>if install_k3s</code> 看到的是成功，于是直接 <code>break</code>，直到后面的 <code>cat</code> 才发现 kubeconfig 根本不存在。</p>
<figure class="not-prose">
<figcaption>图 1 · 同样一次下载失败，退出状态决定了重试能不能接住它</figcaption>
<div>

下载失败的状态传递对比
curl 下载失败且没有脚本输出，sh 读取空输入后返回零。未启用 pipefail 时，pipeline 返回零，重试误判为安装成功；启用 pipefail 后，pipeline 返回非零，安装尝试判为失败，进入重试或在次数耗尽后退出。



curl 失败，没有输出脚本
sh - 读到空输入，仍可返回 0

没有 pipefail

开启 pipefail

pipeline 返回 0
只看到最后的 sh 成功

pipeline 返回非零
下载失败被传递出来

误判成功，提前 break

本次尝试失败

最后读取 kubeconfig 才报错

进入重试
次数耗尽时明确退出

</div>
<div>这里展示下载失败且没有脚本输出的路径。小屏可在图内左右滑动。</div>
</figure>
<p>这里也不是只补一个 <code>set -e</code> 就能解决。原来的 step 已经用 <code>bash -e</code> 运行，安装函数却处在 <code>if</code> 的条件判断里；我需要让 pipeline 正确返回失败，再由重试逻辑处理，而不是期待某个 Shell 选项替我判断“安装到底有没有完成”。</p>
<p>还有一个让排查更难的细节：原来的 <code>curl -sfL</code> 用了 <code>-s</code>，错误信息也被静默了。改成 <code>curl -sSfL</code>，加上的 <code>-S</code> 才能让错误继续出现在日志里。</p>
<p>那次日志没有留下具体的 curl 错误，所以我没法再倒推出究竟是哪一种下载故障。但“下载失败被误判为成功”这条路径，后来可以用离线测试稳定复现。</p>
<p>我这才意识到，日志最后报错的位置，不一定就是问题最早发生的位置。失败可能已经发生了一会儿，只是没有被正确传下去。</p>
<h2>退出码是信号，安装结果才是成功条件</h2>
<p>开启 <code>pipefail</code> 后，下载失败终于能进入重试。但我继续想了一步：如果安装脚本返回 <code>0</code>，却没有生成 kubeconfig 呢？循环还是会提前结束，最后仍然读不到文件。</p>
<p>所以这里的成功条件不能只有“命令返回了成功”，还要检查这一步实际需要的结果。最终判断是：</p>
<pre><code>if install_k3s &amp;&amp; [[ -s "${kubeconfig_file}" ]]; then
  break
fi
</code></pre>
<p><code>[[ -s file ]]</code> 检查文件存在且非空。两边都满足，才结束重试；否则最多尝试三次，前两次分别等待 15、30 秒，最后明确报错退出。</p>
<figure class="not-prose">
<figcaption>图 2 · 重试判断的，不只是命令有没有返回 0</figcaption>
<div>

k3s 安装的成功条件与重试
执行安装后，同时检查安装命令成功和 kubeconfig 文件存在且非空。满足条件则复制 kubeconfig 并结束安装脚本。否则判断尝试次数，未满三次时等待十五或三十秒再试，第三次仍失败则返回非零。




执行 install_k3s
pipefail 传递失败，curl -S 留下错误

安装成功，而且 kubeconfig 非空？
install_k3s &amp;&amp; [[ -s file ]]
是

结束重试
复制配置
install -m 600

否

已经是第三次尝试？

是

否

记录失败，等待后再试
第一次 15 秒 / 第二次 30 秒

记录最终失败
exit 1，不再复制配置
只在前两次失败后等待，最多执行三次安装。

</div>
</figure>
<p>最终的改动没有继续堆在 workflow 的 <code>run:</code> 里，而是抽成 <code>.github/scripts/install-k3s.sh</code>。workflow 调用脚本，脚本负责明确的成功判断、重试和失败日志。</p>
<p>另外，原来会把 kubeconfig 直接打印到 CI 日志，再用 <code>cp</code> 复制到用户目录。这次也去掉了打印，改用 <code>install -m 600</code> 写到 job 用户的 <code>.kube/config</code>。后续命令仍然能读取，不需要把配置内容留在日志里。</p>
<p>以前我写这类脚本，容易把“命令执行完了”当作“事情完成了”。这次让我开始多问一句：<strong>这一步完成以后，下一步需要的东西，真的已经有了吗？</strong></p>
<h2>让失败稳定发生，比等一次绿灯更有用</h2>
<p>修复方向有了，但怎么确认它真的解决了问题？我没有重跑权限，网络失败也不是想遇到就能遇到。即使下一次 CI 变绿，也不等于下载失败这条分支被验证过。</p>
<p>后来我没有继续等网络出错，而是在临时目录里放了一个假的 <code>curl</code>，把这个目录排到 <code>PATH</code> 最前面。安装脚本调用的名字还是 <code>curl</code>，实际执行的却是测试准备好的 stub。</p>
<p>想模拟下载失败，就让它直接返回非零，并且不输出安装脚本：</p>
<pre><code>#!/bin/sh
# 模拟 curl 下载失败的最小片段，不访问网络
exit 22
</code></pre>
<p>想模拟“安装器成功，但没有产生 kubeconfig”，就让假的 curl 输出一段只会 <code>exit 0</code> 的安装脚本。这样，下载和执行可以看起来都成功，但我故意不提供安装结果。</p>
<p>原来难以等到的故障，就变成了我每次运行都能制造的条件。再把相同输入交给旧逻辑和修复后的逻辑，差别就不再是猜测了。</p>
<p>最后的 <code>.github/scripts/install-k3s-test.sh</code> 覆盖了四种情况：</p>



































<table><thead><tr><th>测试输入</th><th>安装尝试次数</th><th>等待次数</th><th>预期结果</th></tr></thead><tbody><tr><td>curl 下载失败</td><td>3</td><td>2</td><td>明确失败，不复制配置</td></tr><tr><td>安装器返回成功，但没有 kubeconfig</td><td>3</td><td>2</td><td>明确失败，不复制配置</td></tr><tr><td>安装器生成空的 kubeconfig</td><td>3</td><td>2</td><td>明确失败，不复制配置</td></tr><tr><td>安装器成功，并生成非空 kubeconfig</td><td>1</td><td>0</td><td>成功，复制配置</td></tr></tbody></table>
<p>测试里的 <code>sleep</code> 也换成了 stub，记录调用而不真的等待，所以不需要每个失败场景都花 45 秒。成功场景还检查复制后的内容和 <code>600</code> 权限，失败场景则检查最终报错，以及没有写出目标配置。</p>
<p>这是我第一次比较认真地模拟 CI 故障。让我印象深的不是 stub 写得多复杂，而是思路变了：不再等一次运行替我证明结果，而是自己控制输入，看看失败会不会按预期被处理。</p>
<h2>把 workflow 拆开，CI 就没那么像黑盒了</h2>
<p>排查过程中，我原来闲置的阿里云开发机也派上了用场。我开始把它当作自己的 Linux 测试环境，用来跑从 workflow 里拆出来的 Shell 命令，模拟 CI 的执行过程。</p>
<p>以前遇到这类问题，很容易变成：改一点，push，等 GitHub Actions，再看结果。等待久倒还不是最烦的，主要是有时等完了，还是不知道自己的判断对不对。</p>
<p>把脚本拿到自己能控制的环境里以后，我就可以先看退出码、准备缺失文件的场景、确认重试次数，再提交修改。不需要每调整一处判断，都等整个项目重新跑一遍。</p>
<p>我没有把自己的服务器当成 GitHub Runner 的完整替代品。它对这次排查最有用的地方，是让我能把一个具体的失败条件单独拿出来，反复看它怎么执行。</p>
<p>当我开始这样读 workflow，CI 就没以前那么神秘了。至少其中的 <code>run:</code> 不再只是页面上的一段配置，而是一组我也能拿出来运行、检查和测试的命令。</p>
<h2>准备好证据，再去找维护者讨论</h2>
<p>还有一件事，我自己挺想保留下来：这次是我第一次主动联系 ShenYu 的维护者讨论 CI 问题。</p>
<p>当时我已经有了初步判断，但没有重跑权限。我犹豫了一阵，还是把失败的位置、为什么怀疑 k3s 下载，以及目前还不能确定的地方简单说明了一下，想确认这种环境问题能不能单独提出来处理。</p>
<p>对方回复的大意是，如果是 k3s 环境的问题，可以发出来；有其他明确的问题，也可以单独提 PR。后来还问我是在读书还是已经工作了，我说自己还是学生。他也鼓励我继续在社区里多看看、多处理一些问题。</p>
<p>对别人来说，这可能只是很普通的一次交流。但对当时的我，它确实减轻了不少心理压力。以前想到找维护者，我会先担心：自己是不是懂得太少，问题是不是太小，会不会判断错了，又会不会打扰别人。</p>
<p>这次让我发现，正常讨论一个工程问题，并不需要先把整个项目都摸透。先自己查过，把日志、判断和能复现的部分准备好，再把不确定的地方说清楚，就已经比一句“CI 挂了”更容易继续讨论。</p>
<p>后来 <a href="https://github.com/apache/shenyu/pull/7380#pullrequestreview-5372312366" rel="noopener noreferrer" target="_blank">Aias00 的 review</a> 也明确认可了这个方向：把安装器抽出来，加上非空 kubeconfig 判断和专门的重试测试，让原本隐含在 workflow 里的行为变得可以验证。</p>
<p>我开始觉得，证据不仅是用来证明自己没改错，也能让别人更快理解问题，和我一起把事情往前推进。</p>
<h2>看 CI 绿灯，也要看哪些步骤真正跑过</h2>
<p>这次还有一个容易忽略的地方。PR 修改的是 <code>.github/**</code>，相关 workflow 会启动，新增的离线测试也会执行，但真实的 <code>Install k8s</code> 仍受 path filter 和 case resolver 控制。</p>
<p>也就是说，离线测试通过，说明这些失败输入下的重试行为符合预期；真实下载和安装是否执行过，还要看 <code>run_k8s_examples</code> 的输出，不能只看页面最上面的绿勾。</p>
<figure class="not-prose">
<figcaption>图 3 · 离线重试测试与真实安装，是两条不同的验证路径</figcaption>
<div>

离线测试与真实安装的执行范围
同一 workflow 中，离线重试测试不受 k8s case 条件限制，使用 curl 和 sleep stub 验证四个场景。真实安装受路径过滤和 case resolver 控制，run_k8s_examples 为 true 才安装。只修改 .github 的本次 PR 执行离线测试，但跳过真实安装。



离线重试测试
install-k3s-test.sh
不受 k8s case 条件限制

真实 Install k8s
install-k3s.sh
受路径与 case resolver 控制

curl / sleep stub
四种输入，不需要真实网络

run_k8s_examples == true
满足条件才下载与安装 k3s

本次 PR 执行了
验证失败处理与成功路径

本次 PR 跳过了
只修改 .github/** 不触发安装

</div>
</figure>
<p>我把这个范围也写进了 PR 描述。它让我养成了一个更具体的检查习惯：不只看任务是否通过，也看看自己关心的那一步，到底是执行了，还是被跳过了。</p>
<h2>从等人重跑，到自己解释它为什么失败</h2>
<p>如果只看最终修改，这个 PR 是三个文件：安装脚本、离线测试脚本，以及调用它们的 workflow。它没有改 Java 业务逻辑，但排查过程中，我对“CI 配置”的看法确实变了一点。</p>
<p>以前 <code>.github/workflows/*.yml</code> 更像项目附带的配置。现在我会留意，它里面一样有分支、退出状态、成功条件和外部依赖，也会有“代码看起来写了，实际上却没有生效”的问题。</p>
<p>这次最开始，我只是希望有人能帮我再跑一次。后来我能说清楚：失败停在哪里，为什么怀疑 pipeline，怎样不依赖真实网络复现，以及修复以后要检查什么。</p>
<p>对我来说，这种变化比“又合并了一个 PR”更值得记下来。没有重跑权限时，我也不一定只能等；先把能控制的部分拿出来，准备好证据，再去讨论剩下的问题，事情就能继续往前走。</p>
<p>以后看到 CI 红灯，我还是会检查自己的代码，但会先问一句：<strong>它到底失败在哪一层？</strong> 如果重新跑一次就绿了，我也想再弄清楚，第一次失败的信号为什么没有早点出现在日志里。</p>
<p>这次算是我真正开始接触 CI 工程的起点。不是一下子懂了所有 workflow，而是终于开始把它当成一段自己也需要读懂、测试和维护的程序。</p>
<h2>相关链接</h2>
<ul>
<li><a href="https://github.com/apache/shenyu/issues/7379" rel="noopener noreferrer" target="_blank">Issue #7379 · k3s 安装重试被提前跳过</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7380" rel="noopener noreferrer" target="_blank">PR #7380 · 处理下载失败与缺失 kubeconfig 的重试</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7289" rel="noopener noreferrer" target="_blank">PR #7289 · 这次排查的起点</a></li>
<li><a href="https://github.com/apache/shenyu/blob/51ec9761092733b85481eb67fee7a44d69354710/.github/scripts/install-k3s.sh" rel="noopener noreferrer" target="_blank">合并版本的安装脚本</a></li>
<li><a href="https://github.com/apache/shenyu/blob/51ec9761092733b85481eb67fee7a44d69354710/.github/scripts/install-k3s-test.sh" rel="noopener noreferrer" target="_blank">合并版本的离线测试</a></li>
<li><a href="https://www.gnu.org/software/bash/manual/html_node/Pipelines" rel="noopener noreferrer" target="_blank">Bash 手册 · Pipeline 的退出状态</a></li>
<li><a href="https://curl.se/docs/manpage.html" rel="noopener noreferrer" target="_blank">curl 手册 · silent 与 show-error</a></li>
</ul>]]></content:encoded>
      <author>Lymerin</author>
      <category>Apache ShenYu</category><category>GitHub Actions</category><category>CI</category><category>Bash</category><category>k3s</category><category>故障排查</category>
    </item>
    <item>
      <title>代码能跑还不够：一次 OpenTelemetry Review 教会我的事</title>
      <link>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/opentelemetry-pr19732</link>
      <guid>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/opentelemetry-pr19732</guid>
      <updated>2026-10-01T03:26:38.000Z</updated>
      <pubDate>2026-10-01T03:26:38.000Z</pubDate>
      <description><![CDATA[从 Spring Rabbit 重复遥测到反复修改方案，记录我在 OpenTelemetry PR #19732 中遇到的 review、版本兼容问题，以及进入大型仓库时想法的变化。]]></description>
      <content:encoded><![CDATA[
<p>这是我到目前为止做过最折磨的一次开源修改。</p>
<p>一开始，问题看起来很明确：Spring <code>@RabbitListener</code> 消费 RabbitMQ 消息时，一次处理会产生两个 <code>process</code> Span，<code>messaging.process.duration</code> 也被记录了两次。问题能复现，原因也不算特别难找。我当时更多在想，既然已经知道哪里重复了，把其中一层关掉，应该就差不多了吧。</p>
<p>真正开始改以后，我才发现，难的不是让重复的 Span 消失，而是让这次修改和项目原来的架构、历史版本以及遥测语义都能对上。好几次我觉得方案已经能工作，review 又指出了我没看到的地方。</p>
<p><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732" rel="noopener noreferrer" target="_blank">PR #19732</a> 从 8 月 20 日提交，到 9 月 2 日合并，来回改了不少。但回头看，最值得记录的不是改了多少行，而是这段过程中我怎么从“这段代码应该能跑”，慢慢开始问：<strong>这个项目希望我怎样解决这类问题？</strong></p>
<h2>一条消息，为什么会出现两个 process Span？</h2>
<p><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/issues/19588" rel="noopener noreferrer" target="_blank">Issue #19588</a> 涉及 RabbitMQ 和 Spring Rabbit 两套 instrumentation 同时启用的情况。</p>
<p>以 <code>SimpleMessageListenerContainer</code> 为例，RabbitMQ 的 dispatch thread 先执行 consumer 的 <code>handleDelivery()</code>。但 Spring 的这个 consumer 并不在这里直接调用用户的 listener，而是先把消息放进 <code>BlockingQueue</code>，再由另一个线程取出来处理。</p>
<p>RabbitMQ instrumentation 在第一段回调外面创建了一个 <code>process</code> Span；Spring Rabbit instrumentation 在实际调用 listener 时，又创建了一个。线程切换以后，第一段的当前上下文不会自动覆盖第二段，于是原本想描述一次处理的两层 instrumentation，都留下了自己的遥测。</p>
<figure class="not-prose">
<figcaption>图 1 · Simple Container 中，两段不同的工作都被标成了 process</figcaption>
<div>

Spring Rabbit 重复 process 遥测路径
修复前，Simple Container 的 RabbitMQ 分发线程把消息放进队列，这段回调产生 RabbitMQ process 遥测。Spring listener 线程取出消息并执行用户代码，又产生 Spring process 遥测。示意是执行路径，不表示 Span 的父子关系。




RabbitMQ dispatch thread
TracedDelegatingConsumer

handleDelivery()
Spring consumer 把消息放进队列

RabbitMQ process
Span + duration

BlockingQueue
线程切换

Spring listener thread
invokeListener() → 用户代码

Spring Rabbit process
Span + duration

同一次消息处理，留下两份 process 遥测

</div>
<div>修复前的执行路径示意，不是 Span 父子关系图。小屏可在图内左右滑动。</div>
</figure>
<p>这不只是“多一个 Span 不好看”。第一段主要是在入队，真正执行用户 <code>@RabbitListener</code> 的是第二段，两段工作的语义也不同。</p>
<p><code>DirectMessageListenerContainer</code> 又是另一种情况：没有同样的线程交接，Spring 的 process instrumentation 可能因为已有 RabbitMQ process 上下文的 suppression 被抑制。也就是说，换一种 container，实际留下的遥测归属就变了。</p>
<p>所以我要解决的并不是随便删掉一个 Span，而是让两边对<strong>谁负责这次消息处理</strong>达成一致。</p>
<h2>我的实现能工作，但仓库已经有自己的表达方式</h2>
<p>为了让两套 instrumentation 协调，我最初加了一套状态控制，其中一个版本用了 depth counter。进入 Spring 管理的 consumer 注册时增加深度，退出时再减回来，我担心的是调用嵌套以后状态恢复不对。</p>
<p>但 <a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3833600069" rel="noopener noreferrer" target="_blank">trask 的 review</a> 指出了两个我没考虑到的地方：这里的注册路径并没有我担心的那种嵌套；仓库也已经有类似问题的处理方式，比如 <code>KafkaClientsConsumerProcessTracing</code> 和 <code>SpringSchedulingTaskTracing</code>。</p>
<p>它们保存的是进入前的值，退出时恢复，而不是再引入一套计数规则。可以把这种思路简化成：</p>
<pre><code>// 状态保存与恢复的概念示意，不是完整的 Advice 实现
boolean previous = isWrappingEnabled();
setWrappingEnabled(false);
try {
    registerConsumer();
} finally {
    setWrappingEnabled(previous);
}
</code></pre>
<p>当时我有一点挫败：我确实考虑了状态恢复，为什么还是要改？继续看已有实现以后，我才意识到，我关注的是“自己这套逻辑能不能工作”，维护者还在考虑“这个项目一直怎么表达同类问题”。</p>
<p>depth counter 并不是在所有场景下都不对。但这里已经有够用的模式，我再写另一种，就让以后读代码的人多理解一套规则。</p>
<p>这让我第一次很具体地感觉到，成熟项目里的修改，不只是把功能做出来，还得尽量接得上它原来的语言。</p>
<h2>我以为只是在记录一个指标，其实绕过了 Instrumenter</h2>
<p>另一个印象很深的地方，是我为了保留 consumed-message 指标，曾经自己组织 <code>AttributesExtractor</code>、<code>OperationListener</code> 和指标的开始、结束流程。</p>
<p>我的想法是，RabbitMQ 的 process Span 不需要了，但计数还要保留，那就把需要的部分单独拿出来。当时看起来只是多写一个小 helper。</p>
<p>但 <a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3833600076" rel="noopener noreferrer" target="_blank">review 指出</a>，这等于在 <code>Instrumenter</code> 之外维护一条完整的生命周期。它原本负责的 instrumentation scope、版本、schema URL、全局 customizer、异常原因解包和 start/end extractor 分工，都要由这条手写路径自己跟进。</p>
<p>我这才意识到，<code>Instrumenter</code> 不是单纯为了少写几行代码。看起来像包装层的东西，后面可能背着很多我还没看到的责任。</p>
<p>最终实现沿用了 spring-kafka 的思路：在 Spring 负责的 listener 路径中，关闭 RabbitMQ 的 process 遥测，把 consumed-message 指标注册到 Spring Rabbit 的 Instrumenter 上。合并代码里的关键部分是：</p>
<pre><code>// SpringRabbitSingletons 中注册 operation metrics 的片段
.addOperationMetrics(MessagingProcessMetrics.get())
.addOperationMetrics(MessagingConsumerMetrics.getConsumedMessages());
</code></pre>
<h3>指标数值一样，也不代表语义没变</h3>
<p>回看这段讨论时，我发现，自己还需要把“指标有没有记录”和“指标属于谁”分开看。</p>
<p>这次迁移<strong>确实改变了 Spring listener 路径中 consumed-message 指标的 scope</strong>：它从 <code>io.opentelemetry.rabbitmq-2.7</code> 转到 <code>io.opentelemetry.spring-rabbit-1.0</code>。最终测试也相应检查 Spring scope 下的计数，并确认 RabbitMQ scope 不再重复记录这条指标。</p>
<p>讨论里也出现过不同意见。Copilot 曾<a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3836045259" rel="noopener noreferrer" target="_blank">建议保留 RabbitMQ 原来的指标归属</a>，维护者给出的方向则是迁移到 Spring Instrumenter。把这些意见和最终代码放在一起看，我才分清：这里不是不能迁移，而是要让指标跟着实际负责处理消息的 Instrumenter 走，并把相应的测试一起调整。</p>
<p>对我来说，这里的收获不是“scope 永远不能改变”，而是：<strong>一个指标属于谁，本身就是需要明确决定和验证的行为。</strong> 不能只看 dashboard 上的数字仍然是一次，就觉得其他地方都没有变化。</p>
<p>迁移时还要补上原来 RabbitMQ 路径能拿到的信息。Spring 原来的 request 只有 <code>Message</code>，这次把 <code>Channel</code> 也带进 <code>SpringRabbitRequest</code>，再通过已有的属性提取方式补齐服务器和网络信息，而不是只把计数搬过去就结束。</p>
<figure class="not-prose">
<figcaption>图 2 · 最终方案明确了 process 遥测的归属，而不是全局关闭 RabbitMQ</figcaption>
<div>

最终遥测归属
Spring 接管的 listener 路径由 Spring Rabbit Instrumenter 记录 process Span、duration 和 consumed-message 指标，RabbitMQ 不重复记录 process 遥测。原生 basicConsume、consumer-batch 和 RabbitTemplate.receive 的相关 process 路径保留 RabbitMQ instrumentation。生产端 batch 的处理仍归 Spring，不能仅凭 listener 类型分组。



Spring 接管的 listener 处理
单条消息 / producer batch
Simple 与 Direct 使用一致的归属

保留 RabbitMQ 的相关路径
raw basicConsume / consumer batch
以及 RabbitTemplate.receive

Spring Rabbit Instrumenter
spring-rabbit-1.0 scope

RabbitMQ instrumentation
rabbitmq-2.7 scope

由 Spring 记录一次
process Span + duration
consumed-message 指标
不再手动拼接 metrics 生命周期

保留原有相关 process 遥测
不因修复 Spring 重复问题
把其他 RabbitMQ 路径一起关掉

</div>
<div>归属示意；duration 和 consumed-message 指标按相应 messaging semconv 配置启用。</div>
</figure>
<h2>Listener 类型，不一定等于实际的消息形态</h2>
<p>我后来又遇到了一个看起来很合理、实际却不够可靠的判断：根据 listener 是不是 batch listener，决定 Spring 要不要接管。</p>
<p>问题是，<strong>listener 类型和真正传给 <code>invokeListener()</code> 的数据，不完全是一回事</strong>。一个 batch-typed listener，在 consumer batching 没开启的时候，也可能按单条 <code>Message</code> 进入调用路径。如果 Spring 按实际消息处理，RabbitMQ 却按 listener 类型决定是否保留 process，两边就可能再次同时记录，或者互相 suppression。</p>
<p><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3833600064" rel="noopener noreferrer" target="_blank">这条 review</a> 让我记住了一个很实际的问题：跨模块协作的时候，两边的判断依据，真的是同一个事实吗？</p>
<p>这里也不能把所有“batch”都混为一谈。consumer batching 和 producer-created batch 是不同路径。最终实现不只补了注册时的判断，也让 Spring Advice 能处理接收到的 <code>Message</code> 或非空 <code>List&lt;Message&gt;</code>，测试分别检查 consumer batching 开关、producer batch 以及 Simple / Direct 的行为。</p>
<p>对我来说，这比一句“不要重复打点”具体得多。要让两个模块配合好，得先把它们到底在处理什么说清楚。</p>
<h2>当前版本测试通过，旧版本可能连 Advice 都没触发</h2>
<p>这次最让我头疼的，还是版本兼容。</p>
<p>我最开始在当前源码里找到了 <code>BlockingQueueConsumer.consumeFromQueue(...)</code>，就把 Advice 放到这里。当前版本能工作，很容易让我以为已经覆盖了 consumer 注册。</p>
<p>但 <a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3836073980" rel="noopener noreferrer" target="_blank">自动 review 提醒</a>，模块支持的 Spring Rabbit 1.0.0 根本没有这个方法。旧版本在 <code>start()</code> 里直接调用 <code>basicConsume</code>。Advice 挂载点没匹配到，修复就不会触发，却不一定像普通 API 不兼容那样直接报编译错误。</p>
<p>Direct Container 也有类似问题。<a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3883578967" rel="noopener noreferrer" target="_blank">维护者指出</a>，2.0.0–2.1.2 没有我选的 <code>consume()</code> 路径，<code>doConsumeFromQueue(String)</code> 才包住了那里的注册过程。</p>




















<table><thead><tr><th>路径</th><th>最初忽略的地方</th><th>合并版本里的处理</th></tr></thead><tbody><tr><td>BlockingQueueConsumer</td><td>1.0.0 没有 <code>consumeFromQueue(String)</code></td><td>同时覆盖无参 <code>start()</code> 和 <code>consumeFromQueue(String)</code></td></tr><tr><td>Direct Container</td><td>2.0.0–2.1.2 不走选中的 <code>consume()</code></td><td>匹配 <code>doConsumeFromQueue</code> 的一参、两参形式</td></tr></tbody></table>
<p>我以前想到兼容性，更多是在检查“有没有调用旧版本不存在的 API”。这次才发现，在 instrumentation 项目里，还得问：<strong>我选的挂载点，在那些版本里真的存在吗？执行路径真的会经过它吗？</strong></p>
<p>维护者能很快指出旧版本里的这些差异时，我对项目经验的感受特别直接。不是他写 Java 比我快，而是他知道这个类以前长什么样、后来又在哪里改过。</p>
<h2>连测试里的“先启动，再清空”都需要多想一步</h2>
<p>补 container 测试时，我一开始的思路很普通：先启动，然后清掉初始化阶段的遥测，再发消息做断言。</p>
<pre><code>container.start();
testing.clearData();
</code></pre>
<p>但 consumer registration 是异步完成的。<code>start()</code> 返回，不代表相关 startup Span 都已经产生。如果清空得太早，稍后才到的初始化遥测，就会混进后面的 exact trace assertion。</p>
<p><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3836045254" rel="noopener noreferrer" target="_blank">Copilot 的这条 review</a> 建议先等确定会产生的 setup traces，再清空。最终测试里的顺序是：</p>
<pre><code>// 对应这里预期的三条 setup traces，不是通用的固定等待数量
container.start();
testing.waitForTraces(3);
testing.clearData();
</code></pre>
<figure class="not-prose">
<figcaption>图 3 · start() 返回了，不代表初始化遥测已经到齐</figcaption>
<div>

异步初始化测试的等待与清理顺序
左侧立即清空数据，随后到达的 startup Span 可能污染正式断言。右侧先等待本测试已知的 setup traces，然后清空数据，再发消息验证。图只解释这个异步边界，不表示等待可以消除所有测试不稳定因素。


最初的顺序
调整后的顺序

container.start()

container.start()

立即 clearData()

等待已知的 setup traces 到齐

异步 startup Span 稍后到达

再 clearData()

正式断言可能混入初始化数据
出现偶发失败

再发消息，验证正式遥测
先分清初始化与测试阶段

</div>
</figure>
<p>这个建议看起来只多了一行等待，但它要求我理解系统实际怎么执行。不是把测试步骤按顺序写下来，异步工作就会按我想的顺序完成。</p>
<p>review 里还有命名、测试注解以及属性 getter 写法这些小建议。有些来自维护者，有些来自自动 review。它们也让我开始注意，仓库惯例不一定都写在一份文档里，很多时候就藏在现有代码中。</p>
<h2>API 名字对得上，还得确认值从哪里来</h2>
<p>这次还有一位用户 <a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#issuecomment-5414756155" rel="noopener noreferrer" target="_blank">leonard2901</a> 用自己的 Spring 应用验证了分支。他确认重复问题解决了，但发现 <code>messaging.message.body.size</code> 看起来总是 <code>0</code>。</p>
<p>继续看才发现，Spring Rabbit 原来读取的是消息属性里的 <code>contentLength</code>，而不是实际 body 的长度。如果发布者没有显式设置这个属性，收到消息以后它仍然可能是零。</p>
<p>我后来把单条消息的大小提取改为实际字节数组长度，并增加了非空 payload 的回归检查。可以把这个变化理解为：</p>
<pre><code>// 原来的来源：消息属性，不一定由发布者设置
message.getMessageProperties().getContentLength();

// 单条消息改用实际 body 长度，省略完整 request 和 batch 分支
byte[] body = message.getBody();
return body == null ? null : (long) body.length;
</code></pre>
<p>这件事挺让我印象深刻。一个 API 名字看起来完全符合我要的东西，也不代表它在这个具体场景里一定有值。实际用户带来的配置和使用方式，会让我看到自己的测试和理解之外的地方。</p>
<h2>Review 不是只在帮我找 Bug</h2>
<p>最开始被指出这些问题时，我确实有点挫败。有时会想，方案明明能工作，为什么还要再改？历史版本的这些细节，我第一次接触这个仓库，怎么可能一下子全知道？</p>
<p>但后面慢慢发现，review 不是要求我一开始就知道所有东西，而是把我局部的理解，放回整个项目里检查。</p>
<p>“保存旧值再恢复”背后，是已经形成的协作模式；“用 Instrumenter”背后，是统一的生命周期；“这个旧版本没有这个方法”背后，是模块已经承诺支持的范围。这些建议表面上让我改几行代码，实际是在补上我暂时看不到的上下文。</p>
<p>我也开始意识到，review 的不同意见需要继续核对，而不是谁说得更肯定就听谁的。像 consumed-message 指标那段，得把维护者的目标、中间版本和最终代码放在一起看，才能知道这次修改到底选择了什么。</p>
<p>维护者的经验，到这里对我来说变得很具体：不是脑子里比我多一条算法，而是知道一个局部修改，会在项目其他地方留下什么影响。</p>
<h2>做完以后，我进入大型仓库的方式变了一点</h2>
<p>回头看，我当时太急着自己设计方案了。理解 Issue、定位问题，下一步就想开始写。review 却反复把我带回已有实现：去看 Kafka 的状态控制，去看 spring-kafka 的 ownership，再看 RabbitMQ 已经使用的属性 getter。</p>
<p>这些实现一直都在那里，只是我还没有形成先找它们的习惯。</p>
<p>做完这个 PR，我开始留意一些原来没注意到的地方：一个抽象为什么要保留，某个测试为什么要等，某个 Advice 为什么要照顾多年前的调用点。以前读代码时，我更多是在找要改的位置，现在也会想多看一步，弄清楚它为什么写成这样。</p>
<p>但这次至少让我多了一个进入仓库时会问的问题。以前是“这段代码应该怎么改”，现在还会问：<strong>这个仓库以前遇到类似问题时，是怎么做的？</strong></p>
<p>我想，这比记住某一个类名更有用。代码能跑、测试能过，当然重要；但让一个修改真正适合这个项目，还需要理解它已经积累下来的选择。我还在学这一部分，这次 review 正好让我很直接地看到了差距，也知道下一次可以从哪里开始补。</p>
<h2>相关链接</h2>
<ul>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/issues/19588" rel="noopener noreferrer" target="_blank">Issue #19588 · Spring Rabbit 重复 process 遥测</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732" rel="noopener noreferrer" target="_blank">PR #19732 · 修复 Spring Rabbit listener 的重复 process 遥测</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3833600069" rel="noopener noreferrer" target="_blank">状态保存与恢复的 review</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/19732#discussion_r3833600076" rel="noopener noreferrer" target="_blank">Instrumenter 与指标归属的 review</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/0c16d7c125b42f310b8e40a82212d4b75b1ca6cd/instrumentation/spring/spring-rabbit-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/rabbit/v1_0/SpringRabbitSingletons.java" rel="noopener noreferrer" target="_blank">合并版本的 SpringRabbitSingletons</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/0c16d7c125b42f310b8e40a82212d4b75b1ca6cd/instrumentation/spring/spring-rabbit-1.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/spring/rabbit/v1_0/SpringRabbitMqTest.java" rel="noopener noreferrer" target="_blank">合并版本的 Spring Rabbit 测试</a></li>
</ul>]]></content:encoded>
      <author>Lymerin</author>
      <category>OpenTelemetry</category><category>Java Agent</category><category>Spring Rabbit</category><category>RabbitMQ</category><category>Code Review</category><category>版本兼容</category>
    </item>
    <item>
      <title>当“没有实例”也是一次更新：Apache ShenYu ZooKeeper 缓存残留修复</title>
      <link>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr7372</link>
      <guid>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr7372</guid>
      <updated>2026-09-30T18:54:10.000Z</updated>
      <pubDate>2026-09-30T18:54:10.000Z</pubDate>
      <description><![CDATA[维护另一个 PR 的间隙，我遇到了一个很小的缓存问题。顺着空列表往下看，才发现“没有实例”和“没有更新”其实是两回事。]]></description>
      <content:encoded><![CDATA[
<p>当时我正在维护另一个 ShenYu PR <a href="https://github.com/apache/shenyu/pull/7289" rel="noopener noreferrer" target="_blank">#7289</a>。维护者让我处理 merge conflicts，在等待和重新跑测试的间隙，我又翻了一下项目里的 Issue，看到了 <a href="https://github.com/apache/shenyu/issues/6526" rel="noopener noreferrer" target="_blank">#6526</a>。</p>
<p>问题第一眼看起来很简单：ZooKeeper Watcher 收到空的子节点列表时，没有更新本地缓存。最后一个实例已经删掉了，再查一次，返回的却还是旧实例。</p>
<p>和之前的 TCP 问题相比，这次没有复杂的并发设计，也不用沿着很多模块找调用链。最后的生产代码修改，确实只是去掉一个判断。但看进去以后，我觉得它有一个很值得记下来的地方：<strong>空列表是在说“没有数据需要处理”，还是在说“最新状态就是没有数据”？</strong></p>
<p>这个区别想清楚以后，修改就不难了。修复最终通过 <a href="https://github.com/apache/shenyu/pull/7372" rel="noopener noreferrer" target="_blank">PR #7372</a> 合并。下面想记录的，是我怎么理解这个空状态，以及为什么测试没有停在“删干净了”这一步。</p>
<h2>最后一个实例被删了，缓存为什么还在？</h2>
<p><code>ZookeeperInstanceRegisterRepository.selectInstances()</code> 会读取 ZooKeeper 中的实例节点，把结果保存在 <code>watcherInstanceRegisterMap</code> 里，并注册一个 Watcher 来监听后续变化。</p>
<p>假设 <code>service-a</code> 下原来有两个实例。删除其中一个以后，Watcher 重新读取子节点列表，用剩下的实例更新缓存，这时一切正常。真正出问题的是再把<strong>最后一个实例</strong>删掉：ZooKeeper 返回 <code>[]</code>，缓存却没有跟着变空。</p>
<p>原来的 Watcher 回调里，有这样一段逻辑：</p>
<pre><code>// 原有回调中的关键片段
List&lt;String&gt; childrenList = StringUtils.isNotBlank(path)
        ? client.subscribeChildrenChanges(path, this)
        : Collections.emptyList();

if (!childrenList.isEmpty()) {
    watcherInstanceRegisterMap.put(
            selectKey,
            getInstanceRegisterFun.apply(childrenList)
    );
}
</code></pre>
<p>只要列表为空，就跳过更新。于是 ZooKeeper 已经没有实例，本地缓存里却还留着最后一个实例；后续查询命中缓存，读到的自然还是旧结果。</p>
<figure class="not-prose">
<figcaption>图 1 · 同一次空列表通知，修复前后留下了不同的缓存</figcaption>
<div>

空列表更新的修复前后对比
最后一个实例删除后，Watcher 读到空列表。修复前跳过更新，缓存仍为实例 A；修复后保存空快照，缓存为空列表。



最后一个实例被删除
Watcher 读到 children = []

修复前

修复后

因为为空，跳过更新

把空列表也写入缓存

Cache: [A]
仍然返回已经不存在的实例

Cache: []
与 ZooKeeper 当前状态一致

</div>
<div>A 表示之前缓存的实例。小屏可在图内左右滑动查看。</div>
</figure>
<p>第一次看 <code>if (!childrenList.isEmpty())</code> 时，“有数据才更新”其实很容易让人觉得合理。但这里接收的是当前子节点列表，不是一批只需要追加的数据。Watcher 已经告诉我们最新结果是空，这本身就是一次更新。</p>
<p>我后来意识到，问题并不是没收到通知，而是<strong>收到了通知，却因为结果为空，把这次状态变化忽略了</strong>。</p>
<h2>为什么保存空列表，而不是删掉缓存？</h2>
<p>最直接的修复，就是去掉非空判断，让每一次读取到的列表都能更新缓存：</p>
<pre><code>// 修复后的缓存更新，childrenList 也可以是空列表
watcherInstanceRegisterMap.put(
        selectKey,
        getInstanceRegisterFun.apply(childrenList)
);
</code></pre>
<p><code>getInstanceRegisterFun</code> 会把子节点转换成实例列表。输入为空时，得到的也是空列表，因此缓存中会保留 <code>service-a -&gt; []</code>。</p>
<p>看到这里，也很容易想到另一种做法：既然已经没有实例了，直接 <code>remove(selectKey)</code> 不就行了吗？我继续看了 <code>selectInstances()</code> 读取缓存的部分：</p>
<pre><code>final List&lt;InstanceEntity&gt; cachedInstances =
        watcherInstanceRegisterMap.get(selectKey);

if (Objects.nonNull(cachedInstances)) {
    return cachedInstances;
}
</code></pre>
<p>它判断的是有没有缓存结果，而不是结果里有没有实例。<code>[]</code> 是一个有效的命中，下一次查询可以直接返回；如果把整个 entry 删掉，查询就会走到后面的订阅和初始化流程。</p>
<p>这时我才把两件事分开：<strong>没有实例，不代表没有缓存结果。</strong> 我们已经知道这个服务当前没有实例，没必要仅仅因为数量是零，就把它当作一次缓存未命中。</p>

























<table><thead><tr><th>这里读到的结果</th><th>在这段逻辑中的含义</th><th>后续处理</th></tr></thead><tbody><tr><td>缓存为非空列表</td><td>已有当前实例快照</td><td>直接返回缓存</td></tr><tr><td>缓存为 <code>[]</code></td><td>已有快照，当前没有实例</td><td>同样直接返回缓存</td></tr><tr><td>Map 查询返回 <code>null</code></td><td>没有这个 key 的缓存结果</td><td>进入订阅和初始化流程</td></tr></tbody></table>
<p>在这段代码里，<code>null</code> 是没查到缓存，<code>[]</code> 是查到了一个空结果。两者的后续处理不同，不能因为“都没有实例”就混在一起。</p>
<h2>缓存变空以后，还能继续更新吗？</h2>
<p>接着我又想到一个问题：如果 <code>selectInstances()</code> 以后都直接返回缓存里的 <code>[]</code>，那服务重新注册实例的时候，怎么知道它又有数据了？</p>
<p>关键是，<strong>查询命中空缓存，不等于 Watcher 停止工作</strong>。</p>
<p>Watcher 回调仍然会调用 <code>client.subscribeChildrenChanges(path, this)</code>，重新读取子节点并续订监听。之后有新实例出现，回调再把新的实例列表写入同一个缓存 entry。空列表只是这段变化中的一个正常快照，不是终点。</p>
<figure class="not-prose">
<figcaption>图 2 · 查询读快照，Watcher 负责让快照继续变化</figcaption>
<div>

缓存读取与 Watcher 更新的两条路径
监听已经建立且缓存存在时，查询直接返回快照，包括空列表。子节点发生变化后，Watcher 重新读取并续订，再把新快照写入缓存。查询命中空列表不会终止监听。



查询路径 · 已命中缓存
更新路径 · 监听已建立

selectInstances(key)

子节点发生变化

读取已有缓存快照
[] 也是有效命中

Watcher 回调
重新读取子节点，并续订监听

转换后写入缓存
无论列表里有没有实例

直接返回，不重新初始化

Cache 保存最新快照
后续变化仍由 Watcher 处理

</div>
<div>示意监听正常建立后的读写路径，省略缓存未命中与异常处理分支。</div>
</figure>
<p>所以这里不是“把缓存清空以后就不管了”，而是继续用同一套 Watcher 更新当前快照。把读取路径和更新路径放在一起看，这个选择就更容易理解了。</p>
<h2>测试为什么没有停在“已经变空”？</h2>
<p>这次我觉得测试比生产代码的修改更值得展开一点。原来的测试主要检查有实例时能正常读取，我把它补成了 <code>1 → 0 → 1</code>：先有一个实例，删除最后一个，再让实例重新出现。</p>
<p>测试用一个简单状态变量控制模拟的 ZooKeeper 返回值：</p>
<pre><code>final boolean[] hasInstance = {true};

// 根据当前模拟状态，返回一个子节点或空列表
when(mock.subscribeChildrenChanges(anyString(), any(CuratorWatcher.class)))
        .thenAnswer(invocation -&gt; {
            watcherArr[0] = (CuratorWatcher) invocation.getArguments()[1];
            return hasInstance[0]
                    ? Collections.singletonList("shenyu-test")
                    : Collections.emptyList();
        });
</code></pre>
<p>接着，主动触发捕获到的 Watcher 回调，检查每一次状态变化后的查询结果：</p>
<pre><code>// 测试中的关键步骤，省略 repository 和 mockEvent 的初始化
assertEquals(1, repository.selectInstances(selectKey).size());

hasInstance[0] = false;
watcherArr[0].process(mockEvent);
assertTrue(repository.selectInstances(selectKey).isEmpty());

hasInstance[0] = true;
watcherArr[0].process(mockEvent);
assertEquals(1, repository.selectInstances(selectKey).size());
</code></pre>
<figure class="not-prose">
<figcaption>图 3 · 1 → 0 → 1：变空以后，缓存还能跟着下一次变化恢复</figcaption>
<div>

实例与缓存的一到零再到一状态变化
三个阶段依次为存在一个实例 A、删除后为空、实例 A 重新出现。初始查询与后两次 Watcher 回调，使缓存分别保存 A、空列表、A。空列表是中间快照，并不阻断后续更新。


有一个实例
删除最后一个
实例重新出现

ZooKeeper
[A]

ZooKeeper
[]

ZooKeeper
[A]

初始查询

Watcher 回调

Watcher 回调

Cache
[A]

Cache
[]

Cache
[A]
空列表是正常快照，不是监听的终点

</div>
<div>对应这次单元测试的状态序列；A 是模拟返回的同一个实例。</div>
</figure>
<p>如果测试只停在中间一步，能检查旧实例有没有残留，但看不到后续变化还能不能生效。再往后走一步，就把“从有到无”和“从无到有”连起来了。</p>
<p>PR 里还记录了一个对我很有帮助的检查：先加上空列表的断言，它在旧代码下失败；去掉判断以后，再运行通过。这样能确认，新测试确实抓住了这次修复的问题，而不只是给测试多加了几行代码。</p>
<h2>和之前的空快照问题，原来有同一个盲点</h2>
<p>做这个 Issue 时，我想起了<a href="/zh-cn/posts/shenyu-pr6909">第一次处理的 HTTP 空快照问题</a>。那次是收到空的 ProxySelector 快照以后，刷新链路没有把旧的 TCP Server 清理掉；这次是 ZooKeeper 返回空子节点列表以后，旧实例缓存没有被覆盖。</p>
<p>模块不同，后果也不同，但我在两次排查里碰到了一个相似的盲点：看到列表为空，很容易顺手把它理解成“这次没东西需要做”。</p>
<p>但对这些全量快照来说，空数据可能恰恰在说：<strong>之前存在的东西，现在已经全部没有了。</strong> 继续保留旧状态，反而和这次更新的含义相反。</p>
<p>我以前更习惯关注“数据来了以后怎么处理”，这两次之后，开始会多问一句：数据从有变成没有的时候，代码会走哪条分支？旧状态会不会还留在那里？</p>
<h2>改动很小，但我想记下的不只是那两行代码</h2>
<p>和前两个 PR 相比，这次修复简单很多。最后也没有做什么很大的改造，就是删掉一个非空判断，补上状态变化的测试。</p>
<p>不过我并不觉得它只能写成一句“修复缓存残留”。对我来说，这次有意思的地方，是沿着一个看起来很合理的判断往下读，发现它其实混淆了两种状态：没有缓存结果，和已经知道结果为空。</p>
<p>也让我对测试有了一点新的想法。除了检查某个时刻返回什么，还可以把前后变化连起来看：先有数据，后来没有，再后来又有。很多问题正好藏在这些过渡里，而不是某一个静态结果里。</p>
<p>这次记下的东西很简单：<strong>“没有实例”不是“没有状态”，它本身就是当前状态。</strong> 以后再遇到 Watcher、全量同步或者快照式缓存，我想自己会更留意那个容易被直接跳过的空列表。</p>
<h2>相关链接</h2>
<ul>
<li><a href="https://github.com/apache/shenyu/issues/6526" rel="noopener noreferrer" target="_blank">Issue #6526 · 最后一个子节点删除后，ZooKeeper 实例缓存仍有旧数据</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7372" rel="noopener noreferrer" target="_blank">PR #7372 · 更新空子节点列表对应的实例缓存</a></li>
<li><a href="https://github.com/apache/shenyu/blob/1cadb4b09d7cdff47f233ff72d7ff29da1cbef75/shenyu-registry/shenyu-registry-zookeeper/src/main/java/org/apache/shenyu/registry/zookeeper/ZookeeperInstanceRegisterRepository.java" rel="noopener noreferrer" target="_blank">合并版本中的实现</a></li>
<li><a href="https://github.com/apache/shenyu/blob/1cadb4b09d7cdff47f233ff72d7ff29da1cbef75/shenyu-registry/shenyu-registry-zookeeper/src/test/java/org/apache/shenyu/registry/zookeeper/ZookeeperInstanceRegisterRepositoryTest.java" rel="noopener noreferrer" target="_blank">这次补充的 Watcher 测试</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7289" rel="noopener noreferrer" target="_blank">PR #7289 · 当时正在维护的 discovery 缓存删除修复</a></li>
</ul>]]></content:encoded>
      <author>Lymerin</author>
      <category>Apache ShenYu</category><category>Java</category><category>ZooKeeper</category><category>Watcher</category><category>缓存一致性</category>
    </item>
    <item>
      <title>从一把锁的争论到 Single-Flight：Apache ShenYu TCP 并发创建修复</title>
      <link>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr7012</link>
      <guid>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr7012</guid>
      <updated>2026-09-30T18:15:22.000Z</updated>
      <pubDate>2026-09-30T18:15:22.000Z</pubDate>
      <description><![CDATA[第二次参与 ShenYu：记录一次 TCP Server 并发问题的方案取舍，以及几个 AI 给出不同答案之后，我是怎么继续判断的。]]></description>
      <content:encoded><![CDATA[
<p><a href="/zh-cn/posts/shenyu-pr6909">上一篇</a>记录了我第一次在 ShenYu 中完整处理一个 Issue。那次，我更多是在学习怎么从问题入口出发，沿着调用链往下读。第二次遇到的问题，表面上反而更简单：检查缓存，没有就创建一个 TCP Server，再放进缓存。</p>
<p>单线程下，这段逻辑很好理解。但如果两个同步事件同时处理同一个 selector，它们就可能都看到“还没有”，然后各自启动一个 Server。这正是 <a href="https://github.com/apache/shenyu/issues/6735" rel="noopener noreferrer" target="_blank">Issue #6735</a> 指出的问题。</p>
<p>我最初以为，把这几个操作保护起来就行。真正开始比较方案之后，才发现麻烦的不是“会不会加锁”，而是<strong>应该保护多大的范围，以及一个 selector 的慢操作会不会把其他 selector 也堵住</strong>。</p>
<p>这次我也让几个 AI 分别分析了问题，得到的答案却不太一样。最后的修复通过 <a href="https://github.com/apache/shenyu/pull/7012" rel="noopener noreferrer" target="_blank">PR #7012</a> 合并。下面想记下的不只是 single-flight 怎么实现，也包括我在几个看起来都有道理的方案之间，是怎么继续做判断的。</p>
<h2>看起来只是三个操作，为什么会重复创建？</h2>
<p>原来的处理路径可以简化成：</p>
<pre><code>// 原有逻辑的简化示意
if (!cache.containsKey(name)) {
    BootstrapServer server = createBootstrapServer(...);
    cache.put(name, server);
}
</code></pre>
<p>检查、创建、发布，是三个独立动作。即使用的是 <code>ConcurrentHashMap</code>，也不会自动把它们变成一整个原子操作。线程 A 检查完缓存之后，线程 B 完全可能在 A 放入结果之前，也通过同一次检查。</p>
<figure class="not-prose">
<figcaption>图 1 · 同一个 selector，两个线程都通过了缓存检查</figcaption>
<div>

图 1 · 同一个 selector，两个线程都通过了缓存检查
按从上到下的顺序，线程 A 和 B 先后检查缓存，都看到不存在，然后各自进入 TCP Server 创建流程，产生重复启动与资源管理风险。




Thread A
Thread B
检查缓存：不存在inCache(name) → false
检查缓存：仍不存在inCache(name) → false
进入 Server 创建createBootstrapServer(...)
也进入 Server 创建createBootstrapServer(...)
重复启动与资源管理风险端口绑定冲突 / 新建资源失去缓存跟踪

</div>
<div>线程交错示意，从上往下读。小屏可在图内左右滑动查看。</div>
</figure>
<p>这里的创建还不是普通的 <code>new</code>：<code>createBootstrapServer()</code> 会调用启动方法，涉及 Event Loop、端口绑定和 TCP Server 启动。因此，重复创建不只是多了一个 Java 对象，还可能遇到端口绑定失败，或者让已经创建的服务和资源失去缓存跟踪。</p>
<p>看到这里，我意识到，需要保护的是<strong>同一个 selector 的这一轮创建</strong>，而不只是某一次 Map 操作。但不同 selector 本来没有这个冲突，没必要一起排队。</p>
<h2>几个方案都有道理，为什么最后没有选全局锁？</h2>
<p>我当时把问题交给了几个 AI 分别分析。它们给出的方向差别挺大：DeepSeek 的回答偏向全局锁，GPT 的回答偏向公平读写锁和更严格的原子边界，Gemini 则更倾向按 selector 保存创建状态，不使用全局生命周期锁。</p>
<p>单独看，每种方案都能解释得通。全局锁最直接，把检查、创建、发布包在同一个临界区里，思路很清楚。读写锁看起来更细，可以区分读写访问；公平模式也试图减少线程长期抢不到锁的情况。</p>
<p>但我继续往下想时，关注点变了：<strong>这把锁会让谁等谁？</strong></p>
<p>TCP Server 的创建和关闭都可能比较慢。如果在整个 Factory 上使用一把全局锁，让启动、关闭这些操作也进入临界区，selector-a 的慢操作就可能挡住无关的 selector-b。把 <code>synchronized</code> 换成公平读写锁，也不会自动改变这个互斥范围。</p>





















<table><thead><tr><th>讨论过的方向</th><th>我当时主要考虑的地方</th></tr></thead><tbody><tr><td>全局锁</td><td>容易理解，但不想让无关 selector 的启动和关闭一起排队</td></tr><tr><td>全局公平读写锁</td><td>能细分读写访问，但仍要判断哪些操作持锁、持多久</td></tr><tr><td>按 selector 的 single-flight</td><td>同一轮创建只交给一个线程，其他 selector 不争同一把全局锁</td></tr></tbody></table>
<p>所以后来我给自己定的方向是：只限制真正发生冲突的同一个 selector，让其他 selector 尽可能独立地处理。</p>
<p>这不是说“锁越少越好”。对我来说，更重要的是先弄清楚要保护什么，再决定互斥范围，而不是看到并发问题就先把锁加上。</p>
<h2>Single-flight：给正在创建的 selector 留一个位置</h2>
<p>最终的实现是在 <code>TcpBootstrapFactory</code> 中增加一个创建状态表：</p>
<pre><code>ConcurrentMap&lt;String, CompletableFuture&lt;BootstrapServer&gt;&gt; creations
</code></pre>
<p>这里的 <code>CompletableFuture</code> 让我觉得很有意思。它不只是“异步编程工具”，也可以用来表示：<strong>这个 selector 已经有人在创建了，后来的请求可以等这一次结果。</strong></p>
<p>准备创建时，先尝试登记自己的 Future：</p>
<pre><code>CompletableFuture&lt;BootstrapServer&gt; creation =
        new CompletableFuture&lt;&gt;();

CompletableFuture&lt;BootstrapServer&gt; existingCreation =
        creations.putIfAbsent(selectorName, creation);
</code></pre>
<p><code>putIfAbsent()</code> 是原子的。同一个 selector 同时收到两个请求时，只有一个能成功放入占位符，成为这一轮的创建者；另一个拿到已有 Future，走 <code>awaitCreation(existingCreation)</code>，不再自己启动 Server。</p>
<figure class="not-prose">
<figcaption>图 2 · 同一轮创建，一个线程执行，另一个等待</figcaption>
<div>

图 2 · 同一轮创建，一个线程执行，另一个等待
缓存未命中时，线程用 creations.putIfAbsent 登记占位符。登记成功者启动并发布服务，然后完成 Future；其他相同 selector 的调用等待这次 Future，不重复创建。示意省略二次缓存检查与发布冲突分支。









同一个 selector 的创建请求缓存未命中
原子登记创建占位符creations.putIfAbsent(name, future)
登记成功
已有占位
成为创建者启动 TCP Server
成为等待者awaitCreation(existingCreation)
发布启动成功的 Servercache.putIfAbsent(name, server)
完成同一次 Futurecreation.complete(server)
移除本轮创建占位符finally: remove(name, creation)
等待结束，不重复创建当前调用返回 false

</div>
<div>展示成功主路径；虚线表示 Future 完成后等待者得以继续。小屏可在图内左右滑动查看。</div>
</figure>
<p>这就是这里的 single-flight：对同一个 key，同一轮只由一个线程执行创建，其他相同请求等待这一次执行。不同 selector 使用不同的占位符，因此不需要争夺一把 Factory 级别的生命周期锁。</p>
<p>最终实现还有两处缓存检查：进入创建流程前先检查已有缓存，成功登记占位符后再检查一次。真正的 Server 启动放在 Map 的原子操作之外，没有把可能比较慢的启动逻辑塞进 <code>computeIfAbsent()</code> 的回调里。</p>
<h3>有了占位符，为什么发布时还用 putIfAbsent？</h3>
<p>方案到这里还没结束。创建完成、准备放入缓存时，也要处理“缓存里已经出现了另一个实例”的情况。原有的公开 Factory 方法仍然保留，不能只因为新入口有 single-flight，就假定其他路径一定不会写入缓存。</p>
<p>所以发布时不是直接 <code>cache.put()</code>，而是：</p>
<pre><code>BootstrapServer existingServer =
        cache.putIfAbsent(selectorName, bootstrapServer);

if (existingServer != null) {
    bootstrapServer.shutdown();
}
</code></pre>
<p>如果已有实例，就关闭这次新建但没有成功发布的实例，而不是覆盖已有值。然后把已有实例完成到 Future 中，让等待者结束等待。</p>
<p>这里我开始意识到，一个主方案讲得通，不代表周围的状态变化就都不用再检查。占位符负责协调这一轮创建，缓存的原子发布负责守住最后一道边界，两者解决的不是同一个问题。</p>
<h2>创建失败了，等待中的线程怎么办？</h2>
<p>再往下推演，就会碰到失败路径。比如创建者在绑定端口时失败了，另一边却还有线程在等它的 Future。</p>
<p>如果只让创建线程抛出异常，却没有完成这个 Future，等待者就没有办法知道这次创建已经结束了。因此，失败也要作为这一轮创建的结果传出去：</p>
<pre><code>creation.completeExceptionally(ex);
throw ex;
</code></pre>
<p>等待方通过 <code>join()</code> 得到失败，<code>awaitCreation()</code> 再从 <code>CompletionException</code> 中取出原始原因。对于这里处理的运行时异常，会重新抛出原来的异常。</p>
<p>不管成功还是失败，最后都会移除这次创建的占位符：</p>
<pre><code>creations.remove(selectorName, creation);
</code></pre>
<p>这里使用 <code>remove(key, value)</code>，只移除当前这一轮的 Future。失败的占位符清掉之后，后续请求才有机会重新尝试。</p>
<figure class="not-prose">
<figcaption>图 3 · 启动失败后，也要让这一轮创建结束</figcaption>
<div>

图 3 · 启动失败后，也要让这一轮创建结束
TCP Server 启动失败后尝试释放已经创建的 LoopResources，保留原始启动异常；工厂将 Future 完成为失败，等待线程得到异常，finally 移除当前占位符，让后续请求能够重试。






TCP Server 启动失败例如端口绑定失败
尝试释放已创建的 LoopResources清理异常通过 addSuppressed 保留
将创建 Future 标记为失败creation.completeExceptionally(ex)
等待者收到创建异常awaitCreation 解包原始原因
清除当前创建占位符finally: remove(name, creation)
后续请求可以重新尝试不会被失败占位符一直挡住

</div>
<div>小屏可在图内左右滑动查看。</div>
</figure>
<p>这部分让我意识到，Future 占位符不是登记完就能不管了。成功要通知等待者，失败也要通知，最后还要清掉本轮状态。少了一步，原本用来协调线程的机制，反而可能把后来的请求一直挡住。</p>
<h2>并发创建之外，还要照顾资源的开始和结束</h2>
<p>在这次修复里，我也继续检查了 <code>TcpBootstrapServer</code> 的生命周期。Server 启动失败时，创建过程可能已经走了一半；关闭时，也可能被多个清理路径重复调用。</p>
<h3>启动失败，要释放已经创建的资源</h3>
<p>启动过程会先创建 <code>LoopResources</code>，再调用 <code>bindNow()</code> 绑定端口。如果后一步失败，前面创建的资源不会因为方法抛出异常就自动释放。</p>
<p>因此，这次在启动失败时补了清理逻辑。如果清理本身也抛出异常，就把它作为 suppressed exception 挂到原始启动异常上，再继续抛出原始异常。</p>
<p>这样，排查时能看到最初为什么启动失败，也不会丢掉随后清理失败的信息。</p>
<h3>shutdown() 可以重复调用，但关闭流程不重复执行</h3>
<p>这次还给 <code>shutdown()</code> 加了实例级别的 <code>synchronized</code> 和 <code>disposed</code> 标记。同一个服务实例的关闭不会并发执行；关闭流程结束时，在 <code>finally</code> 中标记为已处理，后续调用直接返回。</p>
<p>这里不是给整个 Factory 加一把全局锁，只是在同一个 Server 实例上协调关闭。</p>
<p>另外，<code>server.disposeNow()</code> 失败后，仍然会尝试释放 <code>LoopResources</code>；如果两处都失败，后面的异常通过 <code>addSuppressed()</code> 保留下来。即使关闭抛出异常，<code>disposed</code> 也会置为 <code>true</code>，后续调用不会自动重试。</p>
<h3>一个 selector 关闭得慢，不要挡住另一个</h3>
<p>删除操作通过 <code>removeAndShutdown(selectorName)</code> 先从缓存中原子移除实例，再执行关闭。不存在时就直接返回，关闭操作不放在 Factory 的全局生命周期锁里。</p>
<p>这也是我比较在意的一点：修复同一个 selector 的重复创建，不应该顺带把其他 selector 的删除堵住。PR 的测试专门构造了一个服务关闭被卡住、另一个服务仍然可以删除的场景。</p>
<h2>测试时，我开始更多地关注失败和线程交错</h2>
<p>相比上一篇，这次测试更偏向并发与生命周期边界，而不是只看某个方法有没有被调用。</p>





































<table><thead><tr><th>场景</th><th>验证内容</th></tr></thead><tbody><tr><td>同一 selector 并发创建</td><td>16 个线程同时请求，只有一次调用真正创建并缓存 Server</td></tr><tr><td>创建失败</td><td>不留下成功缓存，后续可以重新尝试</td></tr><tr><td>等待中的调用</td><td>接收到原始创建失败</td></tr><tr><td>不同 selector 删除</td><td>一个 shutdown 卡住，不阻塞另一个 selector 的删除</td></tr><tr><td>bind 失败</td><td>尝试释放已创建的 LoopResources</td></tr><tr><td>重复 shutdown</td><td>关闭流程不重复执行</td></tr><tr><td>多处释放失败</td><td>通过 suppressed exception 保留异常信息</td></tr></tbody></table>
<p>我觉得这次测试带来的变化是：不能只顺着“创建成功、正常关闭”这条路看，还要主动想一想，两个线程插在一起时会怎样，操作只完成一半时又会留下什么。</p>
<p>把这些场景拆开之后，我对自己的实现才更有把握，也更容易发现只看正常路径时漏掉的地方。</p>
<h2>第二次参与开源，我对 AI 协作的理解也变了一点</h2>
<p>这次最值得我记下的，其实不只是 single-flight 这个方案，还有几个 AI 给出不同答案之后，我是怎么继续往下想的。</p>
<p>一开始，每种回答都能讲出一套理由，我很难只看解释就判断谁更适合。后来我发现，与其继续问“哪一个更安全”，不如把问题具体化：如果 selector-a 启动很慢，selector-b 会不会等？如果创建失败，正在等 Future 的线程会怎样？如果资源已经分配了一半，这时候谁负责清理？</p>
<p>这些问题更接近代码实际会遇到的情况，也让我不再只盯着某个方案的名字。</p>
<p>最后，我没有直接照搬某一个模型的完整代码。按 selector 保存占位状态的主方向更接近 Gemini 当时的建议，而 GPT 的分析也让我继续注意原子发布、异常传播和资源释放这些细节。我需要做的，是判断它们能不能放进同一套实现里，以及哪些改动确实属于这个 Issue。</p>
<p>这个过程比“AI 帮我写了一段代码”更有价值。模型之间意见不一致，有时会让我更困惑，但也会逼着我把原本模糊的要求说清楚：到底哪些操作需要互斥，哪些等待是不必要的，失败之后资源又归谁管。</p>
<p>上一篇让我开始找到阅读大型仓库的方法。这次则让我意识到，有了 AI，也不能省掉自己理解代码和做取舍的过程。它可以帮我找问题、提方案、补充我没想到的场景，但最后提交的是我的 PR，我还是需要知道里面每一处修改为什么存在。</p>
<p>现在我更愿意把 AI 当作几个可以一起讨论的 reviewer，而不是等它们给出一个标准答案。对还在学习的我来说，能借助这些讨论，把一个原本只想到“加锁”的问题继续想清楚，就是这次参与开源很实在的收获。</p>
<hr />
<h2>相关链接</h2>
<ul>
<li><a href="/zh-cn/posts/shenyu-pr6909">上一篇：第一次参与 Apache ShenYu 的 Bug 修复</a></li>
<li><a href="https://github.com/apache/shenyu/issues/6735" rel="noopener noreferrer" target="_blank">Issue #6735</a></li>
<li><a href="https://github.com/apache/shenyu/pull/7012" rel="noopener noreferrer" target="_blank">PR #7012</a></li>
</ul>]]></content:encoded>
      <author>Lymerin</author>
      <category>Apache ShenYu</category><category>Java</category><category>并发控制</category><category>Single-Flight</category><category>资源生命周期</category><category>AI 协作</category>
    </item>
    <item>
      <title>从 refresh() 追到 TCP Server：第一次参与 Apache ShenYu 的 Bug 修复</title>
      <link>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr6909</link>
      <guid>https://lymerinblog.z7.web.core.windows.net/zh-cn/posts/shenyu-pr6909</guid>
      <updated>2026-09-30T17:29:33.000Z</updated>
      <pubDate>2026-09-30T17:29:33.000Z</pubDate>
      <description><![CDATA[记录我第一次在 Apache ShenYu 中完整处理一个 Issue 的过程，以及读代码、修复问题时的一些收获。]]></description>
      <content:encoded><![CDATA[
<p>这是我第一次比较完整地在 Apache ShenYu 这样规模的开源仓库里处理一个 Issue。这次遇到的是 <a href="https://github.com/apache/shenyu/issues/6781" rel="noopener noreferrer" target="_blank">Issue #6781</a>：HTTP 同步收到空的 ProxySelector（代理选择器）列表后，Gateway 没有及时清理原有状态，可能留下已经不再需要的 TCP 服务。</p>
<p>刚看到空列表分支直接 <code>return</code> 时，我以为补一次 <code>refresh()</code> 调用就能解决。但顺着代码往下读，才发现事情没有这么简单：<strong>刷新请求经过 Subscriber 后，并没有继续传到真正负责关闭 TCP Server 的 Handler。</strong> 要修复这个问题，我还得弄清楚这几个模块是怎么配合的。</p>
<p>最后，修复通过 <a href="https://github.com/apache/shenyu/pull/6909" rel="noopener noreferrer" target="_blank">PR #6909</a> 合并。这篇文章想记下从“一行调用”继续往下追的过程，也聊聊第一次在不熟悉的开源项目里改代码时，我学到了什么。</p>
<h2>配置删空，TCP Server 为什么还在？</h2>
<p>先看一下这次问题涉及的配置和运行状态。Apache ShenYu 是一个基于 Java 的 API 网关，支持多种协议代理和动态配置。</p>
<p>其中，TCP 插件可以通过 ProxySelector（代理选择器）配置监听端口、负载均衡策略及上游服务等信息。管理员在 Admin 中修改相关配置后，这些配置可以同步到 Gateway，并反映到实际运行的 TCP 代理服务中。</p>
<p>正常情况下，我们希望配置和运行状态保持一致。例如，Admin 中删除某个代理选择器后，Gateway 应当不再保留对应的 TCP 服务。</p>
<p>问题发生在配置同步返回<strong>空列表</strong>的时候。</p>
<p>假设最初存在两个代理选择器：</p>
<pre><code>Admin:
  ProxySelector A
  ProxySelector B

Gateway:
  TCP Server A
  TCP Server B
</code></pre>
<p>随后，管理员删除了全部代理选择器。下一次 HTTP 全量同步返回的 ProxySelector 数据变成空列表（下面只是简化示意，不是原始响应的完整格式）：</p>
<pre><code>{
  "PROXY_SELECTOR": {
    "data": []
  }
}
</code></pre>
<p>按照预期，Gateway 应当识别到这组配置已经为空，并清理原有运行状态。</p>
<p>但原来的 <code>ProxySelectorRefresh</code> 在检测到空列表后，只记录了一条日志，然后直接 <code>return</code>。<strong>同步入口知道配置已经为空，却没有把清理动作传递给真正保存运行时状态的组件。</strong></p>
<figure class="not-prose">
<figcaption>图 1 · 空快照处理：修复前后对比</figcaption>
<div>

空快照处理：修复前后对比
HTTP 空快照进入 ProxySelectorRefresh；修复前直接返回，旧服务残留；修复后沿 Subscriber 和 Handler 转发，清理缓存并尝试关闭服务。








HTTP 同步收到空快照ProxySelector 列表为空
ProxySelectorRefresh
修复前
修复后
直接 return
旧 TCP Server 残留清理事件没有传到资源管理层
通知 Subscriberrefresh()
分发到 Handlerrefresh()
清理 TCP 服务缓存TcpBootstrapFactory.clearCache()
移除缓存 · 尝试 shutdown()

</div>
<div>小屏可在图内左右滑动查看。</div>
</figure>
<p>看到这里，我意识到，<strong>空列表也有它的含义</strong>。如果这次全量同步是成功的，那么返回空列表就表示当前已经没有任何代理选择器，而不是“不需要处理”。</p>
<p>如果程序把空列表理解成“不需要处理”，运行状态就可能与 Admin 的配置出现偏差。</p>
<h2>追踪 refresh()：调用链在哪里断开？</h2>
<p>明确问题后，我主要沿着这条调用链检查：</p>
<pre><code>ProxySelectorRefresh
        ↓
ProxySelectorDataSubscriber
        ↓
CommonProxySelectorDataSubscriber
        ↓
ProxySelectorDataHandler
        ↓
TcpProxySelectorDataHandler
        ↓
TcpBootstrapFactory
        ↓
BootstrapServer
</code></pre>
<p>这里涉及两个不同的层次。</p>
<p>第一个层次是<strong>数据同步</strong>。<code>ProxySelectorRefresh</code> 负责接收同步数据，并通知订阅者。</p>
<p>第二个层次是<strong>插件的运行时状态</strong>。具体插件的 Handler 负责管理对应的代理服务，而 TCP 插件通过 <code>TcpBootstrapFactory</code> 缓存实际的 <code>BootstrapServer</code>。</p>
<p>问题是，这两层之间的清理行为并没有完全连接起来。</p>
<h3>第一处问题：空列表没有触发刷新</h3>
<p>原本 <code>ProxySelectorRefresh</code> 的空列表分支会直接返回。修复这部分只需要在返回之前通知所有订阅者：</p>
<pre><code>proxySelectorDataSubscribers.forEach(
    ProxySelectorDataSubscriber::refresh
);
</code></pre>
<p>但如果修改只停留在这里，问题仍然没有完全解决。</p>
<h3>第二处问题：refresh 是空实现</h3>
<p>继续查看 <code>ProxySelectorDataSubscriber</code>，我发现它虽然定义了 <code>refresh()</code>，但默认实现没有任何操作。而 <code>CommonProxySelectorDataSubscriber</code> 原本也没有把刷新请求传给实际负责资源管理的 Handler。</p>
<p>换句话说，即使 HTTP 层触发了刷新，插件内部也不会自动清除已有的 TCP 服务。</p>
<p>因此，真正需要解决的问题不只是“有没有调用 <code>refresh()</code>”，而是：</p>
<blockquote><p>这个清理事件能否沿着完整的调用链，最终到达负责管理资源的组件？</p></blockquote>
<p>追到这里，我才明白，不能只看入口有没有调用这个方法，还得继续看它最后做了什么。</p>
<h2>修复设计：沿原有架构传递清理事件</h2>
<p>确认调用链后，我没有选择让 HTTP 同步模块直接操作 TCP Server，而是继续使用 ShenYu 原有的 Subscriber 和 Handler 结构。</p>
<p>这样，数据同步模块只需要表达“当前配置需要清空”，至于如何清理，则交给对应的插件。</p>
<h3>给 Handler 增加统一的刷新入口</h3>
<p>首先，在 <code>ProxySelectorDataHandler</code> 接口中增加一个默认的 <code>refresh()</code> 方法。</p>
<p>这里使用 <code>default</code> 方法，是为了不让现有 Handler 都跟着改。需要清理资源的实现，比如 TCP Handler，再覆盖这个方法。</p>
<p>写到这里，我开始意识到，在已有项目里补一个接口方法，不能只看自己的代码能不能用，还要看看其他实现会不会受影响。当然，空默认实现不会自动清理资源，有状态的 Handler 仍然需要实现自己的清理逻辑。</p>
<h3>由 CommonSubscriber 负责分发</h3>
<p>接下来，修改 <code>CommonProxySelectorDataSubscriber.refresh()</code>，让它遍历 <code>handlerMap</code>，调用对应的 Handler：</p>
<pre><code>handlerMap.values().forEach(
    ProxySelectorDataHandler::refresh
);
</code></pre>
<p>这样，清理逻辑就不再固定于 HTTP 同步模块，而是被放到了已有的插件处理结构中。</p>
<p>这样，HTTP 层不需要知道 TCP 服务该怎么关闭，只需要把刷新请求传下去，具体清理由插件负责。这次我主要处理的，还是 Issue 中的 HTTP 空快照场景。</p>
<h2>缓存清理：移除引用不等于释放资源</h2>
<p>接下来就是 TCP 插件的资源释放。</p>
<p>在 <code>TcpBootstrapFactory</code> 中，项目使用 <code>ConcurrentHashMap</code> 保存代理选择器与 TCP 服务实例之间的对应关系：</p>
<pre><code>selectorName → BootstrapServer

tcp-proxy-a → BootstrapServer A
tcp-proxy-b → BootstrapServer B
</code></pre>
<p>最简单的做法似乎是直接调用 <code>cache.clear()</code>。但这里有一个问题：<strong>从 Map 中删除引用，并不等于真正停止 TCP 服务。</strong></p>
<p><code>BootstrapServer</code> 是运行中的服务实例，可能涉及监听端口以及其他网络资源。仅清空容器，并不会自动调用它的 <code>shutdown()</code>。</p>
<p>如果想确保 TCP 代理选择器被清理，就需要完成两个动作：</p>
<ol>
<li>将实例从缓存中移除。</li>
<li>对移除的实例执行关闭操作。</li>
</ol>
<p>因此，我在 <code>TcpBootstrapFactory</code> 中增加了 <code>clearCache()</code> 方法，统一处理这两件事。</p>
<h3>为什么使用条件删除？</h3>
<p>这里没有采用单纯的遍历再按 key 删除，而是使用类似下面的逻辑：</p>
<pre><code>if (cache.remove(selectorName, bootstrapServer)) {
    // 关闭当前移除成功的实例
}
</code></pre>
<p><code>ConcurrentHashMap.remove(key, value)</code> 的意义是：只有当前 key 对应的值仍然是这个实例时，才执行删除。</p>
<p>考虑一个并发场景：线程 A 正在执行全量清理；与此同时，线程 B 更新了某个代理选择器，把旧服务替换成新服务。</p>
<p>如果线程 A 直接按照遍历时拿到的 key 删除，就可能误删线程 B 刚刚更新的实例。条件删除可以避免这种特定情况：发现当前值已经改变时，线程 A 不再删除新值。</p>
<p>同时，只有成功移除缓存项的线程才继续关闭对应实例，也减少了并发清理时重复关闭同一对象的风险。</p>
<p>不过，条件删除保护的是当前这条缓存项，并没有把整个全量刷新变成原子操作。</p>
<h3>如果一个 TCP Server 关闭失败呢？</h3>
<p>假设当前存在三个服务：</p>
<pre><code>Server A
Server B
Server C
</code></pre>
<p>如果在关闭 Server B 时抛出异常，直接让异常中断整个遍历，就可能导致 Server C 没有机会执行清理。</p>
<p>因此，<code>clearCache()</code> 会在每个实例的 <code>shutdown()</code> 外捕获 <code>RuntimeException</code>，记录错误并继续处理其他实例。这样可以避免单个实例的关闭异常阻断其他实例的清理。</p>
<p>关闭失败的实例仍可能有资源没有释放。这次先记录异常，让其他服务继续清理，没有再把重试和资源恢复一起加进来。</p>
<figure class="not-prose">
<figcaption>图 2 · TCP 缓存清理及异常处理</figcaption>
<div>

TCP 缓存清理与异常处理
遍历缓存；条件删除失败则跳过，成功则调用 shutdown；RuntimeException 记录后继续遍历，未抛出异常也继续遍历。关闭失败不代表资源已释放，全量清理也不具备严格原子性。










遍历 TCP Server 缓存selectorName / BootstrapServer
条件删除成功？remove(key, value)
否
是
调用 shutdown()
跳过该条目
抛出 RuntimeException？
是
否
记录异常
未抛出异常
继续遍历

</div>
<div>小屏可在图内左右滑动查看。</div>
</figure>
<h2>单条删除：check-then-act 的竞态</h2>
<p>除了全量清理，这次还修改了 <code>TcpProxySelectorDataHandler.removeProxySelector()</code>。</p>
<p>原来的实现会先调用 <code>inCache()</code> 判断某个选择器是否存在，再调用 <code>removeCache()</code> 获取实例并关闭。乍一看没有问题，但并发情况下，这两个操作不是原子的。</p>
<p>例如：</p>
<pre><code>线程 A：检查 selector 是否存在 → true
线程 B：清理并移除 selector
线程 A：再次 removeCache() → null
线程 A：调用 shutdown() → NullPointerException
</code></pre>
<p>这里就是典型的 <em>check-then-act</em> 问题。</p>
<p><code>ConcurrentHashMap</code> 能保证单次操作的线程安全，但无法自动保证两个独立操作之间的状态不变。</p>
<p>修复思路也比较直接：不再依赖之前的存在性判断，而是直接尝试移除，随后判断返回值是否为空。只有确实取得 <code>BootstrapServer</code> 实例时才调用 <code>shutdown()</code>。</p>
<p>这样既处理了并发移除导致的空指针风险，也让重复删除不存在的选择器成为安全的空操作。</p>
<p>这是修复过程中顺带发现的问题。改动不大，却让我意识到，不能因为用了 <code>ConcurrentHashMap</code> 就觉得这段逻辑一定安全。单次操作是线程安全的，但把“先判断、再删除”放在一起，中间还是可能被其他线程插进来。</p>
<h2>回归测试：验证事件传递与关闭调用</h2>
<p>这次 PR 增加了多层测试，而不是只检查 <code>ProxySelectorRefresh</code> 有没有调用订阅者。</p>

























<table><thead><tr><th>测试位置</th><th>验证内容</th></tr></thead><tbody><tr><td><code>ProxySelectorRefreshTest</code></td><td>空列表触发刷新，非空列表仍执行订阅</td></tr><tr><td><code>CommonProxySelectorDataSubscriberTest</code></td><td>刷新事件能传递到各 Handler</td></tr><tr><td><code>TcpProxySelectorDataHandlerTest</code></td><td>验证 shutdown() 调用、缓存移除和关闭异常隔离</td></tr><tr><td><code>HttpSyncDataServiceTest</code></td><td>HTTP 同步路径能触发 ProxySelector 刷新</td></tr></tbody></table>
<p>其中，我觉得比较有意义的是关闭异常测试。</p>
<p>测试中放入两个模拟的 <code>BootstrapServer</code>，让其中一个在 <code>shutdown()</code> 时主动抛出异常，然后检查另一个是否仍然收到关闭调用，以及两个缓存项是否都已移除。</p>
<p>这样验证的不只是正常路径，还包括清理失败时的处理行为。</p>
<h2>第一次参与之后，我学到了什么？</h2>
<p>这次修复的代码改动不算多，但读代码和确定修改范围的过程，比我一开始想的复杂。最初觉得补一个 <code>refresh()</code> 就够了，后来才发现，要让这次调用真的起作用，还得跨过几个模块，一直追到缓存和 TCP Server。</p>
<p>对我来说，最大的收获不是多认识了几个类，而是开始找到阅读大型代码库的方法。刚接触 ShenYu 时，很容易觉得要先把整个项目弄懂，才有把握动手。但这次做下来，我发现可以先从一个具体问题开始：找到入口，顺着调用往下看，遇到模块边界时，再确认数据和操作有没有继续传下去。这样一点点缩小范围，比一开始就试图读完整个仓库更容易入手。</p>
<p>另一个感受是，修改已有代码和自己从头写项目不太一样。自己写的时候，很多接口和结构都能按自己的想法来；但在 ShenYu 里，改一个方法之前，还需要看看谁在调用它、哪些类实现了它。比如这次给 Handler 增加刷新入口，我不仅要考虑 TCP 插件怎么用，还要尽量不影响其他已有实现。这让我更具体地理解了“兼容性”为什么重要。</p>
<p>并发问题也是类似的。以前看到 <code>ConcurrentHashMap</code>，我更多关注的是这个容器本身是否线程安全。这次把两个线程的执行顺序拆开看，才发现判断和删除之间也会出问题。这个场景让我对“线程安全”有了更具体的认识，而不只是记住一个类的特点。</p>
<p>回头看，我还没有因此就熟悉整个 ShenYu，但至少更清楚该怎么面对一段不熟悉的代码了：不急着改，也不用等到理解所有模块才开始。先把问题相关的那条路径弄清楚，再确认自己的修改会影响哪里。对还在学习的我来说，这就是这次参与开源最实在的收获。</p>
<hr />
<h2>相关链接</h2>
<ul>
<li><a href="https://github.com/apache/shenyu/issues/6781" rel="noopener noreferrer" target="_blank">Issue #6781</a></li>
<li><a href="https://github.com/apache/shenyu/pull/6909" rel="noopener noreferrer" target="_blank">PR #6909</a></li>
<li><a href="https://shenyu.apache.org/zh/docs/2.7.0/plugin-center/proxy/tcp-plugin/" rel="noopener noreferrer" target="_blank">Apache ShenYu TCP 插件文档</a></li>
</ul>]]></content:encoded>
      <author>Lymerin</author>
      <category>Apache ShenYu</category><category>Java</category><category>数据同步</category><category>资源生命周期</category><category>并发安全</category>
    </item>
  </channel>
</rss>