<?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>Firestore on Tarragon</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/</link><description>Recent content in Firestore 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/backend/01-database/vendors/firestore/index.xml" rel="self" type="application/rss+xml"/><item><title>從 Firestore 遷往自建 relational：撞牆驅動的 Type E 重建模、存取模型反轉與並行期</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/migrate-to-relational/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/migrate-to-relational/</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 的 migration playbook。寫作參照 &lt;a href="https://tarrragon.github.io/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 寫作方法論&lt;/a>。BaaS 託管平台整場遷出的資產線盤點與並行期總覽見 &lt;a href="https://tarrragon.github.io/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">10.3 託管形態遷出&lt;/a>；本文聚焦資料層的跨 paradigm 重建模。&lt;/p>&lt;/blockquote>
&lt;p>「我們把 Firestore 整包匯出，匯進 PostgreSQL 就好。」這句話低估了遷移的真正內容 — Firestore 遷往自建 relational 的難點是&lt;strong>反轉整個存取模型&lt;/strong>，搬資料只是其中最容易的一條線。Firestore 是 client 用 SDK 直連資料庫、授權寫在 Security Rules；自建 relational 是 client 打自己的後端 API、授權在後端中介層。資料可以匯出，但反正規化的 document 形狀、沿查詢限制長出來的資料模型、realtime listener 與 offline 同步能力，都沒有 1:1 的對應物。字面意義的「匯出再匯入」只搬走了最容易的那部分。本文走 paradigm shift 結構：先講為何字面遷移不成立、再講哪些該遷哪些先留、最後才是階段化執行。&lt;/p>
&lt;h2 id="遷移的-driver三面牆不是relational-比較好">遷移的 driver：三面牆，不是「relational 比較好」&lt;/h2>
&lt;p>Firestore 遷往自建很少因為「relational 比較好」這種空泛動機，而是撞到 &lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21&lt;/a> BaaS 段描述的三面具體的牆。先確認 driver 真的成立、再啟動遷移：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Driver&lt;/th>
 &lt;th>撞牆訊號&lt;/th>
 &lt;th>遷移要解的問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>報表 / 分析查詢&lt;/td>
 &lt;td>跨 collection 報表查不出來、已經在維護資料複製管線&lt;/td>
 &lt;td>把資料放回支援 JOIN / aggregation 的 relational&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本曲線轉折&lt;/td>
 &lt;td>read / write 計費隨流量線性成長、超過自建 + cache 的成本&lt;/td>
 &lt;td>用自管資料庫 + 應用層快取壓低單位成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>授權控制面失控&lt;/td>
 &lt;td>Security Rules 長到難以測試 / review、授權邏輯沒有版本治理&lt;/td>
 &lt;td>把授權拉回後端 API 中介層、可測試可審查&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>&lt;strong>No-go condition&lt;/strong>：產品仍以多裝置 realtime 同步與 offline-first 為核心賣點、且查詢需求簡單、成本仍在舒適區 → 先不要遷。這些正是 Firestore 的主場，硬遷會把 realtime / offline 這層平台白送的能力變成自己要重建的工程。遷移前先問「撞的是哪面牆」，三面牆都沒撞到就是 &lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22&lt;/a> 講的偽自建。&lt;/p>&lt;/blockquote>
&lt;p>逐能力遷出是常態而非整包搬離：&lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22 的「成長期 SaaS」例子&lt;/a> 就是只把撞牆的資料層搬到自管 PostgreSQL、認證留在原平台。本文預設的也是這種逐能力遷出 — 遷的是資料層，不一定連認證、儲存一起搬。&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 的 migration playbook。寫作參照 <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>。BaaS 託管平台整場遷出的資產線盤點與並行期總覽見 <a href="/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">10.3 託管形態遷出</a>；本文聚焦資料層的跨 paradigm 重建模。</p></blockquote>
<p>「我們把 Firestore 整包匯出，匯進 PostgreSQL 就好。」這句話低估了遷移的真正內容 — Firestore 遷往自建 relational 的難點是<strong>反轉整個存取模型</strong>，搬資料只是其中最容易的一條線。Firestore 是 client 用 SDK 直連資料庫、授權寫在 Security Rules；自建 relational 是 client 打自己的後端 API、授權在後端中介層。資料可以匯出，但反正規化的 document 形狀、沿查詢限制長出來的資料模型、realtime listener 與 offline 同步能力，都沒有 1:1 的對應物。字面意義的「匯出再匯入」只搬走了最容易的那部分。本文走 paradigm shift 結構：先講為何字面遷移不成立、再講哪些該遷哪些先留、最後才是階段化執行。</p>
<h2 id="遷移的-driver三面牆不是relational-比較好">遷移的 driver：三面牆，不是「relational 比較好」</h2>
<p>Firestore 遷往自建很少因為「relational 比較好」這種空泛動機，而是撞到 <a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21</a> BaaS 段描述的三面具體的牆。先確認 driver 真的成立、再啟動遷移：</p>
<table>
  <thead>
      <tr>
          <th>Driver</th>
          <th>撞牆訊號</th>
          <th>遷移要解的問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>報表 / 分析查詢</td>
          <td>跨 collection 報表查不出來、已經在維護資料複製管線</td>
          <td>把資料放回支援 JOIN / aggregation 的 relational</td>
      </tr>
      <tr>
          <td>成本曲線轉折</td>
          <td>read / write 計費隨流量線性成長、超過自建 + cache 的成本</td>
          <td>用自管資料庫 + 應用層快取壓低單位成本</td>
      </tr>
      <tr>
          <td>授權控制面失控</td>
          <td>Security Rules 長到難以測試 / review、授權邏輯沒有版本治理</td>
          <td>把授權拉回後端 API 中介層、可測試可審查</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>No-go condition</strong>：產品仍以多裝置 realtime 同步與 offline-first 為核心賣點、且查詢需求簡單、成本仍在舒適區 → 先不要遷。這些正是 Firestore 的主場，硬遷會把 realtime / offline 這層平台白送的能力變成自己要重建的工程。遷移前先問「撞的是哪面牆」，三面牆都沒撞到就是 <a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22</a> 講的偽自建。</p></blockquote>
<p>逐能力遷出是常態而非整包搬離：<a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22 的「成長期 SaaS」例子</a> 就是只把撞牆的資料層搬到自管 PostgreSQL、認證留在原平台。本文預設的也是這種逐能力遷出 — 遷的是資料層，不一定連認證、儲存一起搬。</p>
<h2 id="6-維-diff-audit主導維度是-paradigm--application-change">6 維 diff audit：主導維度是 paradigm + application change</h2>
<p>遷移前先盤點 source 跟 target 的差異落在哪幾維、決定 playbook 結構：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Firestore → 自建 relational</th>
          <th>程度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>document / collection → 正規 table、SDK query → 後端 API + SQL</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>serverless 全託管 → 自管 / managed 資料庫、自己擔 backup / failover</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Paradigm</td>
          <td>client 直連 + 規則授權 → API 中介 + 後端授權</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Components 數量</td>
          <td>單一平台 → 新增一層自建後端服務 + 資料庫</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>前端拔 SDK 改打 API、realtime / offline 要重建</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>平台複製 → 自己設計 replica / 多 region / DR</td>
          <td>Medium</td>
      </tr>
  </tbody>
</table>
<p>主導維度是 <strong>paradigm 與 application change</strong>：六維裡五維落在 High。這定義了結構 — <strong>Type E paradigm shift</strong>（排除 schema 翻譯 Type A 和 drop-in Type B）：存取模型反轉、部分能力重建、可能長期混合（資料層自建、認證仍留平台）。</p>
<h2 id="為什麼字面遷移不成立存取模型反轉">為什麼字面遷移不成立：存取模型反轉</h2>
<p>Firestore 的存取模型是 <em>前端即客戶端、資料庫直接面向公網、授權在規則層</em>；自建 relational 是 <em>前端打後端、後端面向資料庫、授權在服務層</em>。這個反轉是遷移的核心難點，不在資料搬運。</p>
<p><strong>反正規化 document → 正規 schema</strong>：</p>
<ul>
<li>Firestore 為了繞開查詢限制，常把關聯資料冗餘寫進同一 document（一份資料複製多處）</li>
<li>遷往 relational 要把冗餘拆回正規化 table、重建外鍵關係，這是逆向工程：要先讀懂當初為什麼這樣存</li>
<li>反過來說，有些 document 的巢狀結構在 relational 用 JSONB 保留更省事（見 <a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">PostgreSQL jsonb</a>）— 不是所有 document 都要拆成 table</li>
</ul>
<p><strong>Security Rules 授權 → 後端授權</strong>：</p>
<ul>
<li>Firestore 的授權邏輯散在 Security Rules DSL 裡，遷移要把每一條規則翻譯成後端 API 的權限檢查</li>
<li>這層翻譯是安全敏感的：漏一條規則等於開一個越權查詢的洞，對應 <a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a></li>
</ul>
<p><strong>SDK 直連 → API 中介</strong>：</p>
<ul>
<li>前端原本用 Firestore SDK 直接讀寫，遷移後要拔掉 SDK、改打自建 API</li>
<li>這是 application 層的大改，不是資料庫換連線字串</li>
</ul>
<p><strong>realtime listener / offline persistence → 自己重建</strong>：</p>
<ul>
<li>snapshot listener 的即時推送、offline 讀寫快取，是平台白送的能力</li>
<li>自建要用 WebSocket / SSE 重建即時層（見 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a> 與 presence 設計）、用前端本地儲存重建 offline — 這是遷移最容易被漏估的工作量</li>
</ul>
<p>所以遷移的第一步不是匯資料，是<strong>盤點 application 對 Firestore 的所有依賴面</strong>：查詢路徑、授權規則、realtime 訂閱、offline 行為。這份清單決定哪些能直接遷、哪些要重建、哪些先留在平台。</p>
<h2 id="哪些該遷哪些先留逐能力混合">哪些該遷、哪些先留（逐能力混合）</h2>
<p>Type E 的本質是不收斂 — 不必把所有 Firebase 能力一次搬完。判讀標準：</p>
<table>
  <thead>
      <tr>
          <th>Workload / 能力特徵</th>
          <th>去向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>需要報表 / JOIN / aggregation 的資料</td>
          <td>遷自建 relational</td>
      </tr>
      <tr>
          <td>讀取量大、成本敏感、access pattern 穩定的資料</td>
          <td>遷自建 + <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">應用層快取</a></td>
      </tr>
      <tr>
          <td>仍以 realtime 同步為核心、查詢簡單的資料</td>
          <td>先留 Firestore / 或最後再遷</td>
      </tr>
      <tr>
          <td>認證（Firebase Auth）</td>
          <td>可留平台、逐能力決定（見 0.22）</td>
      </tr>
      <tr>
          <td>檔案儲存（Firebase Storage）</td>
          <td>可留平台、與資料層解耦後再評估</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22 的成長期 SaaS</a> 是這個判讀的 case anchor：撞牆的是資料層的 query 複雜度與成本，遷的就是資料層，認證留在原地。混合不是過渡失敗，是逐能力選型的穩態。</p>
<h2 id="phase-plan存取模型反轉的階段化">Phase plan：存取模型反轉的階段化</h2>
<p>paradigm shift 的階段化把不可逆動作放到最後、每階段有獨立驗證門檻：</p>
<h4 id="phase-1依賴面盤點">Phase 1：依賴面盤點</h4>
<p>列出 application 對 Firestore 的所有讀寫路徑、Security Rules 授權條件、realtime 訂閱點、offline 行為。標每項的頻率、安全敏感度、是否可重建。這份清單不完整不進下一階段。</p>
<h4 id="phase-2relational-重建模">Phase 2：relational 重建模</h4>
<p>把反正規化 document 設計回正規 schema、決定哪些巢狀結構用 JSONB 保留。同步設計後端 API 的端點與授權檢查、把 Security Rules 逐條翻譯成服務層權限。對應 <a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">1.2 schema design</a> 與 <a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a>。</p>
<h4 id="phase-3自建後端--dual-write">Phase 3：自建後端 + dual-write</h4>
<p>立起自建後端 API 與資料庫，前端關鍵寫入路徑同時寫 Firestore 與新後端。Firestore 仍是 source of truth、新庫累積資料。dual-write 要處理一邊失敗的補償（對應 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 Reconciliation</a>）。</p>
<h4 id="phase-4backfill-歷史資料">Phase 4：backfill 歷史資料</h4>
<p>把 Firestore 既有 document 按新 schema 轉換寫入新庫。backfill 與 dual-write 並行時要處理覆蓋順序，backfill 不能蓋掉 dual-write 的新值。轉換過程記 checksum / row count 對照。</p>
<h4 id="phase-5shadow-read-驗證">Phase 5：shadow read 驗證</h4>
<p>讀路徑同時打 Firestore 與新後端、比對結果、記錄差異但仍以 Firestore 回應用戶。差異率降到可接受才進 cutover。對應 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 Schema Migration Rollout 證據</a> 的 evidence 方法。</p>
<h4 id="phase-6漸進-cutover--重建即時層">Phase 6：漸進 cutover + 重建即時層</h4>
<p>前端逐步把讀寫從 Firestore SDK 切到自建 API（按比例 / 按功能模組），保留切回能力。若產品需要 realtime，這階段要把 snapshot listener 換成自建即時層（WebSocket / SSE）並驗證延遲與斷線重連。cutover 完成後資料層的 source of truth 轉到自建；未遷的能力（認證、儲存）仍在平台 — 混合架構成立。</p>
<h2 id="evidence每階段的前進依據">Evidence：每階段的前進依據</h2>
<p>每個階段用資料證明可前進、不靠感覺：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>Evidence</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>dual-write</td>
          <td>雙寫成功率、寫入失敗補償紀錄、兩邊 document / row 數差異</td>
      </tr>
      <tr>
          <td>backfill</td>
          <td>已轉換比例、轉換錯誤數、checksum 對照、反正規化還原正確性抽查</td>
      </tr>
      <tr>
          <td>shadow read</td>
          <td>新舊結果差異率、差異分類（建模差異 vs 真錯誤）、授權翻譯漏洞掃描</td>
      </tr>
      <tr>
          <td>cutover</td>
          <td>切流比例、新 API latency p99、error rate、realtime 推送延遲、rollback 是否觸發</td>
      </tr>
  </tbody>
</table>
<p>這些 evidence 對齊 <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>（Source / Time range / Query link / Owner / Data quality）與 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a>。授權翻譯這項要特別當成 gate 條件 — 它是安全邊界、不只是功能正確性。</p>
<h2 id="cutover-與-rollback-決策">Cutover 與 rollback 決策</h2>
<p>資料庫切流失敗代價高、加上這裡牽涉授權正確性，決策權責要寫清楚：</p>
<ul>
<li><strong>cutover window</strong>：選低流量時段、明確切流比例階梯（如 1% → 10% → 50% → 100%），按功能模組切比按全站切安全</li>
<li><strong>rollback condition</strong>：新 API error rate / latency 超閾值、shadow read 差異率異常、或發現授權翻譯漏洞 → 切回 Firestore</li>
<li><strong>decision owner</strong>：誰有權喊停、依據什麼 evidence、記錄在 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 incident decision log</a></li>
<li><strong>realtime 連續性</strong>：若即時層同步切換，要驗證切換期間訂閱不中斷、或明確告知短暫降級</li>
</ul>
<p>對應 <a href="/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">rollback window</a>、<a href="/blog/backend/knowledge-cards/rollback-condition/" data-link-title="Rollback Condition" data-link-desc="說明決策執行後出現哪些訊號時要撤回、回退或改路線">rollback condition</a>。</p>
<h2 id="cleanup-與長期混合">Cleanup 與長期混合</h2>
<p>Type E 的 cleanup 通常不是「關掉整個 Firebase」— 多數情況認證、儲存仍留平台：</p>
<ul>
<li>已遷資料路徑的 Firestore collection、Security Rules、dual-write code path 退役</li>
<li>shadow read 比對 code 移除</li>
<li>前端殘留的 Firestore SDK 依賴清掉（資料層已不走它）</li>
<li>但 Firebase Auth / Storage 若仍在用，保留；明確標示哪條資料路徑的 source of truth 是自建庫、哪條仍在平台</li>
<li>Firestore 的資料匯出備份保留到確認新庫穩定，對應 <a href="/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">10.3</a> 的並行期退役判準</li>
</ul>
<p>混合架構不是遷移失敗、是逐能力選型的穩態 — 撞牆的資料層自建、沒撞牆的認證 / 儲存留在平台。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的 5 個踩雷：</p>
<h4 id="case-1只匯資料漏了存取模型反轉">Case 1：只匯資料、漏了存取模型反轉</h4>
<p>把 Firestore 匯出匯進 PostgreSQL 就以為遷完、忘了前端還在打 SDK、授權還在 Security Rules。修法：依賴面盤點是 Phase 1、資料搬運只是其中一條線，存取模型反轉才是主體。</p>
<h4 id="case-2security-rules-翻譯漏洞">Case 2：Security Rules 翻譯漏洞</h4>
<p>把規則翻成後端授權時漏一條、開了越權查詢的洞、上線後資料外洩。修法：授權翻譯要逐條對照 + 紅隊驗證（<a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5</a>）、當成 cutover gate 條件、不是功能 bug。</p>
<h4 id="case-3反正規化還原錯誤">Case 3：反正規化還原錯誤</h4>
<p>document 的冗餘副本拆回 table 時還原錯關係、新庫資料關聯接錯。修法：Phase 2 先讀懂當初為何反正規化、backfill 後抽查還原正確性、shadow read 比對抓出建模差異。</p>
<h4 id="case-4低估-realtime--offline-重建工作量">Case 4：低估 realtime / offline 重建工作量</h4>
<p>以為遷資料庫就好、上線才發現 snapshot listener 與 offline 同步整層要自己重建、進度爆炸。修法：依賴面盤點就把 realtime 訂閱點與 offline 行為標出來、列入工作量、必要時這層最後遷或先保留。</p>
<h4 id="case-5dual-write-一邊失敗沒補償">Case 5：dual-write 一邊失敗沒補償</h4>
<p>dual-write 時新庫寫成功 Firestore 失敗（或反之）、兩邊分歧、cutover 後資料不完整。修法：dual-write 要有失敗補償（記錄、重試、標記人工對帳），對應 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 Reconciliation</a>。</p>
<p><strong>Anti-recommendation</strong>：產品仍重度依賴 realtime / offline、或團隊還沒有自建後端與資料庫的營運能力（backup、failover、授權設計）→ 先不要遷。可先把一塊撞牆最明顯、realtime 需求最低的資料（例如報表來源資料）試點、累積自建營運經驗再擴大。</p>
<h2 id="容量與成本crossover-判讀">容量與成本：crossover 判讀</h2>
<p>遷移的成本判讀關鍵是 <em>遷移後的總帳</em>、不是只看 Firestore 帳單：</p>
<ul>
<li><strong>遷移當下</strong>：高 read 流量下，自管資料庫 + 應用層快取的單位成本常低於 Firestore 的 per-read 計費</li>
<li><strong>但要加回自建的隱性成本</strong>：後端服務的開發與維運、資料庫的 backup / failover / 擴容、realtime 層的重建與維護、團隊人力</li>
<li><strong>判讀分層</strong>：撞到成本牆且已有後端團隊 → 自建總帳通常划算；仍是小團隊、realtime 是核心、流量不大 → Firestore 的「平台白送能力」可能仍比自建總帳便宜</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：crossover 隨流量形狀、region pricing、團隊成本結構變動、無通用閾值。遷移省下的 Firestore 帳單要扣掉自建後端 + 資料庫 + 即時層的維運成本後再比，不是直接拿兩邊資料庫帳單對照。</p></blockquote>
<p>接回 <a href="/blog/backend/00-service-selection/cost-risk-tradeoffs/" data-link-title="0.6 成本、風險與選型取捨" data-link-desc="用人力成本、雲端成本、操作成本與失敗代價判斷後端能力投入順序">0.6 成本、風險與選型取捨</a>、<a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="跟其他遷移路徑的關係">跟其他遷移路徑的關係</h3>
<ul>
<li><strong>保留 document model</strong>：若只是要逃離 Firestore 的查詢限制、但 document 形狀仍適合，遷 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB</a> 比遷 relational 的 paradigm 跨度小、不必反正規化還原</li>
<li><strong>整包託管遷出</strong>：若連認證、儲存一起搬離 Firebase，整場資產線盤點與並行期走 <a href="/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">10.3 託管形態遷出</a>、本文是其中資料層那一條</li>
<li><strong>反向視角</strong>：哪些資料當初就不該進 Firestore（報表來源、強一致交易），見 <a href="/blog/backend/01-database/vendors/firestore/#%e4%b8%8d%e9%81%a9%e7%94%a8%e5%a0%b4%e6%99%af" 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></li>
</ul>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<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> — 服務定位與查詢邊界</li>
<li><a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同 DB 內 schema 演進與資料變更的可分段驗證流程、跟 1.12 cross-DB migration 分工">1.6 資料庫轉換實作</a> — 通用 dual-write / shadow read / cutover 框架</li>
<li><a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a> — Security Rules 授權翻譯的安全驗證</li>
<li><a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 Reconciliation 與 Data Repair</a> — dual-write 失敗補償與資料對帳</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/migrate-rds-mongodb-to-dynamodb/" data-link-title="從 RDS / MongoDB 遷移到 DynamoDB：access-pattern-first 重建模、混合架構與 cost crossover" data-link-desc="RDS / MongoDB → DynamoDB 不是搬 schema 而是換 paradigm；本文走 Type E paradigm shift 結構，展開為何字面遷移不成立、access pattern 重建模、哪些 workload 該遷哪些該留的混合架構、dual-write &#43; shadow read 階段化，以及 Zomato cost crossover 的長期成本判讀">從 RDS / MongoDB 遷往 DynamoDB</a> — 同為 Type E paradigm shift 的對照（方向相反：遷入 NoSQL vs 遷出 BaaS）</li>
<li><a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21 交付形態選型</a> / <a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22 能力級買 vs 建</a> — 遷移 driver 的選型層背景</li>
</ul>
]]></content:encoded></item><item><title>Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/</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/security/get-started">官方 Security Rules 文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境授權沒有後端可以藏">問題情境：授權沒有後端可以藏&lt;/h2>
&lt;p>自建後端的授權有一個天然的藏身處：所有讀寫都過 API，權限檢查寫在 service 層，前端拿不到的資料就是拿不到。Firestore 的 client 直連模型把這個藏身處拿掉了——前端 SDK 直接連資料庫，唯一擋在「任何人都能讀整個 collection」與「正確授權」之間的，就是 Security Rules。規則寫錯一條，等於把資料庫對公網敞開。&lt;/p>
&lt;p>這個責任轉移最常見的引爆點是上線後的滲透測試或 bug bounty：報告指出「未登入就能用 REST API 拉出整張 &lt;code>users&lt;/code> collection」。根因幾乎都是同一類——開發期為了方便把規則設成 &lt;code>allow read, write: if true&lt;/code>，上線忘了收。Firestore 的規則是控制面的全部，這篇處理它的求值模型、如何把它寫成可測試的程式碼、以及它撐不住時的退場路線。&lt;/p>
&lt;h2 id="核心概念規則的求值模型">核心概念：規則的求值模型&lt;/h2>
&lt;p>Firestore Security Rules 是一套宣告式 DSL，掛在 &lt;code>match&lt;/code> path 上、對每個讀寫請求求值。理解它要抓住四個跟後端授權不同的點：&lt;/p>
&lt;p>&lt;strong>規則不是 filter，是 allow/deny 判定&lt;/strong>。一條 &lt;code>allow read: if &amp;lt;condition&amp;gt;&lt;/code> 不會「只回傳符合條件的 document」——它是對「這次請求能不能執行」的布林判定。query 若可能讀到任何不符合規則的 document，整個 query 被拒絕，不是默默過濾。這逼著 client 的 query 必須自帶與規則一致的條件（例如 &lt;code>where('ownerId', '==', uid)&lt;/code>），規則才放行。&lt;/p>
&lt;p>&lt;strong>規則預設拒絕&lt;/strong>。沒有 &lt;code>match&lt;/code> 命中的 path 一律拒絕。&lt;code>rules_version = '2'&lt;/code> 下，&lt;code>match /{document=**}&lt;/code> 遞迴匹配所有 subcollection，要小心別用一條寬鬆的遞迴規則蓋掉底下該嚴格的 path。&lt;/p>
&lt;p>&lt;strong>請求脈絡來自 &lt;code>request&lt;/code> 與 &lt;code>resource&lt;/code>&lt;/strong>。&lt;code>request.auth&lt;/code> 是已驗證的身分（&lt;code>request.auth.uid&lt;/code>、&lt;code>request.auth.token&lt;/code> 的 custom claims）；&lt;code>request.resource.data&lt;/code> 是寫入後的 document 狀態；&lt;code>resource.data&lt;/code> 是寫入前的既有狀態。授權與資料驗證都在這幾個物件上展開。&lt;/p>
&lt;p>&lt;strong>跨 document 查詢用 &lt;code>get()&lt;/code> / &lt;code>exists()&lt;/code>&lt;/strong>。判斷「這個 user 是不是這個 project 的成員」要去讀另一份 document，用 &lt;code>get(/databases/$(database)/documents/projects/$(pid)/members/$(uid))&lt;/code>。每個 &lt;code>get()&lt;/code> 是一次額外讀取、計入計費，也有每請求次數上限（規則內 document access 有上限，設計時要省著用）。&lt;/p>
&lt;p>基本骨架：&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="nx">rules_version&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;2&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 class="nx">service&lt;/span> &lt;span class="nx">cloud&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firestore&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">databases&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">database&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">documents&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">notes&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">noteId&lt;/span>&lt;span class="p">}&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 class="nx">allow&lt;/span> &lt;span class="nx">read&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ownerId&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">create&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ownerId&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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">allow&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">delete&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ownerId&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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="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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>read&lt;/code> 用 &lt;code>resource.data&lt;/code>（既有 document），&lt;code>create&lt;/code> 用 &lt;code>request.resource.data&lt;/code>（沒有既有狀態），&lt;code>update&lt;/code> 兩者都要看——把 &lt;code>read&lt;/code> / &lt;code>create&lt;/code> / &lt;code>update&lt;/code> / &lt;code>delete&lt;/code> 分開是建模的起點，混成一條 &lt;code>allow read, write&lt;/code> 是後面所有漏洞的源頭。&lt;/p>
&lt;h2 id="配置把授權拆成可組合-function">配置：把授權拆成可組合 function&lt;/h2>
&lt;p>規則一旦超過幾個 collection，inline 的 &lt;code>if&lt;/code> 條件會重複且難讀。把授權判斷抽成 &lt;code>function&lt;/code>，讓每條規則讀起來像在描述意圖，是讓規則可維護的核心手段：&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="nx">rules_version&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;2&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 class="nx">service&lt;/span> &lt;span class="nx">cloud&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firestore&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">databases&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">database&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">documents&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">isSignedIn&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">isOwner&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">docData&lt;/span>&lt;span class="p">)&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="k">return&lt;/span> &lt;span class="nx">isSignedIn&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">docData&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ownerId&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">isSignedIn&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="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">exists&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sr">/databases/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">database&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">documents&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">projects&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">members&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">hasRole&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">role&lt;/span>&lt;span class="p">)&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">return&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&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="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sr">/databases/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">database&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">documents&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">projects&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">members&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">role&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">role&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 寫入時欄位白名單：禁止 client 竄改 ownerId / createdAt
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">fieldsUnchanged&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fields&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">diff&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">affectedKeys&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">hasOnly&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fields&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">projects&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">read&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">hasRole&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;admin&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">fieldsUnchanged&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;description&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;updatedAt&amp;#39;&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="k">delete&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">hasRole&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;owner&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">tasks&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">taskId&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">read&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">create&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">createdBy&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">delete&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">42&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這裡有三個建模手段值得展開。第一，&lt;code>isProjectMember&lt;/code> / &lt;code>hasRole&lt;/code> 把「成員資格」與「角色」的判斷集中成單一定義，授權邏輯改一處全站生效，避免同一條規則散落在十個 collection。第二，&lt;code>fieldsUnchanged&lt;/code> 用 &lt;code>diff().affectedKeys().hasOnly()&lt;/code> 把「這次 update 只准動哪些欄位」寫成白名單——這擋掉 client 直接改 &lt;code>ownerId&lt;/code> 把別人的資料佔為己有的攻擊，是 client 直連模型必備的欄位級防護。第三，custom claims（&lt;code>request.auth.token.role&lt;/code>）適合放跨專案、低頻變動的全域角色；per-resource 的成員資格用 &lt;code>get()&lt;/code> 查 membership document，因為 claims 改動要等 token 刷新、不適合表達即時變動的權限。&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/security/get-started">官方 Security Rules 文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="問題情境授權沒有後端可以藏">問題情境：授權沒有後端可以藏</h2>
<p>自建後端的授權有一個天然的藏身處：所有讀寫都過 API，權限檢查寫在 service 層，前端拿不到的資料就是拿不到。Firestore 的 client 直連模型把這個藏身處拿掉了——前端 SDK 直接連資料庫，唯一擋在「任何人都能讀整個 collection」與「正確授權」之間的，就是 Security Rules。規則寫錯一條，等於把資料庫對公網敞開。</p>
<p>這個責任轉移最常見的引爆點是上線後的滲透測試或 bug bounty：報告指出「未登入就能用 REST API 拉出整張 <code>users</code> collection」。根因幾乎都是同一類——開發期為了方便把規則設成 <code>allow read, write: if true</code>，上線忘了收。Firestore 的規則是控制面的全部，這篇處理它的求值模型、如何把它寫成可測試的程式碼、以及它撐不住時的退場路線。</p>
<h2 id="核心概念規則的求值模型">核心概念：規則的求值模型</h2>
<p>Firestore Security Rules 是一套宣告式 DSL，掛在 <code>match</code> path 上、對每個讀寫請求求值。理解它要抓住四個跟後端授權不同的點：</p>
<p><strong>規則不是 filter，是 allow/deny 判定</strong>。一條 <code>allow read: if &lt;condition&gt;</code> 不會「只回傳符合條件的 document」——它是對「這次請求能不能執行」的布林判定。query 若可能讀到任何不符合規則的 document，整個 query 被拒絕，不是默默過濾。這逼著 client 的 query 必須自帶與規則一致的條件（例如 <code>where('ownerId', '==', uid)</code>），規則才放行。</p>
<p><strong>規則預設拒絕</strong>。沒有 <code>match</code> 命中的 path 一律拒絕。<code>rules_version = '2'</code> 下，<code>match /{document=**}</code> 遞迴匹配所有 subcollection，要小心別用一條寬鬆的遞迴規則蓋掉底下該嚴格的 path。</p>
<p><strong>請求脈絡來自 <code>request</code> 與 <code>resource</code></strong>。<code>request.auth</code> 是已驗證的身分（<code>request.auth.uid</code>、<code>request.auth.token</code> 的 custom claims）；<code>request.resource.data</code> 是寫入後的 document 狀態；<code>resource.data</code> 是寫入前的既有狀態。授權與資料驗證都在這幾個物件上展開。</p>
<p><strong>跨 document 查詢用 <code>get()</code> / <code>exists()</code></strong>。判斷「這個 user 是不是這個 project 的成員」要去讀另一份 document，用 <code>get(/databases/$(database)/documents/projects/$(pid)/members/$(uid))</code>。每個 <code>get()</code> 是一次額外讀取、計入計費，也有每請求次數上限（規則內 document access 有上限，設計時要省著用）。</p>
<p>基本骨架：</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="nx">rules_version</span> <span class="o">=</span> <span class="s1">&#39;2&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">service</span> <span class="nx">cloud</span><span class="p">.</span><span class="nx">firestore</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">match</span> <span class="o">/</span><span class="nx">databases</span><span class="o">/</span><span class="p">{</span><span class="nx">database</span><span class="p">}</span><span class="o">/</span><span class="nx">documents</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">match</span> <span class="o">/</span><span class="nx">notes</span><span class="o">/</span><span class="p">{</span><span class="nx">noteId</span><span class="p">}</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nx">allow</span> <span class="nx">read</span><span class="o">:</span> <span class="k">if</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span> <span class="o">!=</span> <span class="kc">null</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                  <span class="o">&amp;&amp;</span> <span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">ownerId</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nx">allow</span> <span class="nx">create</span><span class="o">:</span> <span class="k">if</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span> <span class="o">!=</span> <span class="kc">null</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                    <span class="o">&amp;&amp;</span> <span class="nx">request</span><span class="p">.</span><span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">ownerId</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="nx">allow</span> <span class="nx">update</span><span class="p">,</span> <span class="k">delete</span><span class="o">:</span> <span class="k">if</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span> <span class="o">!=</span> <span class="kc">null</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                            <span class="o">&amp;&amp;</span> <span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">ownerId</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <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 class="p">}</span></span></span></code></pre></div><p><code>read</code> 用 <code>resource.data</code>（既有 document），<code>create</code> 用 <code>request.resource.data</code>（沒有既有狀態），<code>update</code> 兩者都要看——把 <code>read</code> / <code>create</code> / <code>update</code> / <code>delete</code> 分開是建模的起點，混成一條 <code>allow read, write</code> 是後面所有漏洞的源頭。</p>
<h2 id="配置把授權拆成可組合-function">配置：把授權拆成可組合 function</h2>
<p>規則一旦超過幾個 collection，inline 的 <code>if</code> 條件會重複且難讀。把授權判斷抽成 <code>function</code>，讓每條規則讀起來像在描述意圖，是讓規則可維護的核心手段：</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="nx">rules_version</span> <span class="o">=</span> <span class="s1">&#39;2&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">service</span> <span class="nx">cloud</span><span class="p">.</span><span class="nx">firestore</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">match</span> <span class="o">/</span><span class="nx">databases</span><span class="o">/</span><span class="p">{</span><span class="nx">database</span><span class="p">}</span><span class="o">/</span><span class="nx">documents</span> <span class="p">{</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="kd">function</span> <span class="nx">isSignedIn</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="k">return</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</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="kd">function</span> <span class="nx">isOwner</span><span class="p">(</span><span class="nx">docData</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="k">return</span> <span class="nx">isSignedIn</span><span class="p">()</span> <span class="o">&amp;&amp;</span> <span class="nx">docData</span><span class="p">.</span><span class="nx">ownerId</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="kd">function</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="k">return</span> <span class="nx">isSignedIn</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="o">&amp;&amp;</span> <span class="nx">exists</span><span class="p">(</span><span class="sr">/databases/</span><span class="nx">$</span><span class="p">(</span><span class="nx">database</span><span class="p">)</span><span class="o">/</span><span class="nx">documents</span><span class="o">/</span><span class="nx">projects</span><span class="o">/</span><span class="nx">$</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span><span class="o">/</span><span class="nx">members</span><span class="o">/</span><span class="nx">$</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="kd">function</span> <span class="nx">hasRole</span><span class="p">(</span><span class="nx">projectId</span><span class="p">,</span> <span class="nx">role</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="k">return</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="o">&amp;&amp;</span> <span class="nx">get</span><span class="p">(</span><span class="sr">/databases/</span><span class="nx">$</span><span class="p">(</span><span class="nx">database</span><span class="p">)</span><span class="o">/</span><span class="nx">documents</span><span class="o">/</span><span class="nx">projects</span><span class="o">/</span><span class="nx">$</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span><span class="o">/</span><span class="nx">members</span><span class="o">/</span><span class="nx">$</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">)).</span><span class="nx">data</span><span class="p">.</span><span class="nx">role</span> <span class="o">==</span> <span class="nx">role</span><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></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="c1">// 寫入時欄位白名單：禁止 client 竄改 ownerId / createdAt
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"></span>    <span class="kd">function</span> <span class="nx">fieldsUnchanged</span><span class="p">(</span><span class="nx">fields</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">      <span class="k">return</span> <span class="nx">request</span><span class="p">.</span><span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">diff</span><span class="p">(</span><span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">).</span><span class="nx">affectedKeys</span><span class="p">().</span><span class="nx">hasOnly</span><span class="p">(</span><span class="nx">fields</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="nx">match</span> <span class="o">/</span><span class="nx">projects</span><span class="o">/</span><span class="p">{</span><span class="nx">projectId</span><span class="p">}</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">      <span class="nx">allow</span> <span class="nx">read</span><span class="o">:</span> <span class="k">if</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">      <span class="nx">allow</span> <span class="nx">update</span><span class="o">:</span> <span class="k">if</span> <span class="nx">hasRole</span><span class="p">(</span><span class="nx">projectId</span><span class="p">,</span> <span class="s1">&#39;admin&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">                    <span class="o">&amp;&amp;</span> <span class="nx">fieldsUnchanged</span><span class="p">([</span><span class="s1">&#39;name&#39;</span><span class="p">,</span> <span class="s1">&#39;description&#39;</span><span class="p">,</span> <span class="s1">&#39;updatedAt&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">      <span class="nx">allow</span> <span class="k">delete</span><span class="o">:</span> <span class="k">if</span> <span class="nx">hasRole</span><span class="p">(</span><span class="nx">projectId</span><span class="p">,</span> <span class="s1">&#39;owner&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">
</span></span><span class="line"><span class="ln">34</span><span class="cl">      <span class="nx">match</span> <span class="o">/</span><span class="nx">tasks</span><span class="o">/</span><span class="p">{</span><span class="nx">taskId</span><span class="p">}</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="nx">allow</span> <span class="nx">read</span><span class="o">:</span> <span class="k">if</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="nx">allow</span> <span class="nx">create</span><span class="o">:</span> <span class="k">if</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">                      <span class="o">&amp;&amp;</span> <span class="nx">request</span><span class="p">.</span><span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">createdBy</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="nx">allow</span> <span class="nx">update</span><span class="p">,</span> <span class="k">delete</span><span class="o">:</span> <span class="k">if</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡有三個建模手段值得展開。第一，<code>isProjectMember</code> / <code>hasRole</code> 把「成員資格」與「角色」的判斷集中成單一定義，授權邏輯改一處全站生效，避免同一條規則散落在十個 collection。第二，<code>fieldsUnchanged</code> 用 <code>diff().affectedKeys().hasOnly()</code> 把「這次 update 只准動哪些欄位」寫成白名單——這擋掉 client 直接改 <code>ownerId</code> 把別人的資料佔為己有的攻擊，是 client 直連模型必備的欄位級防護。第三，custom claims（<code>request.auth.token.role</code>）適合放跨專案、低頻變動的全域角色；per-resource 的成員資格用 <code>get()</code> 查 membership document，因為 claims 改動要等 token 刷新、不適合表達即時變動的權限。</p>
<h2 id="配置用-emulator-把規則寫成單元測試">配置：用 emulator 把規則寫成單元測試</h2>
<p>規則是安全邊界，改一條就要驗證沒開新洞——這要求規則像程式碼一樣有測試。Firebase Emulator + <code>@firebase/rules-unit-testing</code> 讓規則在本地用真實求值引擎跑斷言，不必碰雲端：</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="c1">// rules.test.js — 用 Jest / Mocha 跑
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">initializeTestEnvironment</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">assertFails</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">assertSucceeds</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;@firebase/rules-unit-testing&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kr">const</span> <span class="p">{</span> <span class="nx">setDoc</span><span class="p">,</span> <span class="nx">getDoc</span><span class="p">,</span> <span class="nx">doc</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;firebase/firestore&#39;</span><span class="p">);</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="kd">let</span> <span class="nx">testEnv</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nx">beforeAll</span><span class="p">(</span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">testEnv</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">initializeTestEnvironment</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">projectId</span><span class="o">:</span> <span class="s1">&#39;demo-notes&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">firestore</span><span class="o">:</span> <span class="p">{</span> <span class="nx">rules</span><span class="o">:</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;fs&#39;</span><span class="p">).</span><span class="nx">readFileSync</span><span class="p">(</span><span class="s1">&#39;firestore.rules&#39;</span><span class="p">,</span> <span class="s1">&#39;utf8&#39;</span><span class="p">)</span> <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><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="nx">afterAll</span><span class="p">(</span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">cleanup</span><span class="p">();</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nx">beforeEach</span><span class="p">(</span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">clearFirestore</span><span class="p">();</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;owner 能讀自己的 note&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="c1">// 用 admin context 預先種一筆資料、繞過規則
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="c1"></span>  <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">withSecurityRulesDisabled</span><span class="p">(</span><span class="kr">async</span> <span class="p">(</span><span class="nx">ctx</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="kr">await</span> <span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">firestore</span><span class="p">(),</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">),</span> <span class="p">{</span> <span class="nx">ownerId</span><span class="o">:</span> <span class="s1">&#39;alice&#39;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">  <span class="kr">const</span> <span class="nx">alice</span> <span class="o">=</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">authenticatedContext</span><span class="p">(</span><span class="s1">&#39;alice&#39;</span><span class="p">).</span><span class="nx">firestore</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">  <span class="kr">await</span> <span class="nx">assertSucceeds</span><span class="p">(</span><span class="nx">getDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">alice</span><span class="p">,</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;非 owner 不能讀別人的 note&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">  <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">withSecurityRulesDisabled</span><span class="p">(</span><span class="kr">async</span> <span class="p">(</span><span class="nx">ctx</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="kr">await</span> <span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">firestore</span><span class="p">(),</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">),</span> <span class="p">{</span> <span class="nx">ownerId</span><span class="o">:</span> <span class="s1">&#39;alice&#39;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">  <span class="kr">const</span> <span class="nx">bob</span> <span class="o">=</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">authenticatedContext</span><span class="p">(</span><span class="s1">&#39;bob&#39;</span><span class="p">).</span><span class="nx">firestore</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">  <span class="kr">await</span> <span class="nx">assertFails</span><span class="p">(</span><span class="nx">getDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">bob</span><span class="p">,</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;未登入完全擋下&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">  <span class="kr">const</span> <span class="nx">anon</span> <span class="o">=</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">unauthenticatedContext</span><span class="p">().</span><span class="nx">firestore</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">  <span class="kr">await</span> <span class="nx">assertFails</span><span class="p">(</span><span class="nx">getDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">anon</span><span class="p">,</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">
</span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;client 不能竄改 ownerId&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">  <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">withSecurityRulesDisabled</span><span class="p">(</span><span class="kr">async</span> <span class="p">(</span><span class="nx">ctx</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">    <span class="kr">await</span> <span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">firestore</span><span class="p">(),</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">),</span> <span class="p">{</span> <span class="nx">ownerId</span><span class="o">:</span> <span class="s1">&#39;alice&#39;</span><span class="p">,</span> <span class="nx">text</span><span class="o">:</span> <span class="s1">&#39;hi&#39;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">  <span class="kr">const</span> <span class="nx">alice</span> <span class="o">=</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">authenticatedContext</span><span class="p">(</span><span class="s1">&#39;alice&#39;</span><span class="p">).</span><span class="nx">firestore</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">  <span class="kr">await</span> <span class="nx">assertFails</span><span class="p">(</span><span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">alice</span><span class="p">,</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">),</span> <span class="p">{</span> <span class="nx">ownerId</span><span class="o">:</span> <span class="s1">&#39;bob&#39;</span><span class="p">,</span> <span class="nx">text</span><span class="o">:</span> <span class="s1">&#39;hi&#39;</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>啟動方式 <code>firebase emulators:exec --only firestore &quot;npm test&quot;</code>，讓測試在 CI 跑。測試要覆蓋的不只是 happy path——每條規則至少要有「正向放行」「越權拒絕」「未登入拒絕」「欄位竄改拒絕」四類斷言。<code>assertFails</code> 比 <code>assertSucceeds</code> 更重要：它證明的是「該擋的有擋住」，正是滲透測試會打的點。把這套測試接進 release gate，規則變更才有 evidence 可交（對應 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a>）。</p>
<h2 id="故障演練五個把規則寫成漏洞的-production-踩坑">故障演練：五個把規則寫成漏洞的 production 踩坑</h2>
<h4 id="case-1allow-read-write-if-true-上線沒收">Case 1：<code>allow read, write: if true</code> 上線沒收</h4>
<p>開發期為了快，把規則開全放，上線忘改。任何人用公開的 project config（前端 bundle 裡就有）就能 REST 拉整個資料庫。修法：規則預設從 deny 起手，開發期的寬鬆規則進不了 main branch；CI 跑一條 lint 掃 <code>if true</code>，命中即 fail。這是 <a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a> 越權查詢路徑的最便宜目標。</p>
<h4 id="case-2read-沒拆-get-與-list">Case 2：<code>read</code> 沒拆 <code>get</code> 與 <code>list</code></h4>
<p><code>allow read</code> 同時涵蓋讀單一 document（<code>get</code>）與查整個 collection（<code>list</code>）。規則只想開「讀自己那筆」，卻因為沒拆 <code>list</code>，讓 client 能 <code>list</code> 整個 collection 撈別人的資料。修法：對 collection-level query 敏感的 path，把 <code>read</code> 拆成 <code>allow get</code> 與 <code>allow list</code>，<code>list</code> 條件更嚴或直接關閉、改走後端彙整。</p>
<h4 id="case-3信任-requestresourcedata-的內容沒驗證">Case 3：信任 <code>request.resource.data</code> 的內容沒驗證</h4>
<p><code>create</code> 規則只檢查 <code>request.auth != null</code>，沒驗證寫入內容。client 自己塞 <code>role: 'admin'</code> 或 <code>balance: 999999</code> 進 document。修法：寫入規則要驗證關鍵欄位的值與型別（<code>request.resource.data.role == 'member'</code>、<code>request.resource.data.amount is int</code>），敏感欄位（角色、金額、狀態）的權威值不該由 client 寫入、改由 Cloud Function 或後端寫。</p>
<h4 id="case-4遞迴-match-document-蓋掉嚴格規則">Case 4：遞迴 <code>match /{document=**}</code> 蓋掉嚴格規則</h4>
<p>頂層放一條 <code>match /{document=**} { allow read: if isSignedIn(); }</code> 圖方便，結果它遞迴命中所有 subcollection，把底下本來該按成員資格嚴格控管的 <code>members</code> collection 也開成「登入即可讀」。修法：避免寬鬆的遞迴萬用規則；授權顆粒不同的 path 各自寫明確 <code>match</code>。</p>
<h4 id="case-5規則複雜到沒人能-review">Case 5：規則複雜到沒人能 review</h4>
<p>授權邏輯長到幾百行、巢狀 <code>get()</code> 互相依賴，改一條沒人敢保證沒開新洞、也沒有測試。修法：這是規則撐不住的訊號（見下方邊界段）——超過這個複雜度，授權該拉回後端中介層，而不是繼續在 DSL 裡長。</p>
<h2 id="容量與觀測get-計費與規則複雜度上限">容量與觀測：<code>get()</code> 計費與規則複雜度上限</h2>
<p>規則內的每個 <code>get()</code> / <code>exists()</code> 是一次 document 讀取，計入計費，且單次請求的 document access 有數量上限（以 <a href="https://firebase.google.com/docs/firestore/security/rules-conditions">官方限制</a> 為準）。高頻讀取路徑若每次都 <code>get()</code> 查 membership，成本與延遲都會浮現。優化方向有二：把低頻變動的權限（全域角色）放進 custom claims，從 token 直接讀、零額外 document access；把成員資格設計成可由 document path 直接判斷（例如 membership document 的 ID 就是 uid，用 <code>exists()</code> 而非 <code>get()</code> 撈整份）。</p>
<p>觀測上，授權問題不會在規則層留下豐富 log——被拒的請求 client 端收到 <code>permission-denied</code>。要把這類錯誤從 client 回報、或在關鍵寫入路徑改走 Cloud Function 以取得 server 端 audit log，接回 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">7.7 稽核軌跡</a>。規則本身的變更要進版本控制、每次 deploy 留 diff，授權變更才可回溯。</p>
<h2 id="邊界與整合規則撐不住時把授權拉回後端">邊界與整合：規則撐不住時把授權拉回後端</h2>
<p>Security Rules 適合表達「資源的擁有者與成員能做什麼」這類 resource-scoped 授權。它撐不住的訊號很明確：授權依賴跨多個 document 的複雜聚合判斷、需要呼叫外部系統、規則複雜到無法 review、或業務規則頻繁變動到規則 deploy 跟不上。撞到這些訊號時，正確的動作是把該塊授權移出 client 直連路徑，而非把規則寫得更巧：</p>
<ul>
<li><strong>敏感寫入改走 Cloud Function / 後端 API</strong>：金額、狀態機轉換、跨實體一致性的寫入，由 server 端驗證後以 admin 權限寫入，規則對 client 直接關閉這些 path 的寫入</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> playbook 裡「授權控制面失控」這面牆——把授權拉回後端中介層是遷移的 driver 之一</li>
</ul>
<p>判讀的單位仍是逐路徑：簡單的 owner-scoped 資料留在規則 + client 直連，複雜或敏感的部分走後端。不是非此即彼。</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>（服務定位與查詢邊界）</li>
<li>安全驗證：<a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a>（越權查詢與資料外洩路徑）</li>
<li>遷移 driver：<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>（授權控制面失控的退場）</li>
<li>發布證據：<a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a>（規則測試接進 gate）</li>
<li>官方：<a href="https://firebase.google.com/docs/firestore/security/get-started">Security Rules get started</a>、<a href="https://firebase.google.com/docs/rules/unit-tests">Rules unit testing</a>、<a href="https://firebase.google.com/docs/firestore/security/rules-conditions">Rules conditions limits</a></li>
</ul>
]]></content:encoded></item><item><title>Firestore 高頻寫入與 distributed counter：單 document contention 邊界與分片計數</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/distributed-counter-high-frequency-write/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/distributed-counter-high-frequency-write/</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/best-practices">官方 best practices&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境一個讚數欄位拖垮整條寫入">問題情境：一個讚數欄位拖垮整條寫入&lt;/h2>
&lt;p>直播平台上線一個「即時按讚數」功能：每個貼文一個 document，按讚就 &lt;code>update&lt;/code> 它的 &lt;code>likes&lt;/code> 欄位 &lt;code>+1&lt;/code>。內測沒問題，上了熱門直播——同一個貼文每秒湧入上千次按讚，寫入開始大量失敗、retry，延遲飆高，連帶其他寫入路徑被拖累。&lt;/p>
&lt;p>根因是流量全壓在&lt;strong>單一 document&lt;/strong> 上，而非流量總量超過 Firestore。Firestore 對單一 document 的持續寫入有軟上限（官方長期建議維持在每秒個位數量級、以當前文件為準），因為每次寫入要更新該 document 的所有索引、且並行寫同一 document 會觸發 contention 重試。把高頻變動的值塞進一個 document，等於替自己造一個寫入熱點。這篇處理 contention 的成因、用 distributed counter 把熱點打散的實作，以及這個手段的能力邊界。&lt;/p>
&lt;h2 id="核心概念寫入-contention-從哪來">核心概念：寫入 contention 從哪來&lt;/h2>
&lt;p>Firestore 的寫入成本不只是「寫一個值」。理解 contention 要抓三點：&lt;/p>
&lt;p>&lt;strong>每次寫入維護該 document 的所有索引&lt;/strong>。document 上有幾個被索引的欄位，一次寫入就要更新幾份索引條目。索引越多、單次寫入越重，這是寫入吞吐與索引數量綁定的根因。&lt;/p>
&lt;p>&lt;strong>並行寫同一 document 會序列化&lt;/strong>。Firestore 保證單一 document 的寫入一致性，並行的 &lt;code>+1&lt;/code> 不能各寫各的——它們競爭同一份狀態，後到的要重試。&lt;code>transaction&lt;/code> 與 &lt;code>FieldValue.increment()&lt;/code> 都受這個限制：&lt;code>increment&lt;/code> 省掉「讀-改-寫」的來回，但多個 increment 打同一 document 仍在同一個寫入熱點上排隊。&lt;/p>
&lt;p>&lt;strong>熱點是 per-document，不是 per-collection&lt;/strong>。把 1000 個貼文的讚數分在 1000 個 document，每個 document 每秒個位數寫入，完全沒問題；問題只在「單一 document 每秒上千寫入」。所以解法的方向是&lt;strong>把一個邏輯計數拆成多個物理 document&lt;/strong>。&lt;/p>
&lt;h2 id="配置distributed-counter-分片計數">配置：distributed counter 分片計數&lt;/h2>
&lt;p>distributed counter 的核心是把「一個計數」拆成 N 個 shard document，寫入時隨機挑一個 shard &lt;code>+1&lt;/code>，讀取時把所有 shard 加總。寫入壓力被分散到 N 個 document，每個 shard 的寫入頻率降為原本的 1/N。&lt;/p>
&lt;p>資料結構：在計數目標下建一個 &lt;code>shards&lt;/code> subcollection，N 個 shard document，每個存一段 partial count。&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="c1">// counter.js（用 Firebase Web SDK v9 modular API）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">doc&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">runTransaction&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">getDocs&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">writeBatch&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">increment&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 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"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">NUM_SHARDS&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">10&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">// 初始化：建立 N 個 shard、每個 count = 0
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">export&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">createCounter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">counterRef&lt;/span>&lt;span class="p">)&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="kr">const&lt;/span> &lt;span class="nx">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">writeBatch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&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="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">let&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">NUM_SHARDS&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span> &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 class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">doc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">counterRef&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;shards&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">String&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">count&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">0&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &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="kr">await&lt;/span> &lt;span class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">commit&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="c1">// 寫入：隨機挑一個 shard +1（用 increment 省掉 read-modify-write）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">export&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">incrementCounter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">counterRef&lt;/span>&lt;span class="p">)&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="kr">const&lt;/span> &lt;span class="nx">shardId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">Math&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">floor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">Math&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">random&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">NUM_SHARDS&lt;/span>&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="kr">const&lt;/span> &lt;span class="nx">shardRef&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">doc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">counterRef&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;shards&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">String&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shardId&lt;/span>&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="kr">await&lt;/span> &lt;span class="nx">setDoc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shardRef&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">count&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">increment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">},&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">merge&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="c1">// 讀取：加總所有 shard
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">export&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">getCount&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">counterRef&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">snap&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getDocs&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">counterRef&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;shards&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="kd">let&lt;/span> &lt;span class="nx">total&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">snap&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">s&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">total&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">count&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">total&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個設計點要展開。第一，寫入用 &lt;code>increment(1)&lt;/code> 而非 transaction 的讀-改-寫：&lt;code>increment&lt;/code> 是 atomic 的 server-side 操作，省掉一次讀取，且本身就避開了「讀到舊值再寫」的 race。第二，shard 選擇用隨機分佈，讓寫入均勻打散到 N 個 shard——這是分片有效的前提，若選 shard 有偏（例如按 user id hash 但 user 分佈不均），熱點會在某幾個 shard 復現。第三，讀取要讀 N 個 document 加總，這是分片的代價：寫入便宜了，讀取從「讀 1 筆」變成「讀 N 筆」，計費與延遲都乘以 N。&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/best-practices">官方 best practices</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="問題情境一個讚數欄位拖垮整條寫入">問題情境：一個讚數欄位拖垮整條寫入</h2>
<p>直播平台上線一個「即時按讚數」功能：每個貼文一個 document，按讚就 <code>update</code> 它的 <code>likes</code> 欄位 <code>+1</code>。內測沒問題，上了熱門直播——同一個貼文每秒湧入上千次按讚，寫入開始大量失敗、retry，延遲飆高，連帶其他寫入路徑被拖累。</p>
<p>根因是流量全壓在<strong>單一 document</strong> 上，而非流量總量超過 Firestore。Firestore 對單一 document 的持續寫入有軟上限（官方長期建議維持在每秒個位數量級、以當前文件為準），因為每次寫入要更新該 document 的所有索引、且並行寫同一 document 會觸發 contention 重試。把高頻變動的值塞進一個 document，等於替自己造一個寫入熱點。這篇處理 contention 的成因、用 distributed counter 把熱點打散的實作，以及這個手段的能力邊界。</p>
<h2 id="核心概念寫入-contention-從哪來">核心概念：寫入 contention 從哪來</h2>
<p>Firestore 的寫入成本不只是「寫一個值」。理解 contention 要抓三點：</p>
<p><strong>每次寫入維護該 document 的所有索引</strong>。document 上有幾個被索引的欄位，一次寫入就要更新幾份索引條目。索引越多、單次寫入越重，這是寫入吞吐與索引數量綁定的根因。</p>
<p><strong>並行寫同一 document 會序列化</strong>。Firestore 保證單一 document 的寫入一致性，並行的 <code>+1</code> 不能各寫各的——它們競爭同一份狀態，後到的要重試。<code>transaction</code> 與 <code>FieldValue.increment()</code> 都受這個限制：<code>increment</code> 省掉「讀-改-寫」的來回，但多個 increment 打同一 document 仍在同一個寫入熱點上排隊。</p>
<p><strong>熱點是 per-document，不是 per-collection</strong>。把 1000 個貼文的讚數分在 1000 個 document，每個 document 每秒個位數寫入，完全沒問題；問題只在「單一 document 每秒上千寫入」。所以解法的方向是<strong>把一個邏輯計數拆成多個物理 document</strong>。</p>
<h2 id="配置distributed-counter-分片計數">配置：distributed counter 分片計數</h2>
<p>distributed counter 的核心是把「一個計數」拆成 N 個 shard document，寫入時隨機挑一個 shard <code>+1</code>，讀取時把所有 shard 加總。寫入壓力被分散到 N 個 document，每個 shard 的寫入頻率降為原本的 1/N。</p>
<p>資料結構：在計數目標下建一個 <code>shards</code> subcollection，N 個 shard document，每個存一段 partial count。</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="c1">// counter.js（用 Firebase Web SDK v9 modular API）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">import</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">doc</span><span class="p">,</span> <span class="nx">collection</span><span class="p">,</span> <span class="nx">runTransaction</span><span class="p">,</span> <span class="nx">getDocs</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">writeBatch</span><span class="p">,</span> <span class="nx">increment</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><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"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kr">const</span> <span class="nx">NUM_SHARDS</span> <span class="o">=</span> <span class="mi">10</span><span class="p">;</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">// 初始化：建立 N 個 shard、每個 count = 0
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="kr">export</span> <span class="kr">async</span> <span class="kd">function</span> <span class="nx">createCounter</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="nx">counterRef</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kr">const</span> <span class="nx">batch</span> <span class="o">=</span> <span class="nx">writeBatch</span><span class="p">(</span><span class="nx">db</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">NUM_SHARDS</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">batch</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">counterRef</span><span class="p">,</span> <span class="s1">&#39;shards&#39;</span><span class="p">,</span> <span class="nb">String</span><span class="p">(</span><span class="nx">i</span><span class="p">)),</span> <span class="p">{</span> <span class="nx">count</span><span class="o">:</span> <span class="mi">0</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="kr">await</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">commit</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1">// 寫入：隨機挑一個 shard +1（用 increment 省掉 read-modify-write）
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="kr">export</span> <span class="kr">async</span> <span class="kd">function</span> <span class="nx">incrementCounter</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="nx">counterRef</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="kr">const</span> <span class="nx">shardId</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">floor</span><span class="p">(</span><span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">()</span> <span class="o">*</span> <span class="nx">NUM_SHARDS</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="kr">const</span> <span class="nx">shardRef</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">(</span><span class="nx">counterRef</span><span class="p">,</span> <span class="s1">&#39;shards&#39;</span><span class="p">,</span> <span class="nb">String</span><span class="p">(</span><span class="nx">shardId</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="kr">await</span> <span class="nx">setDoc</span><span class="p">(</span><span class="nx">shardRef</span><span class="p">,</span> <span class="p">{</span> <span class="nx">count</span><span class="o">:</span> <span class="nx">increment</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="p">},</span> <span class="p">{</span> <span class="nx">merge</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1">// 讀取：加總所有 shard
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="c1"></span><span class="kr">export</span> <span class="kr">async</span> <span class="kd">function</span> <span class="nx">getCount</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="nx">counterRef</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">  <span class="kr">const</span> <span class="nx">snap</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getDocs</span><span class="p">(</span><span class="nx">collection</span><span class="p">(</span><span class="nx">counterRef</span><span class="p">,</span> <span class="s1">&#39;shards&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">  <span class="kd">let</span> <span class="nx">total</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">  <span class="nx">snap</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">total</span> <span class="o">+=</span> <span class="nx">s</span><span class="p">.</span><span class="nx">data</span><span class="p">().</span><span class="nx">count</span><span class="p">;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">  <span class="k">return</span> <span class="nx">total</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>三個設計點要展開。第一，寫入用 <code>increment(1)</code> 而非 transaction 的讀-改-寫：<code>increment</code> 是 atomic 的 server-side 操作，省掉一次讀取，且本身就避開了「讀到舊值再寫」的 race。第二，shard 選擇用隨機分佈，讓寫入均勻打散到 N 個 shard——這是分片有效的前提，若選 shard 有偏（例如按 user id hash 但 user 分佈不均），熱點會在某幾個 shard 復現。第三，讀取要讀 N 個 document 加總，這是分片的代價：寫入便宜了，讀取從「讀 1 筆」變成「讀 N 筆」，計費與延遲都乘以 N。</p>
<p>如果即時讀取頻率也很高（每個觀眾畫面都要顯示即時讚數），讀 N 個 shard 的成本會反過來變成瓶頸。這時把彙總值定期寫回一個 summary document，client 訂閱 summary 而非每次加總：</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="c1">// 由 Cloud Function 定時（或 onWrite 觸發 + debounce）彙總寫回 summary
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">export</span> <span class="kr">async</span> <span class="kd">function</span> <span class="nx">aggregateToSummary</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="nx">counterRef</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">const</span> <span class="nx">total</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getCount</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="nx">counterRef</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kr">await</span> <span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">counterRef</span><span class="p">,</span> <span class="s1">&#39;summary&#39;</span><span class="p">,</span> <span class="s1">&#39;current&#39;</span><span class="p">),</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">count</span><span class="o">:</span> <span class="nx">total</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">updatedAt</span><span class="o">:</span> <span class="nx">serverTimestamp</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這把「即時精確」換成「近即時」：summary 有刷新間隔的延遲，但讀取從 N 筆降回 1 筆。讚數、觀看數這類「差幾個不影響體驗」的計數，這個取捨幾乎總是對的。</p>
<h2 id="故障演練五個高頻寫入踩坑">故障演練：五個高頻寫入踩坑</h2>
<h4 id="case-1直接-increment-單一-document-沒分片">Case 1：直接 <code>increment</code> 單一 document 沒分片</h4>
<p>最常見的起手——以為 <code>FieldValue.increment()</code> 就解決了並行，忽略它仍在單一 document 的寫入熱點上。低流量沒事、熱門事件寫爆。修法：判斷該計數的峰值寫入頻率，超過單 document 軟上限就上 distributed counter；不確定峰值就先分片，分片對低流量無害（只是多讀幾筆）。</p>
<h4 id="case-2shard-數量拍腦袋定太小">Case 2：shard 數量拍腦袋定太小</h4>
<p>設了 3 個 shard，峰值流量下每個 shard 仍每秒上百寫入、照樣 contention。修法：shard 數要對齊峰值寫入頻率除以單 shard 安全寫入率（每秒個位數）。預期峰值每秒 500 寫入、單 shard 安全 5/s，就需要約 100 個 shard。寧可估高。</p>
<h4 id="case-3shard-太多拖垮讀取">Case 3：shard 太多拖垮讀取</h4>
<p>反向錯誤——為了保險設 1000 個 shard，結果每次讀計數要讀 1000 個 document，讀取計費與延遲爆炸。修法：shard 數是寫入分散與讀取成本的取捨；高寫入低讀取用多 shard + 直接加總，高寫入高讀取用多 shard + summary 彙總，別用「讀 N 筆加總」硬扛高頻讀取。</p>
<h4 id="case-4選-shard-有偏導致熱點復現">Case 4：選 shard 有偏導致熱點復現</h4>
<p>用 <code>userId</code> 的 hash 選 shard、但活躍 user 集中在少數，寫入仍打在某幾個 shard 上。修法：shard 選擇要與寫入來源無關的隨機分佈，不要綁任何可能傾斜的 key。</p>
<h4 id="case-5把分片計數當強一致餘額用">Case 5：把分片計數當強一致餘額用</h4>
<p>把 distributed counter 拿來記帳戶餘額、庫存這類需要強一致與精確讀的值。分片計數的讀取是「加總當下各 shard」，並行寫入下讀到的是近似值，不適合做扣款判斷。修法：強一致的計數（餘額、庫存、配額）不該用分片計數，也通常不該用 Firestore 的單欄位累加——這類值要走 transaction 嚴格控制、或放關聯式資料庫用 row lock，見邊界段。</p>
<h2 id="容量與觀測shard-數的估算與監控">容量與觀測：shard 數的估算與監控</h2>
<p>shard 數量的估算從峰值寫入頻率反推：<code>shard 數 ≈ 峰值每秒寫入 / 單 shard 安全寫入率</code>。單 shard 安全寫入率以官方當前的單 document 持續寫入建議為基準（個位數量級），估算時取保守值。讀取成本同步要算：每次讀計數 = N 次 document read，乘上讀取頻率與日活，這是 distributed counter 的隱性帳。</p>
<p>監控的訊號是寫入失敗率與 contention 重試。寫入大量失敗 + retry 是 contention 的直接徵兆；單一 shard 的寫入頻率若明顯高於其他 shard，是 shard 選擇有偏的徵兆。這些訊號接回 <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>，把高頻寫入的健康度當成可觀測指標而非事故才發現。</p>
<p>容量規劃還要考慮 shard 數的可調整性：shard 數寫死在 client 程式裡，事後要加 shard 得同時改寫入與讀取邏輯、並補建新 shard document。預期會成長的計數，起步就把 shard 數設在峰值對應的量級，比事後擴容省事。</p>
<h2 id="邊界與整合什麼計數不該用分片什麼該離開-firestore">邊界與整合：什麼計數不該用分片，什麼該離開 Firestore</h2>
<p>distributed counter 解的是「高頻、可接受近似、不需強一致」的計數——讚數、觀看數、瀏覽量、即時參與人數。它的邊界很清楚：</p>
<ul>
<li><strong>需要強一致與精確的計數</strong>：帳戶餘額、庫存、配額扣減。這些要嘛用 Firestore transaction 嚴格序列化（但就回到單 document 寫入上限的限制、不適合高頻），要嘛放關聯式資料庫用 row-level lock 與交易保護（見 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction 與一致性邊界</a>）</li>
<li><strong>需要任意維度聚合的計數</strong>：要算「各地區、各時段的累計」這類多維彙總，分片計數表達不了，該把事件流寫進分析系統或關聯式資料庫做 aggregation</li>
<li><strong>計數本身是核心交易資料</strong>：當計數驅動扣款、結算這類有金錢後果的流程，把它留在 client 直連的 Firestore 是控制面風險，該移到後端——這呼應 <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> 的成本與授權 driver</li>
</ul>
<p>判讀順序是先問「這個計數能不能容忍近似與最終一致」。能，distributed counter 是 Firestore 內的正解；不能，這個計數從一開始就不該用 Firestore 的單欄位累加表達。</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>（容量特性與寫入熱點）</li>
<li>一致性邊界：<a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction 與一致性邊界</a>（強一致計數的去處）</li>
<li>容量背景：<a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a></li>
<li>觀測：<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>（寫入失敗率與 contention 監控）</li>
<li>官方：<a href="https://firebase.google.com/docs/firestore/best-practices">Firestore best practices</a>、<a href="https://firebase.google.com/docs/firestore/solutions/counters">Distributed counters solution</a></li>
</ul>
]]></content:encoded></item><item><title>Firestore document 反正規化與一致性維護：fan-out write、副本同步與資料修復</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/denormalization-fanout-consistency/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/denormalization-fanout-consistency/</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;/p>&lt;/blockquote>
&lt;h2 id="問題情境改一個使用者名稱要改一千筆">問題情境：改一個使用者名稱要改一千筆&lt;/h2>
&lt;p>一個社群 app 的貼文列表要顯示作者頭像與名稱。關聯式思路是貼文存 &lt;code>authorId&lt;/code>、查詢時 JOIN &lt;code>users&lt;/code> 表。但 Firestore 沒有 JOIN——要嘛 client 每顯示一則貼文就多查一次 &lt;code>users&lt;/code>（列表 20 則就 20 次額外讀取），要嘛在貼文 document 裡直接存一份 &lt;code>authorName&lt;/code> 與 &lt;code>authorAvatar&lt;/code> 副本。為了讀取效率，多數人選後者。&lt;/p>
&lt;p>副本一上線就埋了一致性債：使用者改了名稱，他過去發的一千則貼文裡的 &lt;code>authorName&lt;/code> 還是舊的。改名這個動作從「更新一筆 &lt;code>users&lt;/code> document」變成「更新一千筆貼文 document」。這篇處理 Firestore 反正規化的建模決策、如何用 fan-out write 維護副本一致、以及這套手段撐不住時的退場。&lt;/p>
&lt;h2 id="核心概念反正規化是查詢邊界逼出來的">核心概念：反正規化是查詢邊界逼出來的&lt;/h2>
&lt;p>關聯式資料庫預設正規化，靠 JOIN 在查詢時組合資料；Firestore 沒有 server 端 JOIN，組合資料只有兩條路：client 多次查詢自己組，或寫入時就把要一起讀的資料存在一起。後者就是反正規化——它不是 Firestore 的「壞習慣」，是 client 直連 + 無 JOIN 的查詢模型逼出來的必然建模。&lt;/p>
&lt;p>反正規化的判斷單位是 access pattern，不是資料的「正規與否」。問題不是「該不該複製」，而是「這份資料在哪些讀取路徑上要被一起讀到，複製它的一致性維護成本，比每次多查一次划不划算」。判斷有三個輸入：&lt;/p>
&lt;p>&lt;strong>讀寫比&lt;/strong>。讀多寫少的資料適合反正規化——複製成本攤在少數寫入上、省下大量讀取的額外查詢。作者名稱顯示在每則貼文（高讀），但改名很少（低寫），複製划算。反過來，高頻變動的資料複製多份，每次變動要 fan-out 到所有副本，成本可能超過省下的讀取。&lt;/p>
&lt;p>&lt;strong>副本數量的可預測性&lt;/strong>。複製到「一個 user 的 profile 摘要」這種固定副本可控；複製到「該 user 的所有貼文」這種隨資料成長無上限的副本，fan-out 的寫入量會隨規模膨脹，要特別評估。&lt;/p>
&lt;p>&lt;strong>一致性容忍度&lt;/strong>。副本短暫不一致（改名後幾秒內舊貼文還顯示舊名）能不能接受。能容忍最終一致的，反正規化的維護可以非同步、用 Cloud Function 慢慢 fan-out；不能容忍的，要嘛同步 fan-out（貴且有規模上限），要嘛這份資料根本不該複製。&lt;/p>
&lt;h2 id="配置fan-out-write-維護副本一致">配置：fan-out write 維護副本一致&lt;/h2>
&lt;p>fan-out write 是「一次邏輯更新，寫多個 document」。Firestore 的 &lt;code>writeBatch&lt;/code> 讓多個寫入 atomic 提交（最多 500 個操作一批），是固定且可控副本數的標準手段：&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">writeBatch&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">doc&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">query&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">getDocs&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">// 改名：更新 users/{uid} + fan-out 到該 user 的所有貼文副本
&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">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">renameUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">uid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">newName&lt;/span>&lt;span class="p">)&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 class="c1">// 1. 更新權威來源
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">userRef&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">doc&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;users&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">uid&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 2. 查出所有要同步的副本
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">postsSnap&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getDocs&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">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;posts&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="nx">where&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;authorId&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">uid&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="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 3. batch 提交（超過 500 要分批）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">ops&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[{&lt;/span> &lt;span class="nx">ref&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">userRef&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">displayName&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">newName&lt;/span> &lt;span class="p">}&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">postsSnap&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">p&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="nx">ops&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">ref&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ref&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">authorName&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">newName&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">let&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">ops&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="mi">500&lt;/span>&lt;span class="p">)&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="kr">const&lt;/span> &lt;span class="nx">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">writeBatch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&lt;/span>&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="nx">ops&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">500&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">op&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">op&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ref&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">op&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&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="kr">await&lt;/span> &lt;span class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">commit&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這裡的關鍵取捨是同步 fan-out 與非同步 fan-out。上面的同步版本在使用者點「儲存」時就把一千筆貼文改完，使用者等待時間隨副本數成長、且超過 500 要分批多次提交，副本數無上限時會撞到不可接受的延遲。非同步版本把權威來源（&lt;code>users/{uid}&lt;/code>）同步更新，副本同步丟給 Cloud Function 在背景慢慢做：&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="c1">// Cloud Function：onUpdate users document 時 fan-out 到副本
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">exports&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fanoutUserName&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">functions&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firestore&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;users/{uid}&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">onUpdate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kr">async&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">context&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">before&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">change&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">before&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">after&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">change&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">after&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">before&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">displayName&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="nx">after&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">displayName&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 名稱沒變不做
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">uid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">params&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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="kr">const&lt;/span> &lt;span class="nx">postsSnap&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">admin&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firestore&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="p">.&lt;/span>&lt;span class="nx">collection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;posts&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">where&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;authorId&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">uid&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">get&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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 分批 fan-out，背景執行、使用者不等待
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">docs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">postsSnap&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">docs&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="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">let&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">docs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="mi">500&lt;/span>&lt;span class="p">)&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="kr">const&lt;/span> &lt;span class="nx">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">admin&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firestore&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">batch&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">docs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">500&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">d&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">d&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ref&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">authorName&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">after&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">displayName&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="kr">await&lt;/span> &lt;span class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">commit&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;/code>&lt;/pre>&lt;/div>&lt;p>非同步 fan-out 把「使用者體驗的即時性」與「副本的最終一致」分開：權威來源立刻更新、副本最終收斂。代價是中間有一段不一致窗口（改名後到 fan-out 完成前，舊貼文顯示舊名），這對社群 app 的顯示名稱通常可接受。&lt;code>writeBatch&lt;/code> 與 &lt;code>transaction&lt;/code> 的選擇在這裡也要分清：fan-out 是「寫多個獨立 document、不依賴彼此既有值」用 &lt;code>writeBatch&lt;/code>；若更新要依賴讀到的當前值（例如同時扣 A 加 B 且要看當前餘額）才用 &lt;code>transaction&lt;/code>，但 transaction 在大量 document 的 fan-out 上不適用。&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>。</p></blockquote>
<h2 id="問題情境改一個使用者名稱要改一千筆">問題情境：改一個使用者名稱要改一千筆</h2>
<p>一個社群 app 的貼文列表要顯示作者頭像與名稱。關聯式思路是貼文存 <code>authorId</code>、查詢時 JOIN <code>users</code> 表。但 Firestore 沒有 JOIN——要嘛 client 每顯示一則貼文就多查一次 <code>users</code>（列表 20 則就 20 次額外讀取），要嘛在貼文 document 裡直接存一份 <code>authorName</code> 與 <code>authorAvatar</code> 副本。為了讀取效率，多數人選後者。</p>
<p>副本一上線就埋了一致性債：使用者改了名稱，他過去發的一千則貼文裡的 <code>authorName</code> 還是舊的。改名這個動作從「更新一筆 <code>users</code> document」變成「更新一千筆貼文 document」。這篇處理 Firestore 反正規化的建模決策、如何用 fan-out write 維護副本一致、以及這套手段撐不住時的退場。</p>
<h2 id="核心概念反正規化是查詢邊界逼出來的">核心概念：反正規化是查詢邊界逼出來的</h2>
<p>關聯式資料庫預設正規化，靠 JOIN 在查詢時組合資料；Firestore 沒有 server 端 JOIN，組合資料只有兩條路：client 多次查詢自己組，或寫入時就把要一起讀的資料存在一起。後者就是反正規化——它不是 Firestore 的「壞習慣」，是 client 直連 + 無 JOIN 的查詢模型逼出來的必然建模。</p>
<p>反正規化的判斷單位是 access pattern，不是資料的「正規與否」。問題不是「該不該複製」，而是「這份資料在哪些讀取路徑上要被一起讀到，複製它的一致性維護成本，比每次多查一次划不划算」。判斷有三個輸入：</p>
<p><strong>讀寫比</strong>。讀多寫少的資料適合反正規化——複製成本攤在少數寫入上、省下大量讀取的額外查詢。作者名稱顯示在每則貼文（高讀），但改名很少（低寫），複製划算。反過來，高頻變動的資料複製多份，每次變動要 fan-out 到所有副本，成本可能超過省下的讀取。</p>
<p><strong>副本數量的可預測性</strong>。複製到「一個 user 的 profile 摘要」這種固定副本可控；複製到「該 user 的所有貼文」這種隨資料成長無上限的副本，fan-out 的寫入量會隨規模膨脹，要特別評估。</p>
<p><strong>一致性容忍度</strong>。副本短暫不一致（改名後幾秒內舊貼文還顯示舊名）能不能接受。能容忍最終一致的，反正規化的維護可以非同步、用 Cloud Function 慢慢 fan-out；不能容忍的，要嘛同步 fan-out（貴且有規模上限），要嘛這份資料根本不該複製。</p>
<h2 id="配置fan-out-write-維護副本一致">配置：fan-out write 維護副本一致</h2>
<p>fan-out write 是「一次邏輯更新，寫多個 document」。Firestore 的 <code>writeBatch</code> 讓多個寫入 atomic 提交（最多 500 個操作一批），是固定且可控副本數的標準手段：</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">writeBatch</span><span class="p">,</span> <span class="nx">doc</span><span class="p">,</span> <span class="nx">collection</span><span class="p">,</span> <span class="nx">query</span><span class="p">,</span> <span class="nx">where</span><span class="p">,</span> <span class="nx">getDocs</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">// 改名：更新 users/{uid} + fan-out 到該 user 的所有貼文副本
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="kd">function</span> <span class="nx">renameUser</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="nx">uid</span><span class="p">,</span> <span class="nx">newName</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// 1. 更新權威來源
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">userRef</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">(</span><span class="nx">db</span><span class="p">,</span> <span class="s1">&#39;users&#39;</span><span class="p">,</span> <span class="nx">uid</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="c1">// 2. 查出所有要同步的副本
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">postsSnap</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getDocs</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <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;posts&#39;</span><span class="p">),</span> <span class="nx">where</span><span class="p">(</span><span class="s1">&#39;authorId&#39;</span><span class="p">,</span> <span class="s1">&#39;==&#39;</span><span class="p">,</span> <span class="nx">uid</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="c1">// 3. batch 提交（超過 500 要分批）
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">ops</span> <span class="o">=</span> <span class="p">[{</span> <span class="nx">ref</span><span class="o">:</span> <span class="nx">userRef</span><span class="p">,</span> <span class="nx">data</span><span class="o">:</span> <span class="p">{</span> <span class="nx">displayName</span><span class="o">:</span> <span class="nx">newName</span> <span class="p">}</span> <span class="p">}];</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nx">postsSnap</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">p</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="nx">ops</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span> <span class="nx">ref</span><span class="o">:</span> <span class="nx">p</span><span class="p">.</span><span class="nx">ref</span><span class="p">,</span> <span class="nx">data</span><span class="o">:</span> <span class="p">{</span> <span class="nx">authorName</span><span class="o">:</span> <span class="nx">newName</span> <span class="p">}</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">ops</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span> <span class="o">+=</span> <span class="mi">500</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="kr">const</span> <span class="nx">batch</span> <span class="o">=</span> <span class="nx">writeBatch</span><span class="p">(</span><span class="nx">db</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">ops</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span> <span class="nx">i</span> <span class="o">+</span> <span class="mi">500</span><span class="p">).</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">op</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">update</span><span class="p">(</span><span class="nx">op</span><span class="p">.</span><span class="nx">ref</span><span class="p">,</span> <span class="nx">op</span><span class="p">.</span><span class="nx">data</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="kr">await</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">commit</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡的關鍵取捨是同步 fan-out 與非同步 fan-out。上面的同步版本在使用者點「儲存」時就把一千筆貼文改完，使用者等待時間隨副本數成長、且超過 500 要分批多次提交，副本數無上限時會撞到不可接受的延遲。非同步版本把權威來源（<code>users/{uid}</code>）同步更新，副本同步丟給 Cloud Function 在背景慢慢做：</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="c1">// Cloud Function：onUpdate users document 時 fan-out 到副本
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">exports</span><span class="p">.</span><span class="nx">fanoutUserName</span> <span class="o">=</span> <span class="nx">functions</span><span class="p">.</span><span class="nx">firestore</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="p">.</span><span class="nb">document</span><span class="p">(</span><span class="s1">&#39;users/{uid}&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">.</span><span class="nx">onUpdate</span><span class="p">(</span><span class="kr">async</span> <span class="p">(</span><span class="nx">change</span><span class="p">,</span> <span class="nx">context</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kr">const</span> <span class="nx">before</span> <span class="o">=</span> <span class="nx">change</span><span class="p">.</span><span class="nx">before</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="kr">const</span> <span class="nx">after</span> <span class="o">=</span> <span class="nx">change</span><span class="p">.</span><span class="nx">after</span><span class="p">.</span><span class="nx">data</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">before</span><span class="p">.</span><span class="nx">displayName</span> <span class="o">===</span> <span class="nx">after</span><span class="p">.</span><span class="nx">displayName</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span> <span class="c1">// 名稱沒變不做
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kr">const</span> <span class="nx">uid</span> <span class="o">=</span> <span class="nx">context</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="kr">const</span> <span class="nx">postsSnap</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">admin</span><span class="p">.</span><span class="nx">firestore</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="p">.</span><span class="nx">collection</span><span class="p">(</span><span class="s1">&#39;posts&#39;</span><span class="p">).</span><span class="nx">where</span><span class="p">(</span><span class="s1">&#39;authorId&#39;</span><span class="p">,</span> <span class="s1">&#39;==&#39;</span><span class="p">,</span> <span class="nx">uid</span><span class="p">).</span><span class="nx">get</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="c1">// 分批 fan-out，背景執行、使用者不等待
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>    <span class="kr">const</span> <span class="nx">docs</span> <span class="o">=</span> <span class="nx">postsSnap</span><span class="p">.</span><span class="nx">docs</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">docs</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span> <span class="o">+=</span> <span class="mi">500</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="kr">const</span> <span class="nx">batch</span> <span class="o">=</span> <span class="nx">admin</span><span class="p">.</span><span class="nx">firestore</span><span class="p">().</span><span class="nx">batch</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">      <span class="nx">docs</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span> <span class="nx">i</span> <span class="o">+</span> <span class="mi">500</span><span class="p">).</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">d</span><span class="p">)</span> <span class="p">=&gt;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">batch</span><span class="p">.</span><span class="nx">update</span><span class="p">(</span><span class="nx">d</span><span class="p">.</span><span class="nx">ref</span><span class="p">,</span> <span class="p">{</span> <span class="nx">authorName</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">displayName</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="kr">await</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">commit</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></code></pre></div><p>非同步 fan-out 把「使用者體驗的即時性」與「副本的最終一致」分開：權威來源立刻更新、副本最終收斂。代價是中間有一段不一致窗口（改名後到 fan-out 完成前，舊貼文顯示舊名），這對社群 app 的顯示名稱通常可接受。<code>writeBatch</code> 與 <code>transaction</code> 的選擇在這裡也要分清：fan-out 是「寫多個獨立 document、不依賴彼此既有值」用 <code>writeBatch</code>；若更新要依賴讀到的當前值（例如同時扣 A 加 B 且要看當前餘額）才用 <code>transaction</code>，但 transaction 在大量 document 的 fan-out 上不適用。</p>
<h2 id="故障演練五個副本不一致的-production-踩坑">故障演練：五個副本不一致的 production 踩坑</h2>
<h4 id="case-1複製了卻沒建-fan-out-路徑">Case 1：複製了卻沒建 fan-out 路徑</h4>
<p>貼文存了 <code>authorName</code> 副本，但改名邏輯只更新 <code>users</code>，沒人寫 fan-out。副本永遠停在建立時的值。修法：反正規化的建模決策必須連同「誰負責同步副本」一起定，複製一份資料就要有對應的 fan-out write 路徑，沒有 fan-out 的副本是一致性債。</p>
<h4 id="case-2同步-fan-out-撞到副本數上限">Case 2：同步 fan-out 撞到副本數上限</h4>
<p>改名時同步更新所有貼文，某個高產出使用者有幾萬則貼文，提交分成幾十批、使用者等了半分鐘還在轉圈、甚至 timeout。修法：副本數無上限的 fan-out 改非同步（Cloud Function 背景做），同步 fan-out 只用在副本數固定且小的場景。</p>
<h4 id="case-3fan-out-中途失敗留下部分更新">Case 3：fan-out 中途失敗留下部分更新</h4>
<p>非同步 fan-out 跑到一半 function 掛了，前 500 筆改了、後面沒改，副本處於半新半舊。修法：fan-out function 要可重入（重跑能補完未完成的），或記錄 fan-out 進度；殘留的不一致由對帳流程掃出修復（對應 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 Reconciliation 與 Data Repair</a>）。</p>
<h4 id="case-4雙向反正規化造成更新環">Case 4：雙向反正規化造成更新環</h4>
<p>A 存 B 的副本、B 也存 A 的副本，改 A 觸發 fan-out 改 B、又觸發 fan-out 改回 A，function 互相觸發成環。修法：反正規化要有明確的權威方向（誰是 source of truth、誰是副本），副本不反向觸發權威來源的更新。</p>
<h4 id="case-5把副本當權威來源讀來做判斷">Case 5：把副本當權威來源讀來做判斷</h4>
<p>拿貼文裡的 <code>authorName</code> 副本去做權限或業務判斷，而非讀 <code>users</code> 權威來源。副本在不一致窗口內是舊值，判斷出錯。修法：副本只供顯示，任何需要正確性的判斷讀權威來源；明確標示哪個 document 是 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a>、哪些是顯示副本。</p>
<h2 id="容量與觀測fan-out-寫入量與不一致窗口">容量與觀測：fan-out 寫入量與不一致窗口</h2>
<p>反正規化的容量帳要算 fan-out 的寫入放大。一次邏輯更新放大成 N 次寫入，N 是副本數，這 N 次寫入計入計費。高頻變動 + 高副本數的組合會讓寫入成本失控——這正是判斷「該不該反正規化」的成本面：省下的讀取 vs 放大的寫入。</p>
<p>不一致窗口是要監控的健康指標：權威來源更新到所有副本收斂的延遲。非同步 fan-out 下這個窗口隨副本數與 function 吞吐變動，異常拉長是 fan-out 積壓的徵兆。觀測還要涵蓋 fan-out 失敗率與重試，接回 <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/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 Reconciliation</a> 的可驗證、可修復、可稽核流程。</p>
<h2 id="邊界與整合反正規化複雜到該回關聯式">邊界與整合：反正規化複雜到該回關聯式</h2>
<p>反正規化適合「讀多寫少、副本數可控、能容忍最終一致」的顯示資料。它撐不住的訊號是複製關係長成一張難以追蹤的網——資料被複製到十幾個地方、fan-out 路徑互相依賴、改一個欄位要同步的副本沒人說得清、對帳越來越頻繁。撞到這些訊號時，方向不是把 fan-out 寫得更巧：</p>
<ul>
<li><strong>關聯查詢成為主導需求</strong>：當資料的核心價值在「任意關聯與聚合」（報表、跨實體分析），反正規化是在用副本模擬 JOIN，成本與複雜度都不划算。這是 <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> 的報表牆——relational 的 JOIN 在查詢時組合，省掉整套副本維護</li>
<li><strong>副本維護成本超過查詢省下的成本</strong>：高頻變動的資料反正規化，fan-out 放大的寫入成本超過正規化後多查一次的成本，反正規化的前提就不成立</li>
<li><strong>巢狀結構保留比拆表更省</strong>：相反方向——有些一起讀寫、不需獨立查詢的關聯資料，在 Firestore 用巢狀 map / array 保留在同一 document 反而比拆 collection 簡單，遷到 relational 時用 <a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">PostgreSQL JSONB</a> 保留，不是所有東西都要拆成正規表</li>
</ul>
<p>判讀的起點永遠是 access pattern 與讀寫比，不是「正規化是對的、反正規化是妥協」這種預設立場。在 Firestore 裡反正規化是正解，問題只在它的維護成本何時翻轉。</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>（資料形狀與查詢邊界）</li>
<li>資料修復：<a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 Reconciliation 與 Data Repair</a>（副本不一致的對帳與修復）</li>
<li>狀態歸屬：<a href="/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">1.8 State Ownership 與 Query Boundary</a>（權威來源與派生副本的分辨）</li>
<li>遷移 driver：<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>（報表牆與反正規化還原）</li>
<li>官方：<a href="https://firebase.google.com/docs/firestore/data-model">Firestore data model</a>、<a href="https://firebase.google.com/docs/firestore/manage-data/transactions">Batched writes</a></li>
</ul>
]]></content:encoded></item><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>