<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Elvis Qian</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://qianwj.github.io/</id>
  <link href="https://qianwj.github.io/" rel="alternate"/>
  <link href="https://qianwj.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Elvis Qian</rights>
  <subtitle>Life is all about continuous experiences.</subtitle>
  <title>Elvis' Garden</title>
  <updated>2026-08-03T02:00:00.000Z</updated>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="开源项目" scheme="https://qianwj.github.io/categories/%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE/"/>
    <category term="gRPC" scheme="https://qianwj.github.io/tags/gRPC/"/>
    <category term="Reactor Netty" scheme="https://qianwj.github.io/tags/Reactor-Netty/"/>
    <category term="HTTP/2" scheme="https://qianwj.github.io/tags/HTTP-2/"/>
    <category term="Protobuf" scheme="https://qianwj.github.io/tags/Protobuf/"/>
    <content>
      <![CDATA[<p>协议值与消息帧完成后，Stage 2 实现了第一条端到端调用：plaintext h2c unary RPC。这部分已经进入 <code>main</code>，后续 Stage 3 和 Stage 4 继续在同一 transport primitive 上完成了全部四种 RPC cardinality。</p><h2 id="Method-descriptor-是协议与类型的交点"><a href="#Method-descriptor-是协议与类型的交点" class="headerlink" title="Method descriptor 是协议与类型的交点"></a>Method descriptor 是协议与类型的交点</h2><p>一个方法需要精确的 service name、method name、cardinality，以及 request&#x2F;response marshaller：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">echo</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">GrpcMethod</span>&lt;&gt;(</span><br><span class="line">    <span class="string">&quot;testing.EchoService&quot;</span>,</span><br><span class="line">    <span class="string">&quot;Echo&quot;</span>,</span><br><span class="line">    GrpcMethod.Cardinality.UNARY,</span><br><span class="line">    <span class="keyword">new</span> <span class="title class_">ProtobufMarshaller</span>&lt;&gt;(StringValue.parser()),</span><br><span class="line">    <span class="keyword">new</span> <span class="title class_">ProtobufMarshaller</span>&lt;&gt;(StringValue.parser()));</span><br></pre></td></tr></table></figure></div><p>它生成的 path 必须是：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/testing.EchoService/Echo</span><br></pre></td></tr></table></figure></div><p>服务注册表按完整 path 精确匹配。未知 path 返回 <code>UNIMPLEMENTED</code>，重复注册同一路径在构建 service definition 时立即失败。</p><h2 id="Server-先验证协议，再订阅业务"><a href="#Server-先验证协议，再订阅业务" class="headerlink" title="Server 先验证协议，再订阅业务"></a>Server 先验证协议，再订阅业务</h2><p><code>ReactorGrpcServer</code> 使用 Reactor Netty h2c：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">DisposableServer</span> <span class="variable">bound</span> <span class="operator">=</span> HttpServer.create()</span><br><span class="line">    .host(host)</span><br><span class="line">    .port(port)</span><br><span class="line">    .protocol(HttpProtocol.H2C)</span><br><span class="line">    .handle(handler::handle)</span><br><span class="line">    .bindNow(Duration.ofSeconds(<span class="number">10</span>));</span><br></pre></td></tr></table></figure></div><p>请求进入后按顺序验证：</p><ul><li>HTTP method 必须是 POST；</li><li>content-type 必须是 <code>application/grpc</code> 或 <code>application/grpc+...</code>；</li><li><code>te</code> 必须声明 trailers；</li><li>path 必须存在；</li><li>当前只允许 unary cardinality；</li><li>metadata 与 message size 不能超过限制。</li></ul><p>验证通过后才创建 <code>GrpcCallContext</code> 并订阅 request body，避免无效请求进入业务 Handler。</p><h2 id="HTTP-200-不代表-RPC-成功"><a href="#HTTP-200-不代表-RPC-成功" class="headerlink" title="HTTP 200 不代表 RPC 成功"></a>HTTP 200 不代表 RPC 成功</h2><p>Server 先写 compatible content-type，最终状态由 trailing headers 产生：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">response.status(<span class="number">200</span>)</span><br><span class="line">    .header(HttpHeaderNames.CONTENT_TYPE, <span class="string">&quot;application/grpc+proto&quot;</span>);</span><br><span class="line"></span><br><span class="line">response.trailerHeaders(trailers -&gt; &#123;</span><br><span class="line">  <span class="type">GrpcException</span> <span class="variable">error</span> <span class="operator">=</span> terminal.get();</span><br><span class="line">  <span class="keyword">if</span> (error == <span class="literal">null</span>) &#123;</span><br><span class="line">    writeStatus(trailers, GrpcStatus.OK, GrpcMetadata.empty());</span><br><span class="line">  &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    writeStatus(trailers, error.status(), error.trailers());</span><br><span class="line">  &#125;</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure></div><p>应用抛出的 <code>GrpcException</code> 保留明确 status 与 trailing metadata。普通应用异常映射为 <code>UNKNOWN</code>；协议违规映射为 <code>INTERNAL</code>。Fatal JVM error 不应该被包装成普通业务状态。</p><p>Client 收到响应后必须读取最终 <code>grpc-status</code>。HTTP 200 却没有 status 是 <code>INTERNAL</code>；非 gRPC HTTP 404 按标准 fallback 映射为 <code>UNIMPLEMENTED</code>，503 则映射为 <code>UNAVAILABLE</code>。</p><h2 id="Metadata-必须保序并允许重复"><a href="#Metadata-必须保序并允许重复" class="headerlink" title="Metadata 必须保序并允许重复"></a>Metadata 必须保序并允许重复</h2><p>gRPC metadata 不是普通 <code>Map&lt;String, String&gt;</code>。同一个 key 可以重复，顺序需要保留，<code>-bin</code> 后缀表示二进制值并在 wire 上使用无 padding Base64。</p><p><code>GrpcMetadata</code> 因此内部保存 immutable entry list：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">GrpcMetadata.builder()</span><br><span class="line">    .addAscii(<span class="string">&quot;trace-id&quot;</span>, <span class="string">&quot;abc&quot;</span>)</span><br><span class="line">    .addBinary(<span class="string">&quot;span-bin&quot;</span>, bytes)</span><br><span class="line">    .build();</span><br></pre></td></tr></table></figure></div><p>用户 metadata 不能覆盖 <code>content-type</code>、<code>te</code>、<code>grpc-status</code>、<code>grpc-timeout</code> 和 HTTP&#x2F;2 pseudo headers 等保留字段。读取时还要限制编码后总大小，防止 peer 用 headers 消耗无界内存。</p><h2 id="Deadline-的-wire-value-不能简单截断"><a href="#Deadline-的-wire-value-不能简单截断" class="headerlink" title="Deadline 的 wire value 不能简单截断"></a>Deadline 的 wire value 不能简单截断</h2><p><code>grpc-timeout</code> 由最多八位数字和一个单位组成，例如 <code>100m</code>、<code>2S</code>。格式化时必须向上取整，确保 wire deadline 不会比调用者要求更短：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">BigInteger</span> <span class="variable">amount</span> <span class="operator">=</span> ceilDivide(nanos, unit.nanos);</span><br><span class="line"><span class="keyword">if</span> (amount.compareTo(MAX_VALUE) &lt;= <span class="number">0</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> amount + String.valueOf(unit.suffix);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>在本篇对应的 Stage 2 提交中，client 只会写入 timeout header，并把 Reactor Netty response timeout 设置得略长，用于区分 gRPC deadline 与底层连接等待。仅能解析 header 还不算完整支持；Stage 5 后续补上了 client 与 server 的 Reactor deadline 定时器、handler 取消和 <code>GrpcCallContext</code> 传播。当前实现仍不会替业务代码自动为 nested call 派生更短的 deadline。</p><h2 id="Unary-cardinality-用-single-强制执行"><a href="#Unary-cardinality-用-single-强制执行" class="headerlink" title="Unary cardinality 用 single 强制执行"></a>Unary cardinality 用 single 强制执行</h2><p>Client 对 unary request 使用 <code>requests.single()</code>，response 同样要求一个值。空请求、两个请求、空响应和两个响应都不是隐式默认值，而是明确错误。</p><p>当前 transport 测试覆盖：</p><ul><li>unary message 与 ASCII metadata 往返；</li><li>declared error 和 trailing metadata；</li><li>unknown method；</li><li>空&#x2F;重复 unary 数据；</li><li>非 gRPC HTTP 响应与缺失 status；</li><li>client cancellation 传播到 server publisher；</li><li>connection failure 映射为 <code>UNAVAILABLE</code>。</li></ul><p>Unary vertical slice 的测试继续作为 streaming transport 的回归基线。Server、client 与 bidirectional streaming 已在后续提交完成；TLS、GOAWAY、keepalive、compression、deadline、连接池和 transport diagnostics 也已在 Stage 5 完成并进入 <code>main</code>。</p><h2 id="Client-请求的完整生命周期"><a href="#Client-请求的完整生命周期" class="headerlink" title="Client 请求的完整生命周期"></a>Client 请求的完整生命周期</h2><p><code>ReactorGrpcClient</code> 的 unary 调用最终映射为一次完整的 HTTP&#x2F;2 stream 交互。下面是从用户调用到网络响应的全路径：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line">client.unary(method, Mono.just(request))</span><br><span class="line">    │</span><br><span class="line">    ├─ Flux.defer() → 惰性组装，每次订阅独立</span><br><span class="line">    │</span><br><span class="line">    ├─ channel.select() → 选取 subchannel（Stage 2 固定单连接）</span><br><span class="line">    │</span><br><span class="line">    ├─ httpClient.post(method.path())</span><br><span class="line">    │   ├─ 写 HTTP/2 request headers</span><br><span class="line">    │   │   content-type: application/grpc+proto</span><br><span class="line">    │   │   te: trailers</span><br><span class="line">    │   │   grpc-accept-encoding: gzip</span><br><span class="line">    │   │   grpc-timeout: &lt;if set&gt;</span><br><span class="line">    │   │   custom metadata</span><br><span class="line">    │   │</span><br><span class="line">    │   ├─ onEventLoop() → 在连接的 event loop 上序列化</span><br><span class="line">    │   │</span><br><span class="line">    │   ├─ GrpcOutboundMessageEncoder.encode()</span><br><span class="line">    │   │   ├─ marshaller.serialize() → Protobuf → ByteBuf</span><br><span class="line">    │   │   ├─ 校验 size ≤ maxOutboundMessageSize</span><br><span class="line">    │   │   └─ GrpcFrameCodec.encode() → 5 字节 header + payload</span><br><span class="line">    │   │</span><br><span class="line">    │   └─ outbound.send(requestFrames) → HTTP/2 DATA frame</span><br><span class="line">    │</span><br><span class="line">    └─ .response((response, content) → decodeResponse)</span><br><span class="line">        ├─ 检查 response headers 中的 grpc-status（trailers-only 错误）</span><br><span class="line">        ├─ 校验 HTTP 200 + content-type = application/grpc</span><br><span class="line">        ├─ 读取 grpc-encoding header</span><br><span class="line">        ├─ GrpcFrameCodec.decode(content) → 增量帧解析</span><br><span class="line">        ├─ marshaller.deserialize() → ByteBuf → Response protobuf</span><br><span class="line">        └─ 读取 trailers 中的 grpc-status + grpc-message</span><br></pre></td></tr></table></figure></div><p>关键设计：整个 pipeline 是 lazy 的——在 <code>Mono</code> 被订阅之前不会发起 HTTP&#x2F;2 连接。这与 grpc-java 的 <code>ClientCall.start()</code> 即触发连接的模式不同。</p><h2 id="为什么要在-event-loop-上序列化"><a href="#为什么要在-event-loop-上序列化" class="headerlink" title="为什么要在 event loop 上序列化"></a>为什么要在 event loop 上序列化</h2><p>Reactor Netty 的每条 HTTP&#x2F;2 连接绑定到一个 Netty event loop 线程。如果 marshalling 发生在用户线程，序列化产生的 ByteBuf 需要跨线程传递给 event loop，带来额外的同步开销。</p><p><code>onEventLoop()</code> 把序列化推迟到连接的 event loop 上执行：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> &lt;T&gt; Flux&lt;T&gt; <span class="title function_">onEventLoop</span><span class="params">(NettyOutbound outbound, Flux&lt;T&gt; source)</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> source.publishOn(</span><br><span class="line">        Schedulers.fromExecutor(outbound.alloc().buffer(<span class="number">0</span>).alloc()</span><br><span class="line">            ... <span class="comment">// 获取 event loop executor</span></span><br><span class="line">        ));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>这样做的好处：</p><ul><li>序列化和网络写入在同一线程，无锁；</li><li>ByteBuf 的 allocator 与连接一致，减少跨 arena 分配；</li><li><code>concatMap(..., 1)</code> 控制 prefetch，保证消息严格按序发出。</li></ul><h2 id="错误映射：从-HTTP-2-异常到-gRPC-状态"><a href="#错误映射：从-HTTP-2-异常到-gRPC-状态" class="headerlink" title="错误映射：从 HTTP&#x2F;2 异常到 gRPC 状态"></a>错误映射：从 HTTP&#x2F;2 异常到 gRPC 状态</h2><p>网络层的错误不能直接暴露给用户，需要映射到 gRPC 语义：</p><table><thead><tr><th>网络异常</th><th>gRPC Status</th><th>含义</th></tr></thead><tbody><tr><td><code>Http2Exception.StreamException(REFUSED_STREAM)</code></td><td>UNAVAILABLE</td><td>Server 拒绝了这条 stream，可安全重试</td></tr><tr><td>GOAWAY frame</td><td>UNAVAILABLE</td><td>Server 正在优雅关闭</td></tr><tr><td>连接关闭 &#x2F; reset</td><td>UNAVAILABLE</td><td>网络断开</td></tr><tr><td>HTTP 404（非 gRPC 响应）</td><td>UNIMPLEMENTED</td><td>路径不存在（可能经过了非 gRPC 代理）</td></tr><tr><td>HTTP 503</td><td>UNAVAILABLE</td><td>服务暂时不可用</td></tr><tr><td>缺失 <code>grpc-status</code></td><td>INTERNAL</td><td>协议违规</td></tr><tr><td><code>content-type</code> 不匹配</td><td>INTERNAL</td><td>响应不是 gRPC</td></tr></tbody></table><p>这个映射遵循 gRPC 协议规范中定义的 <a class="link"   href="https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md" >HTTP status code 到 gRPC status 的转换表<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a>。<code>UNAVAILABLE</code> 意味着客户端可以重试；<code>INTERNAL</code> 和 <code>UNIMPLEMENTED</code> 通常不值得重试。</p><h2 id="Client-取消如何传播到-Server"><a href="#Client-取消如何传播到-Server" class="headerlink" title="Client 取消如何传播到 Server"></a>Client 取消如何传播到 Server</h2><p>Reactor 的 <code>dispose()</code> 需要映射为 HTTP&#x2F;2 的 RST_STREAM，让 server 知道这条调用已经不需要了：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">client: subscription.dispose()</span><br><span class="line">    → Reactor Netty 观察到 subscriber cancel</span><br><span class="line">    → 发送 HTTP/2 RST_STREAM frame</span><br><span class="line">    → Server 连接收到 RST_STREAM</span><br><span class="line">    → request body publisher 被 cancel</span><br><span class="line">    → server handler 的 Mono/Flux 收到 cancel signal</span><br></pre></td></tr></table></figure></div><p>Server 侧通过 connection handler 监听 stream 关闭事件，将其转换为 <code>peerCancellation</code> signal。业务 handler 使用 <code>takeUntilOther(peerCancellation)</code> 订阅这个 signal，确保 client 取消后 server 不再做无用计算。</p><p>这一机制让 Reactor 的背压语义（cancel &#x3D; 我不再需要数据）与 gRPC 的取消语义（RST_STREAM &#x3D; 调用已中止）自然对齐。</p><h2 id="与-grpc-java-的互操作验证"><a href="#与-grpc-java-的互操作验证" class="headerlink" title="与 grpc-java 的互操作验证"></a>与 grpc-java 的互操作验证</h2><p>Stage 2 的退出条件不是”自己调自己成功”，而是必须与 grpc-java 双向互通。<code>UnaryInteroperabilityTest</code> 验证两个方向：</p><p><strong>Reactor client → grpc-java server：</strong></p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">reactorClientCallsGrpcJavaServer</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">try</span> (<span class="type">var</span> <span class="variable">fixture</span> <span class="operator">=</span> GrpcJavaFixture.start();</span><br><span class="line">         <span class="type">var</span> <span class="variable">client</span> <span class="operator">=</span> ReactorGrpcClient.builder().port(fixture.port()).build()) &#123;</span><br><span class="line">        <span class="type">var</span> <span class="variable">response</span> <span class="operator">=</span> stub.unary(Mono.just(request(<span class="string">&quot;hello&quot;</span>))).block();</span><br><span class="line">        assertEquals(<span class="string">&quot;hello&quot;</span>, response.getValue());</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 验证错误 status 和 trailing metadata 正确传递</span></span><br><span class="line">        <span class="type">var</span> <span class="variable">error</span> <span class="operator">=</span> assertThrows(GrpcException.class,</span><br><span class="line">            () -&gt; stub.unary(Mono.just(request(<span class="string">&quot;error&quot;</span>))).block());</span><br><span class="line">        assertEquals(GrpcStatus.Code.INVALID_ARGUMENT, error.status().code());</span><br><span class="line">        assertEquals(<span class="string">&quot;rejected&quot;</span>, error.status().message());</span><br><span class="line">        assertEquals(<span class="string">&quot;fixture&quot;</span>, error.trailers().getLast(<span class="string">&quot;error-id&quot;</span>).orElseThrow().asciiValue());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p><strong>grpc-java client → Reactor server：</strong></p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">grpcJavaClientCallsReactorServer</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="type">var</span> <span class="variable">service</span> <span class="operator">=</span> InteropTestServiceReactorGrpc.bindService(<span class="keyword">new</span> <span class="title class_">Service</span>() &#123;</span><br><span class="line">        <span class="meta">@Override</span></span><br><span class="line">        <span class="keyword">public</span> Mono&lt;TestResponse&gt; <span class="title function_">unary</span><span class="params">(GrpcCallContext context, Mono&lt;TestRequest&gt; requests)</span> &#123;</span><br><span class="line">            <span class="keyword">return</span> requests.map(req -&gt; &#123;</span><br><span class="line">                <span class="keyword">if</span> (req.getValue().equals(<span class="string">&quot;error&quot;</span>)) &#123;</span><br><span class="line">                    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">GrpcException</span>(</span><br><span class="line">                        <span class="keyword">new</span> <span class="title class_">GrpcStatus</span>(GrpcStatus.Code.INVALID_ARGUMENT, <span class="string">&quot;rejected&quot;</span>),</span><br><span class="line">                        GrpcMetadata.builder().addAscii(<span class="string">&quot;error-id&quot;</span>, <span class="string">&quot;reactor&quot;</span>).build());</span><br><span class="line">                &#125;</span><br><span class="line">                <span class="keyword">return</span> TestResponse.newBuilder().setValue(req.getValue()).build();</span><br><span class="line">            &#125;);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">try</span> (<span class="type">var</span> <span class="variable">server</span> <span class="operator">=</span> ReactorGrpcServer.builder().addService(service).start()) &#123;</span><br><span class="line">        <span class="type">var</span> <span class="variable">stub</span> <span class="operator">=</span> InteropTestServiceGrpc.newBlockingStub(channel);</span><br><span class="line">        assertEquals(<span class="string">&quot;hello&quot;</span>, stub.unary(request(<span class="string">&quot;hello&quot;</span>)).getValue());</span><br><span class="line"></span><br><span class="line">        <span class="type">var</span> <span class="variable">error</span> <span class="operator">=</span> assertThrows(StatusRuntimeException.class,</span><br><span class="line">            () -&gt; stub.unary(request(<span class="string">&quot;error&quot;</span>)));</span><br><span class="line">        assertEquals(Status.Code.INVALID_ARGUMENT, error.getStatus().getCode());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>两个方向都验证了：正常响应、错误 status code、错误 message 的 percent-encoding 往返，以及自定义 trailing metadata 的传递。只有 grpc-java 能正确解析 Reactor server 的响应，才能证明 wire protocol 实现是兼容的。</p><h2 id="Stage-2-退出条件"><a href="#Stage-2-退出条件" class="headerlink" title="Stage 2 退出条件"></a>Stage 2 退出条件</h2><ul><li><code>./gradlew clean spotlessCheck test --no-daemon</code> 全量通过</li><li>Reactor client ↔ grpc-java server 双向 unary 调用成功</li><li>grpc-java client ↔ Reactor server 双向 unary 调用成功</li><li>错误 status、message、trailing metadata 正确传递</li><li>Client cancellation 通过 RST_STREAM 传播到 server</li><li>连接失败映射为 UNAVAILABLE</li><li>未知路径映射为 UNIMPLEMENTED</li><li>Stage 0 和 Stage 1 的全部测试继续绿灯</li></ul><p>下一篇进入 Stage 3 和 Stage 4：在同一 transport primitive 上实现 server streaming、client streaming 和 bidirectional streaming，面对的新挑战是多消息流的背压协调、有界入站缓冲和流提前终止的资源回收。</p>]]>
    </content>
    <id>https://qianwj.github.io/2026/06/03/grpc-reactor-series-3/</id>
    <link href="https://qianwj.github.io/2026/06/03/grpc-reactor-series-3/"/>
    <published>2026-06-03T07:50:00.000Z</published>
    <summary>实现 Reactor gRPC unary client/server，分析 HTTP/2 stream 生命周期、事件循环序列化、service dispatch、trailers status、错误映射、取消传播与 grpc-java 互操作验证。</summary>
    <title>grpc-reactor 实战（三）：把 Unary RPC 映射到 Reactor Netty HTTP/2</title>
    <updated>2026-08-03T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="Open Source" scheme="https://qianwj.github.io/categories/Open-Source/"/>
    <category term="gRPC" scheme="https://qianwj.github.io/tags/gRPC/"/>
    <category term="Netty" scheme="https://qianwj.github.io/tags/Netty/"/>
    <category term="ByteBuf" scheme="https://qianwj.github.io/tags/ByteBuf/"/>
    <category term="Reactive Streams" scheme="https://qianwj.github.io/tags/Reactive-Streams/"/>
    <content>
      <![CDATA[<p>This is the second article in my <a class="link"   href="https://github.com/qianwj/grpc-reactor" >grpc-reactor<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a> series. The <a href="https://qianwj.github.io/en/2026/06/01/grpc-reactor-series-1/">first article</a> explains why I chose to build the runtime directly on Reactor Netty and where its compatibility boundary sits. This article moves one layer down into the Stage 1 protocol implementation: the frame decoder that every RPC shape relies on.</p><p>gRPC protobuf messages are not written directly as raw bytes into HTTP&#x2F;2 DATA frames. Every message starts with a five-byte envelope:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">byte 0      bit 0 indicates compression; bits 1-7 must be zero</span><br><span class="line">bytes 1-4   unsigned big-endian payload length</span><br><span class="line">byte 5..n   protobuf message, or its compressed representation</span><br></pre></td></tr></table></figure></div><p>Encoding this envelope is straightforward. The difficult part is decoding it without assuming that one input buffer contains one complete frame. HTTP&#x2F;2, TCP, and Reactor Netty do not promise that buffer boundaries will line up with gRPC message boundaries.</p><p>This post describes the Stage 1 protocol layer. The project has since progressed beyond it, but the ownership and bounded-decoding rules introduced here remain the foundation for the later transport stages.</p><h2 id="Encoding-Must-Define-Ownership"><a href="#Encoding-Must-Define-Ownership" class="headerlink" title="Encoding Must Define Ownership"></a>Encoding Must Define Ownership</h2><p>The contract of <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/main/java/io/github/qianwj/grpc/reactor/protocol/GrpcFrameCodec.java"><code>GrpcFrameCodec.encode</code></a> is deliberately explicit: the returned frame and the input message have independent lifetimes. Encoding must not move the input reader index or release the input buffer.</p><p>The implementation currently copies the readable bytes into a byte array before applying compression:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">static</span> ByteBuf <span class="title function_">encode</span><span class="params">(</span></span><br><span class="line"><span class="params">        ByteBufAllocator allocator,</span></span><br><span class="line"><span class="params">        ByteBuf message,</span></span><br><span class="line"><span class="params">        GrpcCompression.Codec compression)</span> &#123;</span><br><span class="line">    <span class="type">boolean</span> <span class="variable">compressed</span> <span class="operator">=</span> !compression.name().equals(<span class="string">&quot;identity&quot;</span>);</span><br><span class="line">    <span class="type">byte</span>[] payload = <span class="keyword">new</span> <span class="title class_">byte</span>[message.readableBytes()];</span><br><span class="line">    message.getBytes(message.readerIndex(), payload);</span><br><span class="line">    <span class="keyword">if</span> (compressed) &#123;</span><br><span class="line">        payload = compression.compress(payload);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> allocator.buffer(GrpcFrameCodec.HEADER_SIZE + payload.length)</span><br><span class="line">            .writeByte(compressed ? <span class="number">1</span> : <span class="number">0</span>)</span><br><span class="line">            .writeInt(payload.length)</span><br><span class="line">            .writeBytes(payload);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>This is not a zero-copy implementation, and it should not be presented as the fastest possible design. The copy makes the ownership boundary easy to reason about first. If a later optimization uses a slice or a composite buffer, cancellation and exception paths must be re-proven instead of assuming that the old ownership rules still hold.</p><h2 id="The-Decoder-Is-a-Per-Subscription-State-Machine"><a href="#The-Decoder-Is-a-Per-Subscription-State-Machine" class="headerlink" title="The Decoder Is a Per-Subscription State Machine"></a>The Decoder Is a Per-Subscription State Machine</h2><p><code>decode</code> uses <code>Flux.defer</code> so every subscription receives an independent decoder instance:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">return</span> Flux.defer(() -&gt; &#123;</span><br><span class="line">    <span class="type">var</span> <span class="variable">decoder</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">Decoder</span>(</span><br><span class="line">            maxWireMessageSize,</span><br><span class="line">            maxDecompressedMessageSize,</span><br><span class="line">            compression,</span><br><span class="line">            maxBufferedBytesPerStream);</span><br><span class="line">    <span class="keyword">return</span> input.concatMap(decoder::accept, <span class="number">1</span>)</span><br><span class="line">            .concatWith(Flux.defer(decoder::finish))</span><br><span class="line">            .publishOn(Schedulers.immediate(), <span class="number">1</span>);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure></div><p>The state is intentionally small: either the five-byte header is incomplete, or the header is complete and the decoder is collecting a payload of a known length. One input <code>ByteBuf</code> may contain one byte of a header, or three complete messages back-to-back.</p><p><code>concatMap(..., 1)</code> preserves source order and limits the number of source buffers being processed at once. The decoder still has to respect downstream demand when it emits decoded messages. Every source buffer is released in <code>doFinally</code>, including success, failure, and cancellation:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> Flux&lt;ByteBuf&gt; <span class="title function_">accept</span><span class="params">(ByteBuf source)</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> Flux.&lt;ByteBuf&gt;generate(sink -&gt; &#123;</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            <span class="keyword">while</span> (source.isReadable()) &#123;</span><br><span class="line">                <span class="comment">// read the header, allocate a bounded payload,</span></span><br><span class="line">                <span class="comment">// and emit a complete message when available</span></span><br><span class="line">            &#125;</span><br><span class="line">            sink.complete();</span><br><span class="line">        &#125; <span class="keyword">catch</span> (Throwable error) &#123;</span><br><span class="line">            sink.error(error);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;).doFinally(ignored -&gt; source.release());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>The complete implementation also tracks an explicit per-stream buffered-byte limit. That limit covers an incomplete header, a partial payload, and bytes still present in the current source buffer.</p><h2 id="Validate-Peer-Input-Before-Allocating"><a href="#Validate-Peer-Input-Before-Allocating" class="headerlink" title="Validate Peer Input Before Allocating"></a>Validate Peer Input Before Allocating</h2><p>The length field comes from the peer, so it must be validated before allocating a payload array. The decoder first combines the unsigned big-endian bytes in a <code>long</code>, then checks the wire-size limit:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">long</span> <span class="variable">length</span> <span class="operator">=</span> ((<span class="type">long</span>) (header[<span class="number">1</span>] &amp; <span class="number">0xff</span>) &lt;&lt; <span class="number">24</span>)</span><br><span class="line">        | ((<span class="type">long</span>) (header[<span class="number">2</span>] &amp; <span class="number">0xff</span>) &lt;&lt; <span class="number">16</span>)</span><br><span class="line">        | ((<span class="type">long</span>) (header[<span class="number">3</span>] &amp; <span class="number">0xff</span>) &lt;&lt; <span class="number">8</span>)</span><br><span class="line">        | (header[<span class="number">4</span>] &amp; <span class="number">0xffL</span>);</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (length &gt; maxWireMessageSize) &#123;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">GrpcProtocolException</span>(<span class="string">&quot;wire message length exceeds limit&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>Wire size and decompressed size are separate limits. A tiny gzip payload can expand into a huge message, so gzip decompression must enforce a second output limit to defend against decompression bombs.</p><p>Only the lowest compression-flag bit is valid. Any reserved bit is a protocol error. A compressed frame received while the negotiated codec is still <code>identity</code> is also rejected; the decoder must not guess which algorithm the peer intended.</p><h2 id="Test-Every-Header-Split-Point"><a href="#Test-Every-Header-Split-Point" class="headerlink" title="Test Every Header Split Point"></a>Test Every Header Split Point</h2><p>Testing one arbitrary two-buffer split is not enough. A five-byte header has six representative split positions, including before the first byte and after the complete header. The test suite uses a dynamic test for every split from 0 through 5:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">IntStream.rangeClosed(<span class="number">0</span>, GrpcFrameCodec.HEADER_SIZE)</span><br><span class="line">        .mapToObj(split -&gt; DynamicTest.dynamicTest(</span><br><span class="line">                <span class="string">&quot;split after byte &quot;</span> + split,</span><br><span class="line">                () -&gt; &#123;</span><br><span class="line">                    <span class="type">byte</span>[] wire = wireBytes(<span class="string">&quot;hello&quot;</span>);</span><br><span class="line">                    Flux&lt;ByteBuf&gt; chunks = Flux.just(</span><br><span class="line">                            wrapped(wire, <span class="number">0</span>, split),</span><br><span class="line">                            wrapped(wire, split, wire.length - split));</span><br><span class="line">                    StepVerifier.create(GrpcFrameCodec.decode(chunks))</span><br><span class="line">                            .assertNext(message -&gt; assertMessage(message, <span class="string">&quot;hello&quot;</span>))</span><br><span class="line">                            .verifyComplete();</span><br><span class="line">                &#125;));</span><br></pre></td></tr></table></figure></div><p>The same test class covers arbitrary body fragmentation, multiple messages coalesced into one buffer, empty messages, gzip, reserved flags, truncated frames, wire&#x2F;decompressed limits, and the fact that encoding does not consume the input buffer. See <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/test/java/io/github/qianwj/grpc/reactor/protocol/GrpcFrameCodecTest.java"><code>GrpcFrameCodecTest</code></a> for the executable cases.</p><h2 id="Cancellation-Must-Release-Undelivered-Data"><a href="#Cancellation-Must-Release-Undelivered-Data" class="headerlink" title="Cancellation Must Release Undelivered Data"></a>Cancellation Must Release Undelivered Data</h2><p>Suppose one source <code>ByteBuf</code> contains three messages: <code>one</code>, <code>two</code>, and <code>three</code>. The downstream requests two messages and then cancels. The test must assert not only the values it received, but also that the source buffer was released:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">StepVerifier.create(GrpcFrameCodec.decode(Flux.just(source)), <span class="number">0</span>)</span><br><span class="line">        .thenRequest(<span class="number">1</span>)</span><br><span class="line">        .assertNext(message -&gt; assertMessage(message, <span class="string">&quot;one&quot;</span>))</span><br><span class="line">        .thenRequest(<span class="number">1</span>)</span><br><span class="line">        .assertNext(message -&gt; assertMessage(message, <span class="string">&quot;two&quot;</span>))</span><br><span class="line">        .thenCancel()</span><br><span class="line">        .verify();</span><br><span class="line"></span><br><span class="line">assertEquals(<span class="number">0</span>, source.refCnt());</span><br></pre></td></tr></table></figure></div><p>That assertion is more important than a happy-path content check. Network code often behaves correctly under normal completion; leaks tend to appear during cancellation, size-limit failures, truncated frames, or competing terminal signals.</p><p>Cancellation can also arrive before a complete message exists. In that case there is no decoded value for the subscriber to release, so the decoder itself must release the partially accumulated source buffer:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">releasesPartialFrameInputWhenCancelled</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="type">ByteBuf</span> <span class="variable">partial</span> <span class="operator">=</span> Unpooled.buffer(<span class="number">8</span>)</span><br><span class="line">            .writeByte(<span class="number">0</span>)</span><br><span class="line">            .writeInt(<span class="number">16</span>)</span><br><span class="line">            .writeBytes(<span class="keyword">new</span> <span class="title class_">byte</span>[]&#123;<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>&#125;);</span><br><span class="line"></span><br><span class="line">    StepVerifier.create(</span><br><span class="line">                    GrpcFrameCodec.decode(</span><br><span class="line">                            Flux.just(partial).concatWith(Flux.never())),</span><br><span class="line">                    <span class="number">0</span>)</span><br><span class="line">            .thenRequest(<span class="number">1</span>)</span><br><span class="line">            .thenAwait(java.time.Duration.ofMillis(<span class="number">10</span>))</span><br><span class="line">            .thenCancel()</span><br><span class="line">            .verify();</span><br><span class="line"></span><br><span class="line">    assertEquals(<span class="number">0</span>, partial.refCnt());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>The full executable case is <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/test/java/io/github/qianwj/grpc/reactor/protocol/GrpcFrameCodecTest.java"><code>releasesPartialFrameInputWhenCancelled</code></a>. It covers the lifecycle edge that a normal decode-complete test cannot exercise.</p><p>Run only the frame codec suite from the repository root:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">./gradlew :grpc-reactor-protocol:<span class="built_in">test</span> \</span><br><span class="line">  --tests io.github.qianwj.grpc.reactor.protocol.GrpcFrameCodecTest \</span><br><span class="line">  --no-daemon</span><br></pre></td></tr></table></figure></div><p>On JDK 25, the Gradle build and generated JUnit report produced:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GrpcFrameCodecTest: 15 tests, 0 failures, 0 errors, 0 skipped</span><br><span class="line">BUILD SUCCESSFUL in 3s</span><br></pre></td></tr></table></figure></div><p>At the Stage 1 boundary, the frame decoder verifies message-level demand but does not yet implement the two-level flow-control problem of the streaming transport. Reactive Streams counts messages, while HTTP&#x2F;2 flow control counts bytes. They cannot be treated as the same quantity. Stage 3 later adds bounded inbound buffering and demand-aware delivery, and Stage 4 extends those rules to bidirectional streaming. Those transport and stress tests are covered in later posts.</p><h2 id="Metadata-Ordering-Duplicates-and-Binary-Values"><a href="#Metadata-Ordering-Duplicates-and-Binary-Values" class="headerlink" title="Metadata: Ordering, Duplicates, and Binary Values"></a>Metadata: Ordering, Duplicates, and Binary Values</h2><p>gRPC metadata is not a simple <code>Map&lt;String, String&gt;</code>. It must satisfy all of these rules:</p><ul><li>The same key may occur more than once, and insertion order matters.</li><li>Keys may contain only lowercase letters, digits, <code>_</code>, <code>.</code>, and <code>-</code>, validated by <code>[0-9a-z_.-]+</code>.</li><li>Keys ending in <code>-bin</code> carry binary values and use unpadded Base64 on the wire.</li><li>Applications cannot set reserved fields such as <code>content-type</code>, <code>te</code>, <code>grpc-status</code>, or <code>grpc-timeout</code>.</li></ul><p><code>GrpcMetadata</code> stores an immutable entry list so it can be safely shared across asynchronous boundaries:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">GrpcMetadata</span> <span class="variable">metadata</span> <span class="operator">=</span> GrpcMetadata.builder()</span><br><span class="line">        .addAscii(<span class="string">&quot;trace-id&quot;</span>, <span class="string">&quot;abc123&quot;</span>)</span><br><span class="line">        .addAscii(<span class="string">&quot;trace-id&quot;</span>, <span class="string">&quot;def456&quot;</span>) <span class="comment">// duplicates are allowed</span></span><br><span class="line">        .addBinary(<span class="string">&quot;auth-token-bin&quot;</span>, tokenBytes)</span><br><span class="line">        .build();</span><br><span class="line"></span><br><span class="line"><span class="comment">// Order is preserved when reading.</span></span><br><span class="line">List&lt;GrpcMetadata.Entry&gt; all = metadata.getAll(<span class="string">&quot;trace-id&quot;</span>); <span class="comment">// [abc123, def456]</span></span><br></pre></td></tr></table></figure></div><p>When parsing HTTP&#x2F;2 headers, a binary value may be comma-joined by header handling. The implementation splits it on commas and decodes each Base64 segment independently. The total encoded size is bounded at 8 KiB by default, preventing a peer from exhausting memory with oversized headers.</p><h2 id="Status-17-Codes-and-Percent-Encoding"><a href="#Status-17-Codes-and-Percent-Encoding" class="headerlink" title="Status: 17 Codes and Percent-Encoding"></a>Status: 17 Codes and Percent-Encoding</h2><p>gRPC defines <a class="link"   href="https://github.com/grpc/grpc/blob/HEAD/doc/statuscodes.md" >17 standard status codes<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a>, each with a specific meaning for client error handling and future retry policies. <code>GrpcStatus</code> is a record containing a code and a human-readable message:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">record</span> <span class="title class_">GrpcStatus</span><span class="params">(Code code, String message)</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">enum</span> <span class="title class_">Code</span> &#123;</span><br><span class="line">        OK(<span class="number">0</span>), CANCELLED(<span class="number">1</span>), UNKNOWN(<span class="number">2</span>), INVALID_ARGUMENT(<span class="number">3</span>),</span><br><span class="line">        DEADLINE_EXCEEDED(<span class="number">4</span>), NOT_FOUND(<span class="number">5</span>), ALREADY_EXISTS(<span class="number">6</span>),</span><br><span class="line">        PERMISSION_DENIED(<span class="number">7</span>), RESOURCE_EXHAUSTED(<span class="number">8</span>),</span><br><span class="line">        FAILED_PRECONDITION(<span class="number">9</span>), ABORTED(<span class="number">10</span>), OUT_OF_RANGE(<span class="number">11</span>),</span><br><span class="line">        UNIMPLEMENTED(<span class="number">12</span>), INTERNAL(<span class="number">13</span>), UNAVAILABLE(<span class="number">14</span>),</span><br><span class="line">        DATA_LOSS(<span class="number">15</span>), UNAUTHENTICATED(<span class="number">16</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>Several details matter in practice:</p><ul><li><code>DEADLINE_EXCEEDED</code> may be returned even after the operation completed successfully. If the successful response crosses the deadline in transit, the client can still observe a timeout.</li><li><code>UNAVAILABLE</code> indicates a transient failure for which a client may later retry safely; <code>INTERNAL</code> generally describes a server-side bug and should not be blindly retried.</li><li><code>UNIMPLEMENTED</code> carries the semantic meaning of an unsupported method, commonly surfaced through an HTTP 404 response at the protocol boundary.</li></ul><p>Text in the <code>grpc-message</code> trailer uses percent-encoding: printable ASCII characters other than <code>%</code> can pass through, while other bytes become <code>%HH</code>. This allows UTF-8 error descriptions to travel through ASCII HTTP&#x2F;2 headers safely.</p><p>Unknown numeric status codes are mapped to <code>UNKNOWN</code> instead of causing a parse failure. That preserves forward compatibility when a peer adopts a newer gRPC specification.</p><h2 id="Timeout-Eight-Digits-and-a-Unit"><a href="#Timeout-Eight-Digits-and-a-Unit" class="headerlink" title="Timeout: Eight Digits and a Unit"></a>Timeout: Eight Digits and a Unit</h2><p>The gRPC <code>grpc-timeout</code> header carries a relative duration, not an absolute timestamp. By the time the server receives a request, part of the caller’s original time budget has already been consumed by transport latency.</p><p>The wire format is compact: at most eight decimal digits followed by a unit suffix:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">100m       -&gt; 100 milliseconds</span><br><span class="line">2S         -&gt; 2 seconds</span><br><span class="line">99999999H  -&gt; roughly 11,415 years (the maximum value)</span><br></pre></td></tr></table></figure></div><p>The six units are <code>H</code> (hours), <code>M</code> (minutes), <code>S</code> (seconds), <code>m</code> (milliseconds), <code>u</code> (microseconds), and <code>n</code> (nanoseconds).</p><p>When formatting a <code>Duration</code>, the implementation rounds upward using ceiling division. The encoded deadline must never be shorter than the caller’s requested duration:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">BigInteger</span> <span class="variable">amount</span> <span class="operator">=</span> nanos.add(unitNanos.subtract(BigInteger.ONE))</span><br><span class="line">        .divide(unitNanos); <span class="comment">// ceiling division</span></span><br></pre></td></tr></table></figure></div><p><code>BigInteger</code> avoids overflow during nanosecond arithmetic. The formatter scans from nanoseconds upward and selects the first unit whose value fits within 99,999,999.</p><h2 id="Compression-Identity-by-Default-Gzip-Built-In"><a href="#Compression-Identity-by-Default-Gzip-Built-In" class="headerlink" title="Compression: Identity by Default, Gzip Built In"></a>Compression: Identity by Default, Gzip Built In</h2><p><code>GrpcCompression</code> manages codec registration and negotiation:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">GrpcCompression.<span class="type">Registry</span> <span class="variable">registry</span> <span class="operator">=</span> GrpcCompression.Registry.builder()</span><br><span class="line">        .add(GrpcCompression.GZIP)</span><br><span class="line">        .build();</span><br><span class="line"></span><br><span class="line"><span class="comment">// Produces grpc-accept-encoding: gzip</span></span><br><span class="line"><span class="type">String</span> <span class="variable">advertised</span> <span class="operator">=</span> registry.advertisedEncodings();</span><br></pre></td></tr></table></figure></div><p><code>identity</code> is always implicit and appears first in the registry. The codec interface has only three operations: a name, byte-array compression, and bounded byte-array decompression.</p><p>Gzip decompression reads in 8 KiB chunks and uses <code>Math.addExact()</code> while accumulating the output size. It can stop immediately after exceeding <code>maxDecompressedSize</code>, and arithmetic overflow cannot silently wrap the counter. A 100-byte gzip payload can expand to gigabytes, so decompression-bomb protection is part of the protocol contract rather than an optional optimization.</p><h2 id="GrpcMethod-One-Description-for-Four-Cardinalities"><a href="#GrpcMethod-One-Description-for-Four-Cardinalities" class="headerlink" title="GrpcMethod: One Description for Four Cardinalities"></a>GrpcMethod: One Description for Four Cardinalities</h2><p>Each RPC is described by one <code>GrpcMethod</code> record:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">method</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">GrpcMethod</span>&lt;&gt;(</span><br><span class="line">        <span class="string">&quot;testing.InteropTestService&quot;</span>, <span class="comment">// full service name</span></span><br><span class="line">        <span class="string">&quot;Unary&quot;</span>,                      <span class="comment">// method name</span></span><br><span class="line">        GrpcMethod.Cardinality.UNARY,</span><br><span class="line">        <span class="keyword">new</span> <span class="title class_">ProtobufMarshaller</span>&lt;&gt;(TestRequest.parser()),</span><br><span class="line">        <span class="keyword">new</span> <span class="title class_">ProtobufMarshaller</span>&lt;&gt;(TestResponse.parser()));</span><br><span class="line"></span><br><span class="line">method.path();           <span class="comment">// /testing.InteropTestService/Unary</span></span><br><span class="line">method.fullMethodName(); <span class="comment">// testing.InteropTestService/Unary</span></span><br></pre></td></tr></table></figure></div><p>The <code>Cardinality</code> enum exposes <code>singleRequest()</code> and <code>singleResponse()</code>. The transport uses those flags to insert <code>single()</code> at the API boundary, turning cardinality violations into explicit errors instead of silently dropping values.</p><h2 id="ProtobufMarshaller-Serialization-Does-Not-Own-the-Input"><a href="#ProtobufMarshaller-Serialization-Does-Not-Own-the-Input" class="headerlink" title="ProtobufMarshaller: Serialization Does Not Own the Input"></a>ProtobufMarshaller: Serialization Does Not Own the Input</h2><p><code>ProtobufMarshaller</code> wraps a protobuf <code>Parser&lt;T&gt;</code>:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> ByteBuf <span class="title function_">serialize</span><span class="params">(ByteBufAllocator allocator, T value)</span> &#123;</span><br><span class="line">    <span class="type">var</span> <span class="variable">result</span> <span class="operator">=</span> allocator.buffer(value.getSerializedSize());</span><br><span class="line">    <span class="keyword">return</span> result.writeBytes(value.toByteArray());</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> T <span class="title function_">deserialize</span><span class="params">(ByteBuf message)</span> &#123;</span><br><span class="line">    <span class="type">ByteBuffer</span> <span class="variable">bytes</span> <span class="operator">=</span> message.nioBuffer(message.readerIndex(), message.readableBytes());</span><br><span class="line">    <span class="keyword">return</span> parser.parseFrom(bytes);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>The important contract is that <code>deserialize</code> reads through a NIO <code>ByteBuffer</code> view. It does not move the input reader index and does not release the input. Ownership remains with the caller, allowing the frame decoder’s <code>doFinally(release)</code> to manage the buffer lifecycle uniformly whether parsing succeeds or fails.</p><h2 id="Stage-1-Exit-Criteria"><a href="#Stage-1-Exit-Criteria" class="headerlink" title="Stage 1 Exit Criteria"></a>Stage 1 Exit Criteria</h2><p>The protocol module does not need Reactor Netty on its classpath. That dependency boundary is itself part of the verification. The Stage 1 exit criteria are:</p><ul><li>every header and payload split point decodes correctly;</li><li>cancellation leaves no unreleased <code>ByteBuf</code> (<code>refCnt</code> assertions);</li><li>metadata preserves order, supports binary values, and enforces its size limit;</li><li>unknown status codes do not throw;</li><li>all timeout units parse correctly, formatting rounds upward, and arithmetic is overflow-safe;</li><li>gzip decompression limits reject bombs;</li><li>the marshaller does not change the input buffer state.</li></ul><p>These tests use JUnit 5 <code>@TestFactory</code> and <code>DynamicTest</code> for parameterization, together with Reactor Test’s <code>StepVerifier</code> for asynchronous behavior. The protocol layer is the reason the later transport stages can focus on HTTP&#x2F;2 lifecycle instead of rediscovering framing and ownership rules.</p><p>The complete implementation is in <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/main/java/io/github/qianwj/grpc/reactor/protocol/GrpcFrameCodec.java"><code>GrpcFrameCodec.java</code></a>. The related protocol primitives are <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/main/java/io/github/qianwj/grpc/reactor/protocol/GrpcMetadata.java"><code>GrpcMetadata</code></a>, <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/main/java/io/github/qianwj/grpc/reactor/protocol/GrpcStatus.java"><code>GrpcStatus</code></a>, <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/main/java/io/github/qianwj/grpc/reactor/protocol/GrpcTimeout.java"><code>GrpcTimeout</code></a>, <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/main/java/io/github/qianwj/grpc/reactor/protocol/GrpcCompression.java"><code>GrpcCompression</code></a>, and <a href="https://github.com/qianwj/grpc-reactor/blob/main/grpc-reactor-protocol/src/main/java/io/github/qianwj/grpc/reactor/protocol/ProtobufMarshaller.java"><code>ProtobufMarshaller</code></a>.</p>]]>
    </content>
    <id>https://qianwj.github.io/en/2026/06/02/grpc-reactor-series-2/</id>
    <link href="https://qianwj.github.io/en/2026/06/02/grpc-reactor-series-2/"/>
    <published>2026-06-02T07:40:00.000Z</published>
    <summary>How a gRPC decoder handles fragmented HTTP/2 data, bounded allocation, gzip, cancellation, and reference-counted ByteBuf cleanup.</summary>
    <title>Building a Leak-Safe gRPC Frame Decoder on Reactor Netty</title>
    <updated>2026-08-08T06:20:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="开源项目" scheme="https://qianwj.github.io/categories/%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE/"/>
    <category term="gRPC" scheme="https://qianwj.github.io/tags/gRPC/"/>
    <category term="Netty" scheme="https://qianwj.github.io/tags/Netty/"/>
    <category term="ByteBuf" scheme="https://qianwj.github.io/tags/ByteBuf/"/>
    <category term="Reactive Streams" scheme="https://qianwj.github.io/tags/Reactive-Streams/"/>
    <content>
      <![CDATA[<p>gRPC 的 Protobuf 消息不会直接裸写进 HTTP&#x2F;2 DATA。每条消息前面都有五字节 envelope：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">byte 0      bit 0 表示是否压缩，bit 1-7 必须为 0</span><br><span class="line">byte 1-4    payload 长度，无符号大端序</span><br><span class="line">byte 5..n   Protobuf 或压缩后的消息体</span><br></pre></td></tr></table></figure></div><p>编码并不复杂，真正困难的是解码不能假设一次收到完整 frame。HTTP&#x2F;2、TCP 和 Reactor Netty 都不保证 buffer 边界与 gRPC 消息边界一致。</p><h2 id="编码必须定义所有权"><a href="#编码必须定义所有权" class="headerlink" title="编码必须定义所有权"></a>编码必须定义所有权</h2><p><code>GrpcFrameCodec.encode</code> 的契约是：返回 frame 与输入 message 各自拥有独立生命周期，编码过程不移动输入的 reader index，也不释放输入。</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">static</span> ByteBuf <span class="title function_">encode</span><span class="params">(</span></span><br><span class="line"><span class="params">    ByteBufAllocator allocator,</span></span><br><span class="line"><span class="params">    ByteBuf message,</span></span><br><span class="line"><span class="params">    GrpcCompression.Codec compression)</span> &#123;</span><br><span class="line">  <span class="type">byte</span>[] payload = <span class="keyword">new</span> <span class="title class_">byte</span>[message.readableBytes()];</span><br><span class="line">  message.getBytes(message.readerIndex(), payload);</span><br><span class="line">  <span class="keyword">if</span> (!compression.name().equals(<span class="string">&quot;identity&quot;</span>)) &#123;</span><br><span class="line">    payload = compression.compress(payload);</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> allocator.buffer(<span class="number">5</span> + payload.length)</span><br><span class="line">      .writeByte(compressed ? <span class="number">1</span> : <span class="number">0</span>)</span><br><span class="line">      .writeInt(payload.length)</span><br><span class="line">      .writeBytes(payload);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>当前实现为了先把 ownership 做清楚，会复制为 byte array。它不是零拷贝实现，也不应该被描述成性能最优；后续若改用 slice 或 composite buffer，必须重新证明取消和异常路径没有泄漏。</p><h2 id="解码器是每次订阅独立的状态机"><a href="#解码器是每次订阅独立的状态机" class="headerlink" title="解码器是每次订阅独立的状态机"></a>解码器是每次订阅独立的状态机</h2><p><code>decode</code> 使用 <code>Flux.defer</code> 为每个订阅创建 Decoder：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">return</span> Flux.defer(() -&gt; &#123;</span><br><span class="line">  <span class="type">var</span> <span class="variable">decoder</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">Decoder</span>(</span><br><span class="line">      maxWireMessageSize,</span><br><span class="line">      maxDecompressedMessageSize,</span><br><span class="line">      compression);</span><br><span class="line">  <span class="keyword">return</span> input.concatMap(decoder::accept, <span class="number">1</span>)</span><br><span class="line">      .concatWith(Flux.defer(decoder::finish));</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure></div><p>状态只有两部分：尚未收满的五字节 header，以及已知长度但尚未收满的 payload。一个输入 ByteBuf 可能只包含 header 的一个字节，也可能连续包含三条完整消息。</p><p><code>concatMap(..., 1)</code> 保持输入次序，同时让 decoder 按下游 demand 交付消息。每个 source buffer 最终通过 <code>doFinally</code> 释放：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> Flux&lt;ByteBuf&gt; <span class="title function_">accept</span><span class="params">(ByteBuf source)</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> Flux.&lt;ByteBuf&gt;generate(sink -&gt; &#123;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">      <span class="keyword">while</span> (source.isReadable()) &#123;</span><br><span class="line">        <span class="comment">// read header, allocate bounded payload, emit complete message</span></span><br><span class="line">      &#125;</span><br><span class="line">      sink.complete();</span><br><span class="line">    &#125; <span class="keyword">catch</span> (Throwable error) &#123;</span><br><span class="line">      sink.error(error);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;).doFinally(ignored -&gt; source.release());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><h2 id="在分配前验证-peer-输入"><a href="#在分配前验证-peer-输入" class="headerlink" title="在分配前验证 peer 输入"></a>在分配前验证 peer 输入</h2><p>长度字段来自远端，不能读取后立即分配。实现先用 <code>long</code> 组合无符号大端整数，再检查 wire size：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">long</span> <span class="variable">length</span> <span class="operator">=</span> ((<span class="type">long</span>) (header[<span class="number">1</span>] &amp; <span class="number">0xff</span>) &lt;&lt; <span class="number">24</span>)</span><br><span class="line">    | ((<span class="type">long</span>) (header[<span class="number">2</span>] &amp; <span class="number">0xff</span>) &lt;&lt; <span class="number">16</span>)</span><br><span class="line">    | ((<span class="type">long</span>) (header[<span class="number">3</span>] &amp; <span class="number">0xff</span>) &lt;&lt; <span class="number">8</span>)</span><br><span class="line">    | (header[<span class="number">4</span>] &amp; <span class="number">0xffL</span>);</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (length &gt; maxWireMessageSize) &#123;</span><br><span class="line">  <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">GrpcProtocolException</span>(<span class="string">&quot;wire message length exceeds limit&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>wire size 与 decompressed size 是两个不同限制。一个很小的 gzip payload 可能解压为巨大数据，因此 gzip 解压过程中还必须再次限制输出，防止 decompression bomb。</p><p>压缩标志只有最低位合法；保留位不为零直接视为协议错误。收到 compressed frame 但协商结果仍是 identity，同样不能猜测算法继续解码。</p><h2 id="测试所有-header-分割点"><a href="#测试所有-header-分割点" class="headerlink" title="测试所有 header 分割点"></a>测试所有 header 分割点</h2><p>固定测“拆成两个 buffer”还不够，五字节 header 有六个代表性分割位置。测试使用动态用例覆盖 0 到 5：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">IntStream.rangeClosed(<span class="number">0</span>, GrpcFrameCodec.HEADER_SIZE)</span><br><span class="line">    .mapToObj(split -&gt; DynamicTest.dynamicTest(</span><br><span class="line">        <span class="string">&quot;split after byte &quot;</span> + split,</span><br><span class="line">        () -&gt; &#123;</span><br><span class="line">          <span class="type">byte</span>[] wire = wireBytes(<span class="string">&quot;hello&quot;</span>);</span><br><span class="line">          Flux&lt;ByteBuf&gt; chunks = Flux.just(</span><br><span class="line">              wrapped(wire, <span class="number">0</span>, split),</span><br><span class="line">              wrapped(wire, split, wire.length - split));</span><br><span class="line">          StepVerifier.create(GrpcFrameCodec.decode(chunks))</span><br><span class="line">              .assertNext(message -&gt; assertMessage(message, <span class="string">&quot;hello&quot;</span>))</span><br><span class="line">              .verifyComplete();</span><br><span class="line">        &#125;));</span><br></pre></td></tr></table></figure></div><p>另外还覆盖：body 任意分片、多 frame 合并、空消息、gzip、保留位、截断 frame、wire&#x2F;decompressed limit，以及编码不消费输入 buffer。</p><h2 id="取消时也要释放未消费数据"><a href="#取消时也要释放未消费数据" class="headerlink" title="取消时也要释放未消费数据"></a>取消时也要释放未消费数据</h2><p>一个 source ByteBuf 中合并了 <code>one</code>、<code>two</code>、<code>three</code> 三条消息。下游只请求两条后取消，测试最后断言 source 的引用计数归零：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">StepVerifier.create(GrpcFrameCodec.decode(Flux.just(source)), <span class="number">0</span>)</span><br><span class="line">    .thenRequest(<span class="number">1</span>).assertNext(message -&gt; assertMessage(message, <span class="string">&quot;one&quot;</span>))</span><br><span class="line">    .thenRequest(<span class="number">1</span>).assertNext(message -&gt; assertMessage(message, <span class="string">&quot;two&quot;</span>))</span><br><span class="line">    .thenCancel()</span><br><span class="line">    .verify();</span><br><span class="line"></span><br><span class="line">assertEquals(<span class="number">0</span>, source.refCnt());</span><br></pre></td></tr></table></figure></div><p>这条断言比“结果内容正确”更重要。网络库中的 happy path 很容易正确，泄漏往往发生在取消、超限、截断或重复 terminal signal 中。</p><p>在本篇对应的 Stage 1 提交中，frame decoder 只覆盖了消息级 demand，尚未完成 streaming transport 的两级 flow control。Reactive Streams 计算消息数量，HTTP&#x2F;2 window 计算字节，两者不能混为一谈。这个边界后来由 Stage 3 的有界入站缓冲与按需交付补齐，并在 Stage 4 扩展到双向流；对应实现和压力测试会在系列第五、六篇展开。</p><h2 id="Metadata：保序、重复、二进制三合一"><a href="#Metadata：保序、重复、二进制三合一" class="headerlink" title="Metadata：保序、重复、二进制三合一"></a>Metadata：保序、重复、二进制三合一</h2><p>gRPC metadata 不是简单的 <code>Map&lt;String, String&gt;</code>。它必须满足：</p><ul><li>同一个 key 可以出现多次，顺序敏感；</li><li>key 只允许小写字母、数字、<code>_</code>、<code>.</code> 和 <code>-</code>，由正则 <code>[0-9a-z_.-]+</code> 校验；</li><li>以 <code>-bin</code> 结尾的 key 表示二进制值，wire 上使用无 padding Base64 编码；</li><li>用户不能设置保留字段（<code>content-type</code>、<code>te</code>、<code>grpc-status</code>、<code>grpc-timeout</code> 等）。</li></ul><p><code>GrpcMetadata</code> 内部是一个不可变的 entry list，保证跨异步边界安全共享：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">GrpcMetadata</span> <span class="variable">metadata</span> <span class="operator">=</span> GrpcMetadata.builder()</span><br><span class="line">    .addAscii(<span class="string">&quot;trace-id&quot;</span>, <span class="string">&quot;abc123&quot;</span>)</span><br><span class="line">    .addAscii(<span class="string">&quot;trace-id&quot;</span>, <span class="string">&quot;def456&quot;</span>)   <span class="comment">// 允许重复</span></span><br><span class="line">    .addBinary(<span class="string">&quot;auth-token-bin&quot;</span>, tokenBytes)</span><br><span class="line">    .build();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 读取时保留顺序</span></span><br><span class="line">List&lt;GrpcMetadata.Entry&gt; all = metadata.getAll(<span class="string">&quot;trace-id&quot;</span>);  <span class="comment">// [abc123, def456]</span></span><br></pre></td></tr></table></figure></div><p>从 HTTP&#x2F;2 headers 解析时，二进制值可能被逗号拼接（HTTP&#x2F;2 合并同名 header 的行为），实现会按逗号拆分后逐段 Base64 解码。编码后总大小有上限（默认 8KB），防止 peer 用超大 headers 耗尽内存。</p><h2 id="Status：17-种状态码与-percent-encoding"><a href="#Status：17-种状态码与-percent-encoding" class="headerlink" title="Status：17 种状态码与 percent-encoding"></a>Status：17 种状态码与 percent-encoding</h2><p>gRPC 定义了 <a class="link"   href="https://github.com/grpc/grpc/blob/HEAD/doc/statuscodes.md" >17 种标准状态码<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a>，每种对应一个明确的语义，用于指导客户端的重试和错误处理策略。<code>GrpcStatus</code> 是一个 record，包含 status code 和人类可读 message：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">record</span> <span class="title class_">GrpcStatus</span><span class="params">(Code code, String message)</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">enum</span> <span class="title class_">Code</span> &#123;</span><br><span class="line">        OK(<span class="number">0</span>), CANCELLED(<span class="number">1</span>), UNKNOWN(<span class="number">2</span>), INVALID_ARGUMENT(<span class="number">3</span>),</span><br><span class="line">        DEADLINE_EXCEEDED(<span class="number">4</span>), NOT_FOUND(<span class="number">5</span>), ALREADY_EXISTS(<span class="number">6</span>),</span><br><span class="line">        PERMISSION_DENIED(<span class="number">7</span>), RESOURCE_EXHAUSTED(<span class="number">8</span>),</span><br><span class="line">        FAILED_PRECONDITION(<span class="number">9</span>), ABORTED(<span class="number">10</span>), OUT_OF_RANGE(<span class="number">11</span>),</span><br><span class="line">        UNIMPLEMENTED(<span class="number">12</span>), INTERNAL(<span class="number">13</span>), UNAVAILABLE(<span class="number">14</span>),</span><br><span class="line">        DATA_LOSS(<span class="number">15</span>), UNAUTHENTICATED(<span class="number">16</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>几个值得注意的设计细节：</p><ul><li><code>DEADLINE_EXCEEDED</code> 即使操作已成功完成也可能返回——当 server 的成功响应在网络传输中超过了 deadline，client 仍然看到超时；</li><li><code>UNAVAILABLE</code> 表示暂时性故障，client 可以安全重试；而 <code>INTERNAL</code> 表示 server 内部错误，盲目重试通常无意义；</li><li><code>UNIMPLEMENTED</code> 对应 HTTP 404 的语义——路径存在但方法不被支持。</li></ul><p><code>grpc-message</code> trailer 中的文本需要 percent-encoding：可打印 ASCII 中除 <code>%</code> 外直接传输，其余字符转为 <code>%HH</code>。这使得 UTF-8 错误描述能安全地通过纯 ASCII 的 HTTP&#x2F;2 header 传输。</p><p>未知的 status code 数值不会抛异常，而是映射到 <code>UNKNOWN</code>——这保证了向前兼容，当 peer 使用了更新的 gRPC spec 定义的新状态码时不会导致解析失败。</p><h2 id="Timeout：八位数字-单位，向上取整"><a href="#Timeout：八位数字-单位，向上取整" class="headerlink" title="Timeout：八位数字 + 单位，向上取整"></a>Timeout：八位数字 + 单位，向上取整</h2><p>gRPC 协议规定 <code>grpc-timeout</code> 传输的是<strong>相对超时</strong>而非绝对时间戳。Server 收到请求时，deadline 已经比 client 设定的少了一次网络传输延迟，必须用剩余时间窗口判断是否应该继续处理。</p><p>wire 格式非常紧凑：最多八位十进制数字加一个单位后缀：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">100m   → 100 毫秒</span><br><span class="line">2S     → 2 秒</span><br><span class="line">99999999H → 约 11415 年（上限值）</span><br></pre></td></tr></table></figure></div><p>支持六种单位：<code>H</code>（小时）、<code>M</code>（分钟）、<code>S</code>（秒）、<code>m</code>（毫秒）、<code>u</code>（微秒）、<code>n</code>（纳秒）。</p><p>格式化 <code>Duration</code> 为 wire value 时，必须<strong>向上取整</strong>（ceiling division），确保 wire deadline 不会比调用者设定的更短：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">BigInteger</span> <span class="variable">amount</span> <span class="operator">=</span> nanos.add(unitNanos.subtract(BigInteger.ONE))</span><br><span class="line">    .divide(unitNanos);  <span class="comment">// ceiling division</span></span><br></pre></td></tr></table></figure></div><p>使用 <code>BigInteger</code> 而非 <code>long</code> 避免了 nanosecond 乘法溢出。选择单位的策略是从纳秒向上扫描，取第一个使数值不超过 99,999,999 的单位。</p><h2 id="Compression：identity-是默认，gzip-是唯一内置"><a href="#Compression：identity-是默认，gzip-是唯一内置" class="headerlink" title="Compression：identity 是默认，gzip 是唯一内置"></a>Compression：identity 是默认，gzip 是唯一内置</h2><p><code>GrpcCompression</code> 管理 codec 注册和协商：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">GrpcCompression.<span class="type">Registry</span> <span class="variable">registry</span> <span class="operator">=</span> GrpcCompression.Registry.builder()</span><br><span class="line">    .add(GrpcCompression.GZIP)</span><br><span class="line">    .build();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 产生 grpc-accept-encoding: gzip</span></span><br><span class="line"><span class="type">String</span> <span class="variable">advertised</span> <span class="operator">=</span> registry.advertisedEncodings();</span><br></pre></td></tr></table></figure></div><p><code>identity</code> 始终隐含存在且排在第一位。Codec 接口只有两个方法：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">interface</span> <span class="title class_">Codec</span> &#123;</span><br><span class="line">    String <span class="title function_">name</span><span class="params">()</span>;</span><br><span class="line">    <span class="type">byte</span>[] compress(<span class="type">byte</span>[] input);</span><br><span class="line">    <span class="type">byte</span>[] decompress(<span class="type">byte</span>[] input, <span class="type">int</span> maxSize);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>Gzip 解压使用 8KB chunk 流式读取，每个 chunk 后用 <code>Math.addExact()</code> 累加已解压字节数——这既能在超出 <code>maxSize</code> 时立即中断，也能通过算术溢出检查防止 int wrap-around。一个 100 字节的 gzip payload 可能解压出数 GB 数据，所以 decompression bomb 防护不是可选项。</p><h2 id="GrpcMethod：四种-cardinality-的统一描述"><a href="#GrpcMethod：四种-cardinality-的统一描述" class="headerlink" title="GrpcMethod：四种 cardinality 的统一描述"></a>GrpcMethod：四种 cardinality 的统一描述</h2><p>每个 RPC 方法用一个 <code>GrpcMethod</code> record 完整描述：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">method</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">GrpcMethod</span>&lt;&gt;(</span><br><span class="line">    <span class="string">&quot;testing.InteropTestService&quot;</span>,    <span class="comment">// fullServiceName</span></span><br><span class="line">    <span class="string">&quot;Unary&quot;</span>,                         <span class="comment">// methodName</span></span><br><span class="line">    GrpcMethod.Cardinality.UNARY,    <span class="comment">// cardinality</span></span><br><span class="line">    <span class="keyword">new</span> <span class="title class_">ProtobufMarshaller</span>&lt;&gt;(TestRequest.parser()),</span><br><span class="line">    <span class="keyword">new</span> <span class="title class_">ProtobufMarshaller</span>&lt;&gt;(TestResponse.parser()));</span><br><span class="line"></span><br><span class="line">method.path();           <span class="comment">// &quot;/testing.InteropTestService/Unary&quot;</span></span><br><span class="line">method.fullMethodName(); <span class="comment">// &quot;testing.InteropTestService/Unary&quot;</span></span><br></pre></td></tr></table></figure></div><p><code>Cardinality</code> 枚举提供 <code>singleRequest()</code> 和 <code>singleResponse()</code> 查询，transport 层用它在边界插入 <code>single()</code> 操作符，把多值违规转为明确异常而非默默丢弃。</p><h2 id="ProtobufMarshaller：序列化不拥有输入"><a href="#ProtobufMarshaller：序列化不拥有输入" class="headerlink" title="ProtobufMarshaller：序列化不拥有输入"></a>ProtobufMarshaller：序列化不拥有输入</h2><p><code>ProtobufMarshaller</code> 包装 Protobuf 的 <code>Parser&lt;T&gt;</code>：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> ByteBuf <span class="title function_">serialize</span><span class="params">(ByteBufAllocator allocator, T value)</span> &#123;</span><br><span class="line">    <span class="type">byte</span>[] bytes = value.toByteArray();</span><br><span class="line">    <span class="keyword">return</span> allocator.buffer(bytes.length).writeBytes(bytes);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> T <span class="title function_">deserialize</span><span class="params">(ByteBuf message)</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> parser.parseFrom(message.nioBuffer());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>关键契约：<code>deserialize</code> 使用 NIO ByteBuffer 视图读取，<strong>不移动</strong> ByteBuf 的 reader index，也<strong>不释放</strong>输入。所有权始终留给调用者。这个设计让 frame decoder 的 <code>doFinally(release)</code> 可以统一管理所有 buffer 生命周期，不论反序列化成功还是失败。</p><h2 id="Stage-1-退出条件"><a href="#Stage-1-退出条件" class="headerlink" title="Stage 1 退出条件"></a>Stage 1 退出条件</h2><p>协议模块的测试不需要 Reactor Netty 在 classpath 上——这验证了模块边界的正确性。退出条件：</p><ul><li>所有 header 分割点 + payload 分割的 frame 解码通过</li><li>取消路径无 ByteBuf 泄漏（refCnt 断言）</li><li>Metadata 保序、二进制编解码、大小限制生效</li><li>Status 未知 code 不抛异常</li><li>Timeout 所有单位解析、格式化向上取整、溢出安全</li><li>Gzip 解压限制生效（decompression bomb 被拒绝）</li><li>Marshaller 不改变输入 buffer 状态</li></ul><p>这些测试使用 JUnit 5 的 <code>@TestFactory</code> + <code>DynamicTest</code> 参数化，配合 Reactor Test 的 <code>StepVerifier</code> 验证异步流行为。协议层完成后，下一篇进入 Stage 2：在 Reactor Netty HTTP&#x2F;2 上跑通第一条 unary 调用。</p>]]>
    </content>
    <id>https://qianwj.github.io/2026/06/02/grpc-reactor-series-2/</id>
    <link href="https://qianwj.github.io/2026/06/02/grpc-reactor-series-2/"/>
    <published>2026-06-02T07:40:00.000Z</published>
    <summary>从 gRPC 五字节 message envelope 出发，实现可处理任意 DATA 分片、大小限制、gzip 和取消释放的增量解码器，并完成 metadata、status、timeout、compression 全套协议原语。</summary>
    <title>grpc-reactor 实战（二）：五字节消息帧、分片解码与协议原语</title>
    <updated>2026-06-03T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="Open Source" scheme="https://qianwj.github.io/categories/Open-Source/"/>
    <category term="gRPC" scheme="https://qianwj.github.io/tags/gRPC/"/>
    <category term="Reactor Netty" scheme="https://qianwj.github.io/tags/Reactor-Netty/"/>
    <category term="HTTP/2" scheme="https://qianwj.github.io/tags/HTTP-2/"/>
    <category term="Java" scheme="https://qianwj.github.io/tags/Java/"/>
    <content>
      <![CDATA[<p>I recently started building <a class="link"   href="https://github.com/qianwj/grpc-reactor" >grpc-reactor<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a>: an experimental gRPC implementation built directly on Reactor Netty HTTP&#x2F;2. The project uses Protobuf but has zero runtime dependency on grpc-java transport, <code>ClientCall</code>, <code>ServerCall</code>, or <code>StreamObserver</code>.</p><p>This is not about proving grpc-java is bad. grpc-java is mature, stable, and covers load balancing, NameResolver, retries, and rich observability. This project answers a different question: if your programming model is already Reactor, can <code>Mono</code> and <code>Flux</code> flow all the way from the generated API down to the HTTP&#x2F;2 stream, without adapting between two async abstractions?</p><p>This post is based on JDK 25, Gradle 9.2.1, Reactor 3.8.6, Reactor Netty 1.3.6, and Netty 4.2.15.Final. The <code>main</code> branch has completed Stage 0 through Stage 10: beyond protocol, four RPC cardinalities, production transport, DNS, codegen, standard services, and Stage 9 hardening, Stage 10 adds an optional load-balancing extensions artifact for grpclb, RLS, load reporting, and ORCA.</p><h2 id="The-API-Goal-Is-Not-Wrapping-StreamObserver"><a href="#The-API-Goal-Is-Not-Wrapping-StreamObserver" class="headerlink" title="The API Goal Is Not Wrapping StreamObserver"></a>The API Goal Is Not Wrapping StreamObserver</h2><p>Protobuf methods have four cardinalities. The target API maps them directly:</p><table><thead><tr><th>Protobuf Method</th><th>Reactor Signature</th></tr></thead><tbody><tr><td>unary</td><td><code>Mono&lt;Resp&gt; method(Mono&lt;Req&gt;)</code></td></tr><tr><td>server streaming</td><td><code>Flux&lt;Resp&gt; method(Mono&lt;Req&gt;)</code></td></tr><tr><td>client streaming</td><td><code>Mono&lt;Resp&gt; method(Flux&lt;Req&gt;)</code></td></tr><tr><td>bidirectional streaming</td><td><code>Flux&lt;Resp&gt; method(Flux&lt;Req&gt;)</code></td></tr></tbody></table><p>Why does gRPC define four instead of just unary? It’s essentially a 2x2 combination where request and response each independently choose “single value or stream”:</p><table><thead><tr><th></th><th>Single Response</th><th>Streaming Response</th></tr></thead><tbody><tr><td><strong>Single Request</strong></td><td>unary</td><td>server streaming</td></tr><tr><td><strong>Streaming Request</strong></td><td>client streaming</td><td>bidirectional</td></tr></tbody></table><p>They solve different scenarios: unary covers classic request-response; server streaming handles server push (event subscriptions, large paginated pulls); client streaming handles bulk uploads (file chunks, batch writes); bidirectional streaming handles real-time two-way communication (chat, collaborative editing). These aren’t invented patterns — HTTP&#x2F;2 streams are inherently full-duplex. A single connection can multiplex hundreds of concurrent streams, with both request and response sending multiple data frames independently. gRPC elevates this transport capability to first-class API semantics: not a variant of chunked transfer encoding, but compiler-level type checking that callers and implementers match.</p><p>The low-level transport retains a single unified model:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Flux&lt;Req&gt; -&gt; HTTP/2 stream -&gt; Flux&lt;Resp&gt;</span><br></pre></td></tr></table></figure></div><p>Generated code applies cardinality checks like <code>single()</code> at the boundary. This avoids implementing four separate network logic paths for four RPC types, while explicitly rejecting empty requests, duplicate requests, or duplicate responses in unary calls.</p><h2 id="Compatibility-Targets-the-Wire-Protocol"><a href="#Compatibility-Targets-the-Wire-Protocol" class="headerlink" title="Compatibility Targets the Wire Protocol"></a>Compatibility Targets the Wire Protocol</h2><p>The project’s compatibility target is the public <a class="link"   href="https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md" >gRPC over HTTP&#x2F;2 protocol spec<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a>, not grpc-java internal APIs:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">generated Reactor API</span><br><span class="line">        |</span><br><span class="line">call dispatcher / service registry</span><br><span class="line">        |</span><br><span class="line">marshaller + message framer/deframer</span><br><span class="line">        |</span><br><span class="line">metadata + status + deadline</span><br><span class="line">        |</span><br><span class="line">Reactor Netty HTTP/2 stream</span><br></pre></td></tr></table></figure></div><p>Each RPC corresponds to one HTTP&#x2F;2 stream. Requests start with a HEADERS frame containing at minimum:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">:method      POST</span><br><span class="line">:path        /&lt;package.Service&gt;/&lt;Method&gt;</span><br><span class="line">content-type application/grpc+proto</span><br><span class="line">te           trailers</span><br><span class="line">grpc-timeout &lt;relative timeout, e.g. 100m for 100ms&gt;</span><br></pre></td></tr></table></figure></div><p>Message bodies use Length-Prefixed-Message format encapsulated in DATA frames — each Protobuf message is preceded by a five-byte envelope (1 byte compression flag + 4 bytes big-endian length). A single DATA frame may contain multiple gRPC messages, and a large message may span multiple DATA frames.</p><p><strong>Why does gRPC require HTTP trailers?</strong> This is one of the protocol’s most counterintuitive designs. HTTP status codes are nearly useless for gRPC — the protocol requires the HTTP layer to always return 200, with the actual call result (<code>grpc-status</code> and <code>grpc-message</code>) placed in trailers. The reason: in streaming scenarios, the server may have already sent thousands of messages, and whether it ultimately succeeded or failed can only be determined after processing the last piece of data. HTTP headers are sent before the body and cannot carry this posterior result. Trailers are the only mechanism in HTTP&#x2F;2 that can append metadata after the body.</p><p>The protocol also defines <strong>trailers-only</strong> mode: when the server can determine failure before reading the body (e.g., path not found, authentication failed), <code>grpc-status</code> is returned directly in response headers without sending a body, saving one round-trip. Clients must check both headers and trailers to correctly extract the final status.</p><h2 id="Modules-Split-Along-Protocol-Boundaries"><a href="#Modules-Split-Along-Protocol-Boundaries" class="headerlink" title="Modules Split Along Protocol Boundaries"></a>Modules Split Along Protocol Boundaries</h2><p>The project currently has nine modules:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">grpc-reactor-protocol      → Transport-independent protocol primitives</span><br><span class="line">grpc-reactor-transport     → Reactor Netty HTTP/2 mapping</span><br><span class="line">grpc-reactor-codegen       → Protoc plugin, generates Reactor stubs</span><br><span class="line">grpc-reactor-gradle-plugin → Gradle integration</span><br><span class="line">grpc-reactor-maven-plugin  → Maven generate-sources integration</span><br><span class="line">grpc-reactor-services      → Optional Health, Reflection &amp; Channelz standard services</span><br><span class="line">grpc-reactor-binlog        → Optional, bounded canonical binary logging</span><br><span class="line">grpc-reactor-lb-extensions → Optional grpclb, RLS, load reporting &amp; ORCA</span><br><span class="line">grpc-reactor-interop-test  → grpc-java compatibility tests</span><br></pre></td></tr></table></figure></div><p><code>protocol</code> handles only transport-independent values and codecs: message framing, metadata, status, timeout, compression, and protobuf marshallers. It depends on Reactor Core and Netty Buffer but not Reactor Netty — meaning the protocol layer can be tested independently without real HTTP&#x2F;2 connections.</p><p><code>transport</code> maps the protocol onto Reactor Netty HTTP&#x2F;2, handling client, server, service registry, and per-call context.</p><p><code>codegen</code> consumes Protobuf <code>CodeGeneratorRequest</code> and generates type-safe Reactor client and service binders.</p><p><code>services</code> uses the same codegen to generate canonical gRPC service bindings, implementing Health v1, Reflection v1, and Channelz v1 on top of transport’s immutable descriptor&#x2F;diagnostics snapshots. This module requires explicit registration — adding the dependency alone won’t expose management endpoints.</p><p><code>binlog</code> is also an explicitly-enabled standalone module. It captures canonical binary-log v1 events via a transport interceptor, controlling information exposure and memory limits through metadata&#x2F;message truncation, sensitive key redaction, and fixed-capacity sinks.</p><p><code>interop-test</code> introduces grpc-java in test scope. grpc-java serves as the compatibility oracle here, never entering the project runtime.</p><h2 id="Dependency-Selection-and-Version-Pinning"><a href="#Dependency-Selection-and-Version-Pinning" class="headerlink" title="Dependency Selection and Version Pinning"></a>Dependency Selection and Version Pinning</h2><p>The runtime dependency chain is intentionally kept short:</p><table><thead><tr><th>Dependency</th><th>Version</th><th>Purpose</th></tr></thead><tbody><tr><td>Reactor Core</td><td>3.8.6</td><td>Mono&#x2F;Flux programming model</td></tr><tr><td>Reactor Netty</td><td>1.3.6</td><td>HTTP&#x2F;2 client&#x2F;server</td></tr><tr><td>Netty</td><td>4.2.15</td><td>ByteBuf, HTTP&#x2F;2 codec</td></tr><tr><td>Protobuf-java</td><td>4.35.1</td><td>Message serialization</td></tr></tbody></table><p>The project does not introduce Spring, Micrometer, or any DI framework. Tests use JUnit 6.1.2 and Reactor Test’s <code>StepVerifier</code>. grpc-java 1.82.2 appears only in <code>interop-test</code>‘s test classpath with zero runtime intrusion.</p><p>Versions are centrally managed via <code>gradle/libs.versions.toml</code> with Gradle dependency locking generating lock files, ensuring fully reproducible builds across machines.</p><h2 id="Why-the-Protocol-Layer-Must-Come-First"><a href="#Why-the-Protocol-Layer-Must-Come-First" class="headerlink" title="Why the Protocol Layer Must Come First"></a>Why the Protocol Layer Must Come First</h2><p>It’s tempting to start with “spin up an HTTP&#x2F;2 Server” — you quickly get an echo demo, but it pushes the truly difficult problems to later:</p><ul><li>DATA may split in the middle of the five-byte gRPC header;</li><li>A single DATA buffer may contain multiple messages;</li><li>Metadata allows duplicate keys, and binary values require Base64;</li><li>Timeout wire values are at most eight digits with a unit suffix;</li><li>Final status comes from trailers;</li><li>ByteBuf must be released on success, failure, and cancellation paths;</li><li>Reactive Streams demand counts messages, HTTP&#x2F;2 flow control counts bytes.</li></ul><p>Therefore the project progresses by Stage: first lock down the build and interop fixtures, then complete the protocol layer, then implement unary transport, streaming, production features, and codegen. Each Stage has executable exit criteria — “classes have been created” is not a completion standard.</p><h2 id="Stage-0-Build-Baseline-and-Interop-Fixture"><a href="#Stage-0-Build-Baseline-and-Interop-Fixture" class="headerlink" title="Stage 0: Build Baseline and Interop Fixture"></a>Stage 0: Build Baseline and Interop Fixture</h2><p>Before writing any protocol code, Stage 0 solves “how to prove code is correct”:</p><p><strong>JDK 25 compilation</strong> — The project uses <code>-Xlint:all -parameters -encoding UTF-8</code> for strict compilation. All warnings are compilation errors; silent suppression is not allowed. JDK 25 was chosen to validate Netty and Protobuf compatibility on the latest JVM early.</p><p><strong>Spotless formatting</strong> — Unified Eclipse formatter config plus ktlint. <code>spotlessCheck</code> is the first gate in CI. This eliminates all code review discussions about formatting.</p><p><strong>interop.proto test fixture</strong> — Defines a test service covering all four RPC cardinalities:</p><div class="code-container" data-rel="Protobuf"><figure class="iseeu highlight protobuf"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">service </span><span class="title class_">InteropTestService</span> &#123;</span><br><span class="line">  <span class="function"><span class="keyword">rpc</span> Unary (TestRequest) <span class="keyword">returns</span> (TestResponse)</span>;</span><br><span class="line">  <span class="function"><span class="keyword">rpc</span> ServerStreaming (TestRequest) <span class="keyword">returns</span> (stream TestResponse)</span>;</span><br><span class="line">  <span class="function"><span class="keyword">rpc</span> ClientStreaming (stream TestRequest) <span class="keyword">returns</span> (TestResponse)</span>;</span><br><span class="line">  <span class="function"><span class="keyword">rpc</span> BidirectionalStreaming (stream TestRequest) <span class="keyword">returns</span> (stream TestResponse)</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p><strong>GrpcJavaFixture</strong> — In the <code>interop-test</code> module, a test utility class starts both a grpc-java server and client, providing <code>start()</code> &#x2F; <code>close()</code> lifecycle. Through it, bidirectional verification is possible: Reactor client calls grpc-java server, and grpc-java client calls Reactor server. Both use the same <code>.proto</code> generated code, ensuring wire compatibility.</p><p><strong>Utilities</strong>:</p><ul><li><code>FreePorts</code>: Allocates independent ports for each test, avoiding parallel test conflicts;</li><li><code>TlsTestCertificates</code>: Pre-generates self-signed certificates for subsequent TLS tests;</li><li><code>LeakDetection</code>: Integrates Netty’s <code>ResourceLeakDetector</code>, ensuring ByteBuf leaks are immediately exposed in tests.</li></ul><p><strong>Stage 0 exit criteria</strong>: <code>./gradlew clean test</code> passes from fresh checkout, CI is green on Linux + Java 25, protoc generation is deterministically reproducible, grpc-java fixture communicates bidirectionally.</p><h2 id="Staged-Verification-Strategy"><a href="#Staged-Verification-Strategy" class="headerlink" title="Staged Verification Strategy"></a>Staged Verification Strategy</h2><p>The project progresses through 12 Stages, each with clear goals, executable exit criteria, and regression coverage:</p><table><thead><tr><th>Stage</th><th>Goal</th><th>Key Deliverable</th></tr></thead><tbody><tr><td>0</td><td>Build baseline</td><td>CI, formatting, interop fixture</td></tr><tr><td>1</td><td>Protocol foundation</td><td>Frame codec, metadata, status, timeout, compression</td></tr><tr><td>2</td><td>Unary transport</td><td>End-to-end h2c unary call</td></tr><tr><td>3</td><td>Server streaming</td><td>Multi-message response stream</td></tr><tr><td>4</td><td>Full cardinality</td><td>Client streaming + bidirectional</td></tr><tr><td>5</td><td>Production transport</td><td>TLS, gzip, deadline, GOAWAY, connection pool, keepalive</td></tr><tr><td>6</td><td>Name resolution</td><td>DNS, subchannel, pick_first, round_robin</td></tr><tr><td>7</td><td>Codegen &amp; build integration</td><td>Protoc plugin, descriptor registry, Gradle&#x2F;Maven plugins</td></tr><tr><td>8</td><td>Standard services</td><td>Health v1, Reflection v1, Channelz v1</td></tr><tr><td>9</td><td>Operations &amp; hardening</td><td>Interceptor, observer, binlog, canonical smoke, fuzz&#x2F;churn</td></tr><tr><td>10</td><td>Load-balancing extensions</td><td>grpclb, RLS, load reporter, ORCA</td></tr><tr><td>11</td><td>Diagnostics (planned)</td><td>Channelz v2 over the bounded diagnostics registry</td></tr></tbody></table><p>Each Stage completion requires: <code>./gradlew clean spotlessCheck test --no-daemon</code> passes in full, and all prior Stage tests continue running as regression. This means when Stage 4 completes, Stage 2’s unary tests are still green.</p><h2 id="Where-Reactor-semantics-differ-from-grpc-java"><a href="#Where-Reactor-semantics-differ-from-grpc-java" class="headerlink" title="Where Reactor semantics differ from grpc-java"></a>Where Reactor semantics differ from grpc-java</h2><p>The wire contract is shared, but the application contracts are not. The tests therefore compare the two implementations at the protocol boundary and test each runtime’s lifecycle rules separately:</p><table><thead><tr><th>Concern</th><th>Reactor contract</th><th>grpc-java contract</th><th>What the tests must prove</th></tr></thead><tbody><tr><td>Demand</td><td><code>Subscription.request(n)</code> counts decoded messages; transport demand must also respect HTTP&#x2F;2 byte windows</td><td>inbound flow is controlled through <code>ClientCall.request(n)</code> &#x2F; readiness callbacks</td><td>no response DATA is delivered before downstream demand, and coalesced frames do not bypass demand</td></tr><tr><td>Cancellation</td><td>disposing a <code>Mono</code>&#x2F;<code>Flux</code> cancels both application publishers and resets an open stream</td><td><code>ClientCall.cancel</code> or <code>Context</code> cancellation reaches <code>StreamObserver</code> callbacks</td><td>both sides stop, exactly one terminal signal wins, and late DATA&#x2F;trailers are ignored</td></tr><tr><td>Trailers</td><td>success trailers are retained in the call result; non-OK trailers become <code>GrpcException.trailers()</code></td><td>trailers are exposed through <code>ClientCall.Listener#onClose</code> or <code>StatusRuntimeException#getTrailers()</code></td><td><code>grpc-status</code>, <code>grpc-message</code>, and custom trailers survive in both directions, including trailers-only errors</td></tr><tr><td>Buffer ownership</td><td>Netty <code>ByteBuf</code> is reference-counted and must be released on success, error, and cancel; decoded protobuf values cross the API boundary</td><td>generated protobuf messages hide transport buffers from application code</td><td>every rejected, partial, compressed, and cancelled frame reaches <code>refCnt() == 0</code></td></tr></tbody></table><p>For example, the Reactor demand test starts with zero demand and asks for one response at a time:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">StepVerifier.create(client.bidirectionalStreaming(BIDI, requests), <span class="number">0</span>)</span><br><span class="line">        .thenRequest(<span class="number">1</span>)</span><br><span class="line">        .expectNext(first)</span><br><span class="line">        .thenRequest(<span class="number">2</span>)</span><br><span class="line">        .expectNext(second, third)</span><br><span class="line">        .expectComplete()</span><br><span class="line">        .verify();</span><br></pre></td></tr></table></figure></div><p>The corresponding grpc-java test uses <code>StreamObserver</code> callbacks and completion signals. The messages and trailers must be wire-compatible, but it would be incorrect to describe the two tests as asserting the same backpressure mechanism. This distinction is why the project keeps both cross-runtime interop and runtime-specific failure tests.</p><p>The repository now also has two server-streaming cancellation interop cases in <code>ServerStreamingInteroperabilityTest</code>: a Reactor client receives one response from a grpc-java server and cancels, while a grpc-java client cancels a Reactor server after its first response. Both sides wait for the peer’s cancellation callback under paranoid leak detection. The lower-level <code>GrpcFrameCodecTest</code> keeps the direct ownership assertion by checking that cancelled and partial inputs finish with <code>refCnt() == 0</code>.</p><p>The essential cancellation paths are deliberately small:</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Reactor client: cancel the HTTP/2 call after the first response.</span></span><br><span class="line"><span class="type">TestResponse</span> <span class="variable">first</span> <span class="operator">=</span> reactorStub</span><br><span class="line">        .serverStreaming(Mono.just(request(<span class="number">5</span>)))</span><br><span class="line">        .take(<span class="number">1</span>)</span><br><span class="line">        .single()</span><br><span class="line">        .block(Duration.ofSeconds(<span class="number">5</span>));</span><br><span class="line"></span><br><span class="line"><span class="comment">// grpc-java client: cancel from the response callback.</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">onNext</span><span class="params">(TestResponse response)</span> &#123;</span><br><span class="line">    requestStream.cancel(<span class="string">&quot;cancel after first response&quot;</span>, <span class="literal">null</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>On the grpc-java server side, the test installs <code>setOnCancelHandler</code>; on the Reactor server side, the response <code>Flux</code> uses <code>doOnCancel</code>. The complete test, including latches, peer status assertions, shutdown, and leak-detection scope, is in <a href="https://github.com/qianwj/grpc-reactor/blob/db1a2fb/grpc-reactor-interop-test/src/test/java/io/github/qianwj/grpc/reactor/testing/ServerStreamingInteroperabilityTest.java#L97-L183"><code>ServerStreamingInteroperabilityTest.java</code></a>.</p><h2 id="Current-Verification-Results"><a href="#Current-Verification-Results" class="headerlink" title="Current Verification Results"></a>Current Verification Results</h2><p>The project uses this unified gate that simultaneously checks formatting, compilation, protocol tests, transport tests, and grpc-java interop tests:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">./gradlew clean spotlessCheck <span class="built_in">test</span> --no-daemon</span><br></pre></td></tr></table></figure></div><p>This command passes as of this writing. The gate now includes the optional Stage 10 load-balancing extension tests in addition to protocol, transport, codegen, services, binary-log, and grpc-java interop tests. The dependency baseline is Protobuf 4.35.1, grpc-java 1.82.2, and JUnit 6.1.2. Under JDK 25, you’ll still see Protobuf <code>Unsafe</code>, Netty&#x2F;Gradle native access, and Gradle deprecated feature warnings — they don’t affect test results but need ongoing tracking through future JDK and Gradle upgrades.</p><p>Subsequent posts will continue discussing the five-byte gRPC message envelope, ByteBuf ownership, four RPC cardinalities, production transport, DNS, code generation, standard management services, and how to integrate interceptors, observation, and binary logging without breaking Reactive Streams semantics.</p>]]>
    </content>
    <id>https://qianwj.github.io/en/2026/06/01/grpc-reactor-series-1/</id>
    <link href="https://qianwj.github.io/en/2026/06/01/grpc-reactor-series-1/"/>
    <published>2026-06-01T07:30:00.000Z</published>
    <summary>Starting from the boundary between grpc-java and the Reactor API, this post introduces grpc-reactor's goals, module structure, compatibility boundary, and staged verification strategy.</summary>
    <title>Why I Built a gRPC Runtime Directly on Reactor Netty</title>
    <updated>2026-08-08T05:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="开源项目" scheme="https://qianwj.github.io/categories/%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE/"/>
    <category term="gRPC" scheme="https://qianwj.github.io/tags/gRPC/"/>
    <category term="Reactor Netty" scheme="https://qianwj.github.io/tags/Reactor-Netty/"/>
    <category term="HTTP/2" scheme="https://qianwj.github.io/tags/HTTP-2/"/>
    <category term="Java" scheme="https://qianwj.github.io/tags/Java/"/>
    <content>
      <![CDATA[<p>最近我开始开发 <a class="link"   href="https://github.com/qianwj/grpc-reactor" >grpc-reactor<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a>：一个直接构建在 Reactor Netty HTTP&#x2F;2 之上的实验性 gRPC 实现。项目使用 Protobuf，但运行时不依赖 grpc-java transport、<code>ClientCall</code>、<code>ServerCall</code> 或 <code>StreamObserver</code>。</p><p>这不是为了证明 grpc-java 不好。grpc-java 已经成熟、稳定，并覆盖负载均衡、NameResolver、重试和丰富的可观测性。这个项目要回答的是另一个问题：如果应用的编程模型本来就是 Reactor，能否让 <code>Mono</code> 和 <code>Flux</code> 从生成 API 一直贯穿到 HTTP&#x2F;2 stream，而不是在两套异步抽象之间适配？</p><p>本文基于 JDK 25、Gradle 9.2.1、Reactor 3.8.6、Reactor Netty 1.3.6 和 Netty 4.2.15.Final。<code>main</code> 已完成 Stage 0 到 Stage 10：在协议、四种 RPC cardinality、生产传输、DNS、codegen、标准服务和 Stage 9 加固之外，Stage 10 又加入了可选的负载均衡扩展制品，支持 grpclb、RLS、负载报告和 ORCA。</p><h2 id="API-目标不是包装-StreamObserver"><a href="#API-目标不是包装-StreamObserver" class="headerlink" title="API 目标不是包装 StreamObserver"></a>API 目标不是包装 StreamObserver</h2><p>Protobuf 方法有四种 cardinality，目标 API 直接映射为：</p><table><thead><tr><th>Protobuf 方法</th><th>Reactor 签名</th></tr></thead><tbody><tr><td>unary</td><td><code>Mono&lt;Resp&gt; method(Mono&lt;Req&gt;)</code></td></tr><tr><td>server streaming</td><td><code>Flux&lt;Resp&gt; method(Mono&lt;Req&gt;)</code></td></tr><tr><td>client streaming</td><td><code>Mono&lt;Resp&gt; method(Flux&lt;Req&gt;)</code></td></tr><tr><td>bidirectional streaming</td><td><code>Flux&lt;Resp&gt; method(Flux&lt;Req&gt;)</code></td></tr></tbody></table><p>为什么 gRPC 要定义四种而不是只做 unary？本质上这是请求和响应各自独立选择”单值还是流”的 2x2 组合：</p><table><thead><tr><th></th><th>单值响应</th><th>流式响应</th></tr></thead><tbody><tr><td><strong>单值请求</strong></td><td>unary</td><td>server streaming</td></tr><tr><td><strong>流式请求</strong></td><td>client streaming</td><td>bidirectional</td></tr></tbody></table><p>它们解决不同场景：unary 覆盖经典 request-response；server streaming 用于服务端推送（事件订阅、大数据分页拉取）；client streaming 用于客户端批量上传（文件分片、批量写入）；bidirectional streaming 用于实时双向通信（聊天、协同编辑）。这不是凭空发明的四种模式——HTTP&#x2F;2 stream 本身就是全双工的，单个连接可以多路复用数百条并发 stream，请求和响应都可以独立发送多帧数据。gRPC 把这种传输能力提升为一等 API 语义：不是 chunked transfer encoding 的变体，而是让编译器在类型层面检查调用方和实现方是否匹配。</p><p>低层传输只保留一个统一模型：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Flux&lt;Req&gt; -&gt; HTTP/2 stream -&gt; Flux&lt;Resp&gt;</span><br></pre></td></tr></table></figure></div><p>生成代码负责在边界应用 <code>single()</code> 等 cardinality 检查。这样不需要为四种 RPC 分别实现四套网络逻辑，也能明确拒绝 unary 空请求、重复请求或重复响应。</p><h2 id="兼容的是-wire-protocol"><a href="#兼容的是-wire-protocol" class="headerlink" title="兼容的是 wire protocol"></a>兼容的是 wire protocol</h2><p>项目的兼容目标是公开的 <a class="link"   href="https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md" >gRPC over HTTP&#x2F;2 协议规范<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a>，不是 grpc-java 内部 API：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">generated Reactor API</span><br><span class="line">        |</span><br><span class="line">call dispatcher / service registry</span><br><span class="line">        |</span><br><span class="line">marshaller + message framer/deframer</span><br><span class="line">        |</span><br><span class="line">metadata + status + deadline</span><br><span class="line">        |</span><br><span class="line">Reactor Netty HTTP/2 stream</span><br></pre></td></tr></table></figure></div><p>每个 RPC 对应一个 HTTP&#x2F;2 stream。请求以 HEADERS frame 开始，至少包含：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">:method      POST</span><br><span class="line">:path        /&lt;package.Service&gt;/&lt;Method&gt;</span><br><span class="line">content-type application/grpc+proto</span><br><span class="line">te           trailers</span><br><span class="line">grpc-timeout &lt;相对超时，如 100m 表示 100ms&gt;</span><br></pre></td></tr></table></figure></div><p>消息体使用 Length-Prefixed-Message 格式封装在 DATA frame 中——每条 Protobuf 消息前有五字节 envelope（1 字节压缩标志 + 4 字节大端长度）。一个 DATA frame 可能包含多条 gRPC 消息，一条大消息也可能跨越多个 DATA frame。</p><p><strong>为什么 gRPC 必须依赖 HTTP trailers？</strong> 这是协议最反直觉的设计之一。HTTP status code 对 gRPC 来说几乎无用——协议要求 HTTP 层始终返回 200，真正的调用结果（<code>grpc-status</code> 和 <code>grpc-message</code>）放在 trailers 中。原因在于：streaming 场景下，server 可能已经发送了数千条消息，最终成功还是失败只有处理完最后一条数据后才能确定。HTTP headers 在 body 之前发送，无法承载这个后验结果。trailers 是 HTTP&#x2F;2 中唯一可以在 body 之后附加 metadata 的机制。</p><p>协议还定义了 <strong>trailers-only</strong> 模式：当 server 在读取 body 之前就能判定失败（比如路径不存在、认证失败），<code>grpc-status</code> 直接放在 response headers 中返回，不发送 body，节省一次 round-trip。Client 必须同时检查 headers 和 trailers 两个位置才能正确提取最终状态。</p><h2 id="按协议边界拆模块"><a href="#按协议边界拆模块" class="headerlink" title="按协议边界拆模块"></a>按协议边界拆模块</h2><p>项目目前有九个模块：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">grpc-reactor-protocol      → 与传输无关的协议原语</span><br><span class="line">grpc-reactor-transport     → Reactor Netty HTTP/2 映射</span><br><span class="line">grpc-reactor-codegen       → Protoc 插件，生成 Reactor stub</span><br><span class="line">grpc-reactor-gradle-plugin → Gradle 集成</span><br><span class="line">grpc-reactor-maven-plugin  → Maven generate-sources 集成</span><br><span class="line">grpc-reactor-services      → 可选 Health、Reflection 与 Channelz 标准服务</span><br><span class="line">grpc-reactor-binlog        → 可选、有界的 canonical binary logging</span><br><span class="line">grpc-reactor-lb-extensions → 可选 grpclb、RLS、负载报告与 ORCA</span><br><span class="line">grpc-reactor-interop-test  → grpc-java 兼容性测试</span><br></pre></td></tr></table></figure></div><p><code>protocol</code> 只处理与传输无关的值和编解码：消息帧、metadata、status、timeout、compression 和 protobuf marshaller。它依赖 Reactor Core 与 Netty Buffer，但不依赖 Reactor Netty——这意味着协议层可以独立测试而不需要真实的 HTTP&#x2F;2 连接。</p><p><code>transport</code> 把协议映射到 Reactor Netty HTTP&#x2F;2，负责 client、server、service registry 和 per-call context。</p><p><code>codegen</code> 消费 Protobuf <code>CodeGeneratorRequest</code>，生成类型安全的 Reactor client 和 service binder。</p><p><code>services</code> 使用同一套 codegen 生成 canonical gRPC service binding，并在 transport 的 immutable descriptor&#x2F;diagnostics snapshot 之上实现 Health v1、Reflection v1 和 Channelz v1。该模块需要显式注册，不会因为加入依赖就自动暴露管理端点。</p><p><code>binlog</code> 同样是显式启用的独立模块。它以 transport interceptor 捕获 canonical binary-log v1 event，通过 metadata&#x2F;message 截断、敏感 key 脱敏和固定容量 sink 控制信息暴露与内存上限。</p><p><code>interop-test</code> 在测试作用域引入 grpc-java。grpc-java 在这里是兼容性判定器，不进入项目运行时。</p><h2 id="依赖选型与版本锁定"><a href="#依赖选型与版本锁定" class="headerlink" title="依赖选型与版本锁定"></a>依赖选型与版本锁定</h2><p>运行时依赖链刻意保持短小：</p><table><thead><tr><th>依赖</th><th>版本</th><th>用途</th></tr></thead><tbody><tr><td>Reactor Core</td><td>3.8.6</td><td>Mono&#x2F;Flux 编程模型</td></tr><tr><td>Reactor Netty</td><td>1.3.6</td><td>HTTP&#x2F;2 client&#x2F;server</td></tr><tr><td>Netty</td><td>4.2.15</td><td>ByteBuf、HTTP&#x2F;2 codec</td></tr><tr><td>Protobuf-java</td><td>4.35.1</td><td>消息序列化</td></tr></tbody></table><p>项目不引入 Spring、Micrometer 或任何 DI 框架。测试使用 JUnit 6.1.2 与 Reactor Test 的 <code>StepVerifier</code>。grpc-java 1.82.2 仅出现在 <code>interop-test</code> 的 test classpath，运行时零侵入。</p><p>版本通过 <code>gradle/libs.versions.toml</code> 集中管理，配合 Gradle dependency locking 生成锁文件，确保不同机器的构建完全可复现。</p><h2 id="为什么协议层必须先完成"><a href="#为什么协议层必须先完成" class="headerlink" title="为什么协议层必须先完成"></a>为什么协议层必须先完成</h2><p>直接从”启动一个 HTTP&#x2F;2 Server”开始很容易得到一个能 echo 的演示，却会把真正困难的问题推迟：</p><ul><li>DATA 可能在五字节 gRPC header 中间分片；</li><li>一个 DATA buffer 可能包含多条消息；</li><li>metadata 允许重复键，二进制值需要 Base64；</li><li>timeout wire value 最多八位并带单位；</li><li>最终状态来自 trailers；</li><li>ByteBuf 在成功、失败和取消路径都必须释放；</li><li>Reactive Streams demand 以消息计数，HTTP&#x2F;2 flow control 以字节计数。</li></ul><p>因此项目按 Stage 推进：先固定构建和互操作 fixture，再完成协议层，之后才实现 unary transport、streaming、生产特性和 codegen。每个 Stage 都有可执行退出条件，不以”类已经创建”作为完成标准。</p><h2 id="Stage-0：构建基线与互操作-fixture"><a href="#Stage-0：构建基线与互操作-fixture" class="headerlink" title="Stage 0：构建基线与互操作 fixture"></a>Stage 0：构建基线与互操作 fixture</h2><p>在写任何协议代码之前，Stage 0 解决的是”如何证明代码正确”：</p><p><strong>JDK 25 编译配置</strong>——项目使用 <code>-Xlint:all -parameters -encoding UTF-8</code> 严格编译。所有警告都是编译错误，不允许静默忽略。选择 JDK 25 是为了提前验证 Netty 和 Protobuf 在最新 JVM 上的兼容性。</p><p><strong>Spotless 格式化</strong>——统一使用 Eclipse formatter 配置加 ktlint，<code>spotlessCheck</code> 是 CI 门禁的第一道关。这消除了所有关于格式的 code review 讨论。</p><p><strong>interop.proto 测试 fixture</strong>——定义了包含全部四种 RPC cardinality 的测试 service：</p><div class="code-container" data-rel="Protobuf"><figure class="iseeu highlight protobuf"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">service </span><span class="title class_">InteropTestService</span> &#123;</span><br><span class="line">  <span class="function"><span class="keyword">rpc</span> Unary (TestRequest) <span class="keyword">returns</span> (TestResponse)</span>;</span><br><span class="line">  <span class="function"><span class="keyword">rpc</span> ServerStreaming (TestRequest) <span class="keyword">returns</span> (stream TestResponse)</span>;</span><br><span class="line">  <span class="function"><span class="keyword">rpc</span> ClientStreaming (stream TestRequest) <span class="keyword">returns</span> (TestResponse)</span>;</span><br><span class="line">  <span class="function"><span class="keyword">rpc</span> BidirectionalStreaming (stream TestRequest) <span class="keyword">returns</span> (stream TestResponse)</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p><strong>GrpcJavaFixture</strong>——在 <code>interop-test</code> 模块中，一个测试工具类同时启动 grpc-java server 和 client，提供 <code>start()</code> &#x2F; <code>close()</code> 生命周期。通过它可以双向验证：Reactor client 调用 grpc-java server，以及 grpc-java client 调用 Reactor server。两者使用相同的 <code>.proto</code> 生成代码，确保 wire 兼容。</p><p><strong>辅助工具</strong>：</p><ul><li><code>FreePorts</code>：为每个测试分配独立端口，避免并行测试冲突；</li><li><code>TlsTestCertificates</code>：为后续 TLS 测试预生成自签名证书；</li><li><code>LeakDetection</code>：集成 Netty 的 <code>ResourceLeakDetector</code>，确保 ByteBuf 泄漏在测试中立即暴露。</li></ul><p><strong>Stage 0 退出条件</strong>：<code>./gradlew clean test</code> 从 fresh checkout 通过，CI 在 Linux + Java 25 绿灯，protoc 生成确定性可重现，grpc-java fixture 双向通信正常。</p><h2 id="分阶段验证策略"><a href="#分阶段验证策略" class="headerlink" title="分阶段验证策略"></a>分阶段验证策略</h2><p>项目按 12 个 Stage 递进，每个 Stage 有明确的目标、可执行退出条件和回归保障：</p><table><thead><tr><th>Stage</th><th>目标</th><th>关键交付</th></tr></thead><tbody><tr><td>0</td><td>构建基线</td><td>CI、格式、interop fixture</td></tr><tr><td>1</td><td>协议基础</td><td>帧编解码、metadata、status、timeout、compression</td></tr><tr><td>2</td><td>Unary transport</td><td>端到端 h2c unary 调用</td></tr><tr><td>3</td><td>Server streaming</td><td>多消息响应流</td></tr><tr><td>4</td><td>全 cardinality</td><td>client streaming + bidirectional</td></tr><tr><td>5</td><td>生产传输</td><td>TLS、gzip、deadline、GOAWAY、连接池、keepalive</td></tr><tr><td>6</td><td>Name resolution</td><td>DNS、subchannel、pick_first、round_robin</td></tr><tr><td>7</td><td>Codegen 与构建集成</td><td>protoc 插件、描述符注册表、Gradle&#x2F;Maven 插件</td></tr><tr><td>8</td><td>标准服务</td><td>Health v1、Reflection v1、Channelz v1</td></tr><tr><td>9</td><td>运行面与加固</td><td>interceptor、observer、binlog、canonical smoke、fuzz&#x2F;churn</td></tr><tr><td>10</td><td>负载均衡扩展</td><td>grpclb、RLS、load reporter、ORCA</td></tr><tr><td>11</td><td>诊断（计划中）</td><td>在有界 diagnostics registry 之上增加 Channelz v2</td></tr></tbody></table><p>每个 Stage 完成时必须满足：<code>./gradlew clean spotlessCheck test --no-daemon</code> 全量通过，且前序 Stage 的测试作为回归继续运行。这意味着 Stage 4 完成时，Stage 2 的 unary 测试仍然是绿的。</p><h2 id="Reactor-与-grpc-java-的语义差异"><a href="#Reactor-与-grpc-java-的语义差异" class="headerlink" title="Reactor 与 grpc-java 的语义差异"></a>Reactor 与 grpc-java 的语义差异</h2><p>两套实现共享 wire protocol，但应用层契约并不相同。互操作测试验证消息、状态和 metadata 能跨实现传输；运行时测试则分别验证各自的生命周期规则，不能把两者简单视为同一种背压或取消机制：</p><table><thead><tr><th>关注点</th><th>Reactor 语义</th><th>grpc-java 语义</th><th>测试需要证明</th></tr></thead><tbody><tr><td>Demand</td><td><code>Subscription.request(n)</code> 以解码后的消息计数，传输层还要同时遵守 HTTP&#x2F;2 的字节窗口</td><td>入站流通过 <code>ClientCall.request(n)</code> 和 readiness callback 控制</td><td>下游没有 demand 时不能交付 response DATA，合并在一个 frame 中的消息也不能绕过 demand</td></tr><tr><td>Cancellation</td><td>取消 <code>Mono</code>&#x2F;<code>Flux</code> 会取消两侧 application publisher，并在 stream 仍打开时发送 reset</td><td><code>ClientCall.cancel</code> 或 <code>Context</code> cancellation 通过 <code>StreamObserver</code> callback 传播</td><td>两侧都停止，只产生一个 terminal signal，迟到的 DATA&#x2F;trailers 被忽略</td></tr><tr><td>Trailers</td><td>成功 trailers 保存在 call result 中；非 OK trailers 转换为 <code>GrpcException.trailers()</code></td><td>通过 <code>ClientCall.Listener#onClose</code> 或 <code>StatusRuntimeException#getTrailers()</code> 暴露</td><td><code>grpc-status</code>、<code>grpc-message</code> 和自定义 trailers 在双向调用中都能保留，包括 trailers-only 错误</td></tr><tr><td>Buffer ownership</td><td>Netty <code>ByteBuf</code> 有引用计数，成功、失败和取消路径都必须释放；只有解码后的 Protobuf 值越过 API 边界</td><td>生成的 Protobuf message 对应用隐藏了传输 buffer</td><td>被拒绝、分片、压缩和取消的 frame 最终都达到 <code>refCnt() == 0</code></td></tr></tbody></table><p>例如，Reactor 的双向流测试从零 demand 开始，逐批请求响应：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">StepVerifier.create(client.bidirectionalStreaming(BIDI, requests), <span class="number">0</span>)</span><br><span class="line">        .thenRequest(<span class="number">1</span>)</span><br><span class="line">        .expectNext(first)</span><br><span class="line">        .thenRequest(<span class="number">2</span>)</span><br><span class="line">        .expectNext(second, third)</span><br><span class="line">        .expectComplete()</span><br><span class="line">        .verify();</span><br></pre></td></tr></table></figure></div><p>对应的 grpc-java 测试使用 <code>StreamObserver</code> callback 和完成信号。消息与 trailers 必须 wire-compatible，但两套测试断言的背压机制并不相同；这也是项目同时保留跨实现互操作测试和运行时故障测试的原因。</p><p>仓库现在还在 <code>ServerStreamingInteroperabilityTest</code> 中加入了两个 server streaming 取消互操作案例：Reactor client 从 grpc-java server 收到一条响应后取消，以及 grpc-java client 在收到 Reactor server 的第一条响应后取消。两侧都在 paranoid leak detection 下等待对端的取消 callback；更底层的 <code>GrpcFrameCodecTest</code> 则直接断言取消和 partial frame 输入最终达到 <code>refCnt() == 0</code>。</p><p>核心取消路径其实很短：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Reactor client：收到第一条响应后取消 HTTP/2 调用</span></span><br><span class="line"><span class="type">TestResponse</span> <span class="variable">first</span> <span class="operator">=</span> reactorStub</span><br><span class="line">        .serverStreaming(Mono.just(request(<span class="number">5</span>)))</span><br><span class="line">        .take(<span class="number">1</span>)</span><br><span class="line">        .single()</span><br><span class="line">        .block(Duration.ofSeconds(<span class="number">5</span>));</span><br><span class="line"></span><br><span class="line"><span class="comment">// grpc-java client：在响应 callback 中取消</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">onNext</span><span class="params">(TestResponse response)</span> &#123;</span><br><span class="line">    requestStream.cancel(<span class="string">&quot;cancel after first response&quot;</span>, <span class="literal">null</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>grpc-java server 通过 <code>setOnCancelHandler</code> 观察取消，Reactor server 则在响应 <code>Flux</code> 上使用 <code>doOnCancel</code>。包含 latch、对端状态断言、资源关闭和 leak detection 的完整代码见 <a href="https://github.com/qianwj/grpc-reactor/blob/db1a2fb/grpc-reactor-interop-test/src/test/java/io/github/qianwj/grpc/reactor/testing/ServerStreamingInteroperabilityTest.java#L97-L183"><code>ServerStreamingInteroperabilityTest.java</code></a>。</p><h2 id="当前验证结果"><a href="#当前验证结果" class="headerlink" title="当前验证结果"></a>当前验证结果</h2><p>项目统一使用下面的完整门禁，同时检查格式、编译、协议测试、transport 测试和 grpc-java 互操作测试：</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">./gradlew clean spotlessCheck <span class="built_in">test</span> --no-daemon</span><br></pre></td></tr></table></figure></div><p>本文更新时该命令已经通过。当前门禁除了协议、transport、codegen、标准服务、binary-log 和 grpc-java 互操作测试外，也包含 Stage 10 负载均衡扩展测试。依赖基线是 Protobuf 4.35.1、grpc-java 1.82.2 和 JUnit 6.1.2。JDK 25 下仍会看到 Protobuf <code>Unsafe</code>、Netty&#x2F;Gradle native access 与 Gradle deprecated feature 提示，它们不影响本次测试结果，但需要在后续 JDK 和 Gradle 升级时持续跟踪。</p><p>后续文章将继续讨论五字节 gRPC message envelope、ByteBuf 所有权、四种 RPC cardinality、生产传输、DNS、代码生成、标准管理服务，以及如何在不破坏 Reactive Streams 语义的前提下接入 interceptor、观测与 binary log。</p>]]>
    </content>
    <id>https://qianwj.github.io/2026/06/01/grpc-reactor-series-1/</id>
    <link href="https://qianwj.github.io/2026/06/01/grpc-reactor-series-1/"/>
    <published>2026-06-01T07:30:00.000Z</published>
    <summary>从 grpc-java 与 Reactor API 的边界出发，介绍 grpc-reactor 的目标、模块划分、兼容边界和分阶段验证策略。</summary>
    <title>grpc-reactor 实战（一）：为什么直接在 Reactor Netty 上实现 gRPC</title>
    <updated>2026-08-07T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="性能诊断" scheme="https://qianwj.github.io/categories/%E6%80%A7%E8%83%BD%E8%AF%8A%E6%96%AD/"/>
    <category term="Vert.x" scheme="https://qianwj.github.io/tags/Vert-x/"/>
    <category term="性能测试" scheme="https://qianwj.github.io/tags/%E6%80%A7%E8%83%BD%E6%B5%8B%E8%AF%95/"/>
    <category term="wrk" scheme="https://qianwj.github.io/tags/wrk/"/>
    <category term="GraalVM" scheme="https://qianwj.github.io/tags/GraalVM/"/>
    <category term="Native Image" scheme="https://qianwj.github.io/tags/Native-Image/"/>
    <content>
      <![CDATA[<p>性能数字只有与代码版本、测试环境、请求模型和原始输出放在一起才有意义。当前项目还不具备稳定、隔离的压测环境，因此本章先建立可复现的测试方法，不预设吞吐结论。</p><h2 id="固定请求模型"><a href="#固定请求模型" class="headerlink" title="固定请求模型"></a>固定请求模型</h2><p>先准备 <code>create-order.lua</code>：</p><div class="code-container" data-rel="Lua"><figure class="iseeu highlight lua"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">wrk.method = <span class="string">&quot;POST&quot;</span></span><br><span class="line">wrk.headers[<span class="string">&quot;Content-Type&quot;</span>] = <span class="string">&quot;application/json&quot;</span></span><br><span class="line">wrk.body = <span class="string">&#x27;&#123;&quot;customerId&quot;:&quot;load-test&quot;,&quot;sku&quot;:&quot;book&quot;,&quot;quantity&quot;:1&#125;&#x27;</span></span><br></pre></td></tr></table></figure></div><p>当前库存只有 10 件，连续压测很快会得到 409，因此它并不适合直接测吞吐。性能场景必须增加可重置的测试库存或使用不会修改状态的专用端点；把大量 409 也统计成“成功 QPS”没有业务意义。</p><p>准备好隔离数据后再运行：</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">wrk -t4 -c200 -d60s --latency \</span><br><span class="line">  -s create-order.lua http://127.0.0.1:8080/orders</span><br></pre></td></tr></table></figure></div><h2 id="每次必须记录的内容"><a href="#每次必须记录的内容" class="headerlink" title="每次必须记录的内容"></a>每次必须记录的内容</h2><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">提交版本：</span><br><span class="line">JDK / Vert.x / Gradle：</span><br><span class="line">CPU / 内存 / 操作系统：</span><br><span class="line">服务端与压测端是否同机：</span><br><span class="line">线程、连接、持续时间：</span><br><span class="line">请求体与数据准备方式：</span><br><span class="line">QPS、P50、P95、P99、最大延迟：</span><br><span class="line">非 2xx 数量和错误类型：</span><br><span class="line">CPU、堆、GC 和 Event Loop 阻塞：</span><br></pre></td></tr></table></figure></div><p>优化必须一次只改变一个变量，并保留优化前后原始输出。JFR 或 async-profiler 用来定位热点，不能先猜“JSON 慢”就替换序列化库。</p><p>本章没有给出新的 QPS，是因为当前教学实现包含有限库存、内存存储且没有稳定压测环境。缺少这些条件时，诚实地不下结论比漂亮数字更有价值。</p><h2 id="用子项目构建-Native-Image"><a href="#用子项目构建-Native-Image" class="headerlink" title="用子项目构建 Native Image"></a>用子项目构建 Native Image</h2><p>Native Image 通常能缩短启动时间并降低部分场景的常驻内存，但代价包括更慢的构建、闭世界分析限制，以及与 JVM 不同的峰值性能特征。要讨论这些取舍，第一步是让同一份业务代码真正生成并运行原生二进制。</p><p>项目新增 <code>native-image-demo</code> 子项目。它依赖根项目，不复制订单代码：</p><div class="code-container" data-rel="Kotlin"><figure class="iseeu highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">plugins &#123;</span><br><span class="line">    application</span><br><span class="line">    id(<span class="string">&quot;org.graalvm.buildtools.native&quot;</span>) version <span class="string">&quot;0.11.1&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">repositories &#123;</span><br><span class="line">    mavenCentral()</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">dependencies &#123;</span><br><span class="line">    implementation(project(<span class="string">&quot;:&quot;</span>))</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>多项目构建中，子项目不会自动继承根项目的 <code>repositories</code>。第一次执行时正是因为缺少这段配置，Gradle 无法解析根项目传递来的 Vert.x 依赖，构建在进入闭世界分析之前失败。</p><p>原生二进制配置为：</p><div class="code-container" data-rel="Kotlin"><figure class="iseeu highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">graalvmNative &#123;</span><br><span class="line">    toolchainDetection = <span class="literal">true</span></span><br><span class="line">    binaries &#123;</span><br><span class="line">        named(<span class="string">&quot;main&quot;</span>) &#123;</span><br><span class="line">            imageName = <span class="string">&quot;vertx-order-native&quot;</span></span><br><span class="line">            mainClass = <span class="string">&quot;dev.elvis.orders.nativeimage.NativeOrderApplication&quot;</span></span><br><span class="line">            buildArgs.add(<span class="string">&quot;-H:+ReportExceptionStackTraces&quot;</span>)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>构建命令从根目录执行：</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">./gradlew :native-image-demo:nativeCompile</span><br></pre></td></tr></table></figure></div><p>本次实际环境和结果如下：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">GraalVM CE：25.0.3</span><br><span class="line">架构：macOS arm64</span><br><span class="line">Native Image 阶段：23.0 秒</span><br><span class="line">可达类型：7,562</span><br><span class="line">可达方法：39,049</span><br><span class="line">反射注册类型：413</span><br><span class="line">峰值构建 RSS：1.85 GiB</span><br><span class="line">二进制大小：27.68 MiB</span><br></pre></td></tr></table></figure></div><p>这些是当前机器、依赖和提交下的一次构建记录，不是对其他项目的尺寸或速度承诺。</p><h2 id="处理-Netty-allocator-的运行时问题"><a href="#处理-Netty-allocator-的运行时问题" class="headerlink" title="处理 Netty allocator 的运行时问题"></a>处理 Netty allocator 的运行时问题</h2><p>第一次生成的二进制可以监听端口，但请求建立连接后立即被重置。增加 HTTP Server 异常日志后得到堆栈：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">java.lang.NullPointerException</span><br><span class="line">  at io.netty.util.internal.PlatformDependent0.safeConstructPutInt(...)</span><br><span class="line">  at io.netty.buffer.AdaptivePoolingAllocator$Chunk.&lt;init&gt;(...)</span><br></pre></td></tr></table></figure></div><p>问题发生在 Netty 的 adaptive allocator，而不是路由或 JSON 反射。使用运行参数验证 <code>pooled</code> allocator 后，健康检查恢复 200。为了不要求部署者记住额外参数，子项目提供专用入口：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">final</span> <span class="keyword">class</span> <span class="title class_">NativeOrderApplication</span> &#123;</span><br><span class="line">  <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">main</span><span class="params">(String[] args)</span> &#123;</span><br><span class="line">    System.setProperty(<span class="string">&quot;io.netty.allocator.type&quot;</span>, <span class="string">&quot;pooled&quot;</span>);</span><br><span class="line">    OrderApplication.main(args);</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>这个入口只属于 Native 子项目，不会改变 JVM 主项目。它也记录了明确的兼容原因，后续升级 Netty 或 GraalVM 时可以删除属性重测，而不是永久保留一个来源不明的系统参数。</p><h2 id="验证原生服务的业务路径"><a href="#验证原生服务的业务路径" class="headerlink" title="验证原生服务的业务路径"></a>验证原生服务的业务路径</h2><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">HTTP_PORT=18081 \</span><br><span class="line">  ./native-image-demo/build/native/nativeCompile/vertx-order-native</span><br></pre></td></tr></table></figure></div><p>本次验证了三条请求：</p><table><thead><tr><th>请求</th><th>预期</th><th>实际</th></tr></thead><tbody><tr><td><code>GET /health</code></td><td>服务可用</td><td>200，<code>{&quot;status&quot;:&quot;UP&quot;}</code></td></tr><tr><td><code>POST /orders</code>，购买 2 本书</td><td>创建并预留库存</td><td>201，剩余库存 8</td></tr><tr><td><code>POST /orders</code>，购买 4 个键盘</td><td>库存不足</td><td>409，返回明确错误</td></tr></tbody></table><p>这说明 HTTP Router、JSON、Event Bus、Future 编排和内存状态在当前 Native Image 中能工作。WebSocket 长连接和熔断恢复计时仍应增加原生进程级自动化测试；健康检查成功本身不足以证明完整兼容。</p><h2 id="用-Circuit-Breaker-隔离支付故障"><a href="#用-Circuit-Breaker-隔离支付故障" class="headerlink" title="用 Circuit Breaker 隔离支付故障"></a>用 Circuit Breaker 隔离支付故障</h2><p>支付下游不可用时，新请求不应该持续冲击已经故障的服务。项目使用一个可以注入真实 WebClient 或测试实现的 <code>ResilientPaymentClient</code>：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">this</span>.breaker = CircuitBreaker.create(</span><br><span class="line">    <span class="string">&quot;payment&quot;</span>,</span><br><span class="line">    vertx,</span><br><span class="line">    <span class="keyword">new</span> <span class="title class_">CircuitBreakerOptions</span>()</span><br><span class="line">        .setTimeout(<span class="number">800</span>)</span><br><span class="line">        .setMaxFailures(<span class="number">3</span>)</span><br><span class="line">        .setResetTimeout(<span class="number">5_000</span>));</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> Future&lt;JsonObject&gt; <span class="title function_">authorize</span><span class="params">(JsonObject payment)</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> breaker.execute(() -&gt; delegate.apply(payment));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>800 毫秒、3 次失败和 5 秒恢复只是演示参数。真实值要结合支付接口延迟分位、订单总超时预算、流量和下游恢复方式决定。</p><p>测试注入一个每次失败的支付实现，并用 <code>AtomicInteger</code> 记录下游真实调用次数。连续执行四次授权后断言：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">assertThat(client.state()).isEqualTo(CircuitBreakerState.OPEN);</span><br><span class="line">assertThat(calls).hasValue(<span class="number">3</span>);</span><br></pre></td></tr></table></figure></div><p>前三次到达下游，达到失败阈值后第四次被熔断器直接拒绝。这是由当前 Vert.x 5.0.0 依赖和自动化测试验证的结果，而不是根据文档推测。</p><p>支付请求不能随意自动重试。超时不代表支付方没有成功处理，缺少幂等键时重试可能重复扣款。降级也不能伪造支付成功；更合理的状态通常是 <code>PAYMENT_PENDING</code> 或明确失败，再通过查询与对账恢复。</p><p>Circuit Breaker 只负责故障隔离，不等于分布式事务、限流或监控。生产接入仍需记录开路状态、失败原因和半开探测，并为订单状态设计可靠恢复流程。</p><h2 id="当前系列的验证边界"><a href="#当前系列的验证边界" class="headerlink" title="当前系列的验证边界"></a>当前系列的验证边界</h2><p>截至本文更新，配套项目执行 <code>./gradlew test</code>，订单创建查询、库存不足和连续支付失败后三个场景均通过。Hexo 站点也可以完整生成。</p><p>仍未验证的内容会保持明确：没有可靠数据库、没有消息代理、没有稳定压测结果，也没有多节点部署。Native Image 已完成构建和三条 HTTP 路径验证，但还没有 WebSocket 与熔断恢复的原生进程级测试。后续补充时仍应同时提交代码、命令和原始结果。</p>]]>
    </content>
    <id>https://qianwj.github.io/2026/05/05/vertx-series-5/</id>
    <link href="https://qianwj.github.io/2026/05/05/vertx-series-5/"/>
    <published>2026-05-05T14:00:00.000Z</published>
    <summary>为订单服务建立可复现的性能测试，使用 GraalVM JDK 25 构建并验证 Native Image，并通过测试验证 Circuit Breaker 的故障隔离行为。</summary>
    <title>Vert.x 实战（五）：性能验证、Native Image 与故障隔离</title>
    <updated>2026-07-13T06:20:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="实时通信" scheme="https://qianwj.github.io/categories/%E5%AE%9E%E6%97%B6%E9%80%9A%E4%BF%A1/"/>
    <category term="Vert.x" scheme="https://qianwj.github.io/tags/Vert-x/"/>
    <category term="WebSocket" scheme="https://qianwj.github.io/tags/WebSocket/"/>
    <category term="Event Bus" scheme="https://qianwj.github.io/tags/Event-Bus/"/>
    <content>
      <![CDATA[<p>订单创建后，管理界面希望立即看到新订单。轮询可以工作，但会产生固定延迟和大量空请求。本章在同一个 HTTP Server 上增加 <code>/events</code> WebSocket，并把订单事件推送给已连接客户端。</p><h2 id="业务层只发布事件"><a href="#业务层只发布事件" class="headerlink" title="业务层只发布事件"></a>业务层只发布事件</h2><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">orders.put(id, order);</span><br><span class="line">vertx.eventBus().publish(OrderVerticle.CREATED_ADDRESS, order);</span><br><span class="line">message.reply(order);</span><br></pre></td></tr></table></figure></div><p>订单层不知道 WebSocket 的存在。以后增加审计日志或指标消费者时，只需要订阅同一地址。这里的 <code>publish</code> 是进程内即时通知，不是可靠消息：没有消费者时事件会丢失，消费者失败也不会让订单创建回滚。</p><h2 id="管理连接生命周期"><a href="#管理连接生命周期" class="headerlink" title="管理连接生命周期"></a>管理连接生命周期</h2><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">void</span> <span class="title function_">connectEvents</span><span class="params">(ServerWebSocket socket)</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (!<span class="string">&quot;/events&quot;</span>.equals(socket.path())) &#123;</span><br><span class="line">    socket.close((<span class="type">short</span>) <span class="number">1008</span>, <span class="string">&quot;unsupported path&quot;</span>);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line">  eventClients.add(socket);</span><br><span class="line">  socket.closeHandler(ignored -&gt; eventClients.remove(socket));</span><br><span class="line">  socket.exceptionHandler(ignored -&gt; eventClients.remove(socket));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>创建事件到达 HTTP Verticle 后，对连接集合调用 <code>writeTextMessage</code>。写失败的连接会被移除，避免长期持有已失效对象。</p><p>可以使用任意 WebSocket 客户端连接：</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">wscat -c ws://localhost:8080/events</span><br></pre></td></tr></table></figure></div><p>另一个终端创建订单，前一个终端应收到包含订单 ID 的 JSON。</p><h2 id="当前版本不能直接上生产"><a href="#当前版本不能直接上生产" class="headerlink" title="当前版本不能直接上生产"></a>当前版本不能直接上生产</h2><p>演示实现没有处理慢客户端的写队列、身份认证、心跳、断线补发和事件顺序；服务多实例部署后，每个实例也只知道连接到自己的客户端。真实系统通常需要消息代理或持久事件流，并让客户端携带最后确认的事件位置进行恢复。</p><p>“能保持十万连接”不是 WebSocket API 自带的属性。文件描述符、TLS、消息大小、广播比例、内存和客户端网络都会改变结果。没有测试环境和原始记录时，本文不会给出具体连接数结论。</p><h2 id="把运行参数移出代码"><a href="#把运行参数移出代码" class="headerlink" title="把运行参数移出代码"></a>把运行参数移出代码</h2><p>监听端口不应写死。入口从环境变量读取端口，再通过 <code>DeploymentOptions</code> 传入 HTTP Verticle：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">int</span> <span class="variable">port</span> <span class="operator">=</span> Integer.parseInt(</span><br><span class="line">    System.getenv().getOrDefault(<span class="string">&quot;HTTP_PORT&quot;</span>, <span class="string">&quot;8080&quot;</span>));</span><br><span class="line"><span class="type">DeploymentOptions</span> <span class="variable">options</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">DeploymentOptions</span>()</span><br><span class="line">    .setConfig(<span class="keyword">new</span> <span class="title class_">JsonObject</span>().put(<span class="string">&quot;http.port&quot;</span>, port));</span><br></pre></td></tr></table></figure></div><p>可以在不改源码的情况下启动另一个端口：</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">HTTP_PORT=9090 ./gradlew run</span><br></pre></td></tr></table></figure></div><p>正式项目还要验证端口范围，对格式错误给出清晰启动失败，并从密钥管理系统读取敏感配置而不是提交到仓库。</p><h2 id="本地状态决定扩展方式"><a href="#本地状态决定扩展方式" class="headerlink" title="本地状态决定扩展方式"></a>本地状态决定扩展方式</h2><p>当前服务有两份进程内状态：库存 Map 与 WebSocket 连接集合。增加实例会让库存分裂，每个实例也只能向连接到自己的客户端推送。</p><p>水平扩展至少需要：</p><ul><li>将订单和库存事实保存到支持并发控制的持久化存储；</li><li>通过可靠消息系统传播订单事件；</li><li>为事件设计 ID、幂等消费与重放位置；</li><li>区分存活检查和就绪检查；</li><li>在优雅停机期间停止接收请求并迁移连接。</li></ul><p>Vert.x Cluster Event Bus 能处理一部分跨节点消息寻址，但不会自动解决消息持久化、数据库一致性和 WebSocket 断线补发。对于订单事件，可靠性需求通常比“消息能够跨 JVM 到达”更重要。</p><h2 id="一次实际的启动验证"><a href="#一次实际的启动验证" class="headerlink" title="一次实际的启动验证"></a>一次实际的启动验证</h2><p>本地验证时 8080 已被其他程序占用，Verticle 的启动 Promise 正确失败，没有报告启动成功。改用 <code>HTTP_PORT=18080</code> 后，健康检查返回 200，创建订单返回 201，库存不足返回 409。这也说明外部配置和错误生命周期不是可有可无的样板代码。</p>]]>
    </content>
    <id>https://qianwj.github.io/2026/03/05/vertx-series-4/</id>
    <link href="https://qianwj.github.io/2026/03/05/vertx-series-4/"/>
    <published>2026-03-05T06:00:00.000Z</published>
    <summary>将订单事件推送到 WebSocket 客户端，管理连接生命周期和外部配置，并分析内存状态、可靠消息及水平扩展边界。</summary>
    <title>Vert.x 实战（四）：WebSocket 通知与多实例部署边界</title>
    <updated>2026-07-12T06:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="性能诊断" scheme="https://qianwj.github.io/categories/%E6%80%A7%E8%83%BD%E8%AF%8A%E6%96%AD/"/>
    <category term="Vert.x" scheme="https://qianwj.github.io/tags/Vert-x/"/>
    <category term="Event Loop" scheme="https://qianwj.github.io/tags/Event-Loop/"/>
    <category term="JFR" scheme="https://qianwj.github.io/tags/JFR/"/>
    <content>
      <![CDATA[<p>订单项目目前所有 Handler 都只操作内存并发送异步消息，很快就会返回。但一旦接入同步 JDBC、文件读取或旧 SDK，最容易出现的错误就是把阻塞调用直接放进路由 Handler。</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">router.post(<span class="string">&quot;/orders&quot;</span>).handler(ctx -&gt; &#123;</span><br><span class="line">  legacyClient.createOrder(ctx.body().asString()); <span class="comment">// 同步网络调用</span></span><br><span class="line">  ctx.response().end();</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure></div><p>问题不只影响当前请求。这个 Event Loop 负责的其他连接也无法及时处理读写事件，因此延迟会成片上升。</p><h2 id="先观察，不先调线程数"><a href="#先观察，不先调线程数" class="headerlink" title="先观察，不先调线程数"></a>先观察，不先调线程数</h2><p>开发环境可以把检查阈值调低，让问题尽早暴露：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">VertxOptions</span> <span class="variable">options</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">VertxOptions</span>()</span><br><span class="line">    .setBlockedThreadCheckInterval(<span class="number">500</span>)</span><br><span class="line">    .setMaxEventLoopExecuteTime(<span class="number">200</span>);</span><br><span class="line"><span class="type">Vertx</span> <span class="variable">vertx</span> <span class="operator">=</span> Vertx.vertx(options);</span><br></pre></td></tr></table></figure></div><p>这些值用于诊断，不应该不经测量照搬到生产。出现警告后先看堆栈，确认阻塞位置；增加 Event Loop 线程数只能延后拥塞，不能修复同步调用。</p><h2 id="无法替换的阻塞-API"><a href="#无法替换的阻塞-API" class="headerlink" title="无法替换的阻塞 API"></a>无法替换的阻塞 API</h2><p>旧 SDK 暂时没有异步版本时，可以把小段阻塞操作移到 Worker Pool：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">vertx.executeBlocking(() -&gt; legacyClient.reserve(sku, quantity))</span><br><span class="line">    .onSuccess(result -&gt; message.reply(result))</span><br><span class="line">    .onFailure(error -&gt; message.fail(<span class="number">503</span>, error.getMessage()));</span><br></pre></td></tr></table></figure></div><p>Worker Pool 也有上限。必须同时设置下游超时、监控队列等待并限制并发；否则只是把 Event Loop 堵塞改成 Worker Pool 排队。</p><h2 id="用-JFR-保留证据"><a href="#用-JFR-保留证据" class="headerlink" title="用 JFR 保留证据"></a>用 JFR 保留证据</h2><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">jcmd &lt;pid&gt; JFR.start name=vertx settings=profile duration=60s filename=vertx.jfr</span><br></pre></td></tr></table></figure></div><p>录制期间执行稳定、可重复的请求，再用 JDK Mission Control 查看线程状态、Socket I&#x2F;O、锁等待和分配热点。文章不会给出“JFR 固定小于 2%”之类脱离工作负载的承诺；开销应在自己的环境中测量。</p><p>本章也不声称当前示例能达到某个 QPS。没有独立压测机、请求模型、延迟分位和错误率的单个吞吐数字没有参考意义。</p><h2 id="HTTP-层只承担协议职责"><a href="#HTTP-层只承担协议职责" class="headerlink" title="HTTP 层只承担协议职责"></a>HTTP 层只承担协议职责</h2><p>一个教学项目无法完整覆盖网关所需的协议安全、连接池、限流、动态配置和可观测性，因此当前项目只实现订单服务真正需要的 HTTP 接入层：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">Router</span> <span class="variable">router</span> <span class="operator">=</span> Router.router(vertx);</span><br><span class="line">router.route().handler(BodyHandler.create());</span><br><span class="line">router.get(<span class="string">&quot;/health&quot;</span>).handler(<span class="built_in">this</span>::health);</span><br><span class="line">router.post(<span class="string">&quot;/orders&quot;</span>).handler(<span class="built_in">this</span>::createOrder);</span><br><span class="line">router.get(<span class="string">&quot;/orders/:id&quot;</span>).handler(<span class="built_in">this</span>::findOrder);</span><br></pre></td></tr></table></figure></div><p><code>HttpVerticle</code> 把 JSON 转成 Event Bus 消息，再将回复转换为 HTTP 响应。订单规则仍留在 <code>OrderVerticle</code>，以后增加其他协议入口时不需要复制业务校验。</p><p>JSON 解析失败应立即返回 400：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">try</span> &#123;</span><br><span class="line">  request = ctx.body().asJsonObject();</span><br><span class="line">&#125; <span class="keyword">catch</span> (RuntimeException error) &#123;</span><br><span class="line">  respond(ctx, <span class="number">400</span>, <span class="keyword">new</span> <span class="title class_">JsonObject</span>().put(<span class="string">&quot;error&quot;</span>, <span class="string">&quot;invalid JSON body&quot;</span>));</span><br><span class="line">  <span class="keyword">return</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>创建成功使用 201，库存不足使用 409，内部依赖不可用使用 503。状态码会影响客户端究竟修正参数、提示用户还是稍后重试，不能把所有错误统一包装成 200 或 500。</p><p>公开部署前还需要请求体大小限制、Schema、认证授权、请求 ID、结构化日志、幂等键、TLS 和优雅停机。本文称它为“HTTP 边界”，而不是无法由代码支撑的“生产网关”。</p><h2 id="用-VertxTestContext-测真实异步链路"><a href="#用-VertxTestContext-测真实异步链路" class="headerlink" title="用 VertxTestContext 测真实异步链路"></a>用 VertxTestContext 测真实异步链路</h2><p>每个测试先部署实际消费者：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@BeforeEach</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">deploy</span><span class="params">(Vertx vertx, VertxTestContext context)</span> &#123;</span><br><span class="line">  vertx.deployVerticle(<span class="keyword">new</span> <span class="title class_">InventoryVerticle</span>())</span><br><span class="line">      .compose(ignored -&gt; vertx.deployVerticle(<span class="keyword">new</span> <span class="title class_">OrderVerticle</span>()))</span><br><span class="line">      .onComplete(context.succeedingThenComplete());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>只有两个 Verticle 都启动成功后才进入测试。忘记完成 <code>VertxTestContext</code> 会等待到超时，过早完成又可能让尚未运行的断言被跳过。</p><p>创建和查询通过同一条 Future 链验证：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">vertx.eventBus().&lt;JsonObject&gt;request(OrderVerticle.CREATE_ADDRESS, request)</span><br><span class="line">    .compose(created -&gt; vertx.eventBus().&lt;JsonObject&gt;request(</span><br><span class="line">        OrderVerticle.FIND_ADDRESS,</span><br><span class="line">        created.body().getString(<span class="string">&quot;id&quot;</span>)))</span><br><span class="line">    .onComplete(context.succeeding(found -&gt; context.verify(() -&gt; &#123;</span><br><span class="line">      assertThat(found.body().getString(<span class="string">&quot;sku&quot;</span>)).isEqualTo(<span class="string">&quot;book&quot;</span>);</span><br><span class="line">      context.completeNow();</span><br><span class="line">    &#125;)));</span><br></pre></td></tr></table></figure></div><p><code>context.verify</code> 会把异步回调中的断言异常交还 JUnit。第二条测试通过 <code>context.failing</code> 验证库存不足必须失败，而不是仅检查成功路径。</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">./gradlew <span class="built_in">test</span></span><br></pre></td></tr></table></figure></div><p>这些测试证明当前消息链在已覆盖场景下工作，但还不能替代 HTTP 集成测试、并发库存测试、超时测试和线上监控。</p>]]>
    </content>
    <id>https://qianwj.github.io/2026/03/04/vertx-series-3/</id>
    <link href="https://qianwj.github.io/2026/03/04/vertx-series-3/"/>
    <published>2026-03-04T10:00:00.000Z</published>
    <summary>从 Event Loop 阻塞诊断出发，完善订单服务的 HTTP 错误边界，并使用 VertxTestContext 验证完整异步链路。</summary>
    <title>Vert.x 实战（三）：Event Loop、HTTP 边界与异步测试</title>
    <updated>2026-04-01T05:20:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="后端工程" scheme="https://qianwj.github.io/categories/%E5%90%8E%E7%AB%AF%E5%B7%A5%E7%A8%8B/"/>
    <category term="Vert.x" scheme="https://qianwj.github.io/tags/Vert-x/"/>
    <category term="Event Bus" scheme="https://qianwj.github.io/tags/Event-Bus/"/>
    <category term="Future" scheme="https://qianwj.github.io/tags/Future/"/>
    <content>
      <![CDATA[<p>第一篇完成了工程和 Verticle 生命周期。本篇把 HTTP、订单与库存连接成一条可以成功、拒绝或超时的异步链路。</p><h2 id="用-request-reply-表达订单命令"><a href="#用-request-reply-表达订单命令" class="headerlink" title="用 request&#x2F;reply 表达订单命令"></a>用 request&#x2F;reply 表达订单命令</h2><p>创建订单必须得到明确结果，因此使用 Event Bus 的 <code>request/reply</code>，而不是不等待结果的 <code>send</code> 或广播用的 <code>publish</code>：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">String</span> <span class="variable">CREATE_ADDRESS</span> <span class="operator">=</span> <span class="string">&quot;orders.create&quot;</span>;</span><br><span class="line"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">String</span> <span class="variable">FIND_ADDRESS</span> <span class="operator">=</span> <span class="string">&quot;orders.find&quot;</span>;</span><br></pre></td></tr></table></figure></div><p>订单消费者先校验输入：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">vertx.eventBus().&lt;JsonObject&gt;consumer(CREATE_ADDRESS, message -&gt; &#123;</span><br><span class="line">  <span class="type">JsonObject</span> <span class="variable">request</span> <span class="operator">=</span> message.body();</span><br><span class="line">  <span class="type">String</span> <span class="variable">validationError</span> <span class="operator">=</span> validate(request);</span><br><span class="line">  <span class="keyword">if</span> (validationError != <span class="literal">null</span>) &#123;</span><br><span class="line">    message.fail(<span class="number">400</span>, validationError);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="comment">// 预留库存并创建订单</span></span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure></div><p>这里使用 <code>JsonObject</code> 是为了让第一版代码容易观察，不表示大型项目应该到处传无类型 JSON。字段增多后，可以使用领域对象和 MessageCodec，也可以保留 JSON 协议并增加 Schema 校验。</p><h2 id="把业务失败转换为-HTTP-状态"><a href="#把业务失败转换为-HTTP-状态" class="headerlink" title="把业务失败转换为 HTTP 状态"></a>把业务失败转换为 HTTP 状态</h2><p>消费者的 <code>message.fail(400, ...)</code> 在请求侧表现为 <code>ReplyException</code>。HTTP 层读取 failure code，而不是把所有失败都改成 500：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">handleFailure</span><span class="params">(RoutingContext ctx, Throwable error)</span> &#123;</span><br><span class="line">  <span class="type">int</span> <span class="variable">status</span> <span class="operator">=</span> error <span class="keyword">instanceof</span> ReplyException reply</span><br><span class="line">      ? reply.failureCode()</span><br><span class="line">      : <span class="number">500</span>;</span><br><span class="line">  respond(ctx, status, <span class="keyword">new</span> <span class="title class_">JsonObject</span>().put(<span class="string">&quot;error&quot;</span>, error.getMessage()));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>业务校验失败返回 400，库存冲突返回 409，内部依赖不可用返回 503。状态码决定客户端是修正请求、提示用户还是稍后重试。</p><h2 id="在创建订单前预留库存"><a href="#在创建订单前预留库存" class="headerlink" title="在创建订单前预留库存"></a>在创建订单前预留库存</h2><p>部署顺序先满足内部依赖，再开放 HTTP：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">vertx.deployVerticle(<span class="keyword">new</span> <span class="title class_">InventoryVerticle</span>())</span><br><span class="line">    .compose(ignored -&gt; vertx.deployVerticle(<span class="keyword">new</span> <span class="title class_">OrderVerticle</span>()))</span><br><span class="line">    .compose(ignored -&gt; vertx.deployVerticle(<span class="keyword">new</span> <span class="title class_">HttpVerticle</span>()));</span><br></pre></td></tr></table></figure></div><p><code>compose</code> 表示下一步依赖上一步成功。任何部署失败都会终止链路，不会开放一个缺少消费者的入口。</p><p>库存请求设置一秒等待边界：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">DeliveryOptions</span> <span class="variable">options</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">DeliveryOptions</span>().setSendTimeout(<span class="number">1_000</span>);</span><br><span class="line">vertx.eventBus().&lt;JsonObject&gt;request(</span><br><span class="line">        InventoryVerticle.RESERVE_ADDRESS, request, options)</span><br><span class="line">    .onSuccess(reservation -&gt; &#123;</span><br><span class="line">      <span class="type">String</span> <span class="variable">id</span> <span class="operator">=</span> UUID.randomUUID().toString();</span><br><span class="line">      <span class="type">JsonObject</span> <span class="variable">order</span> <span class="operator">=</span> request.copy()</span><br><span class="line">          .put(<span class="string">&quot;id&quot;</span>, id)</span><br><span class="line">          .put(<span class="string">&quot;status&quot;</span>, <span class="string">&quot;CREATED&quot;</span>)</span><br><span class="line">          .put(<span class="string">&quot;reservation&quot;</span>, reservation.body());</span><br><span class="line">      orders.put(id, order);</span><br><span class="line">      message.reply(order);</span><br><span class="line">    &#125;)</span><br><span class="line">    .onFailure(error -&gt;</span><br><span class="line">        message.fail(failureCode(error), error.getMessage()));</span><br></pre></td></tr></table></figure></div><p>一秒只是教学值。生产参数应来自请求总预算、下游延迟分位和业务容忍度。没有超时的异步请求并不会更可靠，只会让等待变得不明显。</p><h2 id="Future-不提供分布式事务"><a href="#Future-不提供分布式事务" class="headerlink" title="Future 不提供分布式事务"></a>Future 不提供分布式事务</h2><p>当前顺序是先扣内存库存，再保存内存订单。如果库存成功后进程退出，会出现库存已扣而订单未保存。Future 只编排异步结果，不提供跨资源原子性。</p><p>真实系统需要按约束选择一致性方案：</p><ul><li>同一数据库内使用本地事务；</li><li>订单落库后通过 Outbox 发布事件；</li><li>库存预留设置过期时间，以确认或取消完成 Saga；</li><li>请求携带幂等键，避免超时重试重复扣减。</li></ul><h2 id="有状态-Verticle-不能盲目扩容"><a href="#有状态-Verticle-不能盲目扩容" class="headerlink" title="有状态 Verticle 不能盲目扩容"></a>有状态 Verticle 不能盲目扩容</h2><p><code>InventoryVerticle</code> 使用普通 Map 保存库存。同一个消费者的 Handler 在关联 Context 上调度，读取与扣减不会被该消费者另一条消息从中间插入。</p><p>但部署四个实例会产生四份 Map。Event Bus 会轮询消费者，实例之间却无法看到彼此的扣减，总库存会被错误放大。真实库存需要数据库条件更新、按 SKU 一致性分片或其他明确的一致性策略。</p><p>设计有状态 Verticle 时需要回答：状态由谁拥有、哪些消息可以修改它、实例数量变化是否改变语义。当前示例明确存在重启丢失、无法多实例、没有预留过期和无法审计等限制。</p><h2 id="运行与验证"><a href="#运行与验证" class="headerlink" title="运行与验证"></a>运行与验证</h2><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">curl -i -X POST http://localhost:8080/orders \</span><br><span class="line">  -H <span class="string">&#x27;content-type: application/json&#x27;</span> \</span><br><span class="line">  -d <span class="string">&#x27;&#123;&quot;customerId&quot;:&quot;customer-1&quot;,&quot;sku&quot;:&quot;book&quot;,&quot;quantity&quot;:2&#125;&#x27;</span></span><br></pre></td></tr></table></figure></div><p>正常请求返回 201、订单 ID、<code>CREATED</code> 和剩余库存。请求 4 个初始库存只有 3 个的 <code>keyboard</code>，返回 409。自动化测试同时覆盖创建并查询订单以及库存不足，避免只验证成功路径。</p><p>这一版仍没有数据库、幂等键和跨资源补偿，不能被称为生产订单系统；它的价值是让消息协议、失败传播和状态边界都可以实际运行和测试。</p>]]>
    </content>
    <id>https://qianwj.github.io/2025/12/06/vertx-series-2/</id>
    <link href="https://qianwj.github.io/2025/12/06/vertx-series-2/"/>
    <published>2025-12-06T02:23:14.000Z</published>
    <summary>在可运行订单项目中实现 Event Bus request/reply、库存预留、Future 超时和状态隔离，并分析异步编排与分布式事务的边界。</summary>
    <title>Vert.x 实战（二）：Event Bus、Future 与订单状态编排</title>
    <updated>2026-01-04T04:30:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="后端工程" scheme="https://qianwj.github.io/categories/%E5%90%8E%E7%AB%AF%E5%B7%A5%E7%A8%8B/"/>
    <category term="Java" scheme="https://qianwj.github.io/tags/Java/"/>
    <category term="Vert.x" scheme="https://qianwj.github.io/tags/Vert-x/"/>
    <category term="Event Loop" scheme="https://qianwj.github.io/tags/Event-Loop/"/>
    <content>
      <![CDATA[<p>这套教程最初的问题是：怎样用 Java 写一个需要同时等待库存、支付等下游结果的订单接入层？请求处理期间大部分时间不在计算，而在等待网络 I&#x2F;O。如果为每个请求长期占用一个平台线程，线程数量、上下文切换和内存都会逐渐成为成本。</p><p>本系列用一个简化的订单服务验证 Vert.x 的编程模型。项目固定使用 <strong>JDK 21、Vert.x 5.0.0 和 Gradle 9.2.1</strong>。它是可运行的教学项目，不是可以直接上线的电商系统；库存、支付和持久化会被简化，但超时、失败传播和测试不能省略。</p><h2 id="先判断-Vert-x-是否适合"><a href="#先判断-Vert-x-是否适合" class="headerlink" title="先判断 Vert.x 是否适合"></a>先判断 Vert.x 是否适合</h2><p>Vert.x 适合以下工作负载：</p><ul><li>同时维护较多 HTTP、WebSocket 或消息连接；</li><li>一次请求需要并行调用多个下游服务；</li><li>业务可以使用异步 API，团队愿意明确处理失败和超时；</li><li>希望保留 Java 生态，又不想把每个连接绑定到一个平台线程。</li></ul><p>它不是所有 Java 服务的默认答案。大量同步 JDBC、复杂报表计算或强依赖线程本地变量的旧系统，直接迁移只会把阻塞藏进 Event Loop。普通 CRUD 服务如果现有 Spring MVC 已经稳定，也没有必要仅为追求“响应式”而重写。</p><h2 id="这个项目要解决什么"><a href="#这个项目要解决什么" class="headerlink" title="这个项目要解决什么"></a>这个项目要解决什么</h2><p>第一版接口只有三个动作：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">POST /orders       创建订单</span><br><span class="line">GET  /orders/:id   查询订单</span><br><span class="line">GET  /health       存活检查</span><br></pre></td></tr></table></figure></div><p>HTTP 层不直接保存订单，而是通过 Event Bus 把命令交给 <code>OrderVerticle</code>。这种拆分让协议适配与业务状态分开，后续可以替换 HTTP、增加 WebSocket 通知，或者把订单消费者部署为多个实例。</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">HTTP request -&gt; HttpVerticle -&gt; Event Bus -&gt; OrderVerticle -&gt; reply</span><br></pre></td></tr></table></figure></div><p>当前版本使用内存 Map，因此重启后数据会丢失。这是刻意保留的限制：前几章只观察 Verticle、Context 和 Event Bus，不用数据库连接池掩盖核心行为。</p><h2 id="Event-Loop-带来的约束"><a href="#Event-Loop-带来的约束" class="headerlink" title="Event Loop 带来的约束"></a>Event Loop 带来的约束</h2><p>Vert.x 并不会让代码自动变快。它的关键约束是：Event Loop 上的 Handler 必须尽快返回。如果在 Handler 中执行慢 SQL、<code>Thread.sleep</code>、同步 HTTP 请求或大段 CPU 计算，同一个 Context 上后续事件都会被拖住。</p><p>因此系列中会坚持三条规则：</p><ol><li>网络和存储调用使用异步客户端；</li><li>每个异步步骤显式处理失败和超时；</li><li>性能结论必须附测试环境、命令和原始结果，没有实测就不写数字。</li></ol><h2 id="建立可复现的工程"><a href="#建立可复现的工程" class="headerlink" title="建立可复现的工程"></a>建立可复现的工程</h2><p>教程代码最常见的问题不是业务逻辑，而是读者无法还原作者的环境。本项目固定以下基线：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">源码目标版本：JDK 21</span><br><span class="line">Vert.x：5.0.0</span><br><span class="line">Gradle Wrapper：9.2.1</span><br><span class="line">测试：JUnit 5 + Vert.x JUnit 5 + AssertJ</span><br></pre></td></tr></table></figure></div><p>构建文件使用 Vert.x BOM，避免 Core、Web、Web Client 等模块版本不一致：</p><div class="code-container" data-rel="Kotlin"><figure class="iseeu highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">java &#123;</span><br><span class="line">    toolchain &#123;</span><br><span class="line">        languageVersion = JavaLanguageVersion.of(<span class="number">21</span>)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">dependencies &#123;</span><br><span class="line">    implementation(platform(<span class="string">&quot;io.vertx:vertx-stack-depchain:5.0.0&quot;</span>))</span><br><span class="line">    implementation(<span class="string">&quot;io.vertx:vertx-core&quot;</span>)</span><br><span class="line">    implementation(<span class="string">&quot;io.vertx:vertx-web&quot;</span>)</span><br><span class="line"></span><br><span class="line">    testImplementation(<span class="string">&quot;io.vertx:vertx-junit5&quot;</span>)</span><br><span class="line">    testImplementation(<span class="string">&quot;org.junit.jupiter:junit-jupiter:5.11.4&quot;</span>)</span><br><span class="line">    testRuntimeOnly(<span class="string">&quot;org.junit.platform:junit-platform-launcher:1.11.4&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>我的机器运行 GraalVM JDK 25.0.3。最初使用 Gradle 8.14.3 时，Wrapper 在启动阶段失败；升级到 Gradle 9.2.1 后恢复正常。源码仍由 Toolchain 以 Java 21 为目标。构建工具使用的 JVM 与源码目标版本是两个不同问题。</p><p>项目目录保持简单：</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">vertx-serials/</span><br><span class="line">├── build.gradle.kts</span><br><span class="line">├── settings.gradle.kts</span><br><span class="line">├── gradlew</span><br><span class="line">└── src/</span><br><span class="line">    ├── main/java/dev/elvis/orders/</span><br><span class="line">    └── test/java/dev/elvis/orders/</span><br></pre></td></tr></table></figure></div><p>先执行测试，再启动服务：</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">./gradlew <span class="built_in">test</span></span><br><span class="line">./gradlew run</span><br><span class="line">curl -i http://localhost:8080/health</span><br></pre></td></tr></table></figure></div><h2 id="用两个-Verticle-划分职责"><a href="#用两个-Verticle-划分职责" class="headerlink" title="用两个 Verticle 划分职责"></a>用两个 Verticle 划分职责</h2><p><code>HttpVerticle</code> 负责 HTTP 协议，<code>OrderVerticle</code> 负责订单命令与状态。入口类按依赖顺序部署：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">Vertx</span> <span class="variable">vertx</span> <span class="operator">=</span> Vertx.vertx();</span><br><span class="line">vertx.deployVerticle(<span class="keyword">new</span> <span class="title class_">OrderVerticle</span>())</span><br><span class="line">    .compose(ignored -&gt; vertx.deployVerticle(<span class="keyword">new</span> <span class="title class_">HttpVerticle</span>()))</span><br><span class="line">    .onFailure(error -&gt; &#123;</span><br><span class="line">      error.printStackTrace();</span><br><span class="line">      vertx.close();</span><br><span class="line">    &#125;);</span><br></pre></td></tr></table></figure></div><p>先注册订单消费者，再开放 HTTP 端口。若两个部署并行执行，服务可能已经接收请求，但 Event Bus 消费者尚未就绪。</p><p>Vert.x 5.0.0 的 <code>AbstractVerticle</code> 异步生命周期通过 <code>Promise&lt;Void&gt;</code> 表达：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">start</span><span class="params">(Promise&lt;Void&gt; startPromise)</span> &#123;</span><br><span class="line">  vertx.createHttpServer()</span><br><span class="line">      .requestHandler(router)</span><br><span class="line">      .listen(config().getInteger(<span class="string">&quot;http.port&quot;</span>, <span class="number">8080</span>))</span><br><span class="line">      .onSuccess(server -&gt; &#123;</span><br><span class="line">        <span class="built_in">this</span>.server = server;</span><br><span class="line">        startPromise.complete();</span><br><span class="line">      &#125;)</span><br><span class="line">      .onFailure(startPromise::fail);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>只有端口监听成功才完成启动 Promise。端口占用时部署会失败，应用不会打印错误的“启动成功”。停止时也要等待 Server 关闭：</p><div class="code-container" data-rel="Java"><figure class="iseeu highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">stop</span><span class="params">(Promise&lt;Void&gt; stopPromise)</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (server == <span class="literal">null</span>) &#123;</span><br><span class="line">    stopPromise.complete();</span><br><span class="line">  &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    server.close().onComplete(stopPromise);</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>一个普通 Verticle 的 Handler 默认在关联 Context 上调度。它降低了同一实例内状态并发访问的复杂度，但不意味着共享对象天然线程安全，也不意味着可以随意增加实例数。当前订单 Map 只用于教学；真实服务应使用持久化存储和数据库唯一约束。</p><p>到这里我们得到一个能构建、能测试、能正确报告启动失败的最小服务。下一篇会加入 Event Bus、库存预留和 Future 编排。</p>]]>
    </content>
    <id>https://qianwj.github.io/2025/11/16/vertx-series-1/</id>
    <link href="https://qianwj.github.io/2025/11/16/vertx-series-1/"/>
    <published>2025-11-16T09:40:05.000Z</published>
    <summary>从订单接入层的技术选型出发，搭建可复现的 JDK 21 与 Vert.x 5 项目，并通过两个 Verticle 理解生命周期、Context 和启动顺序。</summary>
    <title>Vert.x 实战（一）：从技术选型到可运行的订单服务</title>
    <updated>2026-01-01T04:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Elvis Qian</name>
    </author>
    <category term="PostgreSQL" scheme="https://qianwj.github.io/tags/PostgreSQL/"/>
    <category term="Partition" scheme="https://qianwj.github.io/tags/Partition/"/>
    <category term="Database" scheme="https://qianwj.github.io/tags/Database/"/>
    <content>
      <![CDATA[<h1 id="一、背景"><a href="#一、背景" class="headerlink" title="一、背景"></a>一、背景</h1><p>需要对AI调用进行记录，并对失败过高的情况进行告警。但是目前每天的AI调用量在百万级，对应的数据量也在百万级，单表存储显然是不可靠的。单表过大带来的问题包括：</p><ul><li>查询、插入、更新性能下降；</li><li>索引膨胀，维护困难；</li><li>清理历史数据成本高；</li><li>统计与聚合操作耗时。<br>在这种情况下，<strong>表分区（Partitioning）</strong> 是一种非常有效的优化手段。</li></ul><h1 id="二、什么是分区表"><a href="#二、什么是分区表" class="headerlink" title="二、什么是分区表"></a>二、什么是分区表</h1><p>分区表（Partitioned Table）是一个 <strong>逻辑表</strong>，数据按某种规则（范围、列表、哈希）分布在多个物理子表中（称为分区 &#x2F; Partition）。<br>应用程序仍然对“主表”执行查询或写入，数据库内核自动将操作路由到对应的分区。<br>🔹 举个例子<br>假设我们有一个 AI 模型调用日志表 <code>ai_call_record</code>，每天产生数百万条记录。<br>我们可以根据时间字段 <code>started_at</code> 按天进行分区：</p><div class="code-container" data-rel="Postgresql"><figure class="iseeu highlight postgresql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE</span> <span class="keyword">TABLE</span> ai_call_record (</span><br><span class="line">  id <span class="type">BIGSERIAL</span>,</span><br><span class="line">  call_id <span class="type">UUID</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  channel <span class="type">TEXT</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  model_name <span class="type">TEXT</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  status <span class="type">TEXT</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  started_at <span class="type">TIMESTAMPTZ</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  <span class="keyword">PRIMARY KEY</span> (id, started_at)</span><br><span class="line">) <span class="keyword">PARTITION BY RANGE</span> (started_at);</span><br></pre></td></tr></table></figure></div><p>PostgreSQL 会根据 <code>started_at</code> 的值自动把数据路由到对应的子表：</p><div class="code-container" data-rel="Postgresql"><figure class="iseeu highlight postgresql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE</span> <span class="keyword">TABLE</span> ai_call_record_20251113</span><br><span class="line">    <span class="keyword">PARTITION</span> <span class="keyword">OF</span> ai_call_record</span><br><span class="line">    <span class="keyword">VALUES</span> <span class="keyword">FROM</span> (<span class="string">&#x27;2025-11-13&#x27;</span>) <span class="keyword">TO</span> (<span class="string">&#x27;2025-11-14&#x27;</span>);</span><br></pre></td></tr></table></figure></div><p>当插入 <code>2025-11-13 10:00:00</code> 的数据时，会自动进入该分区表。</p><hr><h1 id="三、PostgreSQL-分区类型"><a href="#三、PostgreSQL-分区类型" class="headerlink" title="三、PostgreSQL 分区类型"></a>三、PostgreSQL 分区类型</h1><p>PostgreSQL 从 v10 开始原生支持表分区（之前依靠继承 + 触发器实现）。<br>常见的分区类型如下：</p><table><thead><tr><th>类型</th><th>划分方式</th><th>适用场景</th><th>示例</th></tr></thead><tbody><tr><td>范围分区（RANGE）</td><td>按连续区间划分</td><td>日志、订单等时间序列数据</td><td>按天或按月分区</td></tr><tr><td>列表分区（LIST）</td><td>按离散值划分</td><td>地区、租户、业务类型</td><td>按国家或渠道分区</td></tr><tr><td>哈希分区（HASH）</td><td>按哈希余数均匀划分</td><td>没有自然范围、需要分散写入的数据</td><td>按用户 ID 分为 8 个分区</td></tr></tbody></table><p>范围分区最适合本文的调用日志场景，因为查询和清理通常都以时间为边界。列表分区要求提前明确值集合；哈希分区能让数据分布更均匀，但不适合按时间快速删除历史数据。</p><hr><h1 id="四、PostgreSQL-分区表的优势"><a href="#四、PostgreSQL-分区表的优势" class="headerlink" title="四、PostgreSQL 分区表的优势"></a>四、PostgreSQL 分区表的优势</h1><p>✅ 1. 自动路由与查询优化<br>当你执行：<br><code>SELECT * FROM ai_call_record WHERE started_at &gt;= now() - interval &#39;1 day&#39;;</code><br>PostgreSQL 的 <strong>分区裁剪（Partition Pruning）</strong> 能自动只扫描符合条件的分区，大幅减少 I&#x2F;O。</p><hr><p>✅ 2. 高效的归档与清理<br>删除历史数据非常简单：<br><code>DROP TABLE ai_call_record_20251001;</code><br>比传统 DELETE 快几个数量级。</p><hr><p>✅ 3. 独立索引与并行查询<br>每个分区都有自己的索引，查询时只访问相关索引；<br> 分区之间还能并行扫描，提高吞吐。</p><hr><p>✅ 4. 与原表操作几乎一致<br><code>INSERT/UPDATE/SELECT/DELETE</code> 都不需要改应用逻辑，完全透明。<br> 只是 <code>DDL</code> 管理上稍微复杂（需要自动建分区任务，如 pg_cron）。</p><hr><h1 id="五、MySQL-的分区表机制"><a href="#五、MySQL-的分区表机制" class="headerlink" title="五、MySQL 的分区表机制"></a>五、MySQL 的分区表机制</h1><p>MySQL 也支持分区（从 5.1 起），主要由 InnoDB 实现。<br> 两者在设计思想相似，但实现方式有明显差异：</p><table><thead><tr><th>场景</th><th>PostgreSQL 分区表</th><th>MySQL 分区表</th></tr></thead><tbody><tr><td>单分区写入</td><td>快，近似普通表</td><td>类似</td></tr><tr><td>分区裁剪查询</td><td>精准裁剪，性能稳定</td><td>条件匹配不严格会全表扫描</td></tr><tr><td>删除历史分区</td><td>DROP TABLE 秒级完成</td><td>ALTER TABLE DROP PARTITION 也快，但锁表风险高</td></tr><tr><td>索引管理</td><td>独立索引，灵活</td><td>全局索引支持有限</td></tr><tr><td>自动化管理</td><td>可借助 pg_cron 或 pg_partman</td><td>无官方自动分区管理工具</td></tr><tr><td>并行查询</td><td>✅ 支持多分区并行</td><td>❌ 主要单线程</td></tr></tbody></table><hr><h1 id="六、实战案例：AI-调用日志按天分区"><a href="#六、实战案例：AI-调用日志按天分区" class="headerlink" title="六、实战案例：AI 调用日志按天分区"></a>六、实战案例：AI 调用日志按天分区</h1><p>1️⃣ 分区表定义</p><div class="code-container" data-rel="Postgresql"><figure class="iseeu highlight postgresql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE</span> <span class="keyword">TABLE</span> ai_call_record (</span><br><span class="line">  id <span class="type">BIGSERIAL</span>,</span><br><span class="line">  call_id <span class="type">UUID</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  channel <span class="type">TEXT</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  model_name <span class="type">TEXT</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  status <span class="type">TEXT</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  duration_ms <span class="type">BIGINT</span>,</span><br><span class="line">  started_at <span class="type">TIMESTAMPTZ</span> <span class="keyword">NOT</span> <span class="keyword">NULL</span>,</span><br><span class="line">  finished_at <span class="type">TIMESTAMPTZ</span>,</span><br><span class="line">  <span class="keyword">PRIMARY KEY</span> (id, started_at)</span><br><span class="line">) <span class="keyword">PARTITION BY RANGE</span> (started_at);</span><br></pre></td></tr></table></figure></div><p>2️⃣ 自动分区创建函数（每日）</p><div class="code-container" data-rel="Postgresql"><figure class="iseeu highlight postgresql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE</span> <span class="keyword">OR REPLACE</span> <span class="keyword">FUNCTION</span> create_ai_call_partitions()</span><br><span class="line"><span class="keyword">RETURNS</span> <span class="type">void</span> <span class="keyword">LANGUAGE</span> plpgsql <span class="keyword">AS</span> $$<span class="language-pgsql"></span></span><br><span class="line"><span class="language-pgsql"><span class="keyword">DECLARE</span></span></span><br><span class="line"><span class="language-pgsql">  base_date <span class="type">date</span> := (now() <span class="keyword">AT TIME ZONE</span> <span class="string">&#x27;UTC&#x27;</span>)::<span class="type">date</span>;</span></span><br><span class="line"><span class="language-pgsql">  i <span class="type">int</span>;</span></span><br><span class="line"><span class="language-pgsql"><span class="keyword">BEGIN</span></span></span><br><span class="line"><span class="language-pgsql">  <span class="keyword">FOR</span> i <span class="keyword">IN</span> <span class="number">0.</span><span class="number">.1</span> <span class="keyword">LOOP</span></span></span><br><span class="line"><span class="language-pgsql">    <span class="keyword">EXECUTE</span> format(</span></span><br><span class="line"><span class="language-pgsql">      <span class="string">&#x27;CREATE TABLE IF NOT EXISTS ai_call_record_%s</span></span></span><br><span class="line"><span class="string"><span class="language-pgsql">         PARTITION OF ai_call_record</span></span></span><br><span class="line"><span class="string"><span class="language-pgsql">         FOR VALUES FROM (%L) TO (%L);&#x27;</span>,</span></span><br><span class="line"><span class="language-pgsql">       to_char(base_date + i, <span class="string">&#x27;YYYYMMDD&#x27;</span>),</span></span><br><span class="line"><span class="language-pgsql">       to_char(base_date + i, <span class="string">&#x27;YYYY-MM-DD 00:00:00+00&#x27;</span>),</span></span><br><span class="line"><span class="language-pgsql">       to_char(base_date + i + <span class="number">1</span>, <span class="string">&#x27;YYYY-MM-DD 00:00:00+00&#x27;</span>)</span></span><br><span class="line"><span class="language-pgsql">    );</span></span><br><span class="line"><span class="language-pgsql">  <span class="keyword">END</span> <span class="keyword">LOOP</span>;</span></span><br><span class="line"><span class="language-pgsql"><span class="keyword">END</span>;</span></span><br><span class="line"><span class="language-pgsql">$$</span>;</span><br></pre></td></tr></table></figure></div><p>3️⃣ 删除过期分区<br><code>DROP TABLE ai_call_record_20251001;</code></p><blockquote><p>🚀 清理历史数据只需秒级，性能极高。</p></blockquote><hr><h1 id="七、性能对比：PostgreSQL-vs-MySQL"><a href="#七、性能对比：PostgreSQL-vs-MySQL" class="headerlink" title="七、性能对比：PostgreSQL vs MySQL"></a>七、性能对比：PostgreSQL vs MySQL</h1><table><thead><tr><th>场景</th><th>PostgreSQL 分区表</th><th>MySQL 分区表</th></tr></thead><tbody><tr><td>单分区写入</td><td>快，近似普通表</td><td>类似</td></tr><tr><td>分区裁剪查询</td><td>精准裁剪，性能稳定</td><td>条件匹配不严格会全表扫描</td></tr><tr><td>删除历史分区</td><td>DROP TABLE 秒级完成</td><td>ALTER TABLE DROP PARTITION 也快，但锁表风险高</td></tr><tr><td>索引管理</td><td>独立索引，灵活</td><td>全局索引支持有限</td></tr><tr><td>自动化管理</td><td>可借助 pg_cron 或 pg_partman</td><td>无官方自动分区管理工具</td></tr><tr><td>并行查询</td><td>✅ 支持多分区并行</td><td>❌ 主要单线程</td></tr></tbody></table><p><strong>结论：</strong></p><blockquote><ul><li>PostgreSQL 的分区表更接近“多表聚合”的设计，灵活、易扩展。</li><li>MySQL 的分区是“逻辑分片”，不如 PostgreSQL 强大，但轻量。</li><li>如果你有时间序列、日志型、归档型数据，PostgreSQL 分区是更优方案。</li></ul></blockquote><hr><h1 id="八、最佳实践建议"><a href="#八、最佳实践建议" class="headerlink" title="八、最佳实践建议"></a>八、最佳实践建议</h1><ol><li>选择合适的分区键<br> 常见为时间戳字段（如 created_at、started_at）。</li><li>提前创建分区<br> 使用 <code>pg_cron</code> 或 <code>pg_partman</code> 定期自动创建未来分区。</li><li>清理过期分区<br> 定期删除旧分区，避免表膨胀。</li><li>索引策略<br> 仅对查询必要的字段建索引，避免每个分区维护大量冗余索引。</li><li>监控与维护<br> 使用 <code>pg_stat_user_tables</code>、<code>pg_inherits</code> 监控分区数量和大小。</li></ol><hr><h1 id="九、总结"><a href="#九、总结" class="headerlink" title="九、总结"></a>九、总结</h1><table><thead><tr><th>对比项</th><th>PostgreSQL</th><th>MySQL</th></tr></thead><tbody><tr><td>分区灵活性</td><td>✅ 高</td><td>⚠️ 一般</td></tr><tr><td>自动化支持</td><td>✅ pg_cron &#x2F; pg_partman</td><td>❌ 需手工</td></tr><tr><td>大数据量表现</td><td>✅ 稳定</td><td>⚠️ 易退化</td></tr><tr><td>清理历史数据</td><td>✅ 秒级 DROP</td><td>✅ 快，但锁表</td></tr><tr><td>查询优化</td><td>✅ 分区裁剪智能</td><td>⚠️ 条件匹配要求高</td></tr></tbody></table><blockquote><p>✅ 一句话总结：<br>PostgreSQL 的分区表是生产级别的“时间序列 + 大数据归档”解决方案，<br>而 MySQL 的分区功能更像是一种轻量级的逻辑分割。</p></blockquote><hr><blockquote><p>📘 推荐扩展阅读</p><ul><li><a class="link"   href="https://www.postgresql.org/docs/current/ddl-partitioning.html" >PostgreSQL 官方文档：Table Partitioning<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li><a class="link"   href="https://github.com/pgpartman/pg_partman" >pg_partman GitHub<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li><a class="link"   href="https://dev.mysql.com/doc/refman/8.0/en/partitioning-overview.html" >MySQL 8.0 Partitioning Overview<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul></blockquote>]]>
    </content>
    <id>https://qianwj.github.io/2025/11/14/postgres_partition/</id>
    <link href="https://qianwj.github.io/2025/11/14/postgres_partition/"/>
    <published>2025-11-14T02:00:00.000Z</published>
    <summary>
      <![CDATA[<h1 id="一、背景"><a href="#一、背景" class="headerlink" title="一、背景"></a>一、背景</h1><p>需要对AI调用进行记录，并对失败过高的情况进行告警。但是目前每天的AI调用量在百万级，对应的数据量也在百万级，单表存储显然是]]>
    </summary>
    <title>PostgreSQL 分区表的原理、实践与 MySQL 对比</title>
    <updated>2026-07-25T01:07:17.314Z</updated>
  </entry>
</feed>
