<?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>Query-Plan on Tarragon</title><link>https://tarrragon.github.io/blog/tags/query-plan/</link><description>Recent content in Query-Plan on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 02 Sep 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/query-plan/index.xml" rel="self" type="application/rss+xml"/><item><title>Query Plan（執行計畫）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/</guid><description>&lt;p>Query plan 是引擎為一段查詢決定的執行步驟，內容是一棵運算子樹：哪張表用什麼方式讀、用哪種演算法做連接、中間結果怎麼傳遞。它由 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer&lt;/a> 產生，而樹上每個節點的輸入與輸出都是一個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>計畫是查詢文字與實際代價之間唯一的橋。SQL 只描述要什麼結果，所以掃全表或走索引、雜湊連接或巢狀迴圈這些決定都不在查詢的文字裡，要問引擎才知道。連接的兩種演算法差在做法：巢狀迴圈對左邊的每一列去右邊找一次，雜湊連接先把一邊建成雜湊表再逐列去查它。連接要保護哪一側是查詢說了算（見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">outer join&lt;/a>），而用什麼演算法達成它由計畫決定。&lt;/p>
&lt;p>同一段查詢的計畫會隨資料量、索引與統計資訊改變，所以計畫是某個時間點某個資料庫狀態下的答案，不是查詢本身的屬性。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>書店把顧客與訂單連起來，&lt;code>EXPLAIN QUERY PLAN&lt;/code> 在 SQLite 上回三行（查的是 &lt;code>FROM 訂單 JOIN 顧客&lt;/code>，訂單二十萬列、顧客五十列）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">|--SCAN 訂單
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">|--BLOOM FILTER ON 顧客 (顧客編號=?)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">`--SEARCH 顧客 USING AUTOMATIC COVERING INDEX (顧客編號=?)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>SCAN&lt;/code> 與 &lt;code>SEARCH&lt;/code> 是兩種取法：&lt;code>SCAN 訂單&lt;/code> 表示二十萬列的訂單表逐列看過，&lt;code>SEARCH 顧客&lt;/code> 表示對五十列的顧客表透過索引定位而不逐列看。&lt;/p>
&lt;p>&lt;code>AUTOMATIC&lt;/code> 指的是這個索引不存在於資料庫裡，是引擎為了這一次查詢臨時建的——它判斷建索引比全表掃描划算。&lt;code>COVERING&lt;/code> 指的是索引本身帶著查詢要的欄位，所以定位之後不必再回去讀原本的列。&lt;code>BLOOM FILTER&lt;/code> 是它加的一道快速排除步驟。&lt;/p>
&lt;p>&lt;strong>這三行裡沒有一個字出現在原本的查詢裡。&lt;/strong> 查詢只說了要把兩張表按顧客編號連起來，其餘全部是引擎的決定——而它們決定了這段查詢要花多久。&lt;/p>
&lt;p>&lt;strong>同一段查詢在別的資料庫狀態下會得到別的計畫&lt;/strong>——有沒有統計、有沒有索引都會換掉它。上面那三行對書寫順序也敏感：把兩張表對調寫成 &lt;code>FROM 顧客 JOIN 訂單&lt;/code>，沒有統計的資料庫會改成掃顧客那張，跑過 &lt;code>ANALYZE&lt;/code> 之後才收斂回上面這一個。所以讀計畫要連同「當時的資料庫是什麼狀態」一起讀，那組對照在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響&lt;/a>。&lt;/p>
&lt;p>DuckDB 的 &lt;code>EXPLAIN&lt;/code> 換一種呈現，回一棵運算子樹，節點標著 &lt;code>HASH_JOIN&lt;/code>、&lt;code>SEQ_SCAN&lt;/code> 這類名稱與估計的列數；&lt;code>EXPLAIN ANALYZE&lt;/code> 另外附上實際跑出來的列數與耗時。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>估計值與實際值差很多的時候，先懷疑統計資訊而不是查詢的寫法——最佳化器是照估計值選的，估計錯了選擇就跟著錯。判讀計畫要對照當時的資料量，把在小資料上量到的計畫套到 production 是常見的誤判來源。&lt;/p></description><content:encoded><![CDATA[<p>Query plan 是引擎為一段查詢決定的執行步驟，內容是一棵運算子樹：哪張表用什麼方式讀、用哪種演算法做連接、中間結果怎麼傳遞。它由 <a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer</a> 產生，而樹上每個節點的輸入與輸出都是一個 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>計畫是查詢文字與實際代價之間唯一的橋。SQL 只描述要什麼結果，所以掃全表或走索引、雜湊連接或巢狀迴圈這些決定都不在查詢的文字裡，要問引擎才知道。連接的兩種演算法差在做法：巢狀迴圈對左邊的每一列去右邊找一次，雜湊連接先把一邊建成雜湊表再逐列去查它。連接要保護哪一側是查詢說了算（見 <a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">outer join</a>），而用什麼演算法達成它由計畫決定。</p>
<p>同一段查詢的計畫會隨資料量、索引與統計資訊改變，所以計畫是某個時間點某個資料庫狀態下的答案，不是查詢本身的屬性。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>書店把顧客與訂單連起來，<code>EXPLAIN QUERY PLAN</code> 在 SQLite 上回三行（查的是 <code>FROM 訂單 JOIN 顧客</code>，訂單二十萬列、顧客五十列）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">|--SCAN 訂單
</span></span><span class="line"><span class="ln">2</span><span class="cl">|--BLOOM FILTER ON 顧客 (顧客編號=?)
</span></span><span class="line"><span class="ln">3</span><span class="cl">`--SEARCH 顧客 USING AUTOMATIC COVERING INDEX (顧客編號=?)</span></span></code></pre></div><p><code>SCAN</code> 與 <code>SEARCH</code> 是兩種取法：<code>SCAN 訂單</code> 表示二十萬列的訂單表逐列看過，<code>SEARCH 顧客</code> 表示對五十列的顧客表透過索引定位而不逐列看。</p>
<p><code>AUTOMATIC</code> 指的是這個索引不存在於資料庫裡，是引擎為了這一次查詢臨時建的——它判斷建索引比全表掃描划算。<code>COVERING</code> 指的是索引本身帶著查詢要的欄位，所以定位之後不必再回去讀原本的列。<code>BLOOM FILTER</code> 是它加的一道快速排除步驟。</p>
<p><strong>這三行裡沒有一個字出現在原本的查詢裡。</strong> 查詢只說了要把兩張表按顧客編號連起來，其餘全部是引擎的決定——而它們決定了這段查詢要花多久。</p>
<p><strong>同一段查詢在別的資料庫狀態下會得到別的計畫</strong>——有沒有統計、有沒有索引都會換掉它。上面那三行對書寫順序也敏感：把兩張表對調寫成 <code>FROM 顧客 JOIN 訂單</code>，沒有統計的資料庫會改成掃顧客那張，跑過 <code>ANALYZE</code> 之後才收斂回上面這一個。所以讀計畫要連同「當時的資料庫是什麼狀態」一起讀，那組對照在 <a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定</a> 與 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a>。</p>
<p>DuckDB 的 <code>EXPLAIN</code> 換一種呈現，回一棵運算子樹，節點標著 <code>HASH_JOIN</code>、<code>SEQ_SCAN</code> 這類名稱與估計的列數；<code>EXPLAIN ANALYZE</code> 另外附上實際跑出來的列數與耗時。</p>
<h2 id="設計責任">設計責任</h2>
<p>估計值與實際值差很多的時候，先懷疑統計資訊而不是查詢的寫法——最佳化器是照估計值選的，估計錯了選擇就跟著錯。判讀計畫要對照當時的資料量，把在小資料上量到的計畫套到 production 是常見的誤判來源。</p>
]]></content:encoded></item><item><title>SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/</guid><description>&lt;p>SQL 寫的是要什麼結果，不是怎麼算出來。這一條是這個語言其餘每個特徵的來源，包括它最容易讓人讀錯的那些。它最直接的一個後果是：同一個問題換兩種寫法送進去回來的結果一模一樣，而引擎在底下做的事可能完全不同——差在哪裡，查詢的文字一個字都沒說。&lt;/p>
&lt;p>&lt;strong>與逐步執行的程式對照一次，這個性質最清楚。&lt;/strong> 用 pandas 或任何一種 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/dataframe/" data-link-title="DataFrame（資料框）" data-link-desc="SQL 教材拿 pandas 或資料框當對照時，查 DataFrame 是什麼、它與資料表差在哪">DataFrame&lt;/a> 寫「找出下過單的顧客」，寫下的是一串動作：先載入這張表、再依條件篩選、再與另一張表合併，每一行都指定了下一步做什麼，而執行就照那個順序發生。SQL 寫下的是一段描述——哪些表參與、配對的條件是什麼、要留下哪些列——至於先讀哪一張、用什麼演算法配對、要不要臨時建索引，一個字都沒說。&lt;/p>
&lt;p>紅利是描述與實作脫鉤。一段查詢在一萬列與一億列的資料上都不必改寫，換一台機器、換一個索引配置也不必改寫，因為怎麼算從來就不在寫下的查詢文字裡面。代價是紅利的另一面——怎麼算不在寫的東西裡面，所以從查詢的文字看不出它要花多少。&lt;/p>
&lt;h2 id="書寫順序求值順序與執行順序">書寫順序、求值順序與執行順序&lt;/h2>
&lt;p>&lt;strong>書寫順序&lt;/strong>是子句在文字上的先後：&lt;code>SELECT&lt;/code> 在最前面，&lt;code>FROM&lt;/code> 跟在後面，&lt;code>WHERE&lt;/code>、&lt;code>GROUP BY&lt;/code>、&lt;code>HAVING&lt;/code>、&lt;code>ORDER BY&lt;/code> 依序往下。這個順序是給人讀的，它的作用是讓查詢讀起來像一句話。&lt;/p>
&lt;p>&lt;strong>求值順序&lt;/strong>是語意模型規定的先後。它與書寫順序不同：先由 &lt;code>FROM&lt;/code> 決定要處理哪個資料集合，再用 &lt;code>WHERE&lt;/code> 篩掉列，接著 &lt;code>GROUP BY&lt;/code> 分組、&lt;code>HAVING&lt;/code> 篩掉組，最後才輪到 &lt;code>SELECT&lt;/code> 把要輸出的欄位算出來、&lt;code>ORDER BY&lt;/code> 排序。寫在最前面的 &lt;code>SELECT&lt;/code> 在語意上排在倒數第二步。&lt;/p>
&lt;p>&lt;strong>執行順序&lt;/strong>是引擎實際做的事，由 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer&lt;/a> 決定，而最佳化器只受一個約束：輸出的列集合要與語意模型算出來的一致。在這個約束之內，它可以任意重排、可以換演算法、可以臨時建資料結構。&lt;/p>
&lt;p>書寫順序、求值順序、執行順序這三者不一致，是宣告式這個選擇本身帶來的。讀查詢時的多數誤判來自把其中兩種當成同一種——最常見的是拿書寫順序去猜執行順序，以為「寫在前面的表會先被讀」。&lt;/p>
&lt;h2 id="執行計畫的依據統計資訊與書寫順序">執行計畫的依據：統計資訊與書寫順序&lt;/h2>
&lt;p>拿書店的兩張表來看這件事。顧客表五十列，訂單表二十萬列，要數的是兩者配得上的訂單有幾筆。同一個問題寫成兩種順序：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩者都回 &lt;code>200000&lt;/code>。看它們的 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">query plan&lt;/a> 要用 &lt;code>EXPLAIN QUERY PLAN&lt;/code>（SQLite 的 &lt;code>EXPLAIN&lt;/code> 給的是逐行 bytecode，不是計畫），而計畫取決於引擎有沒有這兩張表的統計資訊。&lt;/p>
&lt;p>這一節的兩張表可以這樣造：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">RECURSIVE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ALL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;顧客&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">RECURSIVE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ALL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200000&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>剛建好、還沒統計過的資料庫&lt;/strong>上，SQLite 給出兩個不同的計畫：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">-- EXPLAIN QUERY PLAN SELECT count(*) FROM 訂單 JOIN 顧客 ON 訂單.顧客編號 = 顧客.顧客編號;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">|--SCAN 訂單
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">|--BLOOM FILTER ON 顧客 (顧客編號=?)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">`--SEARCH 顧客 USING AUTOMATIC COVERING INDEX (顧客編號=?)
&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">-- EXPLAIN QUERY PLAN SELECT count(*) FROM 顧客 JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">|--SCAN 顧客
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">|--BLOOM FILTER ON 訂單 (顧客編號=?)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">`--SEARCH 訂單 USING AUTOMATIC COVERING INDEX (顧客編號=?)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>沒有統計就不知道哪張表大，於是它退回去按查詢文字裡兩張表出現的先後來決定計畫：&lt;code>顧客 JOIN 訂單&lt;/code> 那種寫法掃五十列的顧客，為了查找而當場替二十萬列的訂單建一個索引。&lt;/p></description><content:encoded><![CDATA[<p>SQL 寫的是要什麼結果，不是怎麼算出來。這一條是這個語言其餘每個特徵的來源，包括它最容易讓人讀錯的那些。它最直接的一個後果是：同一個問題換兩種寫法送進去回來的結果一模一樣，而引擎在底下做的事可能完全不同——差在哪裡，查詢的文字一個字都沒說。</p>
<p><strong>與逐步執行的程式對照一次，這個性質最清楚。</strong> 用 pandas 或任何一種 <a href="/blog/backend/01-database/sql/knowledge-cards/dataframe/" data-link-title="DataFrame（資料框）" data-link-desc="SQL 教材拿 pandas 或資料框當對照時，查 DataFrame 是什麼、它與資料表差在哪">DataFrame</a> 寫「找出下過單的顧客」，寫下的是一串動作：先載入這張表、再依條件篩選、再與另一張表合併，每一行都指定了下一步做什麼，而執行就照那個順序發生。SQL 寫下的是一段描述——哪些表參與、配對的條件是什麼、要留下哪些列——至於先讀哪一張、用什麼演算法配對、要不要臨時建索引，一個字都沒說。</p>
<p>紅利是描述與實作脫鉤。一段查詢在一萬列與一億列的資料上都不必改寫，換一台機器、換一個索引配置也不必改寫，因為怎麼算從來就不在寫下的查詢文字裡面。代價是紅利的另一面——怎麼算不在寫的東西裡面，所以從查詢的文字看不出它要花多少。</p>
<h2 id="書寫順序求值順序與執行順序">書寫順序、求值順序與執行順序</h2>
<p><strong>書寫順序</strong>是子句在文字上的先後：<code>SELECT</code> 在最前面，<code>FROM</code> 跟在後面，<code>WHERE</code>、<code>GROUP BY</code>、<code>HAVING</code>、<code>ORDER BY</code> 依序往下。這個順序是給人讀的，它的作用是讓查詢讀起來像一句話。</p>
<p><strong>求值順序</strong>是語意模型規定的先後。它與書寫順序不同：先由 <code>FROM</code> 決定要處理哪個資料集合，再用 <code>WHERE</code> 篩掉列，接著 <code>GROUP BY</code> 分組、<code>HAVING</code> 篩掉組，最後才輪到 <code>SELECT</code> 把要輸出的欄位算出來、<code>ORDER BY</code> 排序。寫在最前面的 <code>SELECT</code> 在語意上排在倒數第二步。</p>
<p><strong>執行順序</strong>是引擎實際做的事，由 <a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer</a> 決定，而最佳化器只受一個約束：輸出的列集合要與語意模型算出來的一致。在這個約束之內，它可以任意重排、可以換演算法、可以臨時建資料結構。</p>
<p>書寫順序、求值順序、執行順序這三者不一致，是宣告式這個選擇本身帶來的。讀查詢時的多數誤判來自把其中兩種當成同一種——最常見的是拿書寫順序去猜執行順序，以為「寫在前面的表會先被讀」。</p>
<h2 id="執行計畫的依據統計資訊與書寫順序">執行計畫的依據：統計資訊與書寫順序</h2>
<p>拿書店的兩張表來看這件事。顧客表五十列，訂單表二十萬列，要數的是兩者配得上的訂單有幾筆。同一個問題寫成兩種順序：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span></span></span></code></pre></div><p>兩者都回 <code>200000</code>。看它們的 <a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">query plan</a> 要用 <code>EXPLAIN QUERY PLAN</code>（SQLite 的 <code>EXPLAIN</code> 給的是逐行 bytecode，不是計畫），而計畫取決於引擎有沒有這兩張表的統計資訊。</p>
<p>這一節的兩張表可以這樣造：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">50</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;顧客&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">200000</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">50</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">900</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span></span></span></code></pre></div><p><strong>剛建好、還沒統計過的資料庫</strong>上，SQLite 給出兩個不同的計畫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- EXPLAIN QUERY PLAN SELECT count(*) FROM 訂單 JOIN 顧客 ON 訂單.顧客編號 = 顧客.顧客編號;
</span></span><span class="line"><span class="ln">2</span><span class="cl">|--SCAN 訂單
</span></span><span class="line"><span class="ln">3</span><span class="cl">|--BLOOM FILTER ON 顧客 (顧客編號=?)
</span></span><span class="line"><span class="ln">4</span><span class="cl">`--SEARCH 顧客 USING AUTOMATIC COVERING INDEX (顧客編號=?)
</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">-- EXPLAIN QUERY PLAN SELECT count(*) FROM 顧客 JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號;
</span></span><span class="line"><span class="ln">7</span><span class="cl">|--SCAN 顧客
</span></span><span class="line"><span class="ln">8</span><span class="cl">|--BLOOM FILTER ON 訂單 (顧客編號=?)
</span></span><span class="line"><span class="ln">9</span><span class="cl">`--SEARCH 訂單 USING AUTOMATIC COVERING INDEX (顧客編號=?)</span></span></code></pre></div><p>沒有統計就不知道哪張表大，於是它退回去按查詢文字裡兩張表出現的先後來決定計畫：<code>顧客 JOIN 訂單</code> 那種寫法掃五十列的顧客，為了查找而當場替二十萬列的訂單建一個索引。</p>
<p>跑一次 <code>ANALYZE</code> 讓引擎去數過兩張表之後，同樣兩段查詢得到同一個計畫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">|--SCAN 訂單
</span></span><span class="line"><span class="ln">2</span><span class="cl">|--BLOOM FILTER ON 顧客 (顧客編號=?)
</span></span><span class="line"><span class="ln">3</span><span class="cl">`--SEARCH 顧客 USING AUTOMATIC COVERING INDEX (顧客編號=?)</span></span></code></pre></div><p>兩次都選擇掃二十萬列那張、對五十列那張做查找，寫誰在前面沒有影響。</p>
<p>**書寫順序從來不是引擎的依據，它只是引擎在沒有別的統計資訊時的退路。**真正的依據是<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊</a>，而那份資訊不在查詢裡、也不在資料裡，它是引擎另外去數出來存著的資料。</p>
<p>計畫裡還有查詢文字完全沒提到的步驟。<code>AUTOMATIC COVERING INDEX</code> 是 SQLite 當場替顧客表建的<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>，<code>BLOOM FILTER</code> 是它加的過濾步驟。查詢裡沒有一個字要求這些。</p>
<p>「先讀的那張表」這個直覺因此對應不到任何固定的一張表——同一段查詢在剛建好、還沒統計過的資料庫上與跑過 <code>ANALYZE</code> 之後，先讀的表不同。文字上的先後只決定語意，不決定執行。它影響語意的位置在外連接：<code>LEFT JOIN</code> 會保留左邊那張表的每一列，配不到對象的也留下、另一側補上空值，所以一張表寫在左邊還是右邊，決定的是哪一張表的列一定會出現在結果裡。多張表相連時「左邊」已經不是一張表——<a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a> 寫那個累積過程，以及在鏈中間放 <code>RIGHT</code> 為什麼會把保護範圍縮到只剩一張表。</p>
<h2 id="求值順序擋下的寫法聚合函數放進-where">求值順序擋下的寫法：聚合函數放進 WHERE</h2>
<p>語意模型摸不著，而它會在引擎拒絕的地方現形：把聚合函數寫進 <code>WHERE</code>，SQLite 與 DuckDB 都拒絕，同一件事移到 <code>HAVING</code> 就通過。拿<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫</a>的訂單表找下過一張以上訂單的顧客：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite：misuse of aggregate: COUNT()
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- DuckDB：Binder Error: WHERE clause cannot contain aggregates!
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="p">,</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">張數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">HAVING</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 兩個引擎都回 (1, 2)：顧客編號 1 的佳穎，兩張訂單</span></span></span></code></pre></div><p><strong>兩則訊息都只說了不准，理由要從求值順序讀出來。</strong> <code>WHERE</code> 那一步分組還沒發生，沒有組就沒有組的計數，聚合函數拿不到它要用的那個值；<code>HAVING</code> 排在 <code>GROUP BY</code> 之後，那時每一組的計數已經算得出來。這條限制由語意模型規定，與寫法好不好看無關。</p>
<p>求值順序與執行順序不同的地方在於它是語意模型規定死的，換引擎不變，所以「每一步能用到哪些欄位與值」是一個有固定答案的問題。<a href="/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序</a> 逐步走過求值順序這一串步驟，並分開模型層擋得住的限制與各家引擎自己放寬的——後者包括一種不報錯而給出錯答案的形態。</p>
<h2 id="代價的驗證輸出與執行計畫各自回答的問題">代價的驗證：輸出與執行計畫各自回答的問題</h2>
<p>宣告式把「怎麼算」交給引擎決定，代價也跟著由引擎決定。查詢的文字裡沒有任何一處表示它會掃全表還是走索引、會不會臨時建一個索引、會不會把中間結果寫到磁碟——上面那段計畫證明了這一點，<code>SCAN</code>、<code>BLOOM FILTER</code> 與那個臨時索引，三者一個都不在查詢裡。</p>
<p>所以驗證分成兩件互相獨立的事。<strong>結果對不對</strong>看輸出，從查詢本身推得出來。<strong>代價可不可以接受</strong>看計畫，非得問引擎不可，而且同一段查詢在不同資料量、不同索引、不同統計資訊底下的答案不一樣。</p>
<p>開發用的小資料只驗得到結果對不對這一件：資料量小到任何寫法都夠快，所以查詢在開發環境通過，只代表描述得對；代價可不可以接受，要到正式的資料量上才量得出來。</p>
<p>跑 <code>ANALYZE</code> 前後計畫跟著改變的那一組對照，在真實系統上有它的故障形態：統計資訊過時、或者欄位之間的相關性沒有被統計到，最佳化器就拿著錯的資訊選計畫。<a href="/blog/backend/01-database/vendors/postgresql/query-optimization/" data-link-title="PostgreSQL Query Optimization：EXPLAIN、auto_explain、pg_hint_plan 與 planner 選錯 plan 的 production case" data-link-desc="PG query 慢的根因常是 *planner 選錯 plan 或 statistics 過時*。本文從 production case 開場（seq scan vs index / hash vs nested loop / 多 column 統計缺 / parallel query 沒觸發）、走 EXPLAIN / EXPLAIN ANALYZE / auto_explain、pg_hint_plan extension 跟 planner GUC 取捨、production 踩雷（ANALYZE 過時 / multi-column statistics / random_page_cost 沿用 HDD 預設 / effective_cache_size 不對齊 RAM / parallel query 配置）、跟 MySQL query-optimization sibling 對比">PostgreSQL Query Optimization</a> 收了這幾種讓計畫選錯的實際案例，以及讀懂 production 計畫要用的 <code>EXPLAIN</code>、<code>EXPLAIN ANALYZE</code>、<code>auto_explain</code> 三層工具。</p>
]]></content:encoded></item><item><title>Query Statistics（統計資訊）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-statistics/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-statistics/</guid><description>&lt;p>統計資訊是引擎另外去數出來、存在一旁的一份摘要：每張表大概多少列、某個欄位的值大概怎麼分布。它不在查詢的文字裡，也不在資料表裡——資料本身有多少列是一回事，引擎「知道」有多少列是另一回事，而 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer&lt;/a> 選 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">query plan&lt;/a> 時讀的是後者。&lt;/p>
&lt;h2 id="誰產生它什麼時候">誰產生它，什麼時候&lt;/h2>
&lt;p>多數引擎用一道明確的指令收集：SQLite 與 PostgreSQL 都是 &lt;code>ANALYZE&lt;/code>，PostgreSQL 另有 autovacuum 在背景定期重收。剛建好的資料庫沒有統計，剛大量寫入之後統計是舊的。&lt;/p>
&lt;p>沒有統計的時候引擎不會拒絕執行，它退回去用別的線索。SQLite 在這種情形下照查詢文字裡表出現的先後決定計畫——&lt;strong>書寫順序在這裡才有影響力，而那是引擎沒有別的資訊時的退路，不是它的依據&lt;/strong>。跑過 &lt;code>ANALYZE&lt;/code> 之後同樣兩種寫法得到同一個計畫。&lt;/p>
&lt;h2 id="它決定的是估計不是事實">它決定的是估計，不是事實&lt;/h2>
&lt;p>最佳化器比較的是估計代價，而估計來自這份摘要。所以判斷品質取決於統計有多新，與查詢寫得多好無關：&lt;/p>
&lt;ul>
&lt;li>查詢沒動而某天變慢，變的通常是統計而不是寫法——歸因的起點放在統計的新舊&lt;/li>
&lt;li>計畫上的估計列數與實際列數差很多，先懷疑統計，再懷疑寫法&lt;/li>
&lt;li>在小資料上量到的計畫套到 production 常常對不上，因為兩邊的統計不同&lt;/li>
&lt;/ul>
&lt;h2 id="它與索引是兩件事">它與索引是兩件事&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引&lt;/a>決定引擎有沒有一條快的存取路徑可以走，統計決定它知不知道該走那一條。索引建了而統計沒更新的時候，最佳化器可能估錯而不走它——兩者要一起看。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>統計資訊是「查詢的文字不足以決定執行方式」這件事的具體內容。查詢說要什麼結果，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer&lt;/a> 決定怎麼拿，而它決定的依據既不在查詢裡也不在資料裡——它在這一份另外存著的摘要上。&lt;/p>
&lt;p>這讓同一段 SQL 沒有固定的執行方式：資料沒變、索引沒變、查詢沒變，只要統計重收過一次，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">query plan&lt;/a> 就可能不同。所以「這段查詢的計畫是什麼」這個問題要連同資料庫的狀態一起問。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>有無統計的兩個計畫並排、以及書寫順序為什麼只是退路，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價&lt;/a>。統計、索引與資料分布三者各自決定代價的哪一部分，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>統計資訊是引擎另外去數出來、存在一旁的一份摘要：每張表大概多少列、某個欄位的值大概怎麼分布。它不在查詢的文字裡，也不在資料表裡——資料本身有多少列是一回事，引擎「知道」有多少列是另一回事，而 <a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer</a> 選 <a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">query plan</a> 時讀的是後者。</p>
<h2 id="誰產生它什麼時候">誰產生它，什麼時候</h2>
<p>多數引擎用一道明確的指令收集：SQLite 與 PostgreSQL 都是 <code>ANALYZE</code>，PostgreSQL 另有 autovacuum 在背景定期重收。剛建好的資料庫沒有統計，剛大量寫入之後統計是舊的。</p>
<p>沒有統計的時候引擎不會拒絕執行，它退回去用別的線索。SQLite 在這種情形下照查詢文字裡表出現的先後決定計畫——<strong>書寫順序在這裡才有影響力，而那是引擎沒有別的資訊時的退路，不是它的依據</strong>。跑過 <code>ANALYZE</code> 之後同樣兩種寫法得到同一個計畫。</p>
<h2 id="它決定的是估計不是事實">它決定的是估計，不是事實</h2>
<p>最佳化器比較的是估計代價，而估計來自這份摘要。所以判斷品質取決於統計有多新，與查詢寫得多好無關：</p>
<ul>
<li>查詢沒動而某天變慢，變的通常是統計而不是寫法——歸因的起點放在統計的新舊</li>
<li>計畫上的估計列數與實際列數差很多，先懷疑統計，再懷疑寫法</li>
<li>在小資料上量到的計畫套到 production 常常對不上，因為兩邊的統計不同</li>
</ul>
<h2 id="它與索引是兩件事">它與索引是兩件事</h2>
<p><a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>決定引擎有沒有一條快的存取路徑可以走，統計決定它知不知道該走那一條。索引建了而統計沒更新的時候，最佳化器可能估錯而不走它——兩者要一起看。</p>
<h2 id="概念位置">概念位置</h2>
<p>統計資訊是「查詢的文字不足以決定執行方式」這件事的具體內容。查詢說要什麼結果，<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer</a> 決定怎麼拿，而它決定的依據既不在查詢裡也不在資料裡——它在這一份另外存著的摘要上。</p>
<p>這讓同一段 SQL 沒有固定的執行方式：資料沒變、索引沒變、查詢沒變，只要統計重收過一次，<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">query plan</a> 就可能不同。所以「這段查詢的計畫是什麼」這個問題要連同資料庫的狀態一起問。</p>
<h2 id="往下走">往下走</h2>
<p>有無統計的兩個計畫並排、以及書寫順序為什麼只是退路，在 <a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價</a>。統計、索引與資料分布三者各自決定代價的哪一部分，在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>。</p>
]]></content:encoded></item><item><title>SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/</guid><description>&lt;p>一份 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a> 是集合，而集合的成員沒有先後。所以一段查詢算出來的那份關係本身不帶順序，回來的那一批列排成什麼樣，是&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>走到最後剛好留下的形狀。&lt;code>ORDER BY&lt;/code> 是把順序加回去的那個子句，也是唯一的一個——而它要寫在&lt;strong>最外層&lt;/strong>才算數。寫在衍生表或視圖裡面的 &lt;code>ORDER BY&lt;/code> 引擎可以丟掉：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- MySQL 8.4
&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="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">k&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">30&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">group_concat&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">k&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">d&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 1,2,3 主鍵序；衍生表裡的 ORDER BY k 被丟掉了
&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="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">k&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">d&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">k&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 2、3、1 ORDER BY 寫在最外層才照 k 排&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個語言用一套規則決定哪些列會出現在結果裡（那套規則叫&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semantic-model/" data-link-title="Semantic Model（語意模型）" data-link-desc="文章說「模型定死」「符合語意模型」時，查那套規則是什麼、由誰保證">語意模型&lt;/a>），而輸出的順序是它管得最少的一項：&lt;strong>規則只到「排序鍵分得出高下」為止，分不出高下的那些它沒有規定&lt;/strong>。&lt;/p>
&lt;p>本篇交代順序從哪裡來、&lt;code>ORDER BY&lt;/code> 看得到什麼、以及比不出大小的值落在哪一端。&lt;strong>&lt;code>ORDER BY&lt;/code> 沒有規定同分的列誰先誰後，這件事在什麼時候變成錯誤，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記&lt;/a>&lt;/strong>——整批看的時候同分的列換位置沒有後果，每一頁各查一次的時候它變成重複與遺漏；那一篇寫排序鍵要補到兩兩可分的判斷標準，以及把接續標記（cursor）從位置換成值的寫法。&lt;/p>
&lt;p>本篇範圍之外的兩件事各有專篇。哪些列會進到這份結果由條件與連接決定，順序管不到那一層：條件擺哪一邊決定留下哪些列在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a>，連接怎麼改變列數在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺&lt;/a>。視窗函數的 &lt;code>OVER (ORDER BY ...)&lt;/code> 是另一個順序，它決定計算時誰算相鄰、不決定輸出怎麼排，兩者可以不同，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/window-keeps-rows-grouping-collapses/" data-link-title="SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列" data-link-desc="GROUP BY 與視窗函數算完之後原本的列還在不在、依結果的一列代表什麼選用哪一個、視窗函數的值怎麼拿來當篩選條件，以及 LAG 取相鄰列時分區、排序鍵與距離條件各自要確認的事">SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列&lt;/a> 的〈排序相鄰與問題要的相鄰：分區、排序鍵與距離條件〉一節寫那一種。&lt;/p>
&lt;p>本篇的查詢跑在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫&lt;/a>的訂單表上，並把 101 的金額改成 700、再加三張，讓五張訂單裡有三張同樣是 500 元：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">UPDATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">700&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">101&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">103&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-03-10&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">104&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-03-11&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">105&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-03-12&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 101 是 700 元，102、103、105 是 500 元，104 是 300 元&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="沒有-order-by-時列序的來源">沒有 ORDER BY 時列序的來源&lt;/h2>
&lt;p>同一段查詢，同一批資料，中間只多建了一個索引：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite 3.51
&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="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 101,102,103,104,105 計畫：SCAN 訂單
&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="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ix&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 104,102,103,105,101 計畫：SEARCH 訂單 USING INDEX ix (金額&amp;gt;?)
&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">-- 索引照金額排：300 的 104、500 的三張（102、103、105）、700 的 101&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>查詢的文字一個字都沒改，回來的順序整個換掉了。沒有索引的那一次照資料存放的先後走，建了索引的那一次照索引上的先後走——&lt;strong>兩種都正確&lt;/strong>，因為那份關係本來就沒有規定過順序。&lt;/p></description><content:encoded><![CDATA[<p>一份 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 是集合，而集合的成員沒有先後。所以一段查詢算出來的那份關係本身不帶順序，回來的那一批列排成什麼樣，是<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</a>走到最後剛好留下的形狀。<code>ORDER BY</code> 是把順序加回去的那個子句，也是唯一的一個——而它要寫在<strong>最外層</strong>才算數。寫在衍生表或視圖裡面的 <code>ORDER BY</code> 引擎可以丟掉：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- MySQL 8.4
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="n">k</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">30</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="mi">20</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">group_concat</span><span class="p">(</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">k</span><span class="p">)</span><span class="w"> </span><span class="n">d</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 1,2,3    主鍵序；衍生表裡的 ORDER BY k 被丟掉了
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">k</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="n">d</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">k</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- 2、3、1  ORDER BY 寫在最外層才照 k 排</span></span></span></code></pre></div><p>這個語言用一套規則決定哪些列會出現在結果裡（那套規則叫<a href="/blog/backend/01-database/sql/knowledge-cards/semantic-model/" data-link-title="Semantic Model（語意模型）" data-link-desc="文章說「模型定死」「符合語意模型」時，查那套規則是什麼、由誰保證">語意模型</a>），而輸出的順序是它管得最少的一項：<strong>規則只到「排序鍵分得出高下」為止，分不出高下的那些它沒有規定</strong>。</p>
<p>本篇交代順序從哪裡來、<code>ORDER BY</code> 看得到什麼、以及比不出大小的值落在哪一端。<strong><code>ORDER BY</code> 沒有規定同分的列誰先誰後，這件事在什麼時候變成錯誤，在 <a href="/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記</a></strong>——整批看的時候同分的列換位置沒有後果，每一頁各查一次的時候它變成重複與遺漏；那一篇寫排序鍵要補到兩兩可分的判斷標準，以及把接續標記（cursor）從位置換成值的寫法。</p>
<p>本篇範圍之外的兩件事各有專篇。哪些列會進到這份結果由條件與連接決定，順序管不到那一層：條件擺哪一邊決定留下哪些列在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>，連接怎麼改變列數在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a>。視窗函數的 <code>OVER (ORDER BY ...)</code> 是另一個順序，它決定計算時誰算相鄰、不決定輸出怎麼排，兩者可以不同，<a href="/blog/backend/01-database/sql/window-keeps-rows-grouping-collapses/" data-link-title="SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列" data-link-desc="GROUP BY 與視窗函數算完之後原本的列還在不在、依結果的一列代表什麼選用哪一個、視窗函數的值怎麼拿來當篩選條件，以及 LAG 取相鄰列時分區、排序鍵與距離條件各自要確認的事">SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列</a> 的〈排序相鄰與問題要的相鄰：分區、排序鍵與距離條件〉一節寫那一種。</p>
<p>本篇的查詢跑在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫</a>的訂單表上，並把 101 的金額改成 700、再加三張，讓五張訂單裡有三張同樣是 500 元：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">700</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">101</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">500</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">104</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-11&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">300</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">                        </span><span class="p">(</span><span class="mi">105</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-12&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">500</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 101 是 700 元，102、103、105 是 500 元，104 是 300 元</span></span></span></code></pre></div><h2 id="沒有-order-by-時列序的來源">沒有 ORDER BY 時列序的來源</h2>
<p>同一段查詢，同一批資料，中間只多建了一個索引：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- SQLite 3.51
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">300</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 101,102,103,104,105    計畫：SCAN 訂單
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">ix</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">(</span><span class="err">金額</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">300</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- 104,102,103,105,101    計畫：SEARCH 訂單 USING INDEX ix (金額&gt;?)
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">--   索引照金額排：300 的 104、500 的三張（102、103、105）、700 的 101</span></span></span></code></pre></div><p>查詢的文字一個字都沒改，回來的順序整個換掉了。沒有索引的那一次照資料存放的先後走，建了索引的那一次照索引上的先後走——<strong>兩種都正確</strong>，因為那份關係本來就沒有規定過順序。</p>
<p>這件事在開發時多半看不見。一張小表、一種計畫，看到的順序穩定得像是有保證，而讓它變動的條件（建了索引、資料長大、統計更新、換一家引擎）沒有一項在查詢的文字裡。</p>
<h2 id="order-by-在求值順序裡的位置與它用得到的別名">ORDER BY 在求值順序裡的位置與它用得到的別名</h2>
<p><code>ORDER BY</code> 排的是 <code>SELECT</code> 已經算完的那份結果，所以 <code>SELECT</code> 裡取的別名它用得到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">兩倍</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">兩倍</span><span class="w"> </span><span class="k">DESC</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 三家都接受：1400、1000、1000、1000、600
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">兩倍</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">兩倍</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1000</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL：column &#34;兩倍&#34; does not exist
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- MySQL：ERROR 1054 Unknown column &#39;兩倍&#39; in &#39;where clause&#39;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">-- SQLite：收下，回 1400</span></span></span></code></pre></div><p><code>WHERE</code> 那一步發生在算出 <code>兩倍</code> 之前，所以那個名字在那裡還不存在。SQLite 收下同一段，這是它的寬鬆度而不是標準行為（求值順序的完整推導與這條寬鬆度的其他實例在 <a href="/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法</a>）。</p>
<h2 id="null-在排序結果裡的位置與各家引擎的預設">NULL 在排序結果裡的位置與各家引擎的預設</h2>
<p>排序鍵含<a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">空值</a>的時候，<code>NULL</code> 與任何值都比不出大小，所以它落在哪一端由引擎自己規定。會同時是排序鍵又可能為空的欄位不少——選填的折扣金額、還沒完成的那些列的完成時間，都是拿來排序的常見對象。下面的表格與查詢把 103 的金額清成 <code>NULL</code>、刪掉 105，留下四列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">103</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">105</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 101 是 700、102 是 500、103 是 NULL、104 是 300</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>引擎</th>
          <th><code>ORDER BY 金額</code></th>
          <th><code>ORDER BY 金額 DESC</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SQLite 3.51</td>
          <td>NULL 排最前</td>
          <td>NULL 排最後</td>
      </tr>
      <tr>
          <td>MySQL 8.4</td>
          <td>NULL 排最前</td>
          <td>NULL 排最後</td>
      </tr>
      <tr>
          <td>PostgreSQL 18</td>
          <td>NULL 排最後</td>
          <td>NULL 排最前</td>
      </tr>
      <tr>
          <td>DuckDB v0.10.3</td>
          <td>NULL 排最後</td>
          <td>NULL 排最後</td>
      </tr>
  </tbody>
</table>
<p>三家的規定各自自洽：SQLite 與 MySQL 把 <code>NULL</code> 當成比任何值都小，PostgreSQL 當成比任何值都大，DuckDB v0.10.3 則不論升冪降冪都把 <code>NULL</code> 排在最後——它的預設是 <code>NULLS LAST</code>，不隨 <code>DESC</code> 翻轉。要跨引擎一致就得寫出來，而寫法本身也分兩家：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="n">NULLS</span><span class="w"> </span><span class="k">LAST</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL 18、SQLite 3.51、DuckDB v0.10.3：104,102,101,103
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- MySQL 8.4：ERROR 1064 語法錯誤
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 先排一個布林值：金額是 NULL 的列 (金額 IS NULL) 為真，排在為假的列之後
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="p">(</span><span class="err">金額</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">),</span><span class="w"> </span><span class="err">金額</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL 18、SQLite 3.51、MySQL 8.4、DuckDB v0.10.3：104,102,101,103</span></span></span></code></pre></div><p><code>NULL</code> 是比不出大小的那一種值；字串是比得出、而比法由另一條規則決定的那一種。排序鍵換成姓名時，大小寫算不算相同、重音字母排在哪裡，各家預設不同，同一批名字排出來的順序也不同——<a href="/blog/backend/01-database/sql/string-comparison-and-collation/" data-link-title="SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性" data-link-desc="同一段等值條件在各家回不同的列的原因、LIKE 與等號何時分岔、索引與條件的比較規則怎麼對上，以及規則該寫在哪一層">SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性</a> 用同一批名字在三家上排出三種順序。</p>
]]></content:encoded></item><item><title>Sargable（可走索引的條件形狀）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/</guid><description>&lt;p>Sargable 指一個條件能不能被翻譯成&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引&lt;/a>上的一次查找，名字來自 search argument able——這個條件當不當得成搜尋的引數。同一個問題的兩種寫法回同一批列，而其中一種讓引擎在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>裡走查找、另一種讓它掃過整張表。&lt;/p>
&lt;h2 id="引擎要先有一個具體的值可以比對">引擎要先有一個具體的值可以比對&lt;/h2>
&lt;p>索引存的是欄位的值與它們對應的列的位置，所以查找要拿一個值進去比對。條件寫成 &lt;code>下單日 = '2024-03-01'&lt;/code> 時那個值就在條件裡。&lt;/p>
&lt;p>條件寫成 &lt;code>date(下單日) = '2024-03-01'&lt;/code> 時，索引存著 &lt;code>下單日&lt;/code> 的值而條件問的是 &lt;code>date()&lt;/code> 算完的結果，兩者對不起來。引擎要把每一列的 &lt;code>下單日&lt;/code> 取出來各算一次才知道答不答得上，而那就是掃過整張表。同一張表、同一個索引，五萬列：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">-- SQLite 3.51，訂單表五萬列（訂單編號 / 顧客編號 / 下單日 / 金額）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">-- CREATE INDEX ix_日 ON 訂單(下單日)，查的是 SELECT *
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">下單日 = &amp;#39;2024-03-01&amp;#39; SEARCH 訂單 USING INDEX ix_日 (下單日=?)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">date(下單日) = &amp;#39;2024-03-01&amp;#39; SCAN 訂單&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="這個形狀能排除確認要看計畫">這個形狀能排除，確認要看計畫&lt;/h2>
&lt;p>索引欄位被包進函式或運算裡的時候，那個索引走不了&lt;strong>查找&lt;/strong>（引擎仍可能把它整個掃過去當成一份較窄的資料來源，計畫上是 &lt;code>SCAN ... USING COVERING INDEX&lt;/code>），這一步照字面就判得出來：問那個欄位在條件裡是裸著的還是被包住的。上面那兩行取的是 &lt;code>SELECT *&lt;/code>，索引蓋不住它才印成 &lt;code>SCAN 訂單&lt;/code>；同一組條件換成只取索引欄與主鍵，兩行都會多出 &lt;code>COVERING&lt;/code>。&lt;strong>除非另外對同一個運算式建了索引&lt;/strong>——那時走的是另一個索引，對運算式本身建索引那一條路寫的就是它。&lt;/p>
&lt;p>裸著則不保證走得了。同一張表上有幾種裸欄位的條件照樣掃全表：開頭是萬用字元的比對（&lt;code>LIKE '%03-01'&lt;/code>）、&lt;code>OR&lt;/code> 接上另一個沒有索引的欄位、以及 &lt;code>LIKE '2024-03%'&lt;/code> 這種前綴比對。最後一種有兩條出路：SQLite 打開 &lt;code>case_sensitive_like&lt;/code> 之後它自己就走得了，或者手動拆成 &lt;code>&amp;gt;=&lt;/code> 與 &lt;code>&amp;lt;&lt;/code> 的範圍。實測五萬列上，原本的 &lt;code>LIKE '2024-03%'&lt;/code> 掃全表而拆成範圍之後走索引查找，兩者回同一批列。&lt;/p>
&lt;p>所以形狀是排除法的一步，答案仍然要向引擎要計畫。&lt;strong>要不到計畫的時候，看條件的形狀是僅剩能做的那一步&lt;/strong>——它只看條件的文字，不需要連線、不需要看資料，也不需要建立任何物件的權限（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限&lt;/a>）。&lt;/p>
&lt;p>被包住與本來就裸著這兩種命中，能動的位置不同。&lt;strong>被包住的&lt;/strong>（函式包住、運算搬得動）改寫條件本身就能讓它走索引，下一節那三條解決的都是同一件事——把索引欄位還原成裸的，或者換一個索引來配合它。三條的代價並不相同：拆成範圍要讀的人自己拼邊界，運算搬邊與運算式索引不動原本的讀法，而運算式索引把代價換到寫入端（哪一條先試在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形&lt;/a>）。&lt;/p>
&lt;p>&lt;strong>本來就裸著&lt;/strong>而仍然掃全表的，差別在那個條件描不描得出索引上的一段連續區間。前綴比對描得出，所以它換成範圍之後走得了；萬用字元開頭要比對的值散在索引的各處，&lt;code>OR&lt;/code> 接無索引欄則有一半根本不在索引裡。這兩種改寫條件本身幫不上忙，剩下的變數在統計與選擇性上。&lt;/p>
&lt;h2 id="同一個問題換一個形狀">同一個問題換一個形狀&lt;/h2>
&lt;p>&lt;code>date()&lt;/code> 那個條件要的是某一天，而同一件事可以用範圍表達，兩端都是具體的值：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">下單日 &amp;gt;= &amp;#39;2024-03-01&amp;#39; AND 下單日 &amp;lt; &amp;#39;2024-04-01&amp;#39;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> SEARCH 訂單 USING INDEX ix_日 (下單日&amp;gt;? AND 下單日&amp;lt;?)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把運算搬到不含索引欄位的那一邊也成立：&lt;code>下單日 = date('2024-03-15','-14 day')&lt;/code> 的右邊先算成一個值，左邊仍然裸著，計畫走查找。&lt;/p>
&lt;p>條件本身改不動的時候，還有一個方向是讓索引去配合條件——對運算式本身建索引。&lt;code>CREATE INDEX ix_月 ON 訂單(substr(下單日,1,7))&lt;/code> 之後，原本掃全表的 &lt;code>substr(下單日,1,7) = '2024-03'&lt;/code> 變成 &lt;code>SEARCH 訂單 USING INDEX ix_月 (&amp;lt;expr&amp;gt;=?)&lt;/code>。它的代價同樣落在寫入端，而且比一般索引多一項：每次寫入要多算一次那個運算式。&lt;/p>
&lt;h2 id="與統計資訊是兩個成因">與統計資訊是兩個成因&lt;/h2>
&lt;p>索引建了而引擎不走它有兩個常見的成因，處置不同。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊&lt;/a>過舊時引擎估錯代價、判斷它不划算，那時條件的形狀本來就走得了，重收統計就會改；條件把欄位包住時引擎沒有路可走，統計再新也一樣，那時要改的是查詢。&lt;/p>
&lt;p>兩者分得開，是因為形狀決定引擎有沒有路可走、統計只決定它想不想走。把條件換成裸欄位的形狀之後計畫跟著變，代表原本擋住的是路；計畫沒變，代表路一直都在而引擎判斷它不划算。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響&lt;/a> 的結論是代價由資料與索引決定、不由寫法決定，而這張卡是那個結論有界的例外。邊界很窄：寫法在這裡決定的是引擎&lt;strong>能不能用&lt;/strong>那個&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引&lt;/a>，而&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&lt;/a>在幾條可走的路裡挑哪一條仍然由資料與統計決定。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>索引本身是什麼、代價為什麼落在寫入端，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">Index（索引）&lt;/a>。&lt;code>SCAN&lt;/code> 與 &lt;code>SEARCH&lt;/code> 這些字怎麼讀，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">Query Plan（執行計畫）&lt;/a>。同一段查詢的排名隨索引與資料分布重排的實測，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價&lt;/a>。好讀的寫法與走得了索引的寫法分岔時該動哪一層，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Sargable 指一個條件能不能被翻譯成<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>上的一次查找，名字來自 search argument able——這個條件當不當得成搜尋的引數。同一個問題的兩種寫法回同一批列，而其中一種讓引擎在<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</a>裡走查找、另一種讓它掃過整張表。</p>
<h2 id="引擎要先有一個具體的值可以比對">引擎要先有一個具體的值可以比對</h2>
<p>索引存的是欄位的值與它們對應的列的位置，所以查找要拿一個值進去比對。條件寫成 <code>下單日 = '2024-03-01'</code> 時那個值就在條件裡。</p>
<p>條件寫成 <code>date(下單日) = '2024-03-01'</code> 時，索引存著 <code>下單日</code> 的值而條件問的是 <code>date()</code> 算完的結果，兩者對不起來。引擎要把每一列的 <code>下單日</code> 取出來各算一次才知道答不答得上，而那就是掃過整張表。同一張表、同一個索引，五萬列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- SQLite 3.51，訂單表五萬列（訂單編號 / 顧客編號 / 下單日 / 金額）
</span></span><span class="line"><span class="ln">2</span><span class="cl">-- CREATE INDEX ix_日 ON 訂單(下單日)，查的是 SELECT *
</span></span><span class="line"><span class="ln">3</span><span class="cl">下單日 = &#39;2024-03-01&#39;          SEARCH 訂單 USING INDEX ix_日 (下單日=?)
</span></span><span class="line"><span class="ln">4</span><span class="cl">date(下單日) = &#39;2024-03-01&#39;    SCAN 訂單</span></span></code></pre></div><h2 id="這個形狀能排除確認要看計畫">這個形狀能排除，確認要看計畫</h2>
<p>索引欄位被包進函式或運算裡的時候，那個索引走不了<strong>查找</strong>（引擎仍可能把它整個掃過去當成一份較窄的資料來源，計畫上是 <code>SCAN ... USING COVERING INDEX</code>），這一步照字面就判得出來：問那個欄位在條件裡是裸著的還是被包住的。上面那兩行取的是 <code>SELECT *</code>，索引蓋不住它才印成 <code>SCAN 訂單</code>；同一組條件換成只取索引欄與主鍵，兩行都會多出 <code>COVERING</code>。<strong>除非另外對同一個運算式建了索引</strong>——那時走的是另一個索引，對運算式本身建索引那一條路寫的就是它。</p>
<p>裸著則不保證走得了。同一張表上有幾種裸欄位的條件照樣掃全表：開頭是萬用字元的比對（<code>LIKE '%03-01'</code>）、<code>OR</code> 接上另一個沒有索引的欄位、以及 <code>LIKE '2024-03%'</code> 這種前綴比對。最後一種有兩條出路：SQLite 打開 <code>case_sensitive_like</code> 之後它自己就走得了，或者手動拆成 <code>&gt;=</code> 與 <code>&lt;</code> 的範圍。實測五萬列上，原本的 <code>LIKE '2024-03%'</code> 掃全表而拆成範圍之後走索引查找，兩者回同一批列。</p>
<p>所以形狀是排除法的一步，答案仍然要向引擎要計畫。<strong>要不到計畫的時候，看條件的形狀是僅剩能做的那一步</strong>——它只看條件的文字，不需要連線、不需要看資料，也不需要建立任何物件的權限（<a href="/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限</a>）。</p>
<p>被包住與本來就裸著這兩種命中，能動的位置不同。<strong>被包住的</strong>（函式包住、運算搬得動）改寫條件本身就能讓它走索引，下一節那三條解決的都是同一件事——把索引欄位還原成裸的，或者換一個索引來配合它。三條的代價並不相同：拆成範圍要讀的人自己拼邊界，運算搬邊與運算式索引不動原本的讀法，而運算式索引把代價換到寫入端（哪一條先試在 <a href="/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形</a>）。</p>
<p><strong>本來就裸著</strong>而仍然掃全表的，差別在那個條件描不描得出索引上的一段連續區間。前綴比對描得出，所以它換成範圍之後走得了；萬用字元開頭要比對的值散在索引的各處，<code>OR</code> 接無索引欄則有一半根本不在索引裡。這兩種改寫條件本身幫不上忙，剩下的變數在統計與選擇性上。</p>
<h2 id="同一個問題換一個形狀">同一個問題換一個形狀</h2>
<p><code>date()</code> 那個條件要的是某一天，而同一件事可以用範圍表達，兩端都是具體的值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">下單日 &gt;= &#39;2024-03-01&#39; AND 下單日 &lt; &#39;2024-04-01&#39;
</span></span><span class="line"><span class="ln">2</span><span class="cl">    SEARCH 訂單 USING INDEX ix_日 (下單日&gt;? AND 下單日&lt;?)</span></span></code></pre></div><p>把運算搬到不含索引欄位的那一邊也成立：<code>下單日 = date('2024-03-15','-14 day')</code> 的右邊先算成一個值，左邊仍然裸著，計畫走查找。</p>
<p>條件本身改不動的時候，還有一個方向是讓索引去配合條件——對運算式本身建索引。<code>CREATE INDEX ix_月 ON 訂單(substr(下單日,1,7))</code> 之後，原本掃全表的 <code>substr(下單日,1,7) = '2024-03'</code> 變成 <code>SEARCH 訂單 USING INDEX ix_月 (&lt;expr&gt;=?)</code>。它的代價同樣落在寫入端，而且比一般索引多一項：每次寫入要多算一次那個運算式。</p>
<h2 id="與統計資訊是兩個成因">與統計資訊是兩個成因</h2>
<p>索引建了而引擎不走它有兩個常見的成因，處置不同。<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊</a>過舊時引擎估錯代價、判斷它不划算，那時條件的形狀本來就走得了，重收統計就會改；條件把欄位包住時引擎沒有路可走，統計再新也一樣，那時要改的是查詢。</p>
<p>兩者分得開，是因為形狀決定引擎有沒有路可走、統計只決定它想不想走。把條件換成裸欄位的形狀之後計畫跟著變，代表原本擋住的是路；計畫沒變，代表路一直都在而引擎判斷它不划算。</p>
<h2 id="概念位置">概念位置</h2>
<p><a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a> 的結論是代價由資料與索引決定、不由寫法決定，而這張卡是那個結論有界的例外。邊界很窄：寫法在這裡決定的是引擎<strong>能不能用</strong>那個<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>，而<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>在幾條可走的路裡挑哪一條仍然由資料與統計決定。</p>
<h2 id="往下走">往下走</h2>
<p>索引本身是什麼、代價為什麼落在寫入端，在 <a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">Index（索引）</a>。<code>SCAN</code> 與 <code>SEARCH</code> 這些字怎麼讀，在 <a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">Query Plan（執行計畫）</a>。同一段查詢的排名隨索引與資料分布重排的實測，在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>。好讀的寫法與走得了索引的寫法分岔時該動哪一層，在 <a href="/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形</a>。</p>
]]></content:encoded></item><item><title>SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/</guid><description>&lt;p>同一段 SQL 的代價由資料分布與可用的索引決定，不由寫法決定。同一組寫法的快慢排名會隨著索引改變而對調，而分組、自連接與 &lt;code>EXISTS&lt;/code> 三段查詢的文字一個字都沒有動。&lt;/p>
&lt;h2 id="找重複-email-的分組自連接與-exists-寫法">找重複 email 的分組、自連接與 EXISTS 寫法&lt;/h2>
&lt;p>一張 &lt;code>Person&lt;/code> 表，欄位是 &lt;code>id&lt;/code> 與 &lt;code>email&lt;/code>，要找出重複出現的 email。&lt;/p>
&lt;p>&lt;strong>用分組。&lt;/strong> 把列按 email 收成組，問這一組有幾列：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">HAVING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>用自連接。&lt;/strong> 把表配對，找出 email 相同而 id 不同的兩列（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接&lt;/a>）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DISTINCT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p2&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p2&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>用 &lt;code>EXISTS&lt;/code>。&lt;/strong> 對每一列問「有沒有另一列 email 跟我一樣」（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理&lt;/a>）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DISTINCT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="有無索引時分組自連接與-exists-的實測時間與計畫">有無索引時分組、自連接與 EXISTS 的實測時間與計畫&lt;/h2>
&lt;p>分組、自連接、&lt;code>EXISTS&lt;/code> 三種寫法都給出正確答案。以下的時間都在 SQLite 3.51 上量，資料庫沒有跑過 &lt;code>ANALYZE&lt;/code>，同一句各跑二十次取最小值。計時器的解析度是一毫秒，量到的時間落在它以下時，下面表格對應的那一格只寫得出「不到 1」。&lt;/p>
&lt;p>&lt;strong>這一組數字的形狀依賴引擎的存取路徑&lt;/strong>：SQLite 逐列處理、相關子查詢編成逐列的巢狀執行，所以加一個索引能把下面表格裡 &lt;code>EXISTS&lt;/code> 那一格代表的每次查找成本整個換掉。DuckDB 是向量化的引擎——它一次處理一批列而不是一列一列走——處理同一組查詢的方式與 SQLite 不同，三種寫法在它上面的差距形狀也不同。要看的是「代價由資料與可用的存取路徑決定」這個結論，不是下面表格列出的這幾個絕對值。&lt;/p>
&lt;p>資料是這樣造的：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">RECURSIVE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ALL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">4999&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;u&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;@x.com&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">RECURSIVE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ALL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1999&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">100000&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;hot@x.com&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 有索引的那一欄另外跑：CREATE INDEX ix ON Person(email);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>七千列，其中一個 email 重複兩千次。&lt;strong>同一段查詢在別的機器或別的版本上絕對值會不同，要看的是同一欄之內的相對關係。&lt;/strong>&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>寫法&lt;/th>
 &lt;th>&lt;code>email&lt;/code> 沒有索引&lt;/th>
 &lt;th>&lt;code>email&lt;/code> 有索引&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>分組&lt;/td>
 &lt;td>1 ms&lt;/td>
 &lt;td>不到 1 ms&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自連接&lt;/td>
 &lt;td>217 ms&lt;/td>
 &lt;td>186 ms&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>EXISTS&lt;/code>&lt;/td>
 &lt;td>1378 ms&lt;/td>
 &lt;td>1 ms&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>沒有索引時最慢的是 &lt;code>EXISTS&lt;/code>，有索引時它掉到與分組同一個量級——同一段查詢差了三個量級。分組與自連接兩欄之間沒有這種變化：分組本來就貼著計時器的解析度，加了索引之後量不出來；自連接快了百分之十幾（217 對 186 毫秒）。這個差距小到要先排除雜訊，所以另外重建資料重跑了五輪，每一輪照樣各跑二十次取最小值：五輪都是有索引的那一欄較快，而幅度都遠不到一個量級。&lt;strong>三段查詢的文字一個字都沒有改。&lt;/strong>&lt;/p></description><content:encoded><![CDATA[<p>同一段 SQL 的代價由資料分布與可用的索引決定，不由寫法決定。同一組寫法的快慢排名會隨著索引改變而對調，而分組、自連接與 <code>EXISTS</code> 三段查詢的文字一個字都沒有動。</p>
<h2 id="找重複-email-的分組自連接與-exists-寫法">找重複 email 的分組、自連接與 EXISTS 寫法</h2>
<p>一張 <code>Person</code> 表，欄位是 <code>id</code> 與 <code>email</code>，要找出重複出現的 email。</p>
<p><strong>用分組。</strong> 把列按 email 收成組，問這一組有幾列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span></span></span></code></pre></div><p><strong>用自連接。</strong> 把表配對，找出 email 相同而 id 不同的兩列（<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">p1</span><span class="p">.</span><span class="n">email</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="n">p1</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="n">p2</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">p1</span><span class="p">.</span><span class="n">email</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">p2</span><span class="p">.</span><span class="n">email</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">p1</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="n">p2</span><span class="p">.</span><span class="n">id</span><span class="p">;</span></span></span></code></pre></div><p><strong>用 <code>EXISTS</code>。</strong> 對每一列問「有沒有另一列 email 跟我一樣」（<a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="n">p</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="n">q</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">q</span><span class="p">.</span><span class="n">email</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">email</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">q</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">id</span><span class="p">);</span></span></span></code></pre></div><h2 id="有無索引時分組自連接與-exists-的實測時間與計畫">有無索引時分組、自連接與 EXISTS 的實測時間與計畫</h2>
<p>分組、自連接、<code>EXISTS</code> 三種寫法都給出正確答案。以下的時間都在 SQLite 3.51 上量，資料庫沒有跑過 <code>ANALYZE</code>，同一句各跑二十次取最小值。計時器的解析度是一毫秒，量到的時間落在它以下時，下面表格對應的那一格只寫得出「不到 1」。</p>
<p><strong>這一組數字的形狀依賴引擎的存取路徑</strong>：SQLite 逐列處理、相關子查詢編成逐列的巢狀執行，所以加一個索引能把下面表格裡 <code>EXISTS</code> 那一格代表的每次查找成本整個換掉。DuckDB 是向量化的引擎——它一次處理一批列而不是一列一列走——處理同一組查詢的方式與 SQLite 不同，三種寫法在它上面的差距形狀也不同。要看的是「代價由資料與可用的存取路徑決定」這個結論，不是下面表格列出的這幾個絕對值。</p>
<p>資料是這樣造的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">4999</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;u&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;@x.com&#39;</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">1999</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="mi">100000</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;hot@x.com&#39;</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="w"></span><span class="c1">-- 有索引的那一欄另外跑：CREATE INDEX ix ON Person(email);</span></span></span></code></pre></div><p>七千列，其中一個 email 重複兩千次。<strong>同一段查詢在別的機器或別的版本上絕對值會不同，要看的是同一欄之內的相對關係。</strong></p>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th><code>email</code> 沒有索引</th>
          <th><code>email</code> 有索引</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>分組</td>
          <td>1 ms</td>
          <td>不到 1 ms</td>
      </tr>
      <tr>
          <td>自連接</td>
          <td>217 ms</td>
          <td>186 ms</td>
      </tr>
      <tr>
          <td><code>EXISTS</code></td>
          <td>1378 ms</td>
          <td>1 ms</td>
      </tr>
  </tbody>
</table>
<p>沒有索引時最慢的是 <code>EXISTS</code>，有索引時它掉到與分組同一個量級——同一段查詢差了三個量級。分組與自連接兩欄之間沒有這種變化：分組本來就貼著計時器的解析度，加了索引之後量不出來；自連接快了百分之十幾（217 對 186 毫秒）。這個差距小到要先排除雜訊，所以另外重建資料重跑了五輪，每一輪照樣各跑二十次取最小值：五輪都是有索引的那一欄較快，而幅度都遠不到一個量級。<strong>三段查詢的文字一個字都沒有改。</strong></p>
<p>計畫說明了 <code>EXISTS</code> 那一格從沒有索引到有索引的時間變化。沒有索引時：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">|--SCAN p
</span></span><span class="line"><span class="ln">2</span><span class="cl">|--CORRELATED SCALAR SUBQUERY 1
</span></span><span class="line"><span class="ln">3</span><span class="cl">|  `--SCAN q
</span></span><span class="line"><span class="ln">4</span><span class="cl">`--USE TEMP B-TREE FOR DISTINCT</span></span></code></pre></div><p><code>CORRELATED</code> 表示那個子查詢引用了外層的欄位，所以它對外層的每一列各執行一次，而每一次都是 <code>SCAN q</code>——掃過整張表。外層那七千列，每一列都讓內層把整張七千列的表掃過一遍。</p>
<p>有索引之後同一段查詢：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">|--SCAN p USING COVERING INDEX ix
</span></span><span class="line"><span class="ln">2</span><span class="cl">`--CORRELATED SCALAR SUBQUERY 1
</span></span><span class="line"><span class="ln">3</span><span class="cl">   `--SEARCH q USING COVERING INDEX ix (email=?)</span></span></code></pre></div><p>子查詢仍然逐列執行，而每一次從 <code>SCAN</code>（掃全表）變成 <code>SEARCH</code>（透過<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>定位）。逐列執行這個結構沒變，變的是每一次的單價。<code>COVERING</code> 的意思寫在 <a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">Query Plan（執行計畫）</a> 那張卡；索引本身的代價落在寫入端，這一點寫在 <a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">Index（索引）</a> 那張卡。</p>
<h2 id="重複次數改變時分組與自連接的實測">重複次數改變時分組與自連接的實測</h2>
<p>另一組量測換一個變數：五萬列資料，其中一個 email 的重複次數從十次加到兩千次，都沒有索引。三份資料各自這樣造（<code>k</code> 依序是 10、200、2000）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">50000</span><span class="o">-</span><span class="n">k</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;u&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;@x.com&#39;</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">k</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1000000</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;hot@x.com&#39;</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>重複次數</th>
          <th>配對數</th>
          <th>分組</th>
          <th>自連接</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>10</td>
          <td>90</td>
          <td>8 ms</td>
          <td>30 ms</td>
      </tr>
      <tr>
          <td>200</td>
          <td>39,800</td>
          <td>8 ms</td>
          <td>32 ms</td>
      </tr>
      <tr>
          <td>2000</td>
          <td>3,998,000</td>
          <td>8 ms</td>
          <td>232 ms</td>
      </tr>
  </tbody>
</table>
<p>分組完全不動。自連接則在重複次數兩千那一列漲了七倍多。</p>
<p><strong>要看的是配對數那一欄。</strong> 自連接把每一對重複的 email 實際配出來再靠 <code>DISTINCT</code> 收掉，而配對數是 n(n−1)，隨 n 平方成長——重複次數翻十倍，配對數漲的是一百倍。自連接那一欄的時間沒有跟著漲一百倍，是因為五萬列的掃描本身有一筆固定成本，重複次數還小的時候那筆成本蓋過了配對；等配對數大到四百萬，它才浮上檯面。</p>
<p>分組那一欄不管重複多少次都是同一個值，因為它從頭到尾沒有展開任何配對。<strong>分組與自連接在這裡的差別是結構上的，不是調校問題。</strong></p>
<h2 id="寫法影響代價的邊界條件把索引欄包進函式">寫法影響代價的邊界：條件把索引欄包進函式</h2>
<p>本篇的結論有一個邊界，出現在條件把索引欄包進函式的時候。資料沿用〈有無索引時分組、自連接與 EXISTS 的實測時間與計畫〉那一節有索引的七千列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">EXPLAIN</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">lower</span><span class="p">(</span><span class="n">email</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;hot@x.com&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- `--SCAN Person
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- 索引 ix 存的是 email 本身，條件問的是 lower() 算完的結果，
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 引擎在索引上找不到那個值，只能掃全表、逐列算完再比
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">EXPLAIN</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Person</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;hot@x.com&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- `--SEARCH Person USING COVERING INDEX ix (email=?)
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- 條件直接比 email，引擎在索引上查找</span></span></span></code></pre></div><p>兩個條件只在 email 一律以小寫存放時回同一批列——這份資料是，兩段都回兩千列；<code>lower()</code> 那一段另外會配到大小寫寫法不同的 email。這時寫法決定的是引擎<strong>能不能用</strong>那個索引，而不是它在幾條可行的存取路徑裡挑哪一條——所以它是本篇結論的前提：哪幾條存取路徑可行由條件的形狀決定，挑哪一條才由資料與索引決定。<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable（可走索引的條件形狀）</a> 給判斷一個條件能不能走索引的標準，以及讓條件走得了索引的改寫方向：把函數條件改寫成範圍（<code>date(下單日) = X</code> 寫成 <code>下單日 &gt;= X AND 下單日 &lt; 次日</code>）、把運算從索引欄移到比較式的另一個運算元上，或者對運算式本身建索引。</p>
<h2 id="比較兩段查詢的快慢要補上的條件與向引擎要計畫的做法">比較兩段查詢的快慢：要補上的條件與向引擎要計畫的做法</h2>
<p>補上四項再問：<strong>資料有多大、分布長什麼樣、有哪些索引、引擎有沒有<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊</a>。</strong> 最後一項是 <a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價</a> 實測過的——同一段查詢在跑過 <code>ANALYZE</code> 之前與之後拿到不同的計畫；那一篇也從 SQL 這個語言的性質推了一次，代價為什麼一開始就落在查詢文字之外。</p>
<p>資料量決定像自連接那筆固定掃描成本這種常數項會不會被放大。分布決定會不會踩到某個寫法的最壞情況——上面的自連接就是被重複程度打敗的。索引決定每一次查找的單價，而加索引前後的那組量測裡，索引的有無讓同一段查詢差了三個量級。</p>
<p>資料量、分布與索引都是資料庫當下的狀態，查詢的文字裡讀不到，所以「哪種寫法比較快」是一個要向引擎問的問題，不是比較兩段文字就答得出來的問題。而且要問兩次：一次拿到現在這個狀態下的計畫，一次改動其中一項（加索引、換資料量）之後再拿一次，看它變不變。問一次只拿得到一個狀態下的答案，而加索引前後的那張表證明狀態換了排名就換，重複次數那張表證明分組與自連接的差距會隨資料的分布放大。各家的問法不同，SQLite 是 <code>EXPLAIN QUERY PLAN</code>，PostgreSQL 是 <code>EXPLAIN</code>。本篇的計畫只有三四行，真實系統的計畫有巢狀節點與估計列數，<a href="/blog/backend/01-database/vendors/postgresql/query-optimization/" data-link-title="PostgreSQL Query Optimization：EXPLAIN、auto_explain、pg_hint_plan 與 planner 選錯 plan 的 production case" data-link-desc="PG query 慢的根因常是 *planner 選錯 plan 或 statistics 過時*。本文從 production case 開場（seq scan vs index / hash vs nested loop / 多 column 統計缺 / parallel query 沒觸發）、走 EXPLAIN / EXPLAIN ANALYZE / auto_explain、pg_hint_plan extension 跟 planner GUC 取捨、production 踩雷（ANALYZE 過時 / multi-column statistics / random_page_cost 沿用 HDD 預設 / effective_cache_size 不對齊 RAM / parallel query 配置）、跟 MySQL query-optimization sibling 對比">PostgreSQL Query Optimization</a> 給<code>EXPLAIN</code>、<code>EXPLAIN ANALYZE</code>、<code>auto_explain</code> 三層工具的分工，以及四個計畫選錯的 production case：該走索引而走了全表掃描、該用 hash join 而用了 nested loop、缺少多欄位統計而估錯列數、該平行執行而沒有。</p>
<p>代價既然由資料與索引決定，查詢的文字就可以先為讀它的人而寫。<a href="/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形</a> 並排了三組——寫法差異免費的、條件形狀讓兩者分岔的、以及拆開之後在四萬列上快三十多倍的——並給出分岔時該動查詢還是動 schema 的判斷標準。</p>
]]></content:encoded></item></channel></rss>