<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Schema-Evolution on Tarragon</title><link>https://tarrragon.github.io/blog/tags/schema-evolution/</link><description>Recent content in Schema-Evolution on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/schema-evolution/index.xml" rel="self" type="application/rss+xml"/><item><title>Kafka Schema Registry 與 schema 演進：wire format、compatibility level 與安全演進規則</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/schema-registry-evolution/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/schema-registry-evolution/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a> overview「KRaft 與 Schema Registry」段的 implementation-layer deep article。Overview 已交代 Schema Registry 在事件總線中的定位；本文聚焦 &lt;em>怎麼設 compatibility、wire format 長什麼樣、schema 怎麼安全演進、演進設錯會打掛什麼&lt;/em>。對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility&lt;/a> 知識卡的 implementation 展開。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼事件總線需要一個獨立的-schema-治理元件">為什麼事件總線需要一個獨立的 schema 治理元件&lt;/h2>
&lt;p>Schema Registry 是把「event 的結構契約」從 producer 與 consumer 的程式碼裡抽出來、集中存放並強制版本相容性的元件。它承擔的責任是讓不同 service、不同部署節奏的 producer 與 consumer 在 schema 改版時仍能互通，而不需要全體同時上線。Kafka broker 本身只存 bytes、不理解 payload 結構；一旦多個團隊往同一個 topic 寫事件、又各自獨立發版，schema 漂移就會在 consumer 端炸開。&lt;/p>
&lt;p>這個責任在單一 service 內部不存在。一個 service 自己 produce、自己 consume，schema 改版同一個 deploy 就同步了，序列化用什麼格式都行。Schema Registry 解的是 &lt;em>跨 service、跨團隊、跨部署時間&lt;/em> 的契約問題：A 團隊升級了訂單事件加一個欄位，B 團隊的對帳服務還跑舊版 consumer，C 團隊的風控服務跑更舊版——三方不同步演進，靠的就是 registry 在 producer 註冊新 schema 時先擋下破壞相容性的改動。&lt;/p>
&lt;p>Yelp 的 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">Schematizer 案例&lt;/a> 把這個責任拉到極端：一天數十億訊息、數百個 service、數千個 schema，自建 registry 強制所有 message 走 Avro、訊息只帶 schema ID。它揭露 schema 治理是 data pipeline 的核心責任、不是 add-on——當規模到了數千 schema，沒有集中強制的相容性檢查，跨服務事件契約會在某次發版後悄悄斷掉，而 broker 不會報任何錯。&lt;/p>
&lt;p>Confluent Schema Registry 是業界事實標準的實作；Apicurio 是 CNCF 生態的開源替代，額外支援 OpenAPI / AsyncAPI artifact、且提供 Confluent-compatible API endpoint，遷移成本低。兩者都把 schema 存進一個 Kafka topic（Confluent 用 &lt;code>_schemas&lt;/code>，single-partition、compacted），registry 自己是無狀態的，掛掉重啟後從該 topic rebuild。&lt;/p>
&lt;h2 id="schema-id-嵌進訊息的-wire-format">Schema ID 嵌進訊息的 wire format&lt;/h2>
&lt;p>Confluent wire format 在每筆訊息的 value（或 key）前面加 5 個 byte：1 個 magic byte（固定 &lt;code>0x00&lt;/code>）加 4 個 big-endian byte 的 schema ID，後面才接序列化後的 payload。Consumer 拿到訊息先讀這 5 個 byte，用 schema ID 去 registry 查對應 schema，再用該 schema 反序列化。這是「訊息只帶 schema ID、不帶 schema 本體」的機制——schema 本體只在 registry 存一份，訊息裡放的是指標。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> overview「KRaft 與 Schema Registry」段的 implementation-layer deep article。Overview 已交代 Schema Registry 在事件總線中的定位；本文聚焦 <em>怎麼設 compatibility、wire format 長什麼樣、schema 怎麼安全演進、演進設錯會打掛什麼</em>。對應 <a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a> 知識卡的 implementation 展開。</p></blockquote>
<h2 id="為什麼事件總線需要一個獨立的-schema-治理元件">為什麼事件總線需要一個獨立的 schema 治理元件</h2>
<p>Schema Registry 是把「event 的結構契約」從 producer 與 consumer 的程式碼裡抽出來、集中存放並強制版本相容性的元件。它承擔的責任是讓不同 service、不同部署節奏的 producer 與 consumer 在 schema 改版時仍能互通，而不需要全體同時上線。Kafka broker 本身只存 bytes、不理解 payload 結構；一旦多個團隊往同一個 topic 寫事件、又各自獨立發版，schema 漂移就會在 consumer 端炸開。</p>
<p>這個責任在單一 service 內部不存在。一個 service 自己 produce、自己 consume，schema 改版同一個 deploy 就同步了，序列化用什麼格式都行。Schema Registry 解的是 <em>跨 service、跨團隊、跨部署時間</em> 的契約問題：A 團隊升級了訂單事件加一個欄位，B 團隊的對帳服務還跑舊版 consumer，C 團隊的風控服務跑更舊版——三方不同步演進，靠的就是 registry 在 producer 註冊新 schema 時先擋下破壞相容性的改動。</p>
<p>Yelp 的 <a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">Schematizer 案例</a> 把這個責任拉到極端：一天數十億訊息、數百個 service、數千個 schema，自建 registry 強制所有 message 走 Avro、訊息只帶 schema ID。它揭露 schema 治理是 data pipeline 的核心責任、不是 add-on——當規模到了數千 schema，沒有集中強制的相容性檢查，跨服務事件契約會在某次發版後悄悄斷掉，而 broker 不會報任何錯。</p>
<p>Confluent Schema Registry 是業界事實標準的實作；Apicurio 是 CNCF 生態的開源替代，額外支援 OpenAPI / AsyncAPI artifact、且提供 Confluent-compatible API endpoint，遷移成本低。兩者都把 schema 存進一個 Kafka topic（Confluent 用 <code>_schemas</code>，single-partition、compacted），registry 自己是無狀態的，掛掉重啟後從該 topic rebuild。</p>
<h2 id="schema-id-嵌進訊息的-wire-format">Schema ID 嵌進訊息的 wire format</h2>
<p>Confluent wire format 在每筆訊息的 value（或 key）前面加 5 個 byte：1 個 magic byte（固定 <code>0x00</code>）加 4 個 big-endian byte 的 schema ID，後面才接序列化後的 payload。Consumer 拿到訊息先讀這 5 個 byte，用 schema ID 去 registry 查對應 schema，再用該 schema 反序列化。這是「訊息只帶 schema ID、不帶 schema 本體」的機制——schema 本體只在 registry 存一份，訊息裡放的是指標。</p>
<p>本文用 OrbStack 起 <code>confluentinc/cp-kafka</code> + <code>confluentinc/cp-schema-registry</code>，用 Avro console producer 寫一筆 <code>{&quot;id&quot;:1,&quot;name&quot;:&quot;alice&quot;}</code>，再 dump 出 raw bytes 驗證 wire format：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">000000 00 00 00 00 01 02 0a 61 6c 69 63 65 0a   &gt;.......alice.&lt;</span></span></code></pre></div><p>逐 byte 拆解：</p>
<ul>
<li><code>00</code>：magic byte，標識這是 Confluent wire format</li>
<li><code>00 00 00 01</code>：4-byte big-endian schema ID = 1，consumer 拿這個去 registry 查 schema</li>
<li><code>02</code>：Avro 把 <code>id</code>（long）以 zigzag varint 編碼，<code>1</code> 編成 <code>0x02</code></li>
<li><code>0a 61 6c 69 63 65</code>：<code>name</code>（string）長度 5（zigzag <code>0x0a</code>）加 UTF-8 的 <code>alice</code></li>
</ul>
<p>這個格式有兩個工程後果。第一，consumer 反序列化任何訊息前都要能連到 registry——registry 掛掉，已 cache schema ID 的 consumer 還能跑，但遇到沒見過的 schema ID 就卡住。第二，schema ID 是全域單調遞增的整數、跨 subject 共用：同一份 schema 被多個 topic 註冊只會有一個 ID。實機驗證可以看到，先註冊到 <code>user-value</code> 的 schema 拿到 <code>id:1</code>，之後用同樣結構寫 <code>users-demo</code> topic 時，registry 認出是同一份 schema、複用 <code>id:1</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;subject&#34;</span><span class="p">:</span><span class="s2">&#34;users-demo-value&#34;</span><span class="p">,</span><span class="nt">&#34;version&#34;</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nt">&#34;id&#34;</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nt">&#34;schemaType&#34;</span><span class="p">:</span><span class="s2">&#34;AVRO&#34;</span><span class="p">,</span> <span class="err">...</span><span class="p">}</span></span></span></code></pre></div><p><code>version</code> 是 subject 內的序號（每個 subject 從 1 開始）、<code>id</code> 是全域的。除錯時看到某筆訊息反序列化失敗，第一步就是讀那 4-byte schema ID、去 registry 撈出它指向哪個 schema、跟 consumer 預期的對不對。</p>
<h2 id="序列化格式取捨avroprotobufjson-schema">序列化格式取捨：Avro、Protobuf、JSON Schema</h2>
<p>Schema Registry 支援三種格式，差異不只是語法、而是演進規則與生態的取捨。</p>
<table>
  <thead>
      <tr>
          <th>格式</th>
          <th>演進機制</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Avro</td>
          <td>reader / writer schema resolution</td>
          <td>data pipeline、強 schema 演進需求、JVM 生態</td>
      </tr>
      <tr>
          <td>Protobuf</td>
          <td>field number 標記</td>
          <td>已用 gRPC、跨語言 RPC + 事件共用 schema</td>
      </tr>
      <tr>
          <td>JSON Schema</td>
          <td>結構 + validation keyword</td>
          <td>已大量 JSON、要人類可讀、容忍較弱的型別保證</td>
      </tr>
  </tbody>
</table>
<p>Avro 的演進靠 <em>reader schema 與 writer schema 分離</em>：訊息用 writer schema（寫入時的版本）序列化，consumer 用自己的 reader schema（讀取時的版本）反序列化，registry 提供兩者做 schema resolution。這是 Avro 在 data pipeline 場景的核心優勢——欄位帶 default 時，舊資料用新 schema 讀會自動填 default，新資料用舊 schema 讀會自動忽略多出來的欄位。Yelp、多數 Kafka-native data platform 都選 Avro，正是因為它的演進語意最完整。</p>
<p>Protobuf 用 field number 而非欄位名做 wire 識別：欄位改名不破壞相容性（number 沒變即可），刪欄位要 reserve 掉 number 避免重用。已經用 gRPC 的團隊讓 RPC 與事件共用同一份 <code>.proto</code>，省一套 schema 維護。代價是 Protobuf 的 default 語意較弱（proto3 沒有 explicit presence 的 scalar 一律有 zero value），某些演進判斷不如 Avro 直觀。</p>
<p>JSON Schema 適合既有系統已經大量用 JSON、且看重人類可讀與 validation keyword（<code>required</code>、<code>minimum</code>、<code>pattern</code>）的場景。代價是 payload 較大（欄位名重複出現在每筆訊息）、型別保證弱於前兩者。當吞吐量大、payload size 敏感時，JSON Schema 的頻寬成本會顯著高於 Avro 的 binary 編碼。</p>
<p>選型判準：data pipeline 為主、重演進安全 → Avro；已有 gRPC、RPC 與事件共用 → Protobuf；既有 JSON 生態、重可讀性而吞吐量不極端 → JSON Schema。三者可在同一個 registry 並存（每個 subject 各自標 schemaType），但同一個 subject 內不能混用格式。</p>
<h2 id="subject-naming-strategy-決定相容性檢查的邊界">Subject naming strategy 決定相容性檢查的邊界</h2>
<p>Subject 是 registry 裡做版本管理與相容性檢查的基本單位；naming strategy 決定「哪些 schema 被歸進同一個 subject、因而要互相相容」。選錯 strategy 會讓相容性檢查管太寬或太窄，是後面故障演練的根源之一。</p>
<table>
  <thead>
      <tr>
          <th>Strategy</th>
          <th>Subject 名</th>
          <th>相容性檢查邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TopicNameStrategy</td>
          <td><code>&lt;topic&gt;-value</code> / <code>&lt;topic&gt;-key</code></td>
          <td>整個 topic 只能有一種 value schema 演進</td>
      </tr>
      <tr>
          <td>RecordNameStrategy</td>
          <td><code>&lt;record 全名&gt;</code></td>
          <td>同名 record 跨所有 topic 一起演進</td>
      </tr>
      <tr>
          <td>TopicRecordNameStrategy</td>
          <td><code>&lt;topic&gt;-&lt;record 全名&gt;</code></td>
          <td>同 topic 內可放多種 record、各自演進</td>
      </tr>
  </tbody>
</table>
<p>TopicNameStrategy 是預設，subject 名就是 <code>&lt;topic&gt;-value</code>。實機驗證可以看到，用 Avro producer 寫 <code>users-demo</code> topic 時，registry 自動建立 <code>users-demo-value</code> subject：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="s2">&#34;user-value&#34;</span><span class="p">,</span><span class="s2">&#34;users-demo-value&#34;</span><span class="p">]</span></span></span></code></pre></div><p>預設策略的隱含假設是「一個 topic 只承載一種事件型別」。這對多數 topic 成立，但當業務要把多種相關事件（例如 <code>OrderCreated</code> 與 <code>OrderCancelled</code>）放進同一個 topic 以保證跨事件 ordering 時，TopicNameStrategy 會把兩種 record 當成同一個 subject 的版本演進、互相做相容性檢查——這幾乎一定失敗，因為兩種事件結構本來就不同。</p>
<p>這時要改 RecordNameStrategy（subject = record 全名，跨 topic 同名 record 共用一份演進歷史）或 TopicRecordNameStrategy（subject = topic + record 名，同 topic 多型別各自獨立演進）。判準：一個 topic 一種事件 → 預設即可；一個 topic 多種事件且要保 ordering → TopicRecordNameStrategy；同一種 record 散在多個 topic 要強制全域一致 → RecordNameStrategy。Producer 與 consumer 必須設成同一個 strategy，否則 consumer 會用錯 subject 去查 schema。</p>
<h2 id="compatibility-level四種基礎--transitive">Compatibility level：四種基礎 × transitive</h2>
<p>Compatibility level 是 registry 在 producer 註冊新 schema 時套用的相容性規則，決定哪些 schema 改動會被擋下。它回答的問題是「新 schema 跟既有 schema 比，誰應該能讀誰寫的資料」。設定可以是全域預設、也可以 per-subject 覆寫。</p>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>規則</th>
          <th>保護對象</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>BACKWARD</td>
          <td>新 schema 能讀舊 schema 寫的資料</td>
          <td>consumer 先升級、producer 後升級</td>
      </tr>
      <tr>
          <td>FORWARD</td>
          <td>舊 schema 能讀新 schema 寫的資料</td>
          <td>producer 先升級、consumer 後升級</td>
      </tr>
      <tr>
          <td>FULL</td>
          <td>同時滿足 BACKWARD 與 FORWARD</td>
          <td>雙向都能不同步演進</td>
      </tr>
      <tr>
          <td>NONE</td>
          <td>不檢查</td>
          <td>不保護（演進風險全交給人）</td>
      </tr>
  </tbody>
</table>
<p>BACKWARD 是 Confluent 預設，實機驗證可以確認：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;compatibilityLevel&#34;</span><span class="p">:</span><span class="s2">&#34;BACKWARD&#34;</span><span class="p">}</span></span></span></code></pre></div><p>BACKWARD 保護的是「consumer 先升級」的演進順序——新版 consumer 必須能讀舊版 producer 還在寫的舊資料。它允許的安全改動是「加帶 default 的欄位」與「刪欄位」：新 schema 讀舊資料時，舊資料缺的新欄位用 default 補；新 schema 不要的欄位讀舊資料時忽略。它擋下的是「加沒有 default 的必填欄位」——舊資料沒這欄位、新 consumer 又要求它存在，就讀不出來。</p>
<p>FORWARD 反過來保護「producer 先升級」：舊版 consumer 要能讀新版 producer 寫的資料。它允許「刪帶 default 的欄位」與「加欄位」。當演進順序是 producer 先上、consumer 慢慢跟（例如先讓 producer 開始寫新欄位、consumer 之後才用）時選 FORWARD。</p>
<p>FULL 同時滿足兩者，代價是只能做「加帶 default 的欄位」與「刪帶 default 的欄位」這類雙向安全的改動，演進自由度最低但最安全。當 producer 與 consumer 的升級順序無法協調（大型組織、多團隊各自排程）時，FULL 把演進約束到怎麼改都不會斷。</p>
<p>四種各有一個 transitive 變體（<code>BACKWARD_TRANSITIVE</code> 等）。非 transitive 只檢查新 schema 對 <em>最近一版</em>；transitive 檢查新 schema 對 <em>該 subject 所有歷史版本</em>。差別在這個場景：v1 → v2 相容、v2 → v3 相容，但 v3 對 v1 不相容。非 transitive 會放行 v3（因為只比 v2）；transitive 會擋下。當 consumer 可能 replay 很舊的歷史資料（Kafka 的長期保留 + replay 正是常態），transitive 才能保證任何歷史版本都讀得出來。<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a> 講的 replay 邊界，在 schema 層的對應就是 transitive compatibility。</p>
<h2 id="安全演進規則實機驗證註冊與拒絕">安全演進規則：實機驗證註冊與拒絕</h2>
<p>把上面的規則落到實際操作。在預設 BACKWARD 下，註冊 v1（<code>id</code> + <code>name</code>）後，加一個帶 default 的 <code>email</code> 欄位是安全的，registry 接受並記為 v2：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;id&#34;</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nt">&#34;version&#34;</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nt">&#34;schemaType&#34;</span><span class="p">:</span><span class="s2">&#34;AVRO&#34;</span><span class="p">,</span> <span class="err">...</span><span class="p">}</span></span></span></code></pre></div><p><code>user-value</code> 的版本列表確認累積成兩版：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="mi">1</span><span class="p">,</span><span class="mi">2</span><span class="p">]</span></span></span></code></pre></div><p>接著嘗試加一個 <em>沒有 default</em> 的 <code>age</code>（int）必填欄位——這破壞 BACKWARD，因為新 consumer 讀舊資料時 <code>age</code> 沒值也沒 default。registry 回 HTTP 409 並指出確切原因：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;error_code&#34;</span><span class="p">:</span><span class="mi">40901</span><span class="p">,</span><span class="nt">&#34;message&#34;</span><span class="p">:</span><span class="s2">&#34;Schema being registered is incompatible with an earlier schema for subject \&#34;user-value\&#34;</span><span class="p">,</span> <span class="err">details:</span> <span class="err">[{errorType:&#39;READER_FIELD_MISSING_DEFAULT_VALUE&#39;,</span> <span class="err">description:&#39;The</span> <span class="err">field</span> <span class="err">&#39;age&#39;</span> <span class="err">at</span> <span class="err">path</span> <span class="err">&#39;/fields/3&#39;</span> <span class="err">in</span> <span class="err">the</span> <span class="err">new</span> <span class="err">schema</span> <span class="err">has</span> <span class="err">no</span> <span class="err">default</span> <span class="err">value</span> <span class="err">and</span> <span class="err">is</span> <span class="err">missing</span> <span class="err">in</span> <span class="err">the</span> <span class="err">old</span> <span class="err">schema&#39;,</span> <span class="err">...</span><span class="p">}</span><span class="err">],</span> <span class="err">compatibility:</span> <span class="err">&#39;BACKWARD&#39;}</span></span></span></code></pre></div><p><code>READER_FIELD_MISSING_DEFAULT_VALUE</code> 精確命中規則：reader（新 schema）多了一個舊資料沒有、又無 default 的欄位。registry 另外提供 compatibility check API，可以在不真正註冊的前提下先問「相不相容」，給 CI pipeline 在 PR 階段擋下破壞性改動：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;is_compatible&#34;</span><span class="p">:</span><span class="kc">false</span><span class="p">}</span></span></span></code></pre></div><p>由此導出兩條安全演進的操作規則。<strong>加欄位</strong>：一律帶 default（BACKWARD / FULL 都要），舊資料才能用新 schema 讀出。沒有合理 default 的「必填新欄位」不能直接加——要嘛在 producer 端先全部開始寫該欄位、確認資料齊全後再 promote，要嘛走新 topic / 新 record 而非原地演進。<strong>刪欄位</strong>：分步做。先讓所有 consumer 停止依賴該欄位（部署一輪），確認沒人讀之後，下一輪才從 schema 拿掉。一步到位刪掉還在被讀的欄位，會在 FORWARD / FULL 下被擋、在 BACKWARD 下放行但打掛還沒升級的 consumer。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1producer-加必填欄位無-default打掛舊-consumer">Case 1：producer 加必填欄位無 default，打掛舊 consumer</h3>
<p><strong>徵兆</strong>：某團隊 producer 發版後，另一團隊的舊 consumer 開始大量反序列化失敗、<code>SerializationException</code> 或 <code>AvroTypeException: Found X, expecting Y</code>，consumer lag 暴衝、訊息卡在 poll 階段。producer 端與 broker 端完全沒報錯——訊息照寫成功。</p>
<p><strong>根因</strong>：subject 的 compatibility level 被設成 NONE（或該欄位走了 FORWARD 不檢查 reader 缺欄位的路徑）。producer 加了一個沒有 default 的必填欄位、registry 沒擋，新訊息帶新 schema ID 寫進 topic。舊 consumer 用自己的舊 reader schema 去反序列化新 writer schema 的資料，遇到自己不認識又無從補值的結構就炸。問題不在 producer 也不在 broker，在 <em>registry 沒在註冊時擋下這次演進</em>。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>把 compatibility level 改回至少 BACKWARD</strong>：實機驗證過 NONE 會直接放行破壞性 schema——把 <code>compatibility</code> 設成 NONE 後，前面被 409 拒絕的破壞性 schema 立刻被接受成 v3。NONE 等於把演進安全完全交給人，多團隊場景幾乎一定出事。</li>
<li><strong>回退 producer</strong>：先讓 producer 退回舊 schema 止血，恢復舊 consumer 可讀。</li>
<li><strong>重新演進</strong>：欄位帶 default 重發，或若該欄位語意上必填、走「先讓 producer 寫、consumer 升級、再 promote」的分步路徑。</li>
<li><strong>CI 防線</strong>：把 compatibility check API（<code>/compatibility/subjects/&lt;s&gt;/versions/latest</code>）接進 producer repo 的 CI，PR 階段就用 <code>is_compatible:false</code> 擋掉，不等到 production 註冊時才發現。</li>
</ol>
<h3 id="case-2compatibility-level-設錯放行破壞性變更">Case 2：compatibility level 設錯，放行破壞性變更</h3>
<p><strong>徵兆</strong>：team 以為有 registry 把關所以放心演進，某次刪掉一個還在被下游讀的欄位、registry 接受了，下游服務隔天開始拿到 null / 缺欄位、business logic 走錯分支，但沒有任何 exception——資料「看起來正常」只是少了東西。</p>
<p><strong>根因</strong>：compatibility level 設成了 FORWARD 而需求其實是 BACKWARD，或設成 NONE。實機驗證可以看到 per-subject 覆寫的行為——對 <code>user-value</code> 單獨 PUT <code>FORWARD</code> 後查 config 回 <code>{&quot;compatibilityLevel&quot;:&quot;FORWARD&quot;}</code>，這個 subject 的檢查方向就跟全域預設不同了。FORWARD 允許刪帶 default 的欄位（保護 producer 先升級的順序），但團隊實際的演進順序是 consumer 後升級——方向錯配，registry 放行的正是會打掛 consumer 的那類改動。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>依演進順序選 level，不是隨手設</strong>：consumer 先升級選 BACKWARD；producer 先升級選 FORWARD；順序無法協調選 FULL。把這個決策寫進 topic ownership 文件、不是留給註冊當下的人臨時判斷。</li>
<li><strong>可能 replay 歷史就用 transitive</strong>：Kafka 長期保留 + replay 是常態，非 transitive 只擋最近一版、replay 舊資料時舊 schema 仍可能讀不出。長期保留的 topic 預設用 <code>*_TRANSITIVE</code>。</li>
<li><strong>per-subject 覆寫要留審計</strong>：全域預設外的每一個 per-subject 覆寫都是一個風險點，要能查出「誰、何時、為什麼把這個 subject 改成跟預設不同」。</li>
</ol>
<h3 id="case-3schema-id-對不上consumer-反序列化失敗">Case 3：schema ID 對不上，consumer 反序列化失敗</h3>
<p><strong>徵兆</strong>：consumer 報 <code>Schema not found; error code: 40403</code> 或反序列化拿到亂碼、欄位錯位。某些訊息正常、某些失敗，跟特定 producer 或特定時間段相關。</p>
<p><strong>根因</strong>有幾種，靠讀訊息前 5 byte 的 schema ID 定位：</p>
<ul>
<li><strong>registry 換過、ID 不一致</strong>：跨環境（dev / staging / prod）各自一套 registry，schema ID 全域遞增的順序不同，同一份 schema 在不同環境是不同 ID。如果有人把 prod 的訊息 mirror 到 staging 而沒搬 schema，staging consumer 拿 prod 的 schema ID 去 staging registry 查就 404。</li>
<li><strong>訊息根本不是 Confluent wire format</strong>：有 producer 沒走 schema-aware serializer、直接寫 raw bytes，前 5 byte 不是 magic + ID。consumer 把第一個 byte 當 magic、後 4 byte 當 ID 去查，撈到不存在或錯誤的 schema。</li>
<li><strong>registry 不可達或 cache 失效</strong>：consumer 端 schema cache 沒命中、又連不上 registry。</li>
</ul>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>讀 wire format 確認</strong>：dump 訊息 raw bytes，確認第一個 byte 是 <code>00</code>、接下來 4 byte 解出來的 ID 在目標 registry 查得到。本文驗證過 <code>00 00 00 00 01</code> 對應 schema id 1，這是除錯的第一手證據。</li>
<li><strong>跨環境 schema 搬遷</strong>：mirror 訊息時用 registry 的 import / export，或 MirrorMaker 搭配 schema 同步，不要只搬資料不搬 schema。</li>
<li><strong>隔離非 schema-aware producer</strong>：用 ACL 或 topic 命名規範強制所有 producer 走 schema serializer，避免 raw bytes 混進 schema-managed topic。</li>
</ol>
<h3 id="case-4subject-naming-strategy-衝突">Case 4：subject naming strategy 衝突</h3>
<p><strong>徵兆</strong>：把第二種事件型別寫進既有 topic 時，producer 直接註冊失敗報 incompatible，或多 producer 寫同 topic 互相把對方的 schema 判成不相容、彼此發版互相擋。</p>
<p><strong>根因</strong>：用 TopicNameStrategy（預設）卻往同一個 topic 放多種 record。subject 是 <code>&lt;topic&gt;-value</code>、整個 topic 共用一條演進線，registry 拿 <code>OrderCancelled</code> 去跟既有的 <code>OrderCreated</code> 做相容性檢查——兩種結構不同的事件當然不相容。strategy 的隱含假設（一 topic 一事件型別）跟實際用法（一 topic 多事件保 ordering）衝突。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>改 strategy 配合用法</strong>：一 topic 多事件 → TopicRecordNameStrategy，subject 變成 <code>&lt;topic&gt;-&lt;record 全名&gt;</code>，每種 record 各自一條演進線、不互相檢查。</li>
<li><strong>producer 與 consumer 設同一個 strategy</strong>：strategy 不一致時 consumer 會用錯 subject 查 schema，拿到 null 或錯 schema。這是部署層的硬約束，要在共用 config 統一。</li>
<li><strong>若只是不小心寫錯 topic</strong>：那不是 strategy 問題、是路由問題，修 producer 的 topic 選擇邏輯，別為了繞過檢查改成 RecordNameStrategy。</li>
</ol>
<h2 id="容量與運維邊界">容量與運維邊界</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算 / 邊界</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema 數量</td>
          <td>數千 schema registry 仍可運作（Yelp 等級）</td>
          <td><code>_schemas</code> topic 是 single-partition</td>
      </tr>
      <tr>
          <td>Wire format overhead</td>
          <td>每筆訊息固定 +5 byte</td>
          <td>高頻小訊息時相對 overhead 不可忽略</td>
      </tr>
      <tr>
          <td>Registry 可用性</td>
          <td>consumer cache 命中時可短暫容忍 registry 不可達</td>
          <td>冷 consumer / 新 schema ID 時硬依賴</td>
      </tr>
      <tr>
          <td>Compatibility 檢查</td>
          <td>註冊時做、非 hot path</td>
          <td>transitive 對長歷史 subject 檢查較慢</td>
      </tr>
      <tr>
          <td>環境隔離</td>
          <td>每環境一套 registry、schema ID 不跨環境一致</td>
          <td>跨環境 mirror 要同步搬 schema</td>
      </tr>
  </tbody>
</table>
<p>實務 default：data pipeline 場景選 Avro + 至少 BACKWARD；長期保留 + replay 的 topic 用 transitive；compatibility check 接進 CI 在 PR 階段擋破壞性改動，不依賴註冊當下把關；一 topic 一事件型別當預設、要多型別才動 naming strategy。Schema Registry 自己也是個要 HA 的元件——production 跑多副本、<code>_schemas</code> topic 的 replication factor 拉高，registry 是事件總線的單點時要當關鍵基礎設施對待。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-cdc-pipeline-的銜接">跟 CDC pipeline 的銜接</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">Shopify Debezium CDC 案例</a> 跑在 100+ MySQL shard、150 個 Debezium connector 的規模（該案例記載的重點是 lock-free snapshot 與 oversized record 處理）。CDC pipeline 有一個一般性的 schema 演進壓力，以下依 CDC 機制推導、非該案例的結論：上游 DDL 一改，Debezium 產生的 Kafka record schema 跟著變，下游 consumer 受影響。Schema Registry 的 compatibility 檢查就是把這道衝擊在進 Kafka 時攔下的關卡——選錯 compatibility level，一次 ALTER TABLE 就可能透過 CDC 打穿整條 pipeline。Debezium 與 Kafka Connect 原生整合 Schema Registry，connector 設定裡指定 registry URL 與 naming strategy。</p>
<h3 id="跟-replay-邊界與事件契約">跟 replay 邊界與事件契約</h3>
<p><a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a> 講的是事件契約能 replay 多遠；schema 層的對應就是本文的 transitive compatibility。Replay 跨越多個 schema 版本時，只有 transitive 能保證任何歷史版本都讀得出來。兩者一起界定「這條事件流的契約能安全回放到多久以前」。</p>
<h3 id="下游能力">下游能力</h3>
<ul>
<li>概念索引：<a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a> 知識卡（本文的 implementation 來源）</li>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a>（KRaft 與 Schema Registry 段）</li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">3.C14 Yelp Schematizer</a>（schema 治理拉到平台層）、<a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">3.C13 Shopify Debezium CDC</a>（CDC 場景的 schema evolution）</li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item></channel></rss>