<?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>N-Plus-One on Tarragon</title><link>https://tarrragon.github.io/blog/tags/n-plus-one/</link><description>Recent content in N-Plus-One on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/n-plus-one/index.xml" rel="self" type="application/rss+xml"/><item><title>1000 本書、1001 次 SQL — N+1 查詢藏在 async mapper 裡</title><link>https://tarrragon.github.io/blog/work-log/flutter_sqlite_n_plus_one_query/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_sqlite_n_plus_one_query/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 進效能優化階段（UC-08）的現況盤點，抓到一個 P0：&lt;code>getAllBooks()&lt;/code> 對 1000 本書發出 1001 次 SQL 查詢、耗時約 2 秒；推算 10000 本書要 20 秒、UI 完全卡死
&lt;strong>疑問來源&lt;/strong>：沒有人寫過「對每本書查一次資料庫」這種程式碼——這個 N+1 是怎麼長出來的？
&lt;strong>整理目的&lt;/strong>：記下 N+1 在 ORM 之外的手寫形態（async mapper）、修法的結構、以及為什麼它在開發期永遠感覺不到
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.19.0 的 Phase 0 評估記錄；耗時數字是該記錄的影響評估、量級可信&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="形成兩個各自合理的函式組合出災難">形成：兩個各自合理的函式、組合出災難&lt;/h2>
&lt;p>問題程式碼拆開看，每一半都寫得很自然：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 單筆轉換：把一列 DB map 轉成 Book——順便把它的 tags 查出來
&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="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_mapToBook&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Map&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">dynamic&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">map&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">final&lt;/span> &lt;span class="n">db&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">_database&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="kd">final&lt;/span> &lt;span class="n">tagResult&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;book_tags&amp;#39;&lt;/span>&lt;span class="p">,&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"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="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">// 列表查詢：撈全部、逐筆轉換
&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="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">getAllBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&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="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="k">return&lt;/span> &lt;span class="n">Future&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_mapToBook&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">map&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>_mapToBook&lt;/code> 單看合理——Book 有 tags、轉換時補齊關聯是「完整的轉換」；&lt;code>getAllBooks&lt;/code> 單看也合理——每筆都用同一個轉換函式、&lt;code>Future.wait&lt;/code> 還做了並行。&lt;strong>N+1 不存在於任何一行、它存在於組合&lt;/strong>：一個做 IO 的轉換函式、被一個列表方法乘以 N。&lt;/p>
&lt;p>結構性的病灶是&lt;strong>mapper 做了 IO&lt;/strong>。轉換函式的職責是「資料形狀的映射」，把查詢塞進去的那一刻，它的成本從 O(1) 記憶體操作變成一次網路 / 磁碟往返——而呼叫端從簽名上看不出來（回傳本來就是 Future、多一次 await 沒有任何警訊）。&lt;/p>
&lt;h2 id="為什麼開發期永遠感覺不到">為什麼開發期永遠感覺不到&lt;/h2>
&lt;p>影響評估的三個數字說明了它的隱身機制：100 本約 200ms、1000 本約 2 秒、10000 本約 20 秒。開發跟測試用的資料集是幾十本——200ms 以下、混在正常的載入時間裡無法察覺。&lt;strong>N+1 的惡化是使用者資料量的線性函數&lt;/strong>，寫下它的人永遠不會遇到它爆炸的那天，遇到的是半年後書最多的那批使用者。&lt;/p>
&lt;p>這也解釋了為什麼它由效能盤點抓到、而不是被任何測試抓到：功能測試的資料量跟開發期一樣小，而「查詢次數」不在任何斷言的守備範圍——除非專門寫「操作 X 的 SQL 次數 ≤ K」這種預算型測試。&lt;/p>
&lt;h2 id="修法io-上移mapper-變純">修法：IO 上移、mapper 變純&lt;/h2>
&lt;p>修法的設計把查詢次數從 N+1 收斂到 2：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">getAllBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&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="c1">// 1. 一次撈所有書
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">books&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;books&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">orderBy:&lt;/span> &lt;span class="s1">&amp;#39;added_date DESC&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 2. 一次撈所有標籤（IN 批次）
&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="kd">final&lt;/span> &lt;span class="n">bookIds&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">books&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;id&amp;#39;&lt;/span>&lt;span class="p">]).&lt;/span>&lt;span class="n">toList&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="kd">final&lt;/span> &lt;span class="n">allTags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;book_tags&amp;#39;&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="nl">where:&lt;/span> &lt;span class="s1">&amp;#39;book_id IN (&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">bookIds&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="s1">&amp;#39;?&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">join&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="si">}&lt;/span>&lt;span class="s1">)&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="nl">whereArgs:&lt;/span> &lt;span class="n">bookIds&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 3. 記憶體組裝
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">tagsByBookId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_groupTagsByBookId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">allTags&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="k">return&lt;/span> &lt;span class="n">books&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_mapToBookWithTags&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tagsByBookId&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="n">toList&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;/code>&lt;/pre>&lt;/div>&lt;p>結構上的重點勝過次數本身：&lt;strong>IO 全部上移到列表方法、轉換函式變純&lt;/strong>——&lt;code>_mapToBookWithTags&lt;/code> 收「這本書的列」跟「查好的 tags 索引」、不碰資料庫。純轉換函式拿回了三個性質：成本可預期（呼叫 N 次就是 N 次記憶體操作）、可單獨測試（餵 map 斷言 Book）、且&lt;strong>結構上不可能再退化成 N+1&lt;/strong>——它沒有 db 可查。這跟 &lt;a href="https://tarrragon.github.io/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">pure function 領域計算&lt;/a>是同一個藥方在資料層的應用：IO 在邊界、計算（轉換）在核心。&lt;/p>
&lt;h2 id="伴生發現能力早就存在預設路徑不經過它">伴生發現：能力早就存在、預設路徑不經過它&lt;/h2>
&lt;p>同一次盤點還抓到第二個 P0——&lt;code>allBooksProvider&lt;/code> 直呼 &lt;code>getAllBooks()&lt;/code> 全量載入、記憶體隨書量膨脹。反直覺的是修這個問題&lt;strong>不用寫新能力&lt;/strong>：repository 早就有 &lt;code>getBooks(limit, offset)&lt;/code> 分頁方法、批次新增也有、快取系統也完整。能力都在、只是 UI 的預設路徑（&lt;code>allBooksProvider&lt;/code>）不經過它們。&lt;/p>
&lt;p>「有能力」跟「預設路徑用它」是兩回事——provider 是所有畫面拿書的入口、它選全載、分頁能力就是死碼。這是&lt;a href="https://tarrragon.github.io/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣&lt;/a>的資料層版本：要讓分頁被用、要嘛預設 provider 就是分頁的、要嘛全載入口加上明確的成本標記，靠「大家記得用分頁版」跟靠任何慣例一樣不可靠。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 進效能優化階段（UC-08）的現況盤點，抓到一個 P0：<code>getAllBooks()</code> 對 1000 本書發出 1001 次 SQL 查詢、耗時約 2 秒；推算 10000 本書要 20 秒、UI 完全卡死
<strong>疑問來源</strong>：沒有人寫過「對每本書查一次資料庫」這種程式碼——這個 N+1 是怎麼長出來的？
<strong>整理目的</strong>：記下 N+1 在 ORM 之外的手寫形態（async mapper）、修法的結構、以及為什麼它在開發期永遠感覺不到
<strong>本文邊界</strong>：素材是該專案 v0.19.0 的 Phase 0 評估記錄；耗時數字是該記錄的影響評估、量級可信</p></blockquote>
<hr>
<h2 id="形成兩個各自合理的函式組合出災難">形成：兩個各自合理的函式、組合出災難</h2>
<p>問題程式碼拆開看，每一半都寫得很自然：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 單筆轉換：把一列 DB map 轉成 Book——順便把它的 tags 查出來
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="n">Future</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;</span> <span class="n">_mapToBook</span><span class="p">(</span><span class="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">map</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="n">db</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">_database</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">tagResult</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;book_tags&#39;</span><span class="p">,</span> <span class="p">...);</span>   <span class="c1">// 每本書一次
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="p">...</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><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">// 列表查詢：撈全部、逐筆轉換
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="n">Future</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">getAllBooks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">return</span> <span class="n">Future</span><span class="p">.</span><span class="n">wait</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">map</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">_mapToBook</span><span class="p">(</span><span class="n">map</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>_mapToBook</code> 單看合理——Book 有 tags、轉換時補齊關聯是「完整的轉換」；<code>getAllBooks</code> 單看也合理——每筆都用同一個轉換函式、<code>Future.wait</code> 還做了並行。<strong>N+1 不存在於任何一行、它存在於組合</strong>：一個做 IO 的轉換函式、被一個列表方法乘以 N。</p>
<p>結構性的病灶是<strong>mapper 做了 IO</strong>。轉換函式的職責是「資料形狀的映射」，把查詢塞進去的那一刻，它的成本從 O(1) 記憶體操作變成一次網路 / 磁碟往返——而呼叫端從簽名上看不出來（回傳本來就是 Future、多一次 await 沒有任何警訊）。</p>
<h2 id="為什麼開發期永遠感覺不到">為什麼開發期永遠感覺不到</h2>
<p>影響評估的三個數字說明了它的隱身機制：100 本約 200ms、1000 本約 2 秒、10000 本約 20 秒。開發跟測試用的資料集是幾十本——200ms 以下、混在正常的載入時間裡無法察覺。<strong>N+1 的惡化是使用者資料量的線性函數</strong>，寫下它的人永遠不會遇到它爆炸的那天，遇到的是半年後書最多的那批使用者。</p>
<p>這也解釋了為什麼它由效能盤點抓到、而不是被任何測試抓到：功能測試的資料量跟開發期一樣小，而「查詢次數」不在任何斷言的守備範圍——除非專門寫「操作 X 的 SQL 次數 ≤ K」這種預算型測試。</p>
<h2 id="修法io-上移mapper-變純">修法：IO 上移、mapper 變純</h2>
<p>修法的設計把查詢次數從 N+1 收斂到 2：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">Future</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">getAllBooks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 1. 一次撈所有書
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">books</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;books&#39;</span><span class="p">,</span> <span class="nl">orderBy:</span> <span class="s1">&#39;added_date DESC&#39;</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="c1">// 2. 一次撈所有標籤（IN 批次）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">bookIds</span> <span class="o">=</span> <span class="n">books</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">b</span><span class="p">[</span><span class="s1">&#39;id&#39;</span><span class="p">]).</span><span class="n">toList</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kd">final</span> <span class="n">allTags</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;book_tags&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nl">where:</span> <span class="s1">&#39;book_id IN (</span><span class="si">${</span><span class="n">bookIds</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s1">&#39;?&#39;</span><span class="p">).</span><span class="n">join</span><span class="p">(</span><span class="s1">&#39;,&#39;</span><span class="p">)</span><span class="si">}</span><span class="s1">)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nl">whereArgs:</span> <span class="n">bookIds</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="c1">// 3. 記憶體組裝
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">tagsByBookId</span> <span class="o">=</span> <span class="n">_groupTagsByBookId</span><span class="p">(</span><span class="n">allTags</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">return</span> <span class="n">books</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">_mapToBookWithTags</span><span class="p">(</span><span class="n">b</span><span class="p">,</span> <span class="n">tagsByBookId</span><span class="p">)).</span><span class="n">toList</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>結構上的重點勝過次數本身：<strong>IO 全部上移到列表方法、轉換函式變純</strong>——<code>_mapToBookWithTags</code> 收「這本書的列」跟「查好的 tags 索引」、不碰資料庫。純轉換函式拿回了三個性質：成本可預期（呼叫 N 次就是 N 次記憶體操作）、可單獨測試（餵 map 斷言 Book）、且<strong>結構上不可能再退化成 N+1</strong>——它沒有 db 可查。這跟 <a href="/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">pure function 領域計算</a>是同一個藥方在資料層的應用：IO 在邊界、計算（轉換）在核心。</p>
<h2 id="伴生發現能力早就存在預設路徑不經過它">伴生發現：能力早就存在、預設路徑不經過它</h2>
<p>同一次盤點還抓到第二個 P0——<code>allBooksProvider</code> 直呼 <code>getAllBooks()</code> 全量載入、記憶體隨書量膨脹。反直覺的是修這個問題<strong>不用寫新能力</strong>：repository 早就有 <code>getBooks(limit, offset)</code> 分頁方法、批次新增也有、快取系統也完整。能力都在、只是 UI 的預設路徑（<code>allBooksProvider</code>）不經過它們。</p>
<p>「有能力」跟「預設路徑用它」是兩回事——provider 是所有畫面拿書的入口、它選全載、分頁能力就是死碼。這是<a href="/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣</a>的資料層版本：要讓分頁被用、要嘛預設 provider 就是分頁的、要嘛全載入口加上明確的成本標記，靠「大家記得用分頁版」跟靠任何慣例一樣不可靠。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li><strong>mapper / 轉換函式的簽名是 <code>async</code></strong>——最便宜的掃描：<code>rg &quot;Future&lt;\w+&gt; _mapTo&quot; lib/</code>，每個命中都問它裡面的 await 在等什麼</li>
<li>迴圈或 <code>Future.wait</code> 裡呼叫「單筆處理」函式、而該函式含查詢——N+1 的標準組合形</li>
<li>某操作的耗時隨資料量線性惡化、但單筆操作很快——次數問題、不是單次效能問題</li>
<li>repository 有分頁 / 批次 API、但 provider / service 層的預設入口全量載入——能力與預設路徑脫節</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同藥方的領域計算版：<a href="/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">「該收多少錢」抽成 pure function</a>——IO 在邊界、核心保持純，資料層與領域層各一個現場</li>
<li>預設路徑的原則：<a href="/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣</a>——分頁能力閒置的機制跟工具預設值是同一件事</li>
<li>查詢預算的守法：<a href="/blog/work-log/flutter_test_signal_credibility_three_layers/" data-link-title="紅燈在量什麼 — 測試訊號的三層失真：斷言、量測、環境" data-link-desc="「全套件降至 0」有意義的前置條件是紅燈只反映程式缺陷。三層各自會失真：絕對計時斷言量的是機器負載、compact reporter 高並行下行覆寫產生假陰性、fresh checkout 缺 gitignored 生成產物讓整包結果不可信。含 flaky 判定的取樣門檻與對照實驗定歸因的做法。">紅燈在量什麼</a>——效能特性要用專門的量測管道守、不是塞計時斷言進單元測試</li>
</ul>
]]></content:encoded></item></channel></rss>