<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>架构设计 - Tag - SaiyuDoc</title><link>http://www.wangsaiyu.com/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/</link><description>架构设计 - Tag - SaiyuDoc</description><generator>Hugo -- gohugo.io</generator><language>zh-CN</language><managingEditor>panicwang@outlook.com (王赛宇)</managingEditor><webMaster>panicwang@outlook.com (王赛宇)</webMaster><lastBuildDate>Mon, 21 Sep 2026 11:09:07 +0000</lastBuildDate><atom:link href="http://www.wangsaiyu.com/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/" rel="self" type="application/rss+xml"/><item><title>研发平台是怎么长出来的：环境、发布与可观测性的架构取舍</title><link>http://www.wangsaiyu.com/posts/engineering/rd-platform-evolution/</link><pubDate>Mon, 21 Sep 2026 11:09:07 +0000</pubDate><author>王赛宇</author><guid>http://www.wangsaiyu.com/posts/engineering/rd-platform-evolution/</guid><description><![CDATA[<p>先讲一个我们补过的具体问题。</p>
<p>一次生成请求，要经过入口服务、任务调度和 Agent 执行。如果这次只改了入口，流水线按代码变更生成任务，就可能只把入口部署到个人环境。入口的流水线是绿的，后面的个人调度和执行实例却没有跟着补齐。</p>
<p>服务已经发上去了，但这次联调需要的环境还不完整。</p>
<p>我们后来给这三个服务加了发布组：命中这条链的改动时，把需要的依赖一起带上。三个服务可以并行构建；部署则先执行服务，再调度服务，最后入口。请求进来之前，先把接住它的后两段准备好。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/01-overview.svg" title="图 1：只部署改动服务留下的缺口，以及三服务发布组的处理方式" data-thumbnail="/images/rd-platform-evolution/01-overview.svg" data-sub-html="<h2>图 1：只部署改动服务留下的缺口，以及三服务发布组的处理方式</h2>">
        
    </a><figcaption class="image-caption">图 1：只部署改动服务留下的缺口，以及三服务发布组的处理方式</figcaption>
    </figure>
<p><em>图中使用通用角色名。构建并行、部署按依赖顺序推进；发布组本身不提供跨服务事务。</em></p>
<p>这个问题表面上是少部署了两个服务，往下看，其实是两种模型对不上：流水线理解的是“哪些代码变了”，开发需要的是“哪些服务一起存在，才能完成一次联调”。</p>
<p>后面做发布、监控和告警，也反复碰到类似的问题。一个脚本能执行，一条查询能返回，一条消息能发出去，都不算难。难的是入口越来越多以后，谁来决定目标，谁保存执行状态，谁有权更新资源，出了问题又该相信哪份记录。</p>
<p>所以这篇会从这些具体问题讲起，把后面的架构选择一起拆开。先看请求怎么从 NLB、Nginx 走到 Gateway、WAP 和业务 RPC，再看我们怎样为这条链准备环境、执行发布、观察运行结果。</p>
<h2 id="1-从-nlb-到业务-rpc一条请求实际怎么走">1. 从 NLB 到业务 RPC，一条请求实际怎么走</h2>
<p>前面把“入口服务”画成一个框，其实省略了好几层。展开以后，连接先到 NLB，再到 Nginx；Nginx 根据请求路径转给对应的 Gateway 或 WAP；这些接入服务解析客户端请求，再通过 Kitex RPC 调用内部业务服务。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/15-ingress-chain.svg" title="图 2：从 NLB、Nginx 到 Gateway 或 WAP，再进入内部业务 RPC" data-thumbnail="/images/rd-platform-evolution/15-ingress-chain.svg" data-sub-html="<h2>图 2：从 NLB、Nginx 到 Gateway 或 WAP，再进入内部业务 RPC</h2>">
        
    </a><figcaption class="image-caption">图 2：从 NLB、Nginx 到 Gateway 或 WAP，再进入内部业务 RPC</figcaption>
    </figure>
<p><em>图中从 NLB 开始展开业务 API 链路。域名前可能还有 CDN 或同源代理，各入口的 TLS 终止位置也可能不同，这里不把它们画成统一配置。鉴权服务是 Nginx 发起的子请求分支。</em></p>
<p><strong>NLB 先解决连接应该交给哪些入口实例。</strong> 它维护监听和后端集合，不判断这是创建项目还是获取用户信息。迁到 Kubernetes 时，我们延续已有的入口地址和监听，再把后端接到 Service 对应的 Pod 集合。Pod 换了，由控制器维护后端成员，不要求调用方跟着改地址。</p>
<p>Nginx 再处理 HTTP 这一层：按路径选择上游，代理普通请求和 WebSocket，处理跨域预检，并为需要鉴权的业务请求发起认证子请求。认证服务检查凭证、给出身份结果，Nginx 再覆盖写入下游的身份信息。这样客户端自己带来的“用户是谁”，就不会直接变成业务网关的判断依据。</p>
<p>这里要分清两件事。认证回答当前请求是谁发的；这个人能不能访问某个项目，还要在业务入口或对应业务服务里检查。例如 WAP 建立项目连接前，会读取项目归属并核对当前用户。不能因为已经登录，就跳过对象权限。</p>
<p>我们也没有把项目规则写进 Nginx。请求该转去哪、连接该怎样代理，可以留在入口配置里；选什么模板、允许什么操作、怎样组织客户端返回值，放在 Gateway 或 WAP；项目状态和任务执行继续交给拥有这些能力的业务服务。否则每加一种产品交互，都要同时修改入口配置和业务实现，边界很快又混回去了。</p>
<p><strong>Gateway 和 WAP 是面向不同客户端的两种接入。</strong> 常规 Gateway 要承接编辑器里更完整的项目、素材和场景操作；WAP 面向移动端，把创建项目和对话过程收窄成更少的输入与事件。它们各自对外提供 HTTP / WebSocket 接口，再调用共享的内部能力。</p>
<p>比如移动端创建项目，只需要提交用户输入，服务端确定移动端模板等选项；项目、版本和文件等稳定模型继续复用下游定义。我们让客户端协议可以分别演进，同时把项目状态留在原来的业务服务里，避免为了一个移动端入口再维护一份项目和任务状态。</p>
<p>到了 RPC 这一跳，调用方使用生成的客户端和约定好的数据结构。标准客户端统一带上服务发现、环境上下文、调用方信息、超时和调用指标。普通 RPC 的元信息通过 TTHeader 等传输机制交给下游，长连接的 RPC 客户端也要显式保留这些上下文。换成内部调用以后，不能把“这是谁发起的、要去哪套环境”弄丢。</p>
<p>外部接口采用“调用一个方法”的命名方式，也不代表浏览器直接连着内部 Kitex 服务。对外的连接协议和对内的服务协议之间，仍然有 Gateway / WAP 这一层适配。</p>
<p>拿一次移动端生成来说，过程可以拆成下面两段。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/16-client-rpc-lifecycle.svg" title="图 3：HTTP 创建项目与 WebSocket 接收进度，分别连接到内部 RPC 和持续执行的任务" data-thumbnail="/images/rd-platform-evolution/16-client-rpc-lifecycle.svg" data-sub-html="<h2>图 3：HTTP 创建项目与 WebSocket 接收进度，分别连接到内部 RPC 和持续执行的任务</h2>">
        
    </a><figcaption class="image-caption">图 3：HTTP 创建项目与 WebSocket 接收进度，分别连接到内部 RPC 和持续执行的任务</figcaption>
    </figure>
<p><em>前置 NLB、Nginx 和鉴权在上一张图中展开，这里聚焦 WAP 与内部业务的交接；模板查询等辅助调用省略。</em></p>
<p>第一段是创建。WAP 校验输入、选择模板，再调用调度服务创建项目并启动首轮。客户端收到项目对象时，生成工作已经交给后面的执行链，并不需要把整个生成过程塞进这一条 HTTP 响应里。</p>
<p>第二段是连接项目。客户端建立 WebSocket，Nginx 处理协议升级和长连接转发，WAP 校验项目访问权限，再接上内部 Kitex 双向流。客户端命令被转换成下游请求；下游事件则按移动端协议裁剪、组合后返回。比如内部有多种消息和版本事件，客户端需要的是自己能理解的进度和一轮完成结果。</p>
<p>这时，<strong>连接的生命周期和生成任务的生命周期要分开。</strong> 用户关掉页面，断开的是观察这次执行的连接；要停止当前生成，需要发送带有当前轮次身份的明确终止命令。重连则重新取得状态和可恢复的结果，不能把“重新建立连接”理解成“再创建一次任务”。</p>
<p>这也是入口配置需要理解协议的原因。WebSocket 要正确转发升级信息，持续事件不能被普通响应缓冲挡住，连接空闲超时、单次 RPC 超时和任务执行期限也有不同含义。把所有超时一起调大，只会让故障更晚暴露。</p>
<p>分层以后，排障顺序也更明确。请求没进入 Nginx，先看连接与入口后端；Nginx 没有匹配到路由，先看转发规则；网关已经收到请求，再看身份、对象权限以及下游 RPC。浏览器拿到一个 502，不能直接推断生成服务挂了；入口的上游连接状态、网关的调用错误和任务最终状态，需要一层层对上。</p>
<p>我觉得这几层值得保留，是因为它们处理的变化不同：实例变化交给负载均衡与服务发现，URL 和连接协议交给 Nginx，客户端交互交给 Gateway / WAP，业务状态留在内部服务。每层多一次转发都会增加耗时和故障点，所以也要能说清楚它到底承担了什么。</p>
<h2 id="2-先把业务请求和发布指令分开">2. 先把业务请求和发布指令分开</h2>
<p>早期直接在 CI 里执行发布脚本，很顺手：构建完镜像，连接运行环境，更新服务，等它起来。一条流水线就能做完。</p>
<p>问题是，后来除了 CI，还出现了页面、CLI 和自动化调用。假如每个入口都可以直接改集群，发布规则就会散在这些入口里。某个入口检查过环境，另一个入口可能没检查；页面显示一个状态，CI 又按自己的理解给出另一个结论。</p>
<p>我们逐步把这部分收进正式运行的研发平台。它管理服务目标、制品、发布记录以及资源操作，可以把它理解成控制面。用户真正使用产品时，请求仍然经过业务入口、调度和执行服务，不绕到这个管理平台里。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/12-control-plane.svg" title="图 4：业务请求进入应用服务，发布请求由正式控制面持久化并执行" data-thumbnail="/images/rd-platform-evolution/12-control-plane.svg" data-sub-html="<h2>图 4：业务请求进入应用服务，发布请求由正式控制面持久化并执行</h2>">
        
    </a><figcaption class="image-caption">图 4：业务请求进入应用服务，发布请求由正式控制面持久化并执行</figcaption>
    </figure>
<p><em>图中展开发布相关组件。业务环境是被管理的目标，平台自身的运行角色决定谁能领取正式操作。</em></p>
<p>这里有两个很容易混起来的“环境”。一个是这次发布要去个人环境、共享测试、预发布还是正式环境；另一个是这台平台实例有没有资格执行正式控制面的任务。</p>
<p>我们没有让它们共用一个开关。平台自己的开发实例可以用来调试页面和接口，但不能因为请求里带了“正式环境”，就开始领取正式发布任务。CI 和告警回调也接到正式控制面，避免同一件事被几套平台实例分别处理。</p>
<p>入口的身份同样分开校验。人从页面或 CLI 发起操作，要验证人的身份与权限；CI 登记制品，要核对实际构建任务和来源；告警回调用自己的机器身份。业务请求里的环境选择负责路由，不能顺便成为平台的操作授权。</p>
<p><strong>把发布执行权收拢以后，平台就要为结果负责。</strong> CI 只交出明确的制品和发布请求，平台失败不能再偷偷回到旧脚本改集群。否则平时看起来统一了，真正出问题时仍然有两个执行者。</p>
<p>这也带来了集中式控制面的代价：平台故障会阻断新的管理操作，平台数据库和 Worker 本身需要备份、监控和恢复手段。把它放到业务请求链之外，是为了让管理操作的可用性和正在运行的业务请求少一层直接耦合。</p>
<h2 id="3-平台拆成三个服务以后为什么又收了回来">3. 平台拆成三个服务以后，为什么又收了回来</h2>
<p>平台本身也走过一次拆分：Web、网关和管理服务各自运行。后来又把它们收敛到一个 TypeScript 模块和同一份镜像里。</p>
<p>我觉得这段比“用了什么框架”更值得讲。网关转一次请求，并不意味着那里就一定需要一个独立服务。前后几层经常一起改、一起发时，跨进程边界会增加接口同步和部署配合，未必能提供对应的隔离价值。</p>
<p>收敛以后，页面和 HTTP 入口直接调用本地用例，平台内部少了一段网关到管理服务的网络调用。但有一个边界我们保留了：<strong>接请求的进程和执行持久任务的 Worker 分开运行。</strong> 它们使用同一份镜像、随同一个部署发布，各自设置资源限制。网页关掉、HTTP 请求超时，都不该决定发布任务的寿命。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/13-platform-boundaries.svg" title="图 5：从跨服务转发收敛到模块内调用，保留进程职责和数据写入边界" data-thumbnail="/images/rd-platform-evolution/13-platform-boundaries.svg" data-sub-html="<h2>图 5：从跨服务转发收敛到模块内调用，保留进程职责和数据写入边界</h2>">
        
    </a><figcaption class="image-caption">图 5：从跨服务转发收敛到模块内调用，保留进程职责和数据写入边界</figcaption>
    </figure>
<p><em>图中只展开 HTTP 与运维 Worker 两个主要角色，以及与本文相关的模块；分析任务等其他执行角色未展开。</em></p>
<p>这里也有取舍。共同发布省掉了一部分版本配合，但 HTTP 和 Worker 的扩容仍受同一部署影响，故障隔离也没有做到完全独立。如果以后两边的负载、发布频率差别足够大，再把运行单元拆开有实际理由；现在不为了形式再补一层内部 RPC。</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>共用 PostgreSQL，不代表可以跨模块随手改表。比如环境回收需要停止发布，要经过发布模块约定的操作；不能直接把发布表里的状态改成“完成”，让页面看起来干净。确实需要几个模块一起落库的步骤，则通过明确的事务接口放在同一个数据库事务里完成。</p>
<p>内部依赖也按这个原则组织。HTTP 入口负责认证、解析输入和输出；应用用例安排步骤；领域规则判断这次状态变化是否允许；最外面的适配器才访问数据库、Kubernetes、日志和指标系统。用例通过接口调用这些能力，启动时明确组装，不把云 SDK 和数据库操作塞进每条状态判断里。</p>
<p>这样做的实际收益是可以单独验证规则。比如旧执行者还能不能写回、查询失败能不能覆盖旧资源状态，不需要先搭一套集群才能判断。跨模块私有实现引用、领域规则依赖 SDK 等情况，也交给架构检查拦住。单体里的边界，需要靠这些约束维持。</p>
<h2 id="4-个人环境只部署改过的服务为什么不够了">4. 个人环境：只部署改过的服务，为什么不够了</h2>
<p>早期的个人环境，做法很直接：请求带上环境选择，优先找这个环境里的服务，没有个人实例的普通依赖就使用共享实例。只部署自己改过的部分，准备成本比较低。</p>
<p>随着生成链路变长，这种默认回退需要收紧。</p>
<p>入口处理完请求，后面还有任务调度、Agent 执行、模板和沙箱。调度与执行如果回到了共享版本，这次测试就掺进了另一套运行逻辑；即使恰好跑通，也很难确认验证的是哪种组合。</p>
<p>我们对关键调度和执行服务改成了必须命中同一环境。找不到对应实例就明确失败，不静默回到共享服务。普通依赖则继续按策略共享，没有把所有数据库和基础资源复制一遍。</p>
<p>这实际上是两次路由选择。测试入口先按环境标签寻找个人 Gateway 或 WAP；没有对应入口实例时，允许按策略使用共享入口，但环境标签继续往下传。进入 RPC 后，服务发现再按同一份上下文选择下游实例。因此，请求经过共享 Gateway，也可以进入个人调度和执行链。</p>
<p>这里的“找不到”还要和“暂时查不出来”分开。入口明确收到 DNS 不存在的结果，才按规则回退；DNS 超时等临时错误会让请求失败，不能悄悄切到另一套环境。关键调度和执行服务则保持必须命中同一环境的约束。这些细节决定了一次联调究竟验证了哪套服务组合。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/02-environment.svg" title="图 6：请求和异步消息如何保持在同一条个人环境链路" data-thumbnail="/images/rd-platform-evolution/02-environment.svg" data-sub-html="<h2>图 6：请求和异步消息如何保持在同一条个人环境链路</h2>">
        
    </a><figcaption class="image-caption">图 6：请求和异步消息如何保持在同一条个人环境链路</figcaption>
    </figure>
<p><em>入口接到的环境选择还要经过 RPC、异步消息以及后续依赖选择，不能只停在浏览器到入口的第一跳。</em></p>
<p>但路由变严格以后，部署范围也必须跟着改。否则入口已经就绪，调度却找不到个人实例，联调仍然走不下去。前面那个发布组，就是用来补这个缺口的。</p>
<p>这里有两个不同的依赖关系。构建只关心代码和编译输入，能并行就并行；部署还关心运行时谁要调用谁，所以按“执行、调度、入口”推进。把它们混成一张代码依赖表，就会漏掉那些代码没改、运行时却必须存在的服务。</p>
<p>我们保留了共享依赖的低成本，也为关键链路补上了成组部署。代价是一次入口改动可能多出两个部署任务，但它换来的是一条可完整验证的个人链路。</p>
<p>这里的个人环境，首先隔离的是关键代码版本和执行路径。请求经过入口、RPC 和消息队列时都要延续同一份环境上下文；用到共享数据库时，数据隔离仍然要靠业务自己的约束。不能因为请求命中了个人实例，就把它当成一套完全独立的数据环境。</p>
<h2 id="5-流水线同一台-runner也可能反复从冷缓存开始">5. 流水线：同一台 Runner，也可能反复从冷缓存开始</h2>
<p>环境补齐以后，接下来就是等。等准备源码，等编译，等镜像，等实例起来。</p>
<p>我们最先做的是缩小任务范围：根据改动动态生成子流水线，把共享代码会影响的服务展开，其余模块不生成作业。前面的运行时发布组，则补上单靠代码依赖无法表达的联调关系。</p>
<p>缓存里还有一个很容易漏掉的细节。同一台 Runner 有多个并发槽，如果每个槽都使用不同的 BuildKit builder，它们就各自维护一份本地缓存。上一个任务刚编过的依赖，下一个任务换个槽，可能又要准备一遍。</p>
<p>所以 builder 的身份后来固定到 Runner，而不是并发槽或者某个 job。Go 的依赖缓存和编译缓存跟着这份持久状态复用；换到另一台 Runner 时，再用镜像仓库里的缓存兜底。本地缓存挂载和远程镜像层缓存作用不同，不能以为上传了镜像缓存，就把所有编译中间结果也带走了。</p>
<p>另一个改动更简单：结果汇总任务只需要读取上游产物，却也走了一遍源码检出。它没有编译代码，也不需要业务源码，这段准备直接去掉就行。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/03-feedback.svg" title="图 7：一次流水线里，哪些等待可以复用，哪些工作可以直接省掉" data-thumbnail="/images/rd-platform-evolution/03-feedback.svg" data-sub-html="<h2>图 7：一次流水线里，哪些等待可以复用，哪些工作可以直接省掉</h2>">
        
    </a><figcaption class="image-caption">图 7：一次流水线里，哪些等待可以复用，哪些工作可以直接省掉</figcaption>
    </figure>
<p><em>图里画的是任务关系，不是耗时比例。构建缓存是否命中，还取决于 Runner、输入变化和缓存状态。</em></p>
<p>这几件事对应的优化手段完全不同：任务太多，就缩小生成范围；缓存分散，就修正缓存身份；根本用不到输入，就删掉准备步骤。只盯最后那条编译命令，很容易把前面这些时间漏掉。</p>
<p>我们也没有把所有发布都改成同一种启动方式。个人环境可以接受的短暂停顿，正式环境未必能接受。省掉等待之前，还是要看它原来承担了什么作用。</p>
<h2 id="6-监控http-返回了-200生成任务可能才刚进队列">6. 监控：HTTP 返回了 200，生成任务可能才刚进队列</h2>
<p>早期服务监控先覆盖 HTTP 和 RPC：请求量、错误率、耗时、并发数。公共中间件负责采集，Prometheus 保存指标，Grafana 使用共同的看板。</p>
<p>后来把监控接进研发平台时，我们没有再做一份自己的时序数据库。Prometheus 保存指标，SLS 保存原始日志，云监控提供基础资源状态。平台主要保存服务、环境、部署目标和资源身份，再拿这些关系去限定查询。</p>
<p>比如打开一个数据库页面，平台先确定是哪台实例、哪个环境，再分别取调用指标和云端实例指标。这样能把两边的数据放在同一个排查入口里，又不用自己重复承担指标采集、原始日志存储和保留周期的维护。</p>
<p>这套东西能回答接口有没有接住请求。到了异步生成，它还回答不了用户最后有没有拿到结果。</p>
<p>请求返回以后，任务可能还在队列里。执行时，一次模型或工具调用失败了，重试以后又成功了。把这一次调用失败算成整个生成失败，会把任务失败率算高；只看入口的 200，又会把后面真正失败的任务漏掉。</p>
<p>所以我们往下补了几层数据：队列是否在积压，执行有没有推进，模型和工具各自花了多久，以及任务最终是成功、失败还是取消。<strong>最终任务失败率用最终任务结果计算；调用错误率保留在调用层。</strong> 两个数字都看，但不混成一个成功率。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/04-outcomes.svg" title="图 8：一个生成请求返回 200 后，仍然要继续观察哪些执行阶段" data-thumbnail="/images/rd-platform-evolution/04-outcomes.svg" data-sub-html="<h2>图 8：一个生成请求返回 200 后，仍然要继续观察哪些执行阶段</h2>">
        
    </a><figcaption class="image-caption">图 8：一个生成请求返回 200 后，仍然要继续观察哪些执行阶段</figcaption>
    </figure>
<p>Agent 的缓存指标也一样。一次输入很短、一次输入很长，把它们的缓存百分比直接平均，得不到整体节省了多少输入 Token。我们使用输入 Token 加权：缓存输入量的总和，除以输入量的总和。没有输入样本，就保留没有样本的状态。</p>
<p>数据库监控还有另一段演进。</p>
<p>只有 CPU、内存、连接数，能看到实例承受的压力，但不知道是谁在用。我们在标准数据库客户端里装 GORM 采集插件，在 Redis 客户端里加 hook，在其他依赖的标准客户端上做同类封装。每次调用记录调用服务、资源、操作类别、结果和耗时。</p>
<p>这样数据库页面就可以从真实调用指标里列出：哪些服务在读，哪些在写，谁的错误率上升，谁的 P99 变大。调用关系随实际样本出现，不需要再手工维护一张“这个库关联了哪些服务”的表。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/05-infrastructure.svg" title="图 9：标准客户端提供调用指标，云监控提供实例指标，再按身份关联" data-thumbnail="/images/rd-platform-evolution/05-infrastructure.svg" data-sub-html="<h2>图 9：标准客户端提供调用指标，云监控提供实例指标，再按身份关联</h2>">
        
    </a><figcaption class="image-caption">图 9：标准客户端提供调用指标，云监控提供实例指标，再按身份关联</figcaption>
    </figure>
<p>但只用数据库名做关联也有坑。两台实例可能有同名库，不能因为名字一样，就把所有调用量都归到其中一台。因此后来补了实例身份；旧样本身份不完整、又无法唯一对应时，页面显示归因歧义，不替它猜一个实例。</p>
<p>Redis 的阻塞读则是另一个很小、但很实际的例子。<code>XREAD</code>、<code>XREADGROUP</code> 可能本来就在等待新消息，这段时间不能直接解释成 Redis 执行慢。我们把阻塞读取单独分类，避免套用普通命令的延迟判断。</p>
<p>这些工作都不会让看板突然多出什么炫酷效果，但它们会决定人看到一个红色数字以后，究竟会不会查错方向。</p>
<p>代价是，上游指标系统查询失败，平台就只能明确展示查询失败；数据关联不完整，就只能展示歧义。我们宁愿把这个缺口露出来，也不拿数据库里上一次成功查询的结果冒充当前状态。</p>
<h2 id="7-日志选了正式环境怎样确保不会查到预发布">7. 日志：选了正式环境，怎样确保不会查到预发布</h2>
<p>我们使用 SLS 保存日志，后来在研发平台里增加了按服务、环境和实例查询的入口。做这个入口时，最麻烦的部分并不是搜索框。</p>
<p>多个环境的日志可能落在同一个日志库里。历史环境字段还有不同写法，预发布和正式环境又可能使用同一类生产配置。只筛一个 <code>env=prod</code>，不一定能把两者分开。</p>
<p>平台因此先读取选中服务的目标声明，确认集群、命名空间和 Deployment，再生成受约束的 SLS 查询。环境字段用于缩小范围，完整实例名用于确认归属；源端过滤以后，返回的每条日志还要再核对一次。缺少归属字段、无法确认属于当前目标的历史记录，不混进结果。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/06-logs.svg" title="图 10：从服务目标生成查询范围，再核对每条日志的环境与实例" data-thumbnail="/images/rd-platform-evolution/06-logs.svg" data-sub-html="<h2>图 10：从服务目标生成查询范围，再核对每条日志的环境与实例</h2>">
        
    </a><figcaption class="image-caption">图 10：从服务目标生成查询范围，再核对每条日志的环境与实例</figcaption>
    </figure>
<p>另一个容易被忽略的地方是翻页。用户选“最近 15 分钟”，翻到下一页时，如果又按当前时间重新计算窗口，前后两页就不是同一批数据了。我们把起止时间固定在第一次查询里，游标绑定这段时间和筛选条件，只有显式刷新才重新计算窗口。</p>
<p>查询历史日志也不要求那个 Pod 现在还活着。实例被滚动替换以后，旧实例的日志仍然是排查发布前后差异的重要依据。</p>
<p>页面最后保留几种不同结果：查询完成但没有匹配；还在扫描、只有部分结果；没有接入日志；查询失败。它们不能都叫“暂无日志”。否则一个正在扫描的查询，就可能被理解成“这个请求根本没有发生”。</p>
<h2 id="8-告警85-起新告警为什么会变成-460-条通知">8. 告警：85 起新告警，为什么会变成 460 条通知</h2>
<p>一次 24 小时的审查里，我们看到 85 起新告警，其中 84 起被标成 P1，最终产生了 460 条送达通知。</p>
<p>第一反应很容易是发送重试出了问题。但那次检查到的发送账本里，没有发现同一逻辑发送被重复提交，尝试次数都是一次。通知多，需要继续看事件怎么分类、一次事件发给多少人，以及触发和恢复各发了什么。</p>
<p>缓存成本、核心流程失败、预期的业务限制，都挤在相近的紧急等级里。把这些事件同时发给多人，发送链路完全正常，人也照样会被淹没。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/07-alerts.svg" title="图 11：一次告警审查的实际样本，以及重新分级后的默认通知方式" data-thumbnail="/images/rd-platform-evolution/07-alerts.svg" data-sub-html="<h2>图 11：一次告警审查的实际样本，以及重新分级后的默认通知方式</h2>">
        
    </a><figcaption class="image-caption">图 11：一次告警审查的实际样本，以及重新分级后的默认通知方式</figcaption>
    </figure>
<p>我们后来按事件含义拆开默认策略。正式环境的持续服务失败、最终任务失败保留即时通知；性能退化、容量风险、缓存成本进入 P2，按小时汇总；额度不足等预期限制进入 P3，只保留事件。测试、预发布和环境未知的信号，也不再直接套用正式环境的打断方式。</p>
<p>这背后也划了三段职责。Grafana 负责评估规则、产生触发和恢复信号；平台负责事件分级、通知节奏，以及这起事件应该通知哪些人；实际发送继续调用已有的消息服务，平台保存投递记录。判断异常、决定是否打断人、把消息送到，是三件分别可能失败的事。</p>
<p>收件人也跟着事件保存下来。事件持续期间，服务负责人可能变化，但一条恢复通知应该能接回之前那次触发通知。不能临时再查一遍最新负责人，就让开始和结束发给完全不同的人。</p>
<p>缓存命中率就是一个具体调整。那次采用的规则看 30 分钟的 Token 加权命中率，低于 75% 并持续 15 分钟才触发，归到成本与效率类的小时汇总。它依然值得处理，但通常不需要和用户任务持续失败用同样的节奏通知。</p>
<p>错误告警则不能只看百分比。请求很少时，一两次失败就能把比例拉得很高；一味提高最小样本量，又会漏掉低流量接口的持续失败。所以普通错误规则结合请求量、错误量和持续时间，并另外保留低流量严重失败的条件。</p>
<p>恢复通知也补了等待。那次审查里，很多事件在触发后两分钟内恢复，这不能直接证明它们都是误报，但足以提醒我们别把一次短暂抖动立刻拆成两轮打断。P1 恢复默认等待五分钟，收件人和通知方式沿用触发时记录的策略。</p>
<p>这次调整还有个不太漂亮、但值得写出来的结果：把预发布与正式环境拆开以后，规则数量增加，部分新增规则被共享 Grafana 的配额拒绝了。平台保存了策略，不代表告警引擎已经开始评估。我们必须把“保存成功”“引擎已应用”和“消息已送达”分开核对。</p>
<p>上面的数字属于那次审查窗口。我不会据此写一个长期降噪百分比，因为后面还需要同口径的通知样本和漏报复盘。</p>
<h2 id="9-发布重试一个-job不应该再产生一次新发布">9. 发布：重试一个 job，不应该再产生一次新发布</h2>
<p>发布最初由 CI 脚本直接操作运行环境，后来运行底座从托管应用逐步迁到 Kubernetes，再把发布执行权交给统一平台。</p>
<p>为什么只换一层页面还不够？因为入口变多以后，CI、页面和其他工具都可能更新同一个目标。每个入口各自维护重试、回退和状态判断，就会出现两套人都以为自己负责发布的逻辑。</p>
<p>我们把 CI 留在构建和提交请求这一侧：产出镜像，登记不可变摘要及构建来源，再提交平台发布。平台负责解析目标、检查配置、执行更新和记录结果。移交以后，平台报错就处理平台的失败，不再偷偷回到旧脚本直接改集群。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/08-release.svg" title="图 12：CI 登记精确制品，平台接管目标更新与运行核验" data-thumbnail="/images/rd-platform-evolution/08-release.svg" data-sub-html="<h2>图 12：CI 登记精确制品，平台接管目标更新与运行核验</h2>">
        
    </a><figcaption class="image-caption">图 12：CI 登记精确制品，平台接管目标更新与运行核验</figcaption>
    </figure>
<p>版本也不能只记源码提交。同一个提交，换一套构建输入，仍可能得到另一份镜像。对于要求经过预发布的服务，正式发布要使用同一份不可变制品和兼容配置的验证依据，不能拿“同一个提交重新构建了一次”代替。没有声明预发布阶段的服务，按自己的环境流程执行。</p>
<p>再看一个容易出错的情况：CI 已经提交发布，平台正在滚动更新，HTTP 响应却丢了。job 失败以后点重试，如果把新的 job ID 当成一次全新发布，就可能重复执行。</p>
<p>我们的处理是把操作身份稳定在同一次流水线、提交、模块、目标和动作上。重试时先查原操作；原来已经受理，就沿着原来的发布单继续等。平台先保存操作记录，再交给持久 Worker 执行，网页关掉或者客户端超时，不会因此丢掉这次操作。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/09-unknown-result.svg" title="图 13：发布响应丢失后，job 重试继续查询同一操作 A" data-thumbnail="/images/rd-platform-evolution/09-unknown-result.svg" data-sub-html="<h2>图 13：发布响应丢失后，job 重试继续查询同一操作 A</h2>">
        
    </a><figcaption class="image-caption">图 13：发布响应丢失后，job 重试继续查询同一操作 A</figcaption>
    </figure>
<p>持久 Worker 也不能只做成一段不停轮询的代码。多个 Worker 同时运行，或者旧进程卡住以后被新进程接管，都要考虑。</p>
<p>我们先在数据库事务里保存发布意图，提交以后再允许 Worker 去操作外部资源。领取任务时锁住目标和发布记录，检查原租约是否仍然有效，再递增执行代次。其他 Worker 跳过已经被锁住的目标，或者发现租约有效后继续找下一项。</p>
<p>假设 Worker A 拿到第 7 代执行权，调用集群时卡住了。租约过期，Worker B 接管同一张发布单，拿到第 8 代。此时 A 突然恢复，不能让它把迟到的结果盖回去。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/14-release-fencing.svg" title="图 14：新 Worker 接管后，旧 Worker 提交的执行代次会被拒绝" data-thumbnail="/images/rd-platform-evolution/14-release-fencing.svg" data-sub-html="<h2>图 14：新 Worker 接管后，旧 Worker 提交的执行代次会被拒绝</h2>">
        
    </a><figcaption class="image-caption">图 14：新 Worker 接管后，旧 Worker 提交的执行代次会被拒绝</figcaption>
    </figure>
<p><em>第 7、8 代是说明并发顺序的示例编号，不是实际发布记录。</em></p>
<p>因此，写入进度时还要核对当前操作、目标、执行代次、Worker 身份和有效租约。只要其中一项不匹配，旧执行者就失去写回资格。这里的租约决定什么时候可以接管，执行代次则用来识别“回来得太晚的人”，两者作用不同。</p>
<p>但这还没把外部操作变成“绝对只执行一次”。租约到期不会撤回一条已经发给集群的网络请求。所以更新和删除资源时，还要检查资源 UID、版本和操作标记；已经发出、又没有拿到确定结果的动作，要读回实际资源，沿着原操作恢复判断，不能换一个编号再做一次。</p>
<p>我们用 PostgreSQL 同时保存发布意图、领取资格和进度，让这些更新能在本地事务里约束。引入一个消息队列也不会自动解决外部操作和数据库之间的一致性，因此这里先把原有记录的状态变化做完整。相应的代价是 Worker 领取会占用数据库锁，轮询和并发规模需要控制。</p>
<p>进程退出时则先停止领取，给已经开始的步骤留出收尾时间。来不及完成的操作保留可恢复状态，不为了让关机过程看起来成功，就提前写成执行成功。重试规则也留在具体任务里：发布、资源同步和通知投递的失败含义不同，不能用一个统一的“异常就再跑一次”覆盖它们。</p>
<p>查询也不能只问“这个服务有没有成功记录”。还要核对是不是这次操作、这个目标、这份制品，避免把另一次成功发布当作本次结果。</p>
<p>回退同样只引用已记录的历史成功发布，不靠猜镜像标签。切回旧版本以后，还要重新验证业务路径；平台完成了版本切换，只回答了切换有没有执行完。</p>
<h2 id="10-回收环境同名的新实例不能被旧任务删掉">10. 回收环境：同名的新实例，不能被旧任务删掉</h2>
<p>个人环境能自动创建以后，关闭合并请求时自然也希望自动回收。但“收到关闭事件，然后按名字删除资源”是不够的。</p>
<p>考虑这个顺序：环境开始回收，删除请求的响应没有回来；用户又重新打开同一个改动，准备创建新环境；旧清理任务随后重试。只认名字，就有误删新实例的风险。</p>
<p>我们给环境记录了代次和创建操作的身份。关闭时先阻止继续发布，等待已有执行租约结束，再冻结目标。删除使用账本里记录的精确资源 UID 和版本条件；只有旧代确认删除后，才允许新一代复用同样的资源名。名字可以相同，资源身份必须是新的。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/10-lifecycle.svg" title="图 15：旧环境确认回收后才能创建新代，旧清理任务无法按同名误删" data-thumbnail="/images/rd-platform-evolution/10-lifecycle.svg" data-sub-html="<h2>图 15：旧环境确认回收后才能创建新代，旧清理任务无法按同名误删</h2>">
        
    </a><figcaption class="image-caption">图 15：旧环境确认回收后才能创建新代，旧清理任务无法按同名误删</figcaption>
    </figure>
<p>云资源同步里也有类似的问题。供应商接口查询失败，只说明这次没取到状态，不能把上次还在的资源全标成不存在。完整查询成功以后才更新观察结果；失败则保留上次记录，并标明本次查询失败。</p>
<p>“从平台取消管理”和“销毁云上的实例”也分开处理。前者改变平台的管理关系，后者会真实删除资源，两种动作不能共用一个含糊的删除入口。</p>
<p>这些边界让自动化代码多了不少状态判断，但省掉它们的代价，会落在真正创建、删除的那一刻。</p>
<h2 id="11-最后沉淀下来的是不用每次重做的接入步骤">11. 最后沉淀下来的，是不用每次重做的接入步骤</h2>
<p>做完前面的东西，新服务再接入时，就不应该从复制几份旧配置开始。</p>
<p>标准服务模板带上基本运行与监控接入，标准客户端负责采集数据库、缓存和外部依赖调用。服务声明自己用了哪些能力，再对应到需要的监控模板和发布目标。实际跑起来以后，还要检查有没有样本、规则有没有应用，不能把生成了一份配置当成接入结束。</p>
<p>页面之外，我们也提供 CLI。流水线通过它提交发布，人用它查状态，Agent 也可以调用同样的入口。它们引用同一个目标和同一张操作记录，不各自猜一套资源名，也不把一个超时统一包装成失败。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/11-platform.svg" title="图 16：页面、CLI 和流水线共同使用目标、制品及发布操作记录" data-thumbnail="/images/rd-platform-evolution/11-platform.svg" data-sub-html="<h2>图 16：页面、CLI 和流水线共同使用目标、制品及发布操作记录</h2>">
        
    </a><figcaption class="image-caption">图 16：页面、CLI 和流水线共同使用目标、制品及发布操作记录</figcaption>
    </figure>
<p>这对 Agent 参与运维尤其实际。它遇到发布结果未知，下一步应该拿原操作编号去查询；查询没有监控样本，就应该保留数据缺口。把这些输入输出做好，比单纯让它多执行几个命令更有用。</p>
<p>回头看，我们对架构的判断也在变。早期更关注功能怎么接上：能部署、能查询、能通知。后来关注的是同一个动作被多个人、多种工具、多个进程同时碰到时，系统还能不能说清楚它的状态。</p>
<p>有些地方因此收敛了：发布执行统一进入控制面，平台内部减少跨进程转发，入口共用同一套用例。有些边界反而划得更细：请求进程和持久 Worker 分开，数据按模块确定写入者，租约与执行代次分别校验，告警触发与消息送达分别记录。</p>
<p>我现在更愿意拿具体问题检验这些设计：同一次发布重试会不会多执行一次，旧 Worker 回来能不能覆盖新状态，个人环境是不是测到了自己的执行链，查不到数据时页面会不会误导人。能把这些问题答清楚，架构图上的那些线才有意义。</p>]]></description></item></channel></rss>