<?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>Denormalization on Tarragon</title><link>https://tarrragon.github.io/blog/tags/denormalization/</link><description>Recent content in Denormalization on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/denormalization/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>