<?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>Google-Pubsub on Tarragon</title><link>https://tarrragon.github.io/blog/tags/google-pubsub/</link><description>Recent content in Google-Pubsub on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 22 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/google-pubsub/index.xml" rel="self" type="application/rss+xml"/><item><title>AWS SQS → Google Pub/Sub：queue 模型搬到 topic + subscription 模型的跨雲遷移</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/migrate-to-google-pubsub/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/migrate-to-google-pubsub/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub&lt;/a>。這是一個 &lt;em>跨雲 managed-to-managed&lt;/em> 遷移：兩端都是 cloud-managed、運維負擔都低、但 &lt;em>資料拓樸&lt;/em> 跟 &lt;em>消費抽象&lt;/em> 不同 — SQS 是 region-scoped 的單一 pull queue、Pub/Sub 是 global topic + 多個 first-class subscription。主結構走 operational redesign hybrid（Type C）、額外為 components / data topology 兩個高維度抽獨立段。&lt;/p>&lt;/blockquote>
&lt;h2 id="sqs-跟-pubsub-不是同一種訊息抽象">SQS 跟 Pub/Sub 不是同一種訊息抽象&lt;/h2>
&lt;p>SQS 跟 Pub/Sub 都是 cloud-managed 非同步訊息服務、都解「解耦 producer / consumer、不自管 broker」這個問題、application 程式碼裡都是「發訊息、收訊息、處理完確認」的形狀。從這層看兩者可互換、遷移像是換 SDK。&lt;/p>
&lt;p>差別在 &lt;em>消費抽象&lt;/em> 跟 &lt;em>資料拓樸&lt;/em>。SQS 的核心實體是 queue：一條 region-scoped 的訊息隊列、訊息被一個 consumer 領走（in-flight）就對其他 consumer 隱形、處理完 DeleteMessage 就消失。要讓同一筆事件送給多個下游、SQS 端的做法是在 SNS 前面 fan-out、再各接一條 SQS queue。Pub/Sub 的核心實體是 topic + subscription 兩層：topic 收訊息、subscription 是 &lt;em>first-class&lt;/em> 的消費端點、一個 topic 可掛 N 個 subscription、每個 subscription 各自維護消費進度、fan-out 是模型內建而不是外接。&lt;/p>
&lt;p>這個差別決定了遷移的形狀。如果原系統只是「一條 queue、一群 worker 競爭領取」、那 Pub/Sub 端是「一個 topic、一個 pull subscription」、對位乾淨、application 改動小。如果原系統靠 SNS-to-many-SQS 做扇出、那 Pub/Sub 端是「一個 topic、多個 subscription」、整個 fan-out 拓樸要重畫、這不是換 SDK、是重設計訊息流。先判斷自己屬於哪一種、再決定 playbook 的重量。&lt;/p>
&lt;h2 id="為什麼會跨雲遷這條路徑">為什麼會跨雲遷這條路徑&lt;/h2>
&lt;p>跨雲從 SQS 遷到 Pub/Sub 的 driver 跟同雲 vendor 切換不同、通常不是「Pub/Sub 比 SQS 好」、而是 &lt;em>整體 workload 的重心移到 GCP&lt;/em>：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>資料平台落在 GCP&lt;/strong>：下游分析走 BigQuery、streaming 走 Dataflow、容器跑 Cloud Run。事件如果留在 AWS、每筆都要跨雲搬到 GCP 才能進 BigQuery、跨雲 egress 費用跟延遲都是常態成本。把訊息層也移到 Pub/Sub、事件可以用 BigQuery subscription 直接落地、省掉中間搬運。&lt;/li>
&lt;li>&lt;strong>需要 global topic、不想管 region&lt;/strong>：SQS queue 綁 region、跨 region 要自己複製或在前面架路由。Pub/Sub topic 沒有 region 概念、publish 進去全球可訂閱、多區域服務的事件分發是 first-class。&lt;/li>
&lt;li>&lt;strong>fan-out 從外接變內建&lt;/strong>：原本靠 SNS + 多條 SQS 維護的扇出拓樸、在 Pub/Sub 是「一個 topic 掛多個 subscription」、少一層 SNS、扇出關係在 subscription 列表一覽。&lt;/li>
&lt;/ol>
&lt;p>這三條 driver 都假設 &lt;em>重心已經或即將在 GCP&lt;/em>。如果系統長期紮根 AWS、只為了「換個 queue」跨雲、會付出跨雲 IAM 重對位、雙雲計費、跨雲網路延遲的代價、ROI 通常不成立。遷移前先確認 driver 是 workload 重心轉移、不是單純偏好。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a> 跟 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a>。這是一個 <em>跨雲 managed-to-managed</em> 遷移：兩端都是 cloud-managed、運維負擔都低、但 <em>資料拓樸</em> 跟 <em>消費抽象</em> 不同 — SQS 是 region-scoped 的單一 pull queue、Pub/Sub 是 global topic + 多個 first-class subscription。主結構走 operational redesign hybrid（Type C）、額外為 components / data topology 兩個高維度抽獨立段。</p></blockquote>
<h2 id="sqs-跟-pubsub-不是同一種訊息抽象">SQS 跟 Pub/Sub 不是同一種訊息抽象</h2>
<p>SQS 跟 Pub/Sub 都是 cloud-managed 非同步訊息服務、都解「解耦 producer / consumer、不自管 broker」這個問題、application 程式碼裡都是「發訊息、收訊息、處理完確認」的形狀。從這層看兩者可互換、遷移像是換 SDK。</p>
<p>差別在 <em>消費抽象</em> 跟 <em>資料拓樸</em>。SQS 的核心實體是 queue：一條 region-scoped 的訊息隊列、訊息被一個 consumer 領走（in-flight）就對其他 consumer 隱形、處理完 DeleteMessage 就消失。要讓同一筆事件送給多個下游、SQS 端的做法是在 SNS 前面 fan-out、再各接一條 SQS queue。Pub/Sub 的核心實體是 topic + subscription 兩層：topic 收訊息、subscription 是 <em>first-class</em> 的消費端點、一個 topic 可掛 N 個 subscription、每個 subscription 各自維護消費進度、fan-out 是模型內建而不是外接。</p>
<p>這個差別決定了遷移的形狀。如果原系統只是「一條 queue、一群 worker 競爭領取」、那 Pub/Sub 端是「一個 topic、一個 pull subscription」、對位乾淨、application 改動小。如果原系統靠 SNS-to-many-SQS 做扇出、那 Pub/Sub 端是「一個 topic、多個 subscription」、整個 fan-out 拓樸要重畫、這不是換 SDK、是重設計訊息流。先判斷自己屬於哪一種、再決定 playbook 的重量。</p>
<h2 id="為什麼會跨雲遷這條路徑">為什麼會跨雲遷這條路徑</h2>
<p>跨雲從 SQS 遷到 Pub/Sub 的 driver 跟同雲 vendor 切換不同、通常不是「Pub/Sub 比 SQS 好」、而是 <em>整體 workload 的重心移到 GCP</em>：</p>
<ol>
<li><strong>資料平台落在 GCP</strong>：下游分析走 BigQuery、streaming 走 Dataflow、容器跑 Cloud Run。事件如果留在 AWS、每筆都要跨雲搬到 GCP 才能進 BigQuery、跨雲 egress 費用跟延遲都是常態成本。把訊息層也移到 Pub/Sub、事件可以用 BigQuery subscription 直接落地、省掉中間搬運。</li>
<li><strong>需要 global topic、不想管 region</strong>：SQS queue 綁 region、跨 region 要自己複製或在前面架路由。Pub/Sub topic 沒有 region 概念、publish 進去全球可訂閱、多區域服務的事件分發是 first-class。</li>
<li><strong>fan-out 從外接變內建</strong>：原本靠 SNS + 多條 SQS 維護的扇出拓樸、在 Pub/Sub 是「一個 topic 掛多個 subscription」、少一層 SNS、扇出關係在 subscription 列表一覽。</li>
</ol>
<p>這三條 driver 都假設 <em>重心已經或即將在 GCP</em>。如果系統長期紮根 AWS、只為了「換個 queue」跨雲、會付出跨雲 IAM 重對位、雙雲計費、跨雲網路延遲的代價、ROI 通常不成立。遷移前先確認 driver 是 workload 重心轉移、不是單純偏好。</p>
<h2 id="結構為什麼是-operational-hybrid-加兩個高維度獨立段">結構為什麼是 operational hybrid 加兩個高維度獨立段</h2>
<p>寫這篇前先跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">diff dimension audit</a>、6 維評級如下：</p>
<table>
  <thead>
      <tr>
          <th>Diff 維度</th>
          <th>評級</th>
          <th>SQS → Pub/Sub 的具體差異</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>Medium</td>
          <td>都是「發 / 收 / 確認」、但 API 名詞與參數全換（QueueUrl → topic+subscription）</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>High</td>
          <td>IAM policy → Service Account、CloudWatch → Cloud Monitoring、redrive → DLT 重訂閱</td>
      </tr>
      <tr>
          <td>Abstraction</td>
          <td>Medium</td>
          <td>都是訊息服務、但 pull queue ↔ topic/subscription 的消費抽象不同</td>
      </tr>
      <tr>
          <td>Components（數量）</td>
          <td>High</td>
          <td>單一 queue ↔ topic + N subscription 兩層實體；SNS+SQS 扇出 ↔ topic 內建扇出</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>Medium</td>
          <td>SDK 換、ack / fan-out 邏輯改、但商業邏輯多數可保留</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>High</td>
          <td>region-scoped queue ↔ global topic；single-consumer ↔ multi-subscription fan-out</td>
      </tr>
  </tbody>
</table>
<p>主導維度是 <em>operational model</em>（跨雲身份與監控全換）、所以主結構走 Type C operational redesign hybrid。但 components 跟 data topology 也是 High — 不是把它們塞進 operational 段就能講清楚的、消費抽象從「一條 queue」變「topic + 多 subscription」是讀者最容易踩雷的地方。按 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration 方法論的 multi-axis 規則</a>、高維度抽成獨立段補充、不硬塞進單一 type 標籤。所以本篇結構是：operational 對位主軸 + 「消費抽象重設計」獨立段（components / topology 軸）+ 跨雲特有的 IAM 與網路段。</p>
<h2 id="operational-對位機制名詞換語意要逐一確認">Operational 對位：機制名詞換、語意要逐一確認</h2>
<p>跨雲遷移最容易出錯的環節、是 <em>找到語意相近的功能、卻假設行為一致</em>。SQS 跟 Pub/Sub 多數機制都有對位、但每一組都有行為差、找得到對應功能只是第一步。下表先給對照、後面逐項展開語意陷阱。</p>
<table>
  <thead>
      <tr>
          <th>SQS 機制</th>
          <th>Pub/Sub 對位</th>
          <th>語意是否等價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Visibility timeout</td>
          <td>Ack deadline</td>
          <td>近似、但上限與延長機制不同</td>
      </tr>
      <tr>
          <td>DeleteMessage</td>
          <td>Ack（acknowledge）</td>
          <td>近似、但 Pub/Sub 自動 extension 改變實際行為</td>
      </tr>
      <tr>
          <td>maxReceiveCount + DLQ + redrive</td>
          <td>Dead-letter topic + 重訂閱</td>
          <td>概念對應、DLT 是 topic 不是 queue、重處理方式不同</td>
      </tr>
      <tr>
          <td>Long polling（WaitTimeSeconds）</td>
          <td>Streaming pull</td>
          <td>不等價、streaming pull 是長連線串流、不是輪詢</td>
      </tr>
      <tr>
          <td>Message attributes</td>
          <td>Message attributes</td>
          <td>概念對應、型別與大小限制不同</td>
      </tr>
      <tr>
          <td>FIFO queue（MessageGroupId）</td>
          <td>Ordering key</td>
          <td>都給順序、但去重與吞吐取捨不同</td>
      </tr>
      <tr>
          <td>IAM policy + Queue policy</td>
          <td>IAM role + Service Account</td>
          <td>跨雲身份模型完全不同、不是改語法是重對位</td>
      </tr>
      <tr>
          <td>CloudWatch metric / alarm</td>
          <td>Cloud Monitoring metric / alert</td>
          <td>metric 名詞與語意不同、alarm 邏輯要重寫</td>
      </tr>
  </tbody>
</table>
<h3 id="visibility-timeout--ack-deadline">Visibility timeout → ack deadline</h3>
<p>Visibility timeout 跟 ack deadline 都回答同一個問題：consumer 領走訊息後、多久沒確認就視為失敗、把訊息重新投遞。語意對位成立、但兩端的數字與延長機制不同。</p>
<p>SQS visibility timeout 預設 30 秒、上限 12 小時、consumer 要延長就主動呼叫 ChangeMessageVisibility。Pub/Sub ack deadline 預設 10 秒、上限 600 秒（10 分鐘）、而且 client library 預設會 <em>自動</em> 在背景延長 deadline（lease management）。這個自動延長是最容易踩到的差異：在 SQS 端習慣「設一個夠長的 visibility timeout、處理完再 delete」、搬到 Pub/Sub 如果只把 ack deadline 設成 600 秒上限、卻沒意識到 client library 在背景幫忙延長、長任務的行為會跟預期不同；反過來、如果關掉自動延長又設了預設 10 秒、處理稍久就重投。對位的正確做法是先理解 client library 的 lease 行為、再決定 ack deadline 跟 MaxAckPending、而不是把 SQS 的 timeout 數字直接搬過去。</p>
<h3 id="maxreceivecount--redrive--dead-letter-topic">maxReceiveCount / redrive → dead-letter topic</h3>
<p>兩端都用「重試 N 次仍失敗就隔離」防止 poison message 阻塞 pipeline、但隔離後的容器不同。SQS 的 DLQ 是另一條 <em>queue</em>、用 maxReceiveCount 控制門檻、修好下游後用 redrive policy 把訊息放回原 queue。Pub/Sub 的 dead-letter topic 是另一個 <em>topic</em>、用 subscription 的 max delivery attempt 控制門檻、超過就 publish 到 DLT。</p>
<p>差別在重處理路徑。SQS redrive 是把 DLQ 訊息搬回 main queue、是一個 queue-to-queue 的搬移動作。Pub/Sub 的 DLT 是 topic、要重處理得在 DLT 上再開一個 subscription 來消費、沒有內建的「放回原 topic」按鈕。<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari item feed 的案例</a>就是用 DLT 把重試多次仍失敗的訊息隔離、讓後續訊息優先處理、同時把 topic 當突發流量的 load-leveling buffer。從 SQS 搬過來時、redrive 的心智模型要換成「DLT 是一個獨立 topic、重處理是另開 subscription」、不是「按一個按鈕放回去」。設定 DLT 還需要給 Pub/Sub service account 對 DLT 的 publisher 權限跟對原 subscription 的 subscriber 權限、漏設會讓訊息卡住不進 DLT。</p>
<h3 id="long-polling--streaming-pull">Long polling → streaming pull</h3>
<p>這一組不是等價對位、是機制不同。SQS long polling 是 consumer 發一個 ReceiveMessage 請求、最多等 20 秒、有訊息就回、沒有就空回、本質仍是 <em>輪詢</em>、只是把空輪詢的頻率降下來省 cost。Pub/Sub 的 pull 在 client library 預設是 <em>streaming pull</em>：consumer 跟 Pub/Sub 建一條長連線、訊息一到就推過來、不是 consumer 反覆問。</p>
<p>對位時不要把 long polling 的「WaitTimeSeconds 20 秒」翻譯成某個 Pub/Sub 參數 — 沒有對應參數、因為機制不同。要關注的是 flow control：streaming pull 因為訊息會主動推來、要用 MaxOutstandingMessages / MaxAckPending 控制同時在處理的訊息量、否則 consumer 會被一次塞太多訊息壓垮。SQS 端「一次拉最多 10 條」的批次節流、在 Pub/Sub 端變成 flow control 設定。<a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">Spotify autoscaling 的案例</a>揭露了相關陷阱：下游失敗時 consumer 不 ack 仍持續消耗 CPU、autoscaling 反而把資源越拉越高 — autoscale 訊號要看處理成功率、不是 backlog 加 CPU。</p>
<h3 id="iam-policy--service-account">IAM policy → Service Account</h3>
<p>跨雲遷移裡、身份模型是 <em>重對位</em> 而不是改語法的部分。SQS 的存取控制是 IAM policy（identity-based、掛在 user / role）加 queue policy（resource-based、掛在 queue）兩層、cross-account 靠這兩層互動。Pub/Sub 是 GCP IAM role（publisher / subscriber / viewer 等）加 Service Account、push subscription 要用 Service Account 認證到目標 endpoint。</p>
<p>兩套身份模型沒有自動轉換工具、要逐條重畫：誰能 publish 對應誰有 topic 的 publisher role、誰能消費對應誰有 subscription 的 subscriber role。跨雲場景還多一層 — 如果遷移期 AWS 端的服務要 publish 到 GCP 的 topic、得用 workload identity federation 或 service account key、讓 AWS 的工作負載拿到 GCP 身份。這部分沒有 case 可引、依 GCP 官方 IAM 文件加最小權限原則設計：每個 service account 只給它實際需要的 role、不要為了遷移方便給 broad role 再說以後收緊、那個「以後」通常不會來。</p>
<h3 id="cloudwatch--cloud-monitoring">CloudWatch → Cloud Monitoring</h3>
<p>監控訊號要重建、不是改名。SQS 在 CloudWatch 看 ApproximateNumberOfMessagesVisible（queue 深度）跟 ApproximateAgeOfOldestMessage（lag）。Pub/Sub 在 Cloud Monitoring 看 num_undelivered_messages（backlog）跟 oldest_unacked_message_age（最老未確認訊息年齡）。語意相近、但 alarm 邏輯要重寫、而且 Pub/Sub 的 backlog 數字要配合 subscription 維度看 — 同一個 topic 的不同 subscription 各自有 backlog、一個堵住不代表全部堵住。遷移時要把原本對 queue 深度的告警、改成對每個 subscription 的 backlog 與 age 告警。</p>
<h2 id="消費抽象重設計從一條-queue-到-topic-加多-subscription">消費抽象重設計：從一條 queue 到 topic 加多 subscription</h2>
<p>這是 components 跟 data topology 兩個高維度的核心、也是從 SQS 搬到 Pub/Sub 最需要重新畫圖的地方。SQS 的世界裡、一條 queue 對應一群競爭領取的 worker；要扇出就在前面架 SNS、SNS 後面接多條 SQS、每條 queue 各一群 worker。Pub/Sub 把這個拓樸壓平：一個 topic 收訊息、掛多少個 subscription 就有多少條獨立的消費流、每個 subscription 各自記進度、彼此不影響。</p>
<p>重設計從盤點現有拓樸開始。先列出：哪些是「單一 queue、一群 worker」的簡單情境、哪些是「SNS fan-out 到多條 SQS」的扇出情境。簡單情境對位乾淨 — 一個 topic、一個 pull subscription、原本競爭領取的 worker 改成同一個 subscription 的多個 consumer、Pub/Sub 自動把訊息分給它們。扇出情境要把 SNS + 多 SQS 換成「一個 topic + 多 subscription」、原本每條 SQS queue 變成一個 subscription、SNS 那一層消失。</p>
<p>扇出情境裡有個方向相反的陷阱要避免：不要把「多個下游」誤設計成「多個 consumer 共用一個 subscription」。同一個 subscription 的多個 consumer 是 <em>競爭</em> 關係、訊息只會給其中一個 — 那是負載分攤、不是扇出。要每個下游都收到完整一份、就要每個下游一個 <em>獨立</em> subscription。這跟 SQS 端「一條 queue 一個下游、扇出靠 SNS 複製」的直覺方向一致、但實體換了：在 SQS 是多條 queue、在 Pub/Sub 是多個 subscription。畫遷移圖時、SQS 的每條 fan-out queue 一對一映射到 Pub/Sub 的一個 subscription、不要合併。</p>
<h2 id="application-重設計範例sqs-receive-delete-換成-pubsub-pull-ack">Application 重設計範例：SQS receive-delete 換成 Pub/Sub pull-ack</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// SQS 端：long polling receive、處理完 DeleteMessage</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">svc</span> <span class="o">:=</span> <span class="nx">sqs</span><span class="p">.</span><span class="nf">NewFromConfig</span><span class="p">(</span><span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">out</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">svc</span><span class="p">.</span><span class="nf">ReceiveMessage</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">sqs</span><span class="p">.</span><span class="nx">ReceiveMessageInput</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">QueueUrl</span><span class="p">:</span>            <span class="o">&amp;</span><span class="nx">queueURL</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">MaxNumberOfMessages</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">WaitTimeSeconds</span><span class="p">:</span>     <span class="mi">20</span><span class="p">,</span> <span class="c1">// long polling</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">m</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">out</span><span class="p">.</span><span class="nx">Messages</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nf">process</span><span class="p">(</span><span class="nx">m</span><span class="p">.</span><span class="nx">Body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">svc</span><span class="p">.</span><span class="nf">DeleteMessage</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">sqs</span><span class="p">.</span><span class="nx">DeleteMessageInput</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">QueueUrl</span><span class="p">:</span>      <span class="o">&amp;</span><span class="nx">queueURL</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">ReceiptHandle</span><span class="p">:</span> <span class="nx">m</span><span class="p">.</span><span class="nx">ReceiptHandle</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// Pub/Sub 端：streaming pull、處理完 Ack、用 flow control 節流</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">sub</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">Subscription</span><span class="p">(</span><span class="s">&#34;orders-sub&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">sub</span><span class="p">.</span><span class="nx">ReceiveSettings</span><span class="p">.</span><span class="nx">MaxOutstandingMessages</span> <span class="p">=</span> <span class="mi">100</span> <span class="c1">// flow control、取代「一次拉 10 條」</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">err</span> <span class="o">:=</span> <span class="nx">sub</span><span class="p">.</span><span class="nf">Receive</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">msg</span> <span class="o">*</span><span class="nx">pubsub</span><span class="p">.</span><span class="nx">Message</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">process</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">Data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nf">Ack</span><span class="p">()</span> <span class="c1">// 取代 DeleteMessage；client library 在背景自動延長 ack deadline</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>差異：</p>
<ul>
<li>SQS 主動輪詢（ReceiveMessage 迴圈）→ Pub/Sub 回呼模型（Receive 把訊息推進 callback）</li>
<li>SQS DeleteMessage → Pub/Sub msg.Ack()、語意都是「確認處理完、別重投」</li>
<li>SQS WaitTimeSeconds 控制輪詢等待 → Pub/Sub MaxOutstandingMessages 控制 flow control</li>
<li>SQS 一次最多 10 條的批次上限 → Pub/Sub 沒有這個上限、改用 flow control 設同時在途量</li>
<li>ack deadline 的延長在 SQS 要主動 ChangeMessageVisibility、在 Pub/Sub 由 client library 自動處理</li>
</ul>
<p>application 邏輯的商業處理部分（process 函式）多數可保留、改動集中在收訊息的框架跟確認語意、估計 20-40% 程式碼。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1fan-out-設計成共用-subscription下游各收到一部分">Case 1：fan-out 設計成共用 subscription、下游各收到一部分</h3>
<p><strong>徵兆</strong>：把原本 SNS fan-out 到 3 條 SQS 的拓樸搬到 Pub/Sub、為了省事建一個 topic + 一個 subscription、讓 3 個下游服務都連這個 subscription。上線後發現每個下游只收到大約三分之一的訊息、不是各收完整一份。</p>
<p><strong>根因</strong>：同一個 subscription 的多個 consumer 是負載分攤關係、Pub/Sub 把訊息分給其中一個 consumer、不是每個都送。這對應到 SQS 端「一條 queue 多個 worker 競爭領取」的行為、但被誤用在需要扇出的場景。SQS 端的扇出靠 SNS 複製訊息到多條 queue、那個複製動作在 Pub/Sub 應該由「多個 subscription」承擔、不是多個 consumer 共用一個 subscription。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>每個下游一個獨立 subscription</strong>：3 個下游就建 3 個 subscription 掛同一個 topic、每個各收完整一份</li>
<li><strong>遷移圖一對一映射</strong>：SQS 的每條 fan-out queue 對應一個 Pub/Sub subscription、不合併</li>
<li><strong>負載分攤跟扇出分開設計</strong>：同一下游要多 worker 分攤、是同一 subscription 多 consumer；不同下游各收一份、是多 subscription</li>
</ol>
<h3 id="case-2ack-deadline-沿用-sqs-數字太短長任務反覆重投">Case 2：ack deadline 沿用 SQS 數字太短、長任務反覆重投</h3>
<p><strong>徵兆</strong>：SQS 端 visibility timeout 設 5 分鐘跑得好好的、搬到 Pub/Sub 隨手把 ack deadline 設成預設或一個小數字、結果處理時間稍長的訊息被反覆重投、同一筆訊息處理多次、下游出現重複副作用。</p>
<p><strong>根因</strong>：Pub/Sub ack deadline 預設 10 秒、上限 600 秒、跟 SQS visibility timeout 上限 12 小時差很多。如果關掉 client library 的自動 lease extension、又把 ack deadline 設小、處理時間一超過就被判定失敗重投。SQS 的「設一個夠長的 timeout」直覺搬過來不適用、因為 Pub/Sub 的上限低很多、且延長機制是 client library 自動做。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>理解 client library 的 lease 行為</strong>：多數 client library 預設會背景自動延長 ack deadline 到處理完、優先依賴這個而不是手動設超長 deadline</li>
<li><strong>長任務拆短或改架構</strong>：單筆處理超過 10 分鐘上限的、考慮拆成多階段或把長任務移出訊息處理路徑</li>
<li><strong>下游做 idempotency</strong>：跟 SQS 一樣、Pub/Sub 是 at-least-once、重投本來就會發生、下游用 message ID 去重才是根本解</li>
</ol>
<h3 id="case-3fifo-順序需求對位到-ordering-key吞吐落差超出預期">Case 3：FIFO 順序需求對位到 ordering key、吞吐落差超出預期</h3>
<p><strong>徵兆</strong>：原系統用 SQS FIFO queue + MessageGroupId 保證同一群訊息順序處理、搬到 Pub/Sub 啟用 ordering key 對位、上線後吞吐比預期低很多、且某些情境順序仍亂。</p>
<p><strong>根因</strong>：SQS FIFO 跟 Pub/Sub ordering key 都提供順序、但取捨點不同。SQS FIFO 同時給「順序」跟「5 分鐘去重窗口」、吞吐受限（每 MessageGroupId 串行）。Pub/Sub ordering key 給「同一 key 的訊息按 publish 順序送達」、但要 publish 端跟 subscription 端都正確設定（publish 要設 ordering key、subscription 要 enableMessageOrdering）、漏一邊順序就不保證；而且啟用 ordering 後同一 key 串行、吞吐同樣受限。把 FIFO 的「去重 + 順序」一包功能、誤以為 ordering key 也一包提供、是落差來源。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>拆開「順序」跟「去重」兩個需求</strong>：Pub/Sub ordering key 只管順序、去重要 application 端自己用 message ID 做</li>
<li><strong>publish 跟 subscription 兩端都設 ordering</strong>：缺一邊順序不保證、遷移檢查清單要把兩端都列上</li>
<li><strong>重新評估是否真需要全域順序</strong>：FIFO 常被過度使用、很多場景只需要 per-entity 順序、用 ordering key 按 entity 分 key、比強制全域串行吞吐高很多</li>
</ol>
<h3 id="case-4跨雲遷移期雙雲都在跑egress-成本與延遲被低估">Case 4：跨雲遷移期雙雲都在跑、egress 成本與延遲被低估</h3>
<p><strong>徵兆</strong>：漸進 cutover 期間 AWS 跟 GCP 兩邊都在處理訊息、為了對帳把訊息在兩雲之間搬、月底帳單跨雲 egress 費用遠超預估、且跨雲呼叫的尾延遲拖慢端到端處理。</p>
<p><strong>根因</strong>：SQS 在 AWS region 內、Pub/Sub 在 GCP、遷移期的 dual publish 或對帳如果讓資料反覆跨雲、每一筆出 AWS 的訊息都計 egress 費。跨雲不只是錢、跨雲網路的延遲跟抖動比同雲高、放在同步處理路徑上會放大尾延遲。同雲 vendor 切換沒有這個維度、跨雲遷移必須把它列進成本模型。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>縮短雙雲並行窗口</strong>：dual publish 的對帳期越短越省、設明確的並行截止日、不要無限期雙跑</li>
<li><strong>對帳用抽樣不用全量搬運</strong>：驗證一致性用抽樣比對 message ID / count、不要把所有訊息都搬到對面雲比對</li>
<li><strong>生產者就近落點</strong>：遷移後讓 producer 直接 publish 到 Pub/Sub、不要繞 AWS 再跨雲、消除穩態的跨雲 egress</li>
</ol>
<h3 id="case-5dead-letter-topic-權限沒配齊毒訊息卡住不進-dlt">Case 5：dead-letter topic 權限沒配齊、毒訊息卡住不進 DLT</h3>
<p><strong>徵兆</strong>：subscription 設了 dead-letter topic 跟 max delivery attempt、預期重試超限的訊息進 DLT、實際上毒訊息一直在原 subscription 反覆重投、DLT 是空的、後續訊息被堵。</p>
<p><strong>根因</strong>：Pub/Sub 要把訊息送進 DLT、是由 Pub/Sub 的 service account 代為 publish 到 DLT topic；同時它也要對原 subscription 有 subscriber 權限才能 ack 掉原訊息。這兩個權限漏任一個、forwarding 到 DLT 就失敗、訊息卡在原 subscription。SQS 端 DLQ 是 queue 屬性、不需要額外給 service 權限、所以這個跨雲差異容易被漏掉。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>配齊 DLT 雙權限</strong>：給 Pub/Sub service account 對 DLT topic 的 publisher role、跟對原 subscription 的 subscriber role</li>
<li><strong>遷移後做毒訊息演練</strong>：故意 publish 一筆會失敗的訊息、確認它真的在 max attempt 後進 DLT、不是卡在原 subscription</li>
<li><strong>監控 DLT backlog</strong>：DLT 開一個 subscription 監控其 num_undelivered_messages、確認毒訊息有被導流且有人處理、對照 <a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari DLT 案例</a>的設計</li>
</ol>
<h2 id="漸進-cutoverdual-publish-加雙消費對帳">漸進 cutover：dual publish 加雙消費對帳</h2>
<p>跨雲遷移風險高、不適合一次切換、走漸進 cutover 把可逆邊界拉長：</p>
<ol>
<li><strong>Phase 0：拓樸盤點</strong> — 列出所有 SQS queue、標記哪些是單一 queue、哪些是 SNS fan-out、各自映射到 Pub/Sub 的 topic / subscription 結構</li>
<li><strong>Phase 1：Pub/Sub 端建好對位資源</strong> — 建 topic / subscription / DLT、配齊 IAM 與 service account、重建 Cloud Monitoring 告警、application 寫好 Pub/Sub consumer 但先不收流量</li>
<li><strong>Phase 2：dual publish</strong> — producer 同時 publish 到 SQS 跟 Pub/Sub、兩邊 consumer 都跑、Pub/Sub 端的處理結果先寫到隔離區或標記、不影響正式下游</li>
<li><strong>Phase 3：雙消費對帳</strong> — 抽樣比對兩邊處理的訊息 ID 與數量、確認 Pub/Sub 端沒漏、沒重複到無法接受的程度、ack deadline / fan-out / ordering 行為都符合預期</li>
<li><strong>Phase 4：流量切換</strong> — 對帳通過後、把正式下游切到 Pub/Sub 端、SQS 端轉成備援、保留一段觀察期可回切</li>
<li><strong>Phase 5：下線 SQS</strong> — 觀察期穩定後停掉 dual publish、移除 SQS 資源、消除穩態跨雲 egress（這是不可逆階段、不要在對帳沒過時提前做）</li>
</ol>
<p>對帳期是這套流程的核心保險、也是 Case 4 跨雲成本的來源 — 對帳用抽樣、並行窗口設明確截止日、平衡「驗證信心」跟「雙雲成本」。</p>
<h2 id="capacity--cost-對照">Capacity / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>AWS SQS</th>
          <th>Google Pub/Sub</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>計費模型</td>
          <td>每百萬 request（含 send / receive / delete）</td>
          <td>按 throughput（publish + subscribe 的資料量計費）</td>
      </tr>
      <tr>
          <td>Region 模型</td>
          <td>Region-scoped、跨 region 自己處理</td>
          <td>Global topic、無 region 概念</td>
      </tr>
      <tr>
          <td>扇出成本</td>
          <td>SNS + 多 SQS、每條 queue 各計費</td>
          <td>一個 topic 多 subscription、按各 subscription throughput</td>
      </tr>
      <tr>
          <td>訊息保留</td>
          <td>預設 4 天、上限 14 天</td>
          <td>預設 7 天、可調</td>
      </tr>
      <tr>
          <td>順序成本</td>
          <td>FIFO queue 比 standard 貴</td>
          <td>ordering key 啟用後吞吐受限、計費同 standard</td>
      </tr>
      <tr>
          <td>跨雲 egress</td>
          <td>出 AWS 計 egress</td>
          <td>出 GCP 計 egress；穩態應讓 producer 就近 publish</td>
      </tr>
      <tr>
          <td>監控</td>
          <td>CloudWatch（隨用量計費）</td>
          <td>Cloud Monitoring</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：穩態成本兩者量級相近、真正的成本差在 <em>遷移期</em> — dual publish 雙雲並行加跨雲對帳搬運是一次性高峰、不是穩態。把這段窗口縮短、是控制跨雲遷移成本的關鍵、不是去比 SQS 跟 Pub/Sub 的單價。扇出重度的系統遷到 Pub/Sub 後、少掉 SNS 那一層、扇出的計費結構也變簡單。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="遷移後事件可直接落-gcp-資料平台">遷移後事件可直接落 GCP 資料平台</h3>
<p>遷到 Pub/Sub 的一個結構性好處、是事件可以用 BigQuery subscription 直接寫進 BigQuery、不需要再寫 Dataflow pipeline 搬運；或用 Cloud Storage subscription 批次落 GCS。這正是「workload 重心在 GCP」這條 driver 的回報 — 事件層跟資料平台同雲、省掉跨雲搬運。這也是評估是否該跨雲遷移時、要放進 ROI 的一邊。</p>
<h3 id="跟-kafka-遷移的結構對照">跟 Kafka 遷移的結構對照</h3>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>主導差異維度</th>
          <th>結構</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS</a></td>
          <td>Paradigm（高）</td>
          <td>partial + 長期混合</td>
      </tr>
      <tr>
          <td>SQS → Pub/Sub（本篇）</td>
          <td>Operational（高）+ components / topology（高）</td>
          <td>operational hybrid + 高維度獨立段</td>
      </tr>
  </tbody>
</table>
<p><strong>結論</strong>：SQS → Pub/Sub 不是 paradigm shift（兩端都是 cloud-managed 訊息服務、可收斂成單一目標）、是 operational redesign 為主、消費抽象重設計為輔的跨雲遷移；結構由主導差異維度（operational）決定主軸、高維度（components / topology）抽獨立段補充。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source / target vendor：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a> / <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a></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 模型">Kafka</a> / <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> / <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></li>
<li>平行 migration playbook：<a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS</a></li>
<li>引用案例：<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari Item Feed DLT</a> / <a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61 Spotify autoscaling</a></li>
<li>Methodology：<a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration Playbook 寫作方法論</a></li>
<li>上游概念：<a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步選型</a> / <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
</ul>
]]></content:encoded></item><item><title>Google Pub/Sub push vs pull：不是實作偏好，是下游容量的判讀</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a> overview 的 implementation-layer deep article。選型層（Pub/Sub vs Kafka / SQS）見 overview；本文只處理「決定用 Pub/Sub 後，subscription 與 ack 怎麼設」。Pub/Sub 是 managed SaaS、無法本機 docker 驗證，本文 config 依 &lt;a href="https://cloud.google.com/pubsub/docs/subscriber">Pub/Sub 官方文件&lt;/a> 與下列 production case、最後檢查日 2026-06-16；引數與計費以官方為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="push-vs-pull-不是實作偏好">push vs pull 不是實作偏好&lt;/h2>
&lt;p>把 Pub/Sub 的 subscription 設成 push 還是 pull，常被當成「看團隊習慣」的實作選擇。但它其實是一個關於下游容量的判讀。差別在流量控制權在誰手上：push subscription 由 Pub/Sub 主動把訊息 HTTP POST 到目標 endpoint——流量節奏由 Pub/Sub 決定，尖峰時瞬間打過來；pull subscription 由 consumer 主動拉，要拉多少、多快由 consumer 自己控制。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari 的 LINE 整合&lt;/a>把這個判讀講得很具體：Braze webhook 進來轉成 Pub/Sub event，下游要呼叫 LINE API——而 &lt;strong>LINE API 有 RPS 限制&lt;/strong>。如果用 push，Pub/Sub 會把訊息瞬間打到 worker、worker 再打 LINE、直接超過 LINE 的 RPS 上限。所以他們用 pull subscription，worker「精確控制每秒處理訊息數」來對齊 LINE 的限制。這個案例揭露的原則是——&lt;strong>push vs pull 不是實作偏好，是「下游能不能承受 push 的流量衝擊」的判讀&lt;/strong>：下游有速率限制、處理能力有限、或需要平滑流量，就走 pull 自我節流。&lt;/p>
&lt;p>本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic——這些決定了訊息怎麼被可靠地、以下游能承受的速度消費。&lt;/p>
&lt;h2 id="核心概念subscriptionack-deadline-與-flow-control">核心概念：subscription、ack deadline 與 flow control&lt;/h2>
&lt;p>Pub/Sub 把「topic（發布）」跟「subscription（訂閱）」分開，可靠消費的旋鈕都在 subscription 上。&lt;/p>
&lt;p>&lt;strong>一個 topic、多個 subscription、各自獨立&lt;/strong>。發布者發到 topic，每個 subscription 收到一份完整的訊息流、各自維護消費進度。這天然支援 fanout（多個服務各建一個 subscription）。&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari 的另一個案例&lt;/a>還揭露 topic 的雙重角色——它同時是「dispatch」跟「load-leveling buffer」，突發流量先進 topic 緩衝、consumer 按自己節奏消化。&lt;/p>
&lt;p>&lt;strong>ack deadline 是 Pub/Sub 版的可見性逾時&lt;/strong>。consumer 收到訊息後，有一段 ack deadline 來處理並 &lt;code>ack&lt;/code>。在 deadline 內沒 ack，Pub/Sub 重新投遞（at-least-once）。跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &amp;#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &amp;#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &amp;lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout&lt;/a> 同樣是雙邊風險：太短→處理中就重投、太長→失敗後恢復慢。處理中可用 &lt;code>modifyAckDeadline&lt;/code>（client library 通常自動 lease extension）延長。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a> overview 的 implementation-layer deep article。選型層（Pub/Sub vs Kafka / SQS）見 overview；本文只處理「決定用 Pub/Sub 後，subscription 與 ack 怎麼設」。Pub/Sub 是 managed SaaS、無法本機 docker 驗證，本文 config 依 <a href="https://cloud.google.com/pubsub/docs/subscriber">Pub/Sub 官方文件</a> 與下列 production case、最後檢查日 2026-06-16；引數與計費以官方為準。</p></blockquote>
<h2 id="push-vs-pull-不是實作偏好">push vs pull 不是實作偏好</h2>
<p>把 Pub/Sub 的 subscription 設成 push 還是 pull，常被當成「看團隊習慣」的實作選擇。但它其實是一個關於下游容量的判讀。差別在流量控制權在誰手上：push subscription 由 Pub/Sub 主動把訊息 HTTP POST 到目標 endpoint——流量節奏由 Pub/Sub 決定，尖峰時瞬間打過來；pull subscription 由 consumer 主動拉，要拉多少、多快由 consumer 自己控制。</p>
<p><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari 的 LINE 整合</a>把這個判讀講得很具體：Braze webhook 進來轉成 Pub/Sub event，下游要呼叫 LINE API——而 <strong>LINE API 有 RPS 限制</strong>。如果用 push，Pub/Sub 會把訊息瞬間打到 worker、worker 再打 LINE、直接超過 LINE 的 RPS 上限。所以他們用 pull subscription，worker「精確控制每秒處理訊息數」來對齊 LINE 的限制。這個案例揭露的原則是——<strong>push vs pull 不是實作偏好，是「下游能不能承受 push 的流量衝擊」的判讀</strong>：下游有速率限制、處理能力有限、或需要平滑流量，就走 pull 自我節流。</p>
<p>本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic——這些決定了訊息怎麼被可靠地、以下游能承受的速度消費。</p>
<h2 id="核心概念subscriptionack-deadline-與-flow-control">核心概念：subscription、ack deadline 與 flow control</h2>
<p>Pub/Sub 把「topic（發布）」跟「subscription（訂閱）」分開，可靠消費的旋鈕都在 subscription 上。</p>
<p><strong>一個 topic、多個 subscription、各自獨立</strong>。發布者發到 topic，每個 subscription 收到一份完整的訊息流、各自維護消費進度。這天然支援 fanout（多個服務各建一個 subscription）。<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari 的另一個案例</a>還揭露 topic 的雙重角色——它同時是「dispatch」跟「load-leveling buffer」，突發流量先進 topic 緩衝、consumer 按自己節奏消化。</p>
<p><strong>ack deadline 是 Pub/Sub 版的可見性逾時</strong>。consumer 收到訊息後，有一段 ack deadline 來處理並 <code>ack</code>。在 deadline 內沒 ack，Pub/Sub 重新投遞（at-least-once）。跟 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout</a> 同樣是雙邊風險：太短→處理中就重投、太長→失敗後恢復慢。處理中可用 <code>modifyAckDeadline</code>（client library 通常自動 lease extension）延長。</p>
<p><strong>flow control 限制 client 端同時持有的未 ack 量</strong>。pull subscription 的 client library 可設 <code>max_outstanding_messages</code> / <code>max_outstanding_bytes</code>——consumer 最多同時持有多少未 ack 訊息。這是 consumer 端自我節流的旋鈕，避免一次拉太多撐爆自己或下游。Mercari 對齊 LINE RPS 靠的就是這層控制。</p>
<p><strong>dead-letter topic（DLT）給毒訊息出口</strong>。subscription 設 dead-letter policy（<code>maxDeliveryAttempts</code> + dead-letter topic）後，重投超過上限的訊息被轉到 DLT，不再阻塞後續。Mercari item feed 正是「重試多次仍失敗送 DLT、後續訊息優先處理」——避免 poison message 卡住 pipeline。</p>
<h2 id="配置subscription--ack-deadline--dlt依官方文件">配置：subscription + ack deadline + DLT（依官方文件）</h2>
<p>Pub/Sub 是 managed、以下 gcloud 依官方文件（未本機 docker 驗證、引數以官方為準）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 建 topic + dead-letter topic</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics create orders
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">gcloud pubsub topics create orders-dlt
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 2. pull subscription：ack deadline + dead-letter policy</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud pubsub subscriptions create orders-worker <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>orders <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">60</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --dead-letter-topic<span class="o">=</span>orders-dlt <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --max-delivery-attempts<span class="o">=</span><span class="m">5</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 3. consumer 端 flow control（client library、以 Python 為例、概念跨語言一致）</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">#    flow_control = FlowControl(max_messages=100, max_bytes=10*1024*1024)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">#    subscriber.subscribe(sub_path, callback=handle, flow_control=flow_control)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1">#    handle 內：處理成功 message.ack()、失敗 message.nack()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># push subscription（僅當下游能承受 Pub/Sub 主動推的流量時）：</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># gcloud pubsub subscriptions create orders-push \</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1">#   --topic=orders --push-endpoint=https://my-svc/handler --ack-deadline=60</span></span></span></code></pre></div><p>判讀：</p>
<ul>
<li>下游有 RPS 限制 / 處理能力有限 → pull + flow control（self-throttle，Mercari 模式）</li>
<li>下游能吸收推送尖峰、要 serverless 簡單 → push</li>
<li><code>ack-deadline</code> 略高於處理時間；長任務靠 client library 的 lease extension</li>
<li><code>max-delivery-attempts</code> + DLT 給毒訊息出口</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1用-push下游被瞬間流量打爆">Case 1：用 push、下游被瞬間流量打爆</h3>
<p><strong>徵兆</strong>：流量尖峰時下游 endpoint 5xx 暴增、或下游的第三方 API 回 429（rate limited），訊息大量重投惡化。</p>
<p><strong>根因</strong>：用 push subscription，Pub/Sub 把訊息瞬間 POST 到 endpoint，超過下游（或下游依賴的外部 API）的處理 / 速率上限。正是 <a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari LINE</a> 要避開的情形。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>下游有速率限制改用 pull subscription + flow control，由 consumer 自我節流</li>
<li>flow control 的 <code>max_outstanding_messages</code> 對齊下游能承受的並發</li>
<li>push 只用在下游能吸收推送尖峰的場景</li>
<li>push 場景下游要自己擋（rate limit / 佇列），不能假設 Pub/Sub 會幫你平滑</li>
</ol>
<h3 id="case-2ack-deadline-太短訊息處理中就被重投">Case 2：ack deadline 太短、訊息處理中就被重投</h3>
<p><strong>徵兆</strong>：同一則訊息被處理多次，尤其處理較慢時；訂閱的 redelivery 指標偏高。</p>
<p><strong>根因</strong>：ack deadline 設得比處理時間短，訊息在處理途中 deadline 到期、Pub/Sub 重投。跟 SQS visibility timeout 太短同類。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>ack deadline 設成略高於處理時間 p99</li>
<li>用 client library 的自動 lease extension（modifyAckDeadline）處理長尾任務</li>
<li>消費端冪等——at-least-once 本來就可能重投（見 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency</a>）</li>
<li>監控 redelivery 率，偏高代表 deadline 偏短或處理變慢</li>
</ol>
<h3 id="case-3沒設-dlt毒訊息一直重投阻塞">Case 3：沒設 DLT、毒訊息一直重投阻塞</h3>
<p><strong>徵兆</strong>：某則訊息一直失敗、一直被重投，後續訊息處理被拖慢。</p>
<p><strong>根因</strong>：subscription 沒設 dead-letter policy。處理失敗（nack 或沒 ack）的訊息一再重投、沒有上限與出口，毒訊息反覆消耗 consumer。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 dead-letter policy（<code>max-delivery-attempts</code> + DLT），重投達上限轉 DLT</li>
<li>DLT 是另一個 topic，要有處理 / 告警流程（Mercari「送 DLT、後續訊息優先處理」）</li>
<li><code>max-delivery-attempts</code> 平衡暫時性失敗重試與毒訊息隔離</li>
<li>對照 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS redrive</a>：兩者都是 managed 原生 DLQ/DLT、比自建省事</li>
</ol>
<h3 id="case-4flow-control-沒設consumer-一次拉太多撐爆">Case 4：flow control 沒設、consumer 一次拉太多撐爆</h3>
<p><strong>徵兆</strong>：consumer 記憶體暴增 / OOM，或一次拉太多把下游打爆。</p>
<p><strong>根因</strong>：pull subscription 沒設 flow control，client library 預設可能持有大量未 ack 訊息，consumer 端記憶體與下游壓力失控。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 <code>max_outstanding_messages</code> / <code>max_outstanding_bytes</code> 限制同時持有量</li>
<li>對齊 consumer 處理能力與下游容量（Mercari 對齊 LINE RPS）</li>
<li>監控 consumer 記憶體與未 ack 數，調 flow control 參數</li>
<li>flow control 是 pull 自我節流的核心，不設等於放棄背壓</li>
</ol>
<h3 id="case-5誤用-ordering-key吞吐受限">Case 5：誤用 ordering key、吞吐受限</h3>
<p><strong>徵兆</strong>：開了 message ordering 後吞吐明顯下降、特定 ordering key 的訊息處理變慢。</p>
<p><strong>根因</strong>：Pub/Sub 的順序保證是 per-ordering-key 的——同一個 ordering key 的訊息嚴格按序、必須序列處理（前一則 ack 才處理下一則）。把所有訊息塞同一個 ordering key 等於序列化整條流、吞吐崩。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>ordering key 用細粒度（per-entity，如 per-user），讓不同 key 可並行</li>
<li>不需要嚴格順序的就別開 ordering（預設無序、吞吐高）</li>
<li>評估順序需求的真實範圍——多數場景只需 per-entity 順序，不是全域</li>
<li>嚴格全域順序 + 高吞吐有本質衝突，重新審視需求或走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> 的 partition 模型</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>Pub/Sub 的容量判讀（managed、無 broker 運維）：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>subscription backlog（未 ack 數 / 最舊訊息 age）</td>
          <td>在 SLA 內</td>
          <td>持續成長 → consumer 跟不上、加 consumer / 調 flow control</td>
      </tr>
      <tr>
          <td>redelivery 率</td>
          <td>低</td>
          <td>偏高 → ack deadline 太短 / 下游失敗</td>
      </tr>
      <tr>
          <td>DLT 深度</td>
          <td>低且有處理流程</td>
          <td>成長 → 上游系統性失敗</td>
      </tr>
      <tr>
          <td>consumer 記憶體 / 未 ack 量</td>
          <td>在 flow control 限制內</td>
          <td>暴增 → flow control 沒設好</td>
      </tr>
      <tr>
          <td>訊息量（計費基礎）</td>
          <td>對齊預算</td>
          <td>暴增 → 評估 throughput 計費、batch / 壓縮</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要長期保留 + 任意 replay</strong>：Pub/Sub 有 retention（可設、seek 到時間點）但事件流長期 replay + 生態走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。</li>
<li><strong>嚴格全域順序 + 高吞吐</strong>：Pub/Sub ordering 是 per-key 序列化，全域順序高吞吐走 Kafka partition 設計。</li>
<li><strong>不在 GCP 生態</strong>：Pub/Sub 綁 GCP，跨雲走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> / <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> 或對應雲的 managed（<a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS</a>）。</li>
<li><strong>複雜 routing（topic exchange 式）</strong>：Pub/Sub 是 topic→subscription 扇出，複雜 routing 規則走 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> exchange。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>push/pull 判讀與 ack 是 Pub/Sub 可靠消費的核心，它跟其他議題交織：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a></strong>：push/pull、ack deadline、flow control 是 consumer 設計的具體選項。</li>
<li><strong>跟 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></strong>：at-least-once + 重投要求消費冪等。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout</a></strong>：ack deadline 對應 visibility timeout、DLT 對應 redrive，兩個 managed queue 的可靠消費模型高度對位、可對照閱讀。</li>
<li><strong>跟 webhook buffer 模式</strong>：Pub/Sub topic 當 load-leveling buffer（Mercari）對應 <a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">SQS Twilio webhook buffer</a>——把不可控的外部 webhook 流量先緩衝再按自己節奏消化。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a></li>
<li>對照 vendor：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">AWS SQS visibility timeout</a>、<a href="/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/" data-link-title="RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首" data-link-desc="RabbitMQ 處理失敗訊息最常見的錯是直接 requeue 回原隊列——它回到隊首、反覆失敗、把後面的訊息全卡住（head-of-line blocking）。正解是用 dead-letter exchange &#43; TTL 組出 work → delay → DLQ 的分層 escalation。本文展開 DLX 求值模型、實機驗證的三層拓樸、5 個把 retry 寫成無限迴圈與隊列阻塞的 production 踩坑，以及 retry 拓樸的容量邊界">RabbitMQ DLQ</a></li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65 Mercari LINE flow control</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari item feed DLT</a></li>
<li>上游概念：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a></li>
</ul>
]]></content:encoded></item><item><title>Pub/Sub Ordering Key、Dead-Letter Topic 與 Schema Enforcement：三道交付治理</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a> overview 的 implementation-layer deep article。Overview 回答「Pub/Sub 該不該選、跟 Kafka / SQS 差在哪」；本文回答「ordering key 怎麼設、DLT 怎麼擋 poison message、schema 怎麼守契約，各自踩哪些坑」。閱讀前可先讀 overview 的 ordering / DLT / schema 各段建立 context。&lt;/p>
&lt;p>文中 gcloud 指令的語法以 Pub/Sub emulator 實機驗證（topic / subscription / schema / ordering key / DLT / push 各操作均跑通），標準版的雲端配額、IAM 與計費行為依官方文件。&lt;/p>&lt;/blockquote>
&lt;h2 id="三道治理共用同一個交付骨架">三道治理共用同一個交付骨架&lt;/h2>
&lt;p>Pub/Sub 的 ordering key、dead-letter topic、schema enforcement 看似三個獨立功能，實際都掛在同一個交付骨架上：subscription 是消費進度的 first-class 抽象、訊息經 ackDeadline 控制重投、失敗訊息經投遞次數計數決定去留。理解這個骨架之後，三道治理只是骨架上的三個切面 — ordering 切的是「投遞順序」、DLT 切的是「投遞次數上限」、schema 切的是「投遞前的內容守門」。&lt;/p>
&lt;p>這條骨架跟 Kafka 思路不同。Kafka 的消費進度綁在 consumer group + partition offset；Pub/Sub 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 是 first-class，subscription 才是 consumer 抽象，一個 topic 可以掛 N 個 subscription、各自有獨立進度與獨立的 ackDeadline / DLT / ordering 設定。同一份 event 流，A subscription 可以開 ordering 嚴格有序、B subscription 可以不開 ordering 換吞吐，互不影響。&lt;/p>
&lt;p>把這三道治理寫進一篇的理由是：它們在 production 會互相牽制。Ordering key 開了之後 DLT 的隔離行為會變（有序流裡一則 poison message 會卡住整把 key 的後續訊息）；schema enforcement 擋下的不相容 publish 不會進 DLT（根本沒進 topic）。分開讀三個官方頁面看不到這層耦合。&lt;/p>
&lt;h2 id="subscription-是-first-classackdeadline-與-extension">subscription 是 first-class：ackDeadline 與 extension&lt;/h2>
&lt;p>subscription 承擔「這個消費者讀到哪、還有多少沒 ack」的責任。每則訊息投遞給 subscriber 後，Pub/Sub 啟動一個 ackDeadline 倒數；倒數內收到 ack 就移除訊息、倒數結束沒收到 ack 就重投。預設 ackDeadline 是 10 秒、上限 600 秒。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># subscription 的 ackDeadline 預設 10 秒、retention 預設 7 天&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">gcloud pubsub subscriptions describe demo-sub
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1"># ackDeadlineSeconds: 10&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># messageRetentionDuration: 604800s # 7 天&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建 subscription 時可顯式設更長的 ackDeadline 與更短的 retention&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">gcloud pubsub subscriptions create cfg-sub &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --topic&lt;span class="o">=&lt;/span>demo-topic &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --ack-deadline&lt;span class="o">=&lt;/span>&lt;span class="m">120&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --message-retention-duration&lt;span class="o">=&lt;/span>3d
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># ackDeadlineSeconds: 120&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># messageRetentionDuration: 259200s # 3 天&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ackDeadline 是一道「處理時間預算」。設太短，處理還沒完訊息就被重投，consumer 會收到重複；設太長，consumer crash 後訊息要等滿 deadline 才重投，延遲拉高。長任務不靠把 ackDeadline 一次設到 600 秒解決，而是靠 ack deadline extension：consumer 在處理中週期性發 &lt;code>modifyAckDeadline&lt;/code> 把單則訊息的 deadline 往後延，處理完才 ack。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a> overview 的 implementation-layer deep article。Overview 回答「Pub/Sub 該不該選、跟 Kafka / SQS 差在哪」；本文回答「ordering key 怎麼設、DLT 怎麼擋 poison message、schema 怎麼守契約，各自踩哪些坑」。閱讀前可先讀 overview 的 ordering / DLT / schema 各段建立 context。</p>
<p>文中 gcloud 指令的語法以 Pub/Sub emulator 實機驗證（topic / subscription / schema / ordering key / DLT / push 各操作均跑通），標準版的雲端配額、IAM 與計費行為依官方文件。</p></blockquote>
<h2 id="三道治理共用同一個交付骨架">三道治理共用同一個交付骨架</h2>
<p>Pub/Sub 的 ordering key、dead-letter topic、schema enforcement 看似三個獨立功能，實際都掛在同一個交付骨架上：subscription 是消費進度的 first-class 抽象、訊息經 ackDeadline 控制重投、失敗訊息經投遞次數計數決定去留。理解這個骨架之後，三道治理只是骨架上的三個切面 — ordering 切的是「投遞順序」、DLT 切的是「投遞次數上限」、schema 切的是「投遞前的內容守門」。</p>
<p>這條骨架跟 Kafka 思路不同。Kafka 的消費進度綁在 consumer group + partition offset；Pub/Sub 的 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 是 first-class，subscription 才是 consumer 抽象，一個 topic 可以掛 N 個 subscription、各自有獨立進度與獨立的 ackDeadline / DLT / ordering 設定。同一份 event 流，A subscription 可以開 ordering 嚴格有序、B subscription 可以不開 ordering 換吞吐，互不影響。</p>
<p>把這三道治理寫進一篇的理由是：它們在 production 會互相牽制。Ordering key 開了之後 DLT 的隔離行為會變（有序流裡一則 poison message 會卡住整把 key 的後續訊息）；schema enforcement 擋下的不相容 publish 不會進 DLT（根本沒進 topic）。分開讀三個官方頁面看不到這層耦合。</p>
<h2 id="subscription-是-first-classackdeadline-與-extension">subscription 是 first-class：ackDeadline 與 extension</h2>
<p>subscription 承擔「這個消費者讀到哪、還有多少沒 ack」的責任。每則訊息投遞給 subscriber 後，Pub/Sub 啟動一個 ackDeadline 倒數；倒數內收到 ack 就移除訊息、倒數結束沒收到 ack 就重投。預設 ackDeadline 是 10 秒、上限 600 秒。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># subscription 的 ackDeadline 預設 10 秒、retention 預設 7 天</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub subscriptions describe demo-sub
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># ackDeadlineSeconds: 10</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># messageRetentionDuration: 604800s   # 7 天</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 建 subscription 時可顯式設更長的 ackDeadline 與更短的 retention</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">gcloud pubsub subscriptions create cfg-sub <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>demo-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">120</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --message-retention-duration<span class="o">=</span>3d
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># ackDeadlineSeconds: 120</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># messageRetentionDuration: 259200s   # 3 天</span></span></span></code></pre></div><p>ackDeadline 是一道「處理時間預算」。設太短，處理還沒完訊息就被重投，consumer 會收到重複；設太長，consumer crash 後訊息要等滿 deadline 才重投，延遲拉高。長任務不靠把 ackDeadline 一次設到 600 秒解決，而是靠 ack deadline extension：consumer 在處理中週期性發 <code>modifyAckDeadline</code> 把單則訊息的 deadline 往後延，處理完才 ack。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># pull 一則但不 auto-ack，拿到 ackId</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">ACKID</span><span class="o">=</span><span class="k">$(</span>gcloud pubsub subscriptions pull demo-sub --limit<span class="o">=</span><span class="m">1</span> --format<span class="o">=</span><span class="s1">&#39;value(ackId)&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 處理中動態延長這則訊息的 ackDeadline 到 300 秒</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud pubsub subscriptions modify-message-ack-deadline demo-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --ack-ids<span class="o">=</span><span class="s2">&#34;</span><span class="nv">$ACKID</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">300</span></span></span></code></pre></div><p>實務上不手動發 <code>modifyAckDeadline</code>，而是用 client library 的自動 lease 管理：client 在背景對 outstanding 訊息週期性續約，直到 application code 回 ack / nack。這跟 SQS 的 visibility timeout 語意類似 — 都是「訊息正在被處理、暫時別重投」的租約 — 但 Pub/Sub 是 per-message lease + client 自動續約，SQS 是 per-receive visibility window + 手動 <code>ChangeMessageVisibility</code>。</p>
<blockquote>
<p>ackDeadline 的陷阱在 batch 邊界。client library 常以 batch 為單位 pull，但 ackDeadline lease 是 per-message。若 application 把整個 batch 當一個工作單元處理、處理時間超過單則 ackDeadline 且 client 未對每則續約，未 ack 的訊息會被重投。Mercari 的 actionable history pipeline 揭露的正是這個 client library 行為：ack deadline 以整批 batch 為粒度運作，同批只要有一則過期或被 nack，已 ack 的訊息會跟著一起重投（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63</a>）。</p></blockquote>
<h2 id="pushpullstreaming-pull-與-flow-control">Push、Pull、Streaming Pull 與 flow control</h2>
<p>subscription 有兩種交付方向，pull 之下又分 unary pull 與 streaming pull。三者對應不同的下游承壓能力。</p>
<table>
  <thead>
      <tr>
          <th>交付模型</th>
          <th>機制</th>
          <th>適合場景</th>
          <th>flow control 由誰掌握</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Push</td>
          <td>Pub/Sub 主動 POST 到 HTTPS endpoint</td>
          <td>無狀態 worker、Cloud Run、Cloud Functions</td>
          <td>Pub/Sub（按 ack 動態調速）</td>
      </tr>
      <tr>
          <td>Unary Pull</td>
          <td>consumer 每次發一個 pull 請求拿一批</td>
          <td>低頻、批次拉取、簡單腳本</td>
          <td>consumer（自己控拉取頻率）</td>
      </tr>
      <tr>
          <td>Streaming Pull</td>
          <td>consumer 開長連線、Pub/Sub 持續推送到該連線</td>
          <td>高吞吐長 worker、需要精確 flow control</td>
          <td>consumer（client lib 設定）</td>
      </tr>
  </tbody>
</table>
<p>Push 把投遞節奏交給 Pub/Sub：endpoint 回 2xx 視為 ack、回非 2xx 或逾時視為 nack 並 backoff 重投。Pull 把節奏交給 consumer：consumer 想拉才拉、拉多少自己定。Streaming pull 是 production 高吞吐場景的主力 — client library 預設用它，因為它能在單一長連線上做精細的 flow control。</p>
<p>flow control 是 pull 的核心優勢：consumer 用 <code>max_outstanding_messages</code> 與 <code>max_outstanding_bytes</code> 設定「同時最多持有多少未 ack 訊息」，超過上限 client 就暫停從連線拉取，等 application ack 釋放額度才繼續。這讓 consumer 能把消費速率對齊到下游能吃的速率，而不是被 broker 灌爆。</p>
<blockquote>
<p>Push vs pull 不是實作偏好，是「下游能否接受 push 衝擊」的判讀。Mercari 把外部行銷 webhook（Braze）轉成 Pub/Sub event 後，下游 worker 刻意用 pull subscription 精確控制每秒處理訊息數，因為下游要呼叫的外部 LINE API 有 RPS 限制 — push 會把瞬間流量直接打到受限的外部 API（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65</a>）。下游有硬性 RPS 上限時，pull + flow control 是讓消費速率可控的手段。</p></blockquote>
<h2 id="ordering-key有序的代價是吞吐">Ordering Key：有序的代價是吞吐</h2>
<p>Ordering key 讓「帶同一個 ordering key 的訊息，在 subscription 端按 publish 順序投遞」。它把全域無序的 Pub/Sub 變成 per-key 有序 — 不同 key 之間仍可並行、亂序，只有同 key 內部保證順序。要生效需要兩端配合：subscription 建立時開 <code>--enable-message-ordering</code>，publish 時帶 <code>--ordering-key</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># subscription 端開啟 ordering</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud pubsub subscriptions create ord-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>ord-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --enable-message-ordering
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># describe 可見 enableMessageOrdering: true</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># publish 端帶 ordering key（同一 key 的訊息會保序）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">gcloud pubsub topics publish ord-topic --message<span class="o">=</span>m1 --ordering-key<span class="o">=</span>user-123
</span></span><span class="line"><span class="ln">9</span><span class="cl">gcloud pubsub topics publish ord-topic --message<span class="o">=</span>m2 --ordering-key<span class="o">=</span>user-123</span></span></code></pre></div><p>Ordering key 的設計責任在於選對 key 的粒度。粒度太粗（例如所有訊息共用一個 key）會把整條 topic 退化成單線序列、吞吐崩塌；粒度太細（例如每則訊息一個 key）等於沒開 ordering。正確做法是按「需要保序的業務實體」選 key — 同一個 <code>user-123</code> 的事件要保序、不同 user 之間不需要 — 這樣並行度等於活躍 key 數，既保序又不犧牲整體吞吐。</p>
<p>跟 Kafka 對照能看清取捨。Kafka 用 partition + 同 key hash 到同 partition 達成保序，partition 數是固定預先規劃的並行上限；Pub/Sub 沒有顯式 partition，ordering key 的並行度是動態的、由活躍 key 數決定。代價是 Pub/Sub 的有序投遞要求同 key 訊息送到同一個內部處理單元，這個約束讓單一 ordering key 的吞吐有上限（官方標稱單 ordering key 約 1 MB/s）。</p>
<blockquote>
<p>Ordering 跟 DLT 在 production 會耦合：有序流裡若一則訊息反覆失敗、Pub/Sub 為維持順序不會跳過它去投後面的訊息，整把 key 的後續訊息全卡住，直到該訊息 ack 或送進 DLT。沒開 ordering 時 poison message 只卡自己；開了 ordering 後它卡住整條 key 序列。這是下一節 DLT 要解的問題在 ordering 場景下被放大的原因。</p></blockquote>
<h2 id="dead-letter-topic投遞次數上限決定隔離時機">Dead-Letter Topic：投遞次數上限決定隔離時機</h2>
<p>Dead-letter topic 是 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison-message quarantine</a> 在 Pub/Sub 的實作：subscription 對每則訊息計數投遞次數，超過 <code>max-delivery-attempts</code> 就把訊息轉發到另一個 topic（DLT），主 subscription 不再重投它，後續正常訊息得以前進。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">gcloud pubsub topics create main-topic
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics create dl-topic
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">gcloud pubsub subscriptions create main-sub <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>main-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --dead-letter-topic<span class="o">=</span>dl-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --max-delivery-attempts<span class="o">=</span><span class="m">5</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># deadLetterPolicy:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">#   deadLetterTopic: projects/&lt;proj&gt;/topics/dl-topic</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">#   maxDeliveryAttempts: 5</span></span></span></code></pre></div><p>DLT 是 topic 不是 queue，這是 Pub/Sub 跟 SQS DLQ 的關鍵差異。SQS 的 DLQ 是另一個 queue、消費者直接 receive；Pub/Sub 的 DLT 是 topic，要再掛一個 subscription 才能讀。好處是 DLT 上可以同時掛多個 subscription — 一個給人工檢視、一個給自動 replay、一個給長期歸檔 — fan-out 內建。代價是多一層 subscription 配置，且 DLT 也有自己的 retention（同樣預設 7 天，poison message 要在這之內處理掉）。</p>
<p><code>max-delivery-attempts</code> 設定的是「容忍多少次暫時性失敗」與「多快放棄」之間的平衡。設太低（例如 1-2 次），下游短暫抖動就把訊息丟進 DLT、誤殺可恢復的訊息；設太高（例如 50 次），一則真正壞掉的訊息會反覆重試半天、占用 consumer 資源、在有序流裡還會長時間卡住整條 key。官方允許範圍 5-100，常見起點是 5。</p>
<p>搭配 retry policy 的 backoff 能讓重投不至於太密集：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud pubsub subscriptions create retry-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>main-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --min-retry-delay<span class="o">=</span>10s <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --max-retry-delay<span class="o">=</span>600s
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># retryPolicy:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">#   minimumBackoff: 10s</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">#   maximumBackoff: 600s</span></span></span></code></pre></div><blockquote>
<p>啟用 DLT 需要把 Pub/Sub service account 授權對主 subscription 有 subscriber、對 DLT 有 publisher（emulator 不校驗 IAM，正式環境若漏授權，訊息超過 max attempts 後不會進 DLT、而是繼續留在主 subscription 重投，看起來像 DLT 沒生效）。授權細節依 GCP 官方 IAM 文件。</p></blockquote>
<p>Mercari 的商品 feed 同步示範了 DLT 的標準用法：pull subscription + 自家 batch requester、成功 ack 整批、失敗 nack 讓 Pub/Sub 重送、重試多次仍失敗送 DLT、後續訊息優先處理；同一個 topic 還兼當突發流量的 load-leveling buffer（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64</a>）。</p>
<h2 id="schema-enforcement投遞前的契約守門">Schema Enforcement：投遞前的契約守門</h2>
<p>Schema enforcement 把 <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> 從「應用層約定」提升到「broker 強制」。topic 綁定一個 Avro 或 Protobuf schema 後，不符 schema 的 publish 在進 topic 前就被拒絕 — 訊息根本不會被儲存、不會投遞、不會進 DLT。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 建 schema（Avro，一個必填 string 欄位 id）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud pubsub schemas create order-schema <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --type<span class="o">=</span>avro <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --definition<span class="o">=</span><span class="s1">&#39;{&#34;type&#34;:&#34;record&#34;,&#34;name&#34;:&#34;Order&#34;,&#34;fields&#34;:[{&#34;name&#34;:&#34;id&#34;,&#34;type&#34;:&#34;string&#34;}]}&#39;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 2. topic 綁 schema + 指定 message encoding</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">gcloud pubsub topics create sch-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --schema<span class="o">=</span>order-schema <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --message-encoding<span class="o">=</span>json</span></span></code></pre></div><p>綁定後的 publish 行為（emulator 實機驗證 enforce）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 符合 schema：通過</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;{&#34;id&#34;:&#34;abc&#34;}&#39;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># messageIds: [&#39;4&#39;]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 欄位不符 schema：被拒</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;{&#34;wrong&#34;:123}&#39;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># ERROR: INVALID_ARGUMENT: Could not parse message</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 非 JSON 垃圾：被拒</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;not-json&#39;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># ERROR: INVALID_ARGUMENT: Could not parse message</span></span></span></code></pre></div><p>schema 守門的價值在於把契約破壞擋在 producer 端、而不是 consumer 端。沒有 schema enforcement 時，producer 改了 payload 結構、不相容的訊息照樣進 topic、要到 consumer 解析失敗才爆 — 此時訊息已經在系統裡流動、可能已 fan-out 到多個 subscription、修復成本高。有 schema enforcement 時，不相容的 publish 在源頭就失敗，問題暴露在「誰送了壞訊息」而不是「誰收到壞訊息」。</p>
<p>schema evolution 要在「擋住破壞性改版」與「不阻塞合理演進」之間取捨。新增可選欄位或帶預設值的欄位維持相容、可以平滑演進；新增必填欄位、刪欄位、改型別是破壞性改版，會讓既有 producer 或 consumer 失效。設計上先定相容性等級（backward / forward / full）再演進，刪欄位分兩步（先停用再移除），避免一次破壞性改版打掛下游。</p>
<p>跟 Kafka Schema Registry 對照：Kafka 的 schema 校驗在 client 端（producer / consumer 各自向 Registry 查 schema、序列化時校驗），broker 本身不認識 schema；Pub/Sub 的 schema 綁在 topic、校驗在 broker 端 publish 路徑上。前者校驗點分散、靈活但要求所有 client 守規矩；後者校驗點集中在 broker、強制但耦合到 topic 配置。</p>
<h2 id="五個-production-故障演練">五個 Production 故障演練</h2>
<p>deep article 的差異化價值在故障演練。以下五個徵兆對應前述三道治理在 production 的典型失效。</p>
<h3 id="演練一ordering-key-把吞吐限到單線">演練一：Ordering key 把吞吐限到單線</h3>
<p><strong>徵兆</strong>：開了 ordering 後整條 topic 的吞吐從數萬 msg/s 掉到數百 msg/s，subscription backlog（<code>num_undelivered_messages</code>）持續攀升、<code>oldest_unacked_message_age</code> 越拉越長，但 consumer CPU 並不滿載 — consumer 在等訊息、不是在忙。</p>
<p><strong>根因</strong>：ordering key 粒度太粗。最常見是「所有訊息共用同一個 ordering key」（例如固定字串、或單一租戶 ID），整條 topic 退化成單一有序序列，並行度等於 1。單一 ordering key 的吞吐有上限（官方標稱約 1 MB/s），所有訊息擠進一個 key 就被這個上限封頂。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>確認 ordering key 的基數（cardinality）。<code>gcloud pubsub topics publish</code> 帶的 <code>--ordering-key</code> 在 production 是業務欄位映射來的 — 檢查映射邏輯是否塌縮成低基數。</li>
<li>把 key 粒度對齊到「真正需要保序的業務實體」：同一筆訂單 / 同一個 user / 同一個 device 內要保序，跨實體不需要。粒度從「全域一個 key」改成「per-user 一個 key」，並行度從 1 拉到活躍 user 數。</li>
<li>評估是否真的需要 ordering。多數 pipeline 靠 consumer 端 idempotency + 版本號就能容忍亂序，不需要 broker 層保序 — 把保序成本從吞吐換成 consumer 設計（見 <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</a> 的 idempotency key 段）。</li>
</ol>
<h3 id="演練二ack-deadline-太短導致重複投遞">演練二：Ack deadline 太短導致重複投遞</h3>
<p><strong>徵兆</strong>：consumer 處理邏輯正確、下游也成功，但同一則訊息被處理多次；<code>DELIVERY_ATTEMPT</code> 計數異常偏高、下游出現重複副作用（重複扣款 / 重複發信）。Backlog 不一定高，但「處理量」遠大於「publish 量」。</p>
<p><strong>根因</strong>：ackDeadline 比實際處理時間短。預設 10 秒對「呼叫一個慢的外部 API」「處理大 payload」這類任務不夠，訊息在 application 還沒 ack 前就過了 deadline、被 Pub/Sub 重投，於是同一則訊息有多個 consumer 副本在跑。若 client library 的自動 lease extension 沒生效（例如 application 阻塞在同步呼叫、background lease thread 餓死），重投更嚴重。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>量測 p99 處理時間，把 ackDeadline 設到 p99 之上留 buffer，但不要不加判斷地設到 600 秒上限 — deadline 越長，consumer crash 後訊息重投的延遲越長。</li>
<li>長任務靠 lease extension 而非長 ackDeadline：確認 client library 的自動續約有在跑，application code 不要在處理迴圈裡阻塞到讓 background 續約 thread 餓死。</li>
<li>consumer 端做 idempotency：用 message 的 dedup key（<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</a>）讓重複投遞變成無害 — at-least-once 交付下重複是常態，不靠調 ackDeadline 消除、靠 consumer 設計吸收。</li>
</ol>
<h3 id="演練三dlt-max-delivery-attempts-設定誤判">演練三：DLT max delivery attempts 設定誤判</h3>
<p><strong>徵兆</strong>：兩種反向徵兆。其一，DLT 堆滿了「其實能恢復」的訊息 — 下游一抖動就被丟進 DLT，DLT backlog 暴增、人工 replay 不完。其二，主 subscription 卡著一則壞訊息反覆重投半天都不進 DLT、後面訊息（尤其在 ordering 流裡）全堵住。</p>
<p><strong>根因</strong>：第一種是 <code>max-delivery-attempts</code> 設太低（1-2 次），暫時性失敗就被當成 poison。第二種是設太高（數十次）或根本沒設 DLT，真正的 poison message 反覆重試、占資源、卡序列。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>區分「暫時性失敗」與「結構性失敗」。暫時性（下游超時、限流）需要重試容忍度，結構性（payload 解析不了、業務規則永久拒絕）越早隔離越好。</li>
<li><code>max-delivery-attempts</code> 起點設 5，搭配 retry policy backoff（<code>--min-retry-delay</code> / <code>--max-retry-delay</code>）讓重試之間有間隔、給下游恢復時間，而不是密集重打。</li>
<li>確認 DLT 真的接得到訊息：檢查 Pub/Sub service account 對 DLT 的 publisher 授權（漏授權會讓訊息超過 attempts 後繼續留在主 subscription、看起來像沒進 DLT）。</li>
<li>DLT 要掛 subscription 才讀得到 — DLT 是 topic 不是 queue，建完 DLT 還要建 DLT 的 subscription 並設好 retention，否則 poison message 在 DLT 裡放滿 7 天後一樣丟失。</li>
</ol>
<h3 id="演練四push-endpoint-500-觸發-retry-storm">演練四：Push endpoint 500 觸發 retry storm</h3>
<p><strong>徵兆</strong>：push subscription 的下游 HTTP endpoint 開始大量回 500，Pub/Sub backoff 重投、但 endpoint 仍 500，重投量隨 backlog 累積越滾越大；endpoint 一旦短暫恢復就被積壓的重投流量瞬間打回 500，形成「恢復即再掛」的震盪。</p>
<p><strong>根因</strong>：push 的 flow control 由 Pub/Sub 掌握、按 ack 動態調速 — endpoint 回 2xx 視為 ack、非 2xx 視為 nack 並重投。當 endpoint 因下游依賴（DB / 外部 API）掛掉而持續 500，Pub/Sub 的 backoff 重投跟累積的 backlog 疊加，恢復瞬間的流量遠超 endpoint 平時負載。這正是「下游能否接受 push 衝擊」的反面 — push 沒有 consumer 端的 flow control 閥門。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>先判訊息毒性 vs endpoint 健康。若是 endpoint 整體掛（所有訊息都 500），是容量 / 依賴問題；若是特定訊息 500（多數成功、少數失敗），是 poison message，該走 DLT。</li>
<li>endpoint 整體掛的場景，push 不是好選擇 — 改 pull + flow control，讓 consumer 用 <code>max_outstanding_messages</code> 把消費速率對齊到下游能吃的速率，避免恢復瞬間被積壓流量打垮（對照 <a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65</a> 的下游 RPS 限制場景）。</li>
<li>對 push 配 DLT，把反覆 500 的特定訊息隔離出去，避免單一 poison message 混在正常流量裡放大 retry。</li>
<li>endpoint 側對「Pub/Sub 重投」做 idempotency，因為 push 也是 at-least-once、500 後的重投會帶來重複。</li>
</ol>
<h3 id="演練五schema-enforcement-擋下不相容-publish">演練五：Schema enforcement 擋下不相容 publish</h3>
<p><strong>徵兆</strong>：某次 producer 部署後，該 service 的 publish 開始大量回 <code>INVALID_ARGUMENT: Could not parse message</code>，訊息發不出去；但 consumer 端風平浪靜、沒有任何解析錯誤、backlog 也沒異常。</p>
<p><strong>根因</strong>：這通常不是故障、是 schema enforcement 正常運作。producer 改了 payload 結構（加必填欄位 / 改型別 / 漏欄位），新 payload 不符 topic 綁定的 schema，broker 在 publish 路徑上擋下、訊息根本沒進 topic。徵兆出現在 producer 端（publish 失敗）而非 consumer 端（解析失敗），正是 schema 守門把問題前移到源頭的設計意圖。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>先確認是「該擋」還是「誤擋」。對照 producer 的新 payload 與 topic schema：若是破壞性改版（加必填欄位 / 改型別），enforcement 擋對了 — 該回滾 producer 或先演進 schema。</li>
<li>用 <code>gcloud pubsub schemas validate-message</code> 在部署前 dry-run 校驗 payload 對 schema，把「不相容」暴露在 CI 而不是 production publish。</li>
<li>schema 演進走相容路徑：新增欄位帶預設或設可選、刪欄位分兩步、避免一次破壞性改版。先升 schema 再升 producer，順序反了就會出現這個徵兆。</li>
<li>區分 schema enforcement 失敗與 DLT：schema 擋下的訊息不進 topic、不進 DLT（DLT 隔離的是「進了 topic 但消費反覆失敗」的訊息）。兩者是交付管線的不同關卡，徵兆與修法都不同。</li>
</ol>
<h2 id="容量與選型邊界標準版-vs-pubsub-lite">容量與選型邊界：標準版 vs Pub/Sub Lite</h2>
<p>前述配置適用標準版 Pub/Sub。標準版的計費與容量模型偏向「全域路由內建、按用量計費、不需預先規劃容量」；當吞吐極高且 region 確定時，Pub/Sub Lite 的 partition-based / zonal 模型成本更低。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>標準版 Pub/Sub</th>
          <th>Pub/Sub Lite</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>路由</td>
          <td>全域、無 region 概念</td>
          <td>zonal / regional、需指定</td>
      </tr>
      <tr>
          <td>容量模型</td>
          <td>自動擴縮、按用量計費</td>
          <td>partition-based、預先 provision throughput</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>高吞吐時單位成本較高</td>
          <td>高吞吐 + 確定 region 時顯著較低</td>
      </tr>
      <tr>
          <td>CLI surface</td>
          <td><code>gcloud pubsub topics</code></td>
          <td><code>gcloud pubsub lite-topics</code>（獨立）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>全域分發、彈性流量、不想管容量</td>
          <td>已知高且穩定的吞吐、成本敏感、region 確定</td>
      </tr>
  </tbody>
</table>
<p>Pub/Sub Lite 是獨立的 CLI surface（<code>gcloud pubsub lite-topics</code> / <code>gcloud pubsub lite-subscriptions</code>），不是標準版的一個 flag。選 Lite 的代價是要自己 provision partition 數與 throughput capacity（回到接近 Kafka 的容量規劃），換來的是高吞吐穩定流量下顯著更低的成本。判準是吞吐「夠高且夠穩定到值得自己管容量」— 流量彈性大、或不想管 partition 的場景仍該留在標準版。</p>
<blockquote>
<p>Spotify 的 autoscaling 案例揭露 backlog 不等於 consumer healthy：下游 export 失敗時 consumer 不 ack 仍持續耗 CPU，autoscaling 把 CPU 越拉越高、反而擴出更多空轉 consumer；解法是 exponential backoff 抑制 CPU 消耗（<a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61</a>）。容量規劃的 autoscale signal 要看「處理成功率」而非「CPU + backlog」，否則擴縮方向會反。</p></blockquote>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="bigquery--cloud-storage-subscription免-consumer-的落地路徑">BigQuery / Cloud Storage subscription：免 consumer 的落地路徑</h3>
<p>標準版提供兩種「不需要自寫 consumer」的 subscription，直接把訊息落地到分析 / 儲存層：</p>
<ul>
<li><strong>BigQuery subscription</strong>（<code>--bigquery-table</code>）：訊息直接寫進 BQ table，免 Dataflow 中介，適合 streaming analytics。可搭配 <code>--use-topic-schema</code> 讓 BQ table schema 對齊 topic schema — schema enforcement 在這裡延伸成「落地結構也受契約約束」。</li>
<li><strong>Cloud Storage subscription</strong>（<code>--cloud-storage-bucket</code>）：訊息批次寫成 GCS object，適合 data lake / 歸檔。</li>
</ul>
<p>這兩種 subscription 把「event 流 → 分析 / 儲存」的常見管線收進 Pub/Sub 配置，省掉一層自管 consumer。它們仍受同一套 ackDeadline / DLT 骨架管轄。</p>
<h3 id="cross-link">Cross-link</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub overview</a> — 選型層、跟 Kafka / SQS 取捨</li>
<li>契約與重播邊界：<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> — schema / idempotency key / replay window 先於 broker 選型</li>
<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>（schema enforcement 守的契約等級）、<a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">Poison-Message Quarantine</a>（DLT 的隔離機制）</li>
<li>對應 case：<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari Item Feed DLT</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65 Mercari LINE flow control</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61 Spotify autoscaling</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63 Mercari actionable history</a></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>
<h3 id="何時-revisit">何時 revisit</h3>
<ul>
<li>ordering key 吞吐撞上單 key 上限、且無法再細分 key：評估改用 Kafka partition 模型，或把保序成本移到 consumer 端 idempotency</li>
<li>高吞吐穩定流量 + 成本壓力浮現：評估標準版 → Pub/Sub Lite，接受自管 partition 容量換成本</li>
<li>schema 需要跨多 vendor 共用契約（同一份 event 同時進 Pub/Sub 與 Kafka）：評估把 schema source of truth 抽到 broker 外的 registry</li>
</ul>
]]></content:encoded></item><item><title>Kafka → Google Cloud Pub/Sub：從 partition 到 topic-subscription 的模型轉換</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/migrate-from-kafka/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/migrate-from-kafka/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &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>（source）跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a>（target）。跑 6 維 diff dimension audit 後判定為 &lt;strong>Type E paradigm shift&lt;/strong>：兩者投遞模型本質不同（partition-based log vs topic-subscription pub/sub）。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼從-kafka-遷到-pubsub">為什麼從 Kafka 遷到 Pub/Sub&lt;/h2>
&lt;p>這個遷移的 driver 通常是平台策略：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>All-in GCP&lt;/strong>：組織決定收斂到 GCP 生態，Kafka 是唯一非 GCP 的 stateful 服務，維運孤島成本高&lt;/li>
&lt;li>&lt;strong>運維簡化&lt;/strong>：自管 Kafka cluster 的 broker、ZooKeeper/KRaft、partition rebalance、retention 管理需要專職團隊；Pub/Sub 是全託管&lt;/li>
&lt;li>&lt;strong>GCP 整合&lt;/strong>：下游是 BigQuery、Dataflow、Cloud Run — Pub/Sub 原生串接，Kafka 要加 connector 層&lt;/li>
&lt;li>&lt;strong>全球路由&lt;/strong>：Pub/Sub topic 是 global（不綁 region），Kafka 需要 MirrorMaker 做跨 region 同步&lt;/li>
&lt;/ul>
&lt;p>遷移的工作量不在資料搬遷（message queue 通常不搬歷史資料），在 &lt;strong>模型轉換&lt;/strong> — Kafka 的 partition ordering、consumer group、offset commit 跟 Pub/Sub 的 topic-subscription、ack deadline、ordering key 是不同抽象。&lt;/p>
&lt;h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>評估&lt;/th>
 &lt;th>等級&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Schema / API&lt;/td>
 &lt;td>Kafka producer/consumer API → Pub/Sub client library，完全不同 API&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Operational model&lt;/td>
 &lt;td>自管 broker/ZK/KRaft → 全託管&lt;/td>
 &lt;td>High（方向：簡化）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Abstraction / paradigm&lt;/td>
 &lt;td>partition-based log vs topic-subscription pub/sub&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Number of components&lt;/td>
 &lt;td>Kafka + Schema Registry + Connect → Pub/Sub + (optional) Dataflow&lt;/td>
 &lt;td>Medium&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Application change&lt;/td>
 &lt;td>Producer/Consumer 全部改寫&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Data topology&lt;/td>
 &lt;td>Partition × offset → Topic × subscription × ack&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>五維 High — &lt;strong>Type E paradigm shift&lt;/strong>，是兩套模型的橋接，工程量遠超 drop-in 或翻譯。&lt;/p>
&lt;h2 id="模型差異對照">模型差異對照&lt;/h2>
&lt;p>遷移前必須理解兩套模型的對應關係。對應不是一對一 — 有些概念在對方沒有直接等價物。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Kafka 概念&lt;/th>
 &lt;th>Pub/Sub 對應&lt;/th>
 &lt;th>差異重點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Topic&lt;/td>
 &lt;td>Topic&lt;/td>
 &lt;td>名稱相同但語意不同：Kafka topic 有 partition，Pub/Sub topic 沒有&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Partition&lt;/td>
 &lt;td>無直接對應&lt;/td>
 &lt;td>Pub/Sub 的 ordering 用 ordering key 實現，但 ordering key 不保證全域順序&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consumer group&lt;/td>
 &lt;td>Subscription&lt;/td>
 &lt;td>每個 subscription 獨立消費 topic 的全部訊息，類似 Kafka 的 consumer group&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Offset&lt;/td>
 &lt;td>無直接對應&lt;/td>
 &lt;td>Pub/Sub 用 ack/nack 而非 offset commit。ack 後訊息不可重讀（除非用 seek）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Offset commit&lt;/td>
 &lt;td>Ack&lt;/td>
 &lt;td>Kafka 可以 commit 到任意 offset（replay）；Pub/Sub ack 是 per-message、seek 可以回到 timestamp&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Retention&lt;/td>
 &lt;td>Message retention&lt;/td>
 &lt;td>Kafka retention 期內可任意 seek；Pub/Sub retention 期內可用 timestamp seek&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consumer lag&lt;/td>
 &lt;td>Oldest unacked message age&lt;/td>
 &lt;td>觀測指標不同：Kafka 看 offset lag、Pub/Sub 看 oldest_unacked_message_age&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Partition rebalance&lt;/td>
 &lt;td>無（Pub/Sub 自動負載分散）&lt;/td>
 &lt;td>Kafka rebalance 是操作痛點，Pub/Sub 消除了這個概念&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Schema Registry&lt;/td>
 &lt;td>Pub/Sub Schema&lt;/td>
 &lt;td>Pub/Sub 原生支援 Avro/Protobuf schema validation&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Kafka Connect&lt;/td>
 &lt;td>Dataflow / BigQuery subscription&lt;/td>
 &lt;td>下游整合的對應工具不同&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="ordering-語意是最大差異">Ordering 語意是最大差異&lt;/h3>
&lt;p>Kafka 的 ordering 保證是 partition 內全域有序。同一個 partition 的訊息按寫入順序消費，consumer group 內每個 partition 只有一個 consumer。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <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>（source）跟 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a>（target）。跑 6 維 diff dimension audit 後判定為 <strong>Type E paradigm shift</strong>：兩者投遞模型本質不同（partition-based log vs topic-subscription pub/sub）。</p></blockquote>
<h2 id="為什麼從-kafka-遷到-pubsub">為什麼從 Kafka 遷到 Pub/Sub</h2>
<p>這個遷移的 driver 通常是平台策略：</p>
<ul>
<li><strong>All-in GCP</strong>：組織決定收斂到 GCP 生態，Kafka 是唯一非 GCP 的 stateful 服務，維運孤島成本高</li>
<li><strong>運維簡化</strong>：自管 Kafka cluster 的 broker、ZooKeeper/KRaft、partition rebalance、retention 管理需要專職團隊；Pub/Sub 是全託管</li>
<li><strong>GCP 整合</strong>：下游是 BigQuery、Dataflow、Cloud Run — Pub/Sub 原生串接，Kafka 要加 connector 層</li>
<li><strong>全球路由</strong>：Pub/Sub topic 是 global（不綁 region），Kafka 需要 MirrorMaker 做跨 region 同步</li>
</ul>
<p>遷移的工作量不在資料搬遷（message queue 通常不搬歷史資料），在 <strong>模型轉換</strong> — Kafka 的 partition ordering、consumer group、offset commit 跟 Pub/Sub 的 topic-subscription、ack deadline、ordering key 是不同抽象。</p>
<h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>評估</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>Kafka producer/consumer API → Pub/Sub client library，完全不同 API</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>自管 broker/ZK/KRaft → 全託管</td>
          <td>High（方向：簡化）</td>
      </tr>
      <tr>
          <td>Abstraction / paradigm</td>
          <td>partition-based log vs topic-subscription pub/sub</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Number of components</td>
          <td>Kafka + Schema Registry + Connect → Pub/Sub + (optional) Dataflow</td>
          <td>Medium</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>Producer/Consumer 全部改寫</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>Partition × offset → Topic × subscription × ack</td>
          <td>High</td>
      </tr>
  </tbody>
</table>
<p>五維 High — <strong>Type E paradigm shift</strong>，是兩套模型的橋接，工程量遠超 drop-in 或翻譯。</p>
<h2 id="模型差異對照">模型差異對照</h2>
<p>遷移前必須理解兩套模型的對應關係。對應不是一對一 — 有些概念在對方沒有直接等價物。</p>
<table>
  <thead>
      <tr>
          <th>Kafka 概念</th>
          <th>Pub/Sub 對應</th>
          <th>差異重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Topic</td>
          <td>Topic</td>
          <td>名稱相同但語意不同：Kafka topic 有 partition，Pub/Sub topic 沒有</td>
      </tr>
      <tr>
          <td>Partition</td>
          <td>無直接對應</td>
          <td>Pub/Sub 的 ordering 用 ordering key 實現，但 ordering key 不保證全域順序</td>
      </tr>
      <tr>
          <td>Consumer group</td>
          <td>Subscription</td>
          <td>每個 subscription 獨立消費 topic 的全部訊息，類似 Kafka 的 consumer group</td>
      </tr>
      <tr>
          <td>Offset</td>
          <td>無直接對應</td>
          <td>Pub/Sub 用 ack/nack 而非 offset commit。ack 後訊息不可重讀（除非用 seek）</td>
      </tr>
      <tr>
          <td>Offset commit</td>
          <td>Ack</td>
          <td>Kafka 可以 commit 到任意 offset（replay）；Pub/Sub ack 是 per-message、seek 可以回到 timestamp</td>
      </tr>
      <tr>
          <td>Retention</td>
          <td>Message retention</td>
          <td>Kafka retention 期內可任意 seek；Pub/Sub retention 期內可用 timestamp seek</td>
      </tr>
      <tr>
          <td>Consumer lag</td>
          <td>Oldest unacked message age</td>
          <td>觀測指標不同：Kafka 看 offset lag、Pub/Sub 看 oldest_unacked_message_age</td>
      </tr>
      <tr>
          <td>Partition rebalance</td>
          <td>無（Pub/Sub 自動負載分散）</td>
          <td>Kafka rebalance 是操作痛點，Pub/Sub 消除了這個概念</td>
      </tr>
      <tr>
          <td>Schema Registry</td>
          <td>Pub/Sub Schema</td>
          <td>Pub/Sub 原生支援 Avro/Protobuf schema validation</td>
      </tr>
      <tr>
          <td>Kafka Connect</td>
          <td>Dataflow / BigQuery subscription</td>
          <td>下游整合的對應工具不同</td>
      </tr>
  </tbody>
</table>
<h3 id="ordering-語意是最大差異">Ordering 語意是最大差異</h3>
<p>Kafka 的 ordering 保證是 partition 內全域有序。同一個 partition 的訊息按寫入順序消費，consumer group 內每個 partition 只有一個 consumer。</p>
<p>Pub/Sub 預設不保證 ordering。要 ordering 需開啟 ordering key — 同一 ordering key 的訊息有序，但不同 ordering key 之間無序。ordering key 的並行度由 key 的 cardinality 決定（類似 Kafka 的 partition key）。</p>
<p>遷移時的判斷：</p>
<ul>
<li>若 Kafka 的 ordering 只依賴 partition key（常見），ordering key 直接對應</li>
<li>若依賴 partition 內的全域順序（少見但存在），需要重新設計 — Pub/Sub 沒有 partition 全域順序的概念</li>
<li>若完全不需要 ordering（fan-out 場景），Pub/Sub 預設行為更簡單</li>
</ul>
<h3 id="component-數量轉換">Component 數量轉換</h3>
<p>Kafka 生態的 Schema Registry 在 Pub/Sub 由原生 Schema 功能替代（topic-level schema validation）；Kafka Connect 的 sink connector 由 BigQuery subscription 或 Dataflow job 替代。Dataflow 不是必要 — 簡單的 push/pull consumer 不需要 Dataflow，只有 stream processing（windowed aggregation、join）才需要。</p>
<h2 id="階段一producer-遷移雙寫">階段一：Producer 遷移（雙寫）</h2>
<p>雙寫策略是 paradigm shift 遷移的標準起手。Application 同時把訊息寫入 Kafka 和 Pub/Sub，consumer 仍從 Kafka 消費。</p>
<h3 id="producer-改造">Producer 改造</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 示意：雙寫 wrapper（實際生產用各自語言的 client library）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">def</span> <span class="nf">publish_order_event</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="c1"># 原有 Kafka producer</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">kafka_producer</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="s2">&#34;order-events&#34;</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">order_id</span><span class="p">,</span> <span class="n">value</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">to_bytes</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1"># 新增 Pub/Sub producer</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">pubsub_publisher</span><span class="o">.</span><span class="n">publish</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="s2">&#34;projects/my-project/topics/order-events&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">data</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">to_bytes</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">ordering_key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">order_id</span>  <span class="c1"># 對應 Kafka partition key</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">)</span></span></span></code></pre></div><h3 id="雙寫驗證">雙寫驗證</h3>
<table>
  <thead>
      <tr>
          <th>驗證項目</th>
          <th>方法</th>
          <th>通過條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訊息數量一致</td>
          <td>比對 Kafka produce count 與 Pub/Sub publish count</td>
          <td>差異 &lt; 0.01%（允許 timing 差異）</td>
      </tr>
      <tr>
          <td>Ordering 一致</td>
          <td>同一 ordering key 的訊息在兩端順序相同</td>
          <td>抽樣驗證 100 個 key</td>
      </tr>
      <tr>
          <td>Latency 影響</td>
          <td>監控 request latency 變化</td>
          <td>p99 增加 &lt; 10ms</td>
      </tr>
      <tr>
          <td>失敗隔離</td>
          <td>Pub/Sub publish 失敗不影響 Kafka publish</td>
          <td>Pub/Sub timeout 時 Kafka 正常</td>
      </tr>
  </tbody>
</table>
<p>雙寫的失敗隔離要嚴格設計。Pub/Sub publish 失敗時，application 應該 log + metric 但不 block request。Kafka 是已驗證的正式路徑，Pub/Sub 在這個階段是 shadow。</p>
<h2 id="階段二consumer-遷移逐-subscription-切換">階段二：Consumer 遷移（逐 subscription 切換）</h2>
<p>Producer 雙寫穩定後，逐一把 consumer 從 Kafka 切到 Pub/Sub subscription。</p>
<h3 id="consumer-改造重點">Consumer 改造重點</h3>
<p><strong>Ack 模型差異</strong>：Kafka consumer 是 poll + commit offset；Pub/Sub 是 pull（或 push）+ per-message ack。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Kafka consumer pattern</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">for</span> <span class="n">msg</span> <span class="ow">in</span> <span class="n">kafka_consumer</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">process</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">kafka_consumer</span><span class="o">.</span><span class="n">commit</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># Pub/Sub pull subscriber pattern</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">callback</span><span class="p">(</span><span class="n">message</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">process</span><span class="p">(</span><span class="n">message</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">message</span><span class="o">.</span><span class="n">ack</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">message</span><span class="o">.</span><span class="n">nack</span><span class="p">()</span>  <span class="c1"># 會被重新投遞</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">subscriber</span><span class="o">.</span><span class="n">subscribe</span><span class="p">(</span><span class="s2">&#34;projects/my-project/subscriptions/order-processor&#34;</span><span class="p">,</span> <span class="n">callback</span><span class="o">=</span><span class="n">callback</span><span class="p">)</span></span></span></code></pre></div><p><strong>Idempotency 更重要</strong>：Pub/Sub 的 at-least-once delivery 加上 ack deadline 機制，redelivery 比 Kafka 更容易觸發（ack deadline 內沒 ack 就重投）。Consumer 的 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 設計要比 Kafka 時更嚴格。</p>
<p><strong>Flow control</strong>：Pub/Sub client library 支援 <code>max_outstanding_messages</code> 和 <code>max_outstanding_bytes</code> 做 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 控制，對應 Kafka 的 <code>max.poll.records</code>。</p>
<h3 id="切換順序">切換順序</h3>
<p>依 consumer 的重要度和複雜度排序：</p>
<ol>
<li>先切 stateless consumer（log pipeline、metrics aggregation）— 低風險</li>
<li>再切有 side effect 但 idempotent 的 consumer（search index sync、notification）</li>
<li>最後切核心 consumer（payment processing、inventory update）— 需要完整 idempotency 驗證</li>
</ol>
<p>每切一組 consumer：</p>
<ol>
<li>建立對應的 Pub/Sub subscription</li>
<li>部署新 consumer（讀 Pub/Sub）</li>
<li>驗證處理正確性（比對 Kafka consumer 和 Pub/Sub consumer 的輸出）</li>
<li>停止舊 Kafka consumer</li>
<li>觀察 7 天無異常</li>
</ol>
<h2 id="階段三停止雙寫">階段三：停止雙寫</h2>
<p>所有 consumer 切完後：</p>
<ol>
<li>停止 Kafka producer（移除雙寫邏輯）</li>
<li>觀察 Kafka topic 不再有新訊息</li>
<li>等 Kafka retention 過期</li>
<li>下線 Kafka cluster</li>
</ol>
<p>Kafka cluster 不要在 consumer 切完後立即下線。保留 retention period + 7 天作為回退保險。</p>
<h2 id="回退路徑">回退路徑</h2>
<p>Type E 遷移的回退要在每個階段都設計：</p>
<ul>
<li><strong>階段一回退</strong>：移除 Pub/Sub publish 邏輯，Kafka 路徑不受影響</li>
<li><strong>階段二回退</strong>：重啟 Kafka consumer、停止 Pub/Sub subscriber。Kafka 的 offset 要確認是否仍在 retention 內</li>
<li><strong>階段三回退</strong>：如果 Kafka 已下線，需要重新建 cluster 並從 Pub/Sub 反向雙寫回 Kafka — 成本高，所以階段三前要確認穩定</li>
</ul>
<p>回退的關鍵指標：consumer lag（Pub/Sub 的 <code>oldest_unacked_message_age</code>）持續上升、error rate 上升、或 redelivery rate 異常。</p>
<h2 id="遷移後的監控對照">遷移後的監控對照</h2>
<table>
  <thead>
      <tr>
          <th>Kafka 監控指標</th>
          <th>Pub/Sub 對應指標</th>
          <th>來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Consumer lag (offset)</td>
          <td><code>subscription/oldest_unacked_message_age</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Produce rate</td>
          <td><code>topic/send_message_operation_count</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Consume rate</td>
          <td><code>subscription/pull_message_operation_count</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Redelivery count</td>
          <td><code>subscription/dead_letter_message_count</code> + nack rate</td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Broker disk usage</td>
          <td>無需關注（fully managed）</td>
          <td>N/A</td>
      </tr>
      <tr>
          <td>Rebalance events</td>
          <td>無（Pub/Sub 自動分散）</td>
          <td>N/A</td>
      </tr>
  </tbody>
</table>
<h2 id="不適合遷移的場景">不適合遷移的場景</h2>
<p>以下場景 Kafka → Pub/Sub 的 ROI 不成立：</p>
<ul>
<li><strong>需要 exactly-once semantics</strong>：Kafka 的 transactional producer + idempotent producer 提供 exactly-once；Pub/Sub 是 at-least-once，application 層做 dedup</li>
<li><strong>需要長期 replay</strong>：Kafka retention 可設數月甚至永久（tiered storage）；Pub/Sub message retention 最長 31 天（若需超過 31 天的 replay，可用 BigQuery subscription 做長期歸檔，但查詢模式不同於 Kafka 的 offset-based replay）</li>
<li><strong>大量 ordering 依賴</strong>：如果 Kafka topology 重度依賴 partition ordering 且 key cardinality 低，Pub/Sub ordering key 的並行度會比 Kafka 差</li>
<li><strong>使用 Kafka Streams / ksqlDB 做 stateful processing</strong>：stream processing 邏輯跟 Kafka 綁定（state store backed by changelog topic），遷到 Pub/Sub 要同時遷移 processing 框架（→ Dataflow / Beam），工程量額外翻倍且 API 完全不同</li>
<li><strong>多雲 / 非 GCP 環境</strong>：Pub/Sub 是 GCP-only，跨雲場景反而讓 Kafka 更合理</li>
</ul>
<h2 id="交接路由">交接路由</h2>
<ul>
<li>Source vendor overview：<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></li>
<li>Target vendor overview：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a></li>
<li>Pub/Sub 操作細節：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/" data-link-title="Google Pub/Sub push vs pull：不是實作偏好，是下游容量的判讀" data-link-desc="Pub/Sub 的 push 與 pull subscription 常被當成實作偏好二選一，但它其實是一個容量判讀：push 把流量瞬間打到 endpoint，pull 讓 consumer 自己節流。下游有 RPS 限制就只能 pull。本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic，5 個把 push/pull 與 ack deadline 寫成下游打爆與重投的 production 踩坑">Push / Pull / Ack Flow Control</a>、<a href="/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/" data-link-title="Pub/Sub Ordering Key、Dead-Letter Topic 與 Schema Enforcement：三道交付治理" data-link-desc="Pub/Sub overview 之下的 implementation-layer deep article — 把 ordering key 的有序代價、dead-letter topic 的 poison message 隔離、schema enforcement 的契約守門三件事寫到可操作：subscription 是 first-class、ackDeadline 與 extension、push vs pull vs streaming pull &#43; flow control、Avro / Protobuf schema、Pub/Sub Lite 與標準版差異、BigQuery / Cloud Storage subscription，含 5 個 production 故障演練（ordering 限流 / ack deadline 太短重投 / DLT max delivery attempts / push 500 retry storm / schema 擋下不相容 publish）">Ordering / DLT / Schema</a></li>
<li>Consumer idempotency：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 Consumer Design</a>、<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 Processing Recovery Semantics</a></li>
<li>反向路徑（SQS → Pub/Sub）：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/migrate-to-google-pubsub/" data-link-title="AWS SQS → Google Pub/Sub：queue 模型搬到 topic &#43; subscription 模型的跨雲遷移" data-link-desc="SQS 是單一 region-scoped pull queue、Pub/Sub 是 global topic &#43; first-class subscription 的 pub/sub 模型；這篇跨雲 migration playbook 走 6 維 diff dimension audit（components / data topology 軸 High）、對位 visibility timeout → ack deadline、maxReceiveCount → dead-letter topic、long polling → streaming pull、IAM policy → Service Account、SQS-to-many-consumer 要重設計成 topic fan-out；含 5 個 production 故障演練（fan-out 行為差 / ack deadline 太短重投 / ordering key vs FIFO / 跨雲網路成本 / DLT 設定差）跟 dual-publish 漸進 cutover">AWS SQS → Google Pub/Sub</a></li>
</ul>
]]></content:encoded></item><item><title>3.C60 Spotify：Event Delivery 從 Kafka 遷到 Pub/Sub</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-event-delivery-platform/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-event-delivery-platform/</guid><description>&lt;p>Spotify 把全球 event delivery 從 Kafka 遷到 Cloud Pub/Sub 的案例揭露了大規模 pull subscription 的工程現實 — at-least-once 語意意味著應用層去重不可省。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Spotify 的 Event Delivery 系統負責把所有使用者行為事件（播放、搜尋、推薦互動、廣告曝光）從客戶端經由資料管線送到下游消費者。事件是推薦引擎、A/B test、廣告計費跟 analytics 的核心輸入。&lt;/p>
&lt;p>遷移到 GCP Pub/Sub 後的系統規模：每個 event type 一個 topic、~15 個 microservice 跑在 ~2500 VM 上、Q1 2019 高峰 8M events/sec、每日 350 TB raw event 流量。遷出 Kafka 的動機跟技術評估見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20 Spotify 遷出 Kafka（反例）&lt;/a>。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="at-least-once-語意下的重複">At-least-once 語意下的重複&lt;/h3>
&lt;p>Cloud Pub/Sub（早期版本）提供 at-least-once delivery — 同一筆訊息可能被 deliver 多次。在每日 350 TB 的流量下，「偶爾重複」的頻率足以影響 analytics 數據跟廣告計費的準確性。&lt;/p>
&lt;p>Pub/Sub 的重複來源有兩個：ack deadline 到期前 consumer 還沒處理完、訊息被重新 deliver 給其他 consumer；以及 Pub/Sub backend 的內部 redelivery（罕見但非零）。&lt;/p>
&lt;h3 id="pull-subscription-的流控">Pull subscription 的流控&lt;/h3>
&lt;p>Pull subscription 讓 consumer 主動從 Pub/Sub 拉取訊息（vs push subscription 由 Pub/Sub 推送到 HTTP endpoint）。Pull 的好處是 consumer 可以控制自己的消費速度，避免被推送壓垮。&lt;/p>
&lt;p>大規模 pull subscription 的挑戰在於流控的精細度 — 每個 consumer VM 要設定合理的 maxOutstandingMessages 跟 maxOutstandingBytes，太大會讓 consumer 記憶體不足、太小會浪費 Pub/Sub 的吞吐能力。Spotify 的 2500 VM 各自獨立做 pull，需要在 fleet 級別保持流控的一致性。&lt;/p>
&lt;h3 id="每個-event-type-一個-topic-的治理">每個 event type 一個 topic 的治理&lt;/h3>
&lt;p>Spotify 按 event type 建立 topic（例如 &lt;code>play-event&lt;/code>、&lt;code>search-event&lt;/code>、&lt;code>ad-impression&lt;/code>）。Event type 數量成長後，topic 數量跟著增長。每個 topic 需要獨立的 subscription、monitoring、ack deadline 設定跟 retention policy。&lt;/p>
&lt;p>Topic 治理的工程問題是「誰 own 這個 topic、schema 變更怎麼協調、retention 該設多久」。Spotify 自建了 event delivery 平台層（Event Delivery Platform）來管理 topic lifecycle — 包括 topic 建立 / 刪除的 self-service API、schema registry、consumer group 管理。&lt;/p>
&lt;h2 id="解法與取捨">解法與取捨&lt;/h2>
&lt;h3 id="自建-deduplication-層">自建 deduplication 層&lt;/h3>
&lt;p>Spotify 在 consumer 端自建去重機制。每筆 event 帶 unique event ID，consumer 在處理前查 dedup store（記憶體 + 外部 cache）確認是否已處理過。已處理的 event 直接 ack、跳過處理邏輯。&lt;/p></description><content:encoded><![CDATA[<p>Spotify 把全球 event delivery 從 Kafka 遷到 Cloud Pub/Sub 的案例揭露了大規模 pull subscription 的工程現實 — at-least-once 語意意味著應用層去重不可省。</p>
<h2 id="業務背景">業務背景</h2>
<p>Spotify 的 Event Delivery 系統負責把所有使用者行為事件（播放、搜尋、推薦互動、廣告曝光）從客戶端經由資料管線送到下游消費者。事件是推薦引擎、A/B test、廣告計費跟 analytics 的核心輸入。</p>
<p>遷移到 GCP Pub/Sub 後的系統規模：每個 event type 一個 topic、~15 個 microservice 跑在 ~2500 VM 上、Q1 2019 高峰 8M events/sec、每日 350 TB raw event 流量。遷出 Kafka 的動機跟技術評估見 <a href="/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20 Spotify 遷出 Kafka（反例）</a>。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="at-least-once-語意下的重複">At-least-once 語意下的重複</h3>
<p>Cloud Pub/Sub（早期版本）提供 at-least-once delivery — 同一筆訊息可能被 deliver 多次。在每日 350 TB 的流量下，「偶爾重複」的頻率足以影響 analytics 數據跟廣告計費的準確性。</p>
<p>Pub/Sub 的重複來源有兩個：ack deadline 到期前 consumer 還沒處理完、訊息被重新 deliver 給其他 consumer；以及 Pub/Sub backend 的內部 redelivery（罕見但非零）。</p>
<h3 id="pull-subscription-的流控">Pull subscription 的流控</h3>
<p>Pull subscription 讓 consumer 主動從 Pub/Sub 拉取訊息（vs push subscription 由 Pub/Sub 推送到 HTTP endpoint）。Pull 的好處是 consumer 可以控制自己的消費速度，避免被推送壓垮。</p>
<p>大規模 pull subscription 的挑戰在於流控的精細度 — 每個 consumer VM 要設定合理的 maxOutstandingMessages 跟 maxOutstandingBytes，太大會讓 consumer 記憶體不足、太小會浪費 Pub/Sub 的吞吐能力。Spotify 的 2500 VM 各自獨立做 pull，需要在 fleet 級別保持流控的一致性。</p>
<h3 id="每個-event-type-一個-topic-的治理">每個 event type 一個 topic 的治理</h3>
<p>Spotify 按 event type 建立 topic（例如 <code>play-event</code>、<code>search-event</code>、<code>ad-impression</code>）。Event type 數量成長後，topic 數量跟著增長。每個 topic 需要獨立的 subscription、monitoring、ack deadline 設定跟 retention policy。</p>
<p>Topic 治理的工程問題是「誰 own 這個 topic、schema 變更怎麼協調、retention 該設多久」。Spotify 自建了 event delivery 平台層（Event Delivery Platform）來管理 topic lifecycle — 包括 topic 建立 / 刪除的 self-service API、schema registry、consumer group 管理。</p>
<h2 id="解法與取捨">解法與取捨</h2>
<h3 id="自建-deduplication-層">自建 deduplication 層</h3>
<p>Spotify 在 consumer 端自建去重機制。每筆 event 帶 unique event ID，consumer 在處理前查 dedup store（記憶體 + 外部 cache）確認是否已處理過。已處理的 event 直接 ack、跳過處理邏輯。</p>
<p>Dedup store 的挑戰是大小跟 TTL — 要記住多久以前的 event ID 才夠。TTL 太短會漏掉 late redelivery（Pub/Sub 在 ack deadline 之後才重新 deliver）、TTL 太長 dedup store 太大。Spotify 用滑動視窗（retention 跟 ack deadline 的倍數）設定 TTL。</p>
<h3 id="取捨">取捨</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Pub/Sub + 自建 dedup</th>
          <th>自管 Kafka 0.8+</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>運維成本</td>
          <td>低（Pub/Sub 全託管）</td>
          <td>高（自管 broker × 多 region）</td>
      </tr>
      <tr>
          <td>語意保證</td>
          <td>At-least-once + 應用層 dedup</td>
          <td>At-least-once（idempotent 0.11+）</td>
      </tr>
      <tr>
          <td>跨 region replication</td>
          <td>原生支援</td>
          <td>需要 MirrorMaker 或自建</td>
      </tr>
      <tr>
          <td>流控精細度</td>
          <td>Pull subscription 可控</td>
          <td>Consumer group 自動分配</td>
      </tr>
      <tr>
          <td>Topic 治理</td>
          <td>需要自建平台層</td>
          <td>Kafka 生態工具（Confluent 等）</td>
      </tr>
      <tr>
          <td>Dedup 成本</td>
          <td>額外的 cache / store 成本</td>
          <td>Idempotent producer 減少需求</td>
      </tr>
  </tbody>
</table>
<p>自建 dedup 的成本是 Spotify 選 Pub/Sub 的額外付出。這個代價在託管方案的運維節省面前被接受 — 維護一個 dedup cache 的成本遠低於維護跨 5 個 datacenter 的 Kafka broker fleet。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a>：push vs pull subscription、ack deadline、ordering 跟 DLT 的進階主題</li>
<li><a href="/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20 Spotify 遷出 Kafka</a>：遷出 Kafka 的動機跟決策判準</li>
<li><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing recovery semantics</a>：at-least-once 語意下的 dedup 策略</li>
<li><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>：event schema 跟 topic lifecycle 的治理</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>使用 GCP Pub/Sub 且下游消費者偶爾處理到重複事件</li>
<li>Pull subscription 的 consumer 記憶體使用不穩定、maxOutstandingMessages 設定不合理</li>
<li>Topic 數量持續增長但缺少統一的 lifecycle 管理</li>
<li>從自管 Kafka 遷移到 GCP Pub/Sub 的評估階段</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.atspotify.com/2019/11/spotifys-event-delivery-life-in-the-cloud">Spotify&rsquo;s Event Delivery — Life in the Cloud</a></li>
</ul>
]]></content:encoded></item><item><title>3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/</guid><description>&lt;p>這個案例的核心責任是說明「subscription backlog 不等於 consumer healthy」、autoscaling 跟 ack deadline 的耦合風險。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>下游 Cloud Storage export 失敗時、consumer 不 ack 仍持續消耗 CPU 處理同批訊息、造成 autoscaling 把 CPU 越拉越高的反效果；解法是 exponential backoff 抑制 CPU 消耗。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「Subscription backlog 不等於 consumer healthy」— 訊息未 ack 累積跟 autoscaling 的耦合風險。揭露 autoscale signal 該看「處理成功率」而非「CPU + backlog」。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Ack deadline / autoscaling signal 設計。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.atspotify.com/2017/11/autoscaling-pub-sub-consumers">Autoscaling Pub/Sub Consumers&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「subscription backlog 不等於 consumer healthy」、autoscaling 跟 ack deadline 的耦合風險。</p>
<h2 id="觀察">觀察</h2>
<p>下游 Cloud Storage export 失敗時、consumer 不 ack 仍持續消耗 CPU 處理同批訊息、造成 autoscaling 把 CPU 越拉越高的反效果；解法是 exponential backoff 抑制 CPU 消耗。</p>
<h2 id="判讀">判讀</h2>
<p>「Subscription backlog 不等於 consumer healthy」— 訊息未 ack 累積跟 autoscaling 的耦合風險。揭露 autoscale signal 該看「處理成功率」而非「CPU + backlog」。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Ack deadline / autoscaling signal 設計。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.atspotify.com/2017/11/autoscaling-pub-sub-consumers">Autoscaling Pub/Sub Consumers</a></li>
</ul>
]]></content:encoded></item><item><title>3.C62 Spotify：Pub/Sub → GCS reliable export</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-cloud-storage-export/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-cloud-storage-export/</guid><description>&lt;p>這個案例的核心責任是說明 ack 是 end-to-end commit 信號、不是 buffer-flush 信號。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Consumer 只在下游 Completionist 回 200 OK 才 ack 回 Pub/Sub、並用「Oldest Unacknowledged Message」metric 判斷 hourly bucket 何時可安全關閉；ack semantics 直接綁定下游 commit。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>ack 是 end-to-end commit 信號、不是 buffer-flush 信號。揭露為什麼後來原生 GCS subscription 有價值（Spotify 早期沒有原生、自建管線）。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Ack deadline / Cloud Storage subscription（早期無原生、自建對照）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/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&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.atspotify.com/2017/04/reliable-export-of-cloud-pubsub-streams-to-cloud-storage">Reliable Export of Cloud Pub/Sub Streams to Cloud Storage&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 ack 是 end-to-end commit 信號、不是 buffer-flush 信號。</p>
<h2 id="觀察">觀察</h2>
<p>Consumer 只在下游 Completionist 回 200 OK 才 ack 回 Pub/Sub、並用「Oldest Unacknowledged Message」metric 判斷 hourly bucket 何時可安全關閉；ack semantics 直接綁定下游 commit。</p>
<h2 id="判讀">判讀</h2>
<p>ack 是 end-to-end commit 信號、不是 buffer-flush 信號。揭露為什麼後來原生 GCS subscription 有價值（Spotify 早期沒有原生、自建管線）。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Ack deadline / Cloud Storage subscription（早期無原生、自建對照）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <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>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.atspotify.com/2017/04/reliable-export-of-cloud-pubsub-streams-to-cloud-storage">Reliable Export of Cloud Pub/Sub Streams to Cloud Storage</a></li>
</ul>
]]></content:encoded></item><item><title>3.C63 Mercari Actionable History：ack deadline 是 batch-level</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/</guid><description>&lt;p>這個案例的核心責任是揭露 Pub/Sub client lib 「ack deadline 是 batch-level」這個真實的工程陷阱。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Merpay 支付流水帳服務用 Pub/Sub 做 async messaging、靠 nack 控制處理順序；踩到「ack deadline 是整批 batch 而非單訊息」、acked 訊息會跟同 batch 其他 expired/nacked 訊息一起 redeliver 的設計細節。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「ack deadline 是 batch-level」是 Pub/Sub client lib 真實的工程陷阱；idempotency 是處理 duplicate 的必要設計、新出的 exactly-once delivery 才有機會降低重複量。揭露 client lib 的批次語意會「污染」單訊息 ack。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Ack deadline / Push vs Pull / Ordering key（exactly-once / ordering 章節）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.mercari.com/en/blog/entry/20221212-merpay-actionable-history-displaying-millions-of-payments-with-lightning-speed/">Merpay Actionable History: Displaying Millions of Payments with Lightning Speed&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是揭露 Pub/Sub client lib 「ack deadline 是 batch-level」這個真實的工程陷阱。</p>
<h2 id="觀察">觀察</h2>
<p>Merpay 支付流水帳服務用 Pub/Sub 做 async messaging、靠 nack 控制處理順序；踩到「ack deadline 是整批 batch 而非單訊息」、acked 訊息會跟同 batch 其他 expired/nacked 訊息一起 redeliver 的設計細節。</p>
<h2 id="判讀">判讀</h2>
<p>「ack deadline 是 batch-level」是 Pub/Sub client lib 真實的工程陷阱；idempotency 是處理 duplicate 的必要設計、新出的 exactly-once delivery 才有機會降低重複量。揭露 client lib 的批次語意會「污染」單訊息 ack。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Ack deadline / Push vs Pull / Ordering key（exactly-once / ordering 章節）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.mercari.com/en/blog/entry/20221212-merpay-actionable-history-displaying-millions-of-payments-with-lightning-speed/">Merpay Actionable History: Displaying Millions of Payments with Lightning Speed</a></li>
</ul>
]]></content:encoded></item><item><title>3.C64 Mercari Item Feed：DLT 防 poison message 阻塞</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/</guid><description>&lt;p>這個案例的核心責任是說明 DLT 在防止 poison message 阻塞 pipeline 的角色。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>商品 feed 同步用 pull subscription + 自家 batch requester、成功時 ack 整批、失敗時 nack 讓 Pub/Sub 重送；重試多次仍失敗則送 Dead-letter topic、後續訊息優先處理；topic 同時當突發流量的緩衝。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>直接示範 DLT 在防止 poison message 阻塞 pipeline 的角色、以及把 topic 當 load-leveling queue 的設計。揭露「topic = buffer + dispatch」雙重角色。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Dead-letter topic / Push vs Pull subscription。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/" data-link-title="3.C56 PostNL EBE：完整 DLQ &amp;#43; retention &amp;#43; redrive 設計" data-link-desc="PostNL 物流每天 1000 萬訊息、每 producer/consumer 隔離 stack、24h 內 100 次 retry、final DLQ 可 consumer redrive。">3.C56 PostNL EBE&lt;/a>（DLQ 設計對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.mercari.com/en/blog/entry/20241212-mercaris-seamless-item-feed-integration-bridging-the-gap-between-systems/">Mercari&amp;rsquo;s Seamless Item Feed Integration&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 DLT 在防止 poison message 阻塞 pipeline 的角色。</p>
<h2 id="觀察">觀察</h2>
<p>商品 feed 同步用 pull subscription + 自家 batch requester、成功時 ack 整批、失敗時 nack 讓 Pub/Sub 重送；重試多次仍失敗則送 Dead-letter topic、後續訊息優先處理；topic 同時當突發流量的緩衝。</p>
<h2 id="判讀">判讀</h2>
<p>直接示範 DLT 在防止 poison message 阻塞 pipeline 的角色、以及把 topic 當 load-leveling queue 的設計。揭露「topic = buffer + dispatch」雙重角色。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Dead-letter topic / Push vs Pull subscription。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/" data-link-title="3.C56 PostNL EBE：完整 DLQ &#43; retention &#43; redrive 設計" data-link-desc="PostNL 物流每天 1000 萬訊息、每 producer/consumer 隔離 stack、24h 內 100 次 retry、final DLQ 可 consumer redrive。">3.C56 PostNL EBE</a>（DLQ 設計對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.mercari.com/en/blog/entry/20241212-mercaris-seamless-item-feed-integration-bridging-the-gap-between-systems/">Mercari&rsquo;s Seamless Item Feed Integration</a></li>
</ul>
]]></content:encoded></item><item><title>3.C65 Mercari LINE：Pull subscription 對齊外部 RPS</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/</guid><description>&lt;p>這個案例的核心責任是說明「下游有 RPS 限制」是 Pull subscription 勝過 push 的典型情境。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Braze webhook 進來後轉成 Pub/Sub event、下游 LINE worker pull subscription「精確控制每秒處理訊息數」、因為外部 LINE API 有 RPS 限制。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>push 會把流量瞬間打到 endpoint、pull 可由 consumer 自行 throttle。揭露 push vs pull 不是「實作偏好」、是「下游能否接受 push 衝擊」的判讀。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Push vs Pull subscription。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58 Twilio webhook buffer&lt;/a>（webhook + buffer 對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.mercari.com/en/blog/entry/20231212-flow-control-challenges-in-mercaris-line-integration/">Flow Control Challenges in Mercari&amp;rsquo;s LINE Integration&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「下游有 RPS 限制」是 Pull subscription 勝過 push 的典型情境。</p>
<h2 id="觀察">觀察</h2>
<p>Braze webhook 進來後轉成 Pub/Sub event、下游 LINE worker pull subscription「精確控制每秒處理訊息數」、因為外部 LINE API 有 RPS 限制。</p>
<h2 id="判讀">判讀</h2>
<p>push 會把流量瞬間打到 endpoint、pull 可由 consumer 自行 throttle。揭露 push vs pull 不是「實作偏好」、是「下游能否接受 push 衝擊」的判讀。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Push vs Pull subscription。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58 Twilio webhook buffer</a>（webhook + buffer 對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.mercari.com/en/blog/entry/20231212-flow-control-challenges-in-mercaris-line-integration/">Flow Control Challenges in Mercari&rsquo;s LINE Integration</a></li>
</ul>
]]></content:encoded></item><item><title>3.C66 Mercari B2C：自建 PubSub gRPC Pusher</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-b2c-grpc-pusher/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-b2c-grpc-pusher/</guid><description>&lt;p>這個案例的核心責任是說明原生 push subscription 在特定場景的限制、逼出自建層的工程選擇。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>全球商品同步系統、自建 in-house「PubSub gRPC Pusher」（Pub/Sub 的 gRPC 版 push subscription）解決高吞吐 / 長 job / 彈性 RPS；同時用 message ID 做去重、timestamp 驗證解決重複 + 亂序。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>原生 HTTP push subscription 在「長 job + 高吞吐 + 動態 rate」場景的限制、逼出自建層的工程選擇。揭露 managed broker 的「原生功能」不是所有場景的終點。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Push vs Pull subscription / Ordering key（亂序的 application-level 處理）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.mercari.com/en/blog/entry/20251009-from-local-to-global-building-seamless-b2c-product-integration-at-mercari/">From Local to Global: Building Seamless B2C Product Integration at Mercari&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明原生 push subscription 在特定場景的限制、逼出自建層的工程選擇。</p>
<h2 id="觀察">觀察</h2>
<p>全球商品同步系統、自建 in-house「PubSub gRPC Pusher」（Pub/Sub 的 gRPC 版 push subscription）解決高吞吐 / 長 job / 彈性 RPS；同時用 message ID 做去重、timestamp 驗證解決重複 + 亂序。</p>
<h2 id="判讀">判讀</h2>
<p>原生 HTTP push subscription 在「長 job + 高吞吐 + 動態 rate」場景的限制、逼出自建層的工程選擇。揭露 managed broker 的「原生功能」不是所有場景的終點。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Push vs Pull subscription / Ordering key（亂序的 application-level 處理）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.mercari.com/en/blog/entry/20251009-from-local-to-global-building-seamless-b2c-product-integration-at-mercari/">From Local to Global: Building Seamless B2C Product Integration at Mercari</a></li>
</ul>
]]></content:encoded></item><item><title>3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/</guid><description>&lt;p>這個案例的核心責任是說明大規模遊戲 telemetry 的 ingest backbone 設計。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Pokémon GO frontend 把玩家事件 publish 到 Pub/Sub topic 餵分析 pipeline、再進 BigQuery streaming；高峰 ~1M TPS、Pub/Sub 是 managed service 因此 SRE 維運成本低。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>Pub/Sub 在 publisher 突發流量下作為 elastic buffer、下游 BigQuery streaming 是常見組合。揭露「managed service 的 SRE 成本」是大規模遊戲場景的關鍵選型理由。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：BigQuery subscription（原生 BQ subscription 出現前的 Dataflow pattern）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/" data-link-title="3.C68 Wix：Pub/Sub decouple &amp;#43; Dataflow &amp;#43; BQ archive" data-link-desc="Wix App Engine 收 clickstream 進 Pub/Sub、Dataflow 進 Datastore &amp;lt; 100ms、BigQuery 並行存 raw recovery。">3.C68 Wix clickstream&lt;/a>（同類組合）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://cloud.google.com/blog/topics/developers-practitioners/how-pok%C3%A9mon-go-scales-millions-requests">How Pokémon GO Scales to Millions of Requests&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明大規模遊戲 telemetry 的 ingest backbone 設計。</p>
<h2 id="觀察">觀察</h2>
<p>Pokémon GO frontend 把玩家事件 publish 到 Pub/Sub topic 餵分析 pipeline、再進 BigQuery streaming；高峰 ~1M TPS、Pub/Sub 是 managed service 因此 SRE 維運成本低。</p>
<h2 id="判讀">判讀</h2>
<p>Pub/Sub 在 publisher 突發流量下作為 elastic buffer、下游 BigQuery streaming 是常見組合。揭露「managed service 的 SRE 成本」是大規模遊戲場景的關鍵選型理由。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：BigQuery subscription（原生 BQ subscription 出現前的 Dataflow pattern）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/" data-link-title="3.C68 Wix：Pub/Sub decouple &#43; Dataflow &#43; BQ archive" data-link-desc="Wix App Engine 收 clickstream 進 Pub/Sub、Dataflow 進 Datastore &lt; 100ms、BigQuery 並行存 raw recovery。">3.C68 Wix clickstream</a>（同類組合）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://cloud.google.com/blog/topics/developers-practitioners/how-pok%C3%A9mon-go-scales-millions-requests">How Pokémon GO Scales to Millions of Requests</a></li>
</ul>
]]></content:encoded></item><item><title>3.C68 Wix：Pub/Sub decouple + Dataflow + BQ archive</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/</guid><description>&lt;p>這個案例的核心責任是「Pub/Sub buffer + Dataflow stream processor + BQ archive」的教科書組合。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>App Engine 收 clickstream → 進 Cloud Pub/Sub queue、再由 Dataflow streaming 處理進 Datastore、dashboard 端到端 latency &amp;lt; 100ms；BigQuery 並行存 raw data 做 recovery。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「Pub/Sub 當 decouple buffer + Dataflow 當 stream processor + BigQuery 當 raw archive」的 textbook 組合、可作為 BigQuery subscription 出現前的對比 case（為什麼後來原生 BQ subscription 能省掉 Dataflow 中介層）。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：BigQuery subscription / Push vs Pull。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/" data-link-title="3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest" data-link-desc="Pokémon GO frontend publish 玩家事件、~1M TPS、Pub/Sub elastic buffer、下游 BigQuery streaming。">3.C67 Niantic Pokémon GO&lt;/a>（同類組合）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://cloud.google.com/customers/wix">Wix Customer Story&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是「Pub/Sub buffer + Dataflow stream processor + BQ archive」的教科書組合。</p>
<h2 id="觀察">觀察</h2>
<p>App Engine 收 clickstream → 進 Cloud Pub/Sub queue、再由 Dataflow streaming 處理進 Datastore、dashboard 端到端 latency &lt; 100ms；BigQuery 並行存 raw data 做 recovery。</p>
<h2 id="判讀">判讀</h2>
<p>「Pub/Sub 當 decouple buffer + Dataflow 當 stream processor + BigQuery 當 raw archive」的 textbook 組合、可作為 BigQuery subscription 出現前的對比 case（為什麼後來原生 BQ subscription 能省掉 Dataflow 中介層）。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：BigQuery subscription / Push vs Pull。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/" data-link-title="3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest" data-link-desc="Pokémon GO frontend publish 玩家事件、~1M TPS、Pub/Sub elastic buffer、下游 BigQuery streaming。">3.C67 Niantic Pokémon GO</a>（同類組合）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://cloud.google.com/customers/wix">Wix Customer Story</a></li>
</ul>
]]></content:encoded></item><item><title>3.C69 Twitter Ad Engagement：把 stream 切成多 topic 做 partition</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-twitter-ad-engagement/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-twitter-ad-engagement/</guid><description>&lt;p>這個案例的核心責任是說明 Pub/Sub 沒有 Kafka-style partition 概念下的應對策略。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Twitter 把 on-prem 服務的 Avro-formatted 訊息 push 到 Pub/Sub（兩條 stream、較不關鍵但量大的那條 ~80K msg/s 切成 6 個 topic）、下游用 Dataflow + Beam 處理進 Bigtable / BigQuery。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「把單一 high-volume stream 切成多 topic 做 partition」是 Pub/Sub 沒有 Kafka-style partition 概念下的應對策略。揭露 Pub/Sub 跟 Kafka 的選型差異不是 feature parity、是不同的擴張模型。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Schema enforcement（Avro 是常見 schema 候選）/ Ordering key（topic 切分 vs ordering key 的取捨）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &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 模型">Kafka vendor 頁&lt;/a>（partition 對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://cloud.google.com/blog/products/data-analytics/modernizing-twitters-ad-engagement-analytics-platform">Modernizing Twitter&amp;rsquo;s Ad Engagement Analytics Platform&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 Pub/Sub 沒有 Kafka-style partition 概念下的應對策略。</p>
<h2 id="觀察">觀察</h2>
<p>Twitter 把 on-prem 服務的 Avro-formatted 訊息 push 到 Pub/Sub（兩條 stream、較不關鍵但量大的那條 ~80K msg/s 切成 6 個 topic）、下游用 Dataflow + Beam 處理進 Bigtable / BigQuery。</p>
<h2 id="判讀">判讀</h2>
<p>「把單一 high-volume stream 切成多 topic 做 partition」是 Pub/Sub 沒有 Kafka-style partition 概念下的應對策略。揭露 Pub/Sub 跟 Kafka 的選型差異不是 feature parity、是不同的擴張模型。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Schema enforcement（Avro 是常見 schema 候選）/ Ordering key（topic 切分 vs ordering key 的取捨）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a>（partition 對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://cloud.google.com/blog/products/data-analytics/modernizing-twitters-ad-engagement-analytics-platform">Modernizing Twitter&rsquo;s Ad Engagement Analytics Platform</a></li>
</ul>
]]></content:encoded></item></channel></rss>