<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>易方札记 | YI FANG</title><link>https://siegaii.com</link><description>关于软件工程、系统架构、AI 应用与长期主义的技术札记。</description><language>zh-cn</language><item><title>过了斩杀线，还没过验收线：DeepSeek V4-Flash 正式版发布随想</title><link>https://siegaii.com/articles/2026-08-deepseek-v4-flash-release/</link><guid>https://siegaii.com/articles/2026-08-deepseek-v4-flash-release/</guid><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>7 月 31 日下午，DeepSeek 的 API 更新日志里多了一行字：「DeepSeek-V4-Flash 正式版 API 上线公测」。没有发布会，没有直播，官网首页没有横幅。当天晚上，它出现在知乎热搜第一的位置。开发者社区当晚讨论里出现频率最高的词，是「斩杀线」。</p>
<p>这行字值得读三遍的地方在于，正式版和四月的预览版是同一个模型：总参数 284B、激活参数 13B，结构、尺寸都没变，只是重新做了后训练。一个 Flash 档位的轻量模型，靠重练「怎么干活」而不是「更大的底座」，在九项 Agent 基准上全线超过自家的 Pro 预览版。</p>
<p>更微妙的是时间线。7 月 24 日，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>deepseek-chat</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>deepseek-reasoner</span></span></code></span> 两个老接口静默停用，所有流量在那之前就已经指向 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>deepseek-v4-flash</span></span></code></span>。也就是说，在「正式」官宣之前，新模型已经在真实流量里跑了一周多，等 bug 被真实请求消化掉，再补一个正式标签。先灰度、后官宣，这件事本身就是这次发布最工程化的注脚。</p>
<h2 id="斩杀线是怎么画出来的">斩杀线是怎么画出来的</h2>
<p>「斩杀线」这个词来自 Artificial Analysis 那一类价格-性能坐标图：横轴是每百万 Token 的输出成本，纵轴是综合智能指数。V4-Flash-0731 拿到 50 分，比自家 V4-Pro 高 6 分，比 GPT-5.6 Luna 的 51 分低 1 分；输出价格是 $0.28/M，Luna 是 $1.2/M。社区流传的那句话是：「Codex、Claude 定上限，DeepSeek 定斩杀线。跟它持平没溢价，落后它就出局。」</p>
<p><img src="/diagrams/series/2026-v4-flash-kill-line.svg" alt="斩杀线：以 V4-Flash 为界的性能与价格双重淘汰"></p>
<p><em>图 1：斩杀线是价格-性能坐标系里的前沿。落在它右下角的模型，要么更弱，要么更贵。点位为公开报道口径的示意位置。</em></p>
<p>数据确实在支持这个说法。OpenRouter 周度调用量登顶；有编程工具厂商称发布当天平台使用量和新订阅都涨了约三成；几家海外初创把推理切过去之后，月推理成本降了六到八成。时间点也带着火药味——OpenAI 在发布前一天刚把 GPT-5.6 Luna 的 API 价格砍了 80%，第二天评论区就贴满了对比图。</p>
<p>但有一个前提值得单独说清楚：斩杀线是「价格-性能」坐标系里的线，坐标系本身是评测机构和媒体画的。它量的是单次任务的性价比，不是生产环境里的可靠性。它不包含你的仓库、你的工具链、你的验收口径。斩杀线是一条市场意义上的线，不是工程意义上的线。</p>
<h2 id="这轮降价为什么不是价格战">这轮降价为什么不是价格战</h2>
<p>如果只看「便宜」，很容易把这次发布理解成又一轮价格战。真正的信号藏在三个工程细节里。</p>
<p>第一个细节是 98% 的缓存命中折扣。缓存命中的输入只收 $0.0028/M，未命中是 $0.14/M，差 50 倍；行业通行的缓存折扣在 90% 左右。这不是慷慨，是对 Agent 工作负载结构的理解：系统提示词、工具定义、前几轮对话历史，每一轮都在重复发送，缓存命中对 Agent 场景来说不是优化选项，而是负载的内建属性。敢把折扣放到 98%，说明官方对命中率有很强的把握——否则这是自杀式定价。单任务成本比已经降价 80% 的 Luna 还低约 60%，关键就来自这里。</p>
<p>第二个细节是峰谷定价预告。定价页上写着：高峰时段（北京时间 9:00–12:00、14:00–18:00）所有计费项按两倍收取，生效时间以正式通知为准。推理负载有明显的潮汐，需要价格杠杆削峰填谷——这说明规模化运行的成本结构是真实的。「把批量任务排到夜间」很快会从省钱技巧变成基本常识。</p>
<p>第三个细节还是那条 changelog 本身：老接口按时停用，新模型先用 preview 标签消化真实流量，再正式宣布。回看 4 月 24 日预览版上线时预告的「三个月后停止维护」，一切都是按时发生的。灰度发布、按时切换、预算写进价格——这是运维纪律，不是营销。</p>
<h2 id="可靠性提升是真的但要看清它测的是什么">可靠性提升是真的，但要看清它测的是什么</h2>
<p>官方放出的九项基准全部上涨，每一项都超过 V4-Pro-Preview：</p>
<table>
<thead>
<tr>
<th>基准</th>
<th>0731 正式版</th>
<th>Preview</th>
<th>考的是什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>Terminal Bench 2.1</td>
<td>82.7</td>
<td>61.8</td>
<td>真实终端里把任务办成</td>
</tr>
<tr>
<td>Cybergym</td>
<td>76.7</td>
<td>38.7</td>
<td>攻防场景的推理与操作</td>
</tr>
<tr>
<td>Toolathlon-Verified</td>
<td>70.3</td>
<td>49.7</td>
<td>正确选工具并调用</td>
</tr>
<tr>
<td>DSBench-FullStack</td>
<td>68.7</td>
<td>37.0</td>
<td>全栈开发（内部测试集）</td>
</tr>
<tr>
<td>DSBench-Hard</td>
<td>59.6</td>
<td>—</td>
<td>高难度开发（内部测试集）</td>
</tr>
<tr>
<td>DeepSWE</td>
<td>54.4</td>
<td>7.3</td>
<td>真实仓库读 issue、写补丁、跑测试</td>
</tr>
<tr>
<td>NL2Repo</td>
<td>54.2</td>
<td>39.4</td>
<td>一句话生成完整仓库</td>
</tr>
<tr>
<td>Agent Last Exam</td>
<td>25.2</td>
<td>—</td>
<td>综合智能体大考</td>
</tr>
<tr>
<td>Automation Bench</td>
<td>25.1</td>
<td>—</td>
<td>自动化流水线执行</td>
</tr>
</tbody>
</table>
<p>DeepSWE 从 7.3 涨到 54.4 是最值得看的一行。它考的是「把真实 GitHub issue 变成通过测试的补丁」：模型在仓库里自己读代码、写改动、跑测试、失败再试。这类分数描述的不是单次回答质量，而是多步任务的完成率。可靠的 Agent 和会聊天的模型之间的分界线就在这里——任务能不能执行到底，失败了会不会带着证据继续找，工具调用是不是稳定。</p>
<p>但三件事必须打折看。第一，这些是官方自测：DeepSeek Harness 的 minimal mode、max effort、特定采样参数，自家框架自家卷子；第二，DSBench 两个指标来自内部测试集，口径不能和公开基准混排；第三，Agent Last Exam 只有 25.2 分——这是业内著名的地狱级卷子，各家普遍在二三十分徘徊，绝对值没有横向意义。</p>
<p>值得交叉验证的是第三方。Artificial Analysis 的智能指数 50 分，比四月预览版高 10 分；Arena 的前端竞技场 1586 分、开放类第三名，比预览版高 154 分。两个独立口径指向同一结论：Agent 能力是真的上来了，不是 changelog 自嗨。但「评测里能交付」和「你的仓库里能交付」之间，还隔着权限模型、工具链版本、验收口径和发布流程——这段沟，工程团队得自己填。</p>
<p><img src="/diagrams/series/2026-ai-reliability-loop.svg" alt="Agent 可靠性的来源是回路，不是模型"></p>
<p><em>图 2：可靠性来自验证回路：失败带证据重试、超限转人工、结果沉淀为评测集，回灌下一次任务。</em></p>
<h2 id="ai-具备大规模工业级应用能力了吗">AI 具备大规模工业级应用能力了吗</h2>
<p>我的判断是：分场景。判断标准有三个——失败成本、可验证性、任务量。</p>
<p>低风险、结果可以自动验收、任务量大的场景，已经具备了。补单测、批量扫描、重复性重构、日志归因，这类工作的完成率已经够用，成本结构也支持铺量：有开发者在知乎说，一个下午跑掉五个多亿 token，账单没花到一杯奶茶钱。这类活以前因为「模型不行」和「太贵」两个原因没进生产，现在两个原因同时消失了。</p>
<p>高风险、语义含糊、失败代价大的场景，还不具备。跨模块排障、架构判断、涉及资金和权限的改动——模型跑分再高，也不是发布审批。这里真正缺的不是模型能力，是验证手段：你能不能为「删除这条用户数据」构造一个可信的验收？不能的话，换哪个模型都不行。</p>
<p>所以「大规模工业级应用」不是模型的属性，是系统属性。我把它拆成四层：</p>
<table>
<thead>
<tr>
<th>层次</th>
<th>这次的进展</th>
<th>还缺什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>模型层</td>
<td>后训练把 Agent 完成率拉高，13B 激活参数跑 Pro 级任务</td>
<td>更强模型的真实边界仍要自己试</td>
</tr>
<tr>
<td>协议层</td>
<td>原生 Responses API，Codex 一行配置接入</td>
<td>无状态实现，历史要自己带</td>
</tr>
<tr>
<td>成本层</td>
<td>98% 缓存折扣、2500 并发、峰谷定价</td>
<td>命中率取决于提示词结构，账单要会算</td>
</tr>
<tr>
<td>治理层</td>
<td>几乎没有官方进展</td>
<td>自动验收、CI、人工门、可观测，全靠组织自建</td>
</tr>
</tbody>
</table>
<p>模型层和成本层的进展是 DeepSeek 给的；协议层给了一半；治理层完全是组织自己的事。四层齐了才叫「可采购的服务」，否则只是「很便宜的 API」。顺着这个框架再看网上的两种说法：说「AI 已经可以大规模工业级应用」的，多半只在模型层做判断；说「跑分都是虚的」的，多半没算成本层。两者都只看见了一部分。</p>
<h2 id="接下来该做什么">接下来该做什么</h2>
<p>给团队和给自己的建议都很朴素。</p>
<p>先让它干能验收的活。低风险、结果可自动验证的任务优先迁移，一次只换一个变量：同一仓库、同一任务集、同一套测试，比较完成率、耗时和 token 消耗。生产、权限、资金、安全的改动保留人工 review 和 CI——便宜不改变责任。</p>
<p>成本账盯缓存那行。系统提示词和工具定义保持稳定、可缓存，是省钱的正解；峰谷定价落地后，批量任务排到夜间。另外 Responses API 目前是无状态实现：不支持 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>previous_response_id</span></span></code></span>，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>store</span></span></code></span> 恒为 false，对话历史得自己带。协议通了，生态还在早期，接入前先读文档而不是看教程截图。</p>
<p>斩杀线是一条市场线，它的位置由价格和跑分决定，这次被大幅前移了。验收线是一条工程线，它的位置由验证回路和发布纪律决定，它不会因为别人发布了新模型而自动移动。</p>
<p>7 月 31 日那行 changelog 里还带着两句没被足够讨论的话：「V4-Pro 正式版将尽快发布」「峰谷定价具体生效时间以正式通知为准」，再加上大概率会来的开源权重，接下来的变量仍然很多。但无论下一版跑分如何，这次发布把一件此前模糊的事说清楚了：在可验收的低风险场景里，AI 已经从「值得试试」变成了「默认选项」。剩下的，是工程团队自己的事。</p>
<h2 id="参考与数据来源">参考与数据来源</h2>
<ul>
<li>DeepSeek API 更新日志与定价页：<a href="https://api-docs.deepseek.com/zh-cn/updates">https://api-docs.deepseek.com/zh-cn/updates</a></li>
<li>IT之家：DeepSeek-V4-Flash 正式版跑分报告（Artificial Analysis / Arena 第三方数据）</li>
<li>卡码笔记：V4-Flash 正式版上线拆解（基准对比、接入与峰谷定价提示）</li>
<li>AI 内参：DeepSeek V4 Flash 正式上线与「AI 斩杀线」讨论综述</li>
<li>掘金：一行 changelog 里的暗涌（九项基准逐项解读、缓存折扣与接口限制）</li>
<li>知乎：DeepSeek V4 flash 正式版发布讨论（开发者实测口径）</li>
</ul>]]></description></item><item><title>如何让 AI 编程 Agent 在真实仓库里交付</title><link>https://siegaii.com/articles/2026-07-ai-coding-agent-real-repo/</link><guid>https://siegaii.com/articles/2026-07-ai-coding-agent-real-repo/</guid><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>第一次让编程 Agent 独立处理完整需求时，我很容易被结果打动：页面、接口、类型和测试都齐了，代码组织得甚至比赶工时更整洁。真正把它接进原有流程，问题才暴露出来。它完成的是一个逻辑自洽的新功能，不一定是这个系统允许发生的变更。</p>
<p>这两者的差距，恰好是软件工程最难被压缩的部分。真实仓库里，业务规则通常不在一个文件中：权限来自网关上下文，状态是数据库字段和队列事件共同推导的，旧客户端依赖未写进接口文档的行为，发布还要兼容未完成的数据迁移。Agent 能快速补齐局部实现，却不会天然知道哪些历史约束值得保留。</p>
<p>下面用一个做过匿名化处理的内部任务平台需求说明这套流程。需求表面很简单：在失败任务列表中增加“批量重试”。仓库是常见的 React、Node.js、PostgreSQL 和消息队列组合，前端展示派生状态，服务端负责租户隔离和状态转换，Worker 按至少一次投递消费任务。</p>
<p>如果只按页面文案理解，批量重试不过是多选框加一个循环调用。真正进入生产前，需要回答的却是：哪些失败允许重试，谁有权限重试，处理中任务如何排除，同一任务会不会被重复入队，五十个任务中有三个状态已经变化时返回什么，以及操作是否能被审计。</p>
<p><img src="/diagrams/series/2026-agent-repo-delivery.svg" alt="编程 Agent 在真实仓库中的交付时序：合同、仓库事实、隔离修改、验证证据与人工评审"></p>
<p><em>图 1：Agent 不是从提示直接跳到代码。仓库事实和任务合同共同约束实现，验证证据再交给评审者做最后判断。</em></p>
<h2 id="先把一句需求还原成状态变化">先把一句需求还原成状态变化</h2>
<p>我不会先让 Agent 写代码，而是让它把需求翻译成业务结果和不变量。批量重试的目标不是“多发几次请求”，而是“操作者能对当前租户内、处于可重试失败状态的任务发起一次可追踪的新执行”。</p>
<p>这句话直接带出五条不能被实现细节稀释的约束：</p>
<ol>
<li>任务必须属于当前租户，不能相信浏览器提交的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>tenantId</span></span></code></span>。</li>
<li>只有 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>failed</span></span></code></span> 且错误类型允许重试的任务可以进入新一轮执行。</li>
<li>每个任务同一时刻只能存在一个有效执行，按钮双击和网络重试不能重复入队。</li>
<li>批量操作允许部分成功，但必须逐项返回稳定结果，不能只给一个笼统的 200 或 500。</li>
<li>谁在什么时间重试了哪些任务，需要进入审计链路。</li>
</ol>
<p>任务合同因此会写成业务语言，而不是文件清单：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> RetryBatchContract</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  outcome</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "retry-eligible-failed-runs"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  invariants</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> [</span></span>
<span data-line=""><span style="color:#A5D6FF">    "tenant scope comes from authenticated context"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#A5D6FF">    "at most one active attempt per run"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#A5D6FF">    "every accepted retry has an audit record"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#A5D6FF">    "partial failure is explicit per item"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  ];</span></span>
<span data-line=""><span style="color:#FFA657">  limits</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">maxBatchSize</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 50</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">maxAttemptsPerRun</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 5</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  outOfScope</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> [</span><span style="color:#A5D6FF">"change retry policy"</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">"redesign run state model"</span><span style="color:#E6EDF3">];</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>outOfScope</span></span></code></span> 很重要。Agent 容易发现现有状态模型不够优雅，然后顺手把一次交付变成状态机重构。这个判断可能没有错，但它扩大了审查面，也把能否发布与另一项高风险工作绑在一起。先把需求做对，再单独处理架构债务，通常更稳妥。</p>
<h2 id="第一轮只建立仓库事实不允许改文件">第一轮只建立仓库事实，不允许改文件</h2>
<p>我会要求 Agent 从用户动作沿调用链向下追，而不是根据文件名随机搜索。这个需求至少需要确认六个位置：列表状态从哪里得到、单条重试入口怎样鉴权、领域层在哪里判断状态转换、队列消息如何生成幂等键、Worker 怎样领取任务，以及相邻测试使用什么构造方式。</p>
<p>第一次探索的交付物是一张很短的仓库地图：</p>
<table>
<thead>
<tr>
<th>边界</th>
<th>当前事实</th>
<th>对本次变更的影响</th>
</tr>
</thead>
<tbody>
<tr>
<td>Web 列表</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>displayStatus</span></span></code></span> 是多个字段的派生值</td>
<td>不能把页面文本当领域状态提交</td>
</tr>
<tr>
<td>API</td>
<td>租户来自认证中间件</td>
<td>请求体不接受 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>tenantId</span></span></code></span></td>
</tr>
<tr>
<td>Domain</td>
<td>单条重试已有 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>canRetry(run, policy)</span></span></code></span></td>
<td>批量入口必须复用同一规则</td>
</tr>
<tr>
<td>Database</td>
<td>活跃 attempt 有唯一约束</td>
<td>幂等应落在事务边界，不只在 UI 防抖</td>
</tr>
<tr>
<td>Queue</td>
<td>至少一次投递</td>
<td>Worker 仍要按 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>attemptId</span></span></code></span> 去重</td>
</tr>
<tr>
<td>Audit</td>
<td>记录操作者、资源和 before/after</td>
<td>批量结果要保留父操作 ID</td>
</tr>
</tbody>
</table>
<p>如果 Agent 找到了另一套看似更方便的写法，我会让它先解释为什么现有边界不适用。真实仓库中，局部一致性通常比新抽象更有价值。评审者熟悉的事务模式、错误码和测试夹具，本身就是维护成本的一部分。</p>
<p>探索阶段还要先读取工作区状态。未提交修改不是噪声，更不能为了“恢复干净基线”而回滚。Agent 只拥有本次任务产生的差异；遇到重叠文件时，先理解现有修改，再决定能否安全合并。</p>
<h2 id="变更计划按风险切片不按前后端目录切片">变更计划按风险切片，不按前后端目录切片</h2>
<p>“先写接口，再写页面”不够具体。我更关心每一步是否能独立证明一个业务事实。这个需求可以拆成四个切片：</p>
<ol>
<li>为现有单条重试补齐领域规则和反例，固定 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>canRetry</span></span></code></span> 的语义。</li>
<li>增加批量命令，在一个事务中领取可重试任务、创建 attempt 和 outbox 事件。</li>
<li>定义逐项结果协议，让前端正确表达成功、跳过和冲突。</li>
<li>增加多选交互，并在提交后按服务端结果更新列表，而不是乐观地把全部行改成“重试中”。</li>
</ol>
<p>这里最值得审查的是第二步。一个常见但不可靠的实现是：先查询任务，再用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Promise.all</span></span></code></span> 循环调用单条重试。查询与写入之间状态可能变化，任何中途失败都会留下难以解释的半成品，数据库成功和消息发送也可能分裂。</p>
<p>更合适的边界是让数据库事务保存新 attempt 与 outbox 事件，提交后由发布器发送队列消息：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">WITH</span><span style="color:#E6EDF3"> candidates </span><span style="color:#FF7B72">AS</span><span style="color:#E6EDF3"> (</span></span>
<span data-line=""><span style="color:#FF7B72">  SELECT</span><span style="color:#79C0FF"> r</span><span style="color:#E6EDF3">.</span><span style="color:#79C0FF">id</span></span>
<span data-line=""><span style="color:#FF7B72">  FROM</span><span style="color:#E6EDF3"> workflow_runs r</span></span>
<span data-line=""><span style="color:#FF7B72">  WHERE</span><span style="color:#79C0FF"> r</span><span style="color:#E6EDF3">.</span><span style="color:#79C0FF">tenant_id</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> $</span><span style="color:#79C0FF">1</span></span>
<span data-line=""><span style="color:#FF7B72">    AND</span><span style="color:#79C0FF"> r</span><span style="color:#E6EDF3">.</span><span style="color:#79C0FF">id</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> ANY($</span><span style="color:#79C0FF">2</span><span style="color:#E6EDF3">)</span></span>
<span data-line=""><span style="color:#FF7B72">    AND</span><span style="color:#79C0FF"> r</span><span style="color:#E6EDF3">.</span><span style="color:#79C0FF">status</span><span style="color:#FF7B72"> =</span><span style="color:#A5D6FF"> 'failed'</span></span>
<span data-line=""><span style="color:#FF7B72">    AND</span><span style="color:#79C0FF"> r</span><span style="color:#E6EDF3">.</span><span style="color:#79C0FF">retryable</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> true</span></span>
<span data-line=""><span style="color:#FF7B72">  FOR</span><span style="color:#FF7B72"> UPDATE</span></span>
<span data-line=""><span style="color:#E6EDF3">), attempts </span><span style="color:#FF7B72">AS</span><span style="color:#E6EDF3"> (</span></span>
<span data-line=""><span style="color:#FF7B72">  INSERT INTO</span><span style="color:#E6EDF3"> run_attempts (run_id, requested_by, </span><span style="color:#FF7B72">status</span><span style="color:#E6EDF3">)</span></span>
<span data-line=""><span style="color:#FF7B72">  SELECT</span><span style="color:#E6EDF3"> id, $</span><span style="color:#79C0FF">3</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">'pending'</span><span style="color:#FF7B72"> FROM</span><span style="color:#E6EDF3"> candidates</span></span>
<span data-line=""><span style="color:#FF7B72">  ON</span><span style="color:#E6EDF3"> CONFLICT (run_id) </span><span style="color:#FF7B72">WHERE</span><span style="color:#FF7B72"> status</span><span style="color:#FF7B72"> IN</span><span style="color:#E6EDF3"> (</span><span style="color:#A5D6FF">'pending'</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">'running'</span><span style="color:#E6EDF3">)</span></span>
<span data-line=""><span style="color:#E6EDF3">  DO NOTHING</span></span>
<span data-line=""><span style="color:#E6EDF3">  RETURNING id, run_id</span></span>
<span data-line=""><span style="color:#E6EDF3">)</span></span>
<span data-line=""><span style="color:#FF7B72">INSERT INTO</span><span style="color:#E6EDF3"> outbox_events (topic, aggregate_id, payload)</span></span>
<span data-line=""><span style="color:#FF7B72">SELECT</span><span style="color:#A5D6FF"> 'run.retry.requested'</span><span style="color:#E6EDF3">, run_id,</span></span>
<span data-line=""><span style="color:#E6EDF3">       jsonb_build_object(</span><span style="color:#A5D6FF">'attemptId'</span><span style="color:#E6EDF3">, id, </span><span style="color:#A5D6FF">'runId'</span><span style="color:#E6EDF3">, run_id)</span></span>
<span data-line=""><span style="color:#FF7B72">FROM</span><span style="color:#E6EDF3"> attempts</span></span>
<span data-line=""><span style="color:#E6EDF3">RETURNING aggregate_id;</span></span></code></pre></figure>
<p>这段 SQL 不是为了炫技。它把三个关键事实放在同一提交点：候选任务仍然符合条件、活跃执行不会重复创建、成功领取的任务一定留下待发布事件。未被领取的 ID 再根据当前状态映射成 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>not_found</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>not_retryable</span></span></code></span> 或 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>already_active</span></span></code></span>，返回给页面。</p>
<p>接口不会只返回一个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>success: true</span></span></code></span>：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "operationId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"retry_01J..."</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "items"</span><span style="color:#E6EDF3">: [</span></span>
<span data-line=""><span style="color:#E6EDF3">    { </span><span style="color:#7EE787">"runId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"r-101"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"status"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"accepted"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"attemptId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"a-301"</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">    { </span><span style="color:#7EE787">"runId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"r-102"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"status"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"skipped"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"reason"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"already_active"</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">    { </span><span style="color:#7EE787">"runId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"r-103"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"status"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"rejected"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"reason"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"not_retryable"</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#E6EDF3">  ]</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>批量接口的价值不是替前端少发几个请求，而是把并发语义、权限和部分成功放进一个可以维护的领域边界。</p>
<h2 id="给-agent-的自主权必须和后果匹配">给 Agent 的自主权必须和后果匹配</h2>
<p><img src="/diagrams/series/2026-agent-change-control.svg" alt="从任务合同到分层证据的 Agent 变更控制面"></p>
<p><em>图 2：上下文、变更范围和执行资源都有预算。任何一项超出合同，Agent 都应停下来重新确认，而不是自行扩大任务。</em></p>
<p>我允许 Agent 自主读取符号、追调用链、修改局部代码、运行测试和整理差异，但不会把所有决定一起交出去。</p>
<p>业务语义仍由人确认。比如“超时失败是否允许重试”取决于外部副作用：任务可能已经向第三方提交成功，只是回执超时。单看数据库状态，Agent 无法判断重试是否安全。</p>
<p>公共协议和数据模型也需要人工承担。它们会影响其他团队和未来迁移，不应因为当前实现方便就改变。生产发布、数据删除、外部通知等不可逆动作，则必须有独立授权。</p>
<p>相反，可逆、局部、已有模式可依循的工作适合让 Agent 连续完成。关键不在于模型能力强弱，而在于错误发生后的影响半径和恢复成本。</p>
<h2 id="验证要能发现实现和测试一起理解错">验证要能发现“实现和测试一起理解错”</h2>
<p>同一个 Agent 根据同一份上下文生成代码和测试，容易形成自洽闭环。测试会证明它实现的规则成立，却未必证明真实规则成立。因此验证来源要刻意分开。</p>
<p>这次变更至少需要五层证据：</p>
<table>
<thead>
<tr>
<th>层级</th>
<th>关键验证</th>
<th>能发现什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>静态</td>
<td>类型、Lint、生产构建</td>
<td>接口漂移、打包和环境问题</td>
</tr>
<tr>
<td>领域</td>
<td>状态转换表和反例</td>
<td>对可重试条件的错误理解</td>
</tr>
<tr>
<td>集成</td>
<td>真实数据库并发提交</td>
<td>重复 attempt、租户越界、事务分裂</td>
</tr>
<tr>
<td>消费</td>
<td>同一消息投递两次</td>
<td>Worker 幂等是否真实成立</td>
</tr>
<tr>
<td>UI</td>
<td>部分成功、长列表、移动端</td>
<td>页面是否诚实表达服务端结果</td>
</tr>
</tbody>
</table>
<p>并发用例不能只 mock 仓储层。需要两个请求同时重试同一个任务，并验证数据库最终只有一个活跃 attempt、一个可发布事件，另一个请求收到稳定的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>already_active</span></span></code></span>。租户用例则用相同 run ID 组合不同认证上下文，确认越界资源不会通过错误信息泄露存在性。</p>
<p>浏览器检查也不是“页面能打开”。我会实际选择一批混合状态任务，触发批量重试，确认按钮在提交期间禁用、逐项结果可见、失败行仍可定位、列表刷新后与服务端一致，并检查最长错误文案和窄屏布局。</p>
<h2 id="交付物不是一句已完成">交付物不是一句“已完成”</h2>
<p>Agent 结束时，需要把结果压缩成评审者能复核的证据包：行为发生了什么变化，复用了哪些边界，哪些命令和场景已经验证，哪些风险没有覆盖，以及如何打开预览复现关键路径。</p>
<p>我更希望看到“并发重试已用真实 PostgreSQL 验证；外部队列故障只验证到 outbox 积压告警，未做灾备演练”，而不是“所有测试通过，功能已完善”。前一种表述给出了证据边界，后一种只是情绪判断。</p>
<p>这也是我使用编程 Agent 后最大的变化。实现候选变便宜了，审查注意力反而更珍贵。工程师需要把时间从逐字符生产代码，转移到定义问题、固定边界、设计独立验证和判断剩余风险。</p>
<p>Agent 可以连续工作很久，也可以比人更耐心地搜索和运行工具。它真正进入真实仓库的前提，不是一次生成更多文件，而是整个过程有可追踪状态、有停止条件、尊重现有工作，并且最终有人能够沿着证据判断：这项变更为什么可以进入生产。</p>
<p>这篇描述的是“Agent 进仓库之后”的工作协议，它的前提是执行环境本身被隔离好了——文件、网络、密钥和资源预算的边界见<a href="/articles/2026-01-coding-agent-sandbox/">编码 Agent 的沙箱</a>。评审端的验收标准——意图、不变量、证据与剩余风险——在<a href="/articles/2025-12-ai-assisted-programming/">AI 辅助编程之后的代码评审</a>里提前定好了，这里只是把它们落到了真实仓库。</p>]]></description></item><item><title>AI 时代，工程判断比代码产量更稀缺</title><link>https://siegaii.com/articles/2026-07-ai-era-judgment/</link><guid>https://siegaii.com/articles/2026-07-ai-era-judgment/</guid><pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>AI 把“做出一个实现”变得很快，但没有让“确定哪个实现值得进入生产”同样变快。很多时候，代码已经不是项目里最慢的部分。真正消耗时间的是确认业务语义、协调上下游、构造可信验证，以及决定出问题时怎样退回去。</p>
<p>这种变化在架构工作里尤其明显。过去，一个代价较高的方案可能停留在白板上；现在，几套都能运行的候选可以在一天内出现。选择变多以后，判断质量反而更重要。错误方向不再因为实现困难而自然淘汰，它也可以迅速长成一套结构完整、测试齐全的系统。</p>
<p>我最近重新整理过一个典型案例。一个内部任务平台最初只有 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pending</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>running</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>success</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>failed</span></span></code></span> 四种状态。业务发展后，陆续增加人工审批、暂停、取消、外部系统回调和失败补偿。团队提出“升级成标准状态机”，AI 很快给出了枚举迁移、事件溯源和工作流引擎三套实现，每套都有代码结构、迁移脚本和测试建议。</p>
<p>真正困难的问题没有出现在这些代码里：产品页面中的“已取消”究竟表示用户不再等待，还是所有外部副作用已经停止？一个运行中的任务收到取消请求后，Worker 仍可能成功写回结果，计费和通知应该听谁的？历史报表按旧状态聚合，迁移后如何保持口径？</p>
<p>这些问题不先回答，选哪个框架都只是把含糊语义实现得更彻底。</p>
<p><img src="/diagrams/series/2026-engineering-judgment.svg" alt="工程判断从业务事实、约束、可逆方案到结果校准的闭环"></p>
<p><em>图 1：判断不是凭资历拍板，而是把事实、约束、备选、验证和复审组织成可以被质疑的过程。</em></p>
<h2 id="先判断问题属于哪一层">先判断问题属于哪一层</h2>
<p>需求常以方案形式出现：“引入状态机”“换成事件驱动”“增加一个 Agent”。资深工程师的第一项工作不是评价技术，而是把方案退回到问题。</p>
<p>在这个案例里，表面症状是状态判断散落在多个服务中。继续追问后，问题分成了三层：</p>
<table>
<thead>
<tr>
<th>层次</th>
<th>真实问题</th>
<th>不能混在一起解决的原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>业务语义</td>
<td>取消、终止、补偿分别意味着什么</td>
<td>需要产品、运营和风险负责人共同确认</td>
</tr>
<tr>
<td>领域模型</td>
<td>哪些状态与事件合法，谁拥有转换权</td>
<td>决定 API、数据库和 Worker 的稳定边界</td>
</tr>
<tr>
<td>基础设施</td>
<td>是否需要通用工作流引擎</td>
<td>只有规模和能力需求明确后才能评估收益</td>
</tr>
</tbody>
</table>
<p>如果直接从第三层开始，平台会得到一个能力很强的引擎，但团队仍然会在每个节点里重复争论“取消后算不算成功”。架构设计不是把复杂度搬进更专业的工具，而是先让复杂度有正确归属。</p>
<p>AI 很适合帮忙列出遗漏场景、检索现有调用方、对比候选方案。它不适合独自定义“已完成”对业务的承诺，因为这个语义来自组织对用户、资金和数据的责任。</p>
<h2 id="用不变量过滤方案而不是给方案打印象分">用不变量过滤方案，而不是给方案打印象分</h2>
<p>我倾向先写一组跨方案都必须成立的不变量，再讨论技术选型：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>I1 任务结果只能从一个领域入口确认，API 和 Worker 不得各自写最终状态</span></span>
<span data-line=""><span>I2 取消请求与取消完成是两个事实，不能共用一个布尔值</span></span>
<span data-line=""><span>I3 已发生的外部副作用必须可追踪，不能通过改状态假装撤销</span></span>
<span data-line=""><span>I4 历史报表在迁移期间保持旧口径，并可解释新旧差异</span></span>
<span data-line=""><span>I5 任一阶段都能在 30 分钟内切回旧读取路径</span></span></code></pre></figure>
<p>有了这些不变量，三个候选的差异就清楚了。简单枚举迁移改动小，但无法自然表达并发事件；事件溯源保留事实完整，却会显著提高查询、运维和团队学习成本；成熟工作流引擎擅长长事务与恢复，但会引入新的运行面和供应商边界。</p>
<p>最终选择未必是最“先进”的方案。对于当时的任务规模，我们采用了显式状态转换表、不可变事件记录和普通关系表投影，没有立刻引入完整事件溯源或外部引擎。原因很务实：它能够解决所有已确认不变量，团队能维护，也保留未来迁移的事件边界。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> RunState</span><span style="color:#FF7B72"> =</span><span style="color:#A5D6FF"> "queued"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "running"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "cancel_requested"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "succeeded"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "cancelled"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> RunEvent</span><span style="color:#FF7B72"> =</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "started"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">attemptId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "cancel_requested"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">actorId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "effect_committed"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">effectId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "completed"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">resultRef</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "cancel_confirmed"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">workerId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> transition</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">state</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> RunState</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">event</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> RunEvent</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> RunState</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#8B949E">  // 转换表由领域规则维护；未知组合必须拒绝，而不是猜一个“合理状态”。</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> transitionTable[state]?.[event.type] </span><span style="color:#FF7B72">??</span><span style="color:#D2A8FF"> failInvalidTransition</span><span style="color:#E6EDF3">(state, event);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>代码本身并不复杂。关键决定是把“取消意图”“Worker 确认停止”和“外部副作用已经提交”拆成不同事实。这个边界一旦确定，页面、计费、审计和恢复流程才有共同语言。</p>
<h2 id="架构选择要同时计算三种成本">架构选择要同时计算三种成本</h2>
<p>团队讨论方案时，容易只比较开发成本。AI 让开发成本继续下降后，另外两种成本会更加突出。</p>
<p>第一是验证成本。修改一个纯函数可能几分钟就能验证；改变跨服务状态语义，需要历史样本、并发场景、下游消费者和线上对照数据。代码越容易生成，越不能把“实现完成”误当作“语义已经证明”。</p>
<p>第二是认知成本。新增抽象、运行平台或协议都会进入值班和后续需求。一个方案由两个人熟练维护不代表组织已经具备这项能力。设计评审要问：半年后谁能排查它，故障发生时需要跨几支团队，新增成员需要理解多少隐含前提。</p>
<p>第三是退出成本。数据格式、外部协议和用户习惯一旦形成，回退远比删除代码困难。越不可逆的决定，越应该缩小初始承诺、增加对照期，并明确复审触发器。</p>
<p>我会用一张简单的决策表逼迫讨论落到事实：</p>
<table>
<thead>
<tr>
<th>方案</th>
<th>首次交付</th>
<th>验证难度</th>
<th>运行负担</th>
<th>退出成本</th>
<th>当前判断</th>
</tr>
</thead>
<tbody>
<tr>
<td>扩展枚举</td>
<td>低</td>
<td>中</td>
<td>低</td>
<td>中</td>
<td>无法表达关键并发事实</td>
</tr>
<tr>
<td>转换表 + 事件记录</td>
<td>中</td>
<td>中</td>
<td>中</td>
<td>低</td>
<td>满足当前不变量，可渐进演进</td>
</tr>
<tr>
<td>事件溯源</td>
<td>高</td>
<td>高</td>
<td>高</td>
<td>高</td>
<td>能力过剩，团队准备不足</td>
</tr>
<tr>
<td>外部工作流引擎</td>
<td>中</td>
<td>高</td>
<td>高</td>
<td>高</td>
<td>等长事务规模达到触发条件再评估</td>
</tr>
</tbody>
</table>
<p>这张表不是为了把决定伪装成数学题。它的作用是让反对意见有位置，也让未来复审时知道当初基于什么事实。</p>
<h2 id="让迁移可观察而不只是可回滚">让迁移可观察，而不只是可回滚</h2>
<p>“保留旧代码”不等于真正可回滚。状态模型迁移涉及持续写入的数据，回退前必须知道新旧语义是否仍然一致。</p>
<p><img src="/diagrams/series/2026-decision-migration.svg" alt="状态模型迁移中的双算、差异观测与逐步切换"></p>
<p><em>图 2：新模型先在影子路径中计算，不立即影响用户。差异有分类、有阈值、有负责人，才进入小流量读取。</em></p>
<p>我们先让旧模型继续作为唯一读写来源，同时根据相同事件计算新状态，但只记录差异。差异不能只统计一个总数，要区分原因：事件顺序问题、历史脏数据、转换表遗漏，还是旧逻辑本身不一致。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="yaml" data-theme="github-dark-default"><code data-language="yaml" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#7EE787">migration_gate</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#7EE787">  shadow_window</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">14d</span></span>
<span data-line=""><span style="color:#7EE787">  required_samples</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">10000</span></span>
<span data-line=""><span style="color:#7EE787">  unexplained_diff_rate</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"&#x3C; 0.05%"</span></span>
<span data-line=""><span style="color:#7EE787">  critical_diff</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">0</span></span>
<span data-line=""><span style="color:#7EE787">  rollback</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#7EE787">    switch_read_to</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">legacy_projection</span></span>
<span data-line=""><span style="color:#7EE787">    keep_dual_write</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">true</span></span>
<span data-line=""><span style="color:#7EE787">  owners</span><span style="color:#E6EDF3">: [</span><span style="color:#A5D6FF">workflow</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">data</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">operations</span><span style="color:#E6EDF3">]</span></span></code></pre></figure>
<p>这里的阈值只是结构示例，真实数值应由业务影响决定。权限、资金等场景可能要求关键差异为零；低风险展示字段可以接受短期不一致。重要的是上线前写清楚什么结果允许继续、谁解释异常、多久没有结论就停止扩量。</p>
<p>新模型先服务内部查询，再进入少量租户，最后扩大读取。每一步都保留旧投影和切换入口。删除旧路径则要等完整数据保留周期结束，而不是新版本上线一周后就宣布迁移完成。</p>
<h2 id="判断质量来自校准不来自职位">判断质量来自校准，不来自职位</h2>
<p>架构师也会判断错。真正可靠的做法不是把决定包装得更确定，而是保留当时的假设和复审条件。</p>
<p>我会在 ADR 中记录四类内容：当时确认的事实、仍未知的部分、放弃方案的理由、未来什么信号会推翻当前决定。上线后再回看：差异率是否符合预期，故障恢复是否更快，新增状态的交付时间有没有下降，团队是否真的能独立排查。</p>
<p>有些决定最终有效，只是因为业务规模没有增长；有些方案短期遇到问题，但核心假设仍然正确。只看结果容易把运气当能力。持续对照“当时为什么这样判断”和“后来发生了什么”，经验才会变成可校准的方法。</p>
<p>AI 可以在这个过程中提供很大帮助：扫描调用关系、生成迁移候选、补齐反例、分析差异日志、整理决策记录。它提高的是获取证据和执行验证的速度，而不是替负责人承担结论。</p>
<h2 id="代码产量增加不代表系统吞吐增加">代码产量增加，不代表系统吞吐增加</h2>
<p>如果团队每天能生成更多代码，但评审、测试环境、跨团队确认和发布窗口没有变化，整体交付不会按相同比例加速。更常见的情况是，候选实现大量增加，维护者成为新的瓶颈。</p>
<p>因此我不太关心“AI 写了多少代码”或“节省多少编码时间”。更有意义的指标是：从提出问题到获得用户反馈用了多久，重大风险是否更早暴露，变更在评审中往返几次，生产故障能否更快定位和恢复。</p>
<p>成熟团队使用 AI，不是把代码队列堆得更长，而是缩短验证回路：更快做出可丢弃原型，更早生成反例，更便宜地运行迁移演练，更完整地收集上线证据。节省出来的时间应该投入判断，而不是继续扩大改动面积。</p>
<p>工程师的价值也不会收缩成“会不会提示模型”。从用户承诺到数据语义，从接口边界到发布恢复，从技术方案到团队能力，这些仍然需要完整的软件工程经验。AI 让很多实现工作变轻，却让含糊决定更快产生后果。</p>
<p>所谓高级工程能力，不是总能给出更复杂的方案，而是在信息不完整时把问题分层，在多个可行实现中识别真正约束，用可逆方式验证关键假设，并愿意对长期后果负责。代码会越来越便宜，这种判断仍然昂贵。</p>
<p>判断和执行的分离，恰好是 AI 时代人机分工的缩影：模型负责生成候选，人负责分层、约束与复审。候选实现进入生产前的人工确认，在<a href="/articles/2026-05-human-approval-transaction/">人工审批不是弹窗</a>里被描述成一段可验证的事务协议；而同样的“先定义不变量，再比较实现”思路，在<a href="/articles/2026-07-ai-coding-agent-real-repo/">如何让 AI 编程 Agent 在真实仓库里交付</a>里被写成了任务合同。判断稀缺不是新结论，只是 AI 让它在每个环节都更先暴露。</p>]]></description></item><item><title>架构债务不是旧代码，而是被推迟的系统决定</title><link>https://siegaii.com/articles/2026-06-architecture-debt/</link><guid>https://siegaii.com/articles/2026-06-architecture-debt/</guid><pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>旧代码不等于架构债务，新代码也可能在上线当天就欠下债。判断标准不是代码写了多久，而是一个暂时决定是否持续增加变更成本，并且团队已经说不清它应在什么条件下被替换。</p>
<p>我参与过的一个任务平台，最初用一列 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>status</span></span></code></span> 支撑全部流程。MVP 只有排队、执行、成功和失败，简单枚举完全够用。后来产品陆续增加暂停、人工审批、超时重试和外部回调，前端、API、Worker、报表开始各自解释同一个状态。代码仍然能跑，新需求也能继续加，但每次修改都要在几个模块里同步补条件。</p>
<p>真正让我确认它已经从“简单设计”变成债务的，不是某段代码难看，而是三件具体的事：一次取消任务仍然触发了外部通知；同一个状态在列表和计费报表中含义不同；新增“等待审批”时，三个团队分别改了自己的判断，联调到最后一天才发现不一致。</p>
<p>这类债务危险之处在于，它很少让系统立即停机。它只是持续收取利息，直到一次业务变化把隐含矛盾集中暴露出来。</p>
<h2 id="先区分有意识的借款和失控债务">先区分有意识的借款和失控债务</h2>
<p>两周 MVP 采用简单枚举并没有错。错误是上线后没有记录适用边界，也没有定义超出边界时怎么办。</p>
<p>一个健康的临时决定至少应写清四件事：当前为什么足够、牺牲了什么、什么信号出现时必须重审、谁负责推动重审。例如：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="yaml" data-theme="github-dark-default"><code data-language="yaml" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#7EE787">decision</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">single-status-column</span></span>
<span data-line=""><span style="color:#7EE787">context</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">首版仅支持同步任务，无暂停与外部副作用</span></span>
<span data-line=""><span style="color:#7EE787">accepted_limits</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">一个任务只有一个执行尝试</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">状态只服务运行流程，不承担计费口径</span></span>
<span data-line=""><span style="color:#7EE787">review_triggers</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">引入人工审批或补偿</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">同一任务支持多次 attempt</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">两个以上下游直接解释 status</span></span>
<span data-line=""><span style="color:#7EE787">owner</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">workflow-platform</span></span></code></pre></figure>
<p>触发器不是预测未来，而是避免团队在条件已经变化后仍然沿用旧决定。债务往往不是当初选错，而是系统演进后没有重新做决定。</p>
<h2 id="债务利息必须能和业务优先级放在同一张表里">债务利息必须能和业务优先级放在同一张表里</h2>
<p>“这里应该重构”很难获得资源，因为它没有说明继续推迟会付出什么。架构债务需要像可靠性问题一样记录已发生影响，而不是表达工程师的不适。</p>
<p>我会把利息分成四类：</p>
<table>
<thead>
<tr>
<th>利息</th>
<th>任务状态案例中的证据</th>
<th>可观察指标</th>
</tr>
</thead>
<tbody>
<tr>
<td>交付</td>
<td>新增状态要修改多处条件并反复联调</td>
<td>同类需求周期、跨模块改动数</td>
</tr>
<tr>
<td>可靠性</td>
<td>状态竞争导致重复通知和错误重试</td>
<td>状态冲突、人工恢复次数</td>
</tr>
<tr>
<td>认知</td>
<td>新成员无法判断哪个模块拥有状态语义</td>
<td>评审往返、升级咨询数量</td>
</tr>
<tr>
<td>机会</td>
<td>无法安全支持长任务与人工确认</td>
<td>被放弃或降级的业务需求</td>
</tr>
</tbody>
</table>
<p>这里不需要伪造一个精确的“债务金额”。粗略但持续采集的事实已经足够帮助排序。某个旧模块虽然难看，但一年没有需求也没有故障，利息可能很低；一个刚上线的公共字段每周都引发跨团队误解，优先级反而更高。</p>
<h2 id="找到共同根因不要逐个修补症状">找到共同根因，不要逐个修补症状</h2>
<p>债务清单很容易膨胀：前端状态映射需要重构、Worker 重试条件混乱、报表口径不统一、取消逻辑有竞态。把它们当四项任务，会得到四套局部改进；把它们放回系统边界，会发现共同根因是“运行状态、用户意图和业务结果被压在同一个字段里”。</p>
<p>新的模型把这些事实拆开：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> RunProjection</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  desiredState</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "active"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "cancel_requested"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  executionState</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "queued"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "running"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "stopped"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  outcome</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "unknown"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "succeeded"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "compensation_required"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  currentAttemptId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>这不是为了追求更多字段。用户请求取消，不代表 Worker 已经停止；Worker 停止，也不代表已经提交的外部副作用被撤回。把三个事实分开，API、页面、计费和恢复流程才能各自依赖正确语义。</p>
<p>架构治理的价值就在这里：它不是统一命名，而是重新决定哪个边界拥有事实，其他模块通过什么契约消费它。</p>
<h2 id="偿还债务要先建立对照再切换流量">偿还债务要先建立对照，再切换流量</h2>
<p>直接替换旧状态字段风险很高，因为历史代码中可能有尚未发现的消费者。我们采用双算而不是立即双写：旧逻辑继续作为生产事实源，同一批事件同时送入新投影，结果只用于比较。</p>
<p><img src="/diagrams/series/2026-architecture-debt-migration.svg" alt="任务状态模型从旧逻辑、影子投影到逐步切换的迁移时序"></p>
<p><em>图 1：新模型先证明能解释现有事实，再承担读取和写入责任。每一阶段都有差异指标和回退入口。</em></p>
<p>迁移分成六步：</p>
<ol>
<li>盘点所有读写 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>status</span></span></code></span> 的入口，并为旧行为补历史样本测试。</li>
<li>从领域事件计算新投影，不影响任何线上读取。</li>
<li>按原因记录新旧差异，优先处理权限、计费和外部副作用相关差异。</li>
<li>让内部诊断页和少量租户读取新投影，旧读取保留一键切换。</li>
<li>新写入只通过领域命令发生，旧字段由兼容层反向投影。</li>
<li>覆盖完整数据保留周期后，删除旧写入口，再逐步清理读取兼容。</li>
</ol>
<p>双算期间最容易犯的错误是只看总差异率。一个展示文案差异和一个计费结果差异不能被同一个百分比平均。差异需要按业务后果分级，关键语义要求零未解释样本，低风险差异可以设置收敛窗口。</p>
<p>回退也必须演练。配置里保留 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>read_projection=legacy</span></span></code></span> 没有意义，除非值班人知道谁能操作、切换后新写入怎样兼容、缓存多久失效，以及怎样确认用户结果恢复。</p>
<h2 id="双写和兼容层都有截止日期">双写和兼容层都有截止日期</h2>
<p>渐进式迁移不是天然安全。双写会引入新的不一致来源，兼容层会把新旧模型耦合在一起。如果没有退出条件，“临时迁移架构”会成为下一笔债务。</p>
<p>因此每个阶段都要有 owner、进入条件和退出日期。迁移看板不只显示完成百分比，还要显示剩余旧消费者、未解释差异、回退演练时间和兼容层删除计划。</p>
<p>我通常会保留一个明确的停止条件：关键差异没有解释、回退演练失败、值班团队还不能独立定位时，不继续扩大流量。进度压力不能把未知风险包装成“后续优化”。</p>
<h2 id="债务偿还要验证收益而不是统计新代码">债务偿还要验证收益，而不是统计新代码</h2>
<p>重构完成不代表债务已经还清。需要回到最初记录的利息：新增状态的交付周期是否缩短，跨模块条件是否减少，状态冲突和人工恢复是否下降，其他团队能否只依赖稳定契约。</p>
<p>如果代码更漂亮，但业务需求仍要跨多个团队同步修改，根因可能没有解决；如果引入通用平台后值班复杂度显著上升，偿还方式也可能产生了新的利息。</p>
<p>架构债务管理本质上是持续做决定：今天为了速度接受了什么限制，系统何时越过适用边界，继续推迟的代价是什么，谁负责以可回退方式处理。只要这些问题有明确答案，一个不完美的系统仍然可以健康演进。真正失控的不是临时代码，而是所有人都知道问题存在，却没有触发器、负责人和验证标准。</p>
<p>“触发器”要落到文档和监控两处：文档里是 ADR 的复审条件（见<a href="/articles/2024-10-adr-decision-record/">ADR 怎么写才有人看</a>），监控里是利息指标到达阈值自动建任务。状态字段这类单点模型的迁移，我在<a href="/articles/2023-04-schema-migration-pipeline/">低代码配置升级</a>里用单向迁移、黄金样例和写回隔离做过一次完整的演示——债务的偿还方法，常常已经在别的问题上练过一遍。</p>]]></description></item><item><title>人工审批不是弹窗：高风险 Agent 操作的事务边界</title><link>https://siegaii.com/articles/2026-05-human-approval-transaction/</link><guid>https://siegaii.com/articles/2026-05-human-approval-transaction/</guid><pubDate>Sat, 16 May 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>很多 Agent 产品在危险操作前弹一句“是否继续”。这个交互给人一种已经加了安全控制的感觉，但如果批准之后模型仍能修改参数，或者执行器拿着同一份批准重复调用，用户实际确认的内容与系统最终做的事可能并不一致。</p>
<p>我们在一个内部规则管理场景里遇到过这种缺口。运营人员要求 Agent 把三条告警规则的接收组从 A 调整为 B。页面展示了三条规则，用户点击确认。审批等待期间，Agent 根据新返回的数据重新规划，把另一条“相似规则”也加入执行列表。四条修改都符合最初的自然语言目标，却只有三条真正经过人确认。</p>
<p>问题不在模型是否聪明，而在系统把一次 UI 确认误当成了执行授权。高风险审批需要是一段可验证的事务协议：人批准的是一份规范化、不可变、有时效的执行计划；计划任何实质变化都必须重新审批。</p>
<h2 id="先定义哪些动作需要人而不是所有动作都弹窗">先定义哪些动作需要人，而不是所有动作都弹窗</h2>
<p>频繁确认会让人形成机械点击，最后既降低效率，也没有提高安全性。审批应该根据后果分级，而不是根据“是否由 AI 发起”分级。</p>
<table>
<thead>
<tr>
<th>风险级别</th>
<th>示例</th>
<th>默认处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>低</td>
<td>读取公开数据、生成草稿、查询状态</td>
<td>自动执行，保留普通日志</td>
</tr>
<tr>
<td>中</td>
<td>修改可快速恢复的内部配置</td>
<td>策略校验、影响上限、必要时抽样确认</td>
</tr>
<tr>
<td>高</td>
<td>外部通知、权限变更、批量修改、费用操作</td>
<td>明确审批，绑定执行计划</td>
</tr>
<tr>
<td>极高</td>
<td>不可恢复删除、大额资金、越权访问</td>
<td>双人审批或禁止 Agent 直接执行</td>
</tr>
</tbody>
</table>
<p>风险还要结合作用域。修改一条测试环境规则和修改全公司生产告警不是同一件事，即使调用的是同一个工具。策略引擎应基于主体、资源、环境、数量和可逆性判断，模型只负责提出意图，不能自行给动作定级。</p>
<h2 id="审批对象必须是确定性执行计划">审批对象必须是确定性执行计划</h2>
<p>自然语言说明可以帮助人理解，但不能作为授权凭证。真正进入审批的计划需要稳定字段：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ExecutionPlan</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  planId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  taskId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  actor</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">userId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">tenantId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  tool</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  toolVersion</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  input</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> unknown</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  targets</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;{</span></span>
<span data-line=""><span style="color:#FFA657">    resourceType</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">    resourceId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">    expectedVersion</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">  }>;</span></span>
<span data-line=""><span style="color:#FFA657">  expectedEffect</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  compensation</span><span style="color:#FF7B72">?:</span><span style="color:#FFA657"> ToolCall</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  createdAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  expiresAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>网关先对工具输入做 Schema 校验和规范化，再按稳定字段顺序序列化并计算 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>planHash</span></span></code></span>。模型生成的解释、展示顺序和措辞可以变化，工具版本、目标资源、输入与补偿动作不能在批准后变化。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> planHash</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> sha256</span><span style="color:#E6EDF3">(</span><span style="color:#D2A8FF">canonicalJson</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">  actor: plan.actor,</span></span>
<span data-line=""><span style="color:#E6EDF3">  tool: plan.tool,</span></span>
<span data-line=""><span style="color:#E6EDF3">  toolVersion: plan.toolVersion,</span></span>
<span data-line=""><span style="color:#E6EDF3">  input: plan.input,</span></span>
<span data-line=""><span style="color:#E6EDF3">  targets: plan.targets,</span></span>
<span data-line=""><span style="color:#E6EDF3">  compensation: plan.compensation,</span></span>
<span data-line=""><span style="color:#E6EDF3">  expiresAt: plan.expiresAt,</span></span>
<span data-line=""><span style="color:#E6EDF3">}));</span></span></code></pre></figure>
<p>把 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>actor</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>expiresAt</span></span></code></span> 纳入 hash 是为了防止批准在另一个租户、用户或时间窗口中被复用。具体字段应按威胁模型决定，但原则一致：执行器必须能证明“正在执行的就是被批准的那一份计划”。</p>
<h2 id="审批界面展示业务影响不展示模型思维过程">审批界面展示业务影响，不展示模型思维过程</h2>
<p>审批者需要回答的是“我要承担什么后果”，而不是阅读模型长篇解释。页面至少应展示目标对象、before/after、影响数量、数据快照时间、是否可逆、失败后的处理方式，以及触发这次操作的业务原因。</p>
<p>对于批量策略变更，我们会展示完整数量和分组汇总，同时允许展开逐项差异。只展示三个抽样对象不够，因为真正的风险可能藏在未展示部分；把几百行 JSON 全部铺开也不够，因为人无法在有限时间内理解。</p>
<p>审批权限本身也要受控。能使用 Agent 的人，不一定有权批准生产权限变更。对于职责分离场景，提出计划的人和批准人不能是同一主体；极高风险操作还需要两位不同角色的独立批准。</p>
<h2 id="批准是一张有期限只能消费一次的票据">批准是一张有期限、只能消费一次的票据</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> Approval</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  approvalId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  planHash</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  approvedBy</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  approvedAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  expiresAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "approved"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "consumed"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "revoked"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>执行器不能先读取 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>approved</span></span></code></span> 再单独更新状态，这会在并发请求中被消费两次。批准消费和执行记录创建需要处于原子边界：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">UPDATE</span><span style="color:#E6EDF3"> approvals</span></span>
<span data-line=""><span style="color:#FF7B72">SET</span><span style="color:#FF7B72"> status</span><span style="color:#FF7B72"> =</span><span style="color:#A5D6FF"> 'consumed'</span><span style="color:#E6EDF3">, consumed_at </span><span style="color:#FF7B72">=</span><span style="color:#FF7B72"> now</span><span style="color:#E6EDF3">()</span></span>
<span data-line=""><span style="color:#FF7B72">WHERE</span><span style="color:#E6EDF3"> approval_id </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> $</span><span style="color:#79C0FF">1</span></span>
<span data-line=""><span style="color:#FF7B72">  AND</span><span style="color:#E6EDF3"> plan_hash </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> $</span><span style="color:#79C0FF">2</span></span>
<span data-line=""><span style="color:#FF7B72">  AND</span><span style="color:#FF7B72"> status</span><span style="color:#FF7B72"> =</span><span style="color:#A5D6FF"> 'approved'</span></span>
<span data-line=""><span style="color:#FF7B72">  AND</span><span style="color:#E6EDF3"> expires_at </span><span style="color:#FF7B72">></span><span style="color:#FF7B72"> now</span><span style="color:#E6EDF3">()</span></span>
<span data-line=""><span style="color:#E6EDF3">RETURNING approval_id;</span></span></code></pre></figure>
<p>只有返回一行才允许继续。按钮双击、网络重试或两个执行器竞争时，只有一个能拿到票据。对于支持幂等的外部系统，还要使用由 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>planHash</span></span></code></span> 和执行序号派生的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>idempotencyKey</span></span></code></span>，避免内部防重与外部副作用脱节。</p>
<h2 id="执行前必须处理-toctou">执行前必须处理 TOCTOU</h2>
<p>审批时看到的资源可能在等待期间变化。这是典型的 time-of-check to time-of-use 问题。计划中为每个目标保存 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>expectedVersion</span></span></code></span>，执行前通过 compare-and-set 更新：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">UPDATE</span><span style="color:#E6EDF3"> alert_rules</span></span>
<span data-line=""><span style="color:#FF7B72">SET</span><span style="color:#E6EDF3"> receiver_group </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> $</span><span style="color:#79C0FF">1</span><span style="color:#E6EDF3">, </span><span style="color:#FF7B72">version</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> version</span><span style="color:#FF7B72"> +</span><span style="color:#79C0FF"> 1</span></span>
<span data-line=""><span style="color:#FF7B72">WHERE</span><span style="color:#E6EDF3"> tenant_id </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> $</span><span style="color:#79C0FF">2</span></span>
<span data-line=""><span style="color:#FF7B72">  AND</span><span style="color:#E6EDF3"> id </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> $</span><span style="color:#79C0FF">3</span></span>
<span data-line=""><span style="color:#FF7B72">  AND</span><span style="color:#FF7B72"> version</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> $</span><span style="color:#79C0FF">4</span><span style="color:#E6EDF3">;</span></span></code></pre></figure>
<p>影响行数为零时返回 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>PLAN_STALE</span></span></code></span>。Agent 可以重新读取资源并生成新计划，但不能把旧批准自动套在新状态上。即使最终参数碰巧相同，目标集合、版本或影响范围变化也需要重新评估。</p>
<p>批量操作还要事先声明原子性。是全部成功才提交，还是允许部分成功？数据库内的同域修改可以放在一个事务中；跨多个外部系统通常无法获得强原子性，此时必须逐项记录结果，并在审批界面明确“可能部分完成”。</p>
<h2 id="超时不等于失败结果未知必须成为正式状态">超时不等于失败，结果未知必须成为正式状态</h2>
<p>外部系统超时时，调用可能已经成功，只是回执丢失。自动重试高风险副作用，可能重复发消息、重复扣费或重复修改权限。把它简单标记为 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>failed</span></span></code></span> 会诱导系统再次执行。</p>
<p><img src="/diagrams/series/2026-approval-execution-state.svg" alt="高风险 Agent 操作从计划、审批到结果未知和补偿的状态流"></p>
<p><em>图 1：<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>outcome_unknown</span></span></code></span> 不是普通失败分支。系统先查证外部事实，再决定确认成功、重新执行或进入人工恢复。</em></p>
<p>执行状态至少要区分：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>planned -> awaiting_approval -> approved -> executing -> succeeded</span></span>
<span data-line=""><span>                                             |-> failed_before_effect</span></span>
<span data-line=""><span>                                             |-> outcome_unknown</span></span>
<span data-line=""><span>                                             |-> compensation_required</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>failed_before_effect</span></span></code></span> 可以安全重试；<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>outcome_unknown</span></span></code></span> 先用幂等键查询对方回执，或通过资源当前状态核对。无法自动确认时，进入人工恢复队列，展示已知证据和推荐动作，而不是让 Agent猜测。</p>
<p>补偿也不是“回滚”两个字。外部消息无法真正收回，最多发送更正；权限恢复要确认期间是否发生新的合法修改；费用冲正本身可能需要新审批。补偿是一项新的业务动作，同样需要权限、幂等、审计和失败处理。</p>
<h2 id="审计记录要能独立重放当时发生了什么">审计记录要能独立重放当时发生了什么</h2>
<p>完整证据链包括：原始任务、规范化计划与 hash、策略判断、审批人和审批版本、一次性凭证消费、执行器身份、外部请求与回执、状态核对以及补偿结果。</p>
<p>审计不能只保存一段模型总结。半年后排查时，需要能够回答：人具体批准了哪些资源和参数，执行时资源版本是否一致，外部系统返回了什么，为什么进入结果未知，以及最后由谁确认恢复。</p>
<p>敏感字段可以脱敏或单独加密，但不能因此丢掉责任链。日志保留策略也应与业务风险匹配，而不是所有 Agent 调用统一保存七天。</p>
<h2 id="人在环路里的价值是判断不是替系统兜底">人在环路里的价值是判断，不是替系统兜底</h2>
<p>一个可靠的审批系统不会让人频繁点“允许”。低风险动作自动化，高风险动作才把影响压缩成可理解计划；系统负责保证批准和执行一致，处理并发、过期、状态漂移和未知结果。</p>
<p>人应该判断业务后果是否可以接受，不应该负责发现目标列表被悄悄改了、猜测按钮会不会执行两次，或者在超时后手工翻日志确认外部系统是否成功。这些属于工程系统应提供的确定性。</p>
<p>Agent 能让计划生成和工具编排更灵活，但授权边界必须更明确。模型负责提出候选，策略系统决定是否允许进入审批，审批人承担有上下文的业务判断，执行器严格消费被批准的计划。四个角色分开之后，人机协作才不是一层看起来安全的弹窗，而是一条可以审计和恢复的生产协议。</p>
<p>审批票据的消费与执行状态，和<a href="/articles/2025-06-ai-tool-permission-gateway/">不要把生产密钥交给模型</a>里的权限网关是同一套执行边界：前者管“人的确认如何被绑定”，后者管“模型请求如何被校验与幂等化”。超时后 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>outcome_unknown</span></span></code></span> 的查询核对，与<a href="/articles/2024-06-trace-context-propagation/">requestId 到了消息队列就断了</a>里 traceId 与 jobId 的区分同源——执行链和业务身份必须分开保存。</p>]]></description></item><item><title>资深工程师的产出，不应该只存在于代码仓库</title><link>https://siegaii.com/articles/2026-04-senior-engineer/</link><guid>https://siegaii.com/articles/2026-04-senior-engineer/</guid><pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>代码提交容易被看见，也容易被计数。很多真正影响系统的工作却不对应一大段代码：提前否掉错误方向、统一两个团队对接口语义的理解、让一次失败可以自助恢复，或者把只有少数人掌握的经验变成默认检查。</p>
<p>这些工作并不比写代码“高级”，也不能成为不落地的借口。资深工程师仍然要能处理困难实现，只是评价产出时不能只看个人完成了多少需求，而要看系统和团队是否因此更容易做出正确改变。</p>
<p>我经历过一次很普通的发布事故。一个 Node.js 服务新增了第三方回调地址，预发环境配置正常，生产发布后进程也通过了健康检查。第一批异步任务执行到回调阶段才报错，因为生产环境缺少一个变量。值班人临时补配置、重启服务，四十多分钟后恢复。</p>
<p>如果只看故障工单，工作已经结束：原因明确，配置补上，任务重跑成功。但如果到这里为止，同类问题还会以另一个变量、另一个仓库再次出现。资深工程师的责任，是判断这次问题值得沉淀到哪一层，又避免因为一次事故就建设过重的平台。</p>
<h2 id="先完成修复再问系统为什么允许它发生">先完成修复，再问系统为什么允许它发生</h2>
<p>事故处理中，优先级仍然是恢复用户结果。先停止继续接收会失败的任务，补齐配置，重放确认安全的任务，并核对是否有外部副作用。这个阶段不适合一边救火一边重构配置系统。</p>
<p>恢复后再拆原因。表层原因是环境变量缺失，更深的系统缺口有四个：</p>
<ol>
<li>变量只在运行到回调分支时读取，启动阶段没有校验。</li>
<li>健康检查只验证进程存活，不验证关键依赖配置。</li>
<li>部署清单不知道服务声明了哪些必需变量。</li>
<li>错误日志有变量名，却没有受影响任务、恢复入口和安全重试条件。</li>
</ol>
<p>这四个问题分属代码、构建、部署和运维界面。只修当前服务里的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>if (!env.X)</span></span></code></span>，能降低一次风险，却没有解决跨仓库重复发生的模式。</p>
<h2 id="把一次问题分成四层产出">把一次问题分成四层产出</h2>
<p>我通常按修复、反馈、恢复路径和默认能力四层判断，不是每次都做到最上层：</p>
<table>
<thead>
<tr>
<th>层次</th>
<th>这次事故中的产出</th>
<th>什么时候值得做</th>
</tr>
</thead>
<tbody>
<tr>
<td>修复</td>
<td>补配置、回归测试、重放失败任务</td>
<td>每次事故都要完成</td>
</tr>
<tr>
<td>反馈</td>
<td>启动校验、稳定错误码、关键路径指标</td>
<td>问题能再次发生且可以提前发现</td>
</tr>
<tr>
<td>恢复路径</td>
<td>受影响任务查询、Runbook、安全重试入口</td>
<td>恢复需要人在压力下做判断</td>
</tr>
<tr>
<td>默认能力</td>
<td>配置 Schema、CI 对照、部署门禁</td>
<td>多个服务存在相同且稳定的规则</td>
</tr>
</tbody>
</table>
<p><img src="/diagrams/series/2026-senior-engineering-leverage.svg" alt="从单次故障修复到团队默认能力的工程杠杆分层"></p>
<p><em>图 1：不是每个问题都要平台化。重复频率、规则稳定性和影响范围共同决定沉淀到哪一层。</em></p>
<p>当前服务先增加类型化配置入口，进程启动时一次性解析：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> Env</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> z.</span><span style="color:#D2A8FF">object</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">  DATABASE_URL: z.</span><span style="color:#D2A8FF">string</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">url</span><span style="color:#E6EDF3">(),</span></span>
<span data-line=""><span style="color:#E6EDF3">  CALLBACK_BASE_URL: z.</span><span style="color:#D2A8FF">string</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">url</span><span style="color:#E6EDF3">(),</span></span>
<span data-line=""><span style="color:#E6EDF3">  CALLBACK_TIMEOUT_MS: z.coerce.</span><span style="color:#D2A8FF">number</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">int</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">min</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">100</span><span style="color:#E6EDF3">).</span><span style="color:#D2A8FF">max</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">30_000</span><span style="color:#E6EDF3">),</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">export</span><span style="color:#FF7B72"> const</span><span style="color:#79C0FF"> env</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Env.</span><span style="color:#D2A8FF">parse</span><span style="color:#E6EDF3">(process.env);</span></span></code></pre></figure>
<p>这里的价值不只是少写几个非空判断，而是让配置成为可查询契约。CI 可以导出必需键，部署系统可以和目标环境对照，文档也可以从同一份 Schema 生成。运行时仍要处理第三方不可达等动态失败，但“必需配置根本不存在”应该在接流量前被拒绝。</p>
<p>当我们确认多个服务反复遇到相同问题后，才把规则上移到流水线模板：构建产物携带配置清单，部署前比较环境键，缺失则门禁失败。平台不读取具体密钥，只验证声明和注入是否一致。这个边界减少了安全暴露，也避免中央平台理解每个业务变量的含义。</p>
<h2 id="沉淀机制之前先算维护成本">沉淀机制之前先算维护成本</h2>
<p>技术负责人很容易把重复问题都解释成“缺少平台”。平台化本身也会产生长期成本：谁维护规则，例外怎样处理，旧服务如何迁移，误报会不会逼团队绕过门禁。</p>
<p>我会用三个条件决定是否自动化：问题是否已经重复出现，规则是否足够稳定，收益是否覆盖机制本身的维护成本。三者缺一，先用文档、模板或局部检查通常更合适。</p>
<p>以配置为例，“生产环境必须存在服务声明的必需键”是稳定规则，适合自动门禁；“每个回调超时必须小于五秒”取决于业务，不应该由统一平台替领域团队决定。高级工程能力不在于把更多东西集中控制，而在于识别哪些约束应该成为公共默认，哪些决定应保留在业务边界。</p>
<p>机制还要有退出设计。规则需要 owner、版本和例外期限；如果平台条件已经变化，旧门禁应该可以被安全删除。只会增加规则不会删除，工程系统最终会变成没人理解的阻力。</p>
<h2 id="让决策权分布而不是让所有问题都升级给同一个人">让决策权分布，而不是让所有问题都升级给同一个人</h2>
<p>资深工程师常见的反模式是成为团队最可靠的同步接口。遇到复杂问题大家都来问，短期看解决很快，长期看整个系统依赖一个人的在线状态。</p>
<p>真正可分布的任务说明，不是把实现步骤写得更细，而是提供结果、约束、决策权和升级条件：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>Outcome: 部署前发现必需配置缺失，运行中能定位受影响任务</span></span>
<span data-line=""><span>Constraints: 平台不能读取密钥值；旧服务可以渐进接入</span></span>
<span data-line=""><span>Decision rights: 各服务定义自己的 Schema 和业务校验</span></span>
<span data-line=""><span>Escalation: 涉及跨环境密钥迁移或公共协议变化时共同评审</span></span>
<span data-line=""><span>Evidence: 门禁拦截样例、回滚演练、恢复时间与误报率</span></span></code></pre></figure>
<p>负责人应该评审高风险边界和二阶影响，而不是统一所有命名和实现风格。可逆的局部选择交给最接近问题的人，决定作出后再通过结果校准。这样团队获得的是判断能力，而不是更详细的执行指令。</p>
<p>代码评审也一样。资深工程师的价值不应表现为每个 PR 留下最多意见，而是帮助作者看见数据语义、失败路径和长期边界。风格问题交给自动化，局部方案只要符合系统约束，就允许存在差异。</p>
<h2 id="故障恢复能力必须离开个人记忆">故障恢复能力必须离开个人记忆</h2>
<p>事故时的“我知道怎么处理”不是可靠能力。Runbook 至少要让值班人独立完成四件事：确认用户影响、判断是否继续止损、执行可逆恢复、验证结果真正恢复。</p>
<p>配置事故的 Runbook 不会只写“补环境变量并重启”，而会说明：如何找到受影响任务，哪些任务可能已经发生外部副作用，怎样区分安全重试和人工核对，重启后观察哪些指标，什么情况下必须停止批量重放。</p>
<p>发布恢复后，我们让没有参与事故的同事按 Runbook 做一次演练。演练中暴露出来的问题比文档评审更真实：某个查询需要额外权限，重试按钮没有展示幂等键，指标名称和手册不一致。把这些障碍处理掉，恢复能力才真正离开原作者。</p>
<p>我会在复盘里问一句很直接的话：“下次同类故障发生时，谁能在没有我的情况下处理？”如果答案仍然只有一个人，修复就还没有完成。可能缺的是权限、工具、指标或演练，不一定是更多代码。</p>
<h2 id="用系统结果衡量杠杆不用产出数量包装价值">用系统结果衡量杠杆，不用产出数量包装价值</h2>
<p>机制上线后仍要验证是否有效。配置门禁拦截过多少真实问题，误报率是否可接受，发布失败是否更早反馈，平均恢复时间是否下降，值班人能否独立完成重放。这些结果比“建设了配置平台”更能说明价值。</p>
<p>同样，ADR 数量、文档页数、分享场次都不是目标。一份 Runbook 从未在演练和事故中使用，可能只是写得很完整；一个公共组件让团队每次需求都来找原作者修改，也没有形成真正复用。</p>
<p>资深工程师的产出可以是代码、架构、工具、决策记录和更成熟的团队，但它们要满足同一标准：能被别人使用，能用结果验证，并且不需要作者持续在场才能运行。</p>
<p>高级工程师和架构师的“高度”，并不来自谈论更宏大的概念，而是能沿一次具体故障看到跨层原因，选择合适的沉淀层级，控制方案自身的复杂度，再把个人经验转成团队可以维护的默认能力。代码仍然重要，只是最终要服务于一个更稳定、可理解、可持续演进的工程系统。</p>
<p>这套“四层产出”与两篇文章互为注释：判定什么决定值得写下来、反对意见怎样保存，看<a href="/articles/2024-10-adr-decision-record/">ADR 怎么写才有人看</a>；避免自己成为团队同步阻塞点的决策分级，看<a href="/articles/2024-09-tech-lead-boundaries/">技术负责人的边界</a>。而“经验年限不等于资深”的底层判断——如何区分熟练与能力——是<a href="/articles/2024-12-experience-is-not-seniority/">经验年限不等于资深</a>整篇的主题。</p>]]></description></item><item><title>AI 功能怎么发布：把 Evals 做成真正的门禁</title><link>https://siegaii.com/articles/2026-03-eval-release-gate/</link><guid>https://siegaii.com/articles/2026-03-eval-release-gate/</guid><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>传统服务发布前跑测试，AI 功能却常常只在聊天窗口试几条。模型供应商更新、系统提示改一段、检索索引重建，都可能让某类任务悄悄退化。一次我们优化回答长度，平均满意度看起来上升，引用完整率却下降，研究用户需要自己重新找出处。</p>
<p>后来 Evals 不再是一份实验报告，而是发布流水线的门禁。每一项候选配置都有不可变版本，按离线、影子和线上阶段逐层证明。</p>
<p><img src="/diagrams/series/2026-eval-release-gate.svg" alt="AI 功能从固定回归、对抗权限、影子流量到小流量和自动回退的门禁流程"></p>
<p><em>图 1：离线评估阻止已知回归，线上预算捕捉分布变化；两者不能互相替代。</em></p>
<h2 id="候选版本必须完整可重放">候选版本必须完整可重放</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> AiReleaseCandidate</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  model</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  modelParameters</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Record</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">number</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#FFA657">  systemPromptVersion</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  toolRegistryVersion</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  retrievalIndexVersion</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  policyVersion</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  codeSha</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>只写“升级到新模型”无法重放。工具描述、权限策略和索引变化都会改变结果，必须和候选一起冻结。</p>
<h2 id="门禁按风险分层不用平均分放行">门禁按风险分层，不用平均分放行</h2>
<table>
<thead>
<tr>
<th>层级</th>
<th>用例</th>
<th>规则</th>
</tr>
</thead>
<tbody>
<tr>
<td>G0 确定性</td>
<td>Schema、权限、工具参数</td>
<td>必须 100% 通过</td>
</tr>
<tr>
<td>G1 核心任务</td>
<td>高频真实任务与引用</td>
<td>关键切片不得退化</td>
</tr>
<tr>
<td>G2 对抗安全</td>
<td>提示注入、越权、秘密</td>
<td>任一严重失败阻止发布</td>
</tr>
<tr>
<td>G3 质量成本</td>
<td>正确性、延迟、token、工具次数</td>
<td>在预算内做多目标比较</td>
</tr>
<tr>
<td>G4 人工评审</td>
<td>边界案例与新能力</td>
<td>双盲抽样、记录分歧</td>
</tr>
</tbody>
</table>
<p>一个总分可能用文风提升抵消权限回归，这是不可接受的。门禁对关键维度使用硬阈值，其他维度才做权衡。</p>
<h2 id="结果要带统计不确定性">结果要带统计不确定性</h2>
<p>样本量小时，86% 到 88% 可能只是波动。报告展示样本数、置信区间和逐 case 差异；对同一问题使用配对比较，优先看哪些已知案例变好或变坏，而不是只看平均。</p>
<p>失败必须保存输入、候选输出、工具轨迹、评分依据和随机种子/采样参数。否则下一次无法判断是模型随机性、外部工具还是评估器变化。</p>
<h2 id="影子流量验证真实分布">影子流量验证真实分布</h2>
<p>通过离线门禁后，候选在不影响用户的情况下处理一部分脱敏生产请求。它不能执行真实写工具，只能走沙箱或回放录制结果。我们比较任务类型分布、工具计划、延迟、成本和拒答差异。</p>
<p>影子结果仍需遵守数据权限和保留策略，不因为“不展示给用户”就可以无限复制生产输入。</p>
<h2 id="小流量阶段需要自动回退">小流量阶段需要自动回退</h2>
<p>真实发布使用稳定分桶。线上观察任务成功率、人工接管率、工具失败、权限拒绝、引用打开率、P95 延迟和单任务成本。错误预算快速燃烧或安全指标越界时，控制面把流量切回已知稳定候选。</p>
<p>模型响应不可完全复现，回退指的是配置和流量，不承诺已经发生的外部副作用自动消失。写工具仍需要幂等和补偿。</p>
<h2 id="线上失败回到评估集">线上失败回到评估集</h2>
<p>用户修正、人工接管和高成本任务进入候选池，经脱敏、聚类和人工标注后成为新 case。生产回流不能直接修改门禁集，避免错误标签和攻击输入污染；数据集升级有版本与评审。</p>
<p>这套门禁不会消除 AI 的不确定性，但让每次发布承担的风险可见。团队讨论从“新模型感觉更好”变成“核心任务无退化，权限用例全过，成本增加 8%，影子流量发现两类新失败”。能够这样交付，Evals 才从研究工具变成软件工程的一部分。</p>
<p>门禁的数据基础是评估集本身的来源和质量——120 个真实问题的构建、分维度评分与防过拟合，我在<a href="/articles/2025-04-rag-evaluation-dataset/">RAG 评估别再问“感觉怎么样”</a>里记录过；而门禁真正拦下的那类“代码看起来完整”的回归，与<a href="/articles/2025-12-ai-assisted-programming/">AI 辅助编程之后的代码评审</a>关注的是同一件事的两端：生成侧的证据包，和发布侧的回归集。</p>]]></description></item><item><title>上下文工程：让 Agent 知道足够多，但不要知道一切</title><link>https://siegaii.com/articles/2026-02-context-engineering/</link><guid>https://siegaii.com/articles/2026-02-context-engineering/</guid><pubDate>Sat, 21 Feb 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>构建 Agent 应用时，最初的优化往往集中在提示词：调整角色描述、增加规则、提供更多示例。当任务仍然失败，团队继续把文档、历史消息和工具说明塞进输入，期待模型因为“知道更多”而表现更好。</p>
<p>上下文变长后，成本和延迟上升，关键约束反而更容易被淹没。真正的问题不是信息不足，而是没有为当前任务组织信息。</p>
<p>上下文工程关注的不只是一段 Prompt，而是信息从进入系统、被选择、压缩、使用到沉淀的完整生命周期。</p>
<p><img src="/diagrams/context-engineering-pipeline.svg" alt="上下文候选信息经过权限、相关性、新鲜度和预算筛选后进入模型"></p>
<p><em>图 1：上下文组装器的工作不是把信息全部拼起来，而是为当前子任务选择最少但充分的输入，并保留来源与版本。</em></p>
<h2 id="上下文是一种运行时资源">上下文是一种运行时资源</h2>
<p>模型上下文与内存、连接数一样有限。即使窗口足够大，注意力也不是均匀分配。无关信息会增加噪声，相互矛盾的信息会迫使模型猜测，重复信息会浪费成本。</p>
<p>每个任务应有上下文预算。系统先放入不可违反的规则和当前目标，再加入完成任务所需事实、工具和少量相关示例。其余材料通过检索或工具按需获得。</p>
<p>预算需要可观测：固定指令、对话历史、检索材料、工具结果分别占用多少，哪些内容最终被引用。没有这些数据，团队只能凭感觉缩短 Prompt。</p>
<p>上下文还具有时间属性。用户刚刚确认的要求优先于早期假设，实时数据不能被旧缓存覆盖，已经失效的工具说明不应继续出现。信息新旧和来源可信度都应该成为选择条件。</p>
<h2 id="分层组织而不是拼接字符串">分层组织，而不是拼接字符串</h2>
<p>一份可靠上下文至少可以分为五层：系统规则定义行为边界；任务说明定义当前目标与完成标准；领域状态保存经过确认的事实；检索材料提供证据；工作记忆保存本次执行的中间产物。</p>
<p>不同层有不同写入权限。用户和系统可以修改目标，工具产生原始证据，验证步骤才有权把事实写入领域状态，临时推理不应自动成为长期记忆。</p>
<p>这种分层避免“模型说过的话”被误认为事实。每条长期记忆附带来源、创建时间、适用范围和置信状态。下次检索时，系统可以判断是否仍然有效。</p>
<p>上下文组装也应结构化。使用明确标题、Schema 和引用标识，让模型区分规则、事实与待解决问题。字符串模板只是最终序列化形式，内部应该保存可单独筛选和测试的数据对象。</p>
<h2 id="检索需要理解任务意图">检索需要理解任务意图</h2>
<p>RAG 经常被简化为“切块、向量化、取前 K 条”。语义相似并不等于对当前任务有用。用户问“为什么这个版本性能下降”，需要的可能是变更记录、性能指标和架构说明，而不是包含“性能”一词最多的文档。</p>
<p>检索前先把任务转换为查询计划：需要哪些事实类型，时间范围是什么，哪些来源更可信，结果如何验证。复杂问题可以生成多个查询，再合并去重。</p>
<p>混合检索通常更可靠。向量处理语义相似，关键词保留精确标识，结构化过滤控制项目、版本和权限，重排模型根据完整问题重新排序。没有一种方式适合所有材料。</p>
<p>切块也应尊重文档结构。函数、章节、表格和决策记录有自然边界，固定字符切割会把定义和结论分开。每个片段保留标题路径、版本和原文位置，以便回答能够回到证据。</p>
<h2 id="压缩不能丢失约束">压缩不能丢失约束</h2>
<p>长对话与工具输出需要压缩，但“总结一下”很容易删除看似细小、实际关键的条件。更稳妥的压缩是按类型提取：已确认目标、硬约束、已完成动作、开放问题和关键产物分别保存。</p>
<p>对日志和搜索结果，可以先用确定规则裁剪无关字段，再由模型摘要。数值、错误码、文件路径和引用标识应原样保留，不让语言模型重新表述后产生偏差。</p>
<p>压缩结果要带来源指针。需要细节时，Agent 可以通过工具重新读取原始内容，而不是把摘要当作不可追溯的事实。</p>
<p>摘要也有版本。任务范围变化后，旧摘要可能不再强调正确内容。依赖哈希或任务版本能帮助系统判断是否需要重新生成。</p>
<h2 id="工具描述也是上下文">工具描述也是上下文</h2>
<p>Agent 可用工具越多，选择错误工具的概率越高。把几十个工具的完整 Schema 全部放进每次调用，会占用大量预算并增加混淆。</p>
<p>可以先根据任务类型选择工具集合，只暴露当前阶段需要的能力。规划阶段看到搜索和任务拆分工具，执行节点只看到具体数据源，审阅节点只看到验证工具。</p>
<p>工具名称和描述要表达业务意图，而不是内部实现。参数有明确类型、范围和示例，错误返回可行动信息。一个设计良好的工具接口，本身就是对模型的约束。</p>
<p>工具结果也要控制规模。数据库查询返回一万行不应直接进入上下文，应该先聚合、分页或保存为产物，再提供摘要与句柄。模型需要知道结果在哪里和如何继续取用，不需要一次看到全部内容。</p>
<h2 id="工作记忆与长期记忆分开">工作记忆与长期记忆分开</h2>
<p>工作记忆服务当前执行，包含计划、节点输出和未完成问题。任务结束后，大部分内容应被丢弃。长期记忆只保存未来确实有价值、且经过验证的信息。</p>
<p>如果所有对话都自动进入长期记忆，错误假设、过期偏好和一次性内容会不断污染后续任务。写入长期记忆应当像写数据库：有 Schema、去重、权限、更新与删除机制。</p>
<p>用户偏好也需要范围。“所有回答简短”可能只适用于某个项目，不应被提升为全局永久规则。记忆条目应标注用户、组织、项目和任务层级，读取时按作用域合并。</p>
<p>冲突不可避免。新信息与旧记忆冲突时，系统不能悄悄选择一个。应根据来源与时间自动解决简单冲突，对关键事实请求用户确认，并保留变更历史。</p>
<h2 id="多-agent-需要最小共享事实">多 Agent 需要最小共享事实</h2>
<p>多个 Agent 协作时，共享全部对话会让边界消失。更好的方式是共享一块结构化任务状态：目标、约束、已确认事实、产物索引和开放问题。</p>
<p>每个 Agent 获得自己的局部上下文，只把通过验证的产物写回共享状态。检索 Agent 不需要知道报告的全部文风要求，写作 Agent 也不需要看到搜索过程里的每个失败查询。</p>
<p>产物之间保存依赖。某个事实被更正后，系统可以找到使用它的分析和报告节点，标记为过期。没有依赖关系，修正上游信息后只能从头重跑或冒险保留错误结果。</p>
<p>上下文传递使用引用和结构，而不是让 Agent 互相发送长篇“对话”。这更接近软件模块之间通过契约协作，也更容易测试。</p>
<h2 id="安全边界必须在模型之外">安全边界必须在模型之外</h2>
<p>检索材料可能包含提示注入，要求模型忽略规则或执行危险工具。系统不能依赖模型自行识别所有攻击。</p>
<p>外部内容被明确标记为不可信数据，不能覆盖系统规则。工具权限由代码控制，敏感写操作需要用户确认。检索层按用户权限过滤，不能先取回再要求模型“不许看”。</p>
<p>上下文日志也需要脱敏。为了调试保存完整 Prompt 很方便，却可能包含用户文件、身份与业务数据。生产系统应按字段分类保存，设置访问控制与保留周期。</p>
<p>安全不是在提示词里增加一句“不要泄漏”，而是让模型即使被诱导，也没有越过边界的能力。</p>
<h2 id="评估上下文而不只评估答案">评估上下文，而不只评估答案</h2>
<p>最终答案错误，可能来自模型能力，也可能来自上下文缺失、检索噪声、旧记忆或工具描述不清。只给答案打分无法定位问题。</p>
<p>我们分别评估检索召回、证据相关性、关键约束覆盖、引用一致性和上下文成本。对固定任务，记录理想材料集合，检查系统是否取回；对答案中的事实，检查是否由上下文支持。</p>
<p>消融测试很有价值：删除某类上下文，结果是否变化；减少检索数量，正确率是否下降；更换摘要策略，约束是否保留。通过这些实验，可以知道哪些 Token 真正产生价值。</p>
<p>线上收集的失败进入回归集。上下文策略、嵌入模型、切块方式和工具描述每次调整后都重新运行，避免优化一个示例却损害整体。</p>
<h2 id="上下文工程最终是信息架构">上下文工程最终是信息架构</h2>
<p>提示词仍然重要，但它只是系统向模型呈现信息的最后一步。更长期的能力是知道信息从哪里来、谁能修改、何时失效、如何检索、如何压缩，以及输出如何回到可验证来源。</p>
<p>当 Agent 表现不稳定时，先不要急着增加更多规则。检查它是否拿到了正确目标，是否被无关历史干扰，是否能访问需要的工具，是否把临时推断误当成事实。</p>
<p>模型越强，好的上下文工程越不是“教模型聪明一点”，而是让系统承担起组织复杂度的责任。Agent 应该知道完成当前任务所需的一切，同时对其余信息保持无知。</p>
<p>落地时我要求每段进入模型的上下文都携带来源和有效期：source、owner、权限、采集时间、版本、相关性理由和失效条件。输出引用能反查原文，任务结束后知道哪些上下文必须删除。同时统计检索到但未使用、被权限过滤、超过预算裁剪和最终被引用的比例——上下文工程不是塞得更多，而是让每一段信息都能解释为什么此刻应该在这里。</p>
<p>上下文在单 Agent 内部如何组织，在多 Agent 之间就变成“最小共享事实”的问题：哪些事实写回共享黑板、哪些留在局部，我在<a href="/articles/2025-02-multi-agent-workflow/">多 Agent 系统</a>里做了展开；证据检索与权限过滤的具体评估方式，则属于<a href="/articles/2025-04-rag-evaluation-dataset/">RAG 评估</a>。</p>]]></description></item><item><title>编码 Agent 的沙箱：仓库、网络、密钥和资源分别设边界</title><link>https://siegaii.com/articles/2026-01-coding-agent-sandbox/</link><guid>https://siegaii.com/articles/2026-01-coding-agent-sandbox/</guid><pubDate>Sat, 17 Jan 2026 00:00:00 GMT</pubDate><description><![CDATA[<p>编码 Agent 在示例仓库里很容易显得可靠：依赖已经安装，测试很快，没有生产密钥，也没有用户未提交修改。进入真实仓库后，危险通常不是它写不出代码，而是它能访问太多：误改无关目录、运行带副作用脚本、读取 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>.env</span></span></code></span>、通过网络上传上下文，或让一个失控测试占满机器。</p>
<p>我现在把执行环境当成产品能力，而不是运行命令前的准备工作。任务契约先定义允许影响，沙箱再从文件、网络、秘密和资源四个维度执行边界。</p>
<p><img src="/diagrams/series/2026-agent-sandbox.svg" alt="编码 Agent 从任务契约、隔离沙箱到补丁与测试证据的架构"></p>
<p><em>图 1：Agent 的自由度存在于沙箱内部；能否越过边界不依赖模型“自觉”。</em></p>
<h2 id="每个任务先生成可执行契约">每个任务先生成可执行契约</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="yaml" data-theme="github-dark-default"><code data-language="yaml" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#7EE787">taskId</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">task-20260117-042</span></span>
<span data-line=""><span style="color:#7EE787">repository</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">console</span></span>
<span data-line=""><span style="color:#7EE787">baseSha</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">8f12ac9</span></span>
<span data-line=""><span style="color:#7EE787">allowedPaths</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">src/search/**</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">src/components/search/**</span></span>
<span data-line=""><span style="color:#7EE787">readOnlyPaths</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">package.json</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">src/lib/content.ts</span></span>
<span data-line=""><span style="color:#7EE787">forbiddenCommands</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">deploy</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">terraform apply</span></span>
<span data-line=""><span style="color:#7EE787">networkPolicy</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">registry-readonly</span></span>
<span data-line=""><span style="color:#7EE787">timeBudgetSeconds</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">900</span></span>
<span data-line=""><span style="color:#7EE787">verification</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">pnpm check</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">pnpm test -- search</span></span></code></pre></figure>
<p>契约由任务分解器和仓库策略共同生成，模型可以建议扩大范围，但扩大需要重新授权。提示里写“不要改其他文件”是沟通，文件系统策略才是边界。</p>
<h2 id="用临时工作树保护用户现场">用临时工作树保护用户现场</h2>
<p>Agent 不直接在开发者当前工作区修改。执行器从 baseSha 创建独立 worktree 或快照，只挂载允许写目录，用户未提交文件不进入沙箱。任务结束交付 patch、提交候选和验证结果，由人决定怎样合并。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>host repository (read-only metadata)</span></span>
<span data-line=""><span>  └── sandbox worktree @ baseSha</span></span>
<span data-line=""><span>      ├── writable: allowedPaths</span></span>
<span data-line=""><span>      ├── read-only: dependency manifests</span></span>
<span data-line=""><span>      └── hidden: .env, credentials, unrelated private files</span></span></code></pre></figure>
<p>如果 baseSha 已经落后，不能在沙箱里偷偷 merge 最新主分支。交付时显式报告基线，重新基于新 SHA 验证。</p>
<h2 id="网络默认拒绝按任务开放">网络默认拒绝，按任务开放</h2>
<p>多数代码修改只需要已缓存依赖和本地文档。需要安装包时，网络策略只允许公司 registry 的 GET，禁止任意域名与上传；需要访问 issue API 时，经工具代理返回最小字段，不把通用网络 socket 交给 Agent。</p>
<table>
<thead>
<tr>
<th>需求</th>
<th>开放方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>安装锁文件已有依赖</td>
<td>只读 registry + 完整性校验</td>
</tr>
<tr>
<td>查询官方文档</td>
<td>受控文档连接器，记录 URL</td>
</tr>
<tr>
<td>调用测试服务</td>
<td>短期测试身份 + 环境白名单</td>
</tr>
<tr>
<td>生产数据库/云控制面</td>
<td>沙箱内禁止</td>
</tr>
<tr>
<td>用户提供的未知 URL</td>
<td>先解析风险，单独批准</td>
</tr>
</tbody>
</table>
<h2 id="秘密通过代理使用不进入上下文">秘密通过代理使用，不进入上下文</h2>
<p>测试需要凭证时，沙箱获得一次性、最小范围 token；工具代理执行请求并过滤响应。模型看不到长期 key，stdout 和补丁扫描也会阻止秘密外泄。任务结束立即撤销 token。</p>
<h2 id="资源预算防止正确但失控">资源预算防止“正确但失控”</h2>
<p>执行器限制 CPU、内存、进程数、磁盘和墙钟时间。每条命令有单独超时，输出达到上限后截断并保存 artifact。无限测试监听、递归扫描和巨量日志都不能拖垮宿主。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> CommandBudget</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  timeoutMs</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  maxOutputBytes</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  maxProcesses</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  memoryMb</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>超预算不是普通“测试失败”，交付中标记为环境/范围问题，避免模型通过反复重跑消耗更多资源。</p>
<h2 id="交付物是一组证据">交付物是一组证据</h2>
<p>最终结果包含 baseSha、修改路径、diff、命令与退出码、测试报告、未验证项、越界请求和风险提示。沙箱日志能回答每个文件何时改变、哪个命令产生、网络访问过哪里。</p>
<p>我不指望沙箱证明代码一定正确。它解决的是另一类问题：即使 Agent 判断错误，影响也被限制在一个可回收环境；即使结果正确，团队也能看到它如何得到。对真实仓库而言，这种可控失败比偶尔惊艳的生成更重要。</p>
<p>沙箱是执行层，它之上还有两层约束：工具调用层面的授权与幂等，属于<a href="/articles/2025-06-ai-tool-permission-gateway/">不要把生产密钥交给模型</a>；评审层对“交付证据”的验收标准，属于<a href="/articles/2025-12-ai-assisted-programming/">AI 辅助编程之后的代码评审</a>。三层各自负责一部分，Agent 才不会在任何一层获得完整万能权限。</p>]]></description></item><item><title>AI 辅助编程之后，代码评审要看什么</title><link>https://siegaii.com/articles/2025-12-ai-assisted-programming/</link><guid>https://siegaii.com/articles/2025-12-ai-assisted-programming/</guid><pubDate>Sat, 13 Dec 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>AI 可以快速生成组件、测试和迁移脚本，代码量不再是明显瓶颈。问题也随之改变：一段实现看起来完整、风格一致，却可能误解业务不变量，或者引入项目中不存在的抽象。</p>
<p>评审如果仍然主要检查格式和常见语法，就无法覆盖新的风险。</p>
<p><img src="/diagrams/ai-coding-loop.svg" alt="AI 编程从任务契约、仓库建模、最小变更到分层验证和人工评审的闭环"></p>
<p><em>图 1：生成代码只是中间一步。评审从意图和不变量开始，以不同来源的验证证据结束。</em></p>
<p>交付时我会要求一份评审证据包：任务契约、仓库基线、修改文件、关键假设、测试命令与输出、未验证项和人工关注点。只给 diff，会把理解成本转嫁给评审者。对认证、迁移、并发和外部副作用，评审必须有人类重新推演失败路径——AI 可以加快实现和搜索，责任不能被“模型写的”稀释。</p>
<p>证据包让产出速度与可审查性一起提高。这套标准在真实仓库里跑过一轮之后的细节——Agent 怎样理解仓库、交付怎样带证据——在<a href="/articles/2026-07-ai-coding-agent-real-repo/">如何让 AI 编程 Agent 在真实仓库里交付</a>里展开；执行环境本身的边界则是<a href="/articles/2026-01-coding-agent-sandbox/">编码 Agent 的沙箱</a>的主题。</p>
<h2 id="先验证意图">先验证意图</h2>
<p>提交应说明解决什么问题、关键约束和验证方式。评审者先确认模型实现的是否是正确问题，再查看边界情况、失败模式和数据影响。</p>
<p>生成代码常倾向补齐“合理功能”，例如自动重试、默认回退或额外缓存。这些行为必须被显式确认，不能因为代码能运行就进入系统。</p>
<p>我遇到过一类很典型的生成结果：为了“提高可靠性”，模型给创建订单接口加了三次自动重试。代码有退避、日志和测试，看起来很完整，却没有回答创建接口是否幂等。第一次请求已经成功但响应超时，重试就可能创建第二笔订单。</p>
<p>评审这种代码时，我不会先讨论退避参数，而是先写不变量：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#8B949E">// 同一个 clientRequestId 最多对应一个订单</span></span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> CreateOrderCommand</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  clientRequestId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  userId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  items</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;{ </span><span style="color:#FFA657">sku</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">quantity</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> }>;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>只有服务端对 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>clientRequestId</span></span></code></span> 建立唯一约束，并能查询已有结果，客户端才有安全重试的基础。AI 容易补出机制，评审者必须确认机制背后的业务前提。</p>
<h2 id="缩小生成范围">缩小生成范围</h2>
<p>让 AI 在稳定接口内完成局部实现，比一次生成跨越多层的大改动更容易验证。公共协议、权限、财务和不可逆数据变更仍需要更强人工设计。</p>
<p>测试也不能只由同一提示同时生成。先列不变量和反例，再让工具补充实现；关键路径保留独立验收与真实样本。</p>
<p>我会把评审按风险顺序走，而不是从第一行读到最后一行：</p>
<table>
<thead>
<tr>
<th>顺序</th>
<th>检查内容</th>
<th>典型问题</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>行为与非目标</td>
<td>是否实现了错误问题，是否顺手扩展范围</td>
</tr>
<tr>
<td>2</td>
<td>数据与副作用</td>
<td>幂等、事务、迁移、删除、权限</td>
</tr>
<tr>
<td>3</td>
<td>失败路径</td>
<td>超时、部分成功、取消、恢复</td>
</tr>
<tr>
<td>4</td>
<td>跨层契约</td>
<td>调用方、旧版本、缓存、序列化</td>
</tr>
<tr>
<td>5</td>
<td>局部实现</td>
<td>可读性、复杂度、性能</td>
</tr>
</tbody>
</table>
<p>生成代码往往在第五层表现最好，风险却集中在前四层。先看漂亮实现，会让人过早接受它的前提。</p>
<h2 id="提交要保留可追溯意图">提交要保留可追溯意图</h2>
<p>使用 AI 反复修改后，开发者容易只保留最终文件，不知道某段代码为何出现。提交前需要清理无关生成、删除未使用抽象，并用自己的语言说明关键选择。无法解释的代码不应因为测试通过就进入生产。</p>
<p>我要求提交说明至少包含四段：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>Intent: 要改变的行为</span></span>
<span data-line=""><span>Invariants: 保持不变的业务事实</span></span>
<span data-line=""><span>Evidence: 实际运行的检查与结果</span></span>
<span data-line=""><span>Residual risk: 尚未覆盖的场景</span></span></code></pre></figure>
<p>“AI 生成并经人工检查”不是证据。类型检查通过、幂等并发用例通过、旧客户端回归通过，才是可以复核的证据。提示词可以保存为开发过程附件，不能替代这些工程事实。</p>
<p>模型与提示上下文也可以作为开发过程记录，但不能代替正式设计说明。未来维护者需要知道系统约束，而不是重放一场冗长对话。把探索压缩成可验证的决策，是使用 AI 后新的整理工作。</p>
<p>AI 提高的是产出候选方案的速度，不是自动提高正确率。未来代码评审更像设计审查：关注假设、系统边界和长期成本，而不只是逐行纠错。</p>]]></description></item><item><title>流式 AI 界面不是 append 文本：事件协议与状态恢复</title><link>https://siegaii.com/articles/2025-11-streaming-ai-ui-protocol/</link><guid>https://siegaii.com/articles/2025-11-streaming-ai-ui-protocol/</guid><pubDate>Sat, 15 Nov 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>第一个流式回答界面只处理一类消息：服务端每来一段文本就拼到字符串后面。接入工具调用后，页面开始收到“正在检索”“等待确认”“工具结果”和“继续生成”。网络重连又会重复收到部分 token，最终内容偶尔重复，按钮状态也停在 loading。</p>
<p>问题不在 React 渲染，而在协议把一个有状态任务伪装成文本管道。我们改成带 runId、sequence 和明确类型的事件流，客户端用 reducer 重建状态。</p>
<p><img src="/diagrams/series/2025-streaming-ui.svg" alt="用户、UI、Agent 与工具之间包含审批和恢复的流式事件时序"></p>
<p><em>图 1：文本只是事件之一；工具、审批、错误和结束都需要独立语义。</em></p>
<h2 id="每个事件可排序可去重">每个事件可排序、可去重</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> RunEvent</span><span style="color:#FF7B72"> =</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">runId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">seq</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "run.started"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">at</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">runId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">seq</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "message.delta"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">messageId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">text</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">runId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">seq</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "tool.requested"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">callId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">tool</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">summary</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">runId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">seq</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "approval.required"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">approvalId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">planHash</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">runId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">seq</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "tool.completed"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">callId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">result</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ToolSummary</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">runId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">seq</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "run.failed"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">code</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">retryable</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> boolean</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">runId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">seq</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "run.completed"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">usage</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Usage</span><span style="color:#E6EDF3"> };</span></span></code></pre></figure>
<p>服务端为每个 run 单调递增 seq，事件持久化后再推送。客户端保存 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>lastSeq</span></span></code></span>，重连时请求 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>after=lastSeq</span></span></code></span>；收到重复 seq 直接忽略，发现跳号则补拉，不能继续盲拼。</p>
<h2 id="文本按-messageid-聚合">文本按 messageId 聚合</h2>
<p>一个 run 可能产生多条 assistant message，也可能先写分析摘要再生成最终答案。delta 必须携带 messageId：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> reduce</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">state</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> RunState</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">event</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> RunEvent</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> RunState</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (event.seq </span><span style="color:#FF7B72">&#x3C;=</span><span style="color:#E6EDF3"> state.lastSeq) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3"> state;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (event.type </span><span style="color:#FF7B72">===</span><span style="color:#A5D6FF"> "message.delta"</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">    const</span><span style="color:#79C0FF"> previous</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> state.messages[event.messageId] </span><span style="color:#FF7B72">??</span><span style="color:#A5D6FF"> ""</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">    return</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">      ...</span><span style="color:#E6EDF3">state,</span></span>
<span data-line=""><span style="color:#E6EDF3">      lastSeq: event.seq,</span></span>
<span data-line=""><span style="color:#E6EDF3">      messages: { </span><span style="color:#FF7B72">...</span><span style="color:#E6EDF3">state.messages, [event.messageId]: previous </span><span style="color:#FF7B72">+</span><span style="color:#E6EDF3"> event.text },</span></span>
<span data-line=""><span style="color:#E6EDF3">    };</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#D2A8FF"> transitionNonTextEvent</span><span style="color:#E6EDF3">(state, event);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>渲染层按动画帧批量提交 delta，避免每个 token 都触发完整 Markdown 解析。代码块未闭合时使用增量友好的展示，完成后再做最终高亮。</p>
<h2 id="断线不等于任务失败">断线不等于任务失败</h2>
<p>浏览器连接断开，Agent 可能仍在运行。页面进入 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>reconnecting</span></span></code></span>，先查询 run 状态和缺失事件；只有服务端明确 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>run.failed</span></span></code></span> 才展示失败。用户刷新页面也能通过 URL 中的 runId 恢复，而不是丢掉整个任务。</p>
<table>
<thead>
<tr>
<th>状态</th>
<th>界面动作</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>running</span></span></code></span></td>
<td>展示当前阶段与停止按钮</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>waiting_approval</span></span></code></span></td>
<td>固定审批卡片，不继续显示假 loading</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>reconnecting</span></span></code></span></td>
<td>保留已有内容，显示连接恢复状态</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>failed_retryable</span></span></code></span></td>
<td>提供从检查点重试，不清空证据</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>completed</span></span></code></span></td>
<td>固化引用、用量和最终产物</td>
</tr>
</tbody>
</table>
<h2 id="停止也要有服务端确认">停止也要有服务端确认</h2>
<p>点击停止发送 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>run.cancel.requested</span></span></code></span>，UI 不立即假装结束。Agent 在安全点取消工具和生成，服务端发 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>run.cancelled</span></span></code></span>。已经发生的外部副作用不会因停止自动撤销，界面要列出已完成工具及补偿状态。</p>
<h2 id="背压和大小限制必须进入协议">背压和大小限制必须进入协议</h2>
<p>客户端消费慢或切到后台时，服务端不能无限缓冲。事件持久化与实时传输分离，WebSocket/SSE 只做通知；客户端按序补拉。单个工具结果不直接塞入事件流，只返回 artifactId、摘要和受权下载链接。</p>
<p>上线后我们观察重连成功率、序号缺口、重复事件、首 token、完成时间和取消耗时。一个流式界面的稳定性，不是动画是否顺滑，而是任何网络中断、工具等待和页面刷新后，用户仍能准确知道任务做到了哪一步。</p>
<p>事件协议与前端状态机的边界划分，和<a href="/articles/2026-05-human-approval-transaction/">人工审批不是弹窗</a>里审批协议的思路同源：把“需要人确认的阶段”变成协议里的一等事件，而不是 UI 上的一个临时状态。客户端如何组织这些事件的上下文，则属于<a href="/articles/2026-02-context-engineering/">上下文工程</a>讨论的范围。</p>]]></description></item><item><title>产品架构与技术架构应该共享同一张问题地图</title><link>https://siegaii.com/articles/2025-09-product-architecture/</link><guid>https://siegaii.com/articles/2025-09-product-architecture/</guid><pubDate>Sat, 20 Sep 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>产品文档常按页面和功能组织，技术架构则按前端、服务、数据库和基础设施组织。两种视角都有必要，但如果没有共同对象，需求变化会在层级之间反复翻译。</p>
<p>例如“文件分享”不是一个按钮或一个接口，而是包含资源、权限、链接、有效期、访问者和审计的完整能力。产品与技术都应围绕这些概念讨论。</p>
<p>我会先写领域事实，再讨论页面：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ShareLink</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  resourceId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  createdBy</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "active"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "revoked"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "expired"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  expiresAt</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  access</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "anyone"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "organization"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "invited"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>产品看到的是创建、复制、撤销和访问记录；服务端看到的是状态转换、权限与审计；前端看到的是每个状态下可用动作。三方共享 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>ShareLink</span></span></code></span> 这组事实，页面和接口只是不同投影。</p>
<h2 id="共享领域语言">共享领域语言</h2>
<p>先建立关键对象、状态和用户任务，再映射页面与服务。产品确认用户能做什么，技术确认规则由谁拥有，设计确认状态如何被理解。一个词在不同角色那里不能代表三种东西。</p>
<p>状态图尤其有效。它让“审核中能否取消”“过期后是否恢复”这类边界提前出现，而不是等接口联调时才决定。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>draft ──publish──> active ──revoke──> revoked</span></span>
<span data-line=""><span>                       └──time──> expired</span></span>
<span data-line=""> </span>
<span data-line=""><span>revoked / expired 不直接回到 active；需要创建新版本链接</span></span></code></pre></figure>
<p>“恢复过期链接”听起来是一个按钮，实际会影响访问者已经保存的 URL、审计记录和权限预期。决定创建新版本后，前端文案、API 和数据模型同时得到约束，不会各自补一套含义。</p>
<h2 id="路线图也是风险图">路线图也是风险图</h2>
<p>功能排期之外，还要看到公共模型、数据迁移和外部依赖的技术风险。有些基础能力不能直接展示，却决定后续多个功能能否稳定交付。</p>
<p>技术也不能用“架构升级”作为脱离产品的独立目标。每项投入应对应降低的风险、减少的交付成本或新增的产品能力。</p>
<h2 id="让边界进入验收">让边界进入验收</h2>
<p>共享地图最终需要转成可验证结果。一个状态由谁创建、哪些角色能推进、失败后是否可恢复，都应成为验收条件，而不只检查正常路径页面是否完成。技术测试与产品验收由此围绕同一组事实。</p>
<p>针对分享能力，验收不只写“可以复制链接”，而会覆盖：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>期望事实</th>
</tr>
</thead>
<tbody>
<tr>
<td>创建者撤销链接</td>
<td>新访问立即拒绝，已有页面刷新后失效，留下审计事件</td>
</tr>
<tr>
<td>链接过期</td>
<td>服务端按时间判断，不能只由前端隐藏入口</td>
</tr>
<tr>
<td>权限被组织管理员收回</td>
<td>缓存失效，访问者得到可解释拒绝</td>
</tr>
<tr>
<td>重复点击创建</td>
<td>幂等返回当前创建结果，或明确生成新版本</td>
</tr>
</tbody>
</table>
<p>这些场景会自然带出缓存、时钟、幂等和审计问题。共享问题地图的价值，就是让产品边界在写代码前进入工程讨论。</p>
<p>当需求变化时，也先修改地图和状态约束，再评估页面与服务受到的影响。这样变更不会只停留在某一张设计稿，遗漏的数据迁移、通知和权限能更早出现。</p>
<p>当产品和技术共享同一张问题地图，架构不再是后台工作，需求也不再只是页面列表。团队讨论的是同一个系统，只是从不同角度观察。</p>
<p><img src="/diagrams/series/legacy-product-architecture-map.svg" alt="用户任务、系统边界和运行结果共享的问题地图"></p>
<p><em>图：产品与技术使用同一组约束，架构才不会各说各话。</em></p>
<p>这张地图需要被维护，否则三个月后就变成没人看的旧图纸。我维护它的方式是：每个用户任务连接到产品规则、数据实体、服务边界、SLO 和负责人；箭头写清同步、异步与失败恢复。需求变化时先改地图，再判断影响哪些契约和指标。</p>
<p>地图只保留决定边界的事实，不画每个类。它在产品评审、架构评审和事故复盘中使用，没人使用的图两个月后就删除。共享的价值来自共同决策，不来自图本身复杂。</p>
<p>跨市场产品让这个问题更明显：每个区域的支付、合规和弱网约束都必须进同一张图，否则“国际化”就成了前端文案的事。我在<a href="/articles/2025-07-overseas-toc/">海外 ToC 产品中的前端不是一个页面层</a>里用区域发布矩阵把这张图具体化了。</p>]]></description></item><item><title>海外 ToC 产品中的前端不是一个页面层</title><link>https://siegaii.com/articles/2025-07-overseas-toc/</link><guid>https://siegaii.com/articles/2025-07-overseas-toc/</guid><pubDate>Sat, 26 Jul 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>进入海外 ToC 产品后，前端面对的问题不再只是业务功能。语言长度、时区、支付路径、弱网、设备性能和分阶段发布都会直接影响架构。</p>
<p>“在我的浏览器里正常”几乎没有意义，真实用户环境才是系统边界。</p>
<h2 id="国际化从数据模型开始">国际化从数据模型开始</h2>
<p>国际化不是最后替换文案。日期、数字、货币、复数和文本方向都应通过统一格式层处理；布局不能依赖固定字符长度；服务端错误码与用户文案分离，避免后端直接返回某一种语言。</p>
<p>内容发布也需要版本与回退。当翻译缺失时，系统有明确的降级语言，不能让键名进入用户界面。</p>
<p>格式化必须晚于业务计算。服务端返回时间戳、币种和原始金额，前端根据用户区域展示：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> money</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#E6EDF3"> Intl.</span><span style="color:#D2A8FF">NumberFormat</span><span style="color:#E6EDF3">(locale, {</span></span>
<span data-line=""><span style="color:#E6EDF3">  style: </span><span style="color:#A5D6FF">"currency"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  currency: invoice.currency,</span></span>
<span data-line=""><span style="color:#E6EDF3">  currencyDisplay: </span><span style="color:#A5D6FF">"narrowSymbol"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">}).</span><span style="color:#D2A8FF">format</span><span style="color:#E6EDF3">(invoice.amountMinor </span><span style="color:#FF7B72">/</span><span style="color:#79C0FF"> 100</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> createdAt</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#E6EDF3"> Intl.</span><span style="color:#D2A8FF">DateTimeFormat</span><span style="color:#E6EDF3">(locale, {</span></span>
<span data-line=""><span style="color:#E6EDF3">  dateStyle: </span><span style="color:#A5D6FF">"medium"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  timeStyle: </span><span style="color:#A5D6FF">"short"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  timeZone: user.timeZone,</span></span>
<span data-line=""><span style="color:#E6EDF3">}).</span><span style="color:#D2A8FF">format</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">new</span><span style="color:#D2A8FF"> Date</span><span style="color:#E6EDF3">(invoice.createdAt));</span></span></code></pre></figure>
<p>不能把 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>$</span></span></code></span> 默认理解成美元，也不能在服务端先格式化成 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>2025/07/26</span></span></code></span> 再让各端猜日期。金额用最小货币单位保存，时间以明确时区的时间戳传输，展示层才有可靠输入。</p>
<p>文案长度也进入组件验收。按钮不固定宽度，表格列允许换行或截断并提供完整值，德语长复合词和阿拉伯语 RTL 至少进入视觉回归样本。国际化不是翻译团队交付后的最后检查。</p>
<h2 id="体验需要分布数据">体验需要分布数据</h2>
<p>性能指标按地区、网络、设备和版本观察，整体平均值会掩盖重要长尾。静态资源使用 CDN，关键请求减少串行依赖，上传下载等长任务提供进度、暂停与恢复。</p>
<p>我会用相同版本按地区和设备分位观察，而不是只看全球平均：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>LCP p75 by region + connection type</span></span>
<span data-line=""><span>INP p75 by device memory bucket</span></span>
<span data-line=""><span>API error rate by app version + country</span></span>
<span data-line=""><span>upload completion rate by file size bucket</span></span></code></pre></figure>
<p>若全球 LCP 是 2.1 秒，某个核心市场的低端 Android 却达到 5 秒，平均值会给出错误结论。分段也不能无限细，否则每个桶样本太少；先围绕已知业务差异切分，再在异常桶里继续下钻。</p>
<p>大规模发布采用灰度和特性开关，把问题限制在较小范围。开关必须有负责人和清理日期，否则会变成永久条件分支。</p>
<h2 id="错误恢复比错误提示更重要">错误恢复比错误提示更重要</h2>
<p>网络中断、标签页关闭和设备存储不足都无法避免。长任务要保存可恢复状态，重复请求使用幂等标识，重新进入页面后能够继续，而不是要求用户从头开始。对于数据同步，界面应明确哪些已完成、哪些仍在等待。</p>
<p>大文件上传会先创建会话，客户端只保存会话 ID 和已确认分片：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> UploadCheckpoint</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  uploadId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  fileFingerprint</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  chunkSize</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  completedParts</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#FFA657">  expiresAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>重新进入页面后先向服务端查询已接收分片，再继续缺失部分。不能完全相信本地 checkpoint，因为用户可能换设备，服务端也可能清理过期会话。恢复协议必须以服务端事实校准。</p>
<p>错误文案根据用户动作解释下一步，不能只翻译服务端异常。地区与网络差异越大，产品越不能假设一次请求总会成功。恢复路径不是边缘体验，而是海外产品可靠性的主体。</p>
<p>面向大量个人用户时，前端承担的是产品运行时：它连接增长入口、账号状态、数据任务与错误恢复。架构价值最终体现在不同环境下仍然可靠完成用户目标。</p>
<p><img src="/diagrams/series/legacy-overseas-product.svg" alt="市场、本地产品和工程约束汇合的海外 ToC 架构"></p>
<p><em>图：语言只是表层，支付、合规、弱网和支持共同决定体验。</em></p>
<p>“支持更多国家”的正确粒度不是更多语言文件，而是一张区域发布矩阵。每个市场记录支付方式、税与价格展示、时区、弱网比例、隐私同意、客服时段、商店审核和关键设备。功能在一个国家成功，不代表换文案就能复制——法国市场支付失败的原因，往往在巴西根本不成立。</p>
<p>灰度按区域和版本稳定分桶，指标看支付完成、首个价值时刻、退款与支持工单，不只看注册。前端是这些本地约束交汇的地方，因此必须参与产品和服务端边界设计。</p>
<p>跨市场产品的另一面是：所有区域共享同一套领域事实，页面只是投影。产品与技术如何共用一张问题地图，在<a href="/articles/2025-09-product-architecture/">产品架构与技术架构应该共享同一张问题地图</a>里展开。</p>]]></description></item><item><title>不要把生产密钥交给模型：AI 工具调用的权限网关</title><link>https://siegaii.com/articles/2025-06-ai-tool-permission-gateway/</link><guid>https://siegaii.com/articles/2025-06-ai-tool-permission-gateway/</guid><pubDate>Sat, 21 Jun 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>早期 Agent 原型为了方便，把数据库和第三方 API key 直接放进执行进程。模型生成工具名和参数，代码几乎原样调用。演示阶段它能查数据、发消息，真实接入后很快出现两个问题：模型把测试项目 ID 带到生产工具；一次超时重试重复创建了外部任务。</p>
<p>我们把工具执行独立成权限网关。模型不持有密钥，也不能直接访问生产网络；它只提交结构化意图。网关用确定性规则决定参数是否有效、当前主体能否执行、是否需要审批以及怎样去重。</p>
<p><img src="/diagrams/series/2025-tool-permission.svg" alt="模型、权限网关与外部系统之间的工具调用隔离架构"></p>
<p><em>图 1：推理可以不确定，执行边界必须确定、最小授权并且可审计。</em></p>
<h2 id="工具定义包含效果与风险">工具定义包含效果与风险</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ToolDefinition</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">Input</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">Output</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  name</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  inputSchema</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> JsonSchema</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">Input</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#FFA657">  effect</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "read"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "write"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "external_side_effect"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  risk</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "low"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "medium"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "high"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  idempotency</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "native"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "gateway"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "none"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  timeoutMs</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#D2A8FF">  execute</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">ctx</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ToolContext</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">input</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Input</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">Output</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>工具不是一段描述文字。版本、输入边界、副作用等级和幂等能力都进入注册表。模型请求不存在的字段会在 Schema 层失败，不会被执行器“尽量理解”。</p>
<h2 id="策略基于主体资源和上下文">策略基于主体、资源和上下文</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ToolContext</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  actorId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  tenantId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  taskId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  approvedScopes</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#FFA657">  environment</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "sandbox"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "production"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>策略检查：用户是否能访问目标资源；任务契约是否允许该工具；环境是否匹配；参数影响范围是否超过限制；当前调用是否已有审批。模型说“用户要求我这么做”不是授权证据。</p>
<table>
<thead>
<tr>
<th>调用</th>
<th>默认策略</th>
</tr>
</thead>
<tbody>
<tr>
<td>读取当前租户公开文档</td>
<td>自动执行，结果仍做权限过滤</td>
</tr>
<tr>
<td>创建草稿</td>
<td>自动执行，返回可撤销草稿 ID</td>
</tr>
<tr>
<td>发送外部邮件</td>
<td>展示收件人和正文，人工确认</td>
</tr>
<tr>
<td>删除生产资源</td>
<td>默认禁止或双人审批</td>
</tr>
<tr>
<td>执行任意 SQL/Shell</td>
<td>不作为通用生产工具暴露</td>
</tr>
</tbody>
</table>
<h2 id="高风险审批绑定-planhash">高风险审批绑定 planHash</h2>
<p>Agent 先生成规范化计划，网关计算 hash。审批界面显示工具、资源、参数、预计影响和补偿动作。批准签名绑定 planHash；模型后来修改任何参数，都必须重新审批。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> canonicalPlan</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> stableStringify</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">  tool: request.tool,</span></span>
<span data-line=""><span style="color:#E6EDF3">  version: request.version,</span></span>
<span data-line=""><span style="color:#E6EDF3">  input: request.input,</span></span>
<span data-line=""><span style="color:#E6EDF3">  tenantId: context.tenantId,</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> planHash</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> sha256</span><span style="color:#E6EDF3">(canonicalPlan);</span></span></code></pre></figure>
<p>只批准一句自然语言“允许发邮件”范围太大，也无法证明实际执行内容与人看到的一致。</p>
<h2 id="执行使用短期下放权限的凭证">执行使用短期、下放权限的凭证</h2>
<p>网关按单次调用向凭证服务申请几分钟有效、只允许指定资源和动作的 token。外部系统日志能识别真实 actor 与 taskId。长期密钥留在凭证服务，不进入模型上下文、工具结果或普通日志。</p>
<h2 id="重试必须服从副作用语义">重试必须服从副作用语义</h2>
<p>读工具可以有限重试；写工具需要 idempotencyKey；不支持幂等且结果未知的外部副作用不能自动重放，进入 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>unknown</span></span></code></span> 状态等待查询或人工处理。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>requested -> authorized -> executing -> succeeded</span></span>
<span data-line=""><span>                               |-> failed_retryable</span></span>
<span data-line=""><span>                               |-> failed_terminal</span></span>
<span data-line=""><span>                               |-> outcome_unknown</span></span></code></pre></figure>
<p>网关保存规范化输入 hash、策略版本、审批、凭证范围、外部回执与耗时。工具输出回到模型前再做大小限制和敏感字段过滤，防止外部内容把秘密带入上下文。</p>
<p>这层网关看起来增加了延迟和代码，却让 Agent 从“拿着万能钥匙的脚本”变成一个受控调用方。模型擅长决定可能要做什么，真正执行仍需要软件工程里熟悉的 Schema、授权、事务、幂等和审计。</p>
<p>判断“哪些动作需要人在中途确认”不在网关内部完成，而在它上游的策略里：审批应该绑定不可变的执行计划、绑定 planHash、在计划变化时重新授权——这是<a href="/articles/2026-05-human-approval-transaction/">人工审批不是弹窗</a>整篇的内容。执行环境本身的隔离（文件、网络、密钥、资源）则是<a href="/articles/2026-01-coding-agent-sandbox/">编码 Agent 的沙箱</a>的主题，那里把这里的工具边界下沉到了进程级别。</p>]]></description></item><item><title>创业阶段的技术决策：先管理不可逆性</title><link>https://siegaii.com/articles/2025-05-cto-decisions/</link><guid>https://siegaii.com/articles/2025-05-cto-decisions/</guid><pubDate>Sat, 24 May 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>创业团队每天都在做不完整信息下的决定。模型、数据库、桌面端方案和部署方式都有多个选项，时间却不允许逐一验证。</p>
<p>最有效的分类不是“重要或不重要”，而是“可逆或不可逆”。</p>
<h2 id="可逆决定快速行动">可逆决定快速行动</h2>
<p>局部组件、日志库和内部工具通常可以替换。只要边界清楚，选择一个团队熟悉、能尽快交付的方案更合理。为这些决定写长篇评审，会让组织把时间花在低风险差异上。</p>
<p>不可逆或高迁移成本的决定需要更多关注：核心数据模型、公开 API、用户数据归属、任务状态语义和安全边界。一旦积累真实数据或外部依赖，修改成本会快速上升。</p>
<p>我会先给决定做一个粗粒度分类，不追求数学精确：</p>
<table>
<thead>
<tr>
<th>决定</th>
<th>可逆成本</th>
<th>延迟代价</th>
<th>做法</th>
</tr>
</thead>
<tbody>
<tr>
<td>UI 组件库</td>
<td>低到中</td>
<td>低</td>
<td>选熟悉方案，限制封装层</td>
</tr>
<tr>
<td>模型供应商</td>
<td>中</td>
<td>中</td>
<td>统一适配协议，保留真实评估样本</td>
</tr>
<tr>
<td>核心任务状态</td>
<td>高</td>
<td>高</td>
<td>先画状态机，再写数据库</td>
</tr>
<tr>
<td>用户文档存储</td>
<td>高</td>
<td>高</td>
<td>明确归属、导出、删除和备份</td>
</tr>
<tr>
<td>内部日志库</td>
<td>低</td>
<td>低</td>
<td>快速决定，出现摩擦再换</td>
</tr>
</tbody>
</table>
<p>这张表阻止团队在低风险选择上争论一周，也提醒我不能用“两周 MVP”跳过数据删除和备份。速度的来源是把注意力投到正确位置，不是所有事情都少做。</p>
<h2 id="保留证据和退出条件">保留证据和退出条件</h2>
<p>决策记录不必很长，但要说明目标、约束、主要候选、选择理由和重新评估条件。例如当调用量达到某个范围，是否需要更换模型路由；当工作流复杂到何种程度，现有状态模型不再适用。</p>
<p>我实际使用的记录模板只有这些字段：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>Decision: 任务状态持久化到关系数据库</span></span>
<span data-line=""><span>Context: 任务跨多个模型与工具调用，进程重启后必须恢复</span></span>
<span data-line=""><span>Constraints: 两周内上线；团队熟悉 PostgreSQL；当前规模不需事件平台</span></span>
<span data-line=""><span>Alternatives: 进程内状态 / Redis / PostgreSQL</span></span>
<span data-line=""><span>Choice: PostgreSQL 为事实源，队列只负责调度</span></span>
<span data-line=""><span>Consequences: 状态转换需要事务；吞吐不是首要目标</span></span>
<span data-line=""><span>Revisit when: 单任务事件量或并发达到现有写入瓶颈</span></span>
<span data-line=""><span>Owner: workflow</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Consequences</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Revisit when</span></span></code></span> 比“选择理由”更重要。它们承认决定有代价，也给未来改变留下正当入口。</p>
<p>创业速度不等于忽略质量。身份、数据安全、备份和核心流程恢复属于底线，不能以“以后再做”掩盖。其余质量则根据验证阶段分配。</p>
<h2 id="团队理解也是迁移成本">团队理解也是迁移成本</h2>
<p>某个方案即使技术上优秀，如果只有一人能维护，也会形成组织风险。选择需要考虑团队已有能力、学习时间和文档质量。关键系统至少应有第二个人能解释运行和恢复方式。</p>
<p>我会让第二个人在没有口头提示的情况下完成一次恢复演练：从告警找到任务、判断是否能重试、执行恢复并验证结果。只看过架构图不算掌握；能在压力较低的演练中走完整条路径，才说明知识没有锁在负责人脑中。</p>
<p>技术负责人不能把个人偏好包装成长期方向。对重要选择做小型原型，用真实任务比较，再公开记录取舍。这样未来改变决定时，团队是在更新假设，而不是推翻某个人的权威。</p>
<p>技术负责人最重要的工作不是选择最先进的技术，而是让团队知道哪些地方可以大胆试错，哪些边界必须谨慎保护。</p>
<p><img src="/diagrams/series/legacy-cto-reversibility.svg" alt="创业技术决策按影响半径和可逆性分配精度的流程"></p>
<p><em>图：有限注意力优先投入高影响、退出缓慢的选择。</em></p>
<p>实际操作中，我用一张可逆性表决定每个选择的讨论深度：影响半径、回退时间、数据迁移、外部绑定、验证窗口和最晚决定日。两小时能回退的 UI 库不占用一周会议；涉及客户数据格式和长期合同的选择必须留下 ADR 与退出方案。创业阶段最稀缺的是注意力，把判断精度用在不可逆和高影响处，比让所有技术决定都达到理论最优更重要。</p>
<p>两篇相邻的文章可以对照看：两周 MVP 里那些“能推迟就推迟”的功能，依据的是同一张可逆性表（见<a href="/articles/2025-03-two-week-mvp/">两周交付 AI MVP</a>）；而决定一旦属于“必须记录”的那一档，留下证据的格式就是<a href="/articles/2024-10-adr-decision-record/">ADR 怎么写才有人看</a>。</p>]]></description></item><item><title>RAG 评估别再问‘感觉怎么样’：从 120 个真实问题开始</title><link>https://siegaii.com/articles/2025-04-rag-evaluation-dataset/</link><guid>https://siegaii.com/articles/2025-04-rag-evaluation-dataset/</guid><pubDate>Sat, 19 Apr 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>做第一个 RAG 原型时，团队评估方式是每个人随手问几个问题，然后在群里说“这版感觉更聪明”。换 embedding、chunk size 或 reranker 后，演示问题可能更好，真实用户问题却更差。没有固定数据集，所有改动都能找到一条支持自己的样例。</p>
<p>我们从客服记录、研究任务和失败反馈中整理了第一批 120 个问题。数据量不大，但每条都有来源、期望证据、权限条件和可接受回答标准。RAG 终于从主观演示变成可以回归的系统。</p>
<p><img src="/diagrams/series/2025-rag-evaluation.svg" alt="RAG 从真实问题集、检索召回、重排权限到引用和回答评分的评估流程"></p>
<p><em>图 1：先判断证据有没有被找到，再判断模型有没有忠实使用；不能用一个总分掩盖不同失败。</em></p>
<h2 id="数据集保留问题是怎样产生的">数据集保留问题是怎样产生的</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> RagCase</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  question</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  userContext</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">role</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">allowedDocumentIds</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[] };</span></span>
<span data-line=""><span style="color:#FFA657">  expectedEvidence</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;{ </span><span style="color:#FFA657">documentId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">sectionId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }>;</span></span>
<span data-line=""><span style="color:#FFA657">  answerRubric</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#FFA657">  mustRefuse</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> boolean</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  source</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "support"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "research"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "production_failure"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  tags</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>问题文本会脱敏，但不能被改写成系统更容易回答的“标准问法”。口语、省略和错误术语恰恰是真实难度。数据集按版本保存，修改标准需要评审，不把不通过的用例悄悄删除。</p>
<h2 id="检索与生成分开评分">检索与生成分开评分</h2>
<p>如果期望证据没进入 top K，回答错误主要是检索问题；证据已经出现但模型忽略，才看提示与生成。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>Recall@K = 命中的期望证据数 / 期望证据总数</span></span>
<span data-line=""><span>MRR = 第一个正确证据排名的倒数</span></span>
<span data-line=""><span>Citation precision = 被引用且真正支持结论的引用 / 全部引用</span></span></code></pre></figure>
<p>我们还记录“最小充分证据”：回答这个问题至少需要哪几段。否则检索返回一整本文档也能获得召回，却把成本和干扰留给模型。</p>
<h2 id="权限错误比答错更严重">权限错误比答错更严重</h2>
<p>每条 case 携带用户可访问文档集合。评估在检索前注入权限，断言 top K 和引用中不出现越权文档。禁止先全局检索再在生成后过滤，因为标题、摘要和排名本身就可能泄漏信息。</p>
<table>
<thead>
<tr>
<th>失败类型</th>
<th>严重度</th>
<th>发布门禁</th>
</tr>
</thead>
<tbody>
<tr>
<td>越权检索或引用</td>
<td>Critical</td>
<td>任意 1 条即阻止发布</td>
</tr>
<tr>
<td>引用不支持结论</td>
<td>High</td>
<td>必须低于严格阈值</td>
</tr>
<tr>
<td>找不到已有答案</td>
<td>Medium</td>
<td>按核心问题召回阈值</td>
</tr>
<tr>
<td>应拒答却猜测</td>
<td>High</td>
<td>单独统计拒答准确率</td>
</tr>
<tr>
<td>文风不理想</td>
<td>Low</td>
<td>人工样本评审，不覆盖事实分</td>
</tr>
</tbody>
</table>
<h2 id="评分器也需要被校准">评分器也需要被校准</h2>
<p>LLM-as-judge 可以扩展评估，但它不是绝对真值。我们先让两位人工标注者独立评 50 条，解决 rubric 分歧，再比较 judge 与人工一致率。Judge 输入只包含问题、证据、回答和 rubric，不告诉它候选模型名称，减少偏好。</p>
<p>对数字、日期和实体关系，优先使用确定性检查；对解释完整性才用模型评分。所有低置信或发布边界附近用例进入人工复核。</p>
<h2 id="回归报告必须能定位到配置变化">回归报告必须能定位到配置变化</h2>
<p>每次实验保存 corpus 版本、切分器、embedding、索引参数、reranker、提示版本和模型版本。报告不仅展示总分，还按问题标签、文档类型和失败阶段切片。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "runId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"eval_20250419_07"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "corpus"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"finance-docs-v12"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "chunker"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"semantic-v4:600"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "retriever"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"hybrid-rrf-v3"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "topK"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">8</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "prompt"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"answer-with-citations-v9"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "model"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"pinned-model-version"</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>一次 chunk 调整让总分上升 2%，但“跨章节比较”标签下降 11%。如果只看平均值，我们会发布一个对关键研究任务更差的版本。</p>
<h2 id="线上失败要回流但不能污染测试集">线上失败要回流，但不能污染测试集</h2>
<p>用户点踩、人工修正和无答案请求进入候选池，经脱敏与标注后加入下一版数据集。用于日常调参的开发集和最终发布门禁集分开，避免团队无意中对固定答案过拟合。</p>
<p>这 120 个问题没有让 RAG 立刻变好，却改变了团队讨论方式。我们不再说“这个回答更自然”，而是能指出检索没找到证据、权限过滤太晚、引用不支持结论，或拒答边界错误。AI 系统能沉淀的第一份资产，往往不是提示词，而是一套来源真实、标准明确、可以反复运行的评估集。</p>
<p>有一件事我在早期吃过亏：评估集只覆盖“模型会不会答”，不覆盖“系统会不会发布”。把同样的用例接上发布流水线，用固定回归集做门禁、用影子流量验证真实分布，是从研究走向工程的那一步。这套完整流程在<a href="/articles/2026-03-eval-release-gate/">AI 功能怎么发布</a>里，那里把 120 个问题变成了发布前置条件；评估任务本身的选型标准（频率、可验证性、错误后果）则来自<a href="/articles/2025-01-ai-product-first-principles/">AI 产品的起点不是模型能力</a>。</p>]]></description></item><item><title>两周交付 AI MVP：速度来自主动缩小系统</title><link>https://siegaii.com/articles/2025-03-two-week-mvp/</link><guid>https://siegaii.com/articles/2025-03-two-week-mvp/</guid><pubDate>Sat, 29 Mar 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>两周完成一个可运行的 AI 金融研究产品，听起来像工程速度问题。真正决定能否完成的，是能否持续拒绝那些“以后肯定需要”的功能。</p>
<p>MVP 不是缩小版完整产品。它应该用最少系统验证最大风险：用户是否愿意把真实研究材料交给产品，工作流能否产出可检查的结果，模型错误是否可以被发现和修正。</p>
<p><img src="/diagrams/mvp-vertical-slice.svg" alt="AI MVP 从真实材料、解析索引、单一任务到引用结果和用户反馈的垂直切片"></p>
<p><em>图 1：首版只打通一条真实、可追溯的垂直路径。平台化能力推迟到核心任务被证明以后。</em></p>
<h2 id="第一天先写失败条件">第一天先写失败条件</h2>
<p>开始前，我们没有列完整功能清单，而是写出两周后必须回答的问题：从一组指定材料出发，系统能否完成一个具体研究任务；结果是否带来源；用户能否看到并干预过程；一次任务的时间和成本是否可接受。</p>
<p>同时写出失败条件。若核心任务仍需大量手工补充、引用无法验证，或每次结果差异大到无法复用，那么界面再完整也不能证明方向成立。</p>
<p>明确失败条件能阻止团队用新增功能掩盖核心问题。登录方式、主题设置、复杂权限和完整计费都被推迟，只有直接影响验证的部分进入范围。</p>
<h2 id="选择一条垂直路径">选择一条垂直路径</h2>
<p>我们选取一种材料、一类任务和一种报告结构，打通从上传、解析、执行到结果查看的完整路径。没有先建设“通用 Agent 平台”，也没有支持所有文档与研究模板。</p>
<p>垂直路径必须是真实的，不能用静态数据伪造关键环节。文档确实经过解析，模型确实调用工具，结果确实保存并可重新打开。边缘场景可以少，主路径不能是演示脚本。</p>
<p>这条路径帮助团队很早发现系统级问题：长文档如何切分，引用如何对应原文，任务失败后状态如何保留。这些问题比主页设计更能决定产品方向。</p>
<h2 id="架构只为当前风险服务">架构只为当前风险服务</h2>
<p>短周期常出现两个极端：完全不设计，所有逻辑写在接口里；或者担心未来重构，先搭建复杂平台。更合适的做法是只建立不会轻易消失的边界。</p>
<p>我们保留任务、节点、产物和来源四个核心模型。模型调用通过统一适配层，工作流状态持久化，前端只依赖稳定任务接口。至于节点市场、可视化编排和多租户配置，都不在 MVP 中。</p>
<p>数据库 Schema 优先表达事实，不追求覆盖未来所有角色。关键写操作保留版本与时间，方便排查。外部调用都有超时和错误分类，避免一个请求挂住整个服务。</p>
<p>这些基础并不昂贵，却让核心流程可以被观察和恢复。MVP 可以省略规模能力，不能省略理解失败所需的证据。</p>
<h2 id="用现有能力换取时间">用现有能力换取时间</h2>
<p>两周内不应该自建身份系统、对象存储、文档解析器和模型网关。优先使用稳定服务与成熟库，把团队时间留给领域工作流。</p>
<p>复用也有边界。选择第三方时，我们关注能否替换、数据是否可导出、故障时能否降级。MVP 不需要多云架构，但不能把核心数据锁在无法迁移的黑盒里。</p>
<p>UI 使用有限组件和明确布局，不建设完整设计系统。保持排版、间距和状态一致，比提供大量组件更重要。产品的专业感来自过程清楚和结果可信，不来自装饰数量。</p>
<h2 id="每天交付可运行状态">每天交付可运行状态</h2>
<p>短周期最怕最后几天才集成。我们把计划按可运行切片：第一天完成最小任务记录，随后接通单个模型节点，再加入文档来源、结果展示和失败恢复。每天结束时，主分支都有一条比昨天更完整的路径。</p>
<p>这样能提前暴露契约冲突，也能让产品判断建立在真实系统上。界面和服务端不各自等待“完成”，而是围绕同一个垂直路径交替推进。</p>
<p>每日复盘只回答三件事：今天验证了什么，发现了什么风险，明天最重要的未知是什么。任务数量不是进度，未知风险下降才是。</p>
<h2 id="ai-功能必须可调试">AI 功能必须可调试</h2>
<p>传统接口输入确定，AI 节点可能因模型、提示词和上下文变化产生不同结果。MVP 如果只保存最终文本，出现问题时几乎无法改进。</p>
<p>每次执行记录工作流版本、模型、提示词版本、输入材料标识、工具调用和结构化产物。敏感原文按权限保存，不把所有内容写进普通日志。</p>
<p>用户看到的是简洁过程，开发视图则能比较不同执行。我们可以判断错误来自检索遗漏、上下文选择、工具失败还是模型生成，而不是统一归因于“模型不稳定”。</p>
<p>这种可调试性也支持快速迭代。修改提示词后，用固定样本重跑，比较引用覆盖、结构完整和人工评分，避免只凭一两个漂亮结果判断。</p>
<h2 id="把人放进工作流">把人放进工作流</h2>
<p>为了演示自动化，很容易让系统直接产出完整报告。但在高价值研究场景，用户需要控制范围、确认材料和修正中间判断。</p>
<p>我们在关键节点保留人工确认：解析后检查材料范围，形成提纲后允许调整，最终结论展示来源并支持回到原文。人机协作不是妥协，它是早期建立信任和收集反馈的方式。</p>
<p>人工步骤也帮助定义未来自动化边界。反复被用户直接通过的检查可以逐步自动化，经常被修改的节点则说明模型或任务定义尚不成熟。</p>
<h2 id="延迟与成本要从第一天可见">延迟与成本要从第一天可见</h2>
<p>AI 产品的单位经济性不能等规模化再考虑。一次任务调用多少模型、消耗多少 Token、等待多久，都会影响产品是否可持续。</p>
<p>我们为每个节点记录耗时和估算成本，界面给出过程反馈并允许取消。独立任务并行，非关键审阅可以按配置关闭。超出预算时，系统返回已完成产物并说明缺失，而不是无限继续。</p>
<p>早期用户可能容忍几分钟等待，但等待必须可理解。看到系统正在检索和分析，与面对一个没有状态的加载动画，是完全不同的体验。</p>
<h2 id="上线标准不是功能完成">上线标准不是功能完成</h2>
<p>两周结束时，我们检查的不是需求列表勾选率，而是主路径能否由非开发者独立完成；失败后能否知道原因；结果能否追溯到来源；系统能否在新材料上重复工作。</p>
<p>还要明确哪些部分只是临时能力。MVP 的技术债如果被写下来、拥有触发条件和替换方案，就是有意识的借款；如果团队假装它不存在，就会在下一阶段变成惊喜。</p>
<p>首版上线后，最重要的产物不是代码，而是一组更清楚的问题：用户真正重视哪一步，哪些自动化不被信任，成本主要发生在哪里，什么能力值得继续投资。</p>
<h2 id="速度来自清晰而不是加班">速度来自清晰，而不是加班</h2>
<p>短时间交付常被归因于团队更努力。努力当然重要，但持续速度主要来自范围清晰、决策链短、主路径统一，以及每天都能获得真实反馈。</p>
<p>当产品、设计、架构和工程由同一个小团队紧密完成时，信息传递成本很低，代价是每个决定都必须更克制。没有大团队替错误方向分摊成本。</p>
<p>两周 MVP 最终证明的，不是我们能多快写完一套系统，而是能多快找到值得继续建设的部分。速度的真正对象不是代码，而是认知。</p>
<p>执行时我会每天更新一张范围账本，只有四列：今天必须证明的假设、最小用户路径、暂时手工完成的部分、明确不做的能力。新增需求必须替换一项，而不是无条件叠加。两周结束时交付的不只是演示，还有真实输入样本、失败案例、用户反馈、成本与延迟数据，以及下一阶段是否值得投入的结论。MVP 的价值是减少不确定性，不是制造一套缩小版大系统。</p>
<p>短周期里“哪些决定要谨慎、哪些决定可以快”是分开的：核心数据模型和对外契约仍然要按不可逆决定对待，我在<a href="/articles/2025-05-cto-decisions/">创业阶段的技术决策</a>里用可逆性表给它们分配了不同的讨论深度。</p>]]></description></item><item><title>多 Agent 系统：协作之前先建立控制面</title><link>https://siegaii.com/articles/2025-02-multi-agent-workflow/</link><guid>https://siegaii.com/articles/2025-02-multi-agent-workflow/</guid><pubDate>Sat, 22 Feb 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>单个大模型完成复杂研究任务时，常见问题是上下文过长、目标漂移、步骤不可见。把任务拆给多个 Agent 看起来很自然：一个负责规划，一个搜索资料，一个分析数据，一个撰写报告，再由另一个审核。</p>
<p>但把角色写进提示词，并不会自动得到组织。多个 Agent 反而会带来更多状态、通信、成本和失败路径。如果没有控制面，系统只是多次模型调用的串联。</p>
<p><img src="/diagrams/multi-agent-control-plane.svg" alt="多 Agent 系统中的控制面、执行 Agent、产物仓库、工具网关和评估系统"></p>
<p><em>图 1：Agent 负责局部开放推理，控制面负责确定性的状态、预算、权限和恢复。各角色通过产物协作，不靠长对话维持一致。</em></p>
<h2 id="先证明为什么需要多个-agent">先证明为什么需要多个 Agent</h2>
<p>复杂任务不一定需要多 Agent。一个模型配合结构化工具调用，往往比角色网络更简单可靠。</p>
<p>我们只在三类情况考虑拆分：子任务需要明显不同的上下文或工具；子任务可以并行并独立验证；任务过程需要明确责任边界和中间产物。如果拆分只是为了让提示词看起来像团队，收益通常抵不过协调成本。</p>
<p>例如金融研究中，材料检索与财务指标计算适合分开。前者处理文档与来源，后者依赖结构化数据和确定公式。最终撰写可以消费两者的受控产物，而不是重新浏览全部原始上下文。</p>
<h2 id="工作流拥有状态agent-不拥有流程">工作流拥有状态，Agent 不拥有流程</h2>
<p>Agent 擅长在局部目标下推理和使用工具，但整个任务的生命周期应由确定性工作流控制。控制面知道当前节点、依赖、输入、输出、尝试次数和预算，Agent 只负责完成一个有边界的步骤。</p>
<p>我们把任务建模为有向图。节点可以是模型调用、确定性函数、人工确认或外部工具；边表达依赖和条件；运行状态持久化到数据库。这样进程重启后可以从已完成节点继续，而不是重新执行全部研究。</p>
<p>每个节点拥有稳定输入 Schema 和输出 Schema。模型输出先经过结构验证，缺失字段或引用格式错误会触发修复，而不是直接流入下游。自然语言仍然存在，但关键控制信息不依赖下游再次猜测。</p>
<p>我会把节点状态收敛成少量可枚举值，而不是用一段自然语言描述“Agent 似乎做到哪里了”。最小结构大致如下：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> NodeRun</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  taskVersion</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  nodeId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "ready"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "running"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "waiting_user"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "succeeded"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  inputHash</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  attempt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  budget</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">tokens</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">toolCalls</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">deadline</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  artifactIds</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#FFA657">  error</span><span style="color:#FF7B72">?:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">kind</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "transient"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "invalid_output"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "insufficient_evidence"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">detail</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>这段结构没有保存模型的完整思考，但足以回答工程上真正需要的问题：执行的是哪个版本、能否安全重试、已经产出什么、为什么停下来。</p>
<h2 id="上下文要按任务分配">上下文要按任务分配</h2>
<p>把所有历史对话、全部文档和所有 Agent 输出塞给每个节点，既昂贵又容易干扰推理。上下文工程的核心是为当前任务选择最少但充分的信息。</p>
<p>规划节点需要用户目标、可用工具与约束；检索节点需要查询意图和来源范围；分析节点需要经过筛选的事实与数据；撰写节点需要结论结构、证据和表达要求。</p>
<p>长材料先被切分和索引，再按问题检索。关键事实以结构化记忆保存，附带来源、时间和置信度。临时推理不会自动进入长期记忆，只有经过验证的产物才被提升。</p>
<p>上下文也要有版本。用户修改研究范围后，下游节点不能继续使用旧假设。通过任务版本与依赖哈希，可以判断哪些结果仍然有效，哪些必须重算。</p>
<h2 id="通信使用产物不使用角色对话">通信使用产物，不使用角色对话</h2>
<p>多 Agent 示例常让不同角色互相发送长消息，仿佛会议讨论。真实系统中，这会重复信息并放大误解。</p>
<p>更稳定的协作方式是传递明确产物：检索 Agent 输出带引用的材料列表，数据 Agent 输出指标表与计算依据，审阅 Agent 输出问题清单和通过状态。下游消费产物，不需要重放上游的全部思考。</p>
<p>共享黑板可以保存当前目标、已确认事实、未解决问题和产物索引。所有 Agent 读取同一份任务事实，但写入需要经过控制面验证，避免不同角色互相覆盖。</p>
<h2 id="工具调用必须有安全边界">工具调用必须有安全边界</h2>
<p>Agent 使用搜索、数据库、代码执行或文件系统时，模型输出不能直接成为无限制命令。工具层负责参数验证、权限、超时、速率限制和结果裁剪。</p>
<p>读操作与写操作分开授权，高风险动作需要人工确认。研究系统通常以读取为主，但导出、发送或修改外部数据仍应明确展示即将发生的动作。</p>
<p>工具返回错误要结构化：是否可重试、是否需要修改参数、是否缺少权限。把一段堆栈直接交给模型，可能让它在错误方向上反复尝试。</p>
<p>每次调用记录任务、节点、模型、工具、耗时和成本，但不保存不必要的敏感内容。审计日志既用于排障，也用于回答“这条结论是如何产生的”。</p>
<h2 id="预算是系统状态的一部分">预算是系统状态的一部分</h2>
<p>多 Agent 很容易形成调用乘法。一轮规划生成多个子任务，每个子任务继续反思和重试，成本与延迟会快速失控。</p>
<p>任务必须有总预算，包括模型调用次数、Token、工具次数和最长执行时间。节点获得局部预算，消耗接近上限时选择降级、请求用户缩小范围或返回当前最佳结果。</p>
<p>预算不是只为节省费用，它迫使工作流定义停止条件。没有停止条件的“继续思考”不等于更高质量，常常只是更多文字。</p>
<p>并行也需要限制。可以独立执行的检索任务并行能缩短时间，但过多并发会触发模型或数据源限流。调度器根据依赖、优先级与资源配额启动节点，而不是让 Agent 自由复制自己。</p>
<h2 id="失败要能局部恢复">失败要能局部恢复</h2>
<p>复杂工作流一定会失败：模型返回非法结构，数据源超时，引用无法访问，某个结论缺乏证据。可靠系统不应从头重跑。</p>
<p>节点执行采用幂等设计。相同任务版本与输入哈希对应同一执行，重试不会重复产生副作用。成功产物持久化，失败保留错误分类和尝试记录。</p>
<p>恢复策略按错误决定：临时网络错误自动退避；格式错误可以用约束更强的修复调用；证据不足返回规划节点补充检索；超过阈值进入人工确认。</p>
<p>人工不是工作流失败，而是一种正式节点。系统应展示需要判断的具体问题、已有证据和可选动作，用户确认后从原位置继续。</p>
<h2 id="评估不能只看最终文风">评估不能只看最终文风</h2>
<p>最终报告读起来顺畅，可能仍然包含错误来源和遗漏数据。多 Agent 需要分层评估。</p>
<p>节点层检查结构合法率、工具成功率、引用覆盖和任务完成；工作流层检查总耗时、成本、重试与人工介入；结果层通过固定问题集评估事实正确、证据一致和任务价值。</p>
<p>对于关键计算，使用确定性程序复核，而不是让另一个模型“感觉是否正确”。对于来源，验证引用确实支持对应陈述。模型评审可以辅助发现表达和覆盖问题，不能成为唯一裁判。</p>
<p>线上失败样本进入评估集，每次提示词、模型或流程改变都运行回归。Agent 系统的行为受多项因素影响，没有稳定样本就无法知道优化是否真实。</p>
<h2 id="可观测的是过程不是思维链">可观测的是过程，不是思维链</h2>
<p>调试系统并不需要暴露模型的私有推理。我们需要的是可执行过程：节点输入摘要、工具调用、结构化输出、状态转换、引用和错误。</p>
<p>界面以任务图展示进度，用户能看到正在检索、计算还是审阅，能展开中间产物，也能取消不再需要的分支。对开发者，追踪视图关联每次调用的版本、耗时和成本。</p>
<p>这种可观测性也改善产品信任。用户不必相信一个突然出现的答案，而是可以检查它使用了哪些材料，哪些步骤由确定程序完成，哪些判断仍有不确定性。</p>
<h2 id="多-agent-的价值在组织复杂度">多 Agent 的价值在组织复杂度</h2>
<p>多 Agent 不是让多个模型模拟一个公司。它的价值是把复杂任务拆成拥有清晰输入、工具与验证方式的工作单元，再由可靠控制面协调。</p>
<p>角色可以帮助模型理解局部目标，但系统边界必须由代码表达。自然语言负责开放推理，Schema 负责契约，工作流负责状态，工具层负责安全，评估负责反馈。</p>
<p>当这些基础设施不存在时，增加 Agent 只会增加不可预测性。当它们成立后，多 Agent 才可能在长周期、跨工具和可审计任务中产生真实价值。</p>
<p>具体到 Agent 之间的交接，我会要求使用结构化任务包：目标、输入 artifact、允许工具、已完成证据、未解决假设、预算和下一步验收。下游 Agent 不读取上游完整对话来猜任务：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="yaml" data-theme="github-dark-default"><code data-language="yaml" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#7EE787">outcome</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">生成可评审的迁移计划</span></span>
<span data-line=""><span style="color:#7EE787">artifacts</span><span style="color:#E6EDF3">: [</span><span style="color:#A5D6FF">schema-v3.json</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">usage-scan.csv</span><span style="color:#E6EDF3">]</span></span>
<span data-line=""><span style="color:#7EE787">openQuestions</span><span style="color:#E6EDF3">: [</span><span style="color:#A5D6FF">删除字段是否仍有离线消费者</span><span style="color:#E6EDF3">]</span></span>
<span data-line=""><span style="color:#7EE787">allowedTools</span><span style="color:#E6EDF3">: [</span><span style="color:#A5D6FF">repo.read</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">metrics.query</span><span style="color:#E6EDF3">]</span></span>
<span data-line=""><span style="color:#7EE787">doneWhen</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">风险与回滚步骤齐全</span></span></code></pre></figure>
<p>结构化交接同时降低上下文成本和责任模糊——这正是<a href="/articles/2026-02-context-engineering/">上下文工程</a>里“最小共享事实”原则在 Agent 协作中的直接应用；工具侧的安全边界则在<a href="/articles/2025-06-ai-tool-permission-gateway/">不要把生产密钥交给模型</a>里单独记录。</p>]]></description></item><item><title>AI 产品的起点不是模型能力</title><link>https://siegaii.com/articles/2025-01-ai-product-first-principles/</link><guid>https://siegaii.com/articles/2025-01-ai-product-first-principles/</guid><pubDate>Sat, 11 Jan 2025 00:00:00 GMT</pubDate><description><![CDATA[<p>开始构建 AI 金融研究产品时，最容易被模型演示吸引：上传文档、提出问题，几秒后得到一段完整回答。演示令人兴奋，却不能证明产品成立。</p>
<p>真实研究任务不是生成一段文字，而是收集材料、验证来源、比较观点、形成判断并留下证据。模型输出只是其中一环。</p>
<h2 id="找到高成本任务">找到高成本任务</h2>
<p>我们先观察研究过程中哪些步骤重复、耗时且可以验证，例如从多份材料中提取指标、追踪同一事实的来源、按固定框架整理公司信息。开放式“帮我分析”很难评价，结构清楚的子任务更适合建立信任。</p>
<p>选择任务时同时考虑错误成本。低成本内容可以快速试错，影响投资判断的结论则必须保留人工复核和引用。</p>
<p>我们后来不用“模型效果好不好”筛任务，而是给候选任务写一张卡：</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>要回答的问题</th>
</tr>
</thead>
<tbody>
<tr>
<td>频率</td>
<td>用户每周会做几次，还是一年一次</td>
</tr>
<tr>
<td>当前成本</td>
<td>时间花在搜索、录入、比较还是写作</td>
</tr>
<tr>
<td>可验证性</td>
<td>是否存在来源、公式或结构化字段可核对</td>
</tr>
<tr>
<td>错误后果</td>
<td>错误能否被发现，是否会进入外部决策</td>
</tr>
<tr>
<td>人工介入点</td>
<td>用户在哪一步拥有足够上下文做判断</td>
</tr>
</tbody>
</table>
<p>“从公告中提取指定指标并链接原文”得分通常高于“帮我判断这家公司是否值得投资”。前者边界窄、频率高、结果可核验；后者把证据选择、分析框架和风险偏好混在一个开放问题里，流畅回答反而容易制造错误信任。</p>
<h2 id="把不确定性放进界面">把不确定性放进界面</h2>
<p>普通软件习惯返回确定结果，大模型天然带有不确定性。产品不能用流畅文本掩盖这一点。答案应关联原始来源，区分事实与推断，信息不足时明确说不知道。</p>
<p>用户也需要控制任务范围、查看执行进度、修正中间结果，而不是只面对一个聊天输入框。</p>
<p>一条研究结论在界面里至少拆成四部分：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ResearchClaim</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  statement</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  kind</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "fact"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "calculation"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "inference"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  citations</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;{ </span><span style="color:#FFA657">documentId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">page</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">quote</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }>;</span></span>
<span data-line=""><span style="color:#FFA657">  confidence</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "high"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "medium"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "low"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  unresolved</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>事实必须有引用；计算需要展示公式和输入；推断要和事实分开；信息不足则把缺口显示出来。<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>confidence</span></span></code></span> 不是模型随口给出的百分比，而是由引用覆盖、来源一致性和任务规则共同产生的离散结果。</p>
<h2 id="先设计评估再设计演示">先设计评估，再设计演示</h2>
<p>每个核心任务都需要可以重复的样本与评价标准。提取任务看字段准确与来源覆盖，比较任务看关键差异是否遗漏，报告任务还要检查事实与引用一致。只问“回答看起来好不好”，团队会被语言流畅度误导。</p>
<p>评估集不必一开始很大，但要来自真实材料，并包含信息缺失、来源冲突和格式异常。每次更换模型、提示词或检索策略都重新运行。AI 产品只有拥有稳定反馈，迭代才不是围绕几个漂亮截图进行。</p>
<p>我们的评估样本会保存输入材料版本、期望字段、可接受变体和证据位置：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "caseId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"metric-conflict-07"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "question"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"报告期内经营现金流是多少？"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "expected"</span><span style="color:#E6EDF3">: { </span><span style="color:#7EE787">"value"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">128000000</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"currency"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"CNY"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"period"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"2024-Q4"</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#7EE787">  "requiredEvidence"</span><span style="color:#E6EDF3">: [{ </span><span style="color:#7EE787">"document"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"annual-report"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"page"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">86</span><span style="color:#E6EDF3"> }],</span></span>
<span data-line=""><span style="color:#7EE787">  "trap"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"摘要页使用了累计口径，正文表格为单季口径"</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>这个样本同时测试检索、口径判断、结构化输出和引用。只比较最终数字，无法知道是碰巧答对还是证据链真的成立。</p>
<p>AI 产品的壁垒不会来自“接入了某个模型”。真正长期的能力是理解任务、组织上下文、验证结果，并让模型输出进入可负责的业务流程。</p>
<p><img src="/diagrams/series/legacy-ai-product-contract.svg" alt="AI 产品从任务、证据、权限、工具副作用到模型选择的设计流程"></p>
<p><em>图：模型和提示放在失败合同之后，产品边界才稳定。</em></p>
<p>“失败合同”是我评估每个 AI 任务的第一步：证据不足时怎么拒答；工具超时是否重试；输出哪些字段必须有引用；用户如何修正；已经发生的副作用怎样补偿。提示词只负责在合同内组织模型行为。</p>
<p>我们用失败案例验收产品，而不是只演示最佳输入。能解释“不知道”“没有权限”和“结果未知”的系统，通常比偶尔给出惊艳答案的原型更接近可交付产品。这条线的后端工程化部分——评估集怎么做、门禁怎么设——在<a href="/articles/2025-04-rag-evaluation-dataset/">RAG 评估别再问“感觉怎么样”</a>里展开。</p>]]></description></item><item><title>经验年限不等于资深</title><link>https://siegaii.com/articles/2024-12-experience-is-not-seniority/</link><guid>https://siegaii.com/articles/2024-12-experience-is-not-seniority/</guid><pubDate>Sat, 28 Dec 2024 00:00:00 GMT</pubDate><description><![CDATA[<p>工作年限增长后，“资深”似乎会自然到来。简历上项目更多，熟悉的框架更多，遇到常见问题也能快速给出答案。但年限只说明时间经过，不说明一个人在这段时间里如何思考和承担责任。</p>
<p>有人用十年反复完成同一种任务，也有人在几年里不断扩大问题范围。资深不是速度更快的初级工程师，也不是会议里更有话语权的人。它是一组能在复杂环境中持续产生可靠结果的能力。</p>
<h2 id="从接收问题到定义问题">从接收问题到定义问题</h2>
<p>早期工程师通常在明确任务中工作：实现页面、接入接口、修复错误。任务本身被假设为正确，完成标准也相对清楚。</p>
<p>复杂项目里，最昂贵的错误常发生在写代码之前。需求描述的是方案而不是目标，团队可能花几周准确实现一个不需要存在的功能。资深工程师会先问：谁遇到了什么问题，当前成本是什么，什么证据说明值得解决。</p>
<p>这不是用问题拖延行动，而是缩小错误方向的投入。一个好的重新定义，可能把“建设一套配置平台”变成“先消除三个重复流程”，让团队用更小代价验证价值。</p>
<p>定义问题还包括识别约束。时间、人员、已有系统、合规和维护能力都会影响答案。离开约束讨论“最佳实践”，往往只是在展示知识。</p>
<p>我会把需求从方案改写成一页问题说明：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>Observed problem: 新项目从申请到首次可部署平均经过多个手工环节</span></span>
<span data-line=""><span>Users: 需要创建项目的研发负责人</span></span>
<span data-line=""><span>Current evidence: 重复填写仓库、构建、权限和通知配置</span></span>
<span data-line=""><span>Constraints: 不能替换现有 Git 与部署系统；平台团队维护能力有限</span></span>
<span data-line=""><span>Success: 默认项目无需线下沟通即可进入测试环境</span></span>
<span data-line=""><span>Non-goals: 本阶段不建设通用工作流设计器</span></span></code></pre></figure>
<p>这份说明让“做一个研发平台”缩成一条可验证路径，也阻止团队因为技术兴趣提前建设节点市场和可视化编排。</p>
<h2 id="从局部实现到系统结果">从局部实现到系统结果</h2>
<p>资深工程师不会只保证自己提交的代码正确。他会沿着结果向上下游观察：数据契约是否稳定，发布是否可回滚，监控能否发现失败，使用者是否真正完成任务。</p>
<p>这不意味着越权承担所有工作，而是能看到边界之间的空隙。系统故障经常发生在“这不归我负责”的地方。有人需要把局部团队重新连接到共同结果。</p>
<p>系统思维也要求理解二阶影响。加一层缓存能提高响应速度，也会引入失效和一致性；增加通用配置能覆盖更多场景，也会扩大测试组合；提高自动化程度，也会让错误更快传播。</p>
<p>成熟的方案不只描述收益，还主动写出新风险以及如何控制。</p>
<p>例如为接口增加缓存，方案至少要同时说明：</p>
<table>
<thead>
<tr>
<th>收益</th>
<th>新风险</th>
<th>控制手段</th>
<th>退出条件</th>
</tr>
</thead>
<tbody>
<tr>
<td>降低下游查询压力</td>
<td>权限撤销后仍命中旧数据</td>
<td>权限版本进入缓存键，高风险操作绕过缓存</td>
<td>命中率低于维护成本时删除</td>
</tr>
<tr>
<td>缩短列表响应</td>
<td>数据更新短暂不可见</td>
<td>明确 TTL，页面展示更新时间</td>
<td>产品要求强一致时回到源查询</td>
</tr>
</tbody>
</table>
<p>资深不是知道“用 Redis”，而是能把这笔交换讲完整，并在系统运行后检查假设是否成立。</p>
<h2 id="判断什么不做">判断什么不做</h2>
<p>初学阶段，能力通过“能做什么”体现；走向资深后，价值越来越体现在“知道什么不值得做”。</p>
<p>技术世界持续产生新框架、新范式和更漂亮的架构图。把它们引入项目很容易带来短期兴奋，也可能让团队承担多年维护成本。资深工程师需要区分学习价值与生产价值。</p>
<p>拒绝也不能依赖权威。要说明目标为何不匹配、现有方案的真实瓶颈在哪里、替代路径是什么。如果反对只是“以前没这么做过”，那是保守；如果反对建立在成本与证据上，才是判断。</p>
<p>同样，技术债不是所有不完美代码。系统中存在很多局部粗糙，只要它稳定、低频、不会阻碍变化，就可能不值得重写。重构资源应该投向持续制造风险和摩擦的地方。</p>
<h2 id="把不确定性显式化">把不确定性显式化</h2>
<p>复杂问题很少有完整信息。资深不意味着永远知道答案，而是知道哪些是假设、哪些需要验证、什么情况下应该改变方向。</p>
<p>设计方案时，可以把决定分为可逆与不可逆。可逆决定快速试验，通过数据修正；涉及公共协议、数据模型和长期绑定的决定，则投入更多评审和原型。</p>
<p>风险也应当有优先级。列出所有可能问题没有意义，需要估计发生概率、影响范围和发现难度。最值得提前处理的，往往是影响大且上线后难以察觉的错误。</p>
<p>我会把风险粗分成三维：影响、发生可能和可探测性。支付重复扣款即使概率低，也因为影响大且不能依赖用户发现而需要幂等和对账；内部报表一个可恢复的样式错位，通常不值得同等级投入。</p>
<p>这种分级会直接改变验证方式：高风险数据变更用备份、影子读取和分阶段切换；局部可逆 UI 变化用视觉回归和快速回滚。测试数量不需要平均分配，监督强度应该跟随后果。</p>
<p>当信息不足时，诚实表达置信度比强行给出确定答案更专业。“目前基于这些事实选择 A，如果指标 X 变化则转向 B”，比“行业都这么做”更可执行。</p>
<h2 id="让团队获得能力">让团队获得能力</h2>
<p>个人解决一个难题有价值，让团队以后都能解决同类问题更有价值。资深工程师会把一次经验沉淀为工具、约定、示例和判断框架。</p>
<p>但沉淀不是写一份没人阅读的长文档。知识要进入工作流：脚手架提供默认结构，CI 自动检查规则，组件库表达交互契约，故障手册连接告警和行动。正确路径应该更容易被采用。</p>
<p>技术负责人还要避免自己成为所有决策的中心。团队成员需要拥有完整上下文和适当决策权。负责人评审高风险边界，建立原则，并在结果不理想时承担责任，而不是逐行控制实现。</p>
<p>培养能力意味着允许可控范围内的不同做法。只接受与自己相同的方案，最终得到的不是一致团队，而是一组等待指令的人。</p>
<h2 id="沟通是工程的一部分">沟通是工程的一部分</h2>
<p>架构无法只存在于一个人的脑中。一个方案如果无法让产品、设计、服务端和运维理解，就很难在真实组织里成立。</p>
<p>不同角色关心不同问题。面对业务，需要解释成本、风险和交付路径；面对工程师，需要明确契约、状态和失败模式；面对管理者，需要说明投入如何对应结果。改变表达不是降低专业性，而是建立共同决策所需的接口。</p>
<p>高质量沟通也包括书写。设计文档保存上下文，决策记录解释为何选择，复盘把事故转成系统改进。口头结论会消失，文字让后来者能重新检查假设。</p>
<p>会议数量不是沟通质量。能够异步说清楚的问题不必开会，需要多方权衡的决定则应让关键人同时出现。沟通的目标是减少误解和等待，而不是制造存在感。</p>
<h2 id="对后果负责">对后果负责</h2>
<p>资深最重要的变化，是从“我的实现没有问题”走向“最终结果由我共同负责”。系统上线后表现不佳，不能只证明代码符合需求；团队方向错误，也不能只说自己早已提出风险。</p>
<p>负责不等于独自背负。它意味着在问题暴露时先恢复系统，再讨论归因；在项目推进时主动补齐缺口；在承诺无法兑现时尽早沟通，而不是等最后一天解释。</p>
<p>责任还包括对长期维护者友好。今天聪明的实现，如果只有作者能修改，就是把成本留给未来。清晰、可测试、可观察和可回滚，都是对后果负责的形式。</p>
<h2 id="保持一线但改变写代码的目的">保持一线，但改变写代码的目的</h2>
<p>承担架构和团队职责后，写代码的时间可能减少。完全离开实现，会让判断逐渐失去现实感；继续抢走所有关键代码，又会阻碍团队成长。</p>
<p>我更愿意选择高杠杆的一线工作：验证风险最大的技术假设，搭建能被团队扩展的骨架，修复暴露系统性问题的缺陷，或亲自走完整个发布与排障链路。</p>
<p>写代码不再为了证明产出，而是为了获得真实反馈，并把复杂路径走通。其余实现交给最接近上下文的人，并通过评审保持系统方向。</p>
<h2 id="资深是一种持续校准">资深是一种持续校准</h2>
<p>没有人因为达到某个职级就永久拥有正确判断。业务会变化，技术会变化，曾经有效的经验也可能成为偏见。</p>
<p>真正可靠的资深工程师，会持续接触一线事实，愿意被数据和更好的论证修正。他拥有原则，但不把原则变成教条；相信经验，也知道经验的适用范围。</p>
<p>从机械专业自学编程，到前端、Node.js、工程平台和复杂数据系统，我学到的技术很多已经更替。留下来的不是 API，而是一些越来越稳定的习惯：先定义问题，寻找主要约束，让失败可见，为未来变化保留边界。</p>
<p>经验年限只是背景。资深真正意味着，在信息不完整、利益有冲突、系统会失败的现实里，仍然能与团队一起做出足够好的决定，并为结果负责。</p>
<p><img src="/diagrams/series/legacy-seniority-evidence.svg" alt="复杂问题经过解释、机制和团队复用转化为资深能力的流程"></p>
<p><em>图：经历只有沉淀成别人可使用的能力，才产生组织杠杆。</em></p>
<p>沉淀时我会用证据组合代替“负责过”。四类成果值得记录：亲手解决的复杂问题；建立后被他人持续使用的机制；培养后能独立负责的同事；主动停止或简化的错误方向。每项都链接变更前后指标、文档或真实使用者。这能防止履历只剩项目名和角色——资深不是离代码更远，而是能把一次判断变成系统、工具和团队都能继续复用的能力。</p>
<p>把“机制被他人使用”变成可检查的标准，需要回答更深一层的问题：哪种产出值得自动化为门禁，哪种只配留在文档。我在<a href="/articles/2026-04-senior-engineer/">资深工程师的产出</a>里用“修复、反馈、恢复路径、默认能力”四层来分，避免把每个重复问题都解释成“缺少平台”。</p>]]></description></item><item><title>ADR 怎么写才有人看：记录判断，不记录会议流水账</title><link>https://siegaii.com/articles/2024-10-adr-decision-record/</link><guid>https://siegaii.com/articles/2024-10-adr-decision-record/</guid><pubDate>Sat, 19 Oct 2024 00:00:00 GMT</pubDate><description><![CDATA[<p>同一个问题在半年内讨论了三次：内部任务系统要继续用数据库队列，还是引入专门消息中间件。每次参与者不同，大家重新列一遍优缺点，最后凭印象说“上次好像觉得暂时不用”。决定没有进入代码，也没有留下复审条件，文档只有两页会议纪要。</p>
<p>我们开始写 Architecture Decision Record。它不是架构说明书，也不是会议转录，只保存当时为什么做这个选择、承担什么代价、什么变化会让它需要重审。</p>
<p><img src="/diagrams/series/2024-adr-decision.svg" alt="ADR 由上下文、决定和后果构成，并包含验证指标与复审触发器"></p>
<p><em>图 1：决定的寿命往往比参与会议的人长，必须保留当时的约束和反证条件。</em></p>
<h2 id="一页-adr-的最小结构">一页 ADR 的最小结构</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="markdown" data-theme="github-dark-default"><code data-language="markdown" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#79C0FF;font-weight:bold"># ADR-017: 任务调度继续使用 PostgreSQL 租约表</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">状态: Accepted</span></span>
<span data-line=""><span style="color:#E6EDF3">日期: 2024-10-19</span></span>
<span data-line=""><span style="color:#E6EDF3">负责人: Platform</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#79C0FF;font-weight:bold">## Context</span></span>
<span data-line=""><span style="color:#E6EDF3">当前峰值 40 jobs/s；要求事务内创建任务；团队无 Kafka 运维经验。</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#79C0FF;font-weight:bold">## Options</span></span>
<span data-line=""><span style="color:#FFA657">1.</span><span style="color:#E6EDF3"> PostgreSQL lease table</span></span>
<span data-line=""><span style="color:#FFA657">2.</span><span style="color:#E6EDF3"> Redis Streams</span></span>
<span data-line=""><span style="color:#FFA657">3.</span><span style="color:#E6EDF3"> Kafka</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#79C0FF;font-weight:bold">## Decision</span></span>
<span data-line=""><span style="color:#E6EDF3">未来两个季度继续使用 PostgreSQL，补充索引、租约回收和容量指标。</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#79C0FF;font-weight:bold">## Consequences</span></span>
<span data-line=""><span style="color:#E6EDF3">接受轮询成本和单库容量上限；暂不承担新中间件运维成本。</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#79C0FF;font-weight:bold">## Revisit when</span></span>
<span data-line=""><span style="color:#E6EDF3">持续 15 分钟超过 200 jobs/s，或跨区域消费成为硬需求。</span></span></code></pre></figure>
<p>上下文里写量级和组织能力，比“PostgreSQL 简单”更有用。规模变化后，旧结论可能自然失效。</p>
<h2 id="备选方案必须真实可选">备选方案必须真实可选</h2>
<p>为了证明自己的选择正确而列一个明显荒谬的对手，没有决策价值。每个选项写同一组维度：可靠性语义、吞吐与延迟、事务边界、运维成本、迁移成本、团队熟悉度和退出路径。</p>
<table>
<thead>
<tr>
<th>选项</th>
<th>优势</th>
<th>当时不能接受的代价</th>
</tr>
</thead>
<tbody>
<tr>
<td>PostgreSQL</td>
<td>与业务事务一致、已有运维</td>
<td>轮询和单库扩展上限</td>
</tr>
<tr>
<td>Redis Streams</td>
<td>延迟低、消费组</td>
<td>持久性与现有备份流程需补齐</td>
</tr>
<tr>
<td>Kafka</td>
<td>高吞吐、重放能力</td>
<td>规模不匹配，运维和迁移成本高</td>
</tr>
</tbody>
</table>
<h2 id="反对意见不要被会议结论抹掉">反对意见不要被会议结论抹掉</h2>
<p>ADR 里保留主要反对理由和为什么仍未选择。Kafka 方案支持者担心两年后迁移更贵，这是合理风险；我们把它转成复审触发器和接口约束：业务层不依赖 PostgreSQL 表结构，任务协议保持可迁移。</p>
<h2 id="决定要连接到执行证据">决定要连接到执行证据</h2>
<p>ADR 链接实现 PR、仪表盘和后续事故。代码中重要边界可以引用 ADR 编号，监控直接展示复审指标。当吞吐连续接近触发器时，自动创建技术任务，而不是等某个人想起旧文档。</p>
<p>状态不是只有 Accepted/Rejected。被新决定替代时标记 Superseded 并链接新 ADR；试验性选择用 Proposed；明确放弃的方案用 Rejected 并保留原因。不要修改旧 ADR 伪装历史一直正确。</p>
<h2 id="不是什么都值得写-adr">不是什么都值得写 ADR</h2>
<p>可轻易回退、影响范围小、遵循现有标准的选择不需要文档。ADR 留给跨团队、长期、昂贵或难逆的决定。数量太多会淹没真正重要的判断。</p>
<p>使用一段时间后，最直接的收益不是少开会，而是新成员能理解“为什么现在这样”，并知道哪些条件变化后可以合理挑战它。好文档不是阻止未来推翻决定，而是让未来的人不必先重演过去的全部争论。</p>
<p>ADR 也会过时。被新决定替代时标记 Superseded 并链接新 ADR；明确放弃的方案保留原因；复审触发器被触发过但没有跟进，是记录失效的最早信号。决定的价值在写下那一刻只是开始，后续每次重审都在更新它。这个“决定 → 利息 → 触发器 → 重审”的循环，在<a href="/articles/2026-06-architecture-debt/">架构债务不是旧代码</a>里有更完整的模型——那里把复审条件从文档变成了可监控的指标。</p>
<p>对负责人而言，ADR 还有一个附加作用：它让“这个决定当时为什么这么做”脱离个人记忆，团队不必事事等我解释。这与<a href="/articles/2024-09-tech-lead-boundaries/">技术负责人的边界</a>里把决定权下放给最接近问题的人，是同一套分权机制的两半。</p>]]></description></item><item><title>技术负责人的边界：不要成为团队的同步阻塞点</title><link>https://siegaii.com/articles/2024-09-tech-lead-boundaries/</link><guid>https://siegaii.com/articles/2024-09-tech-lead-boundaries/</guid><pubDate>Sat, 21 Sep 2024 00:00:00 GMT</pubDate><description><![CDATA[<p>技术负责人很容易变成所有问题的默认入口：方案要确认，代码要评审，线上问题要定位，跨团队信息也集中到一个人。短期看，这能保证一致性；长期看，团队速度被一个人的注意力限制。</p>
<p>如果负责人休假就无法做决定，说明系统依赖了个人，而不是建立了能力。</p>
<h2 id="决策分级">决策分级</h2>
<p>不是每个决定都需要同样参与。影响公共协议、数据模型和长期成本的选择应共同评审；局部可逆实现交给最近问题的人决定；已有原则能覆盖的事情，不再重复开会。</p>
<p>负责人要提供的是上下文和标准：目标是什么，不能破坏什么，如何验证。结论可以由不同成员产生。</p>
<p>我后来用“影响范围 × 可逆性”给决定分级：</p>
<table>
<thead>
<tr>
<th>决定</th>
<th>例子</th>
<th>决策方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>局部且可逆</td>
<td>组件内部实现、测试工具</td>
<td>负责人自行决定，PR 说明即可</td>
</tr>
<tr>
<td>跨模块但可逆</td>
<td>新缓存、内部协议扩展</td>
<td>小型设计评审，约定观测与退出条件</td>
</tr>
<tr>
<td>局部但难逆</td>
<td>数据删除、不可恢复迁移</td>
<td>双人确认，先演练恢复</td>
</tr>
<tr>
<td>跨系统且难逆</td>
<td>核心模型、公开 API、安全边界</td>
<td>RFC、原型、分阶段发布</td>
</tr>
</tbody>
</table>
<p>这张表减少了两种浪费：局部 CSS 实现不再排队等我拍板，核心数据模型也不会在 PR 最后一刻才被发现。技术负责人把精力放到决策成本真正高的地方。</p>
<h2 id="评审关注风险而非控制">评审关注风险而非控制</h2>
<p>代码评审不应要求所有代码都写成负责人的风格。重点是行为正确、边界清楚、风险可控。对于复杂改动，提前评审设计比最后逐行挑选更有效。</p>
<p>设计评审我只要求一页能回答的问题，不鼓励用文档长度制造安全感：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>要改变的用户/系统行为是什么？</span></span>
<span data-line=""><span>必须保持哪些不变量？</span></span>
<span data-line=""><span>数据和状态由谁拥有？</span></span>
<span data-line=""><span>失败后如何恢复？</span></span>
<span data-line=""><span>如何观察结果并判断方案有效？</span></span>
<span data-line=""><span>什么信号出现时要撤回或重做？</span></span></code></pre></figure>
<p>评审结束要留下决定、反对意见和后续验证，不保存一份“大家讨论过”的会议纪要。这样没有参会的人也能沿证据重新判断。</p>
<p>事故发生时，负责人承担协调与结果责任，但不应该独占排查。让成员参与完整过程，复盘改进系统而不是寻找个人错误，团队才会形成下一次独立处理的能力。</p>
<h2 id="上下文要公开流动">上下文要公开流动</h2>
<p>很多“必须由负责人决定”的问题，实际是信息只集中在负责人手里。把业务目标、架构约束和历史决策写进团队可访问的位置，成员才有可能独立判断。会议中的关键结论也要留下记录，不能依赖谁恰好在场。</p>
<p>信息公开不等于把所有细节推给每个人。负责人应提炼当前决定真正需要的上下文，并说明哪些边界不可突破。清晰上下文与明确授权同时存在，分布式决策才不会变成各自为政。</p>
<p>领导力不是让自己不可替代，而是让团队在没有即时指令时仍能做出一致、可靠的判断。</p>
<p><img src="/diagrams/series/legacy-tech-lead-leverage.svg" alt="原则、机制和授权构成的技术负责人杠杆架构"></p>
<p><em>图：优秀负责人降低团队等待，而不是让所有决定汇聚到自己。</em></p>
<p>这套分权要能运行，前提是团队知道什么必须找我。我会公开自己的升级规则：跨团队不可逆决定、生产高风险操作、连续两次无法收敛的故障必须升级；普通实现选择由最接近上下文的人决定，并通过 ADR 或评审留下证据。</p>
<p>我每周检查自己是否成为等待点：多少 PR、发布和方案必须等我；哪些决定可以通过原则、工具或授权下放。技术负责人真正的杠杆，是让系统在自己不在线时仍能做出合格判断。</p>
<p>两条配套实践在这里没有展开：决定怎样留下证据、反对意见怎样不被会议抹掉，属于<a href="/articles/2024-10-adr-decision-record/">ADR 怎么写才有人看</a>；把个人经验沉淀为团队默认能力的分层标准，属于<a href="/articles/2026-04-senior-engineer/">资深工程师的产出，不应该只存在于代码仓库</a>。</p>]]></description></item><item><title>requestId 到了消息队列就断了：异步链路的 Trace Context</title><link>https://siegaii.com/articles/2024-06-trace-context-propagation/</link><guid>https://siegaii.com/articles/2024-06-trace-context-propagation/</guid><pubDate>Sat, 22 Jun 2024 00:00:00 GMT</pubDate><description><![CDATA[<p>用户提交导出任务，API 返回 202，几分钟后页面只显示“导出失败”。前端有 requestId，API 日志能查到入队成功，Worker 日志却使用另一个 ID。我们只能按时间、用户和文件名猜哪条任务对应哪次请求，跨过消息队列后链路就断了。</p>
<p>后来接入 OpenTelemetry 时，我们没有只给 HTTP 自动埋点，而是明确规定上下文怎样进入消息头、怎样在 Worker 恢复，以及异步重试如何表达新的执行尝试。</p>
<p><img src="/diagrams/series/2024-trace-context.svg" alt="traceparent 从浏览器经过 API 和消息队列传播到 Worker 的时序"></p>
<p><em>图 1：队列不是追踪终点；生产 span 与消费 span 通过消息上下文关联。</em></p>
<h2 id="traceid-与-jobid-解决不同问题">traceId 与 jobId 解决不同问题</h2>
<p>traceId 描述一次执行链，适合性能和错误定位；jobId 描述长期业务任务，跨重试、暂停和人工恢复保持稳定。一次 Job 可能对应多个 trace。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ExportMessage</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  jobId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  attempt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  tenantId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  payload</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ExportInput</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>trace context 放消息 headers，不混入业务 payload。日志同时记录 traceId、spanId、jobId 和 attempt，既能沿执行链看时间，也能汇总任务历史。</p>
<h2 id="生产消息时注入上下文">生产消息时注入上下文</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">import</span><span style="color:#E6EDF3"> { context, propagation, trace } </span><span style="color:#FF7B72">from</span><span style="color:#A5D6FF"> "@opentelemetry/api"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> headers</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Record</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> {};</span></span>
<span data-line=""><span style="color:#E6EDF3">propagation.</span><span style="color:#D2A8FF">inject</span><span style="color:#E6EDF3">(context.</span><span style="color:#D2A8FF">active</span><span style="color:#E6EDF3">(), headers);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">await</span><span style="color:#E6EDF3"> queue.</span><span style="color:#D2A8FF">publish</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"export.requested"</span><span style="color:#E6EDF3">, message, { headers });</span></span></code></pre></figure>
<p>消息中间件可能只接受字符串 header，需要显式序列化。不要把整个上下文对象 JSON 化，因为标准传播字段和采样标志需要被其他语言理解。</p>
<h2 id="消费时恢复但不伪造同步父子关系">消费时恢复，但不伪造同步父子关系</h2>
<p>队列消息可能等待很久、被多个消费者处理或批量消费。我们根据语义选择 parent 或 link。一次普通单消费可以以提取上下文为父：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> parent</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> propagation.</span><span style="color:#D2A8FF">extract</span><span style="color:#E6EDF3">(context.</span><span style="color:#D2A8FF">active</span><span style="color:#E6EDF3">(), delivery.headers);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">await</span><span style="color:#E6EDF3"> context.</span><span style="color:#D2A8FF">with</span><span style="color:#E6EDF3">(parent, </span><span style="color:#FF7B72">async</span><span style="color:#E6EDF3"> () </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> tracer</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> trace.</span><span style="color:#D2A8FF">getTracer</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"export-worker"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> tracer.</span><span style="color:#D2A8FF">startActiveSpan</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"export.process"</span><span style="color:#E6EDF3">, </span><span style="color:#FF7B72">async</span><span style="color:#FFA657"> span</span><span style="color:#FF7B72"> =></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    try</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">      await</span><span style="color:#D2A8FF"> processExport</span><span style="color:#E6EDF3">(delivery.message);</span></span>
<span data-line=""><span style="color:#E6EDF3">      span.</span><span style="color:#D2A8FF">setStatus</span><span style="color:#E6EDF3">({ code: </span><span style="color:#79C0FF">1</span><span style="color:#E6EDF3"> });</span></span>
<span data-line=""><span style="color:#E6EDF3">    } </span><span style="color:#FF7B72">catch</span><span style="color:#E6EDF3"> (error) {</span></span>
<span data-line=""><span style="color:#E6EDF3">      span.</span><span style="color:#D2A8FF">recordException</span><span style="color:#E6EDF3">(error </span><span style="color:#FF7B72">as</span><span style="color:#FFA657"> Error</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">      throw</span><span style="color:#E6EDF3"> error;</span></span>
<span data-line=""><span style="color:#E6EDF3">    } </span><span style="color:#FF7B72">finally</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">      span.</span><span style="color:#D2A8FF">end</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">  });</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span></code></pre></figure>
<p>批处理由多个消息共同触发时，更适合新建 span 并链接多个来源，避免假装只有一个父节点。</p>
<h2 id="采样不能让错误证据全部消失">采样不能让错误证据全部消失</h2>
<p>全量 trace 成本太高，我们对普通成功请求低比例采样，对高风险任务和错误提高保留率。头部采样在请求开始时决定，无法预知后续失败；因此 Collector 还使用尾部采样，根据错误、长延迟和关键属性决定保留整条 trace。</p>
<table>
<thead>
<tr>
<th>属性</th>
<th>用途</th>
<th>注意</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>service.name</span></span></code></span></td>
<td>区分服务</td>
<td>使用稳定名称，不含实例 ID</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>job.type</span></span></code></span></td>
<td>分析任务类型</td>
<td>控制基数</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>tenant.id</span></span></code></span></td>
<td>权限过滤与聚合</td>
<td>按政策脱敏，不作为公开标签</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>attempt</span></span></code></span></td>
<td>区分重试</td>
<td>与 jobId 配合</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>error.code</span></span></code></span></td>
<td>聚合失败类型</td>
<td>不使用动态 message</td>
</tr>
</tbody>
</table>
<h2 id="上下文传播也要做契约测试">上下文传播也要做契约测试</h2>
<p>我们在集成环境发送带已知 traceparent 的消息，断言 Worker span 与其关联；重试产生新 span 但 jobId 不变；死信记录最后 traceId；日志能够从 trace 跳到 job 页面，再从 job 页面列出所有 attempts。</p>
<p>链路接通后，那类“API 成功、后台失败”的问题不再靠时间猜测。更重要的是，我们没有把 traceId 当万能业务 ID。追踪关注一次执行，业务模型关注一个长期任务；把两者同时保留，系统才既能诊断性能，也能解释用户结果。</p>
<p>这两类 ID 的边界会在 Agent 场景里被进一步放大：模型的一次调用既是执行链的一部分，又是一个需要审批、重试和补偿的长期任务。我在<a href="/articles/2026-05-human-approval-transaction/">人工审批不是弹窗</a>里把 planId、taskId 与执行尝试分离开，正是同一原则在事务边界的延伸。可观测性的整体组织方式则在<a href="/articles/2024-02-observability-system/">可观测性不是一块 Grafana 大屏</a>里展开。</p>]]></description></item><item><title>差量更新的收益与代价</title><link>https://siegaii.com/articles/2024-05-diff-update/</link><guid>https://siegaii.com/articles/2024-05-diff-update/</guid><pubDate>Sat, 18 May 2024 00:00:00 GMT</pubDate><description><![CDATA[<p>大型报表每次接收新数据都完整重绘，CPU 与内存开销明显。差量更新的思路很自然：比较新旧数据，只把新增、删除和改变的部分交给渲染层。</p>
<p>收益来自减少工作，代价来自必须可靠地判断“什么变了”。</p>
<h2 id="标识比比较更重要">标识比比较更重要</h2>
<p>没有稳定主键，只能按位置或完整对象比较。排序变化会被误认为所有行都改变，深比较本身也可能比重绘更贵。数据模型需要提供稳定标识和版本，更新算法才能保持简单。</p>
<p>我们先用映射建立旧数据索引，再生成新增、更新和删除集合。字段变化还要区分是否影响视觉，例如后台元数据变化不需要重绘图形。</p>
<p>一个 O(n) 的基础实现足以覆盖大多数表格和图元：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> Row</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">value</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">label</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> diffRows</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">previous</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Row</span><span style="color:#E6EDF3">[], </span><span style="color:#FFA657">next</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Row</span><span style="color:#E6EDF3">[]) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> before</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Map</span><span style="color:#E6EDF3">(previous.</span><span style="color:#D2A8FF">map</span><span style="color:#E6EDF3">((</span><span style="color:#FFA657">row</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> [row.id, row]));</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> added</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Row</span><span style="color:#E6EDF3">[] </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> [];</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> updated</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Row</span><span style="color:#E6EDF3">[] </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> [];</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  for</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> row</span><span style="color:#FF7B72"> of</span><span style="color:#E6EDF3"> next) {</span></span>
<span data-line=""><span style="color:#FF7B72">    const</span><span style="color:#79C0FF"> old</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> before.</span><span style="color:#D2A8FF">get</span><span style="color:#E6EDF3">(row.id);</span></span>
<span data-line=""><span style="color:#FF7B72">    if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">old) added.</span><span style="color:#D2A8FF">push</span><span style="color:#E6EDF3">(row);</span></span>
<span data-line=""><span style="color:#FF7B72">    else</span><span style="color:#FF7B72"> if</span><span style="color:#E6EDF3"> (old.version </span><span style="color:#FF7B72">!==</span><span style="color:#E6EDF3"> row.version) updated.</span><span style="color:#D2A8FF">push</span><span style="color:#E6EDF3">(row);</span></span>
<span data-line=""><span style="color:#E6EDF3">    before.</span><span style="color:#D2A8FF">delete</span><span style="color:#E6EDF3">(row.id);</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> { added, updated, removedIds: [</span><span style="color:#FF7B72">...</span><span style="color:#E6EDF3">before.</span><span style="color:#D2A8FF">keys</span><span style="color:#E6EDF3">()] };</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>这里依赖服务端提供单调版本。如果只能深比较对象，首先要确认比较成本和数据语义；<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>JSON.stringify</span></span></code></span> 会受字段顺序影响，也会把和视觉无关的元数据变化算成更新。</p>
<h2 id="什么时候放弃增量">什么时候放弃增量</h2>
<p>变化比例很高、排序规则改变或图表配置变化时，完整重建反而更可靠。系统应设置阈值，在增量成本接近全量时切换策略，而不是执着于每次最小修改。</p>
<p>我会记录 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>changed / total</span></span></code></span>、diff 耗时和渲染耗时，再决定阈值。假设 60% 数据变化时，增量路径既要维护索引，又要执行大量局部操作，往往不如一次全量替换。阈值不是固定经验值，而是当前渲染器和数据规模下的测量结果。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> changeRatio</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> (added.</span><span style="color:#79C0FF">length</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> updated.</span><span style="color:#79C0FF">length</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> removedIds.</span><span style="color:#79C0FF">length</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">/</span><span style="color:#E6EDF3"> next.</span><span style="color:#79C0FF">length</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">if</span><span style="color:#E6EDF3"> (changeRatio </span><span style="color:#FF7B72">></span><span style="color:#79C0FF"> 0.45</span><span style="color:#FF7B72"> ||</span><span style="color:#E6EDF3"> sortChanged </span><span style="color:#FF7B72">||</span><span style="color:#E6EDF3"> schemaChanged) {</span></span>
<span data-line=""><span style="color:#E6EDF3">  renderer.</span><span style="color:#D2A8FF">replaceAll</span><span style="color:#E6EDF3">(next);</span></span>
<span data-line=""><span style="color:#E6EDF3">} </span><span style="color:#FF7B72">else</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">  renderer.</span><span style="color:#D2A8FF">applyPatch</span><span style="color:#E6EDF3">({ added, updated, removedIds });</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>差量路径与全量路径必须得到一致结果。测试用随机变化序列同时执行两种策略，比较最终状态，可以发现遗漏删除、顺序错误和缓存失效问题。</p>
<p>这类测试适合属性测试：随机生成增加、删除、更新和重排序列，让增量状态与 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>replaceAll</span></span></code></span> 后的状态做深比较。全量路径虽然慢，却是很好的参照实现；没有这条校准路径，增量缓存一旦漂移就很难自证。</p>
<h2 id="先减少进入更新的数据">先减少进入更新的数据</h2>
<p>有时最有效的差量并不在 DOM 层。接口若能返回版本或变更游标，前端就不必先下载完整数据再比较；状态管理层若保留结构共享，组件也能快速判断哪些分支未变。越靠近数据源识别变化，后续各层浪费越少。</p>
<p>但端到端增量需要协议支持。丢失一段变更时必须能够重新同步全量，版本不连续要被发现。增量是一条快路径，全量仍是校准事实和恢复系统的基准路径。</p>
<p>性能优化总会交换复杂度。只有测量证明重绘是主要瓶颈，并且数据拥有稳定身份时，差量更新才是一笔值得做的交易。</p>
<p><img src="/diagrams/series/legacy-diff-protocol.svg" alt="客户端与版本、差量和全量存储之间的更新回退时序"></p>
<p><em>图：任何版本缺口或 hash 不一致都必须回到可信基线。</em></p>
<p>差量协议必须守住三个不变量：同一 patch 重放结果不变；任何版本缺口都能回退全量；应用 patch 后的内容 hash 能与服务端目标 hash 对齐。客户端不满足任一项就停止继续增量，重新拉取基线。</p>
<p>上线时同时观察差量命中率、回退全量率、hash 不一致和节省字节。只看带宽下降，会忽略客户端状态逐渐偏离这个更危险的问题——增量路径的正确性只能由全量基线证明，这也是“全量路径必须保留”的原因。</p>
<p>在报表场景里，diff 之前还有一层：数据本身可以先按版本和变化游标交付，让前端不必下载完整快照再比较。这个思路与<a href="/articles/2023-10-large-data-rendering/">十万级数据渲染</a>里按像素聚合、按需取数的预算思想一致——先减少进入系统的数据，再优化对已有数据的处理。</p>]]></description></item><item><title>可观测性不是做一块 Grafana 大屏</title><link>https://siegaii.com/articles/2024-02-observability-system/</link><guid>https://siegaii.com/articles/2024-02-observability-system/</guid><pubDate>Sat, 24 Feb 2024 00:00:00 GMT</pubDate><description><![CDATA[<p>系统接入 Prometheus 和 Grafana 后，很容易拥有几十张图：CPU、内存、请求量、队列长度。图表丰富不等于可观测。真正故障发生时，如果团队仍然不知道用户受到了什么影响、问题从哪里开始，监控只是装饰。</p>
<p><img src="/diagrams/observability-loop.svg" alt="从用户结果、SLO、信号、告警、恢复到复盘的可观测性闭环"></p>
<p><em>图 1：仪表盘用于探索，告警必须指向行动，复盘则同时修正系统和信号。</em></p>
<h2 id="从问题反推信号">从问题反推信号</h2>
<p>每个指标都应回答一个运营问题。请求量、错误率和延迟说明服务是否健康；任务等待时间和重试次数说明队列是否积压；业务流程完成率说明系统是否仍在交付价值。</p>
<p>我通常先写服务级目标，再决定采集什么。以发布平台为例，可以先定义：在 30 天窗口内，99.5% 的发布任务应在 15 分钟内进入成功、失败或等待人工状态；不能永久停在运行中。</p>
<p>这个目标拆成两个可计算信号：终态率和完成时长。Prometheus 查询可以从业务状态出发，而不是先看 CPU：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="promql" data-theme="github-dark-default"><code data-language="promql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>sum(rate(release_job_terminal_total[5m]))</span></span>
<span data-line=""><span>/</span></span>
<span data-line=""><span>sum(rate(release_job_created_total[5m]))</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="promql" data-theme="github-dark-default"><code data-language="promql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>histogram_quantile(</span></span>
<span data-line=""><span>  0.95,</span></span>
<span data-line=""><span>  sum by (le) (rate(release_job_duration_seconds_bucket[15m]))</span></span>
<span data-line=""><span>)</span></span></code></pre></figure>
<p>比值异常后，再沿任务队列等待、Worker 执行和外部部署接口查看资源信号。调查顺序和用户路径一致，仪表盘就不再是一组互不相关的系统图。</p>
<p>资源指标用于解释原因，业务指标用于判断影响。只看 CPU 很难知道用户是否无法发布，只看成功率又无法定位瓶颈。</p>
<p>日志应带有请求或任务标识，使一次流程可以跨服务串联。错误日志记录上下文和分类，但避免敏感数据与无界对象。追踪只在关键链路采样，不能为了完整而制造新的成本。</p>
<p>一次任务至少需要关联这些字段：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "traceId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"tr_7a2f"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "taskId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"release_812"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "step"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"deploy"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "attempt"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">2</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "kind"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"dependency_timeout"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "dependency"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"cluster-api"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "durationMs"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">5012</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>日志里不直接写完整部署参数和 Token。需要复原输入时，通过 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>taskId</span></span></code></span> 到受权限控制的业务存储查询。日志负责索引事件，不应该成为影子数据库。</p>
<h2 id="告警必须指向行动">告警必须指向行动</h2>
<p>告警阈值要考虑持续时间和业务基线，瞬时波动不应唤醒所有人。每条告警都需要负责人、影响说明和第一步检查入口；无人处理的告警应该删除或降级。</p>
<p>我会把告警说明和代码一起版本化：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="yaml" data-theme="github-dark-default"><code data-language="yaml" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#7EE787">alert</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">ReleaseTerminalRateLow</span></span>
<span data-line=""><span style="color:#7EE787">for</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">10m</span></span>
<span data-line=""><span style="color:#7EE787">severity</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">page</span></span>
<span data-line=""><span style="color:#7EE787">owner</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">developer-platform</span></span>
<span data-line=""><span style="color:#7EE787">impact</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">用户发布任务无法进入可解释终态</span></span>
<span data-line=""><span style="color:#7EE787">runbook</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">/runbooks/release-terminal-rate</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>for: 10m</span></span></code></span> 避免单个采集周期波动直接呼叫值班；Runbook 第一屏先给查询路径和止损动作，而不是从系统历史讲起。一次告警若无法指向任何动作，它更适合做仪表盘信号，而不是打断人。</p>
<p>仪表盘用于探索，告警用于行动，复盘用于修正系统。三者通过相同信号连接，才形成真正的可观测性闭环。</p>
<h2 id="从一次故障验证监控">从一次故障验证监控</h2>
<p>监控体系是否有效，最好用真实故障或演练检查。隐藏一个下游依赖、制造任务积压，观察团队多快发现、能否判断用户影响、是否找到相关日志。如果只能通过熟悉系统的人凭经验定位，说明信号仍未形成路径。</p>
<p>复盘后不仅增加指标，也要删除无用面板、修正阈值和补充行动说明。可观测性与代码一样会老化，服务拆分、流量模式和业务目标改变后，过去重要的信号可能只剩噪声。持续维护信号质量，才不会在需要时面对一面失真的仪表墙。</p>
<p>告警还必须走完关闭闭环。告警关闭时必须选择：用户影响结束、指标误报、已知维护或尚未解决但转为长期任务；同时记录止损动作和后续 owner。没有结局的告警会不断重复消耗注意力。月度复盘看重复根因、无行动分页占比、从发现到止损的时间，以及 Runbook 是否真的被使用。可观测性的产出不是图表数量，而是更短、更确定的恢复路径。</p>
<p>这条链路的上游是告警如何被触发：用户结果怎样变成 SLO、错误预算和燃烧率，我在<a href="/articles/2024-01-slo-alert-routing/">告警为什么总在半夜吵醒错误的人</a>里单独记录过。</p>]]></description></item><item><title>告警为什么总在半夜吵醒错误的人：从 SLO 到 Runbook</title><link>https://siegaii.com/articles/2024-01-slo-alert-routing/</link><guid>https://siegaii.com/articles/2024-01-slo-alert-routing/</guid><pubDate>Sat, 20 Jan 2024 00:00:00 GMT</pubDate><description><![CDATA[<p>曾经我们的告警群很热闹：CPU 超过 80%、内存超过 75%、某接口一分钟报错 5 次。值班人半夜打开图表，常常发现用户没有明显影响；真正发生登录大面积失败时，错误分散在多个实例，反而没有任何单机阈值触发。</p>
<p>2024 年我们开始从用户结果定义 SLO，再用错误预算燃烧率决定告警。资源指标仍然保留，但更多用于诊断，而不是直接把人叫醒。</p>
<p><img src="/diagrams/series/2024-slo-alert-routing.svg" alt="用户结果经过 SLI、错误预算和燃烧率计算后路由到 Runbook"></p>
<p><em>图 1：一条值得打断人的告警，应该说明用户结果正在以多快速度恶化，以及谁能做什么。</em></p>
<h2 id="先定义用户看到的成功">先定义用户看到的成功</h2>
<p>以“创建报表”为例，成功不是 HTTP 200，而是用户在 30 秒内得到可打开的报表，且数据查询完成。请求被网关接受但异步任务最终失败，不能计为成功。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>SLI = 30 秒内完成且结果可读取的创建次数 / 有效创建请求数</span></span>
<span data-line=""><span>SLO = 30 天滚动窗口内 SLI >= 99.9%</span></span></code></pre></figure>
<p>有效请求排除明显非法输入，但不排除服务端权限判断或依赖失败。排除规则必须稳定，否则团队可以通过改变分母“优化”SLO。</p>
<h2 id="错误预算把可靠性变成可消耗资源">错误预算把可靠性变成可消耗资源</h2>
<p>99.9% 月度 SLO 意味着 0.1% 失败预算。燃烧率表示当前速度相对预算允许速度的倍数。短窗口高燃烧代表突发事故，长窗口持续燃烧代表慢性问题。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="promql" data-theme="github-dark-default"><code data-language="promql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>(</span></span>
<span data-line=""><span>  sum(rate(report_create_total{result!="success"}[5m]))</span></span>
<span data-line=""><span>  /</span></span>
<span data-line=""><span>  sum(rate(report_create_total[5m]))</span></span>
<span data-line=""><span>) / 0.001</span></span></code></pre></figure>
<p>生产规则使用 5m/1h 和 30m/6h 等多窗口组合：短、长窗口同时超过阈值才分页，降低一瞬间毛刺带来的噪声。</p>
<p>用数字说明会更具体。月度 99.9% 的 SLO 对应错误预算 0.1%，即 30 天里允许约 43 分钟失败。一次持续 10 分钟、错误率 6% 的事件，5 分钟窗口的燃烧率是 60 倍，足以分页；同一事件摊到 1 小时窗口只剩 10 倍。两个窗口不是重复报警，而是区分“正在发生的事故”和“一瞬间的毛刺”。</p>
<table>
<thead>
<tr>
<th>级别</th>
<th>条件示例</th>
<th>动作</th>
</tr>
</thead>
<tbody>
<tr>
<td>Page</td>
<td>5m 与 1h 高速燃烧</td>
<td>立即叫醒值班，目标 5 分钟确认</td>
</tr>
<tr>
<td>Ticket</td>
<td>30m 与 6h 中速燃烧</td>
<td>工作时间处理，阻止继续消耗</td>
</tr>
<tr>
<td>Dashboard</td>
<td>资源接近容量但 SLO 正常</td>
<td>容量规划，不打断值班</td>
</tr>
</tbody>
</table>
<h2 id="告警内容必须支持第一步行动">告警内容必须支持第一步行动</h2>
<p>每条告警包含：受影响用户结果、开始时间、当前燃烧率、主要维度、最近发布、负责人、仪表盘和 Runbook。不要只发一条“error rate high”。</p>
<p>Runbook 的第一屏回答：如何确认影响；最快止损动作；哪些操作有风险；需要升级给谁。长篇原理放在后面。事故中人的工作记忆很有限，文档要按执行顺序写。</p>
<h2 id="按所有权路由而不是按技术栈">按所有权路由，而不是按技术栈</h2>
<p>登录 SLO 由身份团队负责，即使根因发生在 Redis；报表创建由报表团队负责，即使错误来自队列。拥有用户结果的团队先接警，再根据 trace 与依赖图升级。按“数据库群”“Node.js 群”广播，通常会让每个人都以为别人会处理。</p>
<h2 id="每次告警都要有结局">每次告警都要有结局</h2>
<p>我们给告警记录分类：真实事故、已知维护、阈值不合理、数据错误、无行动价值。每月看分页数量、确认时间、无行动占比和重复根因。无法导致任何动作的告警要降级或删除。</p>
<p>改造后值班并没有变得轻松，而是打断更少、每次打断更接近真实用户影响。SLO 的价值不是制造一个新的百分比，而是让团队明确什么结果值得保护，并把注意力留给正在快速消耗可靠性预算的问题。告警只是这条链路的一环：从用户结果定义信号、再到告警关闭后的复盘闭环，完整的做法在<a href="/articles/2024-02-observability-system/">可观测性不是一块 Grafana 大屏</a>里展开。</p>]]></description></item><item><title>Node.js 优雅退出：先停止接单，再归还资源</title><link>https://siegaii.com/articles/2023-12-node-graceful-shutdown/</link><guid>https://siegaii.com/articles/2023-12-node-graceful-shutdown/</guid><pubDate>Sat, 09 Dec 2023 00:00:00 GMT</pubDate><description><![CDATA[<p>一次正常发布后，我们看到短暂 502，同时有两条异步任务被重复执行。容器收到 SIGTERM 后直接退出，负载均衡还在把请求送过来，队列里的任务租约也没来得及归还。代码没有抛异常，问题出在进程结束的顺序。</p>
<p>优雅退出不是一个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>process.on('SIGTERM')</span></span></code></span> 日志，而是入口、在途工作和资源连接之间的协议。</p>
<p><img src="/diagrams/series/2023-graceful-shutdown.svg" alt="Node.js 服务从停止接流量、耗尽在途工作到释放连接的退出架构"></p>
<p><em>图 1：先让外部世界知道“不要再给我新工作”，再处理已经承诺的工作，最后关闭依赖。</em></p>
<h2 id="信号到达后先进入-draining">信号到达后先进入 draining</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">let</span><span style="color:#E6EDF3"> draining </span><span style="color:#FF7B72">=</span><span style="color:#79C0FF"> false</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">app.</span><span style="color:#D2A8FF">get</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"/health/ready"</span><span style="color:#E6EDF3">, (</span><span style="color:#FFA657">_req</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">res</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (draining) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3"> res.</span><span style="color:#D2A8FF">status</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">503</span><span style="color:#E6EDF3">).</span><span style="color:#D2A8FF">json</span><span style="color:#E6EDF3">({ ready: </span><span style="color:#79C0FF">false</span><span style="color:#E6EDF3">, reason: </span><span style="color:#A5D6FF">"draining"</span><span style="color:#E6EDF3"> });</span></span>
<span data-line=""><span style="color:#E6EDF3">  res.</span><span style="color:#D2A8FF">json</span><span style="color:#E6EDF3">({ ready: </span><span style="color:#79C0FF">true</span><span style="color:#E6EDF3"> });</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span></code></pre></figure>
<p>收到 SIGTERM 立即把 readiness 变为失败，让 Kubernetes 从 Service Endpoint 移除实例。这个传播需要时间，因此还要停止 HTTP server 接受新连接，并等待已有连接完成。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> server</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> app.</span><span style="color:#D2A8FF">listen</span><span style="color:#E6EDF3">(port);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> stopAcceptingRequests</span><span style="color:#E6EDF3">() {</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#FF7B72"> new</span><span style="color:#79C0FF"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">void</span><span style="color:#E6EDF3">>((</span><span style="color:#FFA657">resolve</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">reject</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">    server.</span><span style="color:#D2A8FF">close</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">error</span><span style="color:#FF7B72"> =></span><span style="color:#E6EDF3"> error </span><span style="color:#FF7B72">?</span><span style="color:#D2A8FF"> reject</span><span style="color:#E6EDF3">(error) </span><span style="color:#FF7B72">:</span><span style="color:#D2A8FF"> resolve</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#E6EDF3">  });</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>Keep-Alive 连接可能延长等待。运行时需要跟踪 socket，在软超时后通知关闭空闲连接，硬超时才强制销毁。</p>
<h2 id="队列消费者先暂停领取">队列消费者先暂停领取</h2>
<p>Worker 收到信号后停止领取新任务，当前任务根据语义选择完成或归还租约。不能先关闭 Redis，再尝试更新任务状态。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> drainWorkers</span><span style="color:#E6EDF3">() {</span></span>
<span data-line=""><span style="color:#FF7B72">  await</span><span style="color:#E6EDF3"> worker.</span><span style="color:#D2A8FF">pause</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">true</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">  await</span><span style="color:#D2A8FF"> waitForActiveJobs</span><span style="color:#E6EDF3">({ timeoutMs: </span><span style="color:#79C0FF">20_000</span><span style="color:#E6EDF3"> });</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>支付、发布这类有副作用任务必须有幂等和租约；即使硬超时杀进程，下一实例也能安全接管。优雅退出降低中断概率，不是替代任务可靠性。</p>
<h2 id="资源关闭按依赖方向逆序">资源关闭按依赖方向逆序</h2>
<p>启动顺序通常是数据库/Redis → 仓储 → 服务 → HTTP/Worker，关闭反过来：入口 → 业务工作 → 日志/遥测 → Redis/数据库。业务还在运行时先断数据库，只会制造新的失败。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> shutdown</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">signal</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (draining) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">  draining </span><span style="color:#FF7B72">=</span><span style="color:#79C0FF"> true</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> hardStop</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> setTimeout</span><span style="color:#E6EDF3">(() </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> process.</span><span style="color:#D2A8FF">exit</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">1</span><span style="color:#E6EDF3">), </span><span style="color:#79C0FF">30_000</span><span style="color:#E6EDF3">).</span><span style="color:#D2A8FF">unref</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">  try</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    await</span><span style="color:#D2A8FF"> stopAcceptingRequests</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">    await</span><span style="color:#D2A8FF"> drainWorkers</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">    await</span><span style="color:#E6EDF3"> telemetry.</span><span style="color:#D2A8FF">flush</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">    await</span><span style="color:#E6EDF3"> redis.</span><span style="color:#D2A8FF">quit</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">    await</span><span style="color:#E6EDF3"> database.</span><span style="color:#D2A8FF">close</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#D2A8FF">    clearTimeout</span><span style="color:#E6EDF3">(hardStop);</span></span>
<span data-line=""><span style="color:#E6EDF3">    process.</span><span style="color:#D2A8FF">exit</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">0</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">  } </span><span style="color:#FF7B72">catch</span><span style="color:#E6EDF3"> (error) {</span></span>
<span data-line=""><span style="color:#E6EDF3">    logger.</span><span style="color:#D2A8FF">error</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"shutdown_failed"</span><span style="color:#E6EDF3">, { signal, error });</span></span>
<span data-line=""><span style="color:#E6EDF3">    process.</span><span style="color:#D2A8FF">exit</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">1</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<h2 id="时间预算必须小于平台终止窗口">时间预算必须小于平台终止窗口</h2>
<table>
<thead>
<tr>
<th>阶段</th>
<th>预算示例</th>
<th>超时动作</th>
</tr>
</thead>
<tbody>
<tr>
<td>Endpoint 摘除传播</td>
<td>3 秒</td>
<td>HTTP 拒绝新业务请求</td>
</tr>
<tr>
<td>HTTP 在途请求</td>
<td>10 秒</td>
<td>关闭剩余 socket</td>
</tr>
<tr>
<td>当前队列任务</td>
<td>15 秒</td>
<td>归还租约/允许重投</td>
</tr>
<tr>
<td>遥测刷新</td>
<td>2 秒</td>
<td>丢弃非关键批次</td>
</tr>
<tr>
<td>连接关闭</td>
<td>2 秒</td>
<td>强制退出</td>
</tr>
</tbody>
</table>
<p>总预算不能超过 Kubernetes <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>terminationGracePeriodSeconds</span></span></code></span>。平台在 30 秒杀进程，应用内部设计 60 秒等待没有意义。</p>
<p><img src="/diagrams/series/2023-shutdown-budget.svg" alt="优雅退出各阶段与时间预算的关系"></p>
<p><em>图 2：预算不是平均分配，而是先给“让外部世界停止投递”留足时间，业务收尾才真正开始。</em></p>
<p>每个阶段还有各自的降级动作，不是排队等待超时才一起放弃：Endpoint 摘除超时，HTTP 层直接拒绝新请求；HTTP 超时，剩余 socket 强制关闭；队列任务超时，归还租约允许其他 Worker 重投。逐段降级让最坏情况也只损失最远的一段工作，而不是全部等待被一个卡住的阶段耗尽。</p>
<h2 id="用真实信号做集成测试">用真实信号做集成测试</h2>
<p>我们在测试环境持续发送请求和队列任务，向进程发送 SIGTERM，断言：readiness 先失败；新请求不再进入；已接收请求有明确结果；任务不丢且不会产生不可接受重复；退出码和耗时被记录。</p>
<p>上线后观察发布窗口的 502、任务 lease 过期、强制退出数和 shutdown 各阶段耗时。只有应用、负载均衡和编排平台的时间线能对齐，优雅退出才不是一段看起来完整的 finally。队列连接关闭时如果还持有任务状态，那就是另一类泄漏——<a href="/articles/2023-01-nodejs-memory-leak-investigation/">Node.js 内存泄漏排查</a>里记录的实例生命周期问题，和这里的关闭顺序是同一个原则的两面。</p>]]></description></item><item><title>十万级数据渲染：不要让 DOM 承担数据仓库的工作</title><link>https://siegaii.com/articles/2023-10-large-data-rendering/</link><guid>https://siegaii.com/articles/2023-10-large-data-rendering/</guid><pubDate>Sat, 14 Oct 2023 00:00:00 GMT</pubDate><description><![CDATA[<p>当表格需要展示十万级数据时，最明显的问题是 DOM 数量。一次创建数十万个节点会占用大量内存，样式计算与布局也会阻塞主线程。于是虚拟列表成为标准答案：只渲染视口附近的行。</p>
<p>但在真实 BI 页面里，虚拟列表只解决了最后一段。数据下载、解析、排序、聚合、格式化和图表转换都可能先把主线程占满。页面没有生成很多 DOM，用户仍然会感觉冻结。</p>
<p>优化之前必须把完整管线拆开。</p>
<p><img src="/diagrams/large-data-pipeline.svg" alt="海量数据在服务端限制规模、Worker 中计算并通过虚拟列表或 Canvas 渲染的处理管线"></p>
<p><em>图 1：虚拟列表只解决最后一段。数据规模、解析、计算、布局和绘制都需要独立预算。</em></p>
<h2 id="建立数据预算">建立数据预算</h2>
<p>第一步不是写代码，而是确认产品真的需要在浏览器中持有十万条明细。用户要完成的是浏览、搜索、比较还是导出？多数场景只需要聚合结果或当前窗口数据，完整数据应留在服务端。</p>
<p>我们为不同组件定义数据预算：图表有最大点数，表格有直接渲染、虚拟渲染和服务端分页的分界，导出走独立任务。超过预算时，不静默抽样，而是告诉用户当前展示范围和获得完整结果的方式。</p>
<p>预算需要按项目基线测量，下面是一份可落地的起始配置，而不是通用真理：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>默认策略</th>
<th>切换条件</th>
</tr>
</thead>
<tbody>
<tr>
<td>表格浏览</td>
<td>客户端分页</td>
<td>返回数据超过 5,000 行改服务端分页</td>
</tr>
<tr>
<td>固定行表格</td>
<td>虚拟列表</td>
<td>可见 DOM 保持在 100 行以内</td>
</tr>
<tr>
<td>时间序列</td>
<td>按像素宽度聚合</td>
<td>点数超过绘图区宽度的 2-4 倍</td>
</tr>
<tr>
<td>导出</td>
<td>服务端异步任务</td>
<td>不在页面内生成十万行文件</td>
</tr>
</tbody>
</table>
<p>阈值要和设备分布、字段宽度及交互一起压测。配置的价值是让运行时知道何时拒绝危险路径，不是证明浏览器最多能塞多少数据。</p>
<p>预算迫使产品和技术共同回答需求。没有边界的“支持大数据”，最终会变成浏览器承担不适合它的工作。</p>
<h2 id="测量四个阶段">测量四个阶段</h2>
<p>一份大数据从网络到屏幕，至少经过获取、计算、布局和绘制。我们分别记录：响应体大小与下载时间；JSON 解析、字段转换、排序聚合耗时；DOM 创建与布局耗时；Canvas 或 SVG 绘制耗时。</p>
<p>浏览器 Performance 面板可以看到长任务，但还需要业务标记把长任务对应到具体阶段。否则一次 800 毫秒阻塞只会显示为脚本执行，无法知道是排序还是图表配置转换。</p>
<p>内存也要测量。大数组复制、为每行创建新对象、保留旧筛选结果，都会让堆持续增长。一次操作完成后内存没有回落，往往说明引用仍被组件、缓存或监听器持有。</p>
<h2 id="虚拟列表的真实复杂度">虚拟列表的真实复杂度</h2>
<p>固定行高的虚拟列表相对简单：根据滚动位置计算起止索引，容器保留总高度，实际节点通过偏移放到正确位置。渲染节点数量从十万降到几十，布局成本显著下降。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> visibleRange</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">scrollTop</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">viewportHeight</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">rowHeight</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">total</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> overscan</span><span style="color:#FF7B72"> =</span><span style="color:#79C0FF"> 8</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> start</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">max</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">0</span><span style="color:#E6EDF3">, Math.</span><span style="color:#D2A8FF">floor</span><span style="color:#E6EDF3">(scrollTop </span><span style="color:#FF7B72">/</span><span style="color:#E6EDF3"> rowHeight) </span><span style="color:#FF7B72">-</span><span style="color:#E6EDF3"> overscan);</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> end</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">min</span><span style="color:#E6EDF3">(total, Math.</span><span style="color:#D2A8FF">ceil</span><span style="color:#E6EDF3">((scrollTop </span><span style="color:#FF7B72">+</span><span style="color:#E6EDF3"> viewportHeight) </span><span style="color:#FF7B72">/</span><span style="color:#E6EDF3"> rowHeight) </span><span style="color:#FF7B72">+</span><span style="color:#E6EDF3"> overscan);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> { start, end, offsetY: start </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> rowHeight, totalHeight: total </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> rowHeight };</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>overscan</span></span></code></span> 避免快速滚动时露出空白，但不是越大越好。每行包含复杂单元格时，多渲染几十行就可能抵消虚拟化收益。滚动容器、焦点和行 key 也必须稳定，否则节点复用会把输入状态带到错误行。</p>
<p>可变行高会复杂得多。文本换行、展开详情和动态内容都会改变高度，需要测量并维护位置索引。高度估算不准会造成滚动跳动，频繁测量又会触发布局抖动。</p>
<p>我们尽量在大数据模式下约束行高和内容展示，复杂详情通过独立区域打开。不是所有桌面表格能力都应该与虚拟化同时存在。</p>
<p>虚拟化还会影响键盘导航、搜索、复制和可访问性。不存在于 DOM 的行无法被浏览器原生查找，焦点在节点回收时可能丢失。这些不是实现细节，而是功能契约的一部分。</p>
<h2 id="把计算移出主线程">把计算移出主线程</h2>
<p>排序、聚合和复杂转换可以放进 Web Worker，让主线程保持交互响应。Worker 并不会让计算本身变快，它只是改变计算位置，还增加了数据传输成本。</p>
<p>如果把巨大对象频繁 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>postMessage</span></span></code></span>，结构化克隆同样昂贵。我们减少传输字段，使用更紧凑的数据结构，在适合时使用 Transferable 转移二进制缓冲区。任务消息包含版本，用户改变条件后，旧任务结果会被丢弃。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#8B949E">// main thread</span></span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> values</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Float64Array</span><span style="color:#E6EDF3">(rows.</span><span style="color:#D2A8FF">map</span><span style="color:#E6EDF3">((</span><span style="color:#FFA657">row</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> row.value));</span></span>
<span data-line=""><span style="color:#E6EDF3">worker.</span><span style="color:#D2A8FF">postMessage</span><span style="color:#E6EDF3">(</span></span>
<span data-line=""><span style="color:#E6EDF3">  { type: </span><span style="color:#A5D6FF">"aggregate"</span><span style="color:#E6EDF3">, version, values: values.buffer },</span></span>
<span data-line=""><span style="color:#E6EDF3">  [values.buffer],</span></span>
<span data-line=""><span style="color:#E6EDF3">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#8B949E">// worker</span></span>
<span data-line=""><span style="color:#E6EDF3">self.</span><span style="color:#D2A8FF">onmessage</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> ({ </span><span style="color:#FFA657">data</span><span style="color:#E6EDF3"> }) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> values</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Float64Array</span><span style="color:#E6EDF3">(data.values);</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> result</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> aggregateByBucket</span><span style="color:#E6EDF3">(values);</span></span>
<span data-line=""><span style="color:#E6EDF3">  self.</span><span style="color:#D2A8FF">postMessage</span><span style="color:#E6EDF3">({ version: data.version, result: result.buffer }, [result.buffer]);</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>Buffer 转移后主线程的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>values</span></span></code></span> 会失效，这是零拷贝的代价。只有数据所有权明确时才使用 Transferable；若主线程仍要读取同一数组，就需要重新设计边界，而不是偷偷复制回来。</p>
<p>Worker 逻辑保持纯粹：接收输入，执行确定转换，返回结果。不访问 UI 状态，也不承担无法恢复的副作用。这样可以单独测试，并在不支持 Worker 的环境中保留降级实现。</p>
<h2 id="避免无意义的数据复制">避免无意义的数据复制</h2>
<p>前端代码为了“不可变”经常创建新数组和新对象。在小数据里成本可以忽略，十万行下，一次展开、映射和排序就可能制造多个完整副本。</p>
<p>我们区分领域状态和计算缓冲。对需要触发框架更新的边界保持不可变，对内部算法则允许受控地复用内存。排序前确认是否可以使用索引数组，而不是移动完整对象；格式化尽量发生在可见行，而不是预先处理全部数据。</p>
<p>缓存也不能无上限。查询结果按内存成本与访问频率淘汰，大结果的缓存时间更短。缓存命中提升速度，缓存失控则会把页面变成隐形数据仓库。</p>
<h2 id="图表不等于全部数据点">图表不等于全部数据点</h2>
<p>屏幕宽度只有一两千像素，绘制十万个时间点无法提供十万个可区分的信息。对趋势图，服务端或 Worker 可以按像素范围聚合；对异常数据，采样必须保留峰值和转折，不能简单每隔 N 个取一点。</p>
<p>一个保守策略是在每个像素桶保留首值、末值、最小值和最大值。它不如专门的 LTTB 算法紧凑，但能保留突发峰谷，也容易解释。用户放大时间范围后再查询更细粒度数据，避免一次把所有原始点送进浏览器。</p>
<p>SVG 适合需要独立节点和交互的中小规模图形，Canvas 更适合大量绘制，但交互命中、可访问性和文本布局需要额外处理。选择渲染技术要根据图形数量与交互需求，不是根据库的流行程度。</p>
<p>动画在大数据更新中通常价值很低。过渡会同时保留前后状态，增加计算与绘制成本。对监控和分析工具，稳定、快速地呈现新结果比华丽过渡更重要。</p>
<h2 id="更新应该与变化规模匹配">更新应该与变化规模匹配</h2>
<p>很多组件每次数据变化都会重新转换完整配置并重绘。即使只更新一个单元格，也触发全部行重新计算。优化需要让更新粒度接近实际变化。</p>
<p>表格可以用稳定行标识定位变化，图表可以根据系列与数据版本判断是否复用。React 或 Vue 的响应式机制只能减少框架层更新，无法替代底层图表库的增量策略。</p>
<p>但差量更新会增加状态复杂度。必须定义何时安全增量，何时完整重建，并用一致结果测试两条路径。追求每次都最小更新，可能得到无法证明正确的缓存系统。</p>
<h2 id="把性能变成运行时能力">把性能变成运行时能力</h2>
<p>最终我们将这些经验沉淀为组件契约和监控：数据超过阈值时自动切换策略；长任务被记录并关联组件；页面退出时验证 Worker、监听器与图表实例被释放；关键数据量拥有固定基准测试。</p>
<p>十万级渲染不是某个神奇算法解决的问题。它要求从产品需求开始限制规模，在网络、计算、布局和绘制之间分配工作，再对每层建立可观察的边界。</p>
<p>基准测试必须使用真实分布，不只使用随机数。我们的数据集包含长字符串、高基数字段、大量空值、倾斜分组和用户真实排序操作。每个方案记录解析、传输、计算、首次可见、滚动帧率和内存峰值。验收不是“十万条能打开”，而是规定设备与浏览器上，首屏在预算内出现，交互不产生超过阈值的长任务，取消旧计算后内存能回落——真实分布通常比行数更决定性能。</p>
<p>浏览器可以处理很多数据，但用户需要的从来不是“数据已经进入内存”。用户需要的是在合理时间内看见答案，并且界面始终能够响应下一步操作。</p>
<p>两条相邻的实践在这篇里没有展开：让更新粒度匹配变化规模，属于<a href="/articles/2024-05-diff-update/">差量更新的收益与代价</a>；把计算真正搬离主线程的传输与取消细节，属于<a href="/articles/2023-08-web-worker-transfer/">把十万行计算移进 Web Worker</a>。数据管线的每一段，都在这两篇里找到了它该有的边界。</p>]]></description></item><item><title>把十万行计算移进 Web Worker：真正难的是传输与取消</title><link>https://siegaii.com/articles/2023-08-web-worker-transfer/</link><guid>https://siegaii.com/articles/2023-08-web-worker-transfer/</guid><pubDate>Sat, 19 Aug 2023 00:00:00 GMT</pubDate><description><![CDATA[<p>低代码报表里有一个场景：接口返回十万行明细，前端需要按多个维度聚合，再生成透视表。计算函数在本机只要一百多毫秒，放到真实页面却会冻结滚动和输入。把它移进 Web Worker 后，第一次实现仍然卡，因为向 Worker 复制巨大对象本身就占用主线程和双倍内存。</p>
<p>Worker 解决的是执行线程，不自动解决数据传输、任务竞争和结果过期。</p>
<p><img src="/diagrams/series/2023-worker-transfer.svg" alt="页面、数据源、Worker 与渲染器之间传输、计算和丢弃过期结果的时序"></p>
<p><em>图 1：每次计算都有 taskId；用户切换条件后，旧任务即使完成也不能覆盖新结果。</em></p>
<h2 id="先把对象数组变成紧凑列数据">先把对象数组变成紧凑列数据</h2>
<p>十万条 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>{region, amount, timestamp}</span></span></code></span> 对象包含大量属性名和字符串重复。服务层把可枚举字段编码成字典，数值放进 TypedArray：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ColumnBatch</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  rowCount</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  regionCodes</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Uint16Array</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  amounts</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Float64Array</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  timestamps</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Float64Array</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  regions</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>发送时转移 ArrayBuffer 所有权，不做结构化克隆：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">worker.</span><span style="color:#D2A8FF">postMessage</span><span style="color:#E6EDF3">(</span></span>
<span data-line=""><span style="color:#E6EDF3">  { type: </span><span style="color:#A5D6FF">"aggregate"</span><span style="color:#E6EDF3">, taskId, batch },</span></span>
<span data-line=""><span style="color:#E6EDF3">  [</span></span>
<span data-line=""><span style="color:#E6EDF3">    batch.regionCodes.buffer,</span></span>
<span data-line=""><span style="color:#E6EDF3">    batch.amounts.buffer,</span></span>
<span data-line=""><span style="color:#E6EDF3">    batch.timestamps.buffer,</span></span>
<span data-line=""><span style="color:#E6EDF3">  ],</span></span>
<span data-line=""><span style="color:#E6EDF3">);</span></span></code></pre></figure>
<p>转移后主线程里的这些 buffer 会被 detach，不能继续读。这个所有权变化必须写进接口，不能让调用方以为数据仍可复用。</p>
<h2 id="任务协议要有版本和取消">任务协议要有版本和取消</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> WorkerRequest</span><span style="color:#FF7B72"> =</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 1</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "aggregate"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">taskId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">batch</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ColumnBatch</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">groupBy</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[] }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 1</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "cancel"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">taskId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> WorkerResponse</span><span style="color:#FF7B72"> =</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 1</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "progress"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">taskId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">completedRows</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 1</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "result"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">taskId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">result</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> AggregationResult</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 1</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "error"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">taskId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">code</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">message</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span></code></pre></figure>
<p>Worker 无法在一段不让出执行权的长循环中及时处理 cancel。聚合按批次运行，每处理几千行检查取消集合，并通过 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>setTimeout(0)</span></span></code></span> 或分段消息给事件循环机会。</p>
<h2 id="页面只接受当前任务结果">页面只接受当前任务结果</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">let</span><span style="color:#E6EDF3"> activeTaskId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> null</span><span style="color:#FF7B72"> =</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> runAggregation</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">input</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Input</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (activeTaskId) worker.</span><span style="color:#D2A8FF">postMessage</span><span style="color:#E6EDF3">({ type: </span><span style="color:#A5D6FF">"cancel"</span><span style="color:#E6EDF3">, taskId: activeTaskId });</span></span>
<span data-line=""><span style="color:#E6EDF3">  activeTaskId </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> crypto.</span><span style="color:#D2A8FF">randomUUID</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#E6EDF3">  worker.</span><span style="color:#D2A8FF">postMessage</span><span style="color:#E6EDF3">(</span><span style="color:#D2A8FF">buildRequest</span><span style="color:#E6EDF3">(activeTaskId, input), </span><span style="color:#D2A8FF">transferList</span><span style="color:#E6EDF3">(input));</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">worker.</span><span style="color:#D2A8FF">onmessage</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> ({ </span><span style="color:#FFA657">data</span><span style="color:#E6EDF3"> }</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> MessageEvent</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">WorkerResponse</span><span style="color:#E6EDF3">>) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (data.taskId </span><span style="color:#FF7B72">!==</span><span style="color:#E6EDF3"> activeTaskId) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (data.type </span><span style="color:#FF7B72">===</span><span style="color:#A5D6FF"> "result"</span><span style="color:#E6EDF3">) </span><span style="color:#D2A8FF">render</span><span style="color:#E6EDF3">(data.result);</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>取消是性能优化，taskId 检查才是正确性保证。旧 Worker 消息可能已经在队列中，不能假设 cancel 一定赶得上。</p>
<h2 id="内存峰值比平均耗时更容易被忽视">内存峰值比平均耗时更容易被忽视</h2>
<p>我们同时记录主线程和 Worker 的输入字节、结果字节和峰值。数据解析阶段如果先创建对象数组，再转换 TypedArray，会短时间保留两份数据。更好的方式是流式解析直接填充列缓冲，或让服务端输出更适合计算的二进制/列格式。</p>
<table>
<thead>
<tr>
<th>风险</th>
<th>观测</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>postMessage 克隆太慢</td>
<td>发送前后主线程长任务</td>
<td>使用 Transferable</td>
</tr>
<tr>
<td>Worker 内存峰值</td>
<td>每批字节与浏览器崩溃率</td>
<td>分块、限制最大行数</td>
</tr>
<tr>
<td>用户快速切换</td>
<td>取消数与过期结果数</td>
<td>taskId + 分段取消</td>
</tr>
<tr>
<td>结果仍然很大</td>
<td>返回字节与渲染耗时</td>
<td>只返回聚合结果或视窗数据</td>
</tr>
</tbody>
</table>
<h2 id="worker-不能替代服务端边界">Worker 不能替代服务端边界</h2>
<p>把计算移出主线程后页面不再卡，并不意味着让浏览器承担无限数据合理。超过一定规模，我们仍要求服务端聚合、分页或预计算。Worker 是让中等规模交互更流畅的工具，不是把数据仓库搬到用户电脑的理由。</p>
<p>最终的性能收益来自一整套边界：紧凑表示降低传输成本，Transferable 避免复制，taskId 防止旧结果覆盖，分块计算支持取消，数据规模上限控制内存。只把一个函数包进 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>new Worker()</span></span></code></span>，通常只能把卡顿从一个位置移到另一个位置。</p>
<p>上线后我在监控里保留四个数字：单次任务传输字节、Worker 内存峰值、取消及时率（cancel 发出后任务实际停止的时间）、以及过期结果丢弃数。前两个衡量收益，后两个衡量这个方案有没有引入新的竞态。没有这些观测，Worker 很容易从“优化”变成“更隐蔽的卡顿来源”。</p>
<p>这条边界和<a href="/articles/2023-10-large-data-rendering/">十万级数据渲染</a>是同一件事的两面：那里讲如何把数据管线拆开测量，这里讲如何把其中的计算与传输真正搬离主线程。</p>]]></description></item><item><title>正确使用 CocoaPods 国内源：先分清三段网络链路</title><link>https://siegaii.com/articles/2023-06-cocoapods-domestic-sources/</link><guid>https://siegaii.com/articles/2023-06-cocoapods-domestic-sources/</guid><pubDate>Tue, 20 Jun 2023 00:00:00 GMT</pubDate><description><![CDATA[<p>2023 年做 Flutter 项目时，我连续遇到两个看起来一样的问题：<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>gem install cocoapods</span></span></code></span> 很慢，安装完成后 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod install</span></span></code></span> 仍然很慢。很多教程把它们统称为“CocoaPods 换源”，然后贴一组命令，但这两个命令走的不是同一条网络链路。</p>
<p>原文发布于<a href="https://juejin.cn/post/7246638858913562681">掘金</a>。这次重新整理时，我保留了当时验证过的方案，也补上版本边界：CocoaPods、镜像站和网络环境都会变化，理解链路比记住某个镜像地址更可靠。</p>
<p><img src="/diagrams/cocoapods-resolution-flow.svg" alt="CocoaPods 从工具安装、Specs 元数据解析到依赖源码下载的三段网络链路"></p>
<p><em>图 1：RubyGems 源、Specs 源和 podspec 中的源码地址彼此独立，换错一段不会改善另一段。</em></p>
<h2 id="先判断到底慢在哪里">先判断到底慢在哪里</h2>
<p>CocoaPods 是 Ruby 生态中的依赖管理工具。一次从零开始的安装，至少经过三段网络访问：</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th>常见命令</th>
<th>实际访问的内容</th>
<th>对应配置</th>
</tr>
</thead>
<tbody>
<tr>
<td>安装工具</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>gem install cocoapods</span></span></code></span></td>
<td>CocoaPods 及 Ruby gems</td>
<td>RubyGems source</td>
</tr>
<tr>
<td>解析依赖</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod install</span></span></code></span></td>
<td>podspec 元数据与版本索引</td>
<td>Specs CDN 或 Git repo</td>
</tr>
<tr>
<td>下载产物</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod install</span></span></code></span></td>
<td>Git 仓库、压缩包或二进制</td>
<td>每个 podspec 的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>source</span></span></code></span></td>
</tr>
</tbody>
</table>
<p>例如 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod install</span></span></code></span> 已经完成版本解析，却卡在某个 GitHub 地址，此时更换 Specs 镜像没有用。Specs 只告诉 CocoaPods 去哪里下载依赖，最终产物仍可能来自另一个域名。</p>
<p>先用详细输出观察停点：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark-default"><code data-language="bash" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FFA657">gem</span><span style="color:#A5D6FF"> install</span><span style="color:#79C0FF"> -V</span><span style="color:#A5D6FF"> cocoapods</span></span>
<span data-line=""><span style="color:#FFA657">pod</span><span style="color:#A5D6FF"> install</span><span style="color:#79C0FF"> --verbose</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>-V</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>--verbose</span></span></code></span> 不会让网络变快，但能避免在没有进度时盲等，也能把失败定位到具体阶段。</p>
<h2 id="安装-cocoapods处理-rubygems-源">安装 CocoaPods：处理 RubyGems 源</h2>
<p>在当时的国内网络环境里，直接访问 RubyGems 官方源很不稳定。我使用清华 RubyGems 镜像安装工具：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark-default"><code data-language="bash" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#8B949E"># 增加镜像并移除当时不可达的官方源</span></span>
<span data-line=""><span style="color:#FFA657">gem</span><span style="color:#A5D6FF"> sources</span><span style="color:#79C0FF"> --add</span><span style="color:#A5D6FF"> https://mirrors.tuna.tsinghua.edu.cn/rubygems/</span><span style="color:#FF7B72"> \</span></span>
<span data-line=""><span style="color:#79C0FF">  --remove</span><span style="color:#A5D6FF"> https://rubygems.org/</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#8B949E"># 确认当前生效的源</span></span>
<span data-line=""><span style="color:#FFA657">gem</span><span style="color:#A5D6FF"> sources</span><span style="color:#79C0FF"> -l</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#8B949E"># 显示详细安装过程</span></span>
<span data-line=""><span style="color:#FFA657">sudo</span><span style="color:#A5D6FF"> gem</span><span style="color:#A5D6FF"> install</span><span style="color:#79C0FF"> -V</span><span style="color:#A5D6FF"> cocoapods</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>gem</span></span></code></span> 是 Ruby 的包管理器，作用类似 Node.js 的 npm 或 Python 的 pip。这里切换的只是 CocoaPods 工具及其 Ruby 依赖的下载源，不会改变后续 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod install</span></span></code></span> 使用的 Specs 或源码地址。</p>
<p>使用系统 Ruby 时，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>sudo gem install</span></span></code></span> 可能引入权限与版本冲突。团队环境更适合用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>rbenv</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>asdf</span></span></code></span> 或 Bundler 固定 Ruby 和 CocoaPods 版本：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ruby" data-theme="github-dark-default"><code data-language="ruby" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#8B949E"># Gemfile</span></span>
<span data-line=""><span style="color:#E6EDF3">source </span><span style="color:#A5D6FF">"https://rubygems.org"</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">gem </span><span style="color:#A5D6FF">"cocoapods"</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">"1.12.1"</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark-default"><code data-language="bash" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FFA657">bundle</span><span style="color:#A5D6FF"> install</span></span>
<span data-line=""><span style="color:#FFA657">bundle</span><span style="color:#A5D6FF"> exec</span><span style="color:#A5D6FF"> pod</span><span style="color:#A5D6FF"> install</span></span></code></pre></figure>
<p>上面的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>1.12.1</span></span></code></span> 是与 2023 年原文相近的示例，不是“永远应该安装”的版本。真正应该固定的是项目已经验证过的版本，并把 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Gemfile.lock</span></span></code></span> 提交到仓库。</p>
<h2 id="解析依赖理解-cdn-与-git-specs-仓库">解析依赖：理解 CDN 与 Git Specs 仓库</h2>
<p>早期 CocoaPods 会同步完整的 Specs Git 仓库。这个仓库历史很长，当时约 1.3 GB；<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod repo add</span></span></code></span> 没有直观克隆进度，看起来像卡死。</p>
<p>原文使用的是清华 Git 镜像：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark-default"><code data-language="bash" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#79C0FF">cd</span><span style="color:#A5D6FF"> ~/.cocoapods/repos</span></span>
<span data-line=""><span style="color:#FFA657">pod</span><span style="color:#A5D6FF"> repo</span><span style="color:#A5D6FF"> remove</span><span style="color:#A5D6FF"> master</span></span>
<span data-line=""><span style="color:#FFA657">git</span><span style="color:#A5D6FF"> clone</span><span style="color:#A5D6FF"> https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git</span><span style="color:#A5D6FF"> master</span></span></code></pre></figure>
<p>手动 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>git clone</span></span></code></span> 的主要价值不是更换了一套神秘机制，而是能看到对象接收和检出的进度。然后在项目 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Podfile</span></span></code></span> 顶部声明对应源：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ruby" data-theme="github-dark-default"><code data-language="ruby" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">source </span><span style="color:#A5D6FF">"https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git"</span></span></code></pre></figure>
<p>但这套方案有明确成本：第一次需要同步完整仓库，后续还要维护更新。CocoaPods 1.8 之后，官方默认转向按需请求的 CDN 模型，新项目通常不必克隆完整 Specs 历史：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ruby" data-theme="github-dark-default"><code data-language="ruby" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">source </span><span style="color:#A5D6FF">"https://cdn.cocoapods.org/"</span></span></code></pre></figure>
<p>所以不要不分版本地执行 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>rm -rf master</span></span></code></span>。先运行这些命令了解当前状态：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark-default"><code data-language="bash" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FFA657">pod</span><span style="color:#79C0FF"> --version</span></span>
<span data-line=""><span style="color:#FFA657">pod</span><span style="color:#A5D6FF"> repo</span><span style="color:#A5D6FF"> list</span></span>
<span data-line=""><span style="color:#FFA657">pod</span><span style="color:#A5D6FF"> install</span><span style="color:#79C0FF"> --verbose</span></span></code></pre></figure>
<p>如果项目使用 CDN 且元数据访问正常，增加完整 Git Specs 仓库反而会让首次安装更慢。只有当前网络无法稳定访问 CDN、团队已经统一维护镜像，或者私有依赖明确要求 Git Specs 时，才值得切换模型。</p>
<h2 id="下载依赖specs-可用不代表源码可用">下载依赖：Specs 可用不代表源码可用</h2>
<p>完成版本求解后，CocoaPods 会读取每个 podspec 的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>source</span></span></code></span>，它可能指向 GitHub、另一个 Git 服务或压缩包 CDN。这个阶段的典型日志是已经选定版本，然后卡在 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Downloading dependencies</span></span></code></span> 或某个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>git clone</span></span></code></span>。</p>
<p>排查时我会直接看目标 pod 的规格：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark-default"><code data-language="bash" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FFA657">pod</span><span style="color:#A5D6FF"> spec</span><span style="color:#A5D6FF"> which</span><span style="color:#A5D6FF"> SomePod</span></span>
<span data-line=""><span style="color:#FFA657">pod</span><span style="color:#A5D6FF"> install</span><span style="color:#79C0FF"> --verbose</span></span></code></pre></figure>
<p>确认以下信息：</p>
<ul>
<li>卡住的是 Specs 元数据，还是具体源码地址；</li>
<li><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Podfile.lock</span></span></code></span> 是否已经固定到某个不可达版本；</li>
<li>团队其他机器成功是因为网络可达，还是命中了本地缓存；</li>
<li>私有仓库凭证失败是否被误判成“下载慢”；</li>
<li>代理或镜像是否改变了下载内容的完整性。</li>
</ul>
<p>镜像只解决可达性，不应该改变依赖解析结果。切换后必须检查 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Podfile.lock</span></span></code></span> 的版本和校验变化，不能看到命令成功就直接提交。</p>
<h2 id="podfile-中多个-source-的边界">Podfile 中多个 source 的边界</h2>
<p>使用私有 Specs 仓库时，通常会声明多个源：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ruby" data-theme="github-dark-default"><code data-language="ruby" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">source </span><span style="color:#A5D6FF">"https://git.example.com/ios/specs.git"</span></span>
<span data-line=""><span style="color:#E6EDF3">source </span><span style="color:#A5D6FF">"https://cdn.cocoapods.org/"</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">platform </span><span style="color:#79C0FF">:ios</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">"13.0"</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">target </span><span style="color:#A5D6FF">"Runner"</span><span style="color:#FF7B72"> do</span></span>
<span data-line=""><span style="color:#E6EDF3">  pod </span><span style="color:#A5D6FF">"InternalAnalytics"</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">"~> 2.4"</span></span>
<span data-line=""><span style="color:#E6EDF3">  pod </span><span style="color:#A5D6FF">"Alamofire"</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">"~> 5.8"</span></span>
<span data-line=""><span style="color:#FF7B72">end</span></span></code></pre></figure>
<p>源的顺序和同名 Pod 会影响解析。私有仓库不应该复制公共 Pod 的同名规格；否则同一个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Podfile.lock</span></span></code></span> 在不同缓存状态下可能得到意外来源。对于关键私有依赖，团队需要明确仓库所有权、凭证分发和可用性，而不是让每个人单独修改本机配置。</p>
<h2 id="团队环境比个人换源更重要">团队环境比个人换源更重要</h2>
<p>个人电脑上临时换源能解决一次安装，不能保证 CI 和同事环境稳定。更可靠的工程做法包括：</p>
<ul>
<li>用 Bundler 固定 CocoaPods 版本；</li>
<li>提交 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Podfile.lock</span></span></code></span>，CI 使用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod install</span></span></code></span> 而不是随意 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod update</span></span></code></span>；</li>
<li>在 CI 中记录 Ruby、CocoaPods、Xcode 和源配置；</li>
<li>对私有 Specs 与二进制产物建立团队级缓存和可用性监控；</li>
<li>镜像故障时有明确回退源，而不是现场搜索一条新命令；</li>
<li>定期验证镜像同步延迟与依赖校验，避免可用但过期。</li>
</ul>
<p>可以把处理策略归纳成一个简单决策表：</p>
<table>
<thead>
<tr>
<th>日志停点</th>
<th>优先动作</th>
<th>不该先做什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>gem install</span></span></code></span> 下载 gem</td>
<td>检查 RubyGems source 与 Ruby 环境</td>
<td>删除 CocoaPods Specs 仓库</td>
</tr>
<tr>
<td>更新 Specs 元数据</td>
<td>判断 CDN/Git 模型与当前源可达性</td>
<td>反复清空全部缓存</td>
</tr>
<tr>
<td>求解版本冲突</td>
<td>检查 Podfile 与 lockfile 约束</td>
<td>把网络问题当成版本问题</td>
</tr>
<tr>
<td>下载某个 Git/zip</td>
<td>检查 podspec 的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>source</span></span></code></span> 地址</td>
<td>只更换 Specs 镜像</td>
</tr>
<tr>
<td>只有 CI 失败</td>
<td>对比凭证、代理、版本和缓存</td>
<td>在个人电脑上反复重装</td>
</tr>
</tbody>
</table>
<h2 id="这次问题真正教会我的事">这次问题真正教会我的事</h2>
<p>当时最有用的不是记住清华镜像的 URL，而是终于把 CocoaPods 的网络请求拆成了三段。只有知道命令正在安装 Ruby 工具、解析 Specs，还是下载真正的依赖，才知道该改哪个配置。</p>
<p>环境问题最容易被写成一串“复制即可”的命令，但镜像地址、默认源和工具行为都会变化。把版本、适用时间和回退方式一起写进项目文档，才是这类经验能够长期复用的前提。</p>
<p>真正解决后，我会保存一份完整诊断记录：工具版本、Ruby 来源、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>gem sources -l</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pod repo list</span></span></code></span>、失败域名、最终使用的镜像和 lockfile 是否变化。下次同事遇到“pod install 很慢”，先对照这份记录判断卡在哪一段，而不是重新复制一组未知命令。</p>
<p>镜像配置每季度验证一次可达性与同步延迟；失效时回退到团队统一方案。这里有一个容易忽略的判断：镜像和代理可以解决可达性，但永远不能改变依赖解析结果。切换源后必须检查 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Podfile.lock</span></span></code></span> 的版本与校验是否变化——如果变了，说明两个源的元数据不一致，这次“换源”其实是换了一组依赖，不能直接提交。环境文档如果没有验证日期和负责人，很快就会从帮助变成新的故障源。</p>]]></description></item><item><title>低代码 BI 引擎：配置能力的边界在哪里</title><link>https://siegaii.com/articles/2023-06-lowcode-bi-engine/</link><guid>https://siegaii.com/articles/2023-06-lowcode-bi-engine/</guid><pubDate>Sat, 17 Jun 2023 00:00:00 GMT</pubDate><description><![CDATA[<p>业务团队希望自己搭建报表，最直观的方案是提供一个拖拽画布：选择表格或图表，填写接口与字段，再调整颜色和尺寸。演示版本很快就能完成，真正进入多个业务场景后，配置会迅速膨胀。</p>
<p>表格需要维度、指标、排序和汇总；折线图需要时间粒度、系列与缺失值策略；漏斗有自己的阶段语义；不同数据源又拥有不同查询能力。如果每个组件维护一套私有配置，编辑器、运行时和保存结构会被紧密绑定。</p>
<p>低代码 BI 的核心不是拖拽，而是设计一门足够稳定、又不过度承诺的描述语言。</p>
<p><img src="/diagrams/bi-spec-compiler.svg" alt="低代码 BI 配置经过迁移校验、查询计划、视觉编译和渲染器适配的管线"></p>
<p><em>图 1：编辑器保存的是领域 Spec，不是某个图表库的 option；运行时像编译器一样逐层验证和转换。</em></p>
<h2 id="从业务问题建立中间模型">从业务问题建立中间模型</h2>
<p>我们把一张报表拆成四类信息：数据查询描述“从哪里得到什么”；数据转换描述聚合、计算与格式；视觉编码描述字段如何映射到位置、长度、颜色；交互描述筛选、联动与下钻。</p>
<p>这四层不会完全独立，但明确区分后，很多能力可以跨组件复用。日期筛选不必在每种图表中重复实现，数据格式化也不需要嵌入视觉配置。</p>
<p>中间模型不能直接复制某个图表库的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>option</span></span></code></span>。图表库会升级或替换，产品概念却应该稳定。运行时通过适配器把领域配置转换为 G2Plot、ECharts 或表格组件需要的属性。</p>
<p>适配器也暴露能力矩阵。例如某类图支持双轴，另一类不支持；某数据源支持服务端聚合，另一数据源只能返回明细。编辑器根据矩阵限制可选项，避免生成运行时无法兑现的配置。</p>
<p>一个折线图的领域配置可以只有这些内容：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ChartSpecV3</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 3</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  source</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">datasetId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  query</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">    dimensions</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;{ </span><span style="color:#FFA657">field</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">granularity</span><span style="color:#FF7B72">?:</span><span style="color:#A5D6FF"> "day"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "week"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "month"</span><span style="color:#E6EDF3"> }>;</span></span>
<span data-line=""><span style="color:#FFA657">    measures</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;{ </span><span style="color:#FFA657">field</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">aggregate</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "sum"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "avg"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "count"</span><span style="color:#E6EDF3"> }>;</span></span>
<span data-line=""><span style="color:#FFA657">    filters</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> FilterExpression</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#E6EDF3">  };</span></span>
<span data-line=""><span style="color:#FFA657">  encoding</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">    x</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">field</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "temporal"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "category"</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">    y</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">field</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">format</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">    color</span><span style="color:#FF7B72">?:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">field</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#E6EDF3">  };</span></span>
<span data-line=""><span style="color:#FFA657">  interaction</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">tooltip</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> boolean</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">zoom</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> boolean</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>这里没有 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>grid.left</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>series.smooth</span></span></code></span> 之类 ECharts 私有字段。表现细节由主题和适配器给默认值；确实形成产品能力的选项才进入 Schema。这个克制让保存数据不跟随图表库升级频繁迁移。</p>
<h2 id="schema-需要版本">Schema 需要版本</h2>
<p>配置一旦保存，就成为长期数据。新增字段容易，重命名、改变默认值或删除能力都会影响历史报表。</p>
<p>我们为 Schema 保留显式版本，并建立单向迁移。运行时加载旧配置时先转换到当前版本，迁移过程保持可测试。不能把兼容逻辑散落在每个组件里，否则几年后没有人知道某个判断在兼容哪个年代。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> migrations</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#79C0FF">  1</span><span style="color:#E6EDF3">: (</span><span style="color:#FFA657">spec</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartSpecV1</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartSpecV2</span><span style="color:#FF7B72"> =></span><span style="color:#E6EDF3"> ({</span></span>
<span data-line=""><span style="color:#FF7B72">    ...</span><span style="color:#E6EDF3">spec,</span></span>
<span data-line=""><span style="color:#E6EDF3">    version: </span><span style="color:#79C0FF">2</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">    interaction: { tooltip: spec.tooltip </span><span style="color:#FF7B72">??</span><span style="color:#79C0FF"> true</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">  }),</span></span>
<span data-line=""><span style="color:#79C0FF">  2</span><span style="color:#E6EDF3">: (</span><span style="color:#FFA657">spec</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartSpecV2</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartSpecV3</span><span style="color:#FF7B72"> =></span><span style="color:#E6EDF3"> ({</span></span>
<span data-line=""><span style="color:#FF7B72">    ...</span><span style="color:#E6EDF3">spec,</span></span>
<span data-line=""><span style="color:#E6EDF3">    version: </span><span style="color:#79C0FF">3</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">    source: { datasetId: spec.dataset },</span></span>
<span data-line=""><span style="color:#E6EDF3">  }),</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> migrate</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">input</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> AnyChartSpec</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartSpecV3</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  let</span><span style="color:#E6EDF3"> current </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> input;</span></span>
<span data-line=""><span style="color:#FF7B72">  while</span><span style="color:#E6EDF3"> (current.version </span><span style="color:#FF7B72">&#x3C;</span><span style="color:#79C0FF"> 3</span><span style="color:#E6EDF3">) current </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> migrations[current.version](current </span><span style="color:#FF7B72">as</span><span style="color:#79C0FF"> never</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> current </span><span style="color:#FF7B72">as</span><span style="color:#FFA657"> ChartSpecV3</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>迁移测试使用真实历史配置样本，并比较迁移前后的查询语义和最终编码，不只断言版本号变成 3。默认值变化尤其要有快照，否则旧报表会在没人编辑的情况下改变外观或计算。</p>
<p>默认值也必须写入语义。若一个字段未保存，当前版本与未来版本是否得到同样行为？为了压缩配置而省略默认值，可能让历史报表随着系统升级悄悄变化。</p>
<p>配置发布后还要可回滚。编辑态和运行态不能共享一份随时变化的数据，至少需要草稿、已发布版本与版本记录。用户在编辑器里的误操作不应立刻影响正在使用的看板。</p>
<h2 id="编辑器不是配置表单集合">编辑器不是配置表单集合</h2>
<p>当配置项很多时，把它们全部展示出来只会把代码复杂度转移给业务用户。编辑器要围绕分析任务组织：先选数据，再确定想比较或观察什么，系统推荐合适图表，最后开放必要的表现调整。</p>
<p>字段选择需要理解类型和角色。数值可以作为指标，类别可以作为维度，时间拥有粒度和时区。拖拽行为不仅改变位置，还应该立即验证组合是否有效，并给出可以行动的反馈。</p>
<p>实时预览很重要，但不能每次输入都请求完整数据。编辑器可以使用采样数据、缓存查询结果，并对昂贵操作做节流。预览还要明确与生产数据的差异，避免用户把样本当作最终结果。</p>
<p>撤销与重做不适合通过保存整个页面快照无限堆积。更可控的方式是把编辑操作表达为命令，记录可逆变化。这样既能支持历史，也更容易分析用户如何构建报表。</p>
<h2 id="运行时必须与编辑器解耦">运行时必须与编辑器解耦</h2>
<p>编辑器关注创建体验，运行时关注加载速度、稳定性和兼容。两者共享 Schema 与渲染内核，但不应共享全部依赖。最终看板不需要拖拽库、属性面板和编辑历史。</p>
<p>运行时读取已发布配置，验证版本和完整性，按依赖加载数据，再渲染组件。单个图表失败不能拖垮整个看板，错误区域应保留位置并提供原因；公共筛选失败则需要明确影响范围。</p>
<p>运行链路被明确拆成编译与执行：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>已发布 Spec</span></span>
<span data-line=""><span>  → 版本迁移</span></span>
<span data-line=""><span>  → Schema 与能力校验</span></span>
<span data-line=""><span>  → 编译查询计划</span></span>
<span data-line=""><span>  → 权限检查与数据请求</span></span>
<span data-line=""><span>  → 数据转换</span></span>
<span data-line=""><span>  → 编译视觉配置</span></span>
<span data-line=""><span>  → 渲染器适配器</span></span></code></pre></figure>
<p>每一步返回结构化错误。<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>field_not_found</span></span></code></span> 属于配置问题，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>dataset_forbidden</span></span></code></span> 属于权限问题，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>query_timeout</span></span></code></span> 属于运行问题。编辑器可以定位到具体字段，运行时则给看板使用者可行动反馈，二者不需要解析一段统一异常字符串。</p>
<p>每个组件都应该有生命周期：初始化、等待数据、渲染、更新、销毁。图表库常持有 Canvas、事件监听和大块数据，组件切换或页面退出时若不释放，会在长时间使用的后台中形成明显内存问题。</p>
<p>动态渲染也要限制能力。允许配置任意 JavaScript 表达式看似灵活，却带来安全、调试和版本兼容风险。我们更倾向提供受限公式与内置转换函数，让表达能力可以被静态分析和控制。</p>
<h2 id="数据规模决定架构">数据规模决定架构</h2>
<p>低代码让创建图表更容易，也会让一个页面出现更多查询和更大数据。运行时必须把数据规模当作一等约束。</p>
<p>聚合尽量下推到服务端，浏览器只接收展示需要的数据。表格使用分页或虚拟化，图表对点数设置上限并提供采样策略。超过范围时，系统应解释为什么不能直接渲染，而不是让页面卡死。</p>
<p>多图表联动会产生请求风暴。筛选变化先形成统一查询上下文，再由页面级调度器合并、排序和取消任务。相同查询共享结果，旧上下文的响应不能覆盖新状态。这套调度不是顺手实现的细节，值得单独展开——我在<a href="/articles/2023-02-request-queue/">多图表页面为什么需要请求调度</a>里记录了并发限制、优先级老化与取消语义。</p>
<p>缓存键必须包含数据源、查询条件、权限与版本。BI 数据往往具有权限边界，错误缓存比没有缓存更危险。</p>
<h2 id="可扩展不等于无限配置">可扩展不等于无限配置</h2>
<p>低代码产品很容易陷入需求循环：每个客户都有例外，于是新增一个开关；开关组合产生新问题，再新增更多开关。最终没有人能解释全部组合。</p>
<p>我们会判断需求属于通用分析能力、行业模板还是单一场景。通用能力进入 Schema，稳定组合沉淀为模板，单一场景则考虑自定义组件或明确不支持。边界本身就是产品能力。</p>
<p>插件机制也需要契约。一个新图表组件必须声明支持的数据角色、配置 Schema、运行时依赖、序列化方式与版本兼容。只注册一个 React 或 Vue 组件远远不够。</p>
<h2 id="测试的是语义不只是组件">测试的是语义，不只是组件</h2>
<p>低代码系统的组合数量巨大，不可能穷举所有配置。测试重点应放在模型不变量和关键组合。</p>
<p>Schema 验证确保非法配置无法进入运行时；迁移测试保证历史样本升级后语义不变；适配器测试验证领域配置生成正确图表选项；关键模板做视觉回归；真实大数据样本用于性能基线。</p>
<p>每次线上错误都应沉淀为最小配置样本。配置数据比手工操作更适合复现，也能成为长期回归集。</p>
<h2 id="最终交付的是受控表达力">最终交付的是受控表达力</h2>
<p>低代码 BI 的价值，是让业务用户更接近问题与数据，而不是让所有人变成前端开发者。平台需要提供足够表达力，也要阻止无效、危险或无法维护的组合。</p>
<p>拖拽界面只是可见部分。真正决定系统寿命的，是中间模型、版本迁移、运行时隔离、数据边界和扩展契约。</p>
<p>一个成熟的配置系统不会承诺“什么都能做”。它会清楚说明什么能稳定完成，什么需要定制，以及新增能力将如何进入已有秩序。</p>
<p>这种克制最后落到一个很具体的要求：每份配置都能导出一张诊断单。诊断单包含 Schema 版本、迁移路径、数据集版本、查询计划、预计点数、渲染器能力命中和被降级选项。用户反馈“图不对”时，支持人员不必先索要整份敏感数据：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>specVersion: 7 -> 9</span></span>
<span data-line=""><span>queryPlan: server aggregation / 5m</span></span>
<span data-line=""><span>estimatedPoints: 8,640</span></span>
<span data-line=""><span>renderer: echarts-adapter@4</span></span>
<span data-line=""><span>warnings: connectNulls disabled because missingRate=18%</span></span></code></pre></figure>
<p>可解释的中间产物，是低代码平台长期可维护的部分。Schema 迁移的完整管线（单向迁移、语义校验、黄金样例、写回隔离）我在<a href="/articles/2023-04-schema-migration-pipeline/">低代码配置升级</a>里单独记录过，这里只保留了与引擎设计相关的边界。</p>]]></description></item><item><title>低代码配置升级：Schema 迁移不能散落在组件里</title><link>https://siegaii.com/articles/2023-04-schema-migration-pipeline/</link><guid>https://siegaii.com/articles/2023-04-schema-migration-pipeline/</guid><pubDate>Sat, 15 Apr 2023 00:00:00 GMT</pubDate><description><![CDATA[<p>低代码 BI 做到第三个版本时，历史报表开始出现一些难以解释的判断：<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>if (!config.tooltip)</span></span></code></span> 可能是在兼容旧版默认值，也可能是用户真的关闭了 tooltip；<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>dataset</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>source.datasetId</span></span></code></span> 同时存在，运行时按组件不同选择字段。每个组件都能“勉强打开”，却没人说得清一份 2021 年配置经过了哪些兼容。</p>
<p>我们最终把兼容从组件渲染里移出，所有配置先经过单向迁移到当前 Schema，再校验和编译。运行时只理解一个版本。</p>
<p><img src="/diagrams/series/2023-schema-migration.svg" alt="历史低代码配置按版本逐步迁移、校验并编译成运行时计划"></p>
<p><em>图 1：迁移是数据管线，不是散落在 UI 中的一组 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>?? defaultValue</span></span></code></span>。</em></p>
<h2 id="每一步只知道相邻版本">每一步只知道相邻版本</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> Migrator</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">From</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">To</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> (</span><span style="color:#FFA657">input</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> From</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#FFA657"> To</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> migrations</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Record</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">number</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">Migrator</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">any</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">any</span><span style="color:#E6EDF3">>> </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#79C0FF">  1</span><span style="color:#E6EDF3">: (</span><span style="color:#FFA657">v1</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartV1</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartV2</span><span style="color:#FF7B72"> =></span><span style="color:#E6EDF3"> ({</span></span>
<span data-line=""><span style="color:#FF7B72">    ...</span><span style="color:#E6EDF3">v1,</span></span>
<span data-line=""><span style="color:#E6EDF3">    version: </span><span style="color:#79C0FF">2</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">    interaction: { tooltip: v1.tooltip </span><span style="color:#FF7B72">!==</span><span style="color:#79C0FF"> false</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">  }),</span></span>
<span data-line=""><span style="color:#79C0FF">  2</span><span style="color:#E6EDF3">: (</span><span style="color:#FFA657">v2</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartV2</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartV3</span><span style="color:#FF7B72"> =></span><span style="color:#E6EDF3"> ({</span></span>
<span data-line=""><span style="color:#FF7B72">    ...</span><span style="color:#D2A8FF">omit</span><span style="color:#E6EDF3">(v2, </span><span style="color:#A5D6FF">"dataset"</span><span style="color:#E6EDF3">),</span></span>
<span data-line=""><span style="color:#E6EDF3">    version: </span><span style="color:#79C0FF">3</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">    source: { datasetId: v2.dataset },</span></span>
<span data-line=""><span style="color:#E6EDF3">  }),</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> migrateToCurrent</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">input</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> UnknownSpec</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartV3</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  let</span><span style="color:#E6EDF3"> current </span><span style="color:#FF7B72">=</span><span style="color:#D2A8FF"> structuredClone</span><span style="color:#E6EDF3">(input);</span></span>
<span data-line=""><span style="color:#FF7B72">  while</span><span style="color:#E6EDF3"> (current.version </span><span style="color:#FF7B72">&#x3C;</span><span style="color:#79C0FF"> 3</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">    const</span><span style="color:#79C0FF"> migrate</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> migrations[current.version];</span></span>
<span data-line=""><span style="color:#FF7B72">    if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">migrate) </span><span style="color:#FF7B72">throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> MigrationError</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"MIGRATION_PATH_MISSING"</span><span style="color:#E6EDF3">, current.version);</span></span>
<span data-line=""><span style="color:#E6EDF3">    current </span><span style="color:#FF7B72">=</span><span style="color:#D2A8FF"> migrate</span><span style="color:#E6EDF3">(current);</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#D2A8FF"> validateV3</span><span style="color:#E6EDF3">(current);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>V1 直接迁到 V3 看似少一步，版本多后会产生大量组合。相邻迁移让每次发布只维护一个新边，历史配置按同一路径回放。</p>
<h2 id="结构正确不等于语义正确">结构正确不等于语义正确</h2>
<p>JSON Schema 能验证字段类型，不能判断“饼图只能有一个 measure”或“时间粒度只适用于时间字段”。迁移后还有领域校验：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> validateSemantics</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">spec</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ChartV3</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">dataset</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> DatasetSchema</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Issue</span><span style="color:#E6EDF3">[] {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> issues</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Issue</span><span style="color:#E6EDF3">[] </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> [];</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (spec.kind </span><span style="color:#FF7B72">===</span><span style="color:#A5D6FF"> "pie"</span><span style="color:#FF7B72"> &#x26;&#x26;</span><span style="color:#E6EDF3"> spec.query.measures.</span><span style="color:#79C0FF">length</span><span style="color:#FF7B72"> !==</span><span style="color:#79C0FF"> 1</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#E6EDF3">    issues.</span><span style="color:#D2A8FF">push</span><span style="color:#E6EDF3">({ path: </span><span style="color:#A5D6FF">"query.measures"</span><span style="color:#E6EDF3">, code: </span><span style="color:#A5D6FF">"PIE_REQUIRES_ONE_MEASURE"</span><span style="color:#E6EDF3"> });</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">dataset.fields.</span><span style="color:#D2A8FF">has</span><span style="color:#E6EDF3">(spec.encoding.x.field)) {</span></span>
<span data-line=""><span style="color:#E6EDF3">    issues.</span><span style="color:#D2A8FF">push</span><span style="color:#E6EDF3">({ path: </span><span style="color:#A5D6FF">"encoding.x.field"</span><span style="color:#E6EDF3">, code: </span><span style="color:#A5D6FF">"FIELD_NOT_FOUND"</span><span style="color:#E6EDF3"> });</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> issues;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>错误带路径、代码和上下文，编辑器可以定位字段；不能只抛一句 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>invalid config</span></span></code></span>。</p>
<h2 id="迁移必须保持用户意图">迁移必须保持用户意图</h2>
<p>改变默认值最容易悄悄改图。例如 V1 缺少 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>connectNulls</span></span></code></span> 时，旧运行时默认 true，新运行时默认 false。迁移不能简单留空，而要显式写入旧语义。</p>
<table>
<thead>
<tr>
<th>变更</th>
<th>迁移策略</th>
</tr>
</thead>
<tbody>
<tr>
<td>字段重命名</td>
<td>复制值到新字段并删除旧字段</td>
</tr>
<tr>
<td>默认值改变</td>
<td>为旧配置显式写入旧默认</td>
</tr>
<tr>
<td>一个字段拆成多个</td>
<td>根据旧枚举做确定性映射</td>
</tr>
<tr>
<td>能力被移除</td>
<td>标记 unsupported，不能静默丢弃</td>
</tr>
<tr>
<td>外部资源不存在</td>
<td>进入待修复队列，不猜替代资源</td>
</tr>
</tbody>
</table>
<h2 id="用黄金样例验证视觉结果">用黄金样例验证视觉结果</h2>
<p>单元测试验证 JSON 输出还不够。我们保存一组代表性历史配置和固定数据，迁移前用旧运行时截图，迁移后用新运行时截图，对关键视觉和查询计划做差异检查。</p>
<p>黄金样例包括空数据、长标签、双轴、联动筛选和被删除字段。每次新增迁移都跑完整链路：V1 → 当前、V2 → 当前、当前重复迁移。重复执行当前配置应该不再改变内容。</p>
<h2 id="读时迁移与写回分开">读时迁移与写回分开</h2>
<p>页面打开时先在内存迁移，确保可读；是否持久化新版本由后台任务控制。直接在用户打开页面时覆盖旧配置，一旦新运行时有 bug 就失去原始证据。</p>
<p>批量写回按租户和报表分批，记录原版本、目标版本、输入 hash、输出 hash、迁移器版本与结果。失败配置隔离，不阻塞全部批次，也不反复自动重试未知错误。</p>
<p>迁移管线上线后，我盯三个数字：迁移失败率、写回前后 hash 不一致率、以及用户打开历史报表时的报错数。前两个归数据团队管，第三个直接反映用户感知。一次默认值改动曾经让黄金样例里的两张旧图悄悄改变样式——如果只统计“迁移成功”而不断言视觉结果，这类静默变化会被平均分掩盖。所以验收只看两个终态：迁移结果与迁移前语义等价，且每一步都有可回放的证据。</p>
<p>这次治理之后，组件代码里大量“历史原因”判断被删除。更重要的是，我们终于能回答一份配置从哪个版本来、怎样变成今天的结构、哪一步可能改变语义。低代码配置一旦被用户保存，就和数据库数据一样需要严肃的演进纪律。这与<a href="/articles/2023-06-lowcode-bi-engine/">低代码 BI 引擎</a>里 Schema 版本化的设计一脉相承；当债务积累到需要偿还时，我在<a href="/articles/2026-06-architecture-debt/">架构债务不是旧代码</a>里记录了双算与逐步切换的完整做法。</p>]]></description></item><item><title>多图表页面为什么需要请求调度</title><link>https://siegaii.com/articles/2023-02-request-queue/</link><guid>https://siegaii.com/articles/2023-02-request-queue/</guid><pubDate>Sat, 25 Feb 2023 00:00:00 GMT</pubDate><description><![CDATA[<p>一个看板包含十几张图表时，每个组件在挂载后独立请求数据，是最自然的写法。图表继续增加后，浏览器会同时发出大量请求，接口瞬时压力上升，关键图表反而要等待非关键请求完成。以一张 28 张图的看板为例，首屏会同时发出几十个请求：数据库和网关同时报警，主图却因为排在队尾，用户盯着白屏十几秒。</p>
<p>从组件视角看，每个请求都合理；从页面视角看，它们在争夺同一组资源。</p>
<h2 id="页面拥有调度权">页面拥有调度权</h2>
<p>我们在数据层建立请求队列，限制同一数据源的并发数，并根据可见性和业务优先级排序。首屏核心指标先执行，视口之外的图表延后，用户切换筛选条件后，旧队列中尚未开始的任务直接取消。</p>
<p>正在执行的请求也要识别版本。旧条件的结果晚于新条件返回时，不能覆盖当前页面。通过查询签名与取消信号，可以避免这类竞态。</p>
<p>调度器不需要一开始就做成复杂框架。我用过的最小模型只有四个字段：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> QueryTask</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  key</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  priority</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  createdAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  signal</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> AbortSignal</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#D2A8FF">  run</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> () </span><span style="color:#FF7B72">=></span><span style="color:#FFA657"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> MAX_CONCURRENCY</span><span style="color:#FF7B72"> =</span><span style="color:#79C0FF"> 4</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> running</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Map</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">unknown</span><span style="color:#E6EDF3">>>();</span></span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> waiting</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> QueryTask</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">unknown</span><span style="color:#E6EDF3">>[] </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> [];</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>key</span></span></code></span> 由数据源、时间范围、维度和筛选条件规范化生成。完全相同的任务复用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>running</span></span></code></span> 中的 Promise；还没开始且已经过期的任务直接从 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>waiting</span></span></code></span> 删除。正在执行的任务通过 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>AbortController</span></span></code></span> 尽力取消，即使底层请求无法中止，结果提交前仍要比较当前查询版本。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> effectivePriority</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">task</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> QueryTask</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">unknown</span><span style="color:#E6EDF3">>, </span><span style="color:#FFA657">now</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Date.</span><span style="color:#D2A8FF">now</span><span style="color:#E6EDF3">()) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> waitingSeconds</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> (now </span><span style="color:#FF7B72">-</span><span style="color:#E6EDF3"> task.createdAt) </span><span style="color:#FF7B72">/</span><span style="color:#79C0FF"> 1000</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> task.priority </span><span style="color:#FF7B72">+</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">min</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">20</span><span style="color:#E6EDF3">, waitingSeconds </span><span style="color:#FF7B72">*</span><span style="color:#79C0FF"> 0.5</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>等待时间会逐步提高有效优先级，避免低优先级图表永久饥饿。用户刚操作的首屏查询仍然先执行，但一个已等待 40 秒的后台任务最终能获得位置。</p>
<h2 id="合并不等于批量">合并不等于批量</h2>
<p>参数完全相同的请求可以共享结果，时间范围和维度相近的请求有时可以在服务端批量处理。但盲目合并会生成超大响应，让一个失败影响所有图表。</p>
<p>调度策略必须可观测：排队时间、执行时间、取消原因和缓存命中都应被记录。否则优化只是在移动等待。除了指标，我还要求每次取消都带上原因字段（新任务替代、用户离开页面、过期），页面慢时能区分是服务端慢，还是请求在浏览器调度层等了很久——调度器不能从优化层变成新的黑盒。</p>
<table>
<thead>
<tr>
<th>指标</th>
<th>说明</th>
<th>异常意味着什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>queue_wait_ms</span></span></code></span></td>
<td>任务等待时间</td>
<td>并发过低或请求数量失控</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>request_run_ms</span></span></code></span></td>
<td>实际请求时间</td>
<td>后端或网络瓶颈</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>cancelled_before_run</span></span></code></span></td>
<td>开始前取消数</td>
<td>筛选交互频繁，延迟加载有效</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>cancelled_in_flight</span></span></code></span></td>
<td>执行中取消数</td>
<td>请求启动过早或响应太慢</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>dedupe_hit</span></span></code></span></td>
<td>共享结果次数</td>
<td>合并策略带来的真实收益</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>queue_depth</span></span></code></span></td>
<td>等待队列长度</td>
<td>页面复杂度超出调度能力</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>stale_return</span></span></code></span></td>
<td>已取消但仍返回的响应</td>
<td>版本检查遗漏，最容易造成竞态</td>
</tr>
</tbody>
</table>
<p>如果只看接口耗时，会错过任务在浏览器队列里等了两秒的事实。用户感知时间是排队、网络、转换和渲染的总和。</p>
<p>拿一张 28 图的看板做回归，在去重与并发限制生效后，首屏发出的请求数能降到原来的三分之一以下，P95 完成时间通常能从十几秒收敛到几秒内——其中约一半来自不再重复发起相同查询，另一半来自主图不再等辅助图表排队。这个结果印证了一件事：先把"请求在等待"这个看不见的事实变成可量化指标，再谈算法。</p>
<h2 id="优先级不能永远饿死后台任务">优先级不能永远饿死后台任务</h2>
<p>如果核心图表持续产生新请求，低优先级任务可能一直无法执行。调度器需要老化策略：等待越久，优先级逐步提高；同一组件也要限制占用，避免单个异常图表填满队列。</p>
<p>用户主动操作通常高于自动刷新，但自动刷新不能悄悄覆盖用户正在查看的结果。页面可见性、交互意图和数据时效共同决定优先级，简单的先进先出或固定等级都不足以表达真实需求。</p>
<p>组件自治适合局部开发，页面级性能需要全局协调。架构的作用之一，就是让局部正确不会累积成整体失控。</p>
<p><img src="/diagrams/series/legacy-request-scheduler.svg" alt="页面请求经过规范化、合并、排队、并发和过期丢弃的调度流程"></p>
<p><em>图：调度层统一分配网络注意力，组件不再各自争抢。</em></p>
<p>调度器让页面级资源分配成为可能，但要真正省下时间和内存，还需要数据本身更紧凑、计算更早离开主线程。同一个看板场景里的后续优化，我在<a href="/articles/2023-10-large-data-rendering/">十万级数据渲染</a>和<a href="/articles/2023-08-web-worker-transfer/">把计算移进 Web Worker</a>里分别展开：前者拆开数据管线的每个阶段，后者处理任务取消与结果过期。请求调度是这三条线里的第一层。</p>]]></description></item><item><title>Node.js 内存泄漏：一次从监控到句柄引用链的排查</title><link>https://siegaii.com/articles/2023-01-nodejs-memory-leak-investigation/</link><guid>https://siegaii.com/articles/2023-01-nodejs-memory-leak-investigation/</guid><pubDate>Wed, 18 Jan 2023 00:00:00 GMT</pubDate><description><![CDATA[<p>2022 年第四季度的一天，研发用户群里陆续有人反馈平台无法访问，后台堆着大量没有完成的异步任务。服务没有立即崩溃，但响应越来越慢。打开 Grafana 后，最显眼的不是某一次尖峰，而是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>heapUsed</span></span></code></span> 从 10 点左右开始持续抬升，经历多轮 GC 也没有回到原来的基线。</p>
<p>这类问题最难的地方不是知道“内存泄漏”这个名词，而是从无数接口、定时任务和中间件里，找到究竟是哪类对象被谁持有。本文记录的是我当时真实的排查路径。原文最早发布于<a href="https://zhuanlan.zhihu.com/p/599859600">知乎</a>，这里重新整理了证据链、代码边界和生产操作风险。</p>
<p><img src="/diagrams/node-memory-leak-investigation.svg" alt="Node.js 内存泄漏从监控信号、分类、堆快照到修复验证的完整证据链"></p>
<p><em>图 1：监控负责发现“什么时候开始异常”，堆快照负责回答“哪些对象为什么还活着”。</em></p>
<h2 id="先证明它是泄漏而不是内存高">先证明它是泄漏，而不是内存高</h2>
<p>Node.js 进程占用 500 MB 内存并不自动等于泄漏。V8 会为了减少频繁申请内存而保留已经扩张的堆，RSS 也包含堆外缓冲区、原生模块和线程栈。真正需要警惕的是：在相同负载下，执行完整 GC 后的存活对象基线仍然一轮轮上升。</p>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>process.memoryUsage()</span></span></code></span> 暴露了几个容易混淆的指标：</p>
<table>
<thead>
<tr>
<th>指标</th>
<th>含义</th>
<th>排查时怎么用</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>rss</span></span></code></span></td>
<td>进程常驻物理内存</td>
<td>判断容器是否接近限制，但不能单独证明 V8 堆泄漏</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>heapTotal</span></span></code></span></td>
<td>V8 当前申请的堆容量</td>
<td>会随运行扩张，不等于实际存活对象</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>heapUsed</span></span></code></span></td>
<td>V8 堆中已使用的内存</td>
<td>观察 GC 后基线是否持续上升</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>external</span></span></code></span></td>
<td>V8 管理但位于堆外的内存</td>
<td>Buffer、C++ 对象异常时重点关注</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>arrayBuffers</span></span></code></span></td>
<td>ArrayBuffer 与 SharedArrayBuffer</td>
<td>大量二进制处理场景中用于拆分 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>external</span></span></code></span></td>
</tr>
</tbody>
</table>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> sampleMemory</span><span style="color:#E6EDF3">() {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> bytes</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> process.</span><span style="color:#D2A8FF">memoryUsage</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> Object.</span><span style="color:#D2A8FF">fromEntries</span><span style="color:#E6EDF3">(</span></span>
<span data-line=""><span style="color:#E6EDF3">    Object.</span><span style="color:#D2A8FF">entries</span><span style="color:#E6EDF3">(bytes).</span><span style="color:#D2A8FF">map</span><span style="color:#E6EDF3">(([</span><span style="color:#FFA657">name</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">value</span><span style="color:#E6EDF3">]) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> [name, Math.</span><span style="color:#D2A8FF">round</span><span style="color:#E6EDF3">(value </span><span style="color:#FF7B72">/</span><span style="color:#79C0FF"> 1024</span><span style="color:#FF7B72"> /</span><span style="color:#79C0FF"> 1024</span><span style="color:#E6EDF3">)]),</span></span>
<span data-line=""><span style="color:#E6EDF3">  );</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#D2A8FF">setInterval</span><span style="color:#E6EDF3">(() </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">  console.</span><span style="color:#D2A8FF">info</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"memory_mb"</span><span style="color:#E6EDF3">, </span><span style="color:#D2A8FF">sampleMemory</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#E6EDF3">}, </span><span style="color:#79C0FF">30_000</span><span style="color:#E6EDF3">).</span><span style="color:#D2A8FF">unref</span><span style="color:#E6EDF3">();</span></span></code></pre></figure>
<p>原来的监控图里，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>heapUsed</span></span></code></span> 与进程内存都在上涨，活动句柄数也从一百多个持续涨到四百多个。句柄这条线后来成为关键旁证。</p>
<p><img src="/articles/nodejs-memory-leak/01-memory-dashboard.jpg" alt="Grafana 中持续上涨的进程内存、V8 堆和活动句柄"></p>
<p><em>图 2：内存上涨并非孤立信号；活动句柄同步增长，把范围指向了网络连接或事件资源。</em></p>
<h2 id="先分全局泄漏与局部泄漏">先分全局泄漏与局部泄漏</h2>
<p>我会先问一个简单问题：随便压一个最小接口，内存是否也会稳定上涨？</p>
<p>如果任何请求都能触发，泄漏大概率位于全局中间件、日志、请求上下文或公共组件。此时二分法很有效：关闭一半公共逻辑，用同一流量模型重测；根据结果继续对剩余范围二分。2020 年排查 Nuxt SSR 服务时，我就用这种办法在半小时左右把问题定位到 HTTP 客户端相关代码。</p>
<p>如果只有特定接口、异步任务或业务操作才触发，它就是局部泄漏。局部泄漏不能靠随意注释公共代码解决，需要把故障时间、发布记录、请求路径和任务类型对齐。</p>
<table>
<thead>
<tr>
<th>现象</th>
<th>优先怀疑</th>
<th>第一种验证方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>任意接口压测都上涨</td>
<td>中间件、全局缓存、公共 SDK</td>
<td>最小接口 + 二分关闭公共逻辑</td>
</tr>
<tr>
<td>某个接口调用后上涨</td>
<td>请求闭包、事件监听、未释放连接</td>
<td>固定调用次数并比较前后快照</td>
</tr>
<tr>
<td>某类任务执行后上涨</td>
<td>队列实例、定时器、子进程</td>
<td>按任务类型拆分活动句柄与堆对象</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>heapUsed</span></span></code></span> 稳定但 RSS 上涨</td>
<td>Buffer、原生模块、碎片</td>
<td>同时观察 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>external</span></span></code></span>、系统分配与原生依赖</td>
</tr>
</tbody>
</table>
<p>这次问题无法被最小接口复现，只在某批后台任务运行后出现，因此我按局部泄漏处理。</p>
<h2 id="用时间线缩小功能范围">用时间线缩小功能范围</h2>
<p>最理想的状态，是能确定泄漏从哪个发布版本开始。假设一个迭代只上线了 A、B、C 三个功能，就应该先在这三个变更里复现，不要一上来扫描整个仓库。</p>
<p>这次发现得比较晚，只能把初次出现时间缩到大约一个月，期间又跨过一个大版本。仅靠 Git 提交无法快速定位，所以我把以下信号放到同一条时间线上：</p>
<ul>
<li>QPS、状态码和请求路径；</li>
<li>平均响应时间与事件循环延迟；</li>
<li>进程重启次数、CPU 和 Node.js 版本；</li>
<li><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>rss</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>heapUsed</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>external</span></span></code></span> 与可用堆；</li>
<li>活动请求数和活动句柄数；</li>
<li>同期发布的异步任务与依赖变更。</li>
</ul>
<p>这里只有堆快照是不够的。快照告诉你某一刻有什么对象，却不告诉你那个对象对应哪次业务动作。监控时间线把晦涩的对象变化重新接回了真实操作。</p>
<h2 id="采集至少三份可比较的堆快照">采集至少三份可比较的堆快照</h2>
<p>一份 Heap Snapshot 只能描述一个时刻。为了判断哪些对象“只增不减”，至少需要基线、触发后和 GC 后三份快照。当时为了看清趋势，我实际采了五份。</p>
<p>一个可重复的采集序列是：</p>
<ol>
<li>服务启动并预热后，手动 GC，采基线快照；</li>
<li>按固定次数执行可疑任务；</li>
<li>内存上涨一个明确区间后，再次 GC 并采快照；</li>
<li>重复任务与采集，形成对象数量的时间序列；</li>
<li>修复后用完全相同的负载和采集点回归。</li>
</ol>
<p>如果使用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>heapdump</span></span></code></span> 包，可以通过受控的运维信号触发，而不是暴露一个普通 HTTP 接口：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">import</span><span style="color:#E6EDF3"> heapdump </span><span style="color:#FF7B72">from</span><span style="color:#A5D6FF"> "heapdump"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">process.</span><span style="color:#D2A8FF">on</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"SIGUSR2"</span><span style="color:#E6EDF3">, () </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> file</span><span style="color:#FF7B72"> =</span><span style="color:#A5D6FF"> `/tmp/service-${</span><span style="color:#E6EDF3">process</span><span style="color:#A5D6FF">.</span><span style="color:#E6EDF3">pid</span><span style="color:#A5D6FF">}-${</span><span style="color:#E6EDF3">Date</span><span style="color:#A5D6FF">.</span><span style="color:#D2A8FF">now</span><span style="color:#A5D6FF">()</span><span style="color:#A5D6FF">}.heapsnapshot`</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">  heapdump.</span><span style="color:#D2A8FF">writeSnapshot</span><span style="color:#E6EDF3">(file, (</span><span style="color:#FFA657">error</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">filename</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    if</span><span style="color:#E6EDF3"> (error) console.</span><span style="color:#D2A8FF">error</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"heap_snapshot_failed"</span><span style="color:#E6EDF3">, error);</span></span>
<span data-line=""><span style="color:#FF7B72">    else</span><span style="color:#E6EDF3"> console.</span><span style="color:#D2A8FF">info</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"heap_snapshot_written"</span><span style="color:#E6EDF3">, { filename });</span></span>
<span data-line=""><span style="color:#E6EDF3">  });</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span></code></pre></figure>
<p>生产环境采快照有两个不能省略的前提：权限隔离，以及流量切走。采集时主线程会停顿，快照越大，停顿越长；当时我们让主、备两个 Pod 配合，先把业务流量迁移到备用实例，再在目标 Pod 上采集。快照文件本身也可能包含业务数据，下载、存储和删除都应走受控流程。</p>
<p>不要等到容器已经贴近 OOM 临界点才开始采集。写快照本身需要额外内存和时间，太晚可能一份也拿不到。</p>
<p><img src="/articles/nodejs-memory-leak/02-heap-snapshot.jpg" alt="Chrome DevTools 中导入的多份 Heap Snapshot"></p>
<p><em>图 3：快照必须按同一操作序列采集，否则 Comparison 中的增量没有可比性。</em></p>
<h2 id="从什么在增长走到谁在持有">从“什么在增长”走到“谁在持有”</h2>
<p>在 Chrome DevTools 的 Memory 面板中，我通常按三个视图逐层收窄：</p>
<h3 id="summary先看保留量和对象数量">Summary：先看保留量和对象数量</h3>
<p>Summary 用构造函数聚合对象。我先按 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Retained Size</span></span></code></span> 排序，再观察对象数量是否随每份快照稳定增长。除了业务类名，还会特别关注 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>TCP</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Socket</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>EventEmitter</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Timeout</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Buffer</span></span></code></span> 和全局对象。</p>
<p><img src="/articles/nodejs-memory-leak/03-active-handles.jpg" alt="Heap Snapshot Summary 中异常增长的 Socket、TCP 与 EventEmitter"></p>
<p><em>图 4：业务对象不是唯一线索，底层资源对象常常更接近泄漏类型。</em></p>
<h3 id="comparison只看两次操作之间的净增量">Comparison：只看两次操作之间的净增量</h3>
<p>Comparison 比较两份快照的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span># New</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span># Deleted</span></span></code></span> 和大小变化。一次任务创建一批对象很正常；任务结束并 GC 后，对象仍在每轮净增长才异常。</p>
<p><img src="/articles/nodejs-memory-leak/05-summary-view.jpg" alt="Heap Snapshot Comparison 中持续增加的业务对象"></p>
<p><img src="/articles/nodejs-memory-leak/06-comparison-view.jpg" alt="Comparison 视图中对象新增、删除与保留大小的变化"></p>
<h3 id="retainers-与-containment沿引用链找到所有者">Retainers 与 Containment：沿引用链找到所有者</h3>
<p>找到异常对象后，不要停在类名上。继续看 Retainers，回答“是谁让它不能被 GC”。Containment 则适合观察从 GC Root 到目标对象的层级关系。</p>
<p><img src="/articles/nodejs-memory-leak/07-containment-view.jpg" alt="Containment 视图中的 TCP Socket 包装对象和引用路径"></p>
<p><em>图 7：真正有用的不是看到一个 Socket，而是确认它通过哪条引用链仍然可达。</em></p>
<h2 id="根因把进程级队列当成任务级对象">根因：把进程级队列当成任务级对象</h2>
<p>把五份快照放在一起后，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>TCP</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Socket</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>EventEmitter</span></span></code></span> 的数量会随任务执行持续增加；Grafana 里的活动句柄也同步上升。再回看故障时间窗内的功能，范围收敛到了 Bull 消息队列。</p>
<p>问题不是 Bull 自身“必然泄漏”，而是生命周期使用错了：业务流程中频繁创建队列实例，却没有关闭底层 Redis 连接和事件监听。队列通常应该是进程级长生命周期对象。如果确实创建临时实例，就必须明确释放。</p>
<p>容易出问题的形态类似这样：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> enqueueReport</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">input</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ReportInput</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> queue</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Queue</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"report"</span><span style="color:#E6EDF3">, { redis: redisOptions });</span></span>
<span data-line=""><span style="color:#FF7B72">  await</span><span style="color:#E6EDF3"> queue.</span><span style="color:#D2A8FF">add</span><span style="color:#E6EDF3">(input);</span></span>
<span data-line=""><span style="color:#8B949E">  // 函数结束了，但 queue 持有的连接与监听器还活着。</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>修复后的边界是：实例只创建一次，进程退出时统一关闭。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> reportQueue</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Queue</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"report"</span><span style="color:#E6EDF3">, { redis: redisOptions });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">export</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> enqueueReport</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">input</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> ReportInput</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> reportQueue.</span><span style="color:#D2A8FF">add</span><span style="color:#E6EDF3">(input);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> shutdown</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">signal</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#E6EDF3">  console.</span><span style="color:#D2A8FF">info</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"queue_shutdown"</span><span style="color:#E6EDF3">, { signal });</span></span>
<span data-line=""><span style="color:#FF7B72">  await</span><span style="color:#E6EDF3"> reportQueue.</span><span style="color:#D2A8FF">close</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#E6EDF3">  process.</span><span style="color:#D2A8FF">exit</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">0</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">process.</span><span style="color:#D2A8FF">once</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"SIGTERM"</span><span style="color:#E6EDF3">, () </span><span style="color:#FF7B72">=></span><span style="color:#FF7B72"> void</span><span style="color:#D2A8FF"> shutdown</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"SIGTERM"</span><span style="color:#E6EDF3">));</span></span>
<span data-line=""><span style="color:#E6EDF3">process.</span><span style="color:#D2A8FF">once</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"SIGINT"</span><span style="color:#E6EDF3">, () </span><span style="color:#FF7B72">=></span><span style="color:#FF7B72"> void</span><span style="color:#D2A8FF"> shutdown</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"SIGINT"</span><span style="color:#E6EDF3">));</span></span></code></pre></figure>
<p>具体关闭 API 会随 Bull/BullMQ 版本变化，但原则不变：连接、定时器、子进程、文件描述符和事件监听器都有所有者，也必须有明确的结束时机。这与我在<a href="/articles/2023-12-node-graceful-shutdown/">Node.js 优雅退出</a>里处理的问题同源——资源按依赖方向逆序关闭，进程才能安全离开。</p>
<p><img src="/articles/nodejs-memory-leak/04-heap-comparison.jpg" alt="修复前后快照中队列相关对象和句柄的对比"></p>
<h2 id="回归验证要复用同一负载模型">回归验证要复用同一负载模型</h2>
<p>“代码改了，内存暂时没涨”不能算结案。我们重新执行了与故障复现相同的任务次数、并发和观察周期，验证四件事：</p>
<ul>
<li>GC 后 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>heapUsed</span></span></code></span> 回到稳定区间；</li>
<li>活动句柄数不再随任务次数线性增长；</li>
<li>Comparison 中相关对象的净增量接近零；</li>
<li>服务吞吐、错误率和事件循环延迟没有因修复恶化。</li>
</ul>
<p>如果测试前后的请求结构不同，曲线就没有可比性。内存问题的验收标准应该在修复前写下来，而不是看完新曲线后再解释。</p>
<h2 id="我后来保留的排查清单">我后来保留的排查清单</h2>
<p>常见泄漏来源可以记住，但不要拿清单代替证据：</p>
<ul>
<li>无边界全局缓存；</li>
<li>未清理的 EventEmitter 监听器；</li>
<li>持有大对象的闭包；</li>
<li>没有关闭的 Socket、文件、队列和子进程；</li>
<li>不再需要但仍被定时器引用的上下文；</li>
<li>原生模块或 Buffer 导致的堆外增长。</li>
</ul>
<p>我现在处理这类问题的顺序很固定：先从监控确认趋势，再判断全局还是局部；用时间线缩小变更范围；按同一业务动作采至少三份快照；从对象增量沿 Retainers 找到所有者；最后用同一套压测做回归。</p>
<p>堆快照不是一个会直接指出代码行的答案机器。它更像现场取证：单独一张图很难说明问题，但时间、指标、对象数量、引用链和代码生命周期彼此印证后，根因就不再只是猜测。</p>
<p>这次排查的经验后来被固化成一份 Runbook。它固定了判定条件、采样顺序和生产风险：先看 GC 后基线与活动句柄；锁定发布时间窗；快照至少三份；采集前切流量并确认磁盘；快照按敏感数据管理；修复后复用同一负载。判断标准要先于采样写下来，否则拿到新曲线后再解释，任何结论都成立。</p>
<p>Runbook 没有停在文档层。每季度我们会在测试环境制造一次可控句柄泄漏，让值班同学从告警一路走到 Retainers。只有另一位工程师也能按文档独立完成定位，这次昂贵的排查经验才算真正留在团队里，而不是锁在当次参与者的记忆中。</p>]]></description></item><item><title>内部平台也有用户体验</title><link>https://siegaii.com/articles/2022-11-internal-platform-users/</link><guid>https://siegaii.com/articles/2022-11-internal-platform-users/</guid><pubDate>Sat, 19 Nov 2022 00:00:00 GMT</pubDate><description><![CDATA[<p>内部系统常常把功能完整放在体验之前。理由是使用者都是同事，可以培训，也可以在群里问。于是页面不断增加字段、按钮和状态，流程知识依赖口头传递。</p>
<p>这种成本不会消失，只是从产品开发转移到每个使用者和支持人员身上。</p>
<h2 id="理解真实任务">理解真实任务</h2>
<p>研发平台的用户不是为了“使用平台”，而是为了创建工程、完成发布或定位失败。页面结构应围绕任务，而不是围绕后端模块。用户进入页面后要快速知道当前状态、下一步动作和阻塞原因。</p>
<p>以发布任务为例，页面最需要的不是把流水线所有字段铺开，而是稳定回答五件事：</p>
<ol>
<li>正在发布哪个提交到哪个环境。</li>
<li>当前运行到哪一步，已经完成什么。</li>
<li>是否正在等待某个人或某个外部系统。</li>
<li>失败会不会影响已经在线的版本。</li>
<li>用户现在能重试、回滚还是修改配置。</li>
</ol>
<p>这些信息对应一个任务视图模型，而不是若干后端表的直接拼接：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ReleaseTaskView</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  target</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">project</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">commit</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">environment</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "running"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "waiting"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "succeeded"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  currentStep</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  completedSteps</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#FFA657">  blocker</span><span style="color:#FF7B72">?:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">owner</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">reason</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  availableActions</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#A5D6FF">"retry"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "rollback"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "edit-config"</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>危险操作需要明确范围和后果，但不能用多次无意义确认替代权限与可恢复设计。普通操作则应减少表单输入，尽量从已有上下文推导默认值。</p>
<h2 id="错误信息是关键界面">错误信息是关键界面</h2>
<p>外部系统失败时，平台不能只展示状态码。它要说明哪一步失败、是否影响已有结果、可以重试还是需要修改配置。错误越具体，恢复越不依赖平台开发者。</p>
<p>同一个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>403</span></span></code></span> 在不同阶段可能意味着完全不同的动作。读取仓库时的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>403</span></span></code></span> 需要检查代码平台授权；部署生产环境时的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>403</span></span></code></span> 可能需要申请环境权限。平台在调用外部服务时就要记录领域阶段，不能把解释责任留给 UI 猜测。</p>
<p>一条可行动错误至少包含：发生位置、用户影响、是否可重试、推荐动作和追踪编号。堆栈可以放在开发视图，不应该成为用户唯一能看到的答案。</p>
<p>内部产品也需要观察使用：哪些入口没人找到，哪些步骤反复失败，哪些流程仍然在线下完成。用户反馈不只是需求列表，而是判断默认路径是否成立的证据。</p>
<h2 id="培训不能代替设计">培训不能代替设计</h2>
<p>培训适合解释组织规则和少见风险，不适合弥补每次操作都要记忆的界面细节。如果同一个问题在群里重复出现，应先检查信息是否应该直接显示在任务附近，或者默认值是否可以从上下文推导。</p>
<p>帮助内容也要连接到当前状态。相比一份覆盖全平台的手册，错误旁的一段恢复说明、字段旁的来源解释更容易被使用。用户需要的不是知道系统拥有多少能力，而是在当前一步获得下一步答案。</p>
<p>好的内部平台不会让工程师感到自己在操作另一个复杂系统。它应该把组织经验变成清晰路径，把复杂度留在必要的位置。</p>
<p><img src="/diagrams/series/legacy-internal-platform-funnel.svg" alt="内部用户从找到入口到自助恢复完成的任务漏斗"></p>
<p><em>图：跳出平台找人手工处理，也应该进入产品失败数据。</em></p>
<h2 id="观察用户在哪里离开平台">观察用户在哪里离开平台</h2>
<p>我们给核心任务画漏斗：找到入口、通过权限、配置任务、首次执行、失败恢复、最终完成。用户跳去群里找人或登录服务器手工处理，也算平台任务失败，而不是“线下解决”。</p>
<p>每月选三条真实失败会话复盘，优先修阻断最多人的一步。内部用户不会因为没有竞品就接受糟糕体验，他们只会绕过平台；这些绕路正是最真实的产品反馈。</p>]]></description></item><item><title>CI 构建一次、部署多次：不可变产物如何让回滚可信</title><link>https://siegaii.com/articles/2022-09-immutable-ci-artifacts/</link><guid>https://siegaii.com/articles/2022-09-immutable-ci-artifacts/</guid><pubDate>Sat, 17 Sep 2022 00:00:00 GMT</pubDate><description><![CDATA[<p>曾经的流水线在每个环境都执行一次 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>npm install &#x26;&#x26; npm run build</span></span></code></span>。同一提交在预发正常，生产却因为依赖范围解析到新版本而失败。团队第一反应是“代码明明一样”，但真正部署的字节并不一样。</p>
<p>2022 年我们把发布模型改成 build once, deploy many：CI 在受控环境构建一次，后续环境只验证和部署同一 artifactId。</p>
<p><img src="/diagrams/series/2022-ci-artifact-flow.svg" alt="Git 提交经过 CI 生成不可变制品，再按 artifactId 部署和回滚的时序"></p>
<p><em>图 1：环境晋级的是已经验证过的产物，不是一次重新执行构建的机会。</em></p>
<h2 id="产物必须能回答自己从哪里来">产物必须能回答自己从哪里来</h2>
<p>除了压缩包，我们生成 manifest：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "artifactId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"console-20220917-7f31a2c"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "repository"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"git@example/console"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "gitSha"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"7f31a2c"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "lockfileSha256"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"..."</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "builderImage"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"node-builder@sha256:..."</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "artifactSha256"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"..."</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "sbom"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"console-20220917-7f31a2c.spdx.json"</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>Git tag 只能说明源代码位置，不能说明构建器、依赖锁和最终字节。manifest 把这些事实绑定在一起，并由 CI 身份签名。</p>
<h2 id="部署阶段不再拥有编译权限">部署阶段不再拥有编译权限</h2>
<p>部署器只做四件事：按 ID 下载；验证签名与 hash；注入环境配置；切换运行版本。它没有修改产物或访问包管理器的权限。配置通过环境变量或独立配置文件提供，不把生产密钥烤进构建包。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark-default"><code data-language="bash" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FFA657">verify-signature</span><span style="color:#A5D6FF"> artifact.tar.gz</span><span style="color:#A5D6FF"> artifact.sig</span></span>
<span data-line=""><span style="color:#79C0FF">echo</span><span style="color:#A5D6FF"> "</span><span style="color:#E6EDF3">$EXPECTED_SHA256</span><span style="color:#A5D6FF">  artifact.tar.gz"</span><span style="color:#FF7B72"> |</span><span style="color:#FFA657"> sha256sum</span><span style="color:#79C0FF"> --check</span></span>
<span data-line=""><span style="color:#FFA657">deploy</span><span style="color:#79C0FF"> --artifact</span><span style="color:#A5D6FF"> "</span><span style="color:#E6EDF3">$ARTIFACT_ID</span><span style="color:#A5D6FF">"</span><span style="color:#79C0FF"> --environment</span><span style="color:#A5D6FF"> production</span></span></code></pre></figure>
<h2 id="缓存加速不能改变结果">缓存加速不能改变结果</h2>
<p>CI 缓存用于依赖下载和中间层，但 key 至少包含 lockfile hash、运行时版本和构建配置。缓存 miss 只影响耗时，不能影响依赖集合。流水线定期做无缓存构建，与缓存构建的产物 hash 对比，发现构建中隐藏的时间戳或非确定性输入。</p>
<table>
<thead>
<tr>
<th>缓存</th>
<th>key 组成</th>
<th>错误风险</th>
</tr>
</thead>
<tbody>
<tr>
<td>包管理器下载</td>
<td>lockfile + registry</td>
<td>污染源或过期包</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>node_modules</span></span></code></span></td>
<td>OS + Node + lockfile</td>
<td>原生模块 ABI 不匹配</td>
</tr>
<tr>
<td>编译中间层</td>
<td>工具链 + 源码 hash</td>
<td>环境变量未进入 key</td>
</tr>
<tr>
<td>Docker layer</td>
<td>Dockerfile + context</td>
<td>使用可变基础镜像 tag</td>
</tr>
</tbody>
</table>
<h2 id="晋级和回滚都只移动指针">晋级和回滚都只移动指针</h2>
<p>预发通过后，发布记录把同一 artifactId 标记为 production candidate。生产部署不触发新构建。回滚从最近稳定列表选择旧 ID，验证其数据库兼容范围后切换。</p>
<p>制品不能无限保留，但正在运行、处于回滚窗口或被审计冻结的产物不能删除。清理任务依据引用关系，而不是简单“保留最近十个文件”。</p>
<h2 id="sbom-让漏洞响应有范围">SBOM 让漏洞响应有范围</h2>
<p>依赖漏洞出现时，我们需要知道哪些运行产物实际包含受影响版本，而不只是哪些仓库 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>package.json</span></span></code></span> 写过它。每份制品生成 SBOM 并索引，安全团队可以从组件版本反查 artifactId、环境和负责人。</p>
<p>改造后一次发布失败变得更容易解释：如果 hash 不同，是产物传输问题；如果相同但行为不同，看环境配置和外部依赖；不会再把“重新构建一次试试”当成诊断手段。</p>
<p>不可变产物看起来是流水线细节，实际上建立了软件供应链的事实链。只有测试过的字节就是上线的字节，回滚才真正指向一个已知结果。</p>]]></description></item><item><title>RBAC 不只是用户、角色、权限三张表</title><link>https://siegaii.com/articles/2022-07-rbac-is-domain/</link><guid>https://siegaii.com/articles/2022-07-rbac-is-domain/</guid><pubDate>Sat, 23 Jul 2022 00:00:00 GMT</pubDate><description><![CDATA[<p>权限系统的入门模型很简单：用户拥有角色，角色拥有权限。把三张表建立起来后，很快会遇到真实问题：同一个人在不同项目里角色不同，测试环境和生产环境权限不同，临时授权需要过期，审批人又不能审批自己的操作。</p>
<p>权限不是一个独立技术模块，而是业务规则的一部分。</p>
<p><img src="/diagrams/rbac-decision.svg" alt="主体、动作、资源和上下文进入策略引擎后输出授权决策与审计证据"></p>
<p><em>图 1：角色只是授权来源之一。最终决策还要计算资源范围、临时授权、职责分离和策略版本。</em></p>
<h2 id="从动作和资源开始">从动作和资源开始</h2>
<p>比起先列角色，我更愿意先列受保护的动作：谁可以在什么范围内对什么资源做什么。例如“发布生产环境”包含操作者、工程、环境和动作，缺一不可。</p>
<p>角色只是权限集合的管理方式，不应成为代码里到处出现的判断。业务服务询问授权系统“是否允许这个动作”，而不是判断用户是否叫某个角色。</p>
<p>我会把一次授权请求写成四元组：主体、动作、资源、上下文。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> AuthorizationRequest</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  subject</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">userId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">organizationId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  action</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "release.read"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "release.create"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "release.approve"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  resource</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">projectId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">environment</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "test"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "production"</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  context</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">now</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">requestId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> AuthorizationDecision</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  allowed</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> boolean</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  policyVersion</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  reason</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  missingPermissions</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>“生产发布审批人不能是发起人”就不适合塞进角色表。它依赖当前发布任务的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>createdBy</span></span></code></span>，属于资源和上下文共同决定的领域策略。把它写成明确规则，才能测试，也能在拒绝时解释。</p>
<h2 id="拒绝也需要解释">拒绝也需要解释</h2>
<p>一个成熟权限系统不仅返回允许或拒绝，还应给出缺少的条件。用户知道需要哪种权限、去哪里申请，平台支持成本会显著下降。</p>
<p>授权变化要被审计：谁在何时授予了什么范围，为什么授予，何时过期。高风险操作还需要职责分离，避免同一人发起并批准。</p>
<p>审计日志保存的是当时的决策证据，而不只是操作结果：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>requestId / subject / action / resource</span></span>
<span data-line=""><span>decision / reason / policyVersion</span></span>
<span data-line=""><span>matchedGrants / evaluatedAt</span></span></code></pre></figure>
<p>如果半年后只知道“用户当时有管理员角色”，无法复原这个角色在当时包含哪些权限。保存策略版本和匹配授权，才能回答一次高风险操作为什么被允许。</p>
<h2 id="缓存不能改变授权事实">缓存不能改变授权事实</h2>
<p>权限查询频繁，团队自然会考虑缓存。缓存键必须包含用户、资源范围和动作，失效要响应组织与授权变更。管理员刚撤销生产权限，旧缓存不能继续允许操作。</p>
<p>对于高风险动作，可以接受额外延迟以换取实时检查；低风险只读能力则可使用短缓存。安全与性能不是统一答案，而要按后果分级。所有最终判断仍应记录当时使用的策略与权限版本，方便审计。</p>
<p>前端隐藏入口可以改善体验，真正的权限验证必须发生在服务端。安全边界不能依赖用户看不见按钮。</p>
<p>前端仍然应该消费同一份能力结果，用于禁用按钮和解释申请路径，但不能自己复制策略。否则服务端改规则后，页面可能显示可操作，提交却被拒绝；更糟糕的是页面隐藏了入口，API 却没有检查。</p>
<p>RBAC 是起点，不是答案。只有把权限放回具体领域，模型才会既安全又可维护。</p>
<h2 id="权限规则必须拥有反例测试">权限规则必须拥有反例测试</h2>
<p>每条允许规则至少配一个“相似但必须拒绝”的反例：项目管理员能发布本项目，不代表能发布别的项目；报表查看者能导出可见字段，不代表能导出隐藏个人信息。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>allow: actor.role=project_admin, action=release.deploy, resource.projectId=actor.projectId</span></span>
<span data-line=""><span>deny case: same role, different projectId</span></span>
<span data-line=""><span>deny case: production environment without approval</span></span>
<span data-line=""><span>audit: policyVersion + matchedRule</span></span></code></pre></figure>
<p>反例让权限从角色名称回到真实资源和上下文。</p>]]></description></item><item><title>tenantId 不是一个普通筛选条件：多租户隔离的五层防线</title><link>https://siegaii.com/articles/2022-05-multi-tenant-isolation/</link><guid>https://siegaii.com/articles/2022-05-multi-tenant-isolation/</guid><pubDate>Sat, 21 May 2022 00:00:00 GMT</pubDate><description><![CDATA[<p>多租户系统最危险的 bug 往往非常普通：一个新列表接口忘了加 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>WHERE tenant_id = ?</span></span></code></span>。2022 年代码评审里我们发现过一次类似问题，幸好还没上线。查询语法完全正确，测试数据又只有一个租户，所有用例都通过。</p>
<p>如果租户隔离只依靠开发者记得加条件，它迟早会失效。我们把 tenantId 从可选业务参数提升为安全上下文，在认证、服务、仓储、数据库和审计五层重复约束。</p>
<p><img src="/diagrams/series/2022-tenant-isolation.svg" alt="多租户请求从认证上下文到数据库策略与审计的五道边界"></p>
<p><em>图 1：纵深防御不是重复浪费；任意一层遗漏时，下一层仍能阻止跨租户数据返回。</em></p>
<h2 id="tenantid-只能来自可信身份">tenantId 只能来自可信身份</h2>
<p>客户端可以选择当前工作空间，但不能自由声明自己属于哪个租户。网关根据会话和成员关系解析上下文：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> RequestContext</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  actorId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  tenantId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  roles</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#FFA657">  requestId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> resolveContext</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">session</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Session</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">requestedTenant</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> membership</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#E6EDF3"> memberships.</span><span style="color:#D2A8FF">find</span><span style="color:#E6EDF3">(session.userId, requestedTenant);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">membership) </span><span style="color:#FF7B72">throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> ForbiddenError</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"TENANT_ACCESS_DENIED"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> { actorId: session.userId, tenantId: requestedTenant, roles: membership.roles };</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>后续代码接收 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>RequestContext</span></span></code></span>，不再从 query/body 重新读取 tenantId。这样用户输入和授权结果不会混成同一个字符串。</p>
<h2 id="仓储接口强制要求上下文">仓储接口强制要求上下文</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">class</span><span style="color:#FFA657"> CustomerRepository</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  constructor</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">private</span><span style="color:#FF7B72"> readonly</span><span style="color:#FFA657"> db</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Database</span><span style="color:#E6EDF3">) {}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#D2A8FF">  list</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">ctx</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> RequestContext</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">filter</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> CustomerFilter</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">    return</span><span style="color:#79C0FF"> this</span><span style="color:#E6EDF3">.db.</span><span style="color:#D2A8FF">query</span><span style="color:#E6EDF3">(</span></span>
<span data-line=""><span style="color:#A5D6FF">      "SELECT * FROM customers WHERE tenant_id = ? AND status = ?"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">      [ctx.tenantId, filter.status],</span></span>
<span data-line=""><span style="color:#E6EDF3">    );</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>不提供 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>listAll()</span></span></code></span> 给普通业务层。确实需要跨租户的后台任务使用另一套明确命名、权限更高的接口，并要求 reason 和审计。</p>
<h2 id="数据库层再做一次策略兜底">数据库层再做一次策略兜底</h2>
<p>支持 Row-Level Security 的数据库可以在事务开始时设置租户上下文，让策略自动过滤：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">ALTER</span><span style="color:#FF7B72"> TABLE</span><span style="color:#E6EDF3"> customers </span><span style="color:#FF7B72">ENABLE</span><span style="color:#FF7B72"> ROW</span><span style="color:#FF7B72"> LEVEL</span><span style="color:#FF7B72"> SECURITY</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">CREATE</span><span style="color:#FF7B72"> POLICY</span><span style="color:#E6EDF3"> tenant_isolation </span><span style="color:#FF7B72">ON</span><span style="color:#E6EDF3"> customers</span></span>
<span data-line=""><span style="color:#FF7B72">USING</span><span style="color:#E6EDF3"> (tenant_id </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> current_setting(</span><span style="color:#A5D6FF">'app.tenant_id'</span><span style="color:#E6EDF3">));</span></span></code></pre></figure>
<p>应用连接池必须在每个事务显式设置并结束后清理上下文，不能让租户状态泄漏到下一位请求。数据库策略也不能替代服务层授权：它保护行范围，不一定理解“某角色能不能导出”这样的动作。</p>
<h2 id="唯一约束和缓存-key-也要带租户">唯一约束和缓存 key 也要带租户</h2>
<p>隔离不仅发生在 SELECT：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">UNIQUE</span><span style="color:#E6EDF3"> (tenant_id, external_customer_id)</span></span></code></pre></figure>
<p>缓存 key 使用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>tenant:{tenantId}:customer:{id}</span></span></code></span>；对象存储路径、搜索索引、消息 payload 和指标标签都携带租户。漏掉任何一个共享系统，都可能形成旁路。</p>
<table>
<thead>
<tr>
<th>边界</th>
<th>典型遗漏</th>
<th>验证</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据库</td>
<td>查询漏过滤</td>
<td>两租户同 ID 的集成测试</td>
</tr>
<tr>
<td>缓存</td>
<td>key 只含资源 ID</td>
<td>交替请求检查缓存污染</td>
</tr>
<tr>
<td>队列</td>
<td>Worker 无租户上下文</td>
<td>消息 schema 强制 tenantId</td>
</tr>
<tr>
<td>导出</td>
<td>后台任务使用管理员连接</td>
<td>导出数量与租户数据对账</td>
</tr>
<tr>
<td>搜索</td>
<td>索引 filter 可选</td>
<td>服务端固定注入租户过滤</td>
</tr>
</tbody>
</table>
<h2 id="测试必须故意制造相同-id">测试必须故意制造相同 ID</h2>
<p>如果测试数据里两个租户的资源 ID 永不重复，漏过滤可能仍然看不出来。我们给租户 A、B 创建相同业务编号、相似名称和不同秘密字段，所有读取、更新、删除、搜索、导出都做负向断言。</p>
<p>跨租户管理员功能单独测试：授权明确、界面显著提示当前范围、操作需要理由、结果进入审计。不能因为运维方便就在普通请求里增加 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>allTenants=true</span></span></code></span>。</p>
<p>这次险情没有造成真实数据泄漏，但它改变了架构标准。tenantId 不是开发者自觉添加的过滤器，而是贯穿系统的安全上下文。让错误必须同时穿过五层防线，远比要求每个人永远不忘一行 WHERE 可靠。</p>]]></description></item><item><title>把 CI/CD 当作团队产品</title><link>https://siegaii.com/articles/2022-03-cicd-as-product/</link><guid>https://siegaii.com/articles/2022-03-cicd-as-product/</guid><pubDate>Sat, 12 Mar 2022 00:00:00 GMT</pubDate><description><![CDATA[<p>持续集成经常从几段脚本开始：安装依赖、运行检查、构建、上传。项目增加后，不同仓库复制并修改脚本，环境、缓存和发布规则逐渐分叉。某条流水线失败，只有最熟悉它的人知道如何处理。</p>
<p>这时 CI/CD 已经不是工具配置，而是一款服务团队的内部产品。开发者是用户，提交到上线是用户旅程，失败信息就是产品反馈。</p>
<h2 id="设计默认路径">设计默认路径</h2>
<p>多数项目应该通过模板获得一致的检查、构建、制品与部署步骤。差异通过少量显式参数表达，而不是复制整份配置。模板要有版本，升级也应可控，避免一次修改影响所有项目。</p>
<p>仓库配置只声明意图，平台模板负责实现细节：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="yaml" data-theme="github-dark-default"><code data-language="yaml" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#7EE787">pipeline</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#7EE787">  template</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">web-service@3</span></span>
<span data-line=""><span style="color:#7EE787">  runtime</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">node-20</span></span>
<span data-line=""><span style="color:#7EE787">  checks</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#E6EDF3">    - </span><span style="color:#A5D6FF">typecheck</span></span>
<span data-line=""><span style="color:#E6EDF3">    - </span><span style="color:#A5D6FF">unit</span></span>
<span data-line=""><span style="color:#7EE787">  artifact</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#7EE787">    command</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">pnpm build</span></span>
<span data-line=""><span style="color:#7EE787">    path</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">dist/</span></span>
<span data-line=""><span style="color:#7EE787">  deploy</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#7EE787">    staging</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">automatic</span></span>
<span data-line=""><span style="color:#7EE787">    production</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">approval</span></span></code></pre></figure>
<p>这里最重要的是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>web-service@3</span></span></code></span> 的版本。平台不能在周一悄悄修改模板，让所有仓库周二一起失败。新版本先在样本项目验证，提供迁移说明，再由仓库显式升级；严重安全修复才走受控的强制更新。</p>
<p>默认路径越完整，新项目越不需要重新学习发布细节。但平台也要允许合理例外，否则团队会绕过它。</p>
<h2 id="失败必须可行动">失败必须可行动</h2>
<p>“Job failed”不是有效反馈。失败信息应指出阶段、关键原因、相关日志和可能的处理方式。可重试的基础设施错误与必须修改代码的检查失败，展示方式应该不同。</p>
<p>我会把失败结果结构化，而不是让页面解析日志文本：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "stage"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"artifact-upload"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "kind"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"infrastructure"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "retryable"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">true</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "message"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"制品存储暂时不可用"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "logUrl"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"/runs/812/logs#upload"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "suggestedAction"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"retry"</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>开发者看到的是“可直接重试，代码无需修改”；平台团队则能聚合同类基础设施故障。两种用户需要不同粒度，但共享同一事实。</p>
<p>流水线还要记录制品来源、提交、操作者和目标环境，使一次发布可以被审计和回滚。速度重要，可追踪性更重要。</p>
<h2 id="制品只构建一次">制品只构建一次</h2>
<p>同一份代码不应在测试和生产环境分别构建，因为依赖、时间和环境差异会产生不同制品。更可靠的流程是构建一次、验证一次，再把同一制品逐级提升。环境配置在部署阶段注入，并留下版本记录。</p>
<p>回滚也要提前演练。能够点击“回滚”不代表数据和依赖仍然兼容，关键服务需要定义可回退窗口与迁移策略。发布流程把这些检查显式化，才能让快速发布不以侥幸为前提。</p>
<p>衡量 CI/CD 不能只看构建时长，还要看等待、失败恢复和人工操作。真正高效的流水线，会让正确做法成为最省力的做法。</p>
<p>我最终会看四个数字：提交到首次反馈的时间、提交到可部署制品的时间、失败后恢复时间、需要人工介入的运行占比。单纯把构建从 8 分钟压到 6 分钟，如果任务仍排队 20 分钟，用户感知几乎没有变化。</p>
<p><img src="/diagrams/series/legacy-cicd-feedback.svg" alt="开发者、CI、制品库和环境之间可自助恢复的反馈时序"></p>
<p><em>图：流水线失败必须带证据和下一步，不能只返回一屏日志。</em></p>
<h2 id="流水线失败要给出下一步而不是一屏日志">流水线失败要给出下一步，而不是一屏日志</h2>
<p>每个阶段返回稳定错误码、证据链接和建议负责人。例如依赖完整性失败不应提示“build error”，而是指出 lockfile、registry 与缓存 key；部署健康失败直接链接新旧版本指标和回滚入口。</p>
<p>我们按失败后到恢复成功的时间衡量体验，而不是只看流水线成功率。一个严格但能快速自助修复的门禁，比偶尔放过问题、失败后只能找平台同学更像成熟产品。</p>]]></description></item><item><title>审计日志怎么写，才能回答‘谁改了什么’</title><link>https://siegaii.com/articles/2022-01-audit-log-design/</link><guid>https://siegaii.com/articles/2022-01-audit-log-design/</guid><pubDate>Sat, 22 Jan 2022 00:00:00 GMT</pubDate><description><![CDATA[<p>2022 年内部平台出现过一次权限争议：一个项目的发布权限被移除，团队问“谁在什么时候改的，改之前是什么”。应用日志里能找到 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>update role success</span></span></code></span>，却没有操作者、资源范围和变更前值。日志证明某段代码执行过，不能证明业务事实怎样变化。</p>
<p>从那以后我们把审计日志从普通文本日志里拆出来。它面向追责、合规和事故复盘，需要稳定结构、受控访问和独立保留策略。</p>
<p><img src="/diagrams/series/2022-audit-log-chain.svg" alt="审计记录由主体、行为、资源上下文和不可变证据组成"></p>
<p><em>图 1：一条审计记录至少要能重建“谁、在什么上下文、对什么资源、做了什么、结果如何”。</em></p>
<h2 id="记录业务动作不记录-controller-名">记录业务动作，不记录 Controller 名</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> AuditEvent</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  at</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  actor</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "user"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "service"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">tenantId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  action</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "project.member.role_changed"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  resource</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "project_member"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">projectId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  before</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">role</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  after</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">role</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  reason</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  requestId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  sourceIp</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  result</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "succeeded"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "denied"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>POST /role/update</span></span></code></span> 会随着路由重构变化，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>project.member.role_changed</span></span></code></span> 表达的是长期业务语义。查询“所有管理员权限变更”不应该依赖某个年代的接口路径。</p>
<h2 id="审计与业务写入共享提交边界">审计与业务写入共享提交边界</h2>
<p>如果先改权限再异步写审计，进程崩溃可能留下“变更成功但没有记录”。我们在同一数据库事务写业务数据和 outbox 审计事件，独立消费者把它复制到审计存储：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">BEGIN</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">UPDATE</span><span style="color:#E6EDF3"> project_members </span><span style="color:#FF7B72">SET</span><span style="color:#FF7B72"> role</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> :new_role </span><span style="color:#FF7B72">WHERE</span><span style="color:#E6EDF3"> id </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> :member_id;</span></span>
<span data-line=""><span style="color:#FF7B72">INSERT INTO</span><span style="color:#E6EDF3"> audit_outbox (event_id, payload) </span><span style="color:#FF7B72">VALUES</span><span style="color:#E6EDF3"> (:event_id, :payload);</span></span>
<span data-line=""><span style="color:#FF7B72">COMMIT</span><span style="color:#E6EDF3">;</span></span></code></pre></figure>
<p>这样审计平台短暂不可用不会阻塞核心事务，outbox 也不会丢。消费者去重 eventId，写入追加型存储，普通应用账号没有更新和删除权限。</p>
<h2 id="beforeafter-需要脱敏和控制体积">before/after 需要脱敏和控制体积</h2>
<p>完整对象差异很方便，却可能包含 token、手机号或大段配置。我们按资源类型维护字段白名单，只记录与动作相关的值。秘密字段记录“是否发生变化”，不记录内容。</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>审计值</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>role</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>developer -> admin</span></span></code></span></td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>apiToken</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>changed: true</span></span></code></span></td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>phone</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>138****0217</span></span></code></span> 或不记录</td>
</tr>
<tr>
<td>大型策略 JSON</td>
<td>版本号 + 内容 hash + 独立受控快照</td>
</tr>
</tbody>
</table>
<p>审计记录本身也是敏感数据，查看审计的行为也要被审计。</p>
<h2 id="被拒绝的高风险动作也值得记录">被拒绝的高风险动作也值得记录</h2>
<p>只有成功操作会留下业务变化，但连续权限拒绝可能是攻击或错误自动化。我们记录关键 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>denied</span></span></code></span>，包含策略版本和拒绝原因，不把每个普通 404 都灌入审计库。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "action"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"production.deploy.requested"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "result"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"denied"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "policyVersion"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"rbac-42"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "reason"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"missing: production.deploy"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "requestId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"req_8f2..."</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<h2 id="可查询性要在事故前验证">可查询性要在事故前验证</h2>
<p>我们用几个固定问题验收模型：谁在某时间窗修改了项目 P 的生产权限；某用户离职前 24 小时执行过哪些高风险动作；一次 requestId 涉及哪些资源变更；某服务账号使用过哪些权限。</p>
<p>如果这些问题需要临时 grep 文本，审计结构还不够。上线后还监控 outbox 积压、事件写入延迟、字段解析失败和存储完整性校验。</p>
<p>那次权限争议最后无法从旧日志得到完整答案，这是一次真实的信息损失。审计日志的价值不是让系统“看起来合规”，而是在团队最需要事实时，不必靠聊天记录和人的记忆拼凑发生过什么。</p>]]></description></item><item><title>异步任务可靠性的四个问题</title><link>https://siegaii.com/articles/2021-12-async-jobs-reliability/</link><guid>https://siegaii.com/articles/2021-12-async-jobs-reliability/</guid><pubDate>Sat, 18 Dec 2021 00:00:00 GMT</pubDate><description><![CDATA[<p>将构建、同步和通知放进队列后，接口响应变快了，新的问题也随之出现：进程重启时任务是否丢失，同一个任务会不会执行两次，外部接口超时后该不该重试，失败到什么程度需要人工处理。</p>
<p>队列库能提供投递和消费机制，却无法替业务回答这些问题。</p>
<p><img src="/diagrams/async-job-lifecycle.svg" alt="异步任务从创建、排队、运行到成功、重试、失败和人工恢复的状态机"></p>
<p><em>图 1：队列里的消息可以重复，业务任务状态必须持久、幂等，并且知道自动化何时停止。</em></p>
<h2 id="一任务能否重复执行">一，任务能否重复执行</h2>
<p>网络超时不代表对方没有成功。消费者重试前，必须假设上一次可能已经完成。为任务建立业务幂等键，并在执行前检查已有结果，可以避免重复创建发布或通知。</p>
<p>幂等键应来自业务语义，而不是每次请求随机生成。例如同一提交向同一环境发布，可以使用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>projectId + commitSha + environment</span></span></code></span>。数据库先以唯一约束占住事实，再投递队列：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">create</span><span style="color:#FF7B72"> unique index</span><span style="color:#D2A8FF"> uniq_release_request</span></span>
<span data-line=""><span style="color:#FF7B72">on</span><span style="color:#E6EDF3"> release_job(project_id, commit_sha, environment);</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> job</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#E6EDF3"> db.releaseJob.</span><span style="color:#D2A8FF">upsert</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">  where: { projectId_commitSha_environment: input },</span></span>
<span data-line=""><span style="color:#E6EDF3">  create: { </span><span style="color:#FF7B72">...</span><span style="color:#E6EDF3">input, status: </span><span style="color:#A5D6FF">"queued"</span><span style="color:#E6EDF3">, attempt: </span><span style="color:#79C0FF">0</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">  update: {},</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span>
<span data-line=""><span style="color:#FF7B72">await</span><span style="color:#E6EDF3"> queue.</span><span style="color:#D2A8FF">publish</span><span style="color:#E6EDF3">({ jobId: job.id });</span></span></code></pre></figure>
<p>队列偶尔重复投递没有关系，消费者读取同一任务并检查状态。真正危险的是先发布消息、后写数据库：进程在两步之间退出，队列里就会出现查不到业务事实的任务。更严格的场景应使用 Outbox，把业务写入和待发送消息放在同一事务。</p>
<h2 id="二状态是否可追踪">二，状态是否可追踪</h2>
<p>只保存“成功/失败”不足以恢复。任务要记录输入摘要、当前步骤、尝试次数、外部标识和最后错误。队列中的瞬时状态还应同步到持久存储，避免任务丢失后没有证据。</p>
<h2 id="三错误是否适合重试">三，错误是否适合重试</h2>
<p>超时和临时限流可以指数退避重试，参数错误和权限拒绝则不会因等待而消失。错误分类不清，会制造无意义流量，甚至把小故障放大。</p>
<table>
<thead>
<tr>
<th>错误类型</th>
<th>示例</th>
<th>策略</th>
</tr>
</thead>
<tbody>
<tr>
<td>临时错误</td>
<td>网络中断、服务 503</td>
<td>指数退避并加入随机抖动</td>
</tr>
<tr>
<td>限流</td>
<td>HTTP 429</td>
<td>优先遵循 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Retry-After</span></span></code></span></td>
</tr>
<tr>
<td>永久错误</td>
<td>参数非法、资源不存在</td>
<td>立即失败，不重试</td>
</tr>
<tr>
<td>授权错误</td>
<td>Token 过期、权限撤销</td>
<td>尝试刷新一次，否则人工处理</td>
</tr>
<tr>
<td>结果未知</td>
<td>写请求超时</td>
<td>先按幂等键查询，再决定是否重试</td>
</tr>
</tbody>
</table>
<p>重试代码最重要的不是公式，而是上限和分类：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> delayMs</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">min</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">60_000</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">1_000</span><span style="color:#FF7B72"> *</span><span style="color:#79C0FF"> 2</span><span style="color:#FF7B72"> **</span><span style="color:#E6EDF3"> attempt) </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> (</span><span style="color:#79C0FF">0.8</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">random</span><span style="color:#E6EDF3">() </span><span style="color:#FF7B72">*</span><span style="color:#79C0FF"> 0.4</span><span style="color:#E6EDF3">);</span></span></code></pre></figure>
<p>没有抖动时，大量失败任务会在同一秒醒来，再次把下游压垮。退避是保护依赖，不是让任务看起来更努力。</p>
<h2 id="四自动化在哪里停止">四，自动化在哪里停止</h2>
<p>超过重试上限的任务要进入明确的失败队列，并提供查看、修正和重新执行入口。可靠系统不是永不失败，而是失败后仍然知道发生了什么、下一步由谁处理。</p>
<h2 id="顺序与并发也要写进语义">顺序与并发也要写进语义</h2>
<p>同一工程的两次发布任务可能同时进入队列。如果后一个先完成，状态就会倒退。需要按业务键限制并发，或者让更新携带版本条件，只允许从合法前置状态推进。</p>
<p>消费者数量增加会提高吞吐，也会放大数据库和外部接口压力。扩容依据不应只是队列长度，还要观察任务等待时间、执行时间与下游限额。队列把峰值摊开，不代表下游容量已经增加。</p>
<p>异步架构把时间从请求链路中移走，也把一致性问题暴露出来。只有任务拥有可重复、可观察、可恢复的生命周期，队列才真正提高可靠性。</p>
<h2 id="每类任务都保留一份故障回放包">每类任务都保留一份故障回放包</h2>
<p>回放包包含脱敏输入、状态转移、每次 attempt、租约变化、外部回执、最终产物和代码版本。事故后能在隔离环境重放到某个检查点，而不是只看散落日志。</p>
<p>我们用它验收三件事：重复投递不会产生重复副作用；Worker 中途退出任务能被接管；结果未知不会被当成失败自动重试。可靠性只有能够重复演示失败和恢复，才真正进入工程资产。</p>]]></description></item><item><title>消息至少会来两次：异步消费者的幂等边界</title><link>https://siegaii.com/articles/2021-10-idempotent-consumer/</link><guid>https://siegaii.com/articles/2021-10-idempotent-consumer/</guid><pubDate>Sat, 23 Oct 2021 00:00:00 GMT</pubDate><description><![CDATA[<p>2021 年一个发布任务完成后需要发群通知。消费者已经把消息发送出去，却在 ACK 前网络超时；队列认为处理失败，重新投递，同一个发布结果在群里出现两次。我们最初把重试次数从 3 改成 1，重复少了，但瞬时故障也不再恢复。</p>
<p>至少一次投递系统里，重复不是罕见异常，而是协议允许的结果。消费者必须让同一业务事件执行多次仍得到同一结果。</p>
<p><img src="/diagrams/series/2021-idempotent-consumer.svg" alt="消息消费者从幂等检查、业务写入到 ACK 的处理流程"></p>
<p><em>图 1：幂等记录与业务结果要共享一致性边界；否则“标记完成”和“实际完成”会分离。</em></p>
<h2 id="幂等键来自业务事件不来自消费尝试">幂等键来自业务事件，不来自消费尝试</h2>
<p>每次重投都会产生新的 deliveryTag，不能拿它去重。我们在事件创建时生成稳定 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>eventId</span></span></code></span>：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ReleaseCompleted</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  eventId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  eventType</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "release.completed"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  releaseId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  artifactId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  occurredAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>同一个发布完成事实，无论投递多少次都保持 eventId。生产者还要防止业务事务成功但消息未发出，因此采用 outbox：业务写入和待发布事件在同一数据库事务提交，后台再把 outbox 发送到队列。</p>
<h2 id="数据库副作用用唯一约束兜底">数据库副作用用唯一约束兜底</h2>
<p>如果消费者写的是数据库，可以让处理记录和业务写入处于同一事务：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">BEGIN</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">INSERT INTO</span><span style="color:#E6EDF3"> consumed_events (consumer, event_id, consumed_at)</span></span>
<span data-line=""><span style="color:#FF7B72">VALUES</span><span style="color:#E6EDF3"> (</span><span style="color:#A5D6FF">'release-notifier'</span><span style="color:#E6EDF3">, :event_id, </span><span style="color:#FF7B72">NOW</span><span style="color:#E6EDF3">())</span></span>
<span data-line=""><span style="color:#FF7B72">ON</span><span style="color:#E6EDF3"> CONFLICT DO NOTHING;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#8B949E">-- 只有上一步确实插入一行时才执行业务写入</span></span>
<span data-line=""><span style="color:#FF7B72">INSERT INTO</span><span style="color:#E6EDF3"> notifications (event_id, channel, payload)</span></span>
<span data-line=""><span style="color:#FF7B72">VALUES</span><span style="color:#E6EDF3"> (:event_id, :channel, :payload);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">COMMIT</span><span style="color:#E6EDF3">;</span></span></code></pre></figure>
<p>唯一约束是并发去重的最终防线。先查询“是否存在”再插入，中间有竞态，两个消费者可能同时认为不存在。</p>
<h2 id="外部-api-没有事务怎么办">外部 API 没有事务怎么办</h2>
<p>群机器人不支持与本地数据库同一事务。我们只能选择并明确失败窗口：</p>
<ol>
<li>先写本地 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>pending</span></span></code></span> 记录；</li>
<li>调用外部 API，并携带对方支持的幂等键；</li>
<li>保存外部回执，状态改为 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>sent</span></span></code></span>；</li>
<li>超时但结果未知时先查询，不立即盲目重发；</li>
<li>对方不支持查询或幂等时，接受“可能重复”，在消息正文带 eventId 供人工识别。</li>
</ol>
<p>所谓 exactly-once 往往只在某一层成立。跨越数据库和第三方系统后，更诚实的目标是 effect-once：通过幂等、查询和补偿，让业务效果尽量只发生一次。</p>
<h2 id="重试按错误类型决定">重试按错误类型决定</h2>
<table>
<thead>
<tr>
<th>失败</th>
<th>是否重试</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>网络超时，结果未知</td>
<td>延迟重试前先查询</td>
<td>保持同一 eventId</td>
</tr>
<tr>
<td>429</td>
<td>按 Retry-After</td>
<td>不占用紧密循环</td>
</tr>
<tr>
<td>400 参数非法</td>
<td>不重试</td>
<td>进入死信并告警生产者</td>
</tr>
<tr>
<td>401 凭证失效</td>
<td>短暂停止消费</td>
<td>修复凭证后重放</td>
</tr>
<tr>
<td>500</td>
<td>指数退避</td>
<td>超限进入死信</td>
</tr>
</tbody>
</table>
<p>重试次数不是可靠性的唯一参数。错误分类、退避、死信和人工重放共同组成恢复路径。</p>
<h2 id="ack-放在副作用确定之后">ACK 放在副作用确定之后</h2>
<p>消费者只有在业务结果已经提交，或者确认事件过去处理过时才 ACK。进程在处理中崩溃，消息会重投；幂等边界负责吸收重复。不要为了避免重复提前 ACK，那会把可恢复的重复变成不可恢复的丢失。</p>
<p>上线后我们观察重复投递率、幂等命中数、处理延迟、死信数量和未知结果停留时间。重复投递率突然升高可能意味着消费者太慢或网络异常，即使业务被幂等保护，也值得处理。</p>
<p>这次重复通知很小，却把异步系统最核心的事实暴露出来：队列保证的是消息怎样到达，不保证你的业务副作用只发生一次。这个责任最终仍在消费者的数据模型和事务边界里。</p>]]></description></item><item><title>把研发流程做成产品：一个内部平台的起点</title><link>https://siegaii.com/articles/2021-09-react-node-platform/</link><guid>https://siegaii.com/articles/2021-09-react-node-platform/</guid><pubDate>Sat, 25 Sep 2021 00:00:00 GMT</pubDate><description><![CDATA[<p>研发团队使用的工具很多：代码托管、持续集成、项目管理、部署系统和企业通知。每个工具解决局部问题，完整流程却依赖开发者在多个页面之间复制信息、等待结果和确认状态。</p>
<p>内部研发平台的目标不是再造这些工具，而是连接它们，让一次变更从创建到发布拥有连续、可追踪的过程。听起来像接口聚合，真正开始设计后，核心问题很快变成：平台究竟是一层界面，还是流程本身的状态拥有者？</p>
<h2 id="从动作清单转向领域模型">从动作清单转向领域模型</h2>
<p>最初的需求通常以功能表达：创建项目、触发构建、查看发布、发送通知。如果直接按页面开发，后端会成为一组转发接口，业务规则散落在 React 页面、Node 服务和外部系统里。</p>
<p>我们先整理流程中的稳定对象：工程、迭代、变更、构建、发布窗口、环境和审批。每个对象有自己的状态与约束，例如变更只有在检查通过后才能进入发布窗口，发布任务必须关联确定的构建产物。</p>
<p>领域模型并不需要复杂术语。它的价值是让团队使用同一种语言，也让状态变化有唯一位置。页面不再自行判断“是否可发布”，而是展示服务端根据完整规则给出的能力。</p>
<p>服务端返回状态和允许动作，React 页面只负责呈现：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> ReleaseView</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "draft"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "checking"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "ready"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "deploying"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "succeeded"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  artifactId</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  blockers</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;{ </span><span style="color:#FFA657">code</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">message</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">owner</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }>;</span></span>
<span data-line=""><span style="color:#FFA657">  capabilities</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#A5D6FF">"submit"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "approve"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "deploy"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "retry"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "rollback"</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#FFA657">  version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>capabilities</span></span></code></span> 不是权限的替代，动作提交时服务端仍会重新校验。它的作用是让页面和服务共享同一决策结果，避免前端复制“检查通过且有审批角色且在发布窗口”这串规则。</p>
<h2 id="平台不能成为唯一故障点">平台不能成为唯一故障点</h2>
<p>连接更多系统后，平台也拥有更大故障面。GitLab、部署服务或消息接口任何一个变慢，都可能拖住整个请求。如果所有调用都在用户点击后同步完成，页面会长时间等待，重试还可能重复创建任务。</p>
<p>我们将需要较长时间的动作转成异步任务。用户请求只负责验证输入、创建流程记录并入队；Worker 执行外部调用，持续更新状态；前端通过轮询或推送看到进度。</p>
<p>异步并不自动可靠。任务必须拥有幂等键，避免重复点击或重试造成多次发布；每一步要记录开始、成功、失败与外部标识；可重试错误和业务拒绝需要区分；超过次数后进入人工处理，而不是无限循环。</p>
<p>最重要的是，即使平台短暂不可用，也不能破坏原有研发工具的基本能力。平台应该提供更顺畅的路径，而不是把所有系统锁死在自己身后。</p>
<h2 id="react-页面应反映流程而不是拼接表单">React 页面应反映流程，而不是拼接表单</h2>
<p>内部系统常被认为“不需要设计”，结果是大量表单和状态文本堆在一起。研发平台的用户虽然是工程师，同样需要快速判断当前发生了什么、下一步由谁处理、失败后如何恢复。</p>
<p>页面围绕流程记录组织，而不是围绕接口组织。顶部给出当前阶段和阻塞原因，时间线展示关键事件，操作只在满足条件时出现。外部系统链接作为证据保留，让用户可以进入原系统确认细节。</p>
<p>前端状态也严格区分服务器事实与临时交互。流程状态来自服务端，不能因为前端乐观更新就假装发布完成；表单展开、筛选条件则属于本地。混淆二者会让刷新后出现矛盾。</p>
<h2 id="nodejs-服务的边界">Node.js 服务的边界</h2>
<p>Node.js 适合整合大量 I/O 密集接口，也让前端团队能够快速参与全栈开发。但语言统一不代表职责可以混乱。</p>
<p>服务按入口、应用流程、领域规则和基础设施适配拆分。控制器只处理协议，应用层编排用例，领域层保存不依赖外部工具的规则，适配层负责 GitLab、部署与消息系统。这样更换外部接口时，不必改写流程语义。</p>
<p>数据一致性是另一个难点。数据库事务无法覆盖外部系统，我们不能假装一次跨系统操作具有真正原子性。更现实的做法是本地先记录意图，再通过任务推进外部状态；每一步可重复执行，并使用补偿或人工介入处理无法自动恢复的结果。</p>
<p>我们采用本地事务 + Outbox，避免数据库已经创建发布任务但队列消息丢失：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">await</span><span style="color:#E6EDF3"> db.</span><span style="color:#D2A8FF">transaction</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">async</span><span style="color:#E6EDF3"> (</span><span style="color:#FFA657">tx</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> release</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#E6EDF3"> tx.release.</span><span style="color:#D2A8FF">create</span><span style="color:#E6EDF3">({ data: command });</span></span>
<span data-line=""><span style="color:#FF7B72">  await</span><span style="color:#E6EDF3"> tx.outbox.</span><span style="color:#D2A8FF">create</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">    data: {</span></span>
<span data-line=""><span style="color:#E6EDF3">      topic: </span><span style="color:#A5D6FF">"release.requested"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">      aggregateId: release.id,</span></span>
<span data-line=""><span style="color:#E6EDF3">      payload: </span><span style="color:#79C0FF">JSON</span><span style="color:#E6EDF3">.</span><span style="color:#D2A8FF">stringify</span><span style="color:#E6EDF3">({ releaseId: release.id, version: release.version }),</span></span>
<span data-line=""><span style="color:#E6EDF3">    },</span></span>
<span data-line=""><span style="color:#E6EDF3">  });</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span></code></pre></figure>
<p>独立发布器扫描未发送 Outbox，投递成功后标记。消息可能重复，因此 Worker 仍按 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>releaseId + version</span></span></code></span> 幂等消费。Outbox 解决的是“不丢意图”，不保证只执行一次。</p>
<h2 id="权限不是菜单显示">权限不是菜单显示</h2>
<p>研发平台连接生产发布和代码权限，前端隐藏按钮远远不够。权限判断必须在服务端执行，并基于用户、组织、工程、环境和动作共同决定。</p>
<p>同时，权限模型需要能解释拒绝原因。用户被拒绝时应该知道缺少什么角色、由谁审批，而不是只看到“无权限”。这会减少大量线下沟通。</p>
<p>企业身份可以帮助统一登录，但外部系统中的账号映射仍要被管理。映射错误可能让操作以错误身份执行，因此关键动作保留操作者、审批者和外部执行身份。</p>
<h2 id="内部平台也要有产品指标">内部平台也要有产品指标</h2>
<p>上线功能数量不能说明平台价值。我们更关心重复操作是否减少、流程等待时间是否缩短、失败是否更容易恢复、人工沟通是否下降。</p>
<p>指标需要从事件中自然产生，而不是事后统计。创建、审批、构建、发布和失败都记录结构化事件，既用于用户时间线，也用于分析流程瓶颈。这样一套数据可以回答：需求主要卡在哪里，哪类构建最常失败，哪些环节仍然依赖人工。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "releaseId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"rel_812"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "event"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"approval.requested"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "actor"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"user_17"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "occurredAt"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"2021-09-25T08:20:00Z"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "metadata"</span><span style="color:#E6EDF3">: { </span><span style="color:#7EE787">"environment"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"production"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"approverGroup"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"ops"</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>时间线告诉用户“正在等待 ops 审批”，聚合数据则能发现发布周期主要耗在等待而非构建。没有领域事件，团队只能从日志里事后拼流程。</p>
<p>但研发数据很容易被误用为个人绩效。平台应当分析系统效率，而不是把代码量或提交次数包装成生产力。指标一旦影响个人评价，行为会迅速围绕指标优化。</p>
<h2 id="平台工程首先是约束设计">平台工程首先是约束设计</h2>
<p>内部平台的吸引力在于它能把零散经验固化为默认路径：新项目自动获得仓库、检查与发布配置；变更沿着统一状态流转；失败拥有标准恢复入口。</p>
<p>它的风险也在这里。如果默认路径设计错误，错误会被放大到整个团队。因此平台不能只追求覆盖率，还要允许例外、保留逃生通道，并持续从真实使用中修正规则。</p>
<p>这个项目让我第一次系统地从页面走到流程、服务和组织协作。React 与 Node.js 是实现工具，真正需要构建的是一套可被理解、执行和改进的研发秩序。</p>
<p><img src="/diagrams/series/legacy-platform-product.svg" alt="研发平台由用户工作流、控制面和证据面组成的架构"></p>
<p><em>图：操作入口只是表面，自助恢复和可靠执行才是平台价值。</em></p>
<h2 id="内部平台也要用任务结果衡量">内部平台也要用任务结果衡量</h2>
<p>我们不再以页面访问量证明平台价值，而是看创建一次发布需要多少人工步骤、失败后平均多久恢复、多少任务能自助完成、多少问题仍要找平台同学手工处理。</p>
<p>每次新增能力都要减少一个明确摩擦：少复制一份配置、少等一次权限、少进入一台服务器。指标如果只让平台更忙，却没有让使用者更独立，功能数量再多也不算进步。</p>]]></description></item><item><title>从完成页面到理解系统</title><link>https://siegaii.com/articles/2021-06-from-page-to-system/</link><guid>https://siegaii.com/articles/2021-06-from-page-to-system/</guid><pubDate>Sat, 19 Jun 2021 00:00:00 GMT</pubDate><description><![CDATA[<p>做前端很容易用页面作为工作边界：接口给什么就展示什么，需求写什么就实现什么。页面完成后，如果整体流程仍然难用，可以归因给产品；如果数据不稳定，可以归因给服务端。</p>
<p>这种分工能保护局部效率，也会让系统问题无人负责。很多线上故障并不属于某一层，而是发生在层与层之间。</p>
<h2 id="沿着结果向外走">沿着结果向外走</h2>
<p>我开始在需求评审时追问数据来源、权限规则和失败路径，在接口联调前理解服务端的成本，在上线后关注用户是否真正完成任务。前端仍是主要职责，但判断不再停在浏览器边界。</p>
<p>理解上下游并不意味着替所有人工作。它意味着能识别问题属于哪里，提前暴露冲突，并用共同目标而不是职位边界推动解决。</p>
<p>一次“发布按钮一直转圈”的问题让我看清这种差别。浏览器请求在 30 秒后超时，服务端其实已经创建构建任务；用户再次点击，又创建了第二个任务。页面层加长超时只能掩盖问题，真正需要的是一条完整任务语义：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>用户提交发布</span></span>
<span data-line=""><span>  → API 以幂等键创建任务</span></span>
<span data-line=""><span>  → 立即返回 taskId</span></span>
<span data-line=""><span>  → Worker 执行构建和部署</span></span>
<span data-line=""><span>  → 页面订阅任务状态</span></span>
<span data-line=""><span>  → 成功、失败或等待人工确认</span></span></code></pre></figure>
<p>前端职责变成展示任务状态和提供恢复入口；API 负责幂等创建；Worker 负责执行；数据库保存事实。每一层都更简单，因为“发布是否完成”不再由一个长连接猜测。</p>
<h2 id="架构始于责任分配">架构始于责任分配</h2>
<p>一个字段在哪里计算，一个状态由谁保存，一个失败由哪一层恢复，这些看似细小的决定组成系统架构。责任模糊时，各层都会做一点，最终没有任何一层拥有完整语义。</p>
<p>我会用下面四个问题检查责任是否清楚：</p>
<table>
<thead>
<tr>
<th>问题</th>
<th>需要明确的事实</th>
</tr>
</thead>
<tbody>
<tr>
<td>谁创建事实？</td>
<td>例如任务 ID 由服务端生成，而不是页面临时拼接</td>
</tr>
<tr>
<td>谁保存事实？</td>
<td>刷新页面后仍需存在的状态必须持久化</td>
</tr>
<tr>
<td>谁推进状态？</td>
<td>Worker 完成步骤，页面不能把任务“改成成功”</td>
</tr>
<tr>
<td>谁解释失败？</td>
<td>执行层给出错误分类，产品层转换成用户动作</td>
</tr>
</tbody>
</table>
<p>这张表后来也用于 AI 工作流。技术换了，责任问题没有换。</p>
<p>工程师走向更复杂工作，不一定先从学习另一门语言开始。更关键的是扩大观察范围：看到请求之外的发布，组件之外的业务，代码之外的协作。</p>
<h2 id="用图把共同理解外化">用图把共同理解外化</h2>
<p>当问题开始跨层，仅靠口头描述很容易让每个人在脑中形成不同系统。我会画最简单的数据流和状态图：请求从哪里进入，事实由谁保存，失败在哪一层被处理。图不追求完整，而是暴露责任空白和循环依赖。</p>
<p>共同地图还能改善讨论。前端不再只说“接口慢”，服务端也不只说“请求正常”，大家可以沿同一条用户路径查看时间和状态。系统思维并不是知道全部细节，而是能让相关细节在需要时连接起来。</p>
<p>页面是系统与用户接触的地方，却不是系统的全部。只有理解结果如何由多层共同产生，才可能对结果真正负责。</p>
<p><img src="/diagrams/series/legacy-page-system-map.svg" alt="用户体验、服务依赖和运行保障组成的页面系统地图"></p>
<p><em>图：页面症状沿依赖链回溯，才能找到真正责任层。</em></p>
<h2 id="我开始为每个页面画依赖地图">我开始为每个页面画依赖地图</h2>
<p>地图只画完成用户任务必须经过的节点：入口、接口、缓存、队列、数据库和外部服务；每条边写超时、重试与负责人。页面报错时先沿地图判断故障属于哪层，而不是默认回到组件代码。</p>
<p>地图不追求一次完整，事故和新功能后持续修正。能把页面症状连接到系统依赖，是我从“实现需求”走向“对结果负责”的一个可观察变化。</p>]]></description></item><item><title>回滚要在发布之前设计：一次可逆部署的时序</title><link>https://siegaii.com/articles/2021-05-release-rollback/</link><guid>https://siegaii.com/articles/2021-05-release-rollback/</guid><pubDate>Sat, 22 May 2021 00:00:00 GMT</pubDate><description><![CDATA[<p>早期发布是把构建目录 rsync 到服务器。回滚文档写着“重新发布上一版本”，真正出问题时才发现上一版本依赖已经变了、数据库迁移也执行过了，所谓回滚只是重新赌一次。2021 年我们开始把“能否回滚”当成发布设计的一部分，而不是事故发生后的命令搜索。</p>
<p><img src="/diagrams/series/2021-release-rollback.svg" alt="新版本部署、健康检查、渐进切流量和异常回切的发布时序"></p>
<p><em>图 1：新版本先存在但不承载全部流量，证明健康后再逐步接管。</em></p>
<h2 id="产物一旦构建就不再改变">产物一旦构建就不再改变</h2>
<p>CI 对提交 SHA 构建一次，生成 artifactId，记录依赖锁、Node 版本、环境无关配置和校验和。测试、预发、生产部署同一份产物，只注入环境配置。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "artifactId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"web-20210522-a91f3c2"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "gitSha"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"a91f3c2"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "node"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"14.17.0"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "sha256"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"..."</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "builtAt"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"2021-05-22T08:12:03Z"</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>回滚只是选择旧 artifactId，不重新安装依赖。制品库按保留策略保存最近稳定版本，发布记录指明当前每个环境使用哪份产物。</p>
<h2 id="健康检查要覆盖真实依赖">健康检查要覆盖真实依赖</h2>
<p>进程能监听端口不等于可接流量。我们分两类：liveness 只判断进程是否失去响应；readiness 检查必要配置、数据库连接和关键依赖是否足以服务请求。依赖短暂波动时，不应让所有实例一起被重启。</p>
<p>新实例先 readiness 通过，再跑冒烟用例：登录、读取核心列表、创建一条可清理测试数据。只有结果和延迟都满足条件，才进入小流量。</p>
<h2 id="自动停止比自动发布更重要">自动停止比自动发布更重要</h2>
<p>每个发布阶段都有观察窗口和停止条件：</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th>流量</th>
<th>观察指标</th>
<th>异常动作</th>
</tr>
</thead>
<tbody>
<tr>
<td>warm-up</td>
<td>0%</td>
<td>健康、依赖、启动日志</td>
<td>终止新版本</td>
</tr>
<tr>
<td>canary</td>
<td>1%</td>
<td>5xx、P95、核心业务失败</td>
<td>自动回切</td>
</tr>
<tr>
<td>ramp</td>
<td>10% → 50%</td>
<td>资源、队列积压、错误差异</td>
<td>停止扩量</td>
</tr>
<tr>
<td>full</td>
<td>100%</td>
<td>全局 SLO 与旧版本连接耗尽</td>
<td>保留旧实例一段时间</td>
</tr>
</tbody>
</table>
<p>阈值按新旧版本对照，而不是只看绝对值。业务高峰本来就有波动，单一固定阈值容易误判。</p>
<h2 id="数据库变更必须向前兼容">数据库变更必须向前兼容</h2>
<p>应用可以回滚，破坏性数据库迁移不能。我们采用 expand/contract：先增加新字段和双读兼容；新版本逐步写新格式；回填历史数据；确认旧版本不再运行后，才移除旧字段。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>发布 N: 新增 nullable 字段，应用仍读旧字段</span></span>
<span data-line=""><span>发布 N+1: 双写新旧字段，读新失败回退旧</span></span>
<span data-line=""><span>回填: 分批迁移并核对数量</span></span>
<span data-line=""><span>发布 N+2: 只读新字段，仍保留旧字段</span></span>
<span data-line=""><span>观察期后: 删除旧字段</span></span></code></pre></figure>
<p>如果一次发布必须依赖不可逆迁移，它就不具备简单回滚，需要准备前向修复和业务停机方案，不能在界面上仍展示一个绿色“回滚”按钮。</p>
<h2 id="回滚也需要演练和审计">回滚也需要演练和审计</h2>
<p>我们定期在预发执行：选定旧 artifactId、切回流量、确认数据库兼容、验证缓存与异步任务。发布平台记录谁在何时因为哪个指标触发回滚，以及回滚后是否恢复。</p>
<p>最重要的变化，是团队不再把回滚视为发布失败。渐进发布里，自动停止和回切恰恰说明保护机制在工作。真正失败的是系统已经看到异常，却因为回滚路径未知而继续扩大影响。</p>]]></description></item><item><title>性能不是分数，而是一份持续预算</title><link>https://siegaii.com/articles/2021-03-performance-is-budget/</link><guid>https://siegaii.com/articles/2021-03-performance-is-budget/</guid><pubDate>Sat, 06 Mar 2021 00:00:00 GMT</pubDate><description><![CDATA[<p>性能优化最容易变成一次专项：页面慢了，团队集中几天压缩资源、加缓存、拆包，指标改善后便宣布完成。几个月后功能继续增长，同一个问题又以新的形式回来。</p>
<p>原因并不复杂。性能不是一个可以永久修复的缺陷，而是每次增加功能都会消耗的有限预算。如果没有边界和反馈，它必然被逐步透支。</p>
<h2 id="从用户任务定义指标">从用户任务定义指标</h2>
<p>技术指标很多：响应时间、首字节、首屏、可交互、帧率、内存、QPS。它们只有映射到用户任务才有意义。</p>
<p>内容页的首要目标是尽快看到主体文字，运营后台更关心筛选后的表格何时可以操作，播放器页面则要关注首帧与控制响应。用同一指标评价所有页面，会奖励错误的优化。</p>
<p>我会先写出关键路径：用户从哪里进入，要完成什么，哪一步的等待会让任务中断。然后为路径选择少量核心指标，并同时观察中位数和长尾。平均值很容易掩盖网络差、设备旧或接口偶发慢的用户。</p>
<p>服务端同样如此。QPS 只是吞吐能力，必须和延迟、错误率、资源占用一起看。一个服务在低延迟时能承受多少稳定流量，比某次峰值更有参考价值。</p>
<h2 id="建立可以重复的基线">建立可以重复的基线</h2>
<p>没有基线，优化就只能依赖感受。开发机、测试环境和真实用户差异很大，单次测量又容易受缓存与网络波动影响。</p>
<p>我们将测量分成三层。开发阶段用浏览器工具定位网络、脚本与渲染瓶颈；持续集成里对关键页面运行固定环境测试，捕捉明显回退；上线后采集真实用户数据，观察设备与网络分布下的体验。</p>
<p>三层不能相互替代。实验室测试可重复，却不代表真实世界；真实数据足够诚实，却难以精确归因。把它们组合起来，才有机会从“发现变慢”走到“知道为什么变慢”。</p>
<p>每次发布应当保留版本标识，让指标变化可以对应到具体构建。否则折线上的异常只是一个时间点，无法回溯到代码。</p>
<h2 id="预算要能阻止回退">预算要能阻止回退</h2>
<p>性能预算可以作用于资源体积、关键请求数量、主线程长任务和页面指标。预算的数值不必一开始就完美，重要的是它能表达边界，并在越界时触发讨论。</p>
<p>例如入口脚本不能因为一次需求增长几百 KB；首屏不应串行等待多个彼此无关的接口；单个组件不应在滚动期间持续创建大对象。CI 可以检查资源体积，代码评审可以检查请求链路，监控可以发现真实指标回退。</p>
<p>预算不是机械阻止业务。越界可能合理，但必须明确交换：新增能力带来什么价值，能否延迟加载，是否需要删除旧成本。没有这种讨论，性能总会输给眼前功能。</p>
<p>预算最好进入仓库，而不是只写在性能文档里：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "routes"</span><span style="color:#E6EDF3">: {</span></span>
<span data-line=""><span style="color:#7EE787">    "/article/:slug"</span><span style="color:#E6EDF3">: {</span></span>
<span data-line=""><span style="color:#7EE787">      "initialJsKb"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">180</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">      "criticalRequests"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">4</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">      "lcpMs"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">2500</span></span>
<span data-line=""><span style="color:#E6EDF3">    },</span></span>
<span data-line=""><span style="color:#7EE787">    "/dashboard"</span><span style="color:#E6EDF3">: {</span></span>
<span data-line=""><span style="color:#7EE787">      "initialJsKb"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">260</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">      "longTasksOver50ms"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">3</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">      "interactiveMs"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">3000</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>CI 可以稳定检查资源体积和请求数量，LCP 这类波动指标则使用固定环境跑多次并设回退区间，不因为单次抖动阻断提交。线上真实用户指标再验证预算是否代表真实体验。</p>
<p>一次合理越界需要在 PR 里写清交换。例如编辑器新增 35KB 解析器，但只在用户进入编辑模式后动态加载；入口包预算不变，编辑动作增加一次可解释等待。这比简单把预算从 180 改到 215 更诚实。</p>
<h2 id="优化主要矛盾">优化主要矛盾</h2>
<p>性能工具会列出很多建议，逐条完成并不等于解决问题。应该先判断等待发生在哪一层。</p>
<p>如果首字节很慢，压缩前端图片帮助有限；如果主线程被大段脚本占用，升级服务器也不会改善交互；如果请求瀑布来自组件层层依赖，就需要调整数据获取结构，而不是再加一个加载动画。</p>
<p>一次有效优化应当能解释机制：删除了哪段阻塞，减少了多少数据，避免了几次重复计算。只报告“分数从 70 到 90”，很难判断收益能否持续。</p>
<p>我会用一张前后对照表结束优化，而不是只贴 Lighthouse 截图：</p>
<table>
<thead>
<tr>
<th>路径</th>
<th>优化前</th>
<th>改动</th>
<th>优化后</th>
<th>代价</th>
</tr>
</thead>
<tbody>
<tr>
<td>首屏文章</td>
<td>串行等待推荐接口</td>
<td>主体与推荐并行，推荐延后渲染</td>
<td>主体不再被推荐阻塞</td>
<td>推荐区稍晚出现</td>
</tr>
<tr>
<td>运营表格</td>
<td>5000 行一次渲染</td>
<td>服务端分页 + 保留筛选</td>
<td>首次可操作更早</td>
<td>翻页需要请求</td>
</tr>
<tr>
<td>图表更新</td>
<td>全量转换与绘制</td>
<td>按系列版本跳过未变部分</td>
<td>主线程长任务下降</td>
<td>增加版本契约</td>
</tr>
</tbody>
</table>
<p>“代价”一列会阻止团队把优化写成纯收益。后续需求改变时，可以重新判断这笔交换是否仍然成立。</p>
<p>常见优化也有副作用。缓存会引入一致性问题，懒加载可能把等待推迟到用户点击时，预加载会浪费带宽，虚拟列表会增加滚动与可访问性复杂度。性能设计仍然是权衡，而不是技巧清单。</p>
<h2 id="让退化更难发生">让退化更难发生</h2>
<p>长期性能来自架构默认值。路由级拆包应默认开启，图片组件应提供尺寸与合理格式，数据层应避免重复请求，列表组件应对规模设定边界。开发者沿着默认路径工作时，结果就不应过度糟糕。</p>
<p>组件库也要包含性能契约。例如表格在多少数据量内直接渲染，超过后建议分页还是虚拟化；图表更新是全量替换还是增量更新；弹窗关闭后是否释放监听和大对象。</p>
<p>这些契约让性能从个人经验变成团队能力。新成员不需要踩过所有坑，系统本身就能给出约束。</p>
<h2 id="优化也需要退出条件">优化也需要退出条件</h2>
<p>性能工作很容易继续深入：再减少几十毫秒，再拆一段脚本，再调整一次缓存。每个优化开始前应定义目标值、验证环境和停止条件。达到用户任务需要的体验后，剩余资源可能更适合解决可靠性或功能问题。</p>
<p>还要记录没有采用的方案。某种预加载因为弱网浪费明显而放弃，某种服务端缓存因为用户状态难以隔离而暂缓，这些判断能避免团队几个月后重复相同实验。优化不是把所有指标推到极致，而是用有限投入消除当前最有价值的等待。</p>
<h2 id="性能是一种产品判断">性能是一种产品判断</h2>
<p>最快的页面是没有功能的页面。真实工作不是追求绝对速度，而是在功能、成本和体验之间找到可持续平衡。</p>
<p>某些等待可以被用户理解，例如导出大文件；某些等待会破坏信任，例如点击后没有任何反馈。优化优先级应该由任务价值和用户感知决定，而不是由最容易改善的指标决定。</p>
<p>经历服务端渲染与高流量页面的改造后，我越来越少把性能称为“前端优化”。它横跨接口、缓存、构建、运行时和产品流程。一个团队如果只在页面变慢时才关注性能，就像只在账户见底时才讨论预算。</p>
<p>真正成熟的性能体系，不是永远没有回退，而是回退能被尽早发现，成本能被看见，选择能被解释。</p>
<p><img src="/diagrams/series/legacy-performance-budget.svg" alt="性能基线、PR 门禁、线上数据和预算回收的持续流程"></p>
<p><em>图：预算进入每次变更后，性能才不再是上线前突击。</em></p>
<h2 id="把预算写进流水线而不是贴在文档里">把预算写进流水线，而不是贴在文档里</h2>
<p>我们给首屏 JavaScript、关键 CSS、LCP、接口 P95 和长任务设置基线。PR 只要让资源体积或实验室指标超过阈值，就必须写原因和回收计划；不能用一次总分掩盖单项退化。</p>
<p>预算按页面价值区分，低端设备和弱网单独跑。线上再用真实用户数据校正实验室门禁。这样性能不是上线前的一次冲刺，而是每次变更都能看到自己消耗了多少共同资源。</p>]]></description></item><item><title>内部平台为什么需要控制面：不要让页面直接遥控脚本</title><link>https://siegaii.com/articles/2021-02-job-orchestration-control-plane/</link><guid>https://siegaii.com/articles/2021-02-job-orchestration-control-plane/</guid><pubDate>Sat, 20 Feb 2021 00:00:00 GMT</pubDate><description><![CDATA[<p>2021 年内部研发平台最早只有一个按钮：用户选择仓库和分支，后端 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>spawn</span></span></code></span> 一段构建脚本，把 stdout 通过 WebSocket 打回页面。演示很直观，第二个人同时使用时就开始混乱：日志串在一起，进程重启后任务消失，用户不知道按钮点下去到底有没有执行。</p>
<p>问题不是页面缺少 loading，而是系统没有控制面。用户意图、任务状态和执行进程被绑在一次 HTTP 连接里，任何一层断开都失去事实来源。</p>
<p><img src="/diagrams/series/2021-job-orchestration.svg" alt="内部平台由控制面、执行面和证据面组成的任务编排架构"></p>
<p><em>图 1：页面提交任务定义，Worker 执行租约；任务状态和产物不依赖任何一条长连接存活。</em></p>
<h2 id="创建任务先落库再异步调度">创建任务先落库，再异步调度</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> Job</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  type</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "build"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "deploy"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "data-sync"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "queued"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "running"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "succeeded"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "cancelled"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  input</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> unknown</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  createdBy</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  createdAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  attempt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  leaseOwner</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  leaseExpiresAt</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>API 在事务中写入 Job 和审计记录，然后返回 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>202 Accepted</span></span></code></span> 与 jobId。页面通过 jobId 订阅状态；即使刷新或换电脑，也能回到同一任务。</p>
<p>输入不是任意脚本字符串，而是按任务类型校验的结构。构建任务允许仓库、提交 SHA、构建配置；部署任务只接受已经存在的 artifactId。控制面决定“允许做什么”，执行器不解释用户自由文本。</p>
<h2 id="worker-用租约领取任务">Worker 用租约领取任务</h2>
<p>如果只把状态从 queued 改为 running，Worker 进程崩溃后任务会永久卡住。领取操作写入有过期时间的租约，并周期续约：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="github-dark-default"><code data-language="sql" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">UPDATE</span><span style="color:#E6EDF3"> jobs</span></span>
<span data-line=""><span style="color:#FF7B72">SET</span><span style="color:#FF7B72"> status</span><span style="color:#FF7B72"> =</span><span style="color:#A5D6FF"> 'running'</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">    lease_owner </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> :worker_id,</span></span>
<span data-line=""><span style="color:#E6EDF3">    lease_expires_at </span><span style="color:#FF7B72">=</span><span style="color:#FF7B72"> NOW</span><span style="color:#E6EDF3">() </span><span style="color:#FF7B72">+</span><span style="color:#E6EDF3"> INTERVAL </span><span style="color:#79C0FF">30</span><span style="color:#FF7B72"> SECOND</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">    attempt </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> attempt </span><span style="color:#FF7B72">+</span><span style="color:#79C0FF"> 1</span></span>
<span data-line=""><span style="color:#FF7B72">WHERE</span><span style="color:#E6EDF3"> id </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> :job_id</span></span>
<span data-line=""><span style="color:#FF7B72">  AND</span><span style="color:#FF7B72"> status</span><span style="color:#FF7B72"> =</span><span style="color:#A5D6FF"> 'queued'</span><span style="color:#E6EDF3">;</span></span></code></pre></figure>
<p>调度器扫描过期租约，根据任务类型决定重试还是人工恢复。执行结果更新必须校验 leaseOwner，避免旧 Worker 恢复后覆盖新 Worker 的结果。</p>
<h2 id="取消是协议不是-kill--9">取消是协议，不是 kill -9</h2>
<p>用户点击取消后，控制面把 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>cancel_requested_at</span></span></code></span> 写入任务。Worker 在安全检查点读取取消标记，停止后续步骤并执行清理。只有超时不响应才由执行环境强制终止。</p>
<table>
<thead>
<tr>
<th>任务阶段</th>
<th>取消策略</th>
</tr>
</thead>
<tbody>
<tr>
<td>尚未领取</td>
<td>直接转 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>cancelled</span></span></code></span></td>
</tr>
<tr>
<td>下载依赖</td>
<td>中止下载，清理临时目录</td>
</tr>
<tr>
<td>构建中</td>
<td>发送终止信号，等待子进程退出</td>
</tr>
<tr>
<td>发布切流量</td>
<td>进入补偿流程，不能直接杀进程</td>
</tr>
<tr>
<td>已完成</td>
<td>取消无效，必要时创建回滚任务</td>
</tr>
</tbody>
</table>
<p>“取消按钮”如果没有定义每个阶段的业务语义，只是给用户一个虚假的控制感。</p>
<h2 id="日志和产物属于证据面">日志和产物属于证据面</h2>
<p>stdout 不再只通过 WebSocket 转发。Worker 给每一行加 jobId、attempt、step 和 sequence，批量写对象存储；实时通道只是低延迟视图，断开后可以按 sequence 补读。构建产物也用 artifactId 保存校验和、来源 SHA 和工具版本。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "jobId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"job_42"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "attempt"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">2</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "step"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"compile"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "sequence"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">183</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "level"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"info"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "message"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"bundle completed"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "at"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"2021-02-20T10:31:42Z"</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<h2 id="状态机不允许任意跳转">状态机不允许任意跳转</h2>
<p>只有控制面能执行 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>queued -> running -> succeeded/failed</span></span></code></span>。重试不是把 failed 改回 queued，而是保留 attempt 历史并创建新的执行尝试。这样能回答任务失败几次、每次由谁执行、用了哪个输入和产物。</p>
<p>这个平台让我从“给脚本做个页面”走向系统设计。页面只是控制面的一个客户端。真正让内部工具可信的，是任务状态不依赖页面、执行权有租约、每个副作用可追踪、失败后知道从哪里继续。</p>]]></description></item><item><title>JSBridge 的本质是一份跨端协议</title><link>https://siegaii.com/articles/2021-01-jsbridge-contract/</link><guid>https://siegaii.com/articles/2021-01-jsbridge-contract/</guid><pubDate>Sun, 17 Jan 2021 00:00:00 GMT</pubDate><description><![CDATA[<p>H5 运行在 App WebView 中，需要调用登录、旋转屏幕、分享和设备能力。最初的 JSBridge 很直接：前端调用约定好的全局函数，原生端再回调另一个函数。功能少时足够，端版本增多后，问题开始集中出现。</p>
<p>旧版本没有新方法，Android 与 iOS 返回结构不同，页面销毁后回调仍然到达。每个业务模块都加一套平台判断，通信层逐渐失去边界。</p>
<h2 id="把调用建模为消息">把调用建模为消息</h2>
<p>我们把一次调用定义为包含方法、参数、调用标识和协议版本的消息，响应则包含相同标识、结果与标准错误。调用标识解决并发回调对应问题，超时让调用不会永久悬挂，统一错误让业务能决定重试或降级。</p>
<p>前端不直接访问平台对象，而是依赖一个 Promise 接口。原生端能力通过启动时的协商表暴露，页面可以先判断是否支持，而不是调用后再猜测。</p>
<p>协议消息可以保持很小，但字段含义必须稳定：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> BridgeRequest</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  method</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 2</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  params</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Record</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">unknown</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> BridgeResponse</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">ok</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> true</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">result</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> T</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">ok</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> false</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">error</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">code</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">message</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> } };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> BridgeCapabilities</span><span style="color:#FF7B72"> =</span><span style="color:#FFA657"> Record</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, { </span><span style="color:#FFA657">version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> }>;</span></span></code></pre></figure>
<p>调用层为每个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>id</span></span></code></span> 保存 Promise 的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>resolve/reject</span></span></code></span> 和超时句柄。原生响应回来后按 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>id</span></span></code></span> 精确配对，完成后立即删除。若同一响应重复到达，桥接层记录异常但不再次触发业务回调。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> pending</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Map</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">PendingCall</span><span style="color:#E6EDF3">>();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> invoke</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">>(</span><span style="color:#FFA657">method</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">params</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> object</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">timeoutMs</span><span style="color:#FF7B72"> =</span><span style="color:#79C0FF"> 5000</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">> {</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">capabilities[method]) </span><span style="color:#FF7B72">return</span><span style="color:#79C0FF"> Promise</span><span style="color:#E6EDF3">.</span><span style="color:#D2A8FF">reject</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">new</span><span style="color:#D2A8FF"> UnsupportedMethod</span><span style="color:#E6EDF3">(method));</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> id</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> crypto.</span><span style="color:#D2A8FF">randomUUID</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#FF7B72"> new</span><span style="color:#79C0FF"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">>((</span><span style="color:#FFA657">resolve</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">reject</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    const</span><span style="color:#79C0FF"> timer</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> window.</span><span style="color:#D2A8FF">setTimeout</span><span style="color:#E6EDF3">(() </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">      pending.</span><span style="color:#D2A8FF">delete</span><span style="color:#E6EDF3">(id);</span></span>
<span data-line=""><span style="color:#D2A8FF">      reject</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">new</span><span style="color:#D2A8FF"> BridgeTimeout</span><span style="color:#E6EDF3">(method));</span></span>
<span data-line=""><span style="color:#E6EDF3">    }, timeoutMs);</span></span>
<span data-line=""><span style="color:#E6EDF3">    pending.</span><span style="color:#D2A8FF">set</span><span style="color:#E6EDF3">(id, { resolve, reject, timer, method });</span></span>
<span data-line=""><span style="color:#E6EDF3">    nativeTransport.</span><span style="color:#D2A8FF">postMessage</span><span style="color:#E6EDF3">({ id, method, version: </span><span style="color:#79C0FF">2</span><span style="color:#E6EDF3">, params });</span></span>
<span data-line=""><span style="color:#E6EDF3">  });</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<h2 id="兼容性是协议的一部分">兼容性是协议的一部分</h2>
<p>App 发布后无法要求所有用户立刻升级。新增能力必须考虑旧端：参数只做向后兼容扩展，破坏性变化使用新方法名或协议版本，无法支持时返回明确错误。</p>
<p>日志也应保留方法、耗时与错误码，但不记录敏感参数。跨端问题往往只能在特定设备复现，没有通信层证据，排查成本会非常高。</p>
<p>错误码要让调用方能行动，而不是把原生异常字符串透传回来：</p>
<table>
<thead>
<tr>
<th>错误</th>
<th>是否重试</th>
<th>页面行为</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>UNSUPPORTED</span></span></code></span></td>
<td>否</td>
<td>隐藏入口或展示升级说明</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>USER_CANCELLED</span></span></code></span></td>
<td>否</td>
<td>保持当前状态，不弹系统错误</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>PERMISSION_DENIED</span></span></code></span></td>
<td>用户授权后</td>
<td>引导到权限设置</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>TIMEOUT</span></span></code></span></td>
<td>视方法而定</td>
<td>对只读调用可重试，写操作先查询结果</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>NATIVE_FAILURE</span></span></code></span></td>
<td>否</td>
<td>展示可追踪错误编号</td>
</tr>
</tbody>
</table>
<h2 id="生命周期需要显式管理">生命周期需要显式管理</h2>
<p>WebView 页面可能在原生调用完成前离开。桥接层要在页面销毁时清理未完成回调，拒绝对应 Promise，并阻止晚到结果修改新页面。事件订阅同样需要返回取消函数，避免页面多次进入后重复监听。</p>
<p>对于横屏、网络和登录状态这类持续事件，约定初始快照与后续变更的顺序。只有事件而没有初值，页面启动时会处于未知状态；只有查询而没有订阅，状态变化又无法及时反映。协议必须覆盖完整生命周期。</p>
<p>JSBridge 看似只是几段胶水代码，实际连接了两个独立发布的系统。只要两端不能同时更新，它就应该像公开 API 一样被设计和维护。</p>
<p><img src="/diagrams/series/legacy-jsbridge-sequence.svg" alt="H5、Bridge、Native 与系统能力之间带版本和超时的调用时序"></p>
<p><em>图：可靠 Bridge 需要 requestId、来源校验和结构化失败。</em></p>
<h2 id="bridge-兼容矩阵是协议的一部分">Bridge 兼容矩阵是协议的一部分</h2>
<p>每个能力记录首次支持的 iOS/Android 版本、参数版本、超时、重复回调和 H5 降级。页面启动时读取 capability 列表，不用 UA 猜测。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>capability: file.pick</span></span>
<span data-line=""><span>schema: v2</span></span>
<span data-line=""><span>iOS: >= 6.4</span></span>
<span data-line=""><span>Android: >= 6.6</span></span>
<span data-line=""><span>timeout: 30s</span></span>
<span data-line=""><span>fallback: HTML file input</span></span></code></pre></figure>
<p>线上错误按 capability + nativeVersion 聚合，才能区分协议不兼容和业务失败。</p>]]></description></item><item><title>重构 SSR：性能问题首先是系统问题</title><link>https://siegaii.com/articles/2020-11-nuxt-ssr-rebuild/</link><guid>https://siegaii.com/articles/2020-11-nuxt-ssr-rebuild/</guid><pubDate>Sat, 21 Nov 2020 00:00:00 GMT</pubDate><description><![CDATA[<p>内容产品的页面需要搜索引擎收录，也需要在 App 内稳定打开。原有服务把模板、数据请求和页面逻辑混在一起，访问量上升后，服务端渲染逐渐成为瓶颈。最直观的现象是响应变慢、CPU 抖动，严重时连不需要 SSR 的页面也被拖累。</p>
<p>把系统迁移到 Nuxt.js 只是重构的起点。框架可以提供同构渲染能力，却不会自动回答哪些页面应该在服务端生成、哪些数据可以缓存、失败时如何退回客户端，以及一台机器到底能承担多少并发。</p>
<p><img src="/diagrams/ssr-request-pipeline.svg" alt="SSR 请求从 CDN、路由、数据聚合、模板渲染到浏览器激活的完整链路"></p>
<p><em>图 1：缓存、阶段计时和降级都挂在同一条请求链路上。只盯模板渲染时间，会漏掉接口长尾、击穿和激活失败。</em></p>
<h2 id="先画出完整链路">先画出完整链路</h2>
<p>优化前，我们先把一次页面访问拆开：请求进入 Node 服务，匹配路由，获取多个接口数据，创建 Vue 实例，执行服务端渲染，生成 HTML，再返回浏览器完成激活。静态资源由 CDN 提供，但 HTML 和接口聚合仍然集中在服务端。</p>
<p>只看总响应时间无法知道问题在哪里。于是为关键阶段增加计时：路由匹配、远程接口、模板渲染、序列化和整体耗时。同时记录缓存命中率、进程内存与事件循环延迟。</p>
<p>我们把阶段耗时写进 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Server-Timing</span></span></code></span>，这样浏览器、日志和压测结果能使用同一组名字：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">export</span><span style="color:#FF7B72"> default</span><span style="color:#FF7B72"> async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> renderPage</span><span style="color:#FFA657">(req, res) </span><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> marks</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Map</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">number</span><span style="color:#E6EDF3">>();</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#D2A8FF"> measure</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> async</span><span style="color:#E6EDF3"> &#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">>(</span><span style="color:#FFA657">name</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">, </span><span style="color:#D2A8FF">run</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> () </span><span style="color:#FF7B72">=></span><span style="color:#FFA657"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">>) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    const</span><span style="color:#79C0FF"> started</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> performance.</span><span style="color:#D2A8FF">now</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">    try</span><span style="color:#E6EDF3"> { </span><span style="color:#FF7B72">return</span><span style="color:#FF7B72"> await</span><span style="color:#D2A8FF"> run</span><span style="color:#E6EDF3">(); }</span></span>
<span data-line=""><span style="color:#FF7B72">    finally</span><span style="color:#E6EDF3"> { marks.</span><span style="color:#D2A8FF">set</span><span style="color:#E6EDF3">(name, performance.</span><span style="color:#D2A8FF">now</span><span style="color:#E6EDF3">() </span><span style="color:#FF7B72">-</span><span style="color:#E6EDF3"> started); }</span></span>
<span data-line=""><span style="color:#E6EDF3">  };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> data</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#D2A8FF"> measure</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"data"</span><span style="color:#E6EDF3">, () </span><span style="color:#FF7B72">=></span><span style="color:#D2A8FF"> loadPageData</span><span style="color:#E6EDF3">(req));</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> html</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#D2A8FF"> measure</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"render"</span><span style="color:#E6EDF3">, () </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> renderer.</span><span style="color:#D2A8FF">renderRoute</span><span style="color:#E6EDF3">(req.url, { data }));</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> serialized</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#D2A8FF"> measure</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"serialize"</span><span style="color:#E6EDF3">, </span><span style="color:#FF7B72">async</span><span style="color:#E6EDF3"> () </span><span style="color:#FF7B72">=></span><span style="color:#D2A8FF"> injectState</span><span style="color:#E6EDF3">(html, data));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">  res.</span><span style="color:#D2A8FF">setHeader</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"Server-Timing"</span><span style="color:#E6EDF3">, [</span><span style="color:#FF7B72">...</span><span style="color:#E6EDF3">marks].</span><span style="color:#D2A8FF">map</span><span style="color:#E6EDF3">(([</span><span style="color:#FFA657">name</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">ms</span><span style="color:#E6EDF3">]) </span><span style="color:#FF7B72">=></span><span style="color:#A5D6FF"> `${</span><span style="color:#E6EDF3">name</span><span style="color:#A5D6FF">};dur=${</span><span style="color:#E6EDF3">ms</span><span style="color:#A5D6FF">.</span><span style="color:#D2A8FF">toFixed</span><span style="color:#A5D6FF">(</span><span style="color:#79C0FF">1</span><span style="color:#A5D6FF">)</span><span style="color:#A5D6FF">}`</span><span style="color:#E6EDF3">).</span><span style="color:#D2A8FF">join</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">","</span><span style="color:#E6EDF3">));</span></span>
<span data-line=""><span style="color:#E6EDF3">  res.</span><span style="color:#D2A8FF">end</span><span style="color:#E6EDF3">(serialized);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>计时本身也要克制。只记录稳定阶段，不为每个函数打点；否则监控维度快速膨胀，真正需要比较的链路反而看不见。</p>
<p>测量很快推翻了一些直觉。模板渲染并非始终最慢，远程接口的长尾延迟和重复请求占用了大量时间；部分页面每次请求都计算相同数据，热门页面又持续挤压冷门页面的缓存空间。</p>
<h2 id="缓存不是一个开关">缓存不是一个开关</h2>
<p>“加缓存”很容易，“定义缓存语义”更难。我们把缓存分成三层：CDN 负责稳定静态资源；页面级缓存保存变化不频繁、用户无关的完整 HTML；数据级缓存保存多个页面共享的接口结果。</p>
<p>每层都必须明确键、生命周期和失效方式。页面缓存如果包含登录状态，会造成数据串用；只按路径缓存而忽略查询参数，会返回错误内容；缓存时间过长，又会让运营更新无法及时出现。</p>
<p>LRU 能限制进程内缓存占用，却不能替代业务规则。我们根据页面流量和内容时效性设置不同策略，并为主动失效保留入口。缓存命中时要记录，未命中时也要知道原因，否则缓存只是不可解释的黑盒。</p>
<p>还要防止缓存击穿。热门页面过期瞬间，如果所有请求同时回源，压力会集中爆发。更稳妥的方式是让第一个请求负责刷新，其余请求短暂等待或继续使用可接受的旧值。</p>
<p>进程内可以先用 Promise 合并相同回源请求：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> refreshes</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Map</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">CachedPage</span><span style="color:#E6EDF3">>>();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> getOrRefresh</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">key</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> cached</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> pageCache.</span><span style="color:#D2A8FF">get</span><span style="color:#E6EDF3">(key);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (cached?.fresh) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3"> cached.value;</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (cached?.stale </span><span style="color:#FF7B72">&#x26;&#x26;</span><span style="color:#FF7B72"> !</span><span style="color:#E6EDF3">refreshes.</span><span style="color:#D2A8FF">has</span><span style="color:#E6EDF3">(key)) {</span></span>
<span data-line=""><span style="color:#E6EDF3">    refreshes.</span><span style="color:#D2A8FF">set</span><span style="color:#E6EDF3">(key, </span><span style="color:#D2A8FF">refreshPage</span><span style="color:#E6EDF3">(key).</span><span style="color:#D2A8FF">finally</span><span style="color:#E6EDF3">(() </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> refreshes.</span><span style="color:#D2A8FF">delete</span><span style="color:#E6EDF3">(key)));</span></span>
<span data-line=""><span style="color:#FF7B72">    return</span><span style="color:#E6EDF3"> cached.value;</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (refreshes.</span><span style="color:#D2A8FF">has</span><span style="color:#E6EDF3">(key)) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3"> refreshes.</span><span style="color:#D2A8FF">get</span><span style="color:#E6EDF3">(key)</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> request</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> refreshPage</span><span style="color:#E6EDF3">(key).</span><span style="color:#D2A8FF">finally</span><span style="color:#E6EDF3">(() </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> refreshes.</span><span style="color:#D2A8FF">delete</span><span style="color:#E6EDF3">(key));</span></span>
<span data-line=""><span style="color:#E6EDF3">  refreshes.</span><span style="color:#D2A8FF">set</span><span style="color:#E6EDF3">(key, request);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> request;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>这只能合并单进程请求；多实例还需要共享锁或由 CDN 承担页面缓存。我们没有因为示例代码简单就假装问题已经全局解决，而是把适用范围写进缓存策略。</p>
<h2 id="ssr-不是所有页面的默认答案">SSR 不是所有页面的默认答案</h2>
<p>搜索引擎真正关心的是公开内容页，登录后的互动页面并不需要承担同样成本。我们按业务价值划分渲染策略：核心落地页保持完整 SSR；更新频率低的页面尽量静态化；强交互且无需索引的部分采用 CSR；某些区域可以先输出骨架，再由客户端加载。</p>
<p>这不是技术上的妥协，而是资源分配。SSR 提升首屏内容可见性，也增加服务端计算、状态同步和故障面。只有收益高于成本的页面才值得使用。</p>
<p>对同一个页面，也要区分首次访问与后续导航。首次请求需要完整 HTML，客户端接管之后可以按单页应用方式加载数据。重复执行同一请求不仅浪费资源，还可能造成界面闪动和状态覆盖。</p>
<h2 id="激活失败比白屏更隐蔽">激活失败比白屏更隐蔽</h2>
<p>服务端 HTML 与客户端首次渲染不一致时，页面可能看起来正常，却无法正确绑定事件。时间、随机数、浏览器专属 API 和依赖屏幕尺寸的逻辑，都会造成不一致。</p>
<p>我们把环境相关代码集中隔离，服务端不执行依赖 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>window</span></span></code></span> 的逻辑；需要客户端决定的内容使用稳定占位；接口数据通过明确的初始状态传递，避免客户端再次猜测。</p>
<p>对错误也做分类：数据接口失败时，公开页面可以返回保留主体结构的降级内容；模板渲染失败时，回退到客户端壳；服务过载时，优先保护核心路由。降级不是一个统一的错误页，而是根据页面价值保留尽可能多的可用能力。</p>
<table>
<thead>
<tr>
<th>页面</th>
<th>正常策略</th>
<th>数据超时</th>
<th>渲染失败</th>
<th>过载</th>
</tr>
</thead>
<tbody>
<tr>
<td>公开内容页</td>
<td>SSR + 页面缓存</td>
<td>旧缓存并标记时间</td>
<td>CSR 壳</td>
<td>优先保留缓存命中</td>
</tr>
<tr>
<td>搜索页</td>
<td>SSR 首屏</td>
<td>空结果 + 可重试</td>
<td>CSR</td>
<td>限制复杂查询</td>
</tr>
<tr>
<td>登录后工作台</td>
<td>CSR</td>
<td>局部错误</td>
<td>CSR</td>
<td>返回明确维护状态</td>
</tr>
</tbody>
</table>
<p>如果没有按页面价值区分，团队很容易给所有错误返回同一张 500 页面，或者让所有路由竞争同一份 SSR 资源。</p>
<h2 id="容量来自压测也来自边界">容量来自压测，也来自边界</h2>
<p>单次请求变快并不等于系统稳定。我们使用接近真实流量分布的压测，而不是只压一个最简单路由；观察吞吐量的同时，关注 P95 延迟、内存增长、错误率和事件循环阻塞。</p>
<p>Node 进程擅长 I/O，但昂贵的同步计算仍会阻塞所有请求。大对象序列化、复杂字符串处理和日志输出都可能成为隐藏成本。对于可以预计算的内容，尽量移出请求链路；对于无法避免的计算，限制输入规模并考虑拆分。</p>
<p>进程管理和健康检查也属于架构。服务要能识别自己是否失去响应，实例重启不能造成全部容量同时下降，发布过程要保留旧版本承接流量。前端团队一旦拥有 SSR 服务，就必须承担服务端运行责任。</p>
<h2 id="优化后的真正变化">优化后的真正变化</h2>
<p>最终的提升不只来自某个缓存库或 Nuxt 配置，而是来自一组彼此配合的选择：缩小 SSR 范围，建立分层缓存，减少重复数据请求，隔离环境差异，补齐监控和降级，再用压测验证容量。</p>
<p>这次重构改变了我对前端性能的理解。页面变慢可能表现为浏览器问题，根因却可能在接口、渲染策略、缓存语义或发布系统。性能从来不是最后阶段的“优化项”，它是系统如何使用资源的结果。</p>
<p>当问题跨越浏览器与服务端后，最有价值的能力不是熟悉更多参数，而是能画出链路、找到主要矛盾，并让每个优化都拥有可验证的证据。</p>
<h2 id="ssr-评审时我会要求一张容量表">SSR 评审时我会要求一张容量表</h2>
<p>每类路由列出峰值 QPS、P95 服务端渲染时间、缓存命中率、上游调用数、单实例内存和降级策略。没有容量数字的“开启 SSR”只是功能选择，还不是运行设计。</p>
<p>压测也按真实路由占比混合，不只压最简单页面。最终验收看峰值下错误率、事件循环延迟、缓存击穿时的上游 QPS，以及回滚到 CSR 壳是否仍能完成核心动作。</p>]]></description></item><item><title>功能开关不是一个 if：一次分阶段发布的完整设计</title><link>https://siegaii.com/articles/2020-10-feature-flag-rollout/</link><guid>https://siegaii.com/articles/2020-10-feature-flag-rollout/</guid><pubDate>Sat, 17 Oct 2020 00:00:00 GMT</pubDate><description><![CDATA[<p>2020 年重构播放器时，新旧实现牵涉埋点、进度恢复和 App WebView 兼容，不适合某天晚上一次性全量。我们加了一个功能开关，最初只是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>if (newPlayerEnabled)</span></span></code></span>。很快发现真正难的不是开和关，而是谁进入实验、怎样保持同一个人始终在同一组、出现什么指标必须停止，以及开关何时删除。</p>
<p><img src="/diagrams/series/2020-feature-flag-rollout.svg" alt="功能从内部账号、稳定分桶、指标观察到全量和回滚的发布流程"></p>
<p><em>图 1：开关是一条带观测与退出条件的发布管线，而不是永久留在代码里的分支。</em></p>
<h2 id="分桶必须稳定且可解释">分桶必须稳定且可解释</h2>
<p>如果每次请求都用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Math.random()</span></span></code></span>，同一个用户刷新后会在新旧版本之间跳动，进度与缓存状态互相污染。我们用用户稳定标识、实验 key 和盐做哈希：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> bucket</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">subjectId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">flagKey</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">salt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> digest</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> sha256</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">`${</span><span style="color:#E6EDF3">salt</span><span style="color:#A5D6FF">}:${</span><span style="color:#E6EDF3">flagKey</span><span style="color:#A5D6FF">}:${</span><span style="color:#E6EDF3">subjectId</span><span style="color:#A5D6FF">}`</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#D2A8FF"> parseInt</span><span style="color:#E6EDF3">(digest.</span><span style="color:#D2A8FF">slice</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">0</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">8</span><span style="color:#E6EDF3">), </span><span style="color:#79C0FF">16</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">%</span><span style="color:#79C0FF"> 10_000</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> enabled</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">subjectId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">rollout</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#D2A8FF"> bucket</span><span style="color:#E6EDF3">(subjectId, </span><span style="color:#A5D6FF">"player-v2"</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">"2020-10"</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">&#x3C;</span><span style="color:#E6EDF3"> rollout </span><span style="color:#FF7B72">*</span><span style="color:#79C0FF"> 100</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>rollout=1</span></span></code></span> 表示 1%，精度到万分桶。匿名用户使用设备级随机 ID，但登录后要明确是否重新分组，不能把两套身份悄悄混用。</p>
<h2 id="规则优先级要固定">规则优先级要固定</h2>
<p>最终判定顺序是：紧急全局关闭；内部账号强制开启；黑名单关闭；指定版本/平台规则；百分比分桶；默认值。每次评估返回原因，便于排查：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark-default"><code data-language="json" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">{</span></span>
<span data-line=""><span style="color:#7EE787">  "flag"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"player-v2"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "value"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">true</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "reason"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"percentage-rollout"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "ruleId"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"ios-10-percent"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#7EE787">  "configVersion"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">17</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>只记录结果而不记录规则版本，事故后很难知道当时究竟执行了哪份配置。</p>
<h2 id="扩量之前写停止条件">扩量之前写停止条件</h2>
<p>我们没有等数据出来后再讨论“算不算异常”，而是在 1% 前约定：播放启动失败率比旧版高 0.3 个百分点就停止；首帧 P95 恶化超过 15% 就回退；关键埋点缺失超过阈值也不扩量。</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th>人群</th>
<th>最短观察</th>
<th>放行条件</th>
</tr>
</thead>
<tbody>
<tr>
<td>内部</td>
<td>团队账号</td>
<td>1 天</td>
<td>核心路径与降级完成</td>
</tr>
<tr>
<td>1%</td>
<td>稳定随机用户</td>
<td>1 个业务周期</td>
<td>错误率和首帧不劣化</td>
</tr>
<tr>
<td>10%</td>
<td>分平台扩量</td>
<td>2 个高峰</td>
<td>资源和客服反馈正常</td>
</tr>
<tr>
<td>50%</td>
<td>主流版本</td>
<td>1 天</td>
<td>新旧指标差异可解释</td>
</tr>
<tr>
<td>100%</td>
<td>全量</td>
<td>持续观察</td>
<td>开始删除旧实现</td>
</tr>
</tbody>
</table>
<h2 id="回滚时要考虑已经产生的新状态">回滚时要考虑已经产生的新状态</h2>
<p>播放器 v2 保存了新的进度字段。关闭前端开关不能让服务端停止理解这些数据，否则回滚用户会丢进度。我们把读路径做成双格式兼容，写路径在灰度期保留旧字段，直到确认不再回滚才迁移。</p>
<p>功能开关只能切换行为，不能自动补偿已经写入的数据。涉及数据库和外部副作用时，回滚方案必须在上线前单独设计。</p>
<h2 id="每个开关都有删除日期">每个开关都有删除日期</h2>
<p>开关长期存在会让测试组合指数增长。创建时我们写 owner、目的、创建日、预期全量日和删除任务。全量稳定后先删除旧分支，再删除开关配置，而不是让 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>player-v2</span></span></code></span> 五年后仍在每次请求里判断。</p>
<p>这次灰度让我对发布有了新的理解：部署只是把代码送到环境，发布是逐步把真实用户交给新行为。一个合格的功能开关必须同时具备稳定分桶、可观察理由、停止条件、数据兼容和结束日期，否则它只是把风险从发布日拖进了未来代码。</p>]]></description></item><item><title>跨端复用，先明确不能复用什么</title><link>https://siegaii.com/articles/2020-08-cross-platform-boundaries/</link><guid>https://siegaii.com/articles/2020-08-cross-platform-boundaries/</guid><pubDate>Sun, 30 Aug 2020 00:00:00 GMT</pubDate><description><![CDATA[<p>同一产品需要覆盖 PC、App 内 H5 和小程序时，“一套代码多端运行”听起来是最自然的目标。实际开发中，各端的路由、生命周期、权限、存储和交互习惯都不同。强行抹平差异，会把平台判断散落到业务代码里。</p>
<p>复用之前，应该先明确哪些层不应复用。视图和平台能力往往变化最快，领域规则、接口模型和数据转换更稳定。</p>
<h2 id="用适配层隔离平台">用适配层隔离平台</h2>
<p>相机、分享、登录和返回行为可以通过小而明确的适配接口暴露。业务代码依赖“选择图片”或“获得身份”，而不是直接判断当前是否在微信或 App WebView。</p>
<p>适配层不能假装所有平台能力完全一致。某端不支持的能力应该明确返回不可用，让产品决定降级方式，而不是静默失败。</p>
<p>例如选择图片不应该返回一个各端都含义不同的字符串：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> PickedImage</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  localUri</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  mimeType</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  sizeBytes</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> CapabilityResult</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">ok</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> true</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">value</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> T</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">ok</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> false</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">reason</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "unsupported"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "denied"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "cancelled"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">interface</span><span style="color:#FFA657"> MediaCapability</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#D2A8FF">  pickImage</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">options</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">maxCount</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> })</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">CapabilityResult</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">PickedImage</span><span style="color:#E6EDF3">[]>>;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>小程序返回临时路径，Web 返回 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>File</span></span></code></span>，App Bridge 可能先返回资产 ID。适配器负责把这些结果变成业务可理解的最小结构；如果上传仍需平台对象，则把上传也留在适配器内，不要泄漏半转换状态。</p>
<h2 id="复用要计算维护成本">复用要计算维护成本</h2>
<p>共享一段代码节省了首次开发，却可能增加测试组合和发布耦合。如果两个端的迭代节奏不同，过度共享会让小改动触发全端回归。</p>
<p>我更愿意复用稳定规则，而不是追求文件层面的统一。允许少量视图重复，换取清晰的平台边界，通常比一个充满条件分支的“万能组件”更便宜。</p>
<h2 id="测试矩阵要跟着边界走">测试矩阵要跟着边界走</h2>
<p>跨端项目的测试数量很容易相乘。所有功能在所有平台、所有版本上完整回归并不现实。我们按共享层和平台层设计测试：领域规则运行一套稳定用例，适配器针对每个平台验证契约，关键用户路径再覆盖真实设备组合。</p>
<table>
<thead>
<tr>
<th>层</th>
<th>Web</th>
<th>App H5</th>
<th>小程序</th>
</tr>
</thead>
<tbody>
<tr>
<td>领域规则</td>
<td>同一套单元测试</td>
<td>同一套</td>
<td>同一套</td>
</tr>
<tr>
<td>能力适配器</td>
<td>浏览器权限与 File</td>
<td>Bridge 版本与回调</td>
<td>授权弹窗与临时路径</td>
</tr>
<tr>
<td>关键路径</td>
<td>主流浏览器</td>
<td>最低支持 App 版本</td>
<td>当前与上一基础库版本</td>
</tr>
</tbody>
</table>
<p>这样新增一个业务校验不需要全端重复验证，修改 Bridge 协议却会明确触发 App H5 的兼容回归。测试数量跟随边界，而不是跟随仓库文件数。</p>
<p>发布也要记录各端版本对应的协议能力。H5 可以快速更新，App 与小程序存在审核和用户升级延迟，前端不能只按照最新客户端假设。兼容范围如果没有被写出来，就会以线上偶发错误的方式出现。</p>
<p>跨端架构的目标不是最高复用率，而是让差异可见、可控制。承认平台不同，才有可能真正共享值得共享的部分。</p>
<p><img src="/diagrams/series/legacy-cross-platform-capability.svg" alt="业务能力协议连接 Web、Native 和小程序适配的跨端架构"></p>
<p><em>图：共享的是业务语义和协议，不是强行共享所有实现。</em></p>
<h2 id="先做能力矩阵再讨论复用比例">先做能力矩阵，再讨论复用比例</h2>
<p>我们会把登录、返回、分享、上传、支付和文件预览列成矩阵，逐端写输入、输出、权限、超时和降级。只有语义一致的能力共享接口，纯视觉和平台特有体验允许分开。</p>
<table>
<thead>
<tr>
<th>能力</th>
<th>Web</th>
<th>App WebView</th>
<th>小程序</th>
</tr>
</thead>
<tbody>
<tr>
<td>登录</td>
<td>Cookie 会话</td>
<td>Bridge 换短期票</td>
<td>平台 code 换会话</td>
</tr>
<tr>
<td>分享</td>
<td>Web Share/复制</td>
<td>原生分享面板</td>
<td>平台分享钩子</td>
</tr>
<tr>
<td>失败</td>
<td>页面提示</td>
<td>Bridge 错误码</td>
<td>平台回调</td>
</tr>
</tbody>
</table>
<p>这张矩阵比“复用 80%”更能预测真实成本。</p>]]></description></item><item><title>WebView 登录态怎么传：不要把长期 token 交给 H5</title><link>https://siegaii.com/articles/2020-07-webview-auth-boundary/</link><guid>https://siegaii.com/articles/2020-07-webview-auth-boundary/</guid><pubDate>Sat, 18 Jul 2020 00:00:00 GMT</pubDate><description><![CDATA[<p>2020 年做 App 内 H5 页面时，为了让页面快速获得登录态，最早的方案把 token 拼进 URL：<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>?token=...</span></span></code></span>。联调很方便，安全同学一看就否决了。URL 会进入代理日志、统计系统、浏览器历史和 Referer；H5 里任何第三方脚本也可能读到它。更麻烦的是，App token 生命周期很长，一次泄漏的影响远超当前页面。</p>
<p>我们最终把认证拆成三层：原生容器持有长期身份；Bridge 只暴露受控换票能力；H5 使用短期、受众受限的会话 token。</p>
<p><img src="/diagrams/series/2020-webview-auth-boundary.svg" alt="Native、Bridge 与 Web 在登录态传递中的责任边界"></p>
<p><em>图 1：H5 不需要知道原生长期凭证，只需要得到完成当前业务所需的短期能力。</em></p>
<h2 id="bridge-返回一次性授权码">Bridge 返回一次性授权码</h2>
<p>H5 启动后调用明确版本的能力：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> AuthCodeRequest</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  capability</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "auth.issueCode"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  version</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 2</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  audience</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "content-web"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  nonce</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> AuthCodeResponse</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  code</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  expiresInSeconds</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> 30</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>原生端确认当前 WebView 来源在白名单内，再用长期凭证向服务端申请一次性 code。H5 把 code 交给自己的 BFF 换取短期 HttpOnly 会话 cookie。code 使用一次即失效，绑定 audience、设备会话和 nonce。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>H5 -> Native: auth.issueCode(audience, nonce)</span></span>
<span data-line=""><span>Native -> Auth API: issue code with app credential</span></span>
<span data-line=""><span>Auth API -> Native: one-time code (30s)</span></span>
<span data-line=""><span>Native -> H5: code</span></span>
<span data-line=""><span>H5 -> Web BFF: exchange code</span></span>
<span data-line=""><span>Web BFF -> H5: Secure + HttpOnly session cookie</span></span></code></pre></figure>
<h2 id="来源校验不能只看页面声明">来源校验不能只看页面声明</h2>
<p>H5 可以传 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>audience</span></span></code></span>，但原生不能无条件相信。Bridge 根据实际加载 URL、App 包签名、环境和能力白名单共同判断。跳转到第三方域名后，敏感能力立即不可用。</p>
<table>
<thead>
<tr>
<th>检查</th>
<th>失败结果</th>
</tr>
</thead>
<tbody>
<tr>
<td>当前 URL 不在可信 origin</td>
<td>拒绝调用，不返回凭证</td>
</tr>
<tr>
<td>Bridge 版本不支持</td>
<td>返回稳定错误码，H5 提示升级 App</td>
</tr>
<tr>
<td>nonce 已使用</td>
<td>拒绝重放</td>
</tr>
<tr>
<td>App 会话已失效</td>
<td>引导原生重新登录</td>
</tr>
<tr>
<td>H5 BFF audience 不匹配</td>
<td>换票失败并审计</td>
</tr>
</tbody>
</table>
<p>仅靠 JavaScript 隐藏方法名没有安全意义。决定是否执行的策略必须在原生和服务端。</p>
<h2 id="过期与刷新由谁负责">过期与刷新由谁负责</h2>
<p>H5 会话短，必须明确恢复路径。接口返回 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>SESSION_EXPIRED</span></span></code></span> 后，页面只允许一次静默换票；多个并发请求共享同一个刷新 Promise，避免同时弹出登录或创建多份会话。换票失败则让原生接管登录，不在 H5 内保存长期凭证兜底。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">let</span><span style="color:#E6EDF3"> refreshing</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">void</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">|</span><span style="color:#79C0FF"> null</span><span style="color:#FF7B72"> =</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> refreshSessionOnce</span><span style="color:#E6EDF3">() {</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">refreshing) {</span></span>
<span data-line=""><span style="color:#E6EDF3">    refreshing </span><span style="color:#FF7B72">=</span><span style="color:#D2A8FF"> exchangeNativeCode</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">finally</span><span style="color:#E6EDF3">(() </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> { refreshing </span><span style="color:#FF7B72">=</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">; });</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> refreshing;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<h2 id="调试日志必须默认看不到凭证">调试日志必须默认看不到凭证</h2>
<p>我们曾在 Bridge 日志里直接打印完整参数，测试包很方便，线上却留下泄漏风险。后来日志只记录 capability、版本、来源域、结果码和 requestId；token、code、cookie 和用户数据在日志层统一脱敏。</p>
<h2 id="多端一致性靠协议不靠各写一套">多端一致性靠协议，不靠各写一套</h2>
<p>iOS 和 Android 对 Bridge 回调、超时、页面销毁的处理不同。协议中因此增加 requestId、超时语义和重复回调保护。H5 只消费统一 envelope：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> BridgeResult</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">requestId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">ok</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> true</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">data</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> T</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">requestId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">ok</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> false</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">code</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">message</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span></code></pre></figure>
<p>这次设计让我意识到，跨端登录不是“把 token 传过去”这么简单。身份、能力和会话是不同层次。把长期凭证留在最能保护它的地方，让 H5 只获得短期且可撤销的能力，边界才经得住日志、跳转和第三方脚本这些真实环境。</p>]]></description></item><item><title>规范的价值，是减少无意义的决定</title><link>https://siegaii.com/articles/2020-05-engineering-conventions/</link><guid>https://siegaii.com/articles/2020-05-engineering-conventions/</guid><pubDate>Sat, 23 May 2020 00:00:00 GMT</pubDate><description><![CDATA[<p>团队扩大后，同一个项目会出现多种命名、格式和提交习惯。每次评审都花时间讨论分号、目录和变量顺序，真正影响行为的逻辑反而被淹没。</p>
<p>规范常被误解为审美统一。它更实际的价值，是把低价值决定自动化，让协作结果变得可预测。</p>
<h2 id="能自动检查的不要靠提醒">能自动检查的不要靠提醒</h2>
<p>文档里的规范很快会被遗忘。格式交给 Prettier，语法与常见错误交给 ESLint，提交前通过脚本检查。工具给出的反馈一致，也避免评审者扮演格式警察。</p>
<p>但规则不能无限增加。每一条规则都应该回答它在防止什么错误，或者减少什么协作成本。无法说明价值的偏好，不值得阻断提交。</p>
<p>我倾向把反馈按成本分层，越便宜的检查越早执行：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>编辑器：格式、基础类型提示</span></span>
<span data-line=""><span>提交前：只检查本次变更文件</span></span>
<span data-line=""><span>拉取请求：完整类型、单元测试、依赖与构建检查</span></span>
<span data-line=""><span>主分支：集成测试、制品生成</span></span>
<span data-line=""><span>发布前：环境与迁移检查</span></span></code></pre></figure>
<p>把所有检查都塞进 pre-commit，会让一次提交等待几分钟，开发者最终会习惯 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>--no-verify</span></span></code></span>。把所有问题都留给 CI，又把最便宜的反馈推迟到十分钟以后。规则是否正确是一件事，反馈出现的位置同样决定它能否被长期遵守。</p>
<h2 id="提交记录也是产品界面">提交记录也是产品界面</h2>
<p>清晰的提交信息让后来的人理解变化意图，也让回滚与发布记录更可靠。一次提交最好完成一个可以说明的变化，避免把格式化、重构和功能修改混在一起。</p>
<p>我会用“能否独立回滚”检查提交边界。若数据库迁移、服务端兼容字段和前端消费必须按顺序发布，可以分成三个有依赖说明的提交；如果把全仓格式化混进去，任何一步都难以审查和回滚。</p>
<p>提交信息也不复述文件名，而是说明行为：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>fix(search): ignore responses from superseded queries</span></span>
<span data-line=""> </span>
<span data-line=""><span>Users can submit a second query before the first request resolves.</span></span>
<span data-line=""><span>The previous response must not replace results for the current query.</span></span></code></pre></figure>
<p>两行正文保存了代码看不出的时间约束。半年后定位竞态问题时，它比“fix bug”有价值得多。</p>
<p>规范真正生效，需要默认路径足够轻。脚手架提供一致目录，CI 自动验证，新成员不必背诵整本手册。好的规范像道路标线：多数时候不被注意，却让所有人更快到达。</p>
<h2 id="规则也需要生命周期">规则也需要生命周期</h2>
<p>项目升级后，过去用于兼容旧环境的规则可能不再成立。规范如果只增不减，检查会越来越慢，例外注释也会越来越多。每条特殊规则应记录原因和适用范围，工具升级时顺便重新评估。</p>
<p>团队可以通过少量真实样本验证规则是否减少缺陷，而不是用规则数量证明工程化程度。当某条检查长期只产生误报，开发者会开始忽略整个反馈系统。保持信号可信，比覆盖所有可能问题更重要。</p>
<p>工程化不是堆工具，而是识别重复摩擦并系统性消除。团队时间有限，应该花在业务判断与系统设计上，而不是反复做同一种小决定。</p>
<p><img src="/diagrams/series/legacy-convention-system.svg" alt="工程规范从真实证据、自动执行到复审删除的生命周期"></p>
<p><em>图：规范既要有产生原因，也要有退出条件。</em></p>
<h2 id="每条规范都要有删除条件">每条规范都要有删除条件</h2>
<p>规范旁边记录它阻止过什么问题、自动化检查在哪里、负责人是谁、什么条件下可以移除。没有真实失败案例又无法自动检查的规则，优先降级为建议，而不是继续增加评审争论。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>规则：禁止页面直接调用生产域名</span></span>
<span data-line=""><span>原因：环境切换曾导致测试请求进入生产</span></span>
<span data-line=""><span>检查：eslint no-direct-production-url</span></span>
<span data-line=""><span>移除条件：所有网络调用统一经过生成客户端</span></span></code></pre></figure>
<p>规范的目标是减少决定，不是永久保存组织历史。</p>]]></description></item><item><title>缓存同时过期之后：一次 SSR 回源风暴的处理</title><link>https://siegaii.com/articles/2020-03-cache-stampede/</link><guid>https://siegaii.com/articles/2020-03-cache-stampede/</guid><pubDate>Sat, 28 Mar 2020 00:00:00 GMT</pubDate><description><![CDATA[<p>2020 年做内容页 SSR 时，我们给页面数据加了 60 秒缓存。平均响应时间立刻下降，压测曲线却每隔一分钟出现一次整齐尖峰：缓存到期的瞬间，几十个并发请求都发现 miss，同时查询接口、拼装页面并写缓存。缓存本来为了保护上游，却把均匀流量压成周期性风暴。</p>
<p>这类问题后来我习惯叫“缓存击穿”或 stampede。关键不是把 TTL 调长，而是规定缓存过期时谁负责刷新，其他请求得到什么。</p>
<p><img src="/diagrams/series/2020-cache-stampede.svg" alt="缓存过期后一个请求回源，其他请求复用旧值的时序"></p>
<p><em>图 1：数据过期不等于立刻不可用；短时间返回 stale 值，能把刷新成本集中到一个请求。</em></p>
<h2 id="把缓存状态从有无变成三段">把缓存状态从有/无变成三段</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> CacheEntry</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  value</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> T</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  freshUntil</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  staleUntil</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> CacheResult</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">T</span><span style="color:#E6EDF3">> </span><span style="color:#FF7B72">=</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">state</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "fresh"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">value</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> T</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">state</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "stale"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">value</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> T</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">state</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "miss"</span><span style="color:#E6EDF3"> };</span></span></code></pre></figure>
<p>在 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>freshUntil</span></span></code></span> 之前直接返回；进入 stale 窗口后仍可返回旧值，同时后台刷新；超过 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>staleUntil</span></span></code></span> 才是真正 miss。对新闻内容页，晚几十秒通常比所有用户一起等待更可接受，但库存和权限数据不能照搬这套策略。</p>
<h2 id="单进程先做-single-flight">单进程先做 single flight</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> refreshes</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Map</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">PageData</span><span style="color:#E6EDF3">>>();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> refreshOnce</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">key</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> existing</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> refreshes.</span><span style="color:#D2A8FF">get</span><span style="color:#E6EDF3">(key);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (existing) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3"> existing;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> promise</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> loadAndCache</span><span style="color:#E6EDF3">(key).</span><span style="color:#D2A8FF">finally</span><span style="color:#E6EDF3">(() </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> refreshes.</span><span style="color:#D2A8FF">delete</span><span style="color:#E6EDF3">(key));</span></span>
<span data-line=""><span style="color:#E6EDF3">  refreshes.</span><span style="color:#D2A8FF">set</span><span style="color:#E6EDF3">(key, promise);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> promise;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> getPage</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">key</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Promise</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">PageData</span><span style="color:#E6EDF3">> {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> cached</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#E6EDF3"> cache.</span><span style="color:#D2A8FF">read</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#FFA657">PageData</span><span style="color:#E6EDF3">>(key);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (cached.state </span><span style="color:#FF7B72">===</span><span style="color:#A5D6FF"> "fresh"</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3"> cached.value;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (cached.state </span><span style="color:#FF7B72">===</span><span style="color:#A5D6FF"> "stale"</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">    void</span><span style="color:#D2A8FF"> refreshOnce</span><span style="color:#E6EDF3">(key).</span><span style="color:#D2A8FF">catch</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">error</span><span style="color:#FF7B72"> =></span><span style="color:#E6EDF3"> logger.</span><span style="color:#D2A8FF">warn</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"refresh_failed"</span><span style="color:#E6EDF3">, { key, error }));</span></span>
<span data-line=""><span style="color:#FF7B72">    return</span><span style="color:#E6EDF3"> cached.value;</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#D2A8FF"> refreshOnce</span><span style="color:#E6EDF3">(key);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>这只能合并同一个 Node.js 进程里的请求。多实例部署需要共享锁、CDN 层请求合并，或者让缓存刷新成为独立任务。代码示例如果不写适用范围，很容易制造“已经全局解决”的错觉。</p>
<h2 id="分布式锁要考虑持有者死掉">分布式锁要考虑持有者死掉</h2>
<p>共享锁至少需要唯一 token 和租约时间。释放时只能删除自己持有的锁，不能简单 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>DEL key</span></span></code></span>：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>SET refresh:page:42 &#x3C;token> NX PX 10000</span></span></code></pre></figure>
<p>如果刷新耗时可能超过租约，要么续租，要么让写缓存带版本比较，防止旧刷新覆盖新结果。锁本身故障时系统也要有选择：返回 stale、有限等待，还是降级为直接回源。不能让缓存锁成为比上游更脆弱的单点。</p>
<h2 id="ttl-加随机抖动避免批量同刻失效">TTL 加随机抖动，避免批量同刻失效</h2>
<p>一次发布可能同时写入大量缓存，如果 TTL 都是 3600 秒，它们会在一小时后集体过期。我们把非关键缓存加入正负抖动：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> ttlWithJitter</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">baseSeconds</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> ratio</span><span style="color:#FF7B72"> =</span><span style="color:#79C0FF"> 0.15</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">round</span><span style="color:#E6EDF3">(baseSeconds </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> (</span><span style="color:#79C0FF">1</span><span style="color:#FF7B72"> -</span><span style="color:#E6EDF3"> ratio </span><span style="color:#FF7B72">+</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">random</span><span style="color:#E6EDF3">() </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> ratio </span><span style="color:#FF7B72">*</span><span style="color:#79C0FF"> 2</span><span style="color:#E6EDF3">));</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>抖动只负责摊开时间，不能替代 single flight。热门 key 即使单独过期，仍可能被大量并发击穿。</p>
<h2 id="失败时保留什么由业务价值决定">失败时保留什么，由业务价值决定</h2>
<table>
<thead>
<tr>
<th>页面</th>
<th>stale 允许时间</th>
<th>刷新失败策略</th>
</tr>
</thead>
<tbody>
<tr>
<td>公开文章</td>
<td>10 分钟</td>
<td>返回旧内容并记录年龄</td>
</tr>
<tr>
<td>首页推荐</td>
<td>2 分钟</td>
<td>旧列表 + 隐藏实时标识</td>
</tr>
<tr>
<td>用户权限</td>
<td>0</td>
<td>不使用 stale，明确失败</td>
</tr>
<tr>
<td>实时库存</td>
<td>很短或 0</td>
<td>回源或进入保护性降级</td>
</tr>
</tbody>
</table>
<p>我们最终监控 fresh/stale/miss 比例、刷新耗时、锁等待、上游 QPS 和 stale 年龄。只看命中率会掩盖一个问题：命中很多旧数据也可能让用户看到错误结果。</p>
<p>这次事故让我理解，缓存不是一个存取 API，而是数据新鲜度、并发控制和故障策略的组合。TTL 只回答“多久以后重新考虑”，真正的系统设计发生在过期那一刻。</p>]]></description></item><item><title>离开熟悉业务之前，先带走方法</title><link>https://siegaii.com/articles/2020-02-leaving-comfort-zone/</link><guid>https://siegaii.com/articles/2020-02-leaving-comfort-zone/</guid><pubDate>Fri, 14 Feb 2020 00:00:00 GMT</pubDate><description><![CDATA[<p>在一个业务里工作久了，会形成很多局部优势：知道历史接口的例外，知道哪段代码不能轻易动，也知道谁能最快回答问题。这些经验让交付更快，却不一定能被带到下一个环境。</p>
<p>离开熟悉的监控与可视化业务前，我尝试区分两类知识：一类属于具体系统，另一类属于解决系统问题的方法。</p>
<h2 id="结果之外还要记录约束">结果之外还要记录约束</h2>
<p>复盘如果只写“完成了组件化”“优化了性能”，很难在未来复用。我更关心当时有哪些约束、有哪些候选方案、为什么放弃其中一些，以及什么证据说明选择有效。</p>
<p>例如一次图表性能优化，真正可迁移的不是某个参数，而是先测量渲染、数据转换和网络分别占用多少时间，再针对主要瓶颈行动。这套顺序可以用于完全不同的项目。</p>
<p>那次排查我留下的记录很简单，却比“优化 ECharts”更有用：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>现象：切换到 24 小时范围后，页面约两秒不能操作</span></span>
<span data-line=""><span>假设 A：接口返回慢</span></span>
<span data-line=""><span>假设 B：数据转换阻塞主线程</span></span>
<span data-line=""><span>假设 C：图表节点过多</span></span>
<span data-line=""> </span>
<span data-line=""><span>证据：</span></span>
<span data-line=""><span>- 请求约 180ms</span></span>
<span data-line=""><span>- 数据转换约 90ms</span></span>
<span data-line=""><span>- setOption 到 finished 事件约 1.4s</span></span>
<span data-line=""> </span>
<span data-line=""><span>决定：先降低同屏点数，并保留缩放后的原始查询入口</span></span>
<span data-line=""><span>未做：重写数据层；当前证据不支持</span></span></code></pre></figure>
<p>这份记录没有绑定具体框架。换成另一个可视化库，仍然可以从同样的时间切片开始。它还保留了“为什么没重写”的理由，避免后来的人把克制误解为遗漏。</p>
<h2 id="不把熟练误认为能力">不把熟练误认为能力</h2>
<p>对旧系统的熟练有时会制造错觉：问题刚出现就知道答案。但换一个技术栈或团队后，熟练不再存在，提问、阅读和验证的能力才显现出来。</p>
<p>我希望进入更接近互联网用户的产品，面对更大的流量、更快的迭代和更复杂的协作。变化意味着重新变慢，也意味着检验自己究竟学会了什么。</p>
<h2 id="交接也是一次理解测试">交接也是一次理解测试</h2>
<p>准备交接时，最能暴露哪些知识只存在于个人脑中。若一个模块必须依赖口头提醒才能运行，说明它还没有真正成为团队资产。我把常见故障、环境差异和关键数据流补进文档，也清理只有自己理解的脚本入口。</p>
<p>交接时我按四层组织材料：</p>
<ol>
<li>一张系统地图：入口、主要数据源和外部依赖。</li>
<li>三条关键路径：正常操作从哪里开始，经过哪些状态。</li>
<li>一份故障索引：现象对应先检查的日志、接口或配置。</li>
<li>一组未完成决定：为什么推迟，什么条件出现时要处理。</li>
</ol>
<p>真正难写的是第四项。代码里的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>TODO</span></span></code></span> 只说明“没做”，无法说明“为什么现在不做”。把触发条件写出来，技术债才不会变成下一位同事的考古工作。</p>
<p>交接文档不需要解释每一行代码，而要帮助接手者建立地图：系统解决什么问题，主要边界在哪里，哪几处风险需要先关注，遇到异常从哪里开始看。能把这些讲清楚，也是在检验自己是否真的理解系统。</p>
<p>项目会结束，技术会替换。能够带走的，是看待约束的方式，以及在信息不完整时仍能推进问题的能力。</p>
<p><img src="/diagrams/series/legacy-method-handover.svg" alt="项目交接从系统地图、关键决定到接手人演练的流程"></p>
<p><em>图：经验只有能被后来的人独立使用，才真正离开个人记忆。</em></p>
<h2 id="离开项目前我会带走四份资产">离开项目前我会带走四份资产</h2>
<p>我现在做交接，不只列仓库和联系人，而是留下系统地图、关键决策与反对意见、最近三类事故及恢复方式、未来三个月最可能出问题的边界。每份都指向实际仪表盘、Runbook 或 ADR。</p>
<p>这些材料既帮助接手者，也检验我是否真的理解系统。只能靠“有事问我”维持的知识，不算完成交接；能被后来的人独立使用，经验才从个人记忆变成团队资产。</p>]]></description></item><item><title>一块大屏发出 47 个请求之后：给查询和渲染设预算</title><link>https://siegaii.com/articles/2019-12-dashboard-query-budget/</link><guid>https://siegaii.com/articles/2019-12-dashboard-query-budget/</guid><pubDate>Sat, 14 Dec 2019 00:00:00 GMT</pubDate><description><![CDATA[<p>2019 年底，一块监控大屏从最初 8 个组件增长到 31 个。每个组件挂载后自己请求数据，切换时间范围时同时刷新。一次打开页面会发出 47 个请求，浏览器连接排队，后端聚合接口 CPU 抬升，最关键的告警数字反而最后出现。</p>
<p>团队最初的优化是给每个图加 loading，再把某些请求延迟几百毫秒。这只让拥堵错开一点，没有回答哪些数据应该先到、同一份查询为什么执行多次、页面最多允许消耗多少资源。</p>
<p><img src="/diagrams/series/2019-dashboard-query-budget.svg" alt="大屏从首屏关键指标、查询合并到延迟加载的预算流程"></p>
<p><em>图 1：性能预算不是一个总分，而是决定关键资源先得到服务的分配规则。</em></p>
<h2 id="先把组件请求改成查询描述">先把组件请求改成查询描述</h2>
<p>原来每个组件直接调用接口，调度层看不到它们是否相同。我们把查询变成稳定结构：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> QuerySpec</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  metric</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  dimensions</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#FFA657">  filters</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Record</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">string</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#FFA657">  range</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">from</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">to</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  interval</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "1m"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "5m"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "1h"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> queryKey</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">spec</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> QuerySpec</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#D2A8FF"> stableStringify</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#FF7B72">    ...</span><span style="color:#E6EDF3">spec,</span></span>
<span data-line=""><span style="color:#E6EDF3">    dimensions: [</span><span style="color:#FF7B72">...</span><span style="color:#E6EDF3">spec.dimensions].</span><span style="color:#D2A8FF">sort</span><span style="color:#E6EDF3">(),</span></span>
<span data-line=""><span style="color:#E6EDF3">    filters: </span><span style="color:#D2A8FF">sortObject</span><span style="color:#E6EDF3">(spec.filters),</span></span>
<span data-line=""><span style="color:#E6EDF3">  });</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>两个图表查询相同 metric、范围和筛选时，只发一次请求，各自从结果选择展示字段。稳定 key 还用于短期缓存和取消过期请求。</p>
<h2 id="预算按用户价值分层">预算按用户价值分层</h2>
<p>不是每个图都属于首屏。我们和产品一起把组件分成三层：</p>
<table>
<thead>
<tr>
<th>优先级</th>
<th>内容</th>
<th>目标</th>
<th>策略</th>
</tr>
</thead>
<tbody>
<tr>
<td>P0</td>
<td>当前告警数、核心健康状态</td>
<td>2 秒内可读</td>
<td>首批请求，失败明确展示</td>
</tr>
<tr>
<td>P1</td>
<td>主要趋势与分组</td>
<td>4 秒内完成</td>
<td>P0 后调度，可复用聚合结果</td>
</tr>
<tr>
<td>P2</td>
<td>长尾明细与辅助图</td>
<td>进入视口后加载</td>
<td>可取消、可降低精度</td>
</tr>
</tbody>
</table>
<p>总请求并发限制为浏览器和服务端都能承受的数值，而不是组件数量。P0 排队时可以抢占尚未开始的 P2，已经过期的时间范围请求直接取消结果消费。</p>
<h2 id="数据量预算要落到每个查询">数据量预算要落到每个查询</h2>
<p>十万点折线图即使接口很快，解析和绘制也会卡主线程。查询提交前估算点数：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> estimatedPoints</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">spec</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> QuerySpec</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> intervalMs</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> { </span><span style="color:#A5D6FF">"1m"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">60_000</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">"5m"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">300_000</span><span style="color:#E6EDF3">, </span><span style="color:#A5D6FF">"1h"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">3_600_000</span><span style="color:#E6EDF3"> }[spec.interval];</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> buckets</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">ceil</span><span style="color:#E6EDF3">((spec.range.to </span><span style="color:#FF7B72">-</span><span style="color:#E6EDF3"> spec.range.from) </span><span style="color:#FF7B72">/</span><span style="color:#E6EDF3"> intervalMs);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> buckets </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> Math.</span><span style="color:#D2A8FF">max</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">1</span><span style="color:#E6EDF3">, </span><span style="color:#D2A8FF">expectedSeries</span><span style="color:#E6EDF3">(spec));</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>超过预算时，页面不是默默截断，而是提高聚合粒度、限制系列数量，或要求用户缩小范围。图表旁会标明“已按 5 分钟聚合”，避免用户把降采样结果当原始明细。</p>
<h2 id="刷新频率不能由组件各自决定">刷新频率不能由组件各自决定</h2>
<p>过去每个组件都有 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>setInterval</span></span></code></span>，切到后台标签页仍然刷新，恢复前台时又一起发请求。我们改成页面级时钟：只在可见时运行；同一刷新周期合并查询；上一次还没完成就不叠加；连续失败采用退避。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> refreshPolicy</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">  visibleIntervalMs: </span><span style="color:#79C0FF">30_000</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  hidden: </span><span style="color:#A5D6FF">"pause"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  maxConcurrent: </span><span style="color:#79C0FF">6</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  failureBackoff: [</span><span style="color:#79C0FF">30_000</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">60_000</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">120_000</span><span style="color:#E6EDF3">],</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<h2 id="每个超预算项都要能找到负责人">每个超预算项都要能找到负责人</h2>
<p>我们在开发环境显示查询面板，记录 queryKey、调用组件、排队时间、服务端时间、响应字节数、点数和是否命中复用。新增组件如果让 P0 完成时间超过预算，评审不能只说“我的图只多一个请求”。</p>
<p>上线后的指标也按页面版本观察：P0 可读时间、取消请求数、查询复用率、服务端 P95 和主线程长任务。优化后最明显的不是总请求数变成一个漂亮数字，而是核心状态稳定地先出现，时间范围切换不再把页面拖死。</p>
<p>这块大屏让我意识到，组件化容易分散责任。每个组件局部都“合理”，合在一起却可能超出系统容量。预算的价值，是让每一份查询和每一次渲染都说明自己为什么值得占用这部分资源。</p>]]></description></item><item><title>监控界面如何处理信息密度</title><link>https://siegaii.com/articles/2019-10-information-density/</link><guid>https://siegaii.com/articles/2019-10-information-density/</guid><pubDate>Sun, 27 Oct 2019 00:00:00 GMT</pubDate><description><![CDATA[<p>监控系统天然信息密集：设备、时间、位置、指标、告警都想进入同一屏。常见做法是把每组信息放进卡片，再通过颜色区分优先级。卡片越来越多，页面反而失去整体结构。</p>
<p>信息密度不是单位面积内文字的数量，而是用户完成判断所需的信息与视觉成本之比。删掉关键上下文的“简洁”，可能让用户打开更多页面才能完成一次确认。</p>
<h2 id="为扫描设计层级">为扫描设计层级</h2>
<p>操作人员通常不会逐字阅读，而是先找异常，再确认范围，最后查看细节。界面应当支持这条视线顺序：稳定的对齐帮助纵向比较，有限的颜色突出异常，固定位置让重复操作形成记忆。</p>
<p>我更倾向使用表格、分组标题和细分隔线，而不是让每个区域悬浮成独立卡片。卡片强调独立性，监控数据却常常需要横向比较。</p>
<p>一次设备列表改版前，首屏有 12 张卡片。每张卡片都重复设备名、位置、在线状态和三个指标，操作人员要在二维空间里寻找异常。改成表格后，我们没有减少字段，而是重新分配层级：</p>
<table>
<thead>
<tr>
<th>层级</th>
<th>信息</th>
<th>展示方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>第一眼</td>
<td>告警级别、设备名、在线状态</td>
<td>固定列，位置稳定</td>
</tr>
<tr>
<td>快速比较</td>
<td>核心指标、更新时间</td>
<td>数字右对齐，统一单位</td>
</tr>
<tr>
<td>进一步判断</td>
<td>趋势、告警原因</td>
<td>行内展开，不改变主表列宽</td>
</tr>
<tr>
<td>偶尔使用</td>
<td>设备配置、历史记录</td>
<td>详情页</td>
</tr>
</tbody>
</table>
<p>数字右对齐是一个很小但很有效的决定。<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>9.8</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>12.1</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>103.4</span></span></code></span> 的量级差异可以沿小数位快速扫描；把数字居中放在卡片里，用户只能逐个阅读。</p>
<h2 id="颜色必须稀缺">颜色必须稀缺</h2>
<p>如果设备状态、业务类型、按钮和图表系列都使用鲜艳颜色，真正的异常便无法脱颖而出。中性界面不是审美偏好，而是为告警预留表达空间。</p>
<p>同时不能只靠颜色传递状态。文字、形状和位置需要提供冗余线索，才能适应不同屏幕和使用者。</p>
<h2 id="为重复工作保留空间记忆">为重复工作保留空间记忆</h2>
<p>操作工具与一次性展示不同。用户每天会查看相同指标、执行相同筛选，稳定位置能让他们形成肌肉记忆。随数据多少自动改变布局，看起来充分利用空间，却会让视线每次重新寻找。</p>
<p>我们可以允许用户保存筛选和列设置，但核心结构应保持一致。新增信息优先进入可展开详情，而不是不断挤压主表。信息架构要区分“每次判断必需”和“偶尔排查需要”，让高频任务始终拥有最短路径。</p>
<p>密度是否合适，最终要用任务验证，而不是看截图。我们会让熟悉业务的人完成三个动作：找到当前最高级告警、比较两台设备同一指标、确认某条数据最后更新时间。记录完成时间和误读位置，比询问“喜欢新版还是旧版”更能发现问题。</p>
<p>如果用户频繁横向滚动找固定字段，列顺序有问题；如果总要打开详情确认单位，表头信息不完整；如果所有人第一步都是清除默认筛选，默认值就不代表真实工作。</p>
<p>专业工具不必追求“看起来简单”，而要让复杂工作变得可控。密集界面的成熟感，来自秩序，而不是装饰。</p>
<p><img src="/diagrams/series/legacy-information-density.svg" alt="监控界面从发现异常到验证恢复的信息行动流程"></p>
<p><em>图：每层信息只服务当前判断，密度才不会变成噪声。</em></p>
<h2 id="用真实值班任务检验信息密度">用真实值班任务检验信息密度</h2>
<p>我后来不再问“大屏是不是太挤”，而是给操作者三个任务：30 秒内找到异常服务；判断是流量变化还是错误变化；打开一条可执行处置路径。记录完成时间、误点和需要口头解释的地方。</p>
<p>能帮助任务的信息可以密，不能改变判断的装饰要删。密度不是单位面积放多少图，而是用户每移动一次视线能获得多少与当前决定有关的证据。</p>]]></description></item><item><title>前端也需要可观测性</title><link>https://siegaii.com/articles/2019-06-frontend-observability/</link><guid>https://siegaii.com/articles/2019-06-frontend-observability/</guid><pubDate>Sat, 15 Jun 2019 00:00:00 GMT</pubDate><description><![CDATA[<p>前端问题常被描述成“偶尔白屏”“有时点不动”。在开发环境里一切正常，日志也只存在用户的浏览器中。没有现场信息时，排查只能依赖猜测和反复询问。</p>
<p>服务端早已习惯记录请求与错误，前端同样需要建立可观测性。不同之处在于，浏览器环境更碎片化，采集本身也不能成为新的性能负担。</p>
<h2 id="记录能帮助行动的信息">记录能帮助行动的信息</h2>
<p>一个错误栈如果没有版本、路由、用户操作和接口状态，价值非常有限。我会为每次发布保留构建标识，把未捕获异常、资源加载失败和关键请求错误统一上报，并限制相同错误的频率。</p>
<p>客户端上报的最小事件大致是这样：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> FrontendErrorEvent</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  name</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  message</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  stack</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  buildId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  route</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  sessionId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  actionTrail</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Array</span><span style="color:#E6EDF3">&#x3C;{ </span><span style="color:#FFA657">name</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">at</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> }>;</span></span>
<span data-line=""><span style="color:#FFA657">  request</span><span style="color:#FF7B72">?:</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">traceId</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">urlPattern</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#FFA657">  occurredAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>这里故意保存 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>urlPattern</span></span></code></span>，而不是完整 URL；操作轨迹只记录“点击提交”“切换标签”这类事件名，不记录输入内容。排障需要上下文，不等于可以无限采集用户数据。</p>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>buildId</span></span></code></span> 解决了另一个常见问题。静态资源发布后，用户可能仍开着旧页面。没有版本信息时，同一条堆栈到底属于哪份源码都无法确认，Source Map 也就失去意义。</p>
<p>并非数据越多越好。输入内容、身份信息和业务数据不应被随意采集。可观测性首先是工程能力，也必须有明确的隐私边界。</p>
<h2 id="从错误数量走向用户影响">从错误数量走向用户影响</h2>
<p>错误发生一百次，可能来自一个用户的循环刷新；只发生一次，也可能阻断关键流程。监控应该同时关注影响用户数、页面路径和功能阶段，而不是只看总量。</p>
<p>性能也一样。平均加载时间会掩盖长尾，真实用户环境比实验室分数更能说明问题。先建立稳定基线，优化才有方向。</p>
<table>
<thead>
<tr>
<th>层级</th>
<th>示例</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td>用户结果</td>
<td>查询成功率、提交完成率</td>
<td>判断功能是否真的可用</td>
</tr>
<tr>
<td>页面体验</td>
<td>LCP、INP、首屏接口耗时</td>
<td>定位等待发生在哪一段</td>
</tr>
<tr>
<td>工程诊断</td>
<td>JS 错误、资源失败、接口错误</td>
<td>找到具体版本和调用链</td>
</tr>
</tbody>
</table>
<p>只监控第三层，团队会得到很多红色数字，却不知道用户是否受影响。只看第一层，又无法定位原因。三层通过 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>sessionId</span></span></code></span> 和后端 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>traceId</span></span></code></span> 关联，才形成从用户结果到具体请求的证据链。</p>
<h2 id="采样与去重保护系统">采样与去重保护系统</h2>
<p>监控本身也可能制造流量。相同错误在循环中每秒出现数百次，如果全部上报，会挤占网络并淹没真正变化。客户端先按错误指纹去重，对高频事件采样，并在单次会话设置总量上限。</p>
<p>错误指纹不能只使用完整消息，因为动态参数会让同一问题变成无数条记录。更稳定的做法是组合错误类型、规范化堆栈和构建版本。服务端再聚合影响用户与页面，既保留趋势，也能回到具体样本。</p>
<p>可观测性的意义，是把“我觉得没问题”变成“我们知道哪里出了问题”。一旦系统能够诚实报告自身状态，团队才可能持续改善它。</p>
<p><img src="/diagrams/series/legacy-observability-evidence.svg" alt="浏览器动作、服务端请求和错误平台汇合的故障证据时序"></p>
<p><em>图：版本、动作与 requestId 关联后，错误才从堆栈变成可调查事件。</em></p>
<h2 id="一条前端错误事件的最小合同">一条前端错误事件的最小合同</h2>
<p>我们最后固定保留 releaseId、routeTemplate、errorFingerprint、requestId、最近业务动作摘要和采样原因；用户输入、token 与完整响应默认不采集。事件超过大小上限就丢弃明细而不是阻塞页面。</p>
<p>告警验收也写成一句话：值班人能否从事件跳到对应版本、服务端请求和用户动作，并在十分钟内确定影响面。做不到时，增加更多日志通常不如补齐这几条关联字段。</p>]]></description></item><item><title>只有错误堆栈还不够：给前端异常补一条用户动作时间线</title><link>https://siegaii.com/articles/2019-05-error-breadcrumbs/</link><guid>https://siegaii.com/articles/2019-05-error-breadcrumbs/</guid><pubDate>Sat, 11 May 2019 00:00:00 GMT</pubDate><description><![CDATA[<p>有一段时间，线上偶尔出现 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Cannot read property 'map' of undefined</span></span></code></span>。堆栈指向图表转换函数，本地却怎么也复现不了。错误平台里有浏览器、URL 和构建版本，但没有用户在出错前切了哪个筛选、哪个接口先返回。我们只能在群里问用户“刚才做了什么”，大多数人已经记不清。</p>
<p>我开始给错误加 Breadcrumb，也就是异常前的一小段有界事件。它不是录屏，更不是把所有日志上传，而是保留能够解释状态转移的最低信息。</p>
<p><img src="/diagrams/series/2019-error-breadcrumbs.svg" alt="用户动作、前端状态、请求与错误被合并成可诊断的 Breadcrumb 时序"></p>
<p><em>图 1：错误堆栈回答代码停在哪里，Breadcrumb 回答程序怎样走到这里。</em></p>
<h2 id="先定义哪些事件值得留下">先定义哪些事件值得留下</h2>
<p>我们只记录四类：路由变化；用户触发的业务动作；关键请求开始/结束；应用状态从一个可命名阶段转到另一个阶段。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> Breadcrumb</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  at</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  category</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "navigation"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "action"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "http"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "state"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  name</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  data</span><span style="color:#FF7B72">?:</span><span style="color:#FFA657"> Record</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">string</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> number</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> boolean</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> breadcrumbs</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Breadcrumb</span><span style="color:#E6EDF3">[] </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> [];</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> addBreadcrumb</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">event</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Breadcrumb</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#E6EDF3">  breadcrumbs.</span><span style="color:#D2A8FF">push</span><span style="color:#E6EDF3">(</span><span style="color:#D2A8FF">redact</span><span style="color:#E6EDF3">(event));</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (breadcrumbs.</span><span style="color:#79C0FF">length</span><span style="color:#FF7B72"> ></span><span style="color:#79C0FF"> 40</span><span style="color:#E6EDF3">) breadcrumbs.</span><span style="color:#D2A8FF">shift</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>只保留最近 40 条，是为了让成本和含义都有限。无限日志会增加内存，真正发生错误时也难以阅读。</p>
<h2 id="记录业务摘要不记录用户输入">记录业务摘要，不记录用户输入</h2>
<p>搜索关键词、手机号、token 和表单正文都不应该进入 Breadcrumb。我们记录“筛选条件数量从 2 变成 3”，而不是三个条件的原值；记录资源类型与匿名 ID，而不是客户名称。</p>
<table>
<thead>
<tr>
<th>原始信息</th>
<th>实际记录</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>keyword=张三 138...</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>action=search, keywordLength=9</span></span></code></span></td>
</tr>
<tr>
<td>完整请求 URL 与 token</td>
<td>路由模板、method、status、duration</td>
</tr>
<tr>
<td>表单所有字段</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>dirtyFields=3, validation=failed</span></span></code></span></td>
</tr>
<tr>
<td>客户 ID</td>
<td>服务端生成的不可逆诊断 ID</td>
</tr>
</tbody>
</table>
<p>脱敏应该发生在写入缓冲区之前，而不是上传前。否则第三方插件或其他错误路径仍可能读到敏感数据。</p>
<h2 id="用-requestid-把前端和服务端串起来">用 requestId 把前端和服务端串起来</h2>
<p>那次问题最终与两个筛选请求竞态有关。前端给每次请求生成 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>clientRequestId</span></span></code></span>，服务端响应自己的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>requestId</span></span></code></span>，错误事件同时保存两者：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#D2A8FF">addBreadcrumb</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">  at: Date.</span><span style="color:#D2A8FF">now</span><span style="color:#E6EDF3">(),</span></span>
<span data-line=""><span style="color:#E6EDF3">  category: </span><span style="color:#A5D6FF">"http"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  name: </span><span style="color:#A5D6FF">"chart.query.completed"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  data: {</span></span>
<span data-line=""><span style="color:#E6EDF3">    clientRequestId,</span></span>
<span data-line=""><span style="color:#E6EDF3">    requestId: response.headers.</span><span style="color:#D2A8FF">get</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"x-request-id"</span><span style="color:#E6EDF3">),</span></span>
<span data-line=""><span style="color:#E6EDF3">    status: response.status,</span></span>
<span data-line=""><span style="color:#E6EDF3">    durationMs: performance.</span><span style="color:#D2A8FF">now</span><span style="color:#E6EDF3">() </span><span style="color:#FF7B72">-</span><span style="color:#E6EDF3"> startedAt,</span></span>
<span data-line=""><span style="color:#E6EDF3">  },</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span></code></pre></figure>
<p>回看时间线时能看到：请求 A 先发出，请求 B 后发出并先返回，页面切到新筛选；随后请求 A 返回，用旧结构覆盖新状态，转换函数拿到 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>undefined</span></span></code></span>。堆栈没有告诉我们的时间关系，在 Breadcrumb 里非常清楚。</p>
<h2 id="错误聚合不能只看-message">错误聚合不能只看 message</h2>
<p>同一句 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Cannot read property</span></span></code></span> 可能来自不同模块，带动态 ID 的错误也可能被拆成几千组。我们用错误类型、规范化堆栈顶、路由模板和构建版本生成指纹，再观察影响用户数和首次出现版本。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>fingerprint = error.name</span></span>
<span data-line=""><span>            + normalizedTopFrames(3)</span></span>
<span data-line=""><span>            + routeTemplate</span></span>
<span data-line=""><span>            + releaseId</span></span></code></pre></figure>
<p>版本进入指纹不是为了永久拆分，而是帮助判断回归。平台同时提供跨版本合并视图，避免同一根因每次发布都变成新问题。</p>
<h2 id="监控-sdk-自己不能拖垮页面">监控 SDK 自己不能拖垮页面</h2>
<p>采集代码运行在用户页面里，也会失败。序列化遇到循环引用、事件过大、上报接口超时，都不能影响主流程。我们的约束是：同步写入小于 1 ms；异常吞掉但计数；上报使用批量和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>sendBeacon</span></span></code></span>；单事件与会话总量都有上限。</p>
<p>这套 Breadcrumb 没有让所有线上问题自动可解，但把“用户说点了几下就坏了”变成了一条可复盘时间线。对我影响最大的不是某个监控 SDK，而是一个原则：可观测性要记录决定系统行为的状态变化，同时克制地不记录与诊断无关的人。</p>]]></description></item><item><title>图表是一种语言，不是一种装饰</title><link>https://siegaii.com/articles/2019-02-chart-as-language/</link><guid>https://siegaii.com/articles/2019-02-chart-as-language/</guid><pubDate>Fri, 22 Feb 2019 00:00:00 GMT</pubDate><description><![CDATA[<p>监控系统里最常见的需求是“把这组数据画成图”。拿到数组，选一个 ECharts 示例，替换字段，页面很快就有了颜色和动画。但图表能显示数据，不代表它传达了信息。</p>
<p>同一组设备指标，可以强调时间趋势、设备差异、异常位置或整体分布。目标不同，编码方式也不同。折线、位置、颜色和面积不是装饰属性，而是语言中的语法。</p>
<h2 id="先写读图任务">先写读图任务</h2>
<p>配置图表前，我开始先写一句话：用户看完这张图，应该能回答什么问题。如果答案是“最近什么时候开始异常”，时间轴和阈值就比丰富的提示框更重要；如果答案是“哪台设备偏离整体”，排序和对比基线更重要。</p>
<p>这句话也能帮助删除无关信息。网格线、渐变、三维效果都可能增加视觉刺激，却降低比较精度。</p>
<p>例如“最近什么时候开始异常”这类任务，我不会把接口数组直接塞进 ECharts。中间会先形成一个和图表库无关的显示模型：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> MetricPoint</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  at</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  value</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  quality</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "reported"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "missing"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "estimated"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> TrendView</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  unit</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  normalRange</span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> [</span><span style="color:#79C0FF">number</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">number</span><span style="color:#E6EDF3">];</span></span>
<span data-line=""><span style="color:#FFA657">  points</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> MetricPoint</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> toSeries</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">view</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> TrendView</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> view.points.</span><span style="color:#D2A8FF">map</span><span style="color:#E6EDF3">((</span><span style="color:#FFA657">point</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> [</span></span>
<span data-line=""><span style="color:#E6EDF3">    point.at,</span></span>
<span data-line=""><span style="color:#E6EDF3">    point.quality </span><span style="color:#FF7B72">===</span><span style="color:#A5D6FF"> "reported"</span><span style="color:#FF7B72"> ?</span><span style="color:#E6EDF3"> point.value </span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">  ]);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>null</span></span></code></span> 是刻意的：ECharts 会留下断点，用户能看见数据缺失。如果把缺失值补成 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>0</span></span></code></span>，图上会凭空出现一次故障；如果沿用上一个值，又会制造系统持续正常的假象。</p>
<h2 id="让异常拥有上下文">让异常拥有上下文</h2>
<p>只标红异常点并不够。用户还需要知道正常范围、前后变化和数据是否完整。监控图表必须诚实表达缺失值，不能用连线制造连续运行的假象。</p>
<p>工具的配置项很多，真正需要长期维护的是数据到视觉变量的映射。把这层映射写清楚，才能在换图表库或新增指标时保持一致。</p>
<table>
<thead>
<tr>
<th>信息</th>
<th>视觉编码</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>时间</td>
<td>横向位置</td>
<td>最适合判断先后和持续时间</td>
</tr>
<tr>
<td>指标值</td>
<td>纵向位置</td>
<td>保留精确比较能力</td>
</tr>
<tr>
<td>正常范围</td>
<td>低对比背景带</td>
<td>提供上下文，不抢数据焦点</td>
</tr>
<tr>
<td>告警</td>
<td>形状 + 文本</td>
<td>不只依赖颜色，便于定位</td>
</tr>
<tr>
<td>缺失数据</td>
<td>折线断开</td>
<td>诚实表达未知</td>
</tr>
</tbody>
</table>
<p>系列颜色反而排在后面，因为它只承担区分，不承担唯一语义。超过可稳定分辨的系列数量时，应该先减少同屏比较对象，而不是继续从调色板取颜色。</p>
<h2 id="交互不能替代信息结构">交互不能替代信息结构</h2>
<p>提示框、缩放和联动可以提供细节，但用户不应该必须把鼠标移动到每个点上才能理解趋势。关键数值、单位、时间范围和异常说明应直接可见，交互只负责进一步探索。</p>
<p>图表还需要与表格或原始数据建立通道。视觉擅长发现模式，精确核对仍需要数值。为图表提供数据查看与导出，不是重复功能，而是让结论可以验证。尤其在监控场景里，可验证比视觉冲击更重要。</p>
<p>好的可视化不会要求用户欣赏图表，而是让他更快理解系统。图表越像语言，设计者越应该为歧义负责。</p>
<p><img src="/diagrams/series/legacy-chart-language.svg" alt="图表的数据语义、视觉编码和读图任务三层语法"></p>
<p><em>图：图表表达从业务问题开始，不从图表库 option 开始。</em></p>
<h2 id="我现在使用的图表评审卡">我现在使用的图表评审卡</h2>
<p>每张图上线前都要回答：读者要做什么决定；横纵轴单位和时间范围是什么；缺失、零和估算怎样区分；颜色是否已有业务语义；截断坐标轴会不会放大差异；数据口径在哪里查看。</p>
<p>如果一句话说不清“看完这张图下一步做什么”，它可能只是信息陈列。把这六个答案跟图表配置放在一起，后续换库或改样式时也不容易丢掉原始表达意图。</p>]]></description></item><item><title>图表画错了，但代码没有报错：可视化前的数据管线</title><link>https://siegaii.com/articles/2019-01-chart-data-pipeline/</link><guid>https://siegaii.com/articles/2019-01-chart-data-pipeline/</guid><pubDate>Sat, 19 Jan 2019 00:00:00 GMT</pubDate><description><![CDATA[<p>2019 年做监控产品时，我交付过一张“看起来非常正常”的折线图。接口返回、代码执行和图表渲染都没有报错，产品同学却发现某天设备没有上报，曲线反而平滑地从前一天连到了后一天，像是业务稳定增长。代码没有坏，图表达错了意思。</p>
<p>那次之后我不再把接口数组直接塞给图表库。原始记录要经过字段校验、时间与单位归一、业务聚合、缺失值策略和视觉编码。任何一步默认处理，都可能改变用户读到的结论。</p>
<p><img src="/diagrams/series/2019-chart-data-pipeline.svg" alt="原始数据经过校验、语义聚合和视觉编码后进入图表渲染"></p>
<p><em>图 1：图表配置只是最后一层，前面的数据语义决定它到底在说什么。</em></p>
<h2 id="先保留缺失和零的区别">先保留“缺失”和“零”的区别</h2>
<p>设备没有上报是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>missing</span></span></code></span>，设备上报数量为零是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>0</span></span></code></span>。如果前端用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>value || 0</span></span></code></span> 统一处理，两种事实会被抹平：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> Sample</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  timestamp</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  value</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#FF7B72"> |</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  quality</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "reported"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "missing"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "estimated"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> normalize</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">raw</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> unknown</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Sample</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> record</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> raw </span><span style="color:#FF7B72">as</span><span style="color:#FFA657"> Record</span><span style="color:#E6EDF3">&#x3C;</span><span style="color:#79C0FF">string</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">unknown</span><span style="color:#E6EDF3">>;</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> timestamp</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Date.</span><span style="color:#D2A8FF">parse</span><span style="color:#E6EDF3">(</span><span style="color:#D2A8FF">String</span><span style="color:#E6EDF3">(record.time));</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">Number.</span><span style="color:#D2A8FF">isFinite</span><span style="color:#E6EDF3">(timestamp)) </span><span style="color:#FF7B72">throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Error</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">`invalid time: ${</span><span style="color:#E6EDF3">record</span><span style="color:#A5D6FF">.</span><span style="color:#E6EDF3">time</span><span style="color:#A5D6FF">}`</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (record.value </span><span style="color:#FF7B72">===</span><span style="color:#79C0FF"> null</span><span style="color:#FF7B72"> ||</span><span style="color:#E6EDF3"> record.value </span><span style="color:#FF7B72">===</span><span style="color:#79C0FF"> undefined</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">    return</span><span style="color:#E6EDF3"> { timestamp, value: </span><span style="color:#79C0FF">null</span><span style="color:#E6EDF3">, quality: </span><span style="color:#A5D6FF">"missing"</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> value</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> Number</span><span style="color:#E6EDF3">(record.value);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">Number.</span><span style="color:#D2A8FF">isFinite</span><span style="color:#E6EDF3">(value)) </span><span style="color:#FF7B72">throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> Error</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">`invalid value: ${</span><span style="color:#E6EDF3">record</span><span style="color:#A5D6FF">.</span><span style="color:#E6EDF3">value</span><span style="color:#A5D6FF">}`</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> { timestamp, value, quality: </span><span style="color:#A5D6FF">"reported"</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>缺失点在折线中应该断开、标灰或明确标注，而不是自动补零或连线。是否插值属于业务决定，不能让图表库默认值替团队做。</p>
<h2 id="聚合口径要跟数据一起走">聚合口径要跟数据一起走</h2>
<p>同一个“日活”可能按自然日、过去 24 小时或用户时区计算。只传 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>{time, value}</span></span></code></span>，前端无法解释它。我们后来让查询结果携带口径：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> MetricSeries</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  metric</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "active_devices"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  unit</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "count"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  timezone</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "Asia/Shanghai"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  interval</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "1h"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "1d"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  aggregation</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "distinct_device"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  samples</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Sample</span><span style="color:#E6EDF3">[];</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p>Tooltip、导出文件和图表标题都从这份元数据生成，避免页面上写“日活”，接口实际返回小时峰值。</p>
<h2 id="一张图需要明确的质量门禁">一张图需要明确的质量门禁</h2>
<p>渲染前我会检查：时间是否单调；重复时间点如何合并；单位是否一致；异常值是否超出可接受范围；数据点数量是否超过前端预算。</p>
<table>
<thead>
<tr>
<th>检查</th>
<th>失败处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>时间戳无法解析</td>
<td>拒绝该记录并上报字段样例</td>
</tr>
<tr>
<td>同一时间点重复</td>
<td>按明确聚合规则合并，不能只取最后一条</td>
</tr>
<tr>
<td>单位不一致</td>
<td>在数据层统一换算，图表层不猜</td>
</tr>
<tr>
<td>缺失率超过阈值</td>
<td>图上展示数据质量提示</td>
</tr>
<tr>
<td>点数超过预算</td>
<td>服务端降采样或限制时间范围</td>
</tr>
</tbody>
</table>
<p>“尽量画出来”在监控产品里不一定友好。一张静默错误的图，比明确告诉用户数据不完整更危险。</p>
<h2 id="视觉编码也要接受评审">视觉编码也要接受评审</h2>
<p>我曾把多条趋势线自动分配成十几种相近颜色，在自己的显示器上还能区分，投影后几乎一样。后来视觉映射也进入配置和测试：关键系列颜色固定；状态颜色有稳定语义；颜色之外再用线型或标记区分；Tooltip 保留原始值和单位。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> chartSpec</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">  x: { field: </span><span style="color:#A5D6FF">"timestamp"</span><span style="color:#E6EDF3">, type: </span><span style="color:#A5D6FF">"time"</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">  y: { field: </span><span style="color:#A5D6FF">"value"</span><span style="color:#E6EDF3">, unit: series.unit, zeroBaseline: </span><span style="color:#79C0FF">false</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">  line: { connectNulls: </span><span style="color:#79C0FF">false</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">  quality: { field: </span><span style="color:#A5D6FF">"quality"</span><span style="color:#E6EDF3">, missingStyle: </span><span style="color:#A5D6FF">"gap"</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>zeroBaseline: false</span></span></code></span> 也不能无条件使用。折线图为了观察细小波动可以截断纵轴，柱状图截断基线却会夸大差异。图表类型本身携带表达规则。</p>
<h2 id="给数据管线留可检查的中间结果">给数据管线留可检查的中间结果</h2>
<p>排查图表问题时，如果只有最终 option，很难判断错误来自接口、转换还是渲染。我会在开发模式保留每一阶段摘要：原始记录数、过滤数、缺失率、聚合后点数和最终区间。生产日志只记录统计，不上传敏感明细。</p>
<p>这次“没有报错的错误”让我改变了对可视化的理解。图表不是把数值映射成像素的组件，而是一条解释数据的管线。工程师不仅要保证它能画，还要能回答每个点从哪里来、经过什么规则、为什么以这种方式出现。</p>]]></description></item><item><title>前后端联调反复返工后，我开始认真写接口契约</title><link>https://siegaii.com/articles/2018-12-api-contract-boundary/</link><guid>https://siegaii.com/articles/2018-12-api-contract-boundary/</guid><pubDate>Sat, 15 Dec 2018 00:00:00 GMT</pubDate><description><![CDATA[<p>2018 年做一个客户列表时，联调持续了将近一周。接口文档里写着 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>status: number</span></span></code></span>，前端理解成 HTTP 风格的成功状态，服务端实际返回业务枚举；分页一会儿从 0 开始，一会儿从 1 开始；空列表有时是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>[]</span></span></code></span>，有时是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>null</span></span></code></span>。双方每天都在“修一个字段”，第二天又出现新的解释差异。</p>
<p>我以前把接口文档理解成字段说明。那次以后才明白，契约还要描述请求能否重复、数据顺序是否稳定、失败是否可重试，以及新旧客户端同时存在时怎么兼容。</p>
<p><img src="/diagrams/series/2018-api-contract.svg" alt="API 契约由请求语义、响应结构、失败与兼容策略组成"></p>
<p><em>图 1：字段只是表面，真正决定系统能否协作的是双方都能验证的行为。</em></p>
<h2 id="先把模糊词换成可执行定义">先把模糊词换成可执行定义</h2>
<p>“分页正常”“失败返回错误”“字段可能为空”都无法直接测试。我们把列表接口写成更具体的约定：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="yaml" data-theme="github-dark-default"><code data-language="yaml" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#A5D6FF">GET /api/customers</span></span>
<span data-line=""><span style="color:#7EE787">query</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#7EE787">  cursor</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">string | omitted</span></span>
<span data-line=""><span style="color:#7EE787">  limit</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">integer, 1..100, default 20</span></span>
<span data-line=""><span style="color:#7EE787">response</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#7EE787">  items</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">Customer[]</span></span>
<span data-line=""><span style="color:#7EE787">  nextCursor</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">string | null</span></span>
<span data-line=""><span style="color:#7EE787">invariants</span><span style="color:#E6EDF3">:</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">items 始终是数组</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">顺序固定为 createdAt DESC, id DESC</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">nextCursor 为 null 表示没有下一页</span></span>
<span data-line=""><span style="color:#E6EDF3">  - </span><span style="color:#A5D6FF">相同 cursor 与数据快照返回相同边界</span></span></code></pre></figure>
<p>相比 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>page=1</span></span></code></span>，游标在数据持续新增时更稳定，但它也带来一个约束：排序字段必须稳定并包含唯一兜底键。这个细节应该进入契约，而不是藏在服务端实现里。</p>
<h2 id="错误码要指向恢复动作">错误码要指向恢复动作</h2>
<p>早期接口失败统一返回 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>{ code: 500, message: "error" }</span></span></code></span>。前端无法判断是让用户改输入、重新登录，还是稍后重试。我们按恢复责任划分：</p>
<table>
<thead>
<tr>
<th>HTTP</th>
<th>业务码</th>
<th>含义</th>
<th>前端动作</th>
</tr>
</thead>
<tbody>
<tr>
<td>400</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>INVALID_FILTER</span></span></code></span></td>
<td>筛选表达式非法</td>
<td>定位字段并让用户修改</td>
</tr>
<tr>
<td>401</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>SESSION_EXPIRED</span></span></code></span></td>
<td>会话失效</td>
<td>刷新凭证或重新登录</td>
</tr>
<tr>
<td>403</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>CUSTOMER_FORBIDDEN</span></span></code></span></td>
<td>无资源权限</td>
<td>不重试，展示权限说明</td>
</tr>
<tr>
<td>409</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>VERSION_CONFLICT</span></span></code></span></td>
<td>编辑版本落后</td>
<td>拉取最新数据并提示合并</td>
</tr>
<tr>
<td>429</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>RATE_LIMITED</span></span></code></span></td>
<td>超过频率</td>
<td>按 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Retry-After</span></span></code></span> 延迟</td>
</tr>
<tr>
<td>500</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>INTERNAL_ERROR</span></span></code></span></td>
<td>未知服务端错误</td>
<td>展示 requestId，有限重试</td>
</tr>
</tbody>
</table>
<p>稳定的业务码是程序分支依据，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>message</span></span></code></span> 只是给人看的补充，不能反过来解析字符串。</p>
<h2 id="创建接口必须讨论重复请求">创建接口必须讨论重复请求</h2>
<p>网络超时后，客户端不知道请求是没到服务端，还是已经成功但响应丢失。直接重试可能创建重复客户。契约因此加入幂等键：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="http" data-theme="github-dark-default"><code data-language="http" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">POST</span><span style="color:#E6EDF3"> /api/customers</span></span>
<span data-line=""><span style="color:#7EE787">Idempotency-Key</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> 7d62ad0d-5d36-4ae5-a58d-80233f04ecbb</span></span></code></pre></figure>
<p>服务端在同一调用方范围内保存 key、请求摘要和首次响应。相同 key、相同请求返回原结果；相同 key、不同请求返回冲突。这比前端禁用按钮更接近业务边界。</p>
<h2 id="兼容不是永远保留旧字段">兼容不是永远保留旧字段</h2>
<p>字段重命名时，我们曾经同时返回 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>customerName</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>name</span></span></code></span>，却没有删除日期，最终两套字段长期存在。后来每次兼容都写四件事：引入版本、旧客户端比例、迁移方式和移除条件。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>兼容项: response.name -> response.customerName</span></span>
<span data-line=""><span>开始版本: API 2018-12-15</span></span>
<span data-line=""><span>客户端迁移: Web build >= 1842</span></span>
<span data-line=""><span>观测: 旧字段读取量</span></span>
<span data-line=""><span>移除条件: 连续 14 天旧字段读取为 0</span></span></code></pre></figure>
<h2 id="用示例和测试共享同一份事实">用示例和测试共享同一份事实</h2>
<p>文档容易过期。我们把契约示例放进仓库，前端用它做解析测试，服务端用它验证响应，联调环境再跑一次真实请求。即使当时还没有完整 OpenAPI 工具链，这种“同一份样例被双方执行”已经显著减少口头解释。</p>
<p>我也开始在接口变更评审里问：旧调用方会怎样；失败后谁负责恢复；重试是否安全；日志如何把前后端请求串起来。接口契约的价值不是让文档更正式，而是把跨团队猜测变成可以提前失败的测试。</p>]]></description></item><item><title>组件边界不等于视觉边界</title><link>https://siegaii.com/articles/2018-10-component-boundaries/</link><guid>https://siegaii.com/articles/2018-10-component-boundaries/</guid><pubDate>Sat, 13 Oct 2018 00:00:00 GMT</pubDate><description><![CDATA[<p>组件化最容易被理解成“把页面拆小”。导航是组件，表格是组件，每一行、每个按钮也可以继续拆。文件数量增加后，页面看起来很有结构，修改一个字段却需要穿过多层属性和事件。</p>
<p>问题不在组件多少，而在边界为何存在。视觉上的一个方框，可能同时承担数据请求、业务规则和展示；视觉上分开的两块内容，也可能由同一状态驱动。</p>
<h2 id="按变化原因拆分">按变化原因拆分</h2>
<p>我更愿意观察一段代码为什么会变化。纯展示组件因视觉规范变化，业务组件因业务规则变化，数据层因接口变化。如果不同原因混在一起，每次需求都会触碰整棵组件树。</p>
<p>一个好的边界会暴露较小、稳定的接口。它允许内部重写，而调用者无需了解细节。相反，如果组件需要十几个属性才能工作，往往说明它只是把耦合换了位置。</p>
<p>我踩过的一个坑，是把整张告警记录和一组控制开关都传给表格行：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#8B949E">// 看似复用，实际把页面规则泄漏给每一行</span></span>
<span data-line=""><span style="color:#FF7B72">&#x3C;</span><span style="color:#E6EDF3">AlarmRow</span></span>
<span data-line=""><span style="color:#E6EDF3">  alarm</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{alarm}</span></span>
<span data-line=""><span style="color:#E6EDF3">  permissions</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{permissions}</span></span>
<span data-line=""><span style="color:#E6EDF3">  filters</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{filters}</span></span>
<span data-line=""><span style="color:#E6EDF3">  showDevice</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{showDevice}</span></span>
<span data-line=""><span style="color:#E6EDF3">  onRefresh</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{reloadPage}</span></span>
<span data-line=""><span style="color:#E6EDF3">  onOpenDetail</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{openDetail}</span></span>
<span data-line=""><span style="color:#E6EDF3">  onAcknowledge</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{acknowledge}</span></span>
<span data-line=""><span style="color:#FF7B72">/></span></span></code></pre></figure>
<p>后来把业务决策留在列表边界，行组件只接收显示模型和用户意图：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> AlarmRowModel</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FFA657">  id</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  title</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  occurredAt</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  level</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "info"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "warning"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "critical"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  deviceLabel</span><span style="color:#FF7B72">?:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FFA657">  canAcknowledge</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> boolean</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">&#x3C;</span><span style="color:#E6EDF3">AlarmRow value</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{</span><span style="color:#D2A8FF">toRowModel</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">alarm</span><span style="color:#E6EDF3">, </span><span style="color:#FFA657">context</span><span style="color:#E6EDF3">)} onAction</span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3">{handleRowAction} </span><span style="color:#FF7B72">/></span></span></code></pre></figure>
<p>行组件不再知道权限表和筛选器，测试也从构造整页上下文，变成输入一个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>AlarmRowModel</span></span></code></span>。这才是拆分之后认知负担真的下降。</p>
<h2 id="局部清晰胜过全局通用">局部清晰胜过全局通用</h2>
<p>通用组件很有吸引力，但业务相似不等于语义相同。两个列表今天都有分页，明天可能分别需要无限滚动和批量选择。过早合并会把差异压进复杂配置。</p>
<p>我会先让组件在局部场景中表达清楚，等真正出现多次相同变化后再抽取。复用是理解成熟后的结果，不应成为设计的起点。</p>
<p>判断组件边界时，我常看四个信号：</p>
<ol>
<li>修改一个业务规则，需要同时改多少层 props 和事件。</li>
<li>组件测试是否必须构造大量与当前行为无关的数据。</li>
<li>组件名称描述的是领域职责，还是视觉位置，例如 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>LeftBox</span></span></code></span>。</li>
<li>复用是否依靠不断增加布尔开关维持。</li>
</ol>
<p>最后一种尤其危险。<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>compact</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>editable</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>showX</span></span></code></span> 单独都合理，多个开关组合后会产生没人验证的模式。与其维护一个万能组件，不如保留共享的低层原语，让两个业务组件分别表达完整语义。</p>
<h2 id="数据所有权决定关系方向">数据所有权决定关系方向</h2>
<p>组件之间最棘手的问题往往不是视觉，而是谁拥有状态。多个兄弟组件需要同一数据时，状态应提升到它们最近的共同边界；只影响内部交互的状态，则不必进入全局存储。把所有数据集中管理，会让任何变化都像系统级事件。</p>
<p>我会让数据向下流动、意图向上报告，避免子组件直接修改不属于自己的对象。关系越单向，组件越容易独立理解。需要跨越很远层级的依赖，则说明可能缺少更合适的业务边界或上下文入口。</p>
<p>组件边界最终是在分配认知负担：谁负责知道哪些事情，变化需要惊动多少代码。拆得小不代表负担更小，关系清楚才是。</p>
<p><img src="/diagrams/series/legacy-component-ownership.svg" alt="页面编排、领域组件和视觉原语的变化所有权"></p>
<p><em>图：组件边界的目标是隔离变化原因，而不是增加目录层级。</em></p>
<h2 id="一个组件应该由一种变化理由拥有">一个组件应该由一种变化理由拥有</h2>
<p>后来拆组件时，我会给它写“所有者句子”：筛选器由筛选表达式变化驱动，结果表由列模型和数据变化驱动，页面编排由业务流程变化驱动。如果一个组件同时因为三种不相干原因频繁修改，它通常承担了太多职责。</p>
<p>反过来，只渲染一个图标、没有独立语义和测试价值的片段，不必为了目录整齐单独成组件。边界的目标是让变化局部化，不是制造更多文件。</p>]]></description></item><item><title>第一次线上白屏：先恢复页面，再保住证据</title><link>https://siegaii.com/articles/2018-08-blank-screen-incident/</link><guid>https://siegaii.com/articles/2018-08-blank-screen-incident/</guid><pubDate>Sat, 18 Aug 2018 00:00:00 GMT</pubDate><description><![CDATA[<p>第一次遇到线上白屏时，我正在回家的地铁上。测试群里有人发来一张全白截图，刷新有时恢复，有时仍然空白。我的第一反应是远程打开源码查最近改动，几分钟后才意识到：用户此刻不需要我证明根因，先恢复可用性更重要。</p>
<p>那次发布采用带 hash 的 JavaScript 文件。新 HTML 已经指向 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>app.9c31.js</span></span></code></span>，一部分 CDN 节点仍缓存旧资源清单；与此同时发布脚本清理了上一版 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>app.71aa.js</span></span></code></span>。拿到旧 HTML 的用户请求一个已经不存在的 chunk，入口脚本没有运行，页面自然什么也显示不出来。</p>
<p><img src="/diagrams/series/2018-blank-screen-recovery.svg" alt="白屏事故从错误上报、版本识别、回滚到缓存恢复的时序"></p>
<p><em>图 1：事故处理中“恢复”和“定位”并行，但恢复动作不能擦掉版本与请求证据。</em></p>
<h2 id="先用最小信息判断影响面">先用最小信息判断影响面</h2>
<p>我后来给白屏准备了四个快速问题：</p>
<ol>
<li>所有人都失败，还是特定地区、浏览器或缓存状态失败？</li>
<li>HTML 是否能返回，入口 JS 是否 2xx？</li>
<li>错误发生在哪个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>buildId</span></span></code></span>，用户实际加载了哪些资源版本？</li>
<li>回滚应用就够了，还是还要处理 CDN/Service Worker 缓存？</li>
</ol>
<p>当时用无痕窗口、手机网络和不同地区机器复现后，发现结果不一致，更像缓存层而不是代码逻辑。Network 面板里旧 chunk 返回 404，方向就清楚了。</p>
<h2 id="回滚不是重新执行一次旧构建">回滚不是重新执行一次旧构建</h2>
<p>早期发布脚本只保存代码 tag，回滚时重新安装依赖、重新构建。即使代码相同，依赖解析和构建环境也可能不同。那次之后，我们开始保存完整静态产物，用构建编号部署：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>release-20180818-01/</span></span>
<span data-line=""><span>  index.html</span></span>
<span data-line=""><span>  manifest.json</span></span>
<span data-line=""><span>  assets/app.71aa.js</span></span>
<span data-line=""><span>  assets/vendor.c031.js</span></span>
<span data-line=""><span>  build-meta.json</span></span></code></pre></figure>
<p>回滚只是把入口指回旧产物，不重新生成。旧 hash 资源至少保留多个发布周期，HTML 使用短缓存，hash 静态资源使用长期不可变缓存。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="http" data-theme="github-dark-default"><code data-language="http" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#8B949E"># HTML</span></span>
<span data-line=""><span style="color:#7EE787">Cache-Control</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> no-cache</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#8B949E"># /assets/app.71aa.js</span></span>
<span data-line=""><span style="color:#7EE787">Cache-Control</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> public, max-age=31536000, immutable</span></span></code></pre></figure>
<p>这组缓存语义比“发布后刷新 CDN”可靠得多。不可变资源一旦发布就不覆盖、不删除，旧 HTML 才总能找到它引用的文件。</p>
<h2 id="白屏之前要有最后一道自救">白屏之前要有最后一道自救</h2>
<p>如果入口脚本根本没加载，应用内 Error Boundary 帮不上忙。我们在 HTML 里保留了很小的启动超时检查：应用成功挂载后设置标记；超过限定时间仍未挂载，就展示纯 HTML 恢复提示，并上报构建版本与资源状态。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="html" data-theme="github-dark-default"><code data-language="html" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">&#x3C;</span><span style="color:#7EE787">script</span><span style="color:#E6EDF3">></span></span>
<span data-line=""><span style="color:#E6EDF3">  window.__APP_MOUNTED__ </span><span style="color:#FF7B72">=</span><span style="color:#79C0FF"> false</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#D2A8FF">  setTimeout</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">function</span><span style="color:#E6EDF3"> () {</span></span>
<span data-line=""><span style="color:#FF7B72">    if</span><span style="color:#E6EDF3"> (window.__APP_MOUNTED__) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">    document.</span><span style="color:#D2A8FF">getElementById</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"boot-fallback"</span><span style="color:#E6EDF3">).hidden </span><span style="color:#FF7B72">=</span><span style="color:#79C0FF"> false</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">    navigator.</span><span style="color:#D2A8FF">sendBeacon</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"/boot-error"</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">JSON</span><span style="color:#E6EDF3">.</span><span style="color:#D2A8FF">stringify</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">      buildId: document.documentElement.dataset.buildId,</span></span>
<span data-line=""><span style="color:#E6EDF3">      href: location.href,</span></span>
<span data-line=""><span style="color:#E6EDF3">    }));</span></span>
<span data-line=""><span style="color:#E6EDF3">  }, </span><span style="color:#79C0FF">8000</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">&#x3C;/</span><span style="color:#7EE787">script</span><span style="color:#E6EDF3">></span></span></code></pre></figure>
<p>这个兜底不尝试自动无限刷新。缓存错配时，反复刷新可能继续命中同一节点，也会把用户困在闪烁循环里。</p>
<h2 id="发布检查必须从用户入口开始">发布检查必须从用户入口开始</h2>
<p>过去我们验证“上传命令成功”和“服务器文件存在”。事故后增加了真正的冒烟：从公网域名请求 HTML，解析里面所有本地静态资源，逐个确认状态、内容类型和版本；再用无缓存浏览器打开关键路由。</p>
<table>
<thead>
<tr>
<th>门禁</th>
<th>失败时动作</th>
</tr>
</thead>
<tbody>
<tr>
<td>HTML 引用的资源全部可达</td>
<td>禁止切流量</td>
</tr>
<tr>
<td>新旧两版资源能同时访问</td>
<td>禁止清理旧产物</td>
</tr>
<tr>
<td>首页与登录页能够挂载</td>
<td>自动回滚入口</td>
</tr>
<tr>
<td>白屏率和 chunk 404 未升高</td>
<td>停止扩量</td>
</tr>
</tbody>
</table>
<h2 id="人在事故里也需要明确分工">人在事故里也需要明确分工</h2>
<p>那晚最混乱的部分不是技术，而是三个人同时操作 CDN。后来值班流程明确一个指挥者、一个执行回滚、一个保留日志并持续报指标。任何缓存清理都在群里写清目标和时间，避免相互覆盖。</p>
<p>第一次线上白屏之后，我对“前端只是静态文件”的理解彻底变了。HTML、CDN、构建产物和浏览器缓存共同组成运行系统。页面恢复只是结束用户影响，能够解释为什么旧入口找不到旧资源，并把这个条件写进发布门禁，才算真正结案。</p>]]></description></item><item><title>第一份生产代码教会我的事</title><link>https://siegaii.com/articles/2018-06-first-production-code/</link><guid>https://siegaii.com/articles/2018-06-first-production-code/</guid><pubDate>Fri, 29 Jun 2018 00:00:00 GMT</pubDate><description><![CDATA[<p>真正进入项目后，我才发现练习代码与生产代码之间并不是规模差异，而是责任差异。练习失败可以重来，生产页面的失败会阻断监控、误导判断，或者让另一个同事无法继续工作。</p>
<p>第一批任务并不复杂：表格、查询条件、趋势图和设备状态。但数据不再像示例那样整齐。字段可能为空，时间格式不一致，接口在不同环境返回不同结构，旧浏览器也会暴露意料之外的问题。</p>
<h2 id="默认值不是容错方案">默认值不是容错方案</h2>
<p>我曾经为缺失数据加上大量 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>|| '-'</span></span></code></span>，页面看起来不报错，却把“没有数据”“接口异常”和“数值为零”混成同一种结果。容错不能只是隐藏错误，它还要保留错误的语义。</p>
<p>后来我会明确区分数据状态，并和接口提供方确认契约。无法恢复的问题应该被记录，可恢复的问题才适合降级展示。</p>
<p>我后来给接口数据加了一层很薄的归一化，不让组件直接猜测原始字段：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> MetricValue</span><span style="color:#FF7B72"> =</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">kind</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "value"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">value</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">unit</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">kind</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "missing"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">reason</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "not_reported"</span><span style="color:#FF7B72"> |</span><span style="color:#A5D6FF"> "offline"</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">kind</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "invalid"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">raw</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> unknown</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">function</span><span style="color:#D2A8FF"> normalizeTemperature</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">raw</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> unknown</span><span style="color:#E6EDF3">)</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> MetricValue</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (raw </span><span style="color:#FF7B72">===</span><span style="color:#79C0FF"> null</span><span style="color:#FF7B72"> ||</span><span style="color:#E6EDF3"> raw </span><span style="color:#FF7B72">===</span><span style="color:#79C0FF"> undefined</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">    return</span><span style="color:#E6EDF3"> { kind: </span><span style="color:#A5D6FF">"missing"</span><span style="color:#E6EDF3">, reason: </span><span style="color:#A5D6FF">"not_reported"</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> value</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> Number</span><span style="color:#E6EDF3">(raw);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">Number.</span><span style="color:#D2A8FF">isFinite</span><span style="color:#E6EDF3">(value)) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3"> { kind: </span><span style="color:#A5D6FF">"invalid"</span><span style="color:#E6EDF3">, raw };</span></span>
<span data-line=""><span style="color:#FF7B72">  return</span><span style="color:#E6EDF3"> { kind: </span><span style="color:#A5D6FF">"value"</span><span style="color:#E6EDF3">, value, unit: </span><span style="color:#A5D6FF">"°C"</span><span style="color:#E6EDF3"> };</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>渲染 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>0°C</span></span></code></span>、设备离线和脏数据时，组件会走三条明确分支。更重要的是，<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>invalid</span></span></code></span> 会进入监控，而不是被一个横杠安静吞掉。页面“看起来不报错”不再是唯一目标。</p>
<h2 id="修改之前先理解上下游">修改之前先理解上下游</h2>
<p>生产系统里，很少有真正孤立的一行代码。一个字段同时影响列表、导出、权限和告警。只看当前页面完成需求，可能把问题推到另一个环节。</p>
<p>我开始在动手前追踪数据从哪里来、经过哪些转换、最后被谁使用；提交时说明变化范围，而不是只写“修复 bug”。这些动作没有增加功能，却显著减少返工。</p>
<p>一次字段改名让我吃过亏：列表已经改成新字段，导出仍读取旧字段，页面验收通过后用户才发现 CSV 为空。从那以后，字段级修改我会至少检查四个消费点：显示、筛选、排序、导出。若字段参与告警或权限，再把它们加入清单。</p>
<p>这不是要求每次都全仓库搜索，而是先沿数据契约找消费者。接口类型、转换函数和导出模型如果共享一个稳定入口，检查成本会低很多；如果同一字段在各页面重复解析，问题本身也提示我们缺少边界。</p>
<h2 id="团队代码需要留下理由">团队代码需要留下理由</h2>
<p>生产代码很少只被写一次。临时兼容、特殊阈值和看似多余的判断，如果没有解释，下一位维护者可能“清理”掉它，也可能因为害怕而永远不敢修改。我学会把注释留给无法从代码本身看出的业务原因，并在提交记录里关联问题背景。</p>
<p>与此同时，注释不能替代糟糕结构。能通过命名和拆分表达的意图，应先让代码自己说清楚。文档保存上下文，代码表达当前事实，两者承担不同责任。</p>
<p>生产代码最重要的标准并非聪明，而是可预测。别人能理解它，异常能被发现，修改的影响能被控制。代码从个人作品变成团队资产，就是从这里开始的。</p>
<p><img src="/diagrams/series/legacy-production-change.svg" alt="一次生产变更从自检、CI、小流量发布到监控确认的证据链"></p>
<p><em>图：代码进入生产前后都需要可复查的验证结果。</em></p>
<h2 id="提交前我会自己走一遍失败路径">提交前我会自己走一遍失败路径</h2>
<p>第一份生产代码之后，我养成一个简单习惯：评审别人之前先评审自己。除了正常输入，还会主动断网、重复点击、让接口返回空数组和 500、刷新页面、用没有权限的账号再走一遍。</p>
<p>我把发现的问题写进 PR：影响面、复现步骤、修复方式和验证命令。这样评审者看到的不只是代码，还能判断我有没有理解它在真实系统里怎样失败。</p>]]></description></item><item><title>复杂表单不是一组输入框：把提交过程画成状态机</title><link>https://siegaii.com/articles/2018-04-form-state-machine/</link><guid>https://siegaii.com/articles/2018-04-form-state-machine/</guid><pubDate>Sat, 21 Apr 2018 00:00:00 GMT</pubDate><description><![CDATA[<p>2018 年做一张企业资料表单时，页面上有二十多个字段、三个联动下拉框和一个需要请求服务端的公司名校验。测试同学连续点击两次提交，后台生成两条记录；快速修改公司名时，旧请求晚回来，把新值标成“已存在”。我修一个按钮禁用，又冒出一个错误提示残留，代码里到处都是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>isLoading</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>isValidating</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>hasError</span></span></code></span>。</p>
<p>三个布尔值理论上能组合出八种状态，其中很多根本不应该存在，例如同时 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>isLoading=true</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>isValidating=true</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>hasError=true</span></span></code></span>。问题不是少写了一个判断，而是页面没有统一状态模型。</p>
<p><img src="/diagrams/series/2018-form-state-machine.svg" alt="复杂表单从编辑、校验、提交到成功或失败的状态转移"></p>
<p><em>图 1：允许的状态和转移先被写清楚，按钮、提示和请求才能从同一个事实来源派生。</em></p>
<h2 id="用一个联合类型替代布尔组合">用一个联合类型替代布尔组合</h2>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> FormState</span><span style="color:#FF7B72"> =</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "editing"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">values</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Values</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">fieldErrors</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> FieldErrors</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "validating"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">values</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Values</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">requestId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "submitting"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">values</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Values</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">submissionId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "succeeded"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">recordId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "failed"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">values</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Values</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">message</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">retryable</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> boolean</span><span style="color:#E6EDF3"> };</span></span></code></pre></figure>
<p>现在“提交中还能不能编辑”“失败后显示什么”不再由多个组件各自判断。<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>status === "submitting"</span></span></code></span> 时按钮禁用，字段保持只读；<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>failed</span></span></code></span> 保存当时的 values，用户修正后回到 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>editing</span></span></code></span>。</p>
<h2 id="异步校验必须识别过期结果">异步校验必须识别过期结果</h2>
<p>公司名校验的竞态来自两个并行请求：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>t0 输入「易方」      -> 请求 #17</span></span>
<span data-line=""><span>t1 输入「易方科技」  -> 请求 #18</span></span>
<span data-line=""><span>t2 #18 返回可用      -> 页面显示可用</span></span>
<span data-line=""><span>t3 #17 返回已存在    -> 旧结果覆盖新输入</span></span></code></pre></figure>
<p>我给每次校验分配递增编号，只接受当前请求结果：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">let</span><span style="color:#E6EDF3"> validationSequence </span><span style="color:#FF7B72">=</span><span style="color:#79C0FF"> 0</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> validateCompanyName</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">name</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> requestId</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> ++</span><span style="color:#E6EDF3">validationSequence;</span></span>
<span data-line=""><span style="color:#D2A8FF">  transition</span><span style="color:#E6EDF3">({ status: </span><span style="color:#A5D6FF">"validating"</span><span style="color:#E6EDF3">, values: </span><span style="color:#D2A8FF">currentValues</span><span style="color:#E6EDF3">(), requestId });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> result</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#E6EDF3"> api.</span><span style="color:#D2A8FF">checkCompanyName</span><span style="color:#E6EDF3">(name);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (requestId </span><span style="color:#FF7B72">!==</span><span style="color:#E6EDF3"> validationSequence) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (name </span><span style="color:#FF7B72">!==</span><span style="color:#D2A8FF"> currentValues</span><span style="color:#E6EDF3">().companyName) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#D2A8FF">  transition</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">    status: </span><span style="color:#A5D6FF">"editing"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">    values: </span><span style="color:#D2A8FF">currentValues</span><span style="color:#E6EDF3">(),</span></span>
<span data-line=""><span style="color:#E6EDF3">    fieldErrors: result.available </span><span style="color:#FF7B72">?</span><span style="color:#E6EDF3"> {} </span><span style="color:#FF7B72">:</span><span style="color:#E6EDF3"> { companyName: </span><span style="color:#A5D6FF">"名称已存在"</span><span style="color:#E6EDF3"> },</span></span>
<span data-line=""><span style="color:#E6EDF3">  });</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>后来可以用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>AbortController</span></span></code></span> 取消旧请求，但“取消”不能替代过期结果检查：请求可能已经到达服务端，某些客户端也无法真正中止所有阶段。</p>
<h2 id="前端禁用按钮不能保证幂等">前端禁用按钮不能保证幂等</h2>
<p>双击提交的第一层修复是同步进入 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>submitting</span></span></code></span>，在任何 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>await</span></span></code></span> 之前禁用按钮。但网络重试、浏览器刷新和网关超时仍可能重复发送。真正的幂等边界必须在服务端：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> submissionId</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> crypto.</span><span style="color:#D2A8FF">randomUUID</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">await</span><span style="color:#E6EDF3"> api.</span><span style="color:#D2A8FF">createCompany</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">  idempotencyKey: submissionId,</span></span>
<span data-line=""><span style="color:#E6EDF3">  payload: values,</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span></code></pre></figure>
<p>服务端对同一个 key 返回第一次执行结果，而不是创建第二条记录。前端状态机解决交互一致性，服务端幂等解决业务一致性，两者不能互相冒充。</p>
<h2 id="错误按谁能修复分类">错误按“谁能修复”分类</h2>
<table>
<thead>
<tr>
<th>错误</th>
<th>展示位置</th>
<th>下一步</th>
</tr>
</thead>
<tbody>
<tr>
<td>必填、格式错误</td>
<td>字段旁</td>
<td>用户修改后立即重验</td>
</tr>
<tr>
<td>名称重复</td>
<td>对应字段旁</td>
<td>保留其他输入，只改名称</td>
</tr>
<tr>
<td>网络超时</td>
<td>表单顶部</td>
<td>保留 submissionId，允许重试</td>
</tr>
<tr>
<td>权限失效</td>
<td>全局提示</td>
<td>重新登录后恢复草稿</td>
</tr>
<tr>
<td>服务端未知错误</td>
<td>顶部 + requestId</td>
<td>停止自动重试，便于支持排查</td>
</tr>
</tbody>
</table>
<p>过去我会把所有错误都写进一个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>message</span></span></code></span>，用户只看到“提交失败”。分类后，界面开始表达恢复路径，而不仅是坏消息。</p>
<h2 id="状态转移本身也要测试">状态转移本身也要测试</h2>
<p>我最终没有只测某个按钮是否出现，而是测事件序列：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>editing --SUBMIT--> validating</span></span>
<span data-line=""><span>validating --VALID--> submitting</span></span>
<span data-line=""><span>submitting --TIMEOUT--> failed(retryable=true)</span></span>
<span data-line=""><span>failed --RETRY--> submitting (same submissionId)</span></span>
<span data-line=""><span>submitting --RESOLVED--> succeeded</span></span></code></pre></figure>
<p>任何没有定义的事件都不应悄悄改变状态。这个约束让后续增加“保存草稿”和“离开页面提醒”时，不必重新猜每个布尔变量的组合。</p>
<p>这张表单让我第一次真正理解，界面复杂度常常不是 DOM 多，而是时间和状态多。只要有异步校验、重试、恢复和并发输入，先画状态机通常比继续加条件更快。</p>]]></description></item><item><title>界面复杂度，首先是状态复杂度</title><link>https://siegaii.com/articles/2018-02-state-and-interface/</link><guid>https://siegaii.com/articles/2018-02-state-and-interface/</guid><pubDate>Fri, 09 Feb 2018 00:00:00 GMT</pubDate><description><![CDATA[<p>一个带查询、分页和弹窗的页面，代码很快就会充满布尔值：<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>loading</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>visible</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>disabled</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>error</span></span></code></span>。每个变量都不难，组合起来却会出现大量不合理状态，例如弹窗已经关闭，请求回调仍在修改内部表单。</p>
<p>一开始我会继续增加判断。判断越多，代码越像在修补过去的决定，而不是描述当前系统。</p>
<h2 id="先画状态再写事件">先画状态，再写事件</h2>
<p>我尝试在纸上列出页面的主要状态：空闲、加载、成功、失败；再写出哪些事件允许它们相互转换。这样做之后，很多布尔值其实可以被一个枚举替代，很多事件也应该在特定状态下被忽略。</p>
<p>一个搜索列表可以用判别联合描述，而不必维护三四个互相独立的布尔值：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">type</span><span style="color:#FFA657"> SearchState</span><span style="color:#FF7B72"> =</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "idle"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">query</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "loading"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">query</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">requestId</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> number</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "success"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">query</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">items</span><span style="color:#FF7B72">:</span><span style="color:#FFA657"> Item</span><span style="color:#E6EDF3">[] }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "empty"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">query</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""><span style="color:#FF7B72">  |</span><span style="color:#E6EDF3"> { </span><span style="color:#FFA657">status</span><span style="color:#FF7B72">:</span><span style="color:#A5D6FF"> "error"</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">query</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">; </span><span style="color:#FFA657">message</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3"> };</span></span></code></pre></figure>
<p>这个类型直接排除了“既成功又失败”“没有发请求却存在 requestId”之类组合。渲染层只根据 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>status</span></span></code></span> 分支，不再猜测多个布尔值谁优先。</p>
<p>我也把事件写成表，而不是散落在点击回调里：</p>
<table>
<thead>
<tr>
<th>当前状态</th>
<th>事件</th>
<th>下一状态</th>
<th>额外动作</th>
</tr>
</thead>
<tbody>
<tr>
<td>任意</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>QUERY_CHANGED</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>idle</span></span></code></span></td>
<td>使旧请求结果失效</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>idle</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>SUBMIT</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>loading</span></span></code></span></td>
<td>生成新的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>requestId</span></span></code></span></td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>loading</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>RESOLVE(items)</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>success/empty</span></span></code></span></td>
<td>仅接收匹配的请求</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>loading</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>REJECT(error)</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>error</span></span></code></span></td>
<td>区分取消和真实失败</td>
</tr>
</tbody>
</table>
<p>状态模型的价值不是形式漂亮，而是排除不可能。一个系统如果同时允许“加载中”和“加载失败”，使用者与开发者都会困惑。</p>
<h2 id="派生值不要重复保存">派生值不要重复保存</h2>
<p>另一个问题是把可以计算出的值再次存进状态。列表总数、按钮是否可用、当前页是否为空，往往可以由原始数据得到。如果同时保存，就必须保证两份信息永远一致。</p>
<p>每减少一个独立状态，就减少一组可能组合。界面性能问题常被注意，状态空间的膨胀却更容易拖垮维护效率。</p>
<h2 id="异步结果也属于状态机">异步结果也属于状态机</h2>
<p>请求带来时间上的不确定性。用户连续切换条件，后发请求可能先返回；组件已经销毁，旧回调仍可能尝试更新。只在成功回调里赋值，无法说明结果是否仍属于当前状态。</p>
<p>我开始为请求保留标识，在新任务开始时使旧结果失效，并把取消、超时和业务失败分别处理。这样做不是为了制造复杂模型，而是承认异步世界里“最后返回”不等于“当前需要”。时间顺序也必须进入状态设计。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark-default"><code data-language="ts" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">let</span><span style="color:#E6EDF3"> latestRequest </span><span style="color:#FF7B72">=</span><span style="color:#79C0FF"> 0</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">async</span><span style="color:#FF7B72"> function</span><span style="color:#D2A8FF"> search</span><span style="color:#E6EDF3">(</span><span style="color:#FFA657">query</span><span style="color:#FF7B72">:</span><span style="color:#79C0FF"> string</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> requestId</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> ++</span><span style="color:#E6EDF3">latestRequest;</span></span>
<span data-line=""><span style="color:#D2A8FF">  dispatch</span><span style="color:#E6EDF3">({ type: </span><span style="color:#A5D6FF">"START"</span><span style="color:#E6EDF3">, query, requestId });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  try</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    const</span><span style="color:#79C0FF"> items</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> await</span><span style="color:#E6EDF3"> api.</span><span style="color:#D2A8FF">search</span><span style="color:#E6EDF3">(query);</span></span>
<span data-line=""><span style="color:#FF7B72">    if</span><span style="color:#E6EDF3"> (requestId </span><span style="color:#FF7B72">!==</span><span style="color:#E6EDF3"> latestRequest) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#D2A8FF">    dispatch</span><span style="color:#E6EDF3">({ type: </span><span style="color:#A5D6FF">"RESOLVE"</span><span style="color:#E6EDF3">, query, items });</span></span>
<span data-line=""><span style="color:#E6EDF3">  } </span><span style="color:#FF7B72">catch</span><span style="color:#E6EDF3"> (error) {</span></span>
<span data-line=""><span style="color:#FF7B72">    if</span><span style="color:#E6EDF3"> (requestId </span><span style="color:#FF7B72">!==</span><span style="color:#E6EDF3"> latestRequest) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#D2A8FF">    dispatch</span><span style="color:#E6EDF3">({ type: </span><span style="color:#A5D6FF">"REJECT"</span><span style="color:#E6EDF3">, query, error: </span><span style="color:#D2A8FF">toDisplayError</span><span style="color:#E6EDF3">(error) });</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>后来浏览器有了更好用的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>AbortController</span></span></code></span>，框架也提供请求缓存，但 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>requestId</span></span></code></span> 仍然说明了核心语义：异步结果必须证明自己仍属于当前意图。</p>
<p>框架会不断变化，但这条原则不会变：先定义系统允许发生什么，再决定用什么 API 表达。事件处理只是表面，状态才是界面的骨架。</p>
<p><img src="/diagrams/series/legacy-interface-state-space.svg" alt="界面从初始、加载、空状态到失败和恢复的状态空间"></p>
<p><em>图：先列完整状态，再讨论每个视觉组件如何表达。</em></p>
<h2 id="评审页面前先列状态不先看稿子">评审页面前先列状态，不先看稿子</h2>
<p>现在接到一个交互，我会先写状态清单：初始、加载、空、部分成功、失败、权限不足、过期、提交中和恢复中，再给每个用户事件写允许转移。设计稿没有覆盖的状态会在这里提前暴露。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>事件: RETRY</span></span>
<span data-line=""><span>允许来源: failed(retryable=true)</span></span>
<span data-line=""><span>目标: loading</span></span>
<span data-line=""><span>必须保留: 用户筛选、requestId</span></span>
<span data-line=""><span>禁止: failed(permission_denied)</span></span></code></pre></figure>
<p>这张小表已经成了我评审复杂界面的固定输入。</p>]]></description></item><item><title>第一次解决 Git 冲突：不要只让文件变绿</title><link>https://siegaii.com/articles/2017-11-first-git-conflict/</link><guid>https://siegaii.com/articles/2017-11-first-git-conflict/</guid><pubDate>Sat, 18 Nov 2017 00:00:00 GMT</pubDate><description><![CDATA[<p>第一次和同学一起写页面时，我们约定“你写首页，我写列表页”，以为文件不同就不会冲突。结果两个人都改了导航栏：他增加登录入口，我调整 DOM 结构和类名。合并时出现熟悉的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>&#x3C;&#x3C;&#x3C;&#x3C;&#x3C;&#x3C;&#x3C; HEAD</span></span></code></span>，我把冲突标记删掉，选了一份看起来完整的代码，提交后才发现登录入口也被一起删了。</p>
<p>Git 告诉我文本无法自动合并，却不会替我判断产品意图。文件恢复成合法语法，只说明冲突标记消失，不说明两个需求都被保留。</p>
<p><img src="/diagrams/series/2017-git-collaboration.svg" alt="Git 协作中工作区、本地提交历史和远端共享基线的职责"></p>
<p><em>图 1：冲突发生在文本，解决却需要回到提交意图和共享基线。</em></p>
<h2 id="先读三份内容而不是直接选-ourstheirs">先读三份内容，而不是直接选 ours/theirs</h2>
<p>冲突里至少有三种事实：共同祖先、当前分支和待合并分支。只选 ours 或 theirs，相当于假设其中一方的修改可以完整覆盖另一方。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="diff" data-theme="github-dark-default"><code data-language="diff" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">&#x3C;&#x3C;&#x3C;&#x3C;&#x3C;&#x3C;&#x3C; HEAD</span></span>
<span data-line=""><span style="color:#E6EDF3">&#x3C;nav class="site-nav site-nav--compact"></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;a href="/">首页&#x3C;/a></span></span>
<span data-line=""><span style="color:#E6EDF3">=======</span></span>
<span data-line=""><span style="color:#E6EDF3">&#x3C;nav class="site-nav"></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;a href="/">首页&#x3C;/a></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;a href="/login">登录&#x3C;/a></span></span>
<span data-line=""><span style="color:#E6EDF3">>>>>>>> feature/login</span></span></code></pre></figure>
<p>正确结果不是二选一，而是保留新结构和登录入口：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="html" data-theme="github-dark-default"><code data-language="html" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">&#x3C;</span><span style="color:#7EE787">nav</span><span style="color:#79C0FF"> class</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"site-nav site-nav--compact"</span><span style="color:#E6EDF3">></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;</span><span style="color:#7EE787">a</span><span style="color:#79C0FF"> href</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"/"</span><span style="color:#E6EDF3">>首页&#x3C;/</span><span style="color:#7EE787">a</span><span style="color:#E6EDF3">></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;</span><span style="color:#7EE787">a</span><span style="color:#79C0FF"> href</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"/login"</span><span style="color:#E6EDF3">>登录&#x3C;/</span><span style="color:#7EE787">a</span><span style="color:#E6EDF3">></span></span>
<span data-line=""><span style="color:#E6EDF3">&#x3C;/</span><span style="color:#7EE787">nav</span><span style="color:#E6EDF3">></span></span></code></pre></figure>
<p>解决前我会先看两边提交说明和差异，分别写下它们想完成什么。看不懂时直接问作者，比凭文件内容猜更便宜。</p>
<h2 id="小提交让意图更容易恢复">小提交让意图更容易恢复</h2>
<p>当时我们经常一天结束后一次提交二十个文件，提交信息只有 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>update</span></span></code></span>。冲突时无法判断某行变更属于哪个目的。后来我开始按可验证结果拆提交：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>feat(nav): add login entry for anonymous users</span></span>
<span data-line=""><span>refactor(nav): replace float layout with flex</span></span>
<span data-line=""><span>fix(nav): keep active state after route change</span></span></code></pre></figure>
<p>一个提交只承担一个主要意图，合并时就能决定它应该保留、重做还是放弃。提交历史不是备份压缩包，它是协作中的解释材料。</p>
<h2 id="合并完成后验证双方场景">合并完成后验证双方场景</h2>
<p>解决冲突后的检查清单不应该只有“能编译”：</p>
<table>
<thead>
<tr>
<th>原分支意图</th>
<th>验证方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>小屏导航变紧凑</td>
<td>375px 宽度下链接不换乱、不溢出</td>
</tr>
<tr>
<td>未登录用户看到登录入口</td>
<td>清除会话后入口存在且可点击</td>
</tr>
<tr>
<td>已登录用户显示头像</td>
<td>模拟登录态，入口被正确替换</td>
</tr>
<tr>
<td>当前路由保持高亮</td>
<td>首页和列表页分别刷新验证</td>
</tr>
</tbody>
</table>
<p>这张表能防止我只验证自己的改动。冲突解决者实际上临时承担了集成责任。</p>
<h2 id="共享文件需要更清楚的所有权">共享文件需要更清楚的所有权</h2>
<p>导航、路由、依赖清单和全局样式天然容易冲突。我们后来在开始任务前说清楚谁会修改共享文件；大的结构调整先单独合并，再让功能分支基于新结构继续。这样不是为了追求“永远没有冲突”，而是让冲突更早、更小、更容易解释。</p>
<p>我也不再把长期分支放到最后一天才合并。每天同步主分支，冲突仍然存在，但上下文还在脑子里，解决成本低很多。</p>
<h2 id="git-能保存历史不能替团队沟通">Git 能保存历史，不能替团队沟通</h2>
<p>第一次冲突让我学到一个很具体的标准：合并结果必须同时回答两边为什么改，并经过两边场景验证。绿色状态、干净工作区和成功提交只是过程信号。</p>
<p>后来做更大的系统，冲突可能出现在数据库迁移、接口契约或基础设施配置里，已经不是几行 HTML。越靠近共享边界，越需要小提交、明确所有权和提前集成。Git 最有价值的地方不是帮我们避免分歧，而是让分歧有证据可查。</p>]]></description></item><item><title>为什么我选择前端</title><link>https://siegaii.com/articles/2017-08-why-frontend/</link><guid>https://siegaii.com/articles/2017-08-why-frontend/</guid><pubDate>Sun, 20 Aug 2017 00:00:00 GMT</pubDate><description><![CDATA[<p>决定转向软件开发，并不是因为计算机行业听起来更热门。更直接的原因是，我喜欢编程的工作方式：想法可以被实现，结果可以被验证，问题也很少因为身份而回避讨论。</p>
<p>在不同方向里，我最终更愿意从前端开始。它同时接近用户、设计与数据，一段代码的好坏会变成可感知的等待、反馈和操作路径。这种距离让我着迷。</p>
<h2 id="界面不是图片">界面不是图片</h2>
<p>刚学前端时，我把页面理解成需要还原的设计稿。做得多一些才发现，设计稿只是一个静止截面。真实界面要处理加载、空数据、异常、长文本、不同屏幕和连续操作。</p>
<p>前端工程师真正构建的是状态之间的转换。按钮什么时候可用，请求失败后如何恢复，输入是否被保留，这些并不总能从一张图里得到答案。</p>
<p>我真正被吸引的，是一个需求从模糊语言变成可操作系统的过程。比如“提交后给用户反馈”，至少要继续追问：请求超过三秒怎么办，重复点击是否创建两条数据，失败后输入还在不在，成功提示消失后用户去哪里。设计稿通常只画出成功那一帧，代码必须补齐其余时间。</p>
<p>当时我会把一个简单提交按钮拆成下面几种状态：</p>
<table>
<thead>
<tr>
<th>状态</th>
<th>按钮</th>
<th>输入</th>
<th>页面反馈</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>editing</span></span></code></span></td>
<td>可点击</td>
<td>可编辑</td>
<td>无</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>submitting</span></span></code></span></td>
<td>禁用</td>
<td>保留内容</td>
<td>显示进行中</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>failed</span></span></code></span></td>
<td>可重试</td>
<td>保留内容</td>
<td>说明失败原因</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>succeeded</span></span></code></span></td>
<td>返回下一步</td>
<td>按业务决定清空</td>
<td>明确结果</td>
</tr>
</tbody>
</table>
<p>这张表没有任何框架知识，但它会直接决定组件状态、接口幂等和错误文案。前端离用户近，意味着它经常是系统矛盾最早显形的地方。</p>
<h2 id="技术与人的交界面">技术与人的交界面</h2>
<p>服务端可以用接口描述能力，设计可以用视觉说明意图，用户只会面对最终界面。前端需要理解三者，并把冲突变成可执行的选择。它既要求技术严谨，也要求对人的行为保持敏感。</p>
<p>我也喜欢程序员圈子里相对直接的讨论氛围。代码是否可读、方案是否成立，可以通过事实和推理检验。技术不会自动带来平等，但它至少提供了一种共同语言。</p>
<p>后来我不再用“离用户近”给前端加光环。离用户近也意味着要承接上游的不完整：接口字段含义不清、权限规则冲突、加载时间过长，最终都会变成用户看见的问题。好的前端工作不是在页面层把这些问题藏好，而是沿数据流找到责任位置，并和上下游一起修正。</p>
<h2 id="兴趣要落在日常工作里">兴趣要落在日常工作里</h2>
<p>职业选择不能只依赖对成品的喜欢。我也尝试接受前端真实而琐碎的一面：调试兼容问题、反复核对状态、阅读不完整接口、修复用户很少注意却会影响操作的细节。如果只有完成漂亮页面时才有热情，这份兴趣很难持续。</p>
<p>让我确定方向的，是这些细节依然值得钻研。问题解决后，界面会变得更可靠，使用者也许不知道原因，却能自然完成任务。这种不显眼但确切的改善，比学习某个热门框架更能说明我愿意长期做什么。</p>
<p>选择前端不是选择更轻的一层，而是选择站在系统最接近人的位置。这里变化快、细节多，也更容易看到自己的工作是否真的有用。</p>
<p><img src="/diagrams/series/legacy-frontend-system-path.svg" alt="一次用户动作从界面经过服务端和数据再返回反馈的完整路径"></p>
<p><em>图：前端是进入系统的入口，不是责任停止的位置。</em></p>
<h2 id="多年后我怎样修正这次选择">多年后我怎样修正这次选择</h2>
<p>前端确实给了我最快的反馈，但后来真正让我留下来的不是像素，而是用户动作怎样穿过状态、接口和数据。每次只修视觉层、根因却在服务端或契约时，我都会把依赖链补进笔记。</p>
<p>我现在判断一项能力是否值得继续深挖，会记录三件事：它解决了哪个真实问题；失败时影响谁；我能否把经验写成下一位同事可复用的工具或规则。职业方向不是一次选择完成的，而是被这些证据持续校正。</p>]]></description></item><item><title>第一次用 Network 面板拆开一个慢请求</title><link>https://siegaii.com/articles/2017-06-network-panel-http/</link><guid>https://siegaii.com/articles/2017-06-network-panel-http/</guid><pubDate>Sat, 24 Jun 2017 00:00:00 GMT</pubDate><description><![CDATA[<p>2017 年做练习页面时，我引入了一张接近 5 MB 的背景图，又从两个公共 CDN 加载脚本。开发服务器在本机看起来还行，换到手机热点后首屏要四秒多。我第一反应是“JavaScript 太慢”，于是删了几个循环，几乎没有变化。</p>
<p>老师让我打开 DevTools 的 Network 面板，看 Waterfall。那是我第一次意识到，请求的“耗时”不是一个整体。浏览器可能在等连接槽位，做 DNS 查询，建立 TCP/TLS，等待服务器首字节，最后才下载响应。</p>
<p><img src="/diagrams/series/2017-http-request-waterfall.svg" alt="浏览器请求经过 DNS、建连、服务端处理、缓存和资源解析的时序"></p>
<p><em>图 1：优化之前先找到时间花在哪个阶段；不同阶段需要完全不同的处理。</em></p>
<h2 id="waterfall-里的每一段都在说话">Waterfall 里的每一段都在说话</h2>
<p>我把当时最慢的几类资源整理成表：</p>
<table>
<thead>
<tr>
<th>现象</th>
<th>Waterfall 特征</th>
<th>当时的根因</th>
</tr>
</thead>
<tbody>
<tr>
<td>背景图很慢</td>
<td>Download 很长</td>
<td>图片体积过大，没有压缩</td>
</tr>
<tr>
<td>第三方脚本启动慢</td>
<td>DNS + SSL 很长</td>
<td>来自新的跨域主机，无法复用连接</td>
</tr>
<tr>
<td>API 白等一秒</td>
<td>TTFB 很长</td>
<td>服务端故意 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>sleep</span></span></code></span> 的模拟接口</td>
</tr>
<tr>
<td>多张小图排队</td>
<td>Queueing/Stalled 很长</td>
<td>同源并发连接受限</td>
</tr>
<tr>
<td>刷新后突然变快</td>
<td>Size 显示 memory/disk cache</td>
<td>命中浏览器缓存</td>
</tr>
</tbody>
</table>
<p>“接口慢”只有在 TTFB 主要由服务端等待组成时才成立。如果 DNS 就花了八百毫秒，改数据库查询没有任何帮助。</p>
<h2 id="先用可复现条件测量">先用可复现条件测量</h2>
<p>浏览器缓存会让第二次刷新显得很快。为了比较修改前后，我固定了条件：打开 Disable cache；使用相同网络节流；硬刷新；记录 DOMContentLoaded、Load 和最大资源结束时间；每种方案跑三次取中位数。</p>
<p>我没有追求一个漂亮的 Lighthouse 分数，只回答三个问题：首屏必要资源何时到齐；用户什么时候能看到主要内容；哪一个请求占据最长关键路径。</p>
<h2 id="图片优化比删循环有效得多">图片优化比删循环有效得多</h2>
<p>那张 5 MB 图片最终被裁到实际展示尺寸并压缩，体积降到几百 KB。更重要的是，我给图片写了明确尺寸，避免加载完成后页面整体跳动：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="html" data-theme="github-dark-default"><code data-language="html" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">&#x3C;</span><span style="color:#7EE787">img</span></span>
<span data-line=""><span style="color:#79C0FF">  src</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"/images/workbench-960.jpg"</span></span>
<span data-line=""><span style="color:#79C0FF">  width</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"960"</span></span>
<span data-line=""><span style="color:#79C0FF">  height</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"540"</span></span>
<span data-line=""><span style="color:#79C0FF">  alt</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"工作台界面"</span></span>
<span data-line=""><span style="color:#E6EDF3">></span></span></code></pre></figure>
<p>现代项目会使用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>srcset</span></span></code></span>、WebP/AVIF 和图片服务，但原则没有变化：传输的像素应该接近用户实际能看到的像素；布局在资源到达前就应该知道尺寸。</p>
<h2 id="第三方资源也属于自己的性能预算">第三方资源也属于自己的性能预算</h2>
<p>我曾觉得 CDN 上的脚本“不占自己服务器带宽”，就可以放心引入。Network 面板告诉我，用户仍然要为新域名的 DNS、TLS 和下载付费，而且第三方可用性不受我控制。</p>
<p>后来我给外部资源留下清单：用途、所有者、加载阶段、失败后影响和移除条件。一个只用于小动画的库，如果阻塞首屏，它的成本就高于功能价值。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>资源: analytics.example/sdk.js</span></span>
<span data-line=""><span>是否首屏必要: 否</span></span>
<span data-line=""><span>加载策略: defer + 页面可交互后加载</span></span>
<span data-line=""><span>超时影响: 放弃统计，不阻塞业务</span></span>
<span data-line=""><span>负责人: growth</span></span></code></pre></figure>
<h2 id="请求完成不代表页面可用">请求完成不代表页面可用</h2>
<p>Network 面板只能告诉我资源何时到达。脚本下载后还要解析执行，数据返回后还要渲染。为了不把网络与主线程混在一起，我后来同时看 Performance 面板：如果请求很快但页面仍卡住，就继续检查长任务、布局和绘制。</p>
<p>这次优化最有价值的变化，是我不再用“页面慢”概括所有问题。先把时间线拆开，再对症处理：传输大就减体积，连接多就合并域名或复用，TTFB 长就看服务端，主线程忙就看执行。一个模糊抱怨只有变成分段数据，才会变成工程问题。</p>]]></description></item><item><title>浏览器里的第一张完整页面</title><link>https://siegaii.com/articles/2017-03-browser-first-page/</link><guid>https://siegaii.com/articles/2017-03-browser-first-page/</guid><pubDate>Sat, 11 Mar 2017 00:00:00 GMT</pubDate><description><![CDATA[<p>从 Java 转向网页开发后，最直接的惊喜是反馈速度。保存 HTML，刷新浏览器，文字和布局马上出现。相比等待编译，这种即时反馈让学习更像在搭建一个可以触摸的东西。</p>
<p>第一张完整页面包含导航、文章列表和一个表单。它能运行，却没有结构：大量 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>div</span></span></code></span> 混在一起，样式写在标签里，JavaScript 通过层层父子节点寻找元素。新增一个模块，原有选择器就可能失效。</p>
<p>最先坏掉的是表单错误提示。我把脚本写成了“找到表单第二个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>div</span></span></code></span>，把它改成红色”。设计调整字段顺序后，脚本仍然运行，只是把帮助文字标红了。修复它时，我第一次给行为建立了独立契约：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="html" data-theme="github-dark-default"><code data-language="html" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">&#x3C;</span><span style="color:#7EE787">form</span><span style="color:#79C0FF"> data-newsletter-form</span><span style="color:#79C0FF"> novalidate</span><span style="color:#E6EDF3">></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;</span><span style="color:#7EE787">label</span><span style="color:#79C0FF"> for</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"email"</span><span style="color:#E6EDF3">>邮箱&#x3C;/</span><span style="color:#7EE787">label</span><span style="color:#E6EDF3">></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;</span><span style="color:#7EE787">input</span><span style="color:#79C0FF"> id</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"email"</span><span style="color:#79C0FF"> name</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"email"</span><span style="color:#79C0FF"> type</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"email"</span><span style="color:#79C0FF"> aria-describedby</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"email-error"</span><span style="color:#E6EDF3"> /></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;</span><span style="color:#7EE787">p</span><span style="color:#79C0FF"> id</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"email-error"</span><span style="color:#79C0FF"> data-error</span><span style="color:#79C0FF"> aria-live</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"polite"</span><span style="color:#E6EDF3">>&#x3C;/</span><span style="color:#7EE787">p</span><span style="color:#E6EDF3">></span></span>
<span data-line=""><span style="color:#E6EDF3">  &#x3C;</span><span style="color:#7EE787">button</span><span style="color:#79C0FF"> type</span><span style="color:#E6EDF3">=</span><span style="color:#A5D6FF">"submit"</span><span style="color:#E6EDF3">>订阅&#x3C;/</span><span style="color:#7EE787">button</span><span style="color:#E6EDF3">></span></span>
<span data-line=""><span style="color:#E6EDF3">&#x3C;/</span><span style="color:#7EE787">form</span><span style="color:#E6EDF3">></span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark-default"><code data-language="js" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> form</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> document.</span><span style="color:#D2A8FF">querySelector</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"[data-newsletter-form]"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> email</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> form.elements.</span><span style="color:#D2A8FF">namedItem</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"email"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> error</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> form.</span><span style="color:#D2A8FF">querySelector</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"[data-error]"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">form.</span><span style="color:#D2A8FF">addEventListener</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"submit"</span><span style="color:#E6EDF3">, (</span><span style="color:#FFA657">event</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">  event.</span><span style="color:#D2A8FF">preventDefault</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> message</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> email.validity.valid </span><span style="color:#FF7B72">?</span><span style="color:#A5D6FF"> ""</span><span style="color:#FF7B72"> :</span><span style="color:#A5D6FF"> "请输入有效邮箱"</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#E6EDF3">  error.textContent </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> message;</span></span>
<span data-line=""><span style="color:#E6EDF3">  email.</span><span style="color:#D2A8FF">setAttribute</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"aria-invalid"</span><span style="color:#E6EDF3">, </span><span style="color:#D2A8FF">String</span><span style="color:#E6EDF3">(</span><span style="color:#D2A8FF">Boolean</span><span style="color:#E6EDF3">(message)));</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">message) form.</span><span style="color:#D2A8FF">submit</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span></code></pre></figure>
<p>这里的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>data-*</span></span></code></span> 不是为了多写属性，而是把脚本依赖和视觉类名分开；<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>aria-live</span></span></code></span> 则让我意识到页面反馈不能只靠颜色。一个很小的表单，已经同时包含结构、行为、可访问性和浏览器约束。</p>
<h2 id="三层不是三个文件">三层不是三个文件</h2>
<p>把 HTML、CSS 和 JavaScript 分开并不会自动得到清晰结构。真正的分离是让每一层承担明确责任：HTML 表达内容关系，CSS 管理视觉规则，JavaScript 处理状态与交互。</p>
<p>例如一个错误提示，应当在结构上属于表单，在样式上有独立状态，在行为上由验证结果驱动。如果 JavaScript 直接写颜色和位置，规则便散落在三个地方。</p>
<h2 id="dom-是一份公共契约">DOM 是一份公共契约</h2>
<p>页面结构同时被样式和脚本依赖。随意修改类名，看似只是视觉调整，也可能破坏行为。后来我开始为行为使用独立标记，不让样式选择器承担脚本契约，并尽量减少依赖层级关系的查询。</p>
<p>浏览器开发的门槛很低，但它不会替开发者管理复杂度。一个页面从几十行长到几千行时，早期那些看似无关紧要的边界会突然变得昂贵。</p>
<p>我当时给自己定了三个检查问题，每做完一块页面就走一遍：</p>
<ol>
<li>删除 CSS 后，HTML 的阅读顺序是否仍然成立。</li>
<li>改动视觉类名，JavaScript 是否还会工作。</li>
<li>只用键盘，能否完成表单和导航。</li>
</ol>
<p>它们比“在我的浏览器里看起来一样”严格得多，也让我开始把浏览器当运行时，而不是画布。</p>
<h2 id="浏览器差异是真实约束">浏览器差异是真实约束</h2>
<p>同一个页面在不同浏览器中出现偏差时，我最初会继续添加覆盖样式。后来才明白应该先确认标准行为、默认样式和兼容范围。渐进增强比追求所有环境像素一致更实际：核心内容和操作先可用，较新的能力再按支持情况加入。</p>
<p>这也让我第一次体会到 Web 的开放性。页面不运行在受控机器上，字体、屏幕、网络和输入方式都可能不同。前端不是为一张截图编码，而是为一组无法完全预测的环境建立有弹性的规则。</p>
<p>第一次完成页面让我确认自己喜欢这种工作：不仅写逻辑，也直接面对使用者看到和感受到的结果。工程与体验在浏览器里几乎没有距离。</p>
<p><img src="/diagrams/series/legacy-first-web-page.svg" alt="一张可交付网页的内容、交互和运行环境三层结构"></p>
<p><em>图：页面能显示只是第一层，资源失败和键盘操作同样属于交付结果。</em></p>
<h2 id="第一张页面的交付清单">第一张页面的交付清单</h2>
<p>我后来把当时反复遗漏的事情写成六项：禁用 CSS 后内容顺序仍可读；所有图片有尺寸和替代文本；键盘能走完主要操作；网络失败有明确状态；375px 宽度没有整页溢出；控制台没有未处理错误。</p>
<p>这个清单没有设计系统那么完整，却把“我电脑上看起来正常”变成了可重复验收。对初学者而言，先稳定交付一张小页面，比一次引入更多框架更能建立工程直觉。</p>]]></description></item><item><title>一次点击执行两遍：我怎样弄懂 DOM 事件传播</title><link>https://siegaii.com/articles/2017-01-dom-event-propagation/</link><guid>https://siegaii.com/articles/2017-01-dom-event-propagation/</guid><pubDate>Sat, 21 Jan 2017 00:00:00 GMT</pubDate><description><![CDATA[<p>刚会用 JavaScript 操作 DOM 时，我做了一个待办列表。点击每行的“删除”按钮，有时一条记录会被删除，有时连下一条也消失。控制台里同一个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>id</span></span></code></span> 偶尔打印两次。我最初在每个回调里加 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>return false</span></span></code></span>，又到处塞 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>stopPropagation()</span></span></code></span>，问题暂时消失，却不知道自己阻止了什么。</p>
<p>真正的原因是我同时给列表容器和每个按钮绑定了处理器。按钮点击后先在目标上执行，事件又冒泡到容器，两个处理器都调用了删除逻辑。动态新增的行只有容器代理，旧行却有两套监听器，因此现象看起来还不稳定。</p>
<p><img src="/diagrams/series/2017-dom-event-propagation.svg" alt="DOM 事件从 document 捕获到目标，再逐层冒泡的传播流程"></p>
<p><em>图 1：一次用户动作只创建一个事件对象，但它会经过多个节点和阶段。</em></p>
<h2 id="先把事件路径打印出来">先把事件路径打印出来</h2>
<p>与其继续猜，我在捕获和冒泡阶段分别注册日志：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark-default"><code data-language="js" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> list</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> document.</span><span style="color:#D2A8FF">querySelector</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"#todo-list"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">for</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">const</span><span style="color:#79C0FF"> capture</span><span style="color:#FF7B72"> of</span><span style="color:#E6EDF3"> [</span><span style="color:#79C0FF">true</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">false</span><span style="color:#E6EDF3">]) {</span></span>
<span data-line=""><span style="color:#E6EDF3">  document.</span><span style="color:#D2A8FF">addEventListener</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"click"</span><span style="color:#E6EDF3">, (</span><span style="color:#FFA657">event</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#E6EDF3">    console.</span><span style="color:#D2A8FF">log</span><span style="color:#E6EDF3">({</span></span>
<span data-line=""><span style="color:#E6EDF3">      phase: event.eventPhase,</span></span>
<span data-line=""><span style="color:#E6EDF3">      capture,</span></span>
<span data-line=""><span style="color:#E6EDF3">      target: event.target.dataset.action,</span></span>
<span data-line=""><span style="color:#E6EDF3">      current: event.currentTarget.nodeName,</span></span>
<span data-line=""><span style="color:#E6EDF3">    });</span></span>
<span data-line=""><span style="color:#E6EDF3">  }, capture);</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>target</span></span></code></span> 是真正被点击的元素，传播过程中不会改变；<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>currentTarget</span></span></code></span> 是当前正在执行监听器的节点。过去我把两者当成同一个东西，容器处理器拿到按钮事件时就很容易误判。</p>
<h2 id="事件代理只保留一个入口">事件代理只保留一个入口</h2>
<p>列表项会动态增加，逐个绑定监听器既重复又容易漏掉。我最后只在容器保留一个处理器，并用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>closest</span></span></code></span> 找到动作元素：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark-default"><code data-language="js" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">list.</span><span style="color:#D2A8FF">addEventListener</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"click"</span><span style="color:#E6EDF3">, (</span><span style="color:#FFA657">event</span><span style="color:#E6EDF3">) </span><span style="color:#FF7B72">=></span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> button</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> event.target.</span><span style="color:#D2A8FF">closest</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"button[data-action]"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">button </span><span style="color:#FF7B72">||</span><span style="color:#FF7B72"> !</span><span style="color:#E6EDF3">list.</span><span style="color:#D2A8FF">contains</span><span style="color:#E6EDF3">(button)) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  const</span><span style="color:#79C0FF"> row</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> button.</span><span style="color:#D2A8FF">closest</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"li[data-id]"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">row) </span><span style="color:#FF7B72">return</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">  if</span><span style="color:#E6EDF3"> (button.dataset.action </span><span style="color:#FF7B72">===</span><span style="color:#A5D6FF"> "delete"</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#D2A8FF">    removeTodo</span><span style="color:#E6EDF3">(row.dataset.id);</span></span>
<span data-line=""><span style="color:#E6EDF3">  }</span></span>
<span data-line=""><span style="color:#E6EDF3">});</span></span></code></pre></figure>
<p><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>list.contains(button)</span></span></code></span> 这一句是后来补上的。<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>closest</span></span></code></span> 可能找到嵌套在列表之外的祖先元素；代理处理器必须确认目标仍然属于自己的边界。</p>
<h2 id="不要把-stoppropagation-当修复">不要把 stopPropagation 当修复</h2>
<p>阻止传播有合理场景，例如一个可点击卡片里放了独立菜单，菜单操作不应该触发卡片跳转。但它不该用来掩盖重复职责。</p>
<table>
<thead>
<tr>
<th>问题</th>
<th>更合适的处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>同一业务动作绑定两处</td>
<td>删除一套处理器，保留单一入口</td>
</tr>
<tr>
<td>父组件不应响应子动作</td>
<td>父处理器检查目标和动作类型</td>
</tr>
<tr>
<td>组件确实需要隔离事件</td>
<td>在明确边界调用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>stopPropagation</span></span></code></span></td>
</tr>
<tr>
<td>同一处理器被重复注册</td>
<td>保存引用并在销毁时 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>removeEventListener</span></span></code></span></td>
</tr>
</tbody>
</table>
<p>还有一个教训是，匿名函数很难解除：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark-default"><code data-language="js" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">element.</span><span style="color:#D2A8FF">addEventListener</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"click"</span><span style="color:#E6EDF3">, () </span><span style="color:#FF7B72">=></span><span style="color:#D2A8FF"> save</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#E6EDF3">element.</span><span style="color:#D2A8FF">removeEventListener</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"click"</span><span style="color:#E6EDF3">, () </span><span style="color:#FF7B72">=></span><span style="color:#D2A8FF"> save</span><span style="color:#E6EDF3">()); </span><span style="color:#8B949E">// 不是同一个函数引用</span></span></code></pre></figure>
<p>如果组件会反复挂载，应该保留处理器引用，或使用生命周期统一注册和清理。否则“点击两次”可能不是传播，而是监听器泄漏。</p>
<h2 id="用可观察结果验证而不是只看日志">用可观察结果验证，而不是只看日志</h2>
<p>最后我给列表留下三个手工回归用例：旧行和新行都只删除一次；点击行内文本不会删除；点击菜单不会触发行跳转。后来有了测试框架，我会直接统计业务函数调用次数：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark-default"><code data-language="js" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">deleteButton.</span><span style="color:#D2A8FF">click</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#D2A8FF">expect</span><span style="color:#E6EDF3">(removeTodo).</span><span style="color:#D2A8FF">toHaveBeenCalledTimes</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">1</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#D2A8FF">expect</span><span style="color:#E6EDF3">(removeTodo).</span><span style="color:#D2A8FF">toHaveBeenCalledWith</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"todo-17"</span><span style="color:#E6EDF3">);</span></span></code></pre></figure>
<p>这次 bug 让我第一次真正去理解浏览器的运行规则。很多前端问题表面上是一行代码，背后却是事件、渲染和生命周期的系统行为。知道事件为什么经过这里，比记住在哪里加一句 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>stopPropagation()</span></span></code></span> 更能长期复用。</p>]]></description></item><item><title>第一个真正有用的小工具：批量改名前先生成计划</title><link>https://siegaii.com/articles/2016-12-batch-rename-tool/</link><guid>https://siegaii.com/articles/2016-12-batch-rename-tool/</guid><pubDate>Sat, 10 Dec 2016 00:00:00 GMT</pubDate><description><![CDATA[<p>2016 年底，我的课程资料目录里堆着几十个名字混乱的视频：<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>1.mp4</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>01-1.mp4</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>新建文件夹(2).mp4</span></span></code></span>。手工整理到第十几个时，我决定写一个批量改名工具。这是我第一次写“自己真的会继续用”的程序，也因此第一次意识到，文件操作失败不会像练习题一样自动恢复现场。</p>
<p>第一版逻辑只有三步：遍历目录、拼接新名字、调用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>renameTo</span></span></code></span>。测试目录里一切正常，换到真实目录就遇到同名覆盖、文件顺序不稳定和改到一半失败。最危险的是，程序没有记录已经改了哪些文件，我只能凭记忆一点点找回来。</p>
<p><img src="/diagrams/series/2016-file-tool-transaction.svg" alt="批量改名从扫描、生成计划、人工确认到失败回滚的时序"></p>
<p><em>图 1：对不可轻易恢复的操作，先把“准备做什么”变成一份可检查的数据。</em></p>
<h2 id="把改名设计成两阶段操作">把改名设计成两阶段操作</h2>
<p>我后来不再边遍历边改名，而是先生成完整计划：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">record</span><span style="color:#FFA657"> RenamePlan</span><span style="color:#E6EDF3">(Path source, Path target) {}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">List</span><span style="color:#FF7B72">&#x3C;</span><span style="color:#E6EDF3">RenamePlan</span><span style="color:#FF7B72">></span><span style="color:#D2A8FF"> buildPlan</span><span style="color:#E6EDF3">(Path directory) throws IOException {</span></span>
<span data-line=""><span style="color:#FF7B72">    try</span><span style="color:#E6EDF3"> (Stream</span><span style="color:#FFA657">&#x3C;</span><span style="color:#FF7B72">Path</span><span style="color:#FFA657">> </span><span style="color:#E6EDF3">files</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Files.</span><span style="color:#D2A8FF">list</span><span style="color:#E6EDF3">(directory)) {</span></span>
<span data-line=""><span style="color:#E6EDF3">        List</span><span style="color:#FFA657">&#x3C;</span><span style="color:#FF7B72">Path</span><span style="color:#FFA657">> </span><span style="color:#E6EDF3">sorted</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> files</span></span>
<span data-line=""><span style="color:#E6EDF3">            .</span><span style="color:#D2A8FF">filter</span><span style="color:#E6EDF3">(Files</span><span style="color:#FF7B72">::</span><span style="color:#E6EDF3">isRegularFile)</span></span>
<span data-line=""><span style="color:#E6EDF3">            .</span><span style="color:#D2A8FF">sorted</span><span style="color:#E6EDF3">(Comparator.</span><span style="color:#D2A8FF">comparing</span><span style="color:#E6EDF3">(path </span><span style="color:#FF7B72">-></span><span style="color:#E6EDF3"> path.</span><span style="color:#D2A8FF">getFileName</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">toString</span><span style="color:#E6EDF3">()))</span></span>
<span data-line=""><span style="color:#E6EDF3">            .</span><span style="color:#D2A8FF">toList</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E6EDF3">        List</span><span style="color:#FFA657">&#x3C;</span><span style="color:#FF7B72">RenamePlan</span><span style="color:#FFA657">> </span><span style="color:#E6EDF3">plans</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#E6EDF3"> ArrayList&#x3C;>();</span></span>
<span data-line=""><span style="color:#FF7B72">        for</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">int</span><span style="color:#E6EDF3"> index</span><span style="color:#FF7B72"> =</span><span style="color:#79C0FF"> 0</span><span style="color:#E6EDF3">; index </span><span style="color:#FF7B72">&#x3C;</span><span style="color:#E6EDF3"> sorted.</span><span style="color:#D2A8FF">size</span><span style="color:#E6EDF3">(); index</span><span style="color:#FF7B72">++</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#E6EDF3">            Path</span><span style="color:#E6EDF3"> source</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> sorted.</span><span style="color:#D2A8FF">get</span><span style="color:#E6EDF3">(index);</span></span>
<span data-line=""><span style="color:#E6EDF3">            String</span><span style="color:#E6EDF3"> extension</span><span style="color:#FF7B72"> =</span><span style="color:#D2A8FF"> extensionOf</span><span style="color:#E6EDF3">(source.</span><span style="color:#D2A8FF">getFileName</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">toString</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#E6EDF3">            Path</span><span style="color:#E6EDF3"> target</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> directory.</span><span style="color:#D2A8FF">resolve</span><span style="color:#E6EDF3">(String.</span><span style="color:#D2A8FF">format</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"lesson-%03d%s"</span><span style="color:#E6EDF3">, index </span><span style="color:#FF7B72">+</span><span style="color:#79C0FF"> 1</span><span style="color:#E6EDF3">, extension));</span></span>
<span data-line=""><span style="color:#E6EDF3">            plans.</span><span style="color:#D2A8FF">add</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">new</span><span style="color:#D2A8FF"> RenamePlan</span><span style="color:#E6EDF3">(source, target));</span></span>
<span data-line=""><span style="color:#E6EDF3">        }</span></span>
<span data-line=""><span style="color:#FF7B72">        return</span><span style="color:#E6EDF3"> plans;</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>计划生成后先打印 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>old → new</span></span></code></span>，默认不执行。只有传入 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>--apply</span></span></code></span> 并再次确认，才进入文件系统修改阶段。今天看这就是很普通的 dry-run，当时它第一次让我感受到“数据与副作用分开”的价值。</p>
<h2 id="执行前一次性检查所有冲突">执行前一次性检查所有冲突</h2>
<p>如果改到第 17 个文件才发现目标名已存在，前 16 个已经改变。更可靠的做法是在执行前验证整份计划：</p>
<ul>
<li>源文件是否仍然存在；</li>
<li>目标路径是否重复；</li>
<li>目标文件是否已经存在且不在本次源集合中；</li>
<li>源与目标是否位于同一文件系统；</li>
<li>当前进程是否拥有写权限；</li>
<li>名称在目标系统中是否合法。</li>
</ul>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">void</span><span style="color:#D2A8FF"> validate</span><span style="color:#E6EDF3">(List</span><span style="color:#FF7B72">&#x3C;</span><span style="color:#E6EDF3">RenamePlan</span><span style="color:#FF7B72">></span><span style="color:#E6EDF3"> plans) {</span></span>
<span data-line=""><span style="color:#E6EDF3">    Set</span><span style="color:#FFA657">&#x3C;</span><span style="color:#FF7B72">Path</span><span style="color:#FFA657">> </span><span style="color:#E6EDF3">sources</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> plans.</span><span style="color:#D2A8FF">stream</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">map</span><span style="color:#E6EDF3">(RenamePlan</span><span style="color:#FF7B72">::</span><span style="color:#E6EDF3">source).</span><span style="color:#D2A8FF">collect</span><span style="color:#E6EDF3">(Collectors.</span><span style="color:#D2A8FF">toSet</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#E6EDF3">    Set</span><span style="color:#FFA657">&#x3C;</span><span style="color:#FF7B72">Path</span><span style="color:#FFA657">> </span><span style="color:#E6EDF3">targets</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#E6EDF3"> HashSet&#x3C;>();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">    for</span><span style="color:#E6EDF3"> (RenamePlan</span><span style="color:#E6EDF3"> plan</span><span style="color:#FF7B72"> :</span><span style="color:#E6EDF3"> plans) {</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">Files.</span><span style="color:#D2A8FF">exists</span><span style="color:#E6EDF3">(plan.</span><span style="color:#D2A8FF">source</span><span style="color:#E6EDF3">())) </span><span style="color:#FF7B72">throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalStateException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"missing: "</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> plan.</span><span style="color:#D2A8FF">source</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">!</span><span style="color:#E6EDF3">targets.</span><span style="color:#D2A8FF">add</span><span style="color:#E6EDF3">(plan.</span><span style="color:#D2A8FF">target</span><span style="color:#E6EDF3">())) </span><span style="color:#FF7B72">throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalStateException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"duplicate target: "</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> plan.</span><span style="color:#D2A8FF">target</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (Files.</span><span style="color:#D2A8FF">exists</span><span style="color:#E6EDF3">(plan.</span><span style="color:#D2A8FF">target</span><span style="color:#E6EDF3">()) </span><span style="color:#FF7B72">&#x26;&#x26;</span><span style="color:#FF7B72"> !</span><span style="color:#E6EDF3">sources.</span><span style="color:#D2A8FF">contains</span><span style="color:#E6EDF3">(plan.</span><span style="color:#D2A8FF">target</span><span style="color:#E6EDF3">())) {</span></span>
<span data-line=""><span style="color:#FF7B72">            throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalStateException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"target exists: "</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> plan.</span><span style="color:#D2A8FF">target</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#E6EDF3">        }</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<h2 id="交换名称需要中间态">交换名称需要中间态</h2>
<p>假设 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>A.mp4</span></span></code></span> 要改成 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>B.mp4</span></span></code></span>，同时 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>B.mp4</span></span></code></span> 要改成 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>A.mp4</span></span></code></span>。直接执行一定冲突。解决方式是先把所有源文件移动到不会碰撞的临时名，再从临时名移动到最终名：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>A.mp4  -> .rename-tmp/001</span></span>
<span data-line=""><span>B.mp4  -> .rename-tmp/002</span></span>
<span data-line=""><span>.rename-tmp/001 -> B.mp4</span></span>
<span data-line=""><span>.rename-tmp/002 -> A.mp4</span></span></code></pre></figure>
<p>这不是数据库意义上的原子事务，进程仍可能在中途退出，但它消除了计划内部的名称覆盖，也让恢复路径更清晰。</p>
<h2 id="每成功一步就写回滚清单">每成功一步就写回滚清单</h2>
<p>我给每次执行生成一个 JSON 清单，里面记录批次号、源路径、临时路径、目标路径和当前阶段。每完成一次移动，就落盘更新状态。失败时按完成记录逆序恢复：</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th>已完成动作</th>
<th>恢复方式</th>
</tr>
</thead>
<tbody>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>planned</span></span></code></span></td>
<td>尚未改动</td>
<td>直接退出</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>staged</span></span></code></span></td>
<td>源文件已进入临时目录</td>
<td>临时路径移回源路径</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>committed</span></span></code></span></td>
<td>已改为目标名</td>
<td>目标路径按逆序移回源路径</td>
</tr>
<tr>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>rollback_failed</span></span></code></span></td>
<td>部分恢复失败</td>
<td>保留清单，停止自动操作</td>
</tr>
</tbody>
</table>
<p>最后一种状态很重要。程序不能在恢复失败后继续“努力”，否则可能破坏更多文件。它应该停下来，把真实状态和需要人工处理的路径说清楚。</p>
<h2 id="安全默认值比使用说明更可靠">安全默认值比使用说明更可靠</h2>
<p>这个工具最后保留了几条默认规则：默认 dry-run；不覆盖已有文件；执行前打印总文件数和目标目录；真实执行要求输入批次摘要；所有改动写清单；任何异常立刻停止。</p>
<p>那时我还不知道“可逆操作”“补偿事务”这些词，但已经被一次半成功的改名教育过。后来做数据库迁移、批量发布和 Agent 工具调用，我都会先问同一个问题：如果它只做完一半，我们靠什么知道现场，又靠什么回来？</p>]]></description></item><item><title>第一次真正理解抽象</title><link>https://siegaii.com/articles/2016-09-abstraction-first-time/</link><guid>https://siegaii.com/articles/2016-09-abstraction-first-time/</guid><pubDate>Sat, 24 Sep 2016 00:00:00 GMT</pubDate><description><![CDATA[<p>学习面向对象时，最常见的例子是动物会叫、汽车会跑。代码很容易照着写，理解却很浅：似乎只要出现 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>class</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>extends</span></span></code></span> 和 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>interface</span></span></code></span>，就完成了抽象。</p>
<p>真正让我理解抽象的，是一次很普通的课程练习。程序要处理几种不同零件的面积和重量，我一开始为每种零件复制一套计算流程。数量少时没有问题，公式一改，所有分支都要跟着修改。</p>
<h2 id="抽象保护的是什么">抽象保护的是什么</h2>
<p>我尝试把共同属性提出来，结果第一次抽得太多：不同零件被迫拥有并不适合它们的字段，调用方仍然到处判断类型。第二次则只保留计算需要的最小契约，让每个对象自己回答“面积是多少”“重量是多少”。</p>
<p>第二版的核心不是继承树，而是一个很窄的接口：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">interface</span><span style="color:#FFA657"> Weighable</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    double</span><span style="color:#D2A8FF"> volumeM3</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">    default</span><span style="color:#FF7B72"> double</span><span style="color:#D2A8FF"> weightKg</span><span style="color:#E6EDF3">(Material </span><span style="color:#FFA657">material</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">        return</span><span style="color:#D2A8FF"> volumeM3</span><span style="color:#E6EDF3">() </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> material.</span><span style="color:#D2A8FF">densityKgPerM3</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">record</span><span style="color:#FFA657"> Material</span><span style="color:#E6EDF3">(String name, </span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3"> densityKgPerM3) {}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">record</span><span style="color:#FFA657"> Plate</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3"> lengthM, </span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3"> widthM, </span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3"> thicknessM) </span><span style="color:#FF7B72">implements</span><span style="color:#79C0FF"> Weighable</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    public</span><span style="color:#FF7B72"> double</span><span style="color:#D2A8FF"> volumeM3</span><span style="color:#E6EDF3">() {</span></span>
<span data-line=""><span style="color:#FF7B72">        return</span><span style="color:#E6EDF3"> lengthM </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> widthM </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> thicknessM;</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">record</span><span style="color:#FFA657"> Cylinder</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3"> radiusM, </span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3"> heightM) </span><span style="color:#FF7B72">implements</span><span style="color:#79C0FF"> Weighable</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    public</span><span style="color:#FF7B72"> double</span><span style="color:#D2A8FF"> volumeM3</span><span style="color:#E6EDF3">() {</span></span>
<span data-line=""><span style="color:#FF7B72">        return</span><span style="color:#E6EDF3"> Math.PI </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> radiusM </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> radiusM </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> heightM;</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>调用方只依赖 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>volumeM3()</span></span></code></span>，材料密度也不再复制到每种零件里。新加圆柱时，重量统计代码没有变化，这才是抽象带来的真实收益。</p>
<p>第一版为什么失败也很具体。我曾经设计过一个包含 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>length</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>width</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>height</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>radius</span></span></code></span> 的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Part</span></span></code></span> 基类。板材的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>radius</span></span></code></span> 永远是空，圆柱的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>width</span></span></code></span> 又没有意义。为了让字段“通用”，我制造了一组对象必须互相猜测的无效状态。</p>
<p>这时我才意识到，抽象的目的不是消除所有差异，而是保护调用方不受无关差异影响。它应该把稳定的认识写进接口，把变化留在实现里。</p>
<h2 id="不要急着复用">不要急着复用</h2>
<p>重复代码让人不舒服，但过早抽象会制造更隐蔽的问题。两段代码今天相似，不代表它们由同一种原因变化。如果只是为了少写几行就合并，它们以后可能被迫一起演进。</p>
<p>判断是否应该抽象，可以先问三个问题：调用者真正依赖什么；哪些规则会一起变化；删除某个实现后，契约是否仍然成立。回答不清楚时，保留少量重复比制造错误关系更安全。</p>
<p>我后来会用一张变化表检查抽象是否站得住：</p>
<table>
<thead>
<tr>
<th>变化</th>
<th>应该修改的位置</th>
<th>不应该被影响的部分</th>
</tr>
</thead>
<tbody>
<tr>
<td>增加一种零件形状</td>
<td>新实现的体积公式</td>
<td>重量统计与材料选择</td>
</tr>
<tr>
<td>增加材料</td>
<td>材料数据</td>
<td>所有形状类</td>
</tr>
<tr>
<td>改变显示单位</td>
<td>输出适配层</td>
<td>内部体积计算</td>
</tr>
</tbody>
</table>
<p>如果一次变化需要同时修改接口两侧很多文件，边界可能选错了。如果完全不同的变化总要进入同一个“通用类”，它也许只是一个名字好听的耦合点。</p>
<h2 id="命名是抽象的测试">命名是抽象的测试</h2>
<p>如果很难给一个类或接口起准确名字，通常不是词汇不足，而是它承担了几种无关责任。<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Manager</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Helper</span></span></code></span> 这类名字可以容纳任何行为，也因此没有提供信息。我开始尝试用领域中的名词描述对象，用可观察的动作描述方法，并删除那些只能通过注释解释的模糊概念。</p>
<p>一个好名字不会让设计自动正确，却能迫使自己回答“这个东西究竟代表什么”。当名字需要频繁改变，往往说明对问题的认识仍在变化，此时保持实现简单比急着建立继承体系更合适。</p>
<p>抽象不是代码技巧，而是分类能力。它要求我们承认自己对问题的理解有边界，也要求这个边界经得起下一次变化。</p>
<p><img src="/diagrams/series/legacy-abstraction-boundary.svg" alt="抽象成立所需的变化原因、领域语义和生命周期边界"></p>
<p><em>图：语法相似不等于共享边界，三类一致性决定抽象能否长期成立。</em></p>
<h2 id="判断抽象是否有用的三个问题">判断抽象是否有用的三个问题</h2>
<p>我后来不会因为出现两段相似代码就立即抽象，而是先问：它们会不会因为同一个业务原因变化；调用者是否需要理解相同语义；错误和生命周期能否用同一套规则处理。三项都接近，才提取共同边界。</p>
<table>
<thead>
<tr>
<th>信号</th>
<th>适合抽象</th>
<th>暂时保持重复</th>
</tr>
</thead>
<tbody>
<tr>
<td>同一规则多处实现</td>
<td>是</td>
<td></td>
</tr>
<tr>
<td>只是语法长得像</td>
<td></td>
<td>是</td>
</tr>
<tr>
<td>未来变化方向一致</td>
<td>是</td>
<td></td>
</tr>
<tr>
<td>为了减少几行代码</td>
<td></td>
<td>是</td>
</tr>
</tbody>
</table>
<p>抽象的验收不是文件更少，而是下一次规则变化只需要在一个清楚的位置解释。</p>]]></description></item><item><title>用 Java 写零件清单：数据模型从现实约束里长出来</title><link>https://siegaii.com/articles/2016-07-inventory-data-model/</link><guid>https://siegaii.com/articles/2016-07-inventory-data-model/</guid><pubDate>Sat, 30 Jul 2016 00:00:00 GMT</pubDate><description><![CDATA[<p>学到类和对象时，课程里的例子通常是学生、汽车和动物。我能照着写字段和 getter，却不明白为什么一定要这样组织。机械专业的课程里刚好有一份零件明细表：零件编号、名称、材料、数量、单价。于是我决定不用“学生类”，而是把一张真实的清单写成程序。</p>
<p>第一版很快完成：创建几个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Part</span></span></code></span>，放进数组，循环计算总价。它也很快暴露问题。数量能被写成负数；同一个编号可以出现两次；单价使用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>double</span></span></code></span> 后出现 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>19.9900000002</span></span></code></span>；有的零件按“个”，有的按“米”，我却把数量都当成整数。</p>
<p><img src="/diagrams/series/2016-inventory-model.svg" alt="零件清单从输入字段、领域规则到输出证据的数据模型"></p>
<p><em>图 1：程序不是把表格列搬成字段，还要把表格默认依赖的人类常识写成规则。</em></p>
<h2 id="先写不会轻易变化的事实">先写不会轻易变化的事实</h2>
<p>零件编号是标识，不应该被随意修改；名称不能为空；金额需要十进制精度。相比一堆可写字段，我更愿意让对象在创建时就合法：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">import</span><span style="color:#E6EDF3"> java.math.BigDecimal;</span></span>
<span data-line=""><span style="color:#FF7B72">import</span><span style="color:#E6EDF3"> java.util.Objects;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">final</span><span style="color:#FF7B72"> class</span><span style="color:#FFA657"> Part</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    private</span><span style="color:#FF7B72"> final</span><span style="color:#E6EDF3"> String</span><span style="color:#E6EDF3"> code;</span></span>
<span data-line=""><span style="color:#FF7B72">    private</span><span style="color:#FF7B72"> final</span><span style="color:#E6EDF3"> String</span><span style="color:#E6EDF3"> name;</span></span>
<span data-line=""><span style="color:#FF7B72">    private</span><span style="color:#FF7B72"> final</span><span style="color:#E6EDF3"> BigDecimal</span><span style="color:#E6EDF3"> unitPrice;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#D2A8FF">    Part</span><span style="color:#E6EDF3">(String </span><span style="color:#FFA657">code</span><span style="color:#E6EDF3">, String </span><span style="color:#FFA657">name</span><span style="color:#E6EDF3">, BigDecimal </span><span style="color:#FFA657">unitPrice</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#79C0FF">        this</span><span style="color:#E6EDF3">.code </span><span style="color:#FF7B72">=</span><span style="color:#D2A8FF"> requireText</span><span style="color:#E6EDF3">(code, </span><span style="color:#A5D6FF">"code"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#79C0FF">        this</span><span style="color:#E6EDF3">.name </span><span style="color:#FF7B72">=</span><span style="color:#D2A8FF"> requireText</span><span style="color:#E6EDF3">(name, </span><span style="color:#A5D6FF">"name"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (unitPrice.</span><span style="color:#D2A8FF">signum</span><span style="color:#E6EDF3">() </span><span style="color:#FF7B72">&#x3C;</span><span style="color:#79C0FF"> 0</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">            throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalArgumentException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"unitPrice must be >= 0"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">        }</span></span>
<span data-line=""><span style="color:#79C0FF">        this</span><span style="color:#E6EDF3">.unitPrice </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> unitPrice;</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">    private</span><span style="color:#FF7B72"> static</span><span style="color:#E6EDF3"> String </span><span style="color:#D2A8FF">requireText</span><span style="color:#E6EDF3">(String </span><span style="color:#FFA657">value</span><span style="color:#E6EDF3">, String </span><span style="color:#FFA657">field</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#E6EDF3">        String</span><span style="color:#E6EDF3"> normalized</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> Objects.</span><span style="color:#D2A8FF">requireNonNull</span><span style="color:#E6EDF3">(value, field).</span><span style="color:#D2A8FF">trim</span><span style="color:#E6EDF3">();</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (normalized.</span><span style="color:#D2A8FF">isEmpty</span><span style="color:#E6EDF3">()) </span><span style="color:#FF7B72">throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalArgumentException</span><span style="color:#E6EDF3">(field </span><span style="color:#FF7B72">+</span><span style="color:#A5D6FF"> " is empty"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#FF7B72">        return</span><span style="color:#E6EDF3"> normalized;</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>那时我第一次理解“封装”不是把字段改成 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>private</span></span></code></span> 就结束了。它真正保护的是对象不变量：一个 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Part</span></span></code></span> 一旦存在，就至少拥有合法编号、名称和非负价格。</p>
<h2 id="数量不是天然的-int">数量不是天然的 int</h2>
<p>机械清单里的数量看似简单，实际依赖单位。螺钉是 12 个，密封条可能是 1.8 米，润滑剂可能是 0.5 千克。如果模型只有 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>int count</span></span></code></span>，程序会悄悄丢掉现实信息。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">enum</span><span style="color:#FFA657"> Unit</span><span style="color:#E6EDF3"> { </span><span style="color:#79C0FF">PIECE</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">METER</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">KILOGRAM</span><span style="color:#E6EDF3"> }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">record</span><span style="color:#FFA657"> Quantity</span><span style="color:#E6EDF3">(BigDecimal value, Unit unit) {</span></span>
<span data-line=""><span style="color:#D2A8FF">    Quantity</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (value.</span><span style="color:#D2A8FF">signum</span><span style="color:#E6EDF3">() </span><span style="color:#FF7B72">&#x3C;=</span><span style="color:#79C0FF"> 0</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">            throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalArgumentException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"quantity must be > 0"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">        }</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">record</span><span style="color:#FFA657"> LineItem</span><span style="color:#E6EDF3">(Part part, Quantity quantity) {</span></span>
<span data-line=""><span style="color:#E6EDF3">    BigDecimal </span><span style="color:#D2A8FF">amount</span><span style="color:#E6EDF3">() {</span></span>
<span data-line=""><span style="color:#FF7B72">        return</span><span style="color:#E6EDF3"> part.</span><span style="color:#D2A8FF">unitPrice</span><span style="color:#E6EDF3">().</span><span style="color:#D2A8FF">multiply</span><span style="color:#E6EDF3">(quantity.</span><span style="color:#D2A8FF">value</span><span style="color:#E6EDF3">());</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>这里还有一个没有被代码解决的问题：每件单价不能直接乘“米”。价格也需要单位维度。2016 年我没有继续做完整的单位系统，但把这个缺口明确写下来，比假装模型已经正确更重要。</p>
<h2 id="重复编号是集合规则不是零件自己的规则">重复编号是集合规则，不是零件自己的规则</h2>
<p>单个零件无法判断自己的编号是否与别的零件冲突。这个约束属于清单：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">final</span><span style="color:#FF7B72"> class</span><span style="color:#FFA657"> BillOfMaterials</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    private</span><span style="color:#FF7B72"> final</span><span style="color:#E6EDF3"> Map</span><span style="color:#FFA657">&#x3C;</span><span style="color:#FF7B72">String</span><span style="color:#FFA657">, </span><span style="color:#FF7B72">LineItem</span><span style="color:#FFA657">> </span><span style="color:#E6EDF3">items</span><span style="color:#FF7B72"> =</span><span style="color:#FF7B72"> new</span><span style="color:#E6EDF3"> LinkedHashMap&#x3C;>();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">    void</span><span style="color:#D2A8FF"> add</span><span style="color:#E6EDF3">(String </span><span style="color:#FFA657">code</span><span style="color:#E6EDF3">, LineItem </span><span style="color:#FFA657">item</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (items.</span><span style="color:#D2A8FF">putIfAbsent</span><span style="color:#E6EDF3">(code, item) </span><span style="color:#FF7B72">!=</span><span style="color:#79C0FF"> null</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">            throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalArgumentException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"duplicate part code: "</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> code);</span></span>
<span data-line=""><span style="color:#E6EDF3">        }</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>这段代码让我第一次看到“边界”来自规则的归属。编号格式属于零件，编号唯一属于清单，金额汇总属于报表。把所有方法都塞进 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Part</span></span></code></span>，类会越来越像一个什么都知道的工具箱。</p>
<h2 id="错误需要能指回原始行">错误需要能指回原始行</h2>
<p>我最初遇到非法数据就抛异常，最后只能看到“价格不能为负”，却不知道 CSV 的哪一行出了问题。后来给解析结果保留行号和原始文本：</p>
<table>
<thead>
<tr>
<th>行号</th>
<th>字段</th>
<th>原值</th>
<th>错误</th>
</tr>
</thead>
<tbody>
<tr>
<td>12</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>unitPrice</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>-3.2</span></span></code></span></td>
<td>单价不能为负</td>
</tr>
<tr>
<td>19</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>code</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>A-017</span></span></code></span></td>
<td>编号重复，首次出现在第 4 行</td>
</tr>
<tr>
<td>26</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>quantity</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>1.5</span></span></code></span></td>
<td>PIECE 单位必须为整数</td>
</tr>
</tbody>
</table>
<p>批量数据处理不能只告诉用户“失败”。一份可修复的错误报告，应该指出位置、规则和冲突对象。这个认识后来直接影响了我做配置校验、数据导入和 AI 工具参数验证的方式。</p>
<h2 id="最后留下的是一张规则表">最后留下的是一张规则表</h2>
<p>程序写完后，我把约束单独列出来：编号唯一且不可变；金额使用 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>BigDecimal</span></span></code></span>；数量必须携带单位；解析错误保留原始行；汇总前所有行必须通过校验。之后改代码时，先检查有没有破坏这张表。</p>
<p>那份零件清单程序没有实际投入使用，但它把“类和对象”从语法题变成了我熟悉的现实问题。数据模型不是凭空设计出来的，它是把过去由人脑默默维持的约束，一条条变成程序可以执行的规则。</p>]]></description></item><item><title>第一次学会调试：先让错误稳定地发生</title><link>https://siegaii.com/articles/2016-05-reproducible-debugging/</link><guid>https://siegaii.com/articles/2016-05-reproducible-debugging/</guid><pubDate>Sat, 14 May 2016 00:00:00 GMT</pubDate><description><![CDATA[<p>刚开始写 Java 时，我有一个很朴素的误解：程序出错，多看几遍代码就能看出来。真正坐在电脑前才发现，最折磨人的不是编译器报错，而是程序有时对、有时错。那时我写了一个成绩统计小程序，从控制台读入人数和分数，再输出平均分。输入三个人时正常，输入空行、负数或在数字后多敲一个空格，就会出现异常结果。</p>
<p>我最初的处理方式是不断加 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>System.out.println</span></span></code></span>，每次换一组输入，打印也跟着换。半小时后屏幕上堆满日志，我仍然不知道哪一次结果对应哪组输入。后来我把那几组会失败的输入抄在纸上，给它们编号，问题突然变得可控了。</p>
<p><img src="/diagrams/series/2016-debugging-loop.svg" alt="从失败输入、最小复现到回归测试的调试闭环"></p>
<p><em>图 1：错误只有能够稳定重放，后面的假设和修改才有比较基础。</em></p>
<h2 id="把输入和计算拆开">把输入和计算拆开</h2>
<p>原来的程序把读取、校验、计算、输出全部写在 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>main</span></span></code></span> 方法里。每想验证一次平均值，都要重新在控制台敲输入。第一步不是修算法，而是把纯计算抽出来：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">static</span><span style="color:#FF7B72"> double</span><span style="color:#D2A8FF"> average</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">int</span><span style="color:#E6EDF3">[] scores) {</span></span>
<span data-line=""><span style="color:#FF7B72">    if</span><span style="color:#E6EDF3"> (scores.length </span><span style="color:#FF7B72">==</span><span style="color:#79C0FF"> 0</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">        throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalArgumentException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"scores must not be empty"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">    long</span><span style="color:#E6EDF3"> total</span><span style="color:#FF7B72"> =</span><span style="color:#79C0FF"> 0</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">    for</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">int</span><span style="color:#E6EDF3"> score</span><span style="color:#FF7B72"> :</span><span style="color:#E6EDF3"> scores) {</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (score </span><span style="color:#FF7B72">&#x3C;</span><span style="color:#79C0FF"> 0</span><span style="color:#FF7B72"> ||</span><span style="color:#E6EDF3"> score </span><span style="color:#FF7B72">></span><span style="color:#79C0FF"> 100</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">            throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalArgumentException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"score out of range: "</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> score);</span></span>
<span data-line=""><span style="color:#E6EDF3">        }</span></span>
<span data-line=""><span style="color:#E6EDF3">        total </span><span style="color:#FF7B72">+=</span><span style="color:#E6EDF3"> score;</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#FF7B72">    return</span><span style="color:#E6EDF3"> (</span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3">) total </span><span style="color:#FF7B72">/</span><span style="color:#E6EDF3"> scores.length;</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>这个版本没有直接读键盘，也不负责打印。它只接受一组已经解析好的整数。这样我可以反复调用它，而不必把“输入解析失败”和“平均值算错”混在一起。</p>
<h2 id="一次只验证一个假设">一次只验证一个假设</h2>
<p>当时的一个错误是整数除法。<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>total / scores.length</span></span></code></span> 两边都是整数，结果会先截断，再赋给 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>double</span></span></code></span>。我曾经同时改了变量类型、循环和输入逻辑，虽然结果变对了，却不知道究竟是哪一处起作用。</p>
<p>后来我给自己定了一条很笨但有效的规则：一次只改一个变量，并在修改前写下预期。</p>
<table>
<thead>
<tr>
<th>用例</th>
<th>输入</th>
<th>预期</th>
<th>它验证什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>D01</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>[80, 90, 100]</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>90.0</span></span></code></span></td>
<td>正常求和</td>
</tr>
<tr>
<td>D02</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>[80, 81]</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>80.5</span></span></code></span></td>
<td>不能发生整数截断</td>
</tr>
<tr>
<td>D03</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>[]</span></span></code></span></td>
<td>明确报错</td>
<td>空集合语义</td>
</tr>
<tr>
<td>D04</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>[-1, 80]</span></span></code></span></td>
<td>明确报错</td>
<td>分数范围</td>
</tr>
<tr>
<td>D05</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>[100, 100, 100]</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>100.0</span></span></code></span></td>
<td>上边界</td>
</tr>
</tbody>
</table>
<p>当 D02 从 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>80.0</span></span></code></span> 变成 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>80.5</span></span></code></span>，同时其他用例没有变化，才能说明强制转换修对了目标问题。调试从“我觉得这里不对”变成了一个可以被证伪的小实验。</p>
<h2 id="日志要回答问题不是展示变量">日志要回答问题，不是展示变量</h2>
<p>那时我也第一次体会到，打印所有变量不等于获得信息。真正有用的日志应该包含用例、阶段和关键值：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#E6EDF3">System.out.</span><span style="color:#D2A8FF">printf</span><span style="color:#E6EDF3">(</span></span>
<span data-line=""><span style="color:#A5D6FF">    "case=%s stage=average count=%d total=%d result=%.2f%n"</span><span style="color:#E6EDF3">,</span></span>
<span data-line=""><span style="color:#E6EDF3">    caseId, scores.length, total, result</span></span>
<span data-line=""><span style="color:#E6EDF3">);</span></span></code></pre></figure>
<p>看到 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>case=D02 count=2 total=161 result=80.00</span></span></code></span>，问题几乎已经写在日志里。相反，如果只打印三行 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>2</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>161</span></span></code></span>、<span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>80.0</span></span></code></span>，过几分钟就不知道它们分别代表什么。</p>
<h2 id="修复之后把失败输入留下来">修复之后，把失败输入留下来</h2>
<p>最初我会在结果正确后删掉调试代码，然后继续下一节课程。几天后相似问题再次出现，又要从头回忆。后来我把失败用例留在一个最简单的测试类里：</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">static</span><span style="color:#FF7B72"> void</span><span style="color:#D2A8FF"> assertClose</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3"> expected, </span><span style="color:#FF7B72">double</span><span style="color:#E6EDF3"> actual) {</span></span>
<span data-line=""><span style="color:#FF7B72">    if</span><span style="color:#E6EDF3"> (Math.</span><span style="color:#D2A8FF">abs</span><span style="color:#E6EDF3">(expected </span><span style="color:#FF7B72">-</span><span style="color:#E6EDF3"> actual) </span><span style="color:#FF7B72">></span><span style="color:#79C0FF"> 0.0001</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">        throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> AssertionError</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"expected="</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> expected </span><span style="color:#FF7B72">+</span><span style="color:#A5D6FF"> ", actual="</span><span style="color:#FF7B72"> +</span><span style="color:#E6EDF3"> actual);</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">public</span><span style="color:#FF7B72"> static</span><span style="color:#FF7B72"> void</span><span style="color:#D2A8FF"> main</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">String</span><span style="color:#E6EDF3">[] args) {</span></span>
<span data-line=""><span style="color:#D2A8FF">    assertClose</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">90.0</span><span style="color:#E6EDF3">, </span><span style="color:#D2A8FF">average</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">new</span><span style="color:#FF7B72"> int</span><span style="color:#E6EDF3">[]{</span><span style="color:#79C0FF">80</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">90</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">100</span><span style="color:#E6EDF3">}));</span></span>
<span data-line=""><span style="color:#D2A8FF">    assertClose</span><span style="color:#E6EDF3">(</span><span style="color:#79C0FF">80.5</span><span style="color:#E6EDF3">, </span><span style="color:#D2A8FF">average</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">new</span><span style="color:#FF7B72"> int</span><span style="color:#E6EDF3">[]{</span><span style="color:#79C0FF">80</span><span style="color:#E6EDF3">, </span><span style="color:#79C0FF">81</span><span style="color:#E6EDF3">}));</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>这还不是正规的测试框架，却已经有了回归测试最核心的价值：过去付过代价的错误，不应该只留在记忆里。</p>
<h2 id="这套方法后来一直没变">这套方法后来一直没变</h2>
<p>几年后排查接口超时、内存泄漏或 Agent 工具调用失败，系统复杂了很多，步骤却没有本质变化：保存失败输入，建立最小复现，一次只改一个因素，用证据比较前后，最后把案例加入自动验证。</p>
<p>2016 年的我只是想把平均分算对。真正沉淀下来的不是那段 Java，而是一个很具体的习惯：如果一个错误不能稳定发生，就先不要急着解释它。</p>]]></description></item><item><title>从机械图纸到第一段 Java 程序</title><link>https://siegaii.com/articles/2016-03-learning-java/</link><guid>https://siegaii.com/articles/2016-03-learning-java/</guid><pubDate>Fri, 18 Mar 2016 00:00:00 GMT</pubDate><description><![CDATA[<p>第一次认真学习编程，是跟着郝斌的 Java 课程敲代码。那时我仍在机械专业读书，熟悉的是尺寸、公差和制图规范，对类、对象和引用几乎没有直觉。屏幕上最简单的 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>Hello World</span></span></code></span>，也比一张画好的零件图更陌生。</p>
<p>机械制图要求一个零件在不同视图里保持一致，程序也要求同一份状态在不同路径里得到一致解释。这种相似感让我开始理解：编程不是记忆关键字，而是建立一套不会自相矛盾的描述。</p>
<h2 id="语法只是入口">语法只是入口</h2>
<p>最初很容易把学习等同于“把课程看完”。变量、循环、数组、继承，每一节都能照着写，但合上视频后仍然不知道该从哪里开始。后来我给自己加了一条规则：每学一个语法点，都用它解决一个不在课程里的小问题。</p>
<p>学循环时写九九乘法表，学数组时做成绩统计，学类时把机械零件的属性拆成对象。程序很小，却会不断暴露误解。真正有效的反馈不是“视频看懂了”，而是编译器能否接受、输出是否符合预期、换一组输入会不会失败。</p>
<p>我保存下来的一个练习，是按材料密度估算零件重量。现在看只有十几行，但它第一次迫使我把单位和无效输入讲清楚：长度用毫米还是米，密度用什么单位，负数尺寸要不要接受。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="java" data-theme="github-dark-default"><code data-language="java" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span style="color:#FF7B72">final</span><span style="color:#FF7B72"> class</span><span style="color:#FFA657"> Plate</span><span style="color:#E6EDF3"> {</span></span>
<span data-line=""><span style="color:#FF7B72">    private</span><span style="color:#FF7B72"> final</span><span style="color:#FF7B72"> double</span><span style="color:#E6EDF3"> lengthMm;</span></span>
<span data-line=""><span style="color:#FF7B72">    private</span><span style="color:#FF7B72"> final</span><span style="color:#FF7B72"> double</span><span style="color:#E6EDF3"> widthMm;</span></span>
<span data-line=""><span style="color:#FF7B72">    private</span><span style="color:#FF7B72"> final</span><span style="color:#FF7B72"> double</span><span style="color:#E6EDF3"> thicknessMm;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#D2A8FF">    Plate</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">double</span><span style="color:#FFA657"> lengthMm</span><span style="color:#E6EDF3">, </span><span style="color:#FF7B72">double</span><span style="color:#FFA657"> widthMm</span><span style="color:#E6EDF3">, </span><span style="color:#FF7B72">double</span><span style="color:#FFA657"> thicknessMm</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">        if</span><span style="color:#E6EDF3"> (lengthMm </span><span style="color:#FF7B72">&#x3C;=</span><span style="color:#79C0FF"> 0</span><span style="color:#FF7B72"> ||</span><span style="color:#E6EDF3"> widthMm </span><span style="color:#FF7B72">&#x3C;=</span><span style="color:#79C0FF"> 0</span><span style="color:#FF7B72"> ||</span><span style="color:#E6EDF3"> thicknessMm </span><span style="color:#FF7B72">&#x3C;=</span><span style="color:#79C0FF"> 0</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">            throw</span><span style="color:#FF7B72"> new</span><span style="color:#D2A8FF"> IllegalArgumentException</span><span style="color:#E6EDF3">(</span><span style="color:#A5D6FF">"尺寸必须大于 0"</span><span style="color:#E6EDF3">);</span></span>
<span data-line=""><span style="color:#E6EDF3">        }</span></span>
<span data-line=""><span style="color:#79C0FF">        this</span><span style="color:#E6EDF3">.lengthMm </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> lengthMm;</span></span>
<span data-line=""><span style="color:#79C0FF">        this</span><span style="color:#E6EDF3">.widthMm </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> widthMm;</span></span>
<span data-line=""><span style="color:#79C0FF">        this</span><span style="color:#E6EDF3">.thicknessMm </span><span style="color:#FF7B72">=</span><span style="color:#E6EDF3"> thicknessMm;</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#FF7B72">    double</span><span style="color:#D2A8FF"> weightKg</span><span style="color:#E6EDF3">(</span><span style="color:#FF7B72">double</span><span style="color:#FFA657"> densityKgPerM3</span><span style="color:#E6EDF3">) {</span></span>
<span data-line=""><span style="color:#FF7B72">        double</span><span style="color:#E6EDF3"> volumeM3</span><span style="color:#FF7B72"> =</span><span style="color:#E6EDF3"> lengthMm </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> widthMm </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> thicknessMm </span><span style="color:#FF7B72">/</span><span style="color:#79C0FF"> 1_000_000_000d</span><span style="color:#E6EDF3">;</span></span>
<span data-line=""><span style="color:#FF7B72">        return</span><span style="color:#E6EDF3"> volumeM3 </span><span style="color:#FF7B72">*</span><span style="color:#E6EDF3"> densityKgPerM3;</span></span>
<span data-line=""><span style="color:#E6EDF3">    }</span></span>
<span data-line=""><span style="color:#E6EDF3">}</span></span></code></pre></figure>
<p>最初的版本把换算系数散落在 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>main</span></span></code></span> 方法里，输入稍微换一下就不知道结果为什么错。把单位写进变量名、把尺寸检查放到对象入口后，计算公式反而成了最简单的一行。这个练习让我第一次看到：程序的难处不只是计算，而是阻止含义不清的数据进入计算。</p>
<h2 id="错误是一种精确反馈">错误是一种精确反馈</h2>
<p>工程课里的错误有时要到加工或装配阶段才出现，代码的错误却会立刻给出行号。刚开始我讨厌这些红色提示，后来发现它们是最耐心的老师：类型不匹配、空引用、越界，每一种错误都在说明我的模型和机器执行的模型不一致。</p>
<p>学习编程之后，我开始习惯先定义输入与输出，再讨论过程；先让问题变得可验证，再追求漂亮的实现。这种思维远比某一门语言更重要。</p>
<p>当时我没有测试框架，就用一张很土的表格记录输入和预期。它后来成了我写测试用例的雏形：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>输入</th>
<th>预期</th>
</tr>
</thead>
<tbody>
<tr>
<td>正常钢板</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>1000 × 500 × 10mm</span></span></code></span>，密度 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>7850</span></span></code></span></td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>39.25kg</span></span></code></span></td>
</tr>
<tr>
<td>小数尺寸</td>
<td><span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>12.5 × 8 × 2mm</span></span></code></span></td>
<td>结果不被整数截断</td>
</tr>
<tr>
<td>零尺寸</td>
<td>厚度 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>0</span></span></code></span></td>
<td>明确拒绝，而不是返回 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>0</span></span></code></span></td>
</tr>
<tr>
<td>错误单位</td>
<td>把 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>7.85g/cm³</span></span></code></span> 直接传入</td>
<td>通过命名和文档暴露单位不匹配</td>
</tr>
</tbody>
</table>
<p>第三个场景让我理解“能算出一个数”和“程序正确”完全不同。第四个场景则更难：类型系统只能知道它是 <span data-rehype-pretty-code-figure=""><code data-language="text" data-theme="github-dark-default"><span data-line=""><span>double</span></span></code></span>，不知道单位。后来学习值对象时，我才知道可以继续把单位也建模进去。</p>
<h2 id="给学习建立闭环">给学习建立闭环</h2>
<p>我把练习分成三个动作：先不看答案写一遍，再用不同输入破坏它，最后隔一天重新实现。第一遍检验记忆，第二遍暴露假设，第三遍才检验是否真正理解。遇到错误时不急着复制解决方案，而是先写下自己认为程序会怎样执行，再与实际结果逐步对照。</p>
<p>这种方式没有连续看课程快，却能留下更可靠的东西。编程学习最怕用熟悉感代替掌握：看别人敲过十遍，依然不等于自己能从空白文件开始。</p>
<p>今天的代码还非常笨拙。但只要一个想法能被写成程序，它就不再只是想法，而是可以运行、失败和继续改进的东西。这也是我愿意长期学习下去的原因。</p>
<p><img src="/diagrams/series/legacy-java-learning-loop.svg" alt="从观看课程到变式练习、记录失败和隔日重写的学习闭环"></p>
<p><em>图：熟悉感只有经过独立重写和失败输入，才会变成可调用的能力。</em></p>
<h2 id="我当时真正留下的练习账本">我当时真正留下的练习账本</h2>
<p>后来回看，最有效的不是连续看了多少节课，而是一张很朴素的表：题目、自己先写的输入、预期输出、第一次失败原因、第二天能否不看答案重写。每周只保留三道最能暴露误解的题。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark-default"><code data-language="text" data-theme="github-dark-default" style="display: grid;"><span data-line=""><span>题目：统计一组零件数量</span></span>
<span data-line=""><span>边界：空数组、负数、总和超过 int</span></span>
<span data-line=""><span>第一次错误：把空数组平均值当成 0</span></span>
<span data-line=""><span>修复证据：3 个固定输入 + 预期异常</span></span>
<span data-line=""><span>一周后：脱离视频重写</span></span></code></pre></figure>
<p>这份账本比“学完数组”更诚实：它记录的不是接触过什么，而是什么已经能够独立完成。</p>]]></description></item></channel></rss>