<?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>Realtime on Tarragon</title><link>https://tarrragon.github.io/blog/tags/realtime/</link><description>Recent content in Realtime on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/realtime/index.xml" rel="self" type="application/rss+xml"/><item><title>Firestore realtime listener 扇出與成本：snapshot 訂閱、re-read 計費與連線規模</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/realtime-listener-fanout-cost/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/realtime-listener-fanout-cost/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/" data-link-title="Firestore" data-link-desc="Firebase / Google Cloud 的 serverless document database、collection / document 模型、client 直連 &amp;#43; Security Rules、realtime listener 與 offline 同步、BaaS bundle 的資料層面">Firestore&lt;/a> overview 的 deep article。寫作參照 &lt;a href="https://tarrragon.github.io/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 深度技術文章寫作方法論&lt;/a>。計費模型以 &lt;a href="https://firebase.google.com/docs/firestore/pricing">官方 pricing&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境即時很爽帳單很痛">問題情境：即時很爽，帳單很痛&lt;/h2>
&lt;p>Firestore 的 snapshot listener 是它最有吸引力的能力——client &lt;code>onSnapshot&lt;/code> 訂閱一個 query，資料一變就即時推送，多裝置同步、協作介面幾乎免費得到。團隊很快把所有列表都改成 listener：訊息列表、通知、儀表板計數，全部即時更新，體驗很好。&lt;/p>
&lt;p>帳單在用戶量上來後出問題。Firestore 對 listener 的計費規則是——query 結果裡每個被推送的 document 都計一次 read。一個列表有 100 名觀眾各自訂閱、列表變動推送 50 筆，就是 100 × 50 = 5000 次 read。即時的爽感建立在 re-read 計費上，扇出越大、變動越頻繁，成本成乘積成長。這篇處理 listener 的推送與計費模型、如何設計訂閱範圍把成本壓住、以及即時需求超過 listener 能力時的退場。&lt;/p>
&lt;h2 id="核心概念listener-的推送與計費模型">核心概念：listener 的推送與計費模型&lt;/h2>
&lt;p>snapshot listener 不是「推送變動的那一筆」這麼簡單。理解它的成本要抓三點：&lt;/p>
&lt;p>&lt;strong>初次訂閱讀整個結果集，之後讀變動的部分&lt;/strong>。&lt;code>onSnapshot(query)&lt;/code> 第一次觸發時，query 結果的每個 document 計一次 read（跟一次性 &lt;code>getDocs&lt;/code> 相同）。之後 query 結果有 document 新增、修改、移出，推送那些變動的 document，各計一次 read。所以 listener 的計費 = 初次結果集大小 + 後續每次變動推送的 document 數。&lt;/p>
&lt;p>&lt;strong>計費是 per-listener 的&lt;/strong>。同一個 query 被 N 個 client 各自訂閱，是 N 個獨立 listener，變動推送計 N 次。扇出（同一資料多少人在看）直接乘進成本。這跟自建後端用一個 WebSocket broadcast 推給 N 個連線的模型不同——那裡資料讀一次、推 N 份；Firestore listener 是每個訂閱各自從資料庫讀。&lt;/p>
&lt;p>&lt;strong>query 範圍決定推送頻率&lt;/strong>。訂閱一個寬的 query（整個 collection），collection 裡任何符合的 document 變動都推；訂閱窄的 query（只我相關的那幾筆），只有那幾筆變動才推。listener 成本的設計槓桿是「把訂閱範圍縮到 client 真正要即時看到的最小集合」。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">onSnapshot&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">collection&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">where&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">orderBy&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">limit&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="nx">from&lt;/span> &lt;span class="s1">&amp;#39;firebase/firestore&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">// 寬訂閱：整個 messages collection 任何變動都推（成本失控）
&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">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">wide&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">collection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;messages&amp;#39;&lt;/span>&lt;span class="p">));&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">// 窄訂閱：只訂這個對話的最近 50 則（成本可控）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">narrow&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">query&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">collection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;messages&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">where&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;conversationId&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;==&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">convId&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">orderBy&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;createdAt&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;desc&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">limit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">unsub&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">onSnapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">narrow&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">snap&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">snap&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">docChanges&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="nx">change&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 只處理變動的部分，不是每次重畫整個列表
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">change&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">type&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;added&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* ... */&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">change&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">type&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;modified&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* ... */&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">change&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">type&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;removed&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* ... */&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="c1">// 畫面離開時務必取消訂閱，否則 listener 與計費持續
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="c1">// unsub();
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>docChanges()&lt;/code> 是控制成本與效能的關鍵——它只給「跟上次相比變動的 document」，而不是每次都拿整個結果集重畫。用 &lt;code>limit&lt;/code> 把結果集封頂、用 &lt;code>where&lt;/code> 把範圍縮到 client 相關，是 listener 成本設計的兩個主要手段。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/firestore/" data-link-title="Firestore" data-link-desc="Firebase / Google Cloud 的 serverless document database、collection / document 模型、client 直連 &#43; Security Rules、realtime listener 與 offline 同步、BaaS bundle 的資料層面">Firestore</a> overview 的 deep article。寫作參照 <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>。計費模型以 <a href="https://firebase.google.com/docs/firestore/pricing">官方 pricing</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="問題情境即時很爽帳單很痛">問題情境：即時很爽，帳單很痛</h2>
<p>Firestore 的 snapshot listener 是它最有吸引力的能力——client <code>onSnapshot</code> 訂閱一個 query，資料一變就即時推送，多裝置同步、協作介面幾乎免費得到。團隊很快把所有列表都改成 listener：訊息列表、通知、儀表板計數，全部即時更新，體驗很好。</p>
<p>帳單在用戶量上來後出問題。Firestore 對 listener 的計費規則是——query 結果裡每個被推送的 document 都計一次 read。一個列表有 100 名觀眾各自訂閱、列表變動推送 50 筆，就是 100 × 50 = 5000 次 read。即時的爽感建立在 re-read 計費上，扇出越大、變動越頻繁，成本成乘積成長。這篇處理 listener 的推送與計費模型、如何設計訂閱範圍把成本壓住、以及即時需求超過 listener 能力時的退場。</p>
<h2 id="核心概念listener-的推送與計費模型">核心概念：listener 的推送與計費模型</h2>
<p>snapshot listener 不是「推送變動的那一筆」這麼簡單。理解它的成本要抓三點：</p>
<p><strong>初次訂閱讀整個結果集，之後讀變動的部分</strong>。<code>onSnapshot(query)</code> 第一次觸發時，query 結果的每個 document 計一次 read（跟一次性 <code>getDocs</code> 相同）。之後 query 結果有 document 新增、修改、移出，推送那些變動的 document，各計一次 read。所以 listener 的計費 = 初次結果集大小 + 後續每次變動推送的 document 數。</p>
<p><strong>計費是 per-listener 的</strong>。同一個 query 被 N 個 client 各自訂閱，是 N 個獨立 listener，變動推送計 N 次。扇出（同一資料多少人在看）直接乘進成本。這跟自建後端用一個 WebSocket broadcast 推給 N 個連線的模型不同——那裡資料讀一次、推 N 份；Firestore listener 是每個訂閱各自從資料庫讀。</p>
<p><strong>query 範圍決定推送頻率</strong>。訂閱一個寬的 query（整個 collection），collection 裡任何符合的 document 變動都推；訂閱窄的 query（只我相關的那幾筆），只有那幾筆變動才推。listener 成本的設計槓桿是「把訂閱範圍縮到 client 真正要即時看到的最小集合」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">onSnapshot</span><span class="p">,</span> <span class="nx">query</span><span class="p">,</span> <span class="nx">collection</span><span class="p">,</span> <span class="nx">where</span><span class="p">,</span> <span class="nx">orderBy</span><span class="p">,</span> <span class="nx">limit</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;firebase/firestore&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">// 寬訂閱：整個 messages collection 任何變動都推（成本失控）
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">wide</span> <span class="o">=</span> <span class="nx">query</span><span class="p">(</span><span class="nx">collection</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="s1">&#39;messages&#39;</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">// 窄訂閱：只訂這個對話的最近 50 則（成本可控）
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">narrow</span> <span class="o">=</span> <span class="nx">query</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nx">collection</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="s1">&#39;messages&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nx">where</span><span class="p">(</span><span class="s1">&#39;conversationId&#39;</span><span class="p">,</span> <span class="s1">&#39;==&#39;</span><span class="p">,</span> <span class="nx">convId</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nx">orderBy</span><span class="p">(</span><span class="s1">&#39;createdAt&#39;</span><span class="p">,</span> <span class="s1">&#39;desc&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nx">limit</span><span class="p">(</span><span class="mi">50</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">);</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="kr">const</span> <span class="nx">unsub</span> <span class="o">=</span> <span class="nx">onSnapshot</span><span class="p">(</span><span class="nx">narrow</span><span class="p">,</span> <span class="p">(</span><span class="nx">snap</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nx">snap</span><span class="p">.</span><span class="nx">docChanges</span><span class="p">().</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">change</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="c1">// 只處理變動的部分，不是每次重畫整個列表
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span>    <span class="k">if</span> <span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">type</span> <span class="o">===</span> <span class="s1">&#39;added&#39;</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">type</span> <span class="o">===</span> <span class="s1">&#39;modified&#39;</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">type</span> <span class="o">===</span> <span class="s1">&#39;removed&#39;</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1">// 畫面離開時務必取消訂閱，否則 listener 與計費持續
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="c1">// unsub();
</span></span></span></code></pre></div><p><code>docChanges()</code> 是控制成本與效能的關鍵——它只給「跟上次相比變動的 document」，而不是每次都拿整個結果集重畫。用 <code>limit</code> 把結果集封頂、用 <code>where</code> 把範圍縮到 client 相關，是 listener 成本設計的兩個主要手段。</p>
<h2 id="配置訂閱範圍與生命週期設計">配置：訂閱範圍與生命週期設計</h2>
<p>listener 的成本與效能由訂閱範圍和生命週期決定。三個設計原則：</p>
<p><strong>訂閱跟著畫面生命週期</strong>。listener 在畫面進入時建立、離開時 <code>unsubscribe()</code>。最常見的成本洩漏是忘記取消訂閱——使用者切走了，listener 還在背景持續接收推送計費。在元件 unmount、路由切換、app 進背景時取消所有 listener。</p>
<p><strong>用 <code>limit</code> 封頂結果集，配分頁</strong>。即時列表只訂最近 N 筆，往前翻歷史用一次性 <code>getDocs</code> 分頁，不訂閱。歷史資料不會變、不需要即時，訂閱它只是白付 re-read。即時的部分小而精，歷史的部分按需一次性拉。</p>
<p><strong>高扇出的即時值改訂閱彙總 document</strong>。一萬名觀眾要看同一個即時計數，正解是由後端把彙總值寫進一個 summary document、所有人訂閱那一份，而非各自訂閱原始資料加總。扇出仍是一萬個 listener，但每次變動只推一份小 document，而不是推整個結果集——把推送的 payload 壓到最小。這跟 <a href="/blog/backend/01-database/vendors/firestore/distributed-counter-high-frequency-write/" data-link-title="Firestore 高頻寫入與 distributed counter：單 document contention 邊界與分片計數" data-link-desc="Firestore 單一 document 有持續寫入的軟上限、高頻計數寫爆 contention 是常見事故；本文展開寫入 contention 的成因、distributed counter 分片計數的實作與讀取彙總、shard 數量與讀寫成本的取捨、五個高頻寫入踩坑，以及計數需求超過分片能處理時改走外部聚合的邊界">distributed counter</a> 的 summary 彙總是同一個手段的兩面：那裡解寫入熱點，這裡解讀取扇出。</p>
<h2 id="故障演練五個-realtime-成本踩坑">故障演練：五個 realtime 成本踩坑</h2>
<h4 id="case-1把不需要即時的列表也做成-listener">Case 1：把不需要即時的列表也做成 listener</h4>
<p>歷史訊息、已讀通知、靜態設定全用 <code>onSnapshot</code>，這些資料根本不變或極少變，訂閱它們只是把一次性讀取變成持續掛著的 listener。修法：先問「這個資料 client 在看的時候會不會變、變了要不要立刻看到」，否才用 listener；不變或不需即時的用一次性 <code>getDocs</code>。</p>
<h4 id="case-2忘記-unsubscribe-造成-listener-洩漏">Case 2：忘記 unsubscribe 造成 listener 洩漏</h4>
<p>路由切換、元件重建時建了新 listener 沒取消舊的，listener 越積越多、計費持續、記憶體也漏。修法：listener 的建立與取消綁死畫面生命週期，用框架的 cleanup hook（React <code>useEffect</code> return、Vue <code>onUnmounted</code>）統一管理，app 進背景時主動斷。</p>
<h4 id="case-3訂閱寬-query-被無關變動轟炸">Case 3：訂閱寬 query 被無關變動轟炸</h4>
<p>訂了整個 <code>orders</code> collection 想看自己的訂單，結果別人的訂單一變也推給你（雖然規則可能擋讀，但寬 query 本身設計就錯）。修法：query 用 <code>where</code> 縮到 client 相關的最小集合，訂閱範圍與 <a href="/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules</a> 的授權範圍對齊。</p>
<h4 id="case-4每次-snapshot-重畫整個列表">Case 4：每次 snapshot 重畫整個列表</h4>
<p><code>onSnapshot</code> callback 裡拿 <code>snap.docs</code> 整個重建 UI，而不用 <code>docChanges()</code>，列表大時每次推送都重畫、UI 卡頓。修法：用 <code>docChanges()</code> 只處理 added / modified / removed 的增量，UI 做局部更新。</p>
<h4 id="case-5高扇出直接訂閱原始資料">Case 5：高扇出直接訂閱原始資料</h4>
<p>直播觀看數讓每個觀眾訂閱原始事件流自己算，扇出 × 結果集大小的 re-read 爆炸。修法：後端彙總寫 summary document，觀眾訂閱 summary 一份，把推送 payload 與 re-read 都壓到最小。</p>
<h2 id="容量與觀測扇出--變動頻率的成本估算">容量與觀測：扇出 × 變動頻率的成本估算</h2>
<p>listener 成本估算的公式是 <code>初次訂閱 read + Σ(訂閱數 × 每次變動推送的 document 數)</code>。把它拆開算：高扇出（很多人訂同一資料）× 高變動頻率（資料常變）× 大結果集（每次推很多筆）三者相乘，是成本爆炸的組合；任一維壓低都有效。設計時對每個 listener 問這三維的量級，乘起來對照預算。</p>
<p>連線數也有規模考量：Firestore 對並行連線與 listener 有規模上限（以官方當前限制為準），超大扇出（百萬級同時在線）會撞到連線層的天花板，而不只是計費問題。觀測上要監控 read 用量的來源拆分——哪些 collection 的 read 來自 listener 推送、哪些來自一次性查詢，把 listener 的 re-read 成本獨立出來看，接回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 與 <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.7 成本邊界</a>。</p>
<h2 id="邊界與整合即時需求超過-listener-該換推送架構">邊界與整合：即時需求超過 listener 該換推送架構</h2>
<p>snapshot listener 適合「中等扇出、client 要即時看到自己相關資料變動」的場景——協作編輯、聊天、個人通知、儀表板。它撐不住的訊號是扇出或變動頻率推高 re-read 成本到不划算，或連線規模撞到天花板：</p>
<ul>
<li><strong>超高扇出的廣播</strong>：百萬人看同一場直播的即時數據，per-listener 的 re-read 模型成本遠高於自建一次讀取、WebSocket broadcast 推 N 份的模型。這類純廣播（一份資料推給海量訂閱者）用專門的推送層（自建 WebSocket / SSE、或 pub/sub + 邊緣推送）更划算，見 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a> 的 fan-out 設計</li>
<li><strong>複雜事件處理的即時</strong>：即時推送需要先做跨資料聚合、過濾、轉換，listener 只能訂 query 結果、表達不了。這類要後端處理後再推，listener 不是合適的傳輸層</li>
<li><strong>即時是核心且規模化</strong>：當即時同步是產品核心且扇出規模化，整個即時層自建是 <a href="/blog/backend/01-database/vendors/firestore/migrate-to-relational/" data-link-title="從 Firestore 遷往自建 relational：撞牆驅動的 Type E 重建模、存取模型反轉與並行期" data-link-desc="Firestore → 自建後端 &#43; relational 不是匯資料而是反轉存取模型：client 直連變 API 中介、Security Rules 授權變後端授權、document 反正規化變正規 schema、realtime listener 與 offline 同步要重建；本文走 Type E paradigm shift 結構、展開為何字面遷移不成立、哪些該遷哪些先留、dual-write &#43; shadow read 階段化與遷出代價判讀">Firestore → 自建 relational</a> 裡「realtime / offline 要重建」這項工作量——遷移時這層最容易被低估</li>
</ul>
<p>判讀的起點是「這份即時是 client 看自己相關的少量資料，還是海量訂閱者看同一份廣播」。前者 listener 是正解，後者從一開始就該用推送架構，而不是把 listener 的扇出推到極限。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上層：<a href="/blog/backend/01-database/vendors/firestore/" data-link-title="Firestore" data-link-desc="Firebase / Google Cloud 的 serverless document database、collection / document 模型、client 直連 &#43; Security Rules、realtime listener 與 offline 同步、BaaS bundle 的資料層面">Firestore overview</a>（realtime / offline 能力與容量特性）</li>
<li>sibling：<a href="/blog/backend/01-database/vendors/firestore/distributed-counter-high-frequency-write/" data-link-title="Firestore 高頻寫入與 distributed counter：單 document contention 邊界與分片計數" data-link-desc="Firestore 單一 document 有持續寫入的軟上限、高頻計數寫爆 contention 是常見事故；本文展開寫入 contention 的成因、distributed counter 分片計數的實作與讀取彙總、shard 數量與讀寫成本的取捨、五個高頻寫入踩坑，以及計數需求超過分片能處理時改走外部聚合的邊界">distributed counter 高頻寫入</a>（summary 彙總的另一面）</li>
<li>授權對齊：<a href="/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules 授權建模</a>（訂閱範圍與授權範圍一致）</li>
<li>推送架構：<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a>（超高扇出 broadcast 的去處）</li>
<li>成本邊界：<a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.7 成本邊界與 efficiency</a></li>
<li>官方：<a href="https://firebase.google.com/docs/firestore/pricing">Firestore pricing</a>、<a href="https://firebase.google.com/docs/firestore/query-data/listen">Listen to realtime updates</a></li>
</ul>
]]></content:encoded></item></channel></rss>