<?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>SQL 知識卡 on Tarragon</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/</link><description>Recent content in SQL 知識卡 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 31 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/index.xml" rel="self" type="application/rss+xml"/><item><title>Relation（關係）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/</guid><description>&lt;p>Relation 是一組欄位固定、由若干列構成的資料集合。&lt;strong>列&lt;/strong>（row）指橫向的一筆資料，&lt;strong>欄&lt;/strong>（column）指縱向的一個位置——這是台灣的用法，簡體中文的 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> 產出的那一份就是，它比來源表多了幾列補 NULL 的。&lt;/p>
&lt;p>這個詞值得單獨認一次，因為中文的「關係」在日常語言裡指人或事物之間的聯繫，與這裡的意思無關。它是 relation 的譯名，指的是那個集合本身。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;strong>把中間結果也當成關係，多層查詢才讀得動。&lt;/strong> 書店的 &lt;code>FROM 顧客 LEFT JOIN 訂單&lt;/code> 走完之後，得到的那一份有姓名與訂單編號兩種欄位、四列（佳穎兩列、宗翰與雅文各一列而訂單編號是 NULL）。它沒有對應的資料表，沒有名字，也不會被寫進磁碟——而後續的 &lt;code>WHERE&lt;/code>、&lt;code>GROUP BY&lt;/code> 全部對它操作，不對原本那兩張表操作。&lt;/p>
&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>&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> 是這組運算裡的一個，它產出的關係比來源表多了補 NULL 的那些列。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>判斷現在處理的是不是還是原表，看列數與欄位。連接之後列數可能變多（一列配到多列就複製一次）、欄位是兩邊合起來的、還可能出現來源表裡沒有的空缺。三者任一成立就表示已經離開原表了。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>多層查詢的判讀方式是逐層檢查每一層的欄位與列，而不是想像最後那一張表。把每個 &lt;code>JOIN&lt;/code> 都想成「兩張表配對」在第三張表之後就失準，因為那時左邊已經是前兩層累積出來的關係。&lt;/p></description><content:encoded><![CDATA[<p>Relation 是一組欄位固定、由若干列構成的資料集合。<strong>列</strong>（row）指橫向的一筆資料，<strong>欄</strong>（column）指縱向的一個位置——這是台灣的用法，簡體中文的 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> 產出的那一份就是，它比來源表多了幾列補 NULL 的。</p>
<p>這個詞值得單獨認一次，因為中文的「關係」在日常語言裡指人或事物之間的聯繫，與這裡的意思無關。它是 relation 的譯名，指的是那個集合本身。</p>
<h2 id="概念位置">概念位置</h2>
<p><strong>把中間結果也當成關係，多層查詢才讀得動。</strong> 書店的 <code>FROM 顧客 LEFT JOIN 訂單</code> 走完之後，得到的那一份有姓名與訂單編號兩種欄位、四列（佳穎兩列、宗翰與雅文各一列而訂單編號是 NULL）。它沒有對應的資料表，沒有名字，也不會被寫進磁碟——而後續的 <code>WHERE</code>、<code>GROUP BY</code> 全部對它操作，不對原本那兩張表操作。</p>
<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><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> 是這組運算裡的一個，它產出的關係比來源表多了補 NULL 的那些列。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>判斷現在處理的是不是還是原表，看列數與欄位。連接之後列數可能變多（一列配到多列就複製一次）、欄位是兩邊合起來的、還可能出現來源表裡沒有的空缺。三者任一成立就表示已經離開原表了。</p>
<h2 id="設計責任">設計責任</h2>
<p>多層查詢的判讀方式是逐層檢查每一層的欄位與列，而不是想像最後那一張表。把每個 <code>JOIN</code> 都想成「兩張表配對」在第三張表之後就失準，因為那時左邊已經是前兩層累積出來的關係。</p>
]]></content:encoded></item><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>Query Optimizer（查詢最佳化器）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/</guid><description>&lt;p>Query optimizer 是引擎裡負責挑選執行方式的元件。它讀進查詢，產出一個 &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/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a> 要與語意模型算出來的一致。&lt;/p>
&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;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>最佳化器的存在是 SQL 宣告式性質的直接結果。查詢只描述要什麼結果，所以「怎麼算」這個決定必須有人做，而做這個決定的就是它。&lt;/p>
&lt;p>它的選擇依據是估計的代價，而估計來自統計資訊——每張表大概多少列、某個欄位的值大概怎麼分布。所以它的判斷品質取決於那些統計有多新，與查詢寫得多好無關。它的產物是一份 &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/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>書店的顧客表五十列、訂單表二十萬列。同一個連接寫成 &lt;code>FROM 訂單 JOIN 顧客&lt;/code> 與 &lt;code>FROM 顧客 JOIN 訂單&lt;/code>，在跑過 &lt;code>ANALYZE&lt;/code> 的資料庫上 SQLite 兩次回報一模一樣的計畫——都掃二十萬列那張、對五十列那張做查找。&lt;strong>書寫位置決定語意，執行方式由最佳化器另外決定。&lt;/strong>&lt;/p>
&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;/p>
&lt;p>換掉可用的資源，計畫也會變。建好 &lt;code>訂單(顧客編號)&lt;/code> 索引之後，同一段查詢從三行縮成兩行——&lt;code>SCAN 顧客&lt;/code> 接 &lt;code>SEARCH 訂單 USING COVERING INDEX ix&lt;/code>，臨時索引不必再建。&lt;/p>
&lt;p>它也可以做等價變換，例如把某些 &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;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>查詢沒動而某天變慢時，變的是統計而不是查詢——最佳化器照估計值選，估計舊了選擇就跟著舊。所以這種情形的歸因起點是統計資訊的新舊，不是查詢的寫法。&lt;/p>
&lt;p>反過來，最佳化器選錯而統計是新的時候，才輪到用提示或改寫去引導它，而那些手段會把原本自動適應資料變化的查詢釘死在一個選擇上。&lt;/p></description><content:encoded><![CDATA[<p>Query optimizer 是引擎裡負責挑選執行方式的元件。它讀進查詢，產出一個 <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/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 要與語意模型算出來的一致。</p>
<p>它比較的依據是另外存著的一份<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊</a>——那份摘要既不在查詢的文字裡也不在資料表裡，而統計多新，它的估計就多準。</p>
<h2 id="概念位置">概念位置</h2>
<p>最佳化器的存在是 SQL 宣告式性質的直接結果。查詢只描述要什麼結果，所以「怎麼算」這個決定必須有人做，而做這個決定的就是它。</p>
<p>它的選擇依據是估計的代價，而估計來自統計資訊——每張表大概多少列、某個欄位的值大概怎麼分布。所以它的判斷品質取決於那些統計有多新，與查詢寫得多好無關。它的產物是一份 <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/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>書店的顧客表五十列、訂單表二十萬列。同一個連接寫成 <code>FROM 訂單 JOIN 顧客</code> 與 <code>FROM 顧客 JOIN 訂單</code>，在跑過 <code>ANALYZE</code> 的資料庫上 SQLite 兩次回報一模一樣的計畫——都掃二十萬列那張、對五十列那張做查找。<strong>書寫位置決定語意，執行方式由最佳化器另外決定。</strong></p>
<p>統計缺席時它只好照文字的順序掃——那一組對照的完整實驗在 <a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定</a>。</p>
<p>換掉可用的資源，計畫也會變。建好 <code>訂單(顧客編號)</code> 索引之後，同一段查詢從三行縮成兩行——<code>SCAN 顧客</code> 接 <code>SEARCH 訂單 USING COVERING INDEX ix</code>，臨時索引不必再建。</p>
<p>它也可以做等價變換，例如把某些 <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>
<h2 id="設計責任">設計責任</h2>
<p>查詢沒動而某天變慢時，變的是統計而不是查詢——最佳化器照估計值選，估計舊了選擇就跟著舊。所以這種情形的歸因起點是統計資訊的新舊，不是查詢的寫法。</p>
<p>反過來，最佳化器選錯而統計是新的時候，才輪到用提示或改寫去引導它，而那些手段會把原本自動適應資料變化的查詢釘死在一個選擇上。</p>
]]></content:encoded></item><item><title>Outer Join（外連接）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/</guid><description>&lt;p>Outer join 保留配不到對象的列。它解的問題是內連接（&lt;code>INNER JOIN&lt;/code>，也就是只寫 &lt;code>JOIN&lt;/code> 時的預設）會讓沒有對應資料的那些列整個消失，而那些列常常正是要找的對象。產出的仍然是一個 &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>，只是比來源表多了一些補 NULL 的列。&lt;/p>
&lt;p>書店有三位顧客——佳穎下過兩張訂單、宗翰與雅文一張都沒下。要一份「每位顧客與他的訂單」的名單，用內連接得到：&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">佳穎 101
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">佳穎 102&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>宗翰與雅文不在名單上。他們沒有訂單可以配對，於是內連接把他們整個丟掉了。換成 &lt;code>LEFT 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">佳穎 101
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">佳穎 102
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">宗翰 NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">雅文 NULL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>這就是「保護」的意思&lt;/strong>：左邊那張表的每一列都留著，右邊配不上的欄位補 NULL。而「哪些顧客一張單都沒下」這個問題，答案正是那兩列補 NULL 的——內連接會讓它無從回答。&lt;/p>
&lt;p>三種寫法差在保護哪一側：&lt;code>LEFT&lt;/code> 保護左運算元，&lt;code>RIGHT&lt;/code> 保護右運算元，&lt;code>FULL&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/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-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer&lt;/a> 在特定條件下可以把它降級成內連接——前提是查詢的其他部分已經讓補 NULL 的那些列不可能出現在結果裡。&lt;/p>
&lt;p>補上的 NULL 是這個機制的產物，與資料裡本來就有的空值來源不同。這一點決定了後續怎麼判斷：&lt;code>IS NULL&lt;/code> 問的可能是「這一列沒配到」，也可能是「配到了而那個欄位本來就空」。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>保護會被 &lt;code>WHERE&lt;/code> 抵銷。上面那個 &lt;code>LEFT JOIN&lt;/code> 加上 &lt;code>WHERE 訂單.金額 &amp;gt; 100&lt;/code> 之後，宗翰與雅文那兩列的金額是 NULL、答不出「大於 100」，於是被篩掉，結果與內連接相同。&lt;/p>
&lt;p>&lt;strong>外連接寫了卻拿到內連接的結果，多半就是這件事&lt;/strong>：&lt;code>WHERE&lt;/code> 裡引用了右表的欄位。判斷保不保得住的方法是把補 NULL 的那一列代進條件裡：判成真才留得下來。&lt;code>IS NULL&lt;/code> 判成真所以保住，&lt;code>IS NOT NULL&lt;/code> 判成假所以取消——兩者看起來成對而落在相反的一側。而反查「沒配到的那些」要挑一個&lt;strong>不可為空的欄位&lt;/strong>（通常是連接鍵）來問 &lt;code>IS NULL&lt;/code>，用可為空的欄位會把「配到了而那個欄位本來就空」的列一起挑進來。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>要在保留左側全部列的前提下限制右側的配對範圍，條件寫進 &lt;code>ON&lt;/code>；要從成品裡篩，條件寫進 &lt;code>WHERE&lt;/code>。兩者在內連接底下同值，所以這個差別只在外連接現形。&lt;/p>
&lt;p>鏈式查詢裡混用 &lt;code>LEFT&lt;/code> 與 &lt;code>RIGHT&lt;/code> 會讓保護的方向中途反轉。&lt;code>RIGHT&lt;/code> 的右運算元通常只是一張表，而左運算元是前面累積出來的全部，所以保護範圍與多數人的本意相反。把需要保護的表移到最前面、其餘一律 &lt;code>LEFT&lt;/code>，方向就一致了。&lt;/p></description><content:encoded><![CDATA[<p>Outer join 保留配不到對象的列。它解的問題是內連接（<code>INNER JOIN</code>，也就是只寫 <code>JOIN</code> 時的預設）會讓沒有對應資料的那些列整個消失，而那些列常常正是要找的對象。產出的仍然是一個 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>，只是比來源表多了一些補 NULL 的列。</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">佳穎  101
</span></span><span class="line"><span class="ln">2</span><span class="cl">佳穎  102</span></span></code></pre></div><p>宗翰與雅文不在名單上。他們沒有訂單可以配對，於是內連接把他們整個丟掉了。換成 <code>LEFT 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">佳穎  101
</span></span><span class="line"><span class="ln">2</span><span class="cl">佳穎  102
</span></span><span class="line"><span class="ln">3</span><span class="cl">宗翰  NULL
</span></span><span class="line"><span class="ln">4</span><span class="cl">雅文  NULL</span></span></code></pre></div><p><strong>這就是「保護」的意思</strong>：左邊那張表的每一列都留著，右邊配不上的欄位補 NULL。而「哪些顧客一張單都沒下」這個問題，答案正是那兩列補 NULL 的——內連接會讓它無從回答。</p>
<p>三種寫法差在保護哪一側：<code>LEFT</code> 保護左運算元，<code>RIGHT</code> 保護右運算元，<code>FULL</code> 兩側都保護。</p>
<h2 id="概念位置">概念位置</h2>
<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-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer</a> 在特定條件下可以把它降級成內連接——前提是查詢的其他部分已經讓補 NULL 的那些列不可能出現在結果裡。</p>
<p>補上的 NULL 是這個機制的產物，與資料裡本來就有的空值來源不同。這一點決定了後續怎麼判斷：<code>IS NULL</code> 問的可能是「這一列沒配到」，也可能是「配到了而那個欄位本來就空」。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>保護會被 <code>WHERE</code> 抵銷。上面那個 <code>LEFT JOIN</code> 加上 <code>WHERE 訂單.金額 &gt; 100</code> 之後，宗翰與雅文那兩列的金額是 NULL、答不出「大於 100」，於是被篩掉，結果與內連接相同。</p>
<p><strong>外連接寫了卻拿到內連接的結果，多半就是這件事</strong>：<code>WHERE</code> 裡引用了右表的欄位。判斷保不保得住的方法是把補 NULL 的那一列代進條件裡：判成真才留得下來。<code>IS NULL</code> 判成真所以保住，<code>IS NOT NULL</code> 判成假所以取消——兩者看起來成對而落在相反的一側。而反查「沒配到的那些」要挑一個<strong>不可為空的欄位</strong>（通常是連接鍵）來問 <code>IS NULL</code>，用可為空的欄位會把「配到了而那個欄位本來就空」的列一起挑進來。</p>
<h2 id="設計責任">設計責任</h2>
<p>要在保留左側全部列的前提下限制右側的配對範圍，條件寫進 <code>ON</code>；要從成品裡篩，條件寫進 <code>WHERE</code>。兩者在內連接底下同值，所以這個差別只在外連接現形。</p>
<p>鏈式查詢裡混用 <code>LEFT</code> 與 <code>RIGHT</code> 會讓保護的方向中途反轉。<code>RIGHT</code> 的右運算元通常只是一張表，而左運算元是前面累積出來的全部，所以保護範圍與多數人的本意相反。把需要保護的表移到最前面、其餘一律 <code>LEFT</code>，方向就一致了。</p>
]]></content:encoded></item><item><title>Index（索引）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/indexing/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/indexing/</guid><description>&lt;p>Index 是為了加快查找而額外維護的一份資料結構，內容是某幾個欄位的值與它們對應的列的位置。它與資料本身分開存放，由引擎自動維護，而 &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;p>沒有索引時，找出某個條件成立的列要把整張表看過一遍。有索引時，引擎先在索引裡定位，再回頭取那些列。&lt;/p>
&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;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable&lt;/a> 那張卡寫這個形狀怎麼判、怎麼改。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>索引是查詢代價的主要變數之一，而它不出現在查詢的文字裡。同一段 SQL 在有索引與沒索引的資料庫上執行方式完全不同，結果卻一模一樣——這正是宣告式語言的分工：查詢說要什麼，索引與計畫決定怎麼拿。&lt;/p>
&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;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>SQLite 的計畫用 &lt;code>SCAN&lt;/code> 與 &lt;code>SEARCH&lt;/code> 分開兩種取法：&lt;code>SCAN 訂單&lt;/code> 表示整張表逐列看過，&lt;code>SEARCH 顧客 USING AUTOMATIC COVERING INDEX&lt;/code> 表示透過索引定位。&lt;/p>
&lt;p>計畫裡那幾個關鍵字（&lt;code>AUTOMATIC&lt;/code>、&lt;code>COVERING&lt;/code>、&lt;code>BLOOM FILTER&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;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>索引由查詢的形狀決定，不由表的形狀決定。要建哪些索引取決於實際會執行哪些查詢，所以它是在知道查詢模式之後才做得準的決定。&lt;/p>
&lt;p>複合索引的欄位順序有影響：&lt;code>(顧客編號, 下單日)&lt;/code> 這個索引對「先按顧客再按日期」的查找有用，對「只按日期」的查找幫助有限。順序要對齊查詢的條件形狀。&lt;/p></description><content:encoded><![CDATA[<p>Index 是為了加快查找而額外維護的一份資料結構，內容是某幾個欄位的值與它們對應的列的位置。它與資料本身分開存放，由引擎自動維護，而 <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>
<p>沒有索引時，找出某個條件成立的列要把整張表看過一遍。有索引時，引擎先在索引裡定位，再回頭取那些列。</p>
<p>索引建了而引擎不走它有兩個常見的成因。一個是<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊</a>沒更新——索引決定有沒有一條快的路，統計決定引擎知不知道該走那一條。另一個是條件把索引欄位包進了函式或運算裡，那時引擎沒有可以拿去比對的值，<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable</a> 那張卡寫這個形狀怎麼判、怎麼改。</p>
<h2 id="概念位置">概念位置</h2>
<p>索引是查詢代價的主要變數之一，而它不出現在查詢的文字裡。同一段 SQL 在有索引與沒索引的資料庫上執行方式完全不同，結果卻一模一樣——這正是宣告式語言的分工：查詢說要什麼，索引與計畫決定怎麼拿。</p>
<p>它的代價落在寫入端。每一次新增、修改或刪除，相關的索引都要跟著更新，所以索引不是加越多越好。同一個 <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>SQLite 的計畫用 <code>SCAN</code> 與 <code>SEARCH</code> 分開兩種取法：<code>SCAN 訂單</code> 表示整張表逐列看過，<code>SEARCH 顧客 USING AUTOMATIC COVERING INDEX</code> 表示透過索引定位。</p>
<p>計畫裡那幾個關鍵字（<code>AUTOMATIC</code>、<code>COVERING</code>、<code>BLOOM FILTER</code>）各指什麼，在 <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>索引由查詢的形狀決定，不由表的形狀決定。要建哪些索引取決於實際會執行哪些查詢，所以它是在知道查詢模式之後才做得準的決定。</p>
<p>複合索引的欄位順序有影響：<code>(顧客編號, 下單日)</code> 這個索引對「先按顧客再按日期」的查找有用，對「只按日期」的查找幫助有限。順序要對齊查詢的條件形狀。</p>
]]></content:encoded></item><item><title>NULL（空值）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/</guid><description>&lt;p>NULL 表示這個位置沒有值。它與零、空字串都不同——那兩者是值，NULL 是值的缺席。它出現在兩種來源：資料本身就沒填，或 &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;strong>比較：拿 NULL 去比對任何值，答案是第三種——未知。&lt;/strong> 真、假、未知這三種答案合起來叫三值邏輯，SQL 的每一個條件都在這套邏輯裡求值。 &lt;code>NULL = NULL&lt;/code> 的結果既不是真也不是假。而 &lt;code>WHERE&lt;/code> 只留下判斷為真的列，所以「未知」與「假」在那一步的下場相同：列被丟掉。判斷有沒有值要用 &lt;code>IS NULL&lt;/code> 與 &lt;code>IS NOT NULL&lt;/code>，它們問的是位置有沒有值、答案只有真假兩種。&lt;/p>
&lt;p>&lt;strong>計數：&lt;code>count(*)&lt;/code> 數列，&lt;code>count(欄位)&lt;/code> 只數那一欄有值的列。&lt;/strong> 兩者的差額就是空缺的數量。&lt;/p>
&lt;p>&lt;strong>分組：&lt;code>GROUP BY&lt;/code> 把 NULL 當成一個值來分。&lt;/strong> 所有那一欄是 NULL 的列會收進同一組——這是 NULL 少數表現得像一般值的地方。連接產出的那個 &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>書店的三位顧客裡只有佳穎下過單。&lt;code>LEFT JOIN&lt;/code> 之後宗翰與雅文的訂單欄是 NULL，而用等號去找他們回零列且不報錯：&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">WHERE&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="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 0 列，沒有錯誤訊息
&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">WHERE&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="k">IS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 宗翰、雅文&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>分組那一側的表現相反。按訂單表的顧客編號分組，宗翰與雅文因為都是 NULL 而被收進同一組——MySQL、SQLite 與 DuckDB 的結果一致。&lt;/p>
&lt;p>最難發現的是 &lt;code>NOT IN&lt;/code>：子查詢的結果裡只要有一個 NULL，整段查詢回零列而三個引擎都不報錯。推導、實測與 &lt;code>NOT EXISTS&lt;/code> 的安全範圍在 &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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>&lt;strong>否定式的成員判斷一律用 &lt;code>NOT EXISTS&lt;/code>&lt;/strong>，除非能保證子查詢那一欄不會有 NULL——而那個保證要來自欄位的&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束&lt;/a>，不是來自習慣。&lt;/p>
&lt;p>&lt;strong>那條約束是建表時的一個決定，而它有代價。&lt;/strong> 允許為空讀起來就是事實的形狀（沒有值就是沒有值），不允許則要替「沒有」發明一個實際的值；兩條在設計當下都合理，而選了前者之後每一段碰到這一欄的查詢都要處理三值邏輯。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/design-decisions-price-every-query/" data-link-title="1.16 Schema 設計決定的查詢代價：可空性、排序鍵唯一性、表寬、一對多的切法、比較規則與外鍵執法" data-link-desc="建表時每一個決定有哪些選法、選了之後查詢要多做什麼、代價在什麼條件下才浮現、改回來要付的遷移成本，以及設計當下就能問的問題；輸出為 SQLite 與 PostgreSQL 的實測">backend 1.16 Schema 設計決定的查詢代價&lt;/a> 的第一節走這個取捨，並給改回來要付的遷移成本。&lt;/p>
&lt;p>判斷空缺的來源也要分開。&lt;code>IS NULL&lt;/code> 為真時，可能是這一列沒配到對象，也可能是配到了而那個欄位本來就空。兩者在結果裡長得一樣，要靠選對欄位來分——用連接鍵那一欄判斷「有沒有配到」最可靠，因為它在配得上的時候必定有值。&lt;/p></description><content:encoded><![CDATA[<p>NULL 表示這個位置沒有值。它與零、空字串都不同——那兩者是值，NULL 是值的缺席。它出現在兩種來源：資料本身就沒填，或 <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><strong>比較：拿 NULL 去比對任何值，答案是第三種——未知。</strong> 真、假、未知這三種答案合起來叫三值邏輯，SQL 的每一個條件都在這套邏輯裡求值。 <code>NULL = NULL</code> 的結果既不是真也不是假。而 <code>WHERE</code> 只留下判斷為真的列，所以「未知」與「假」在那一步的下場相同：列被丟掉。判斷有沒有值要用 <code>IS NULL</code> 與 <code>IS NOT NULL</code>，它們問的是位置有沒有值、答案只有真假兩種。</p>
<p><strong>計數：<code>count(*)</code> 數列，<code>count(欄位)</code> 只數那一欄有值的列。</strong> 兩者的差額就是空缺的數量。</p>
<p><strong>分組：<code>GROUP BY</code> 把 NULL 當成一個值來分。</strong> 所有那一欄是 NULL 的列會收進同一組——這是 NULL 少數表現得像一般值的地方。連接產出的那個 <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>書店的三位顧客裡只有佳穎下過單。<code>LEFT JOIN</code> 之後宗翰與雅文的訂單欄是 NULL，而用等號去找他們回零列且不報錯：</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">WHERE</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="k">NULL</span><span class="w">     </span><span class="c1">-- 0 列，沒有錯誤訊息
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</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="w">    </span><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p>分組那一側的表現相反。按訂單表的顧客編號分組，宗翰與雅文因為都是 NULL 而被收進同一組——MySQL、SQLite 與 DuckDB 的結果一致。</p>
<p>最難發現的是 <code>NOT IN</code>：子查詢的結果裡只要有一個 NULL，整段查詢回零列而三個引擎都不報錯。推導、實測與 <code>NOT EXISTS</code> 的安全範圍在 <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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p><strong>否定式的成員判斷一律用 <code>NOT EXISTS</code></strong>，除非能保證子查詢那一欄不會有 NULL——而那個保證要來自欄位的<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>，不是來自習慣。</p>
<p><strong>那條約束是建表時的一個決定，而它有代價。</strong> 允許為空讀起來就是事實的形狀（沒有值就是沒有值），不允許則要替「沒有」發明一個實際的值；兩條在設計當下都合理，而選了前者之後每一段碰到這一欄的查詢都要處理三值邏輯。<a href="/blog/backend/01-database/design-decisions-price-every-query/" data-link-title="1.16 Schema 設計決定的查詢代價：可空性、排序鍵唯一性、表寬、一對多的切法、比較規則與外鍵執法" data-link-desc="建表時每一個決定有哪些選法、選了之後查詢要多做什麼、代價在什麼條件下才浮現、改回來要付的遷移成本，以及設計當下就能問的問題；輸出為 SQLite 與 PostgreSQL 的實測">backend 1.16 Schema 設計決定的查詢代價</a> 的第一節走這個取捨，並給改回來要付的遷移成本。</p>
<p>判斷空缺的來源也要分開。<code>IS NULL</code> 為真時，可能是這一列沒配到對象，也可能是配到了而那個欄位本來就空。兩者在結果裡長得一樣，要靠選對欄位來分——用連接鍵那一欄判斷「有沒有配到」最可靠，因為它在配得上的時候必定有值。</p>
]]></content:encoded></item><item><title>Aggregate Function（聚合函數）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/aggregate-function/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/aggregate-function/</guid><description>&lt;p>Aggregate function 把一組列收成一個值：&lt;code>count&lt;/code> 數幾列、&lt;code>sum&lt;/code> 加總、&lt;code>avg&lt;/code> 取平均、&lt;code>max&lt;/code> 與 &lt;code>min&lt;/code> 取極值。它的輸入是一組列，輸出是一個值——這個形狀決定了它能寫在哪裡，也決定了它在 &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;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;strong>「一組」是由 &lt;code>GROUP BY&lt;/code> 的鍵決定的，所以聚合要等到分組發生之後才有意義。&lt;/strong> 這解釋了一個看起來像限制的行為：聚合寫進 &lt;code>WHERE&lt;/code> 會被拒絕，因為那一步分組還沒發生，沒有組就沒有組的計數。同一件事寫進 &lt;code>HAVING&lt;/code> 通過，因為那一步分組已經完成。&lt;/p>
&lt;p>沒有 &lt;code>GROUP BY&lt;/code> 的時候整張表算一組，所以 &lt;code>SELECT count(*) FROM 訂單&lt;/code> 回一列。分組之後結果的單位從列換成組，而每一組仍然是一個 &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>&lt;code>count(*)&lt;/code> 與 &lt;code>count(欄位)&lt;/code> 數的不是同一件事：前者數列，後者只數那一欄有值的列。書店的兩張訂單接上評價之後有三列，而其中一列的星等是空的，於是 &lt;code>count(*)&lt;/code> 回 3、&lt;code>count(評價.星等)&lt;/code> 回 2。差額就是空缺的數量（見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL&lt;/a>）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>&lt;strong>連接之後做聚合，算的是連接結果的列，不是來源表的列。&lt;/strong> 一列配到多列就被複製一次，所以 &lt;code>count&lt;/code> 會多、&lt;code>sum&lt;/code> 會重複加。&lt;/p>
&lt;p>處理方式依聚合的性質分兩類。&lt;strong>可加的&lt;/strong>（&lt;code>count&lt;/code>、&lt;code>sum&lt;/code>）可以用 &lt;code>count(DISTINCT 鍵)&lt;/code> 把單位拉回來，或先把展開的那一層各自彙總成一列再接上去。&lt;strong>不可加的&lt;/strong>（&lt;code>avg&lt;/code>、中位數、百分位）兩條路都不通——先彙總再平均會變成平均的平均，權重錯了。這一類要把分子與分母一起帶出來（子查詢裡同時算 &lt;code>sum&lt;/code> 與 &lt;code>count&lt;/code>，接上來之後再相除）。&lt;/p>
&lt;p>&lt;code>max&lt;/code> 與 &lt;code>min&lt;/code> 是例外：它們對重複不敏感，複製幾次結果都一樣。&lt;/p></description><content:encoded><![CDATA[<p>Aggregate function 把一組列收成一個值：<code>count</code> 數幾列、<code>sum</code> 加總、<code>avg</code> 取平均、<code>max</code> 與 <code>min</code> 取極值。它的輸入是一組列，輸出是一個值——這個形狀決定了它能寫在哪裡，也決定了它在 <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>
<h2 id="概念位置">概念位置</h2>
<p><strong>「一組」是由 <code>GROUP BY</code> 的鍵決定的，所以聚合要等到分組發生之後才有意義。</strong> 這解釋了一個看起來像限制的行為：聚合寫進 <code>WHERE</code> 會被拒絕，因為那一步分組還沒發生，沒有組就沒有組的計數。同一件事寫進 <code>HAVING</code> 通過，因為那一步分組已經完成。</p>
<p>沒有 <code>GROUP BY</code> 的時候整張表算一組，所以 <code>SELECT count(*) FROM 訂單</code> 回一列。分組之後結果的單位從列換成組，而每一組仍然是一個 <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><code>count(*)</code> 與 <code>count(欄位)</code> 數的不是同一件事：前者數列，後者只數那一欄有值的列。書店的兩張訂單接上評價之後有三列，而其中一列的星等是空的，於是 <code>count(*)</code> 回 3、<code>count(評價.星等)</code> 回 2。差額就是空缺的數量（見 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a>）。</p>
<h2 id="設計責任">設計責任</h2>
<p><strong>連接之後做聚合，算的是連接結果的列，不是來源表的列。</strong> 一列配到多列就被複製一次，所以 <code>count</code> 會多、<code>sum</code> 會重複加。</p>
<p>處理方式依聚合的性質分兩類。<strong>可加的</strong>（<code>count</code>、<code>sum</code>）可以用 <code>count(DISTINCT 鍵)</code> 把單位拉回來，或先把展開的那一層各自彙總成一列再接上去。<strong>不可加的</strong>（<code>avg</code>、中位數、百分位）兩條路都不通——先彙總再平均會變成平均的平均，權重錯了。這一類要把分子與分母一起帶出來（子查詢裡同時算 <code>sum</code> 與 <code>count</code>，接上來之後再相除）。</p>
<p><code>max</code> 與 <code>min</code> 是例外：它們對重複不敏感，複製幾次結果都一樣。</p>
]]></content:encoded></item><item><title>Subquery（子查詢）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/subquery/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/subquery/</guid><description>&lt;p>Subquery 是寫在另一個查詢裡面的查詢。它分兩種，而分法只看一件事：&lt;strong>它有沒有引用外層的欄位。&lt;/strong> 這個差別決定它算幾次，也決定了它的代價——完整的量測在 &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;strong>獨立子查詢&lt;/strong>與外層無關，可以整段剪下來單獨執行。它算一次，把結果交給外層。&lt;code>WHERE 顧客編號 IN (SELECT 顧客編號 FROM 訂單)&lt;/code> 裡的括號就是這一種——訂單表那一份清單先算好，外層再拿它比對。&lt;/p>
&lt;p>&lt;strong>相關子查詢&lt;/strong>引用了外層的欄位，離開外層就跑不動。它對外層的每一列各執行一次。&lt;code>WHERE EXISTS (SELECT 1 FROM 訂單 WHERE 訂單.顧客編號 = 顧客.顧客編號)&lt;/code> 是這一種——括號裡的 &lt;code>顧客.顧客編號&lt;/code> 來自外層，所以每一位顧客各問一次。&lt;/p>
&lt;p>讀陌生查詢時這是一個可以直接看的訊號：&lt;strong>括號裡出現外層的表名，它就是逐列被問一次的&lt;/strong>。兩種子查詢交出的都是值而不是一個 &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>子查詢交出的是值，不是表。&lt;code>SELECT 姓名, 訂單.金額 FROM 顧客 WHERE 顧客編號 IN (SELECT ... FROM 訂單)&lt;/code> 會回 &lt;code>no such column: 訂單.金額&lt;/code>——訂單這張表沒有進到外層的 &lt;code>FROM&lt;/code> 裡。&lt;/p>
&lt;p>要帶欄位出來有另一條路：把相關子查詢寫在 &lt;code>SELECT&lt;/code> 裡，&lt;code>SELECT 姓名, (SELECT 金額 FROM 訂單 WHERE 訂單.顧客編號 = 顧客.顧客編號 ORDER BY 訂單編號 LIMIT 1) FROM 顧客&lt;/code>。它一次只交得出一個值，所以要多欄或多列時仍然回到連接。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>相關子查詢的逐列執行在沒有索引時代價很高——外層每一列都讓內層掃一次全表。有索引之後每一次從掃全表變成定位，同一段查詢可以差幾百倍。&lt;/p>
&lt;p>獨立子查詢有另一個陷阱：它的結果裡出現任何一個 NULL，外層的 &lt;code>NOT IN&lt;/code> 就整段失效而不報錯。推導與實測在 &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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Subquery 是寫在另一個查詢裡面的查詢。它分兩種，而分法只看一件事：<strong>它有沒有引用外層的欄位。</strong> 這個差別決定它算幾次，也決定了它的代價——完整的量測在 <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><strong>獨立子查詢</strong>與外層無關，可以整段剪下來單獨執行。它算一次，把結果交給外層。<code>WHERE 顧客編號 IN (SELECT 顧客編號 FROM 訂單)</code> 裡的括號就是這一種——訂單表那一份清單先算好，外層再拿它比對。</p>
<p><strong>相關子查詢</strong>引用了外層的欄位，離開外層就跑不動。它對外層的每一列各執行一次。<code>WHERE EXISTS (SELECT 1 FROM 訂單 WHERE 訂單.顧客編號 = 顧客.顧客編號)</code> 是這一種——括號裡的 <code>顧客.顧客編號</code> 來自外層，所以每一位顧客各問一次。</p>
<p>讀陌生查詢時這是一個可以直接看的訊號：<strong>括號裡出現外層的表名，它就是逐列被問一次的</strong>。兩種子查詢交出的都是值而不是一個 <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>子查詢交出的是值，不是表。<code>SELECT 姓名, 訂單.金額 FROM 顧客 WHERE 顧客編號 IN (SELECT ... FROM 訂單)</code> 會回 <code>no such column: 訂單.金額</code>——訂單這張表沒有進到外層的 <code>FROM</code> 裡。</p>
<p>要帶欄位出來有另一條路：把相關子查詢寫在 <code>SELECT</code> 裡，<code>SELECT 姓名, (SELECT 金額 FROM 訂單 WHERE 訂單.顧客編號 = 顧客.顧客編號 ORDER BY 訂單編號 LIMIT 1) FROM 顧客</code>。它一次只交得出一個值，所以要多欄或多列時仍然回到連接。</p>
<h2 id="設計責任">設計責任</h2>
<p>相關子查詢的逐列執行在沒有索引時代價很高——外層每一列都讓內層掃一次全表。有索引之後每一次從掃全表變成定位，同一段查詢可以差幾百倍。</p>
<p>獨立子查詢有另一個陷阱：它的結果裡出現任何一個 NULL，外層的 <code>NOT IN</code> 就整段失效而不報錯。推導與實測在 <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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>。</p>
]]></content:encoded></item><item><title>Schema（命名空間義）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/schema-namespace/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/schema-namespace/</guid><description>&lt;p>在 PostgreSQL 這類資料庫裡，schema 是資料庫底下的一層命名空間，表、索引、序列這些物件都住在某一個 schema 裡。預設的那一個叫 &lt;code>public&lt;/code>。它與&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 這一層，以及最小權限在應用程式帳號上成立的前提">權限&lt;/a>是分層的：對表的授權管的是讀寫那張表，對 schema 的授權管的是能不能在裡面建立新物件——而表是一種 &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;p>&lt;strong>這個字有另一個常見的所指——「表結構」，也就是有哪些表、每張表有哪些欄位、型別與約束是什麼。&lt;/strong> 兩者不同層：命名空間是容器，表結構是被裝的東西。談 schema migration 的時候用的是後者，談 &lt;code>permission denied for schema public&lt;/code> 的時候用的是前者。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>分層在錯誤訊息上看得出來。一個角色被拒絕讀某張表，訊息是 &lt;code>permission denied for table 顧客&lt;/code>；同一個角色被拒絕建新表，訊息是 &lt;code>permission denied for schema public&lt;/code>——擋的位置不同，要授的權也不同。被擋住的那些物件裡最常見的是表，而表是一種 &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;p>PostgreSQL 15 之後 &lt;code>public&lt;/code> schema 預設不再開放給所有人建立物件，在那之前這個操作會成功。同一段 SQL 在不同版本上的結果因此不同，而差別在預設值不在語法。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>&lt;code>information_schema&lt;/code> 本身就是一個 schema，裡面裝著描述其他物件的視圖。查自己有哪些授權用的 &lt;code>information_schema.role_table_grants&lt;/code> 就是「schema 名.表名」這個兩段式的寫法。&lt;/p>
&lt;p>不是每個資料庫都有這一層。SQLite 沒有 schema 也沒有使用者，整個資料庫是一個檔案，存取由檔案系統決定。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>看到這個字先判它在哪一層。判斷方法是看它旁邊的動詞：跟「建立物件」「授權」「限定名稱」一起出現的是命名空間；跟「欄位」「型別」「遷移」「版本」一起出現的是表結構。&lt;/p>
&lt;p>兩者在部署上也分工：命名空間常用來把不同租戶或不同應用的物件隔開，而表結構的演進是另一組問題——雙寫、回填、切流與回滾，那些在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同一個資料庫引擎內 schema 演進與資料變更的分段驗證流程：expand-contract、回填、驗證查詢、切流與回滾；換資料庫引擎的遷移不在範圍內">backend 模組一的資料庫轉換實作&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>在 PostgreSQL 這類資料庫裡，schema 是資料庫底下的一層命名空間，表、索引、序列這些物件都住在某一個 schema 裡。預設的那一個叫 <code>public</code>。它與<a href="/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">權限</a>是分層的：對表的授權管的是讀寫那張表，對 schema 的授權管的是能不能在裡面建立新物件——而表是一種 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>。</p>
<p><strong>這個字有另一個常見的所指——「表結構」，也就是有哪些表、每張表有哪些欄位、型別與約束是什麼。</strong> 兩者不同層：命名空間是容器，表結構是被裝的東西。談 schema migration 的時候用的是後者，談 <code>permission denied for schema public</code> 的時候用的是前者。</p>
<h2 id="概念位置">概念位置</h2>
<p>分層在錯誤訊息上看得出來。一個角色被拒絕讀某張表，訊息是 <code>permission denied for table 顧客</code>；同一個角色被拒絕建新表，訊息是 <code>permission denied for schema public</code>——擋的位置不同，要授的權也不同。被擋住的那些物件裡最常見的是表，而表是一種 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>。</p>
<p>PostgreSQL 15 之後 <code>public</code> schema 預設不再開放給所有人建立物件，在那之前這個操作會成功。同一段 SQL 在不同版本上的結果因此不同，而差別在預設值不在語法。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p><code>information_schema</code> 本身就是一個 schema，裡面裝著描述其他物件的視圖。查自己有哪些授權用的 <code>information_schema.role_table_grants</code> 就是「schema 名.表名」這個兩段式的寫法。</p>
<p>不是每個資料庫都有這一層。SQLite 沒有 schema 也沒有使用者，整個資料庫是一個檔案，存取由檔案系統決定。</p>
<h2 id="設計責任">設計責任</h2>
<p>看到這個字先判它在哪一層。判斷方法是看它旁邊的動詞：跟「建立物件」「授權」「限定名稱」一起出現的是命名空間；跟「欄位」「型別」「遷移」「版本」一起出現的是表結構。</p>
<p>兩者在部署上也分工：命名空間常用來把不同租戶或不同應用的物件隔開，而表結構的演進是另一組問題——雙寫、回填、切流與回滾，那些在 <a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同一個資料庫引擎內 schema 演進與資料變更的分段驗證流程：expand-contract、回填、驗證查詢、切流與回滾；換資料庫引擎的遷移不在範圍內">backend 模組一的資料庫轉換實作</a>。</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>DDL 與 DML（定義結構與動資料）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/ddl-dml/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/ddl-dml/</guid><description>&lt;p>SQL 的語句按它改動的對象分成兩類。&lt;strong>DDL&lt;/strong>（Data Definition Language）改的是結構——&lt;code>CREATE&lt;/code>、&lt;code>ALTER&lt;/code>、&lt;code>DROP&lt;/code>，它動的是有哪些表、每張表有哪些欄位。&lt;strong>DML&lt;/strong>（Data Manipulation Language）改的是結構裡裝的資料——&lt;code>INSERT&lt;/code>、&lt;code>UPDATE&lt;/code>、&lt;code>DELETE&lt;/code>，表還是那張表，變的是裡面有哪些列（列與欄這兩個字在本站的用法見 &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;code>SELECT&lt;/code> 只讀不改，各家文件有的把它算進 DML、有的另立一類。&lt;/p>
&lt;h2 id="這條線在權限上有實際後果">這條線在權限上有實際後果&lt;/h2>
&lt;p>兩類的&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 這一層，以及最小權限在應用程式帳號上成立的前提">授權&lt;/a>是分開的，而它們在日常運行裡的頻率差很多：應用程式跑起來之後幾乎只做 DML，DDL 只在部署或遷移的時候用一次。這讓最小權限有一個明確的切法——常態運行的帳號不給 DDL，遷移用另一個帳號。&lt;/p>
&lt;p>代價的形狀也不同。一句 DML 寫錯影響的是那些列，可以用另一句 DML 補回來；一句 DDL 寫錯（&lt;code>DROP TABLE&lt;/code> 那一類）拿掉的是結構，補救要靠備份。&lt;/p>
&lt;h2 id="兩類的擋法不在同一層">兩類的擋法不在同一層&lt;/h2>
&lt;p>DML 的權限掛在表上——&lt;code>GRANT SELECT ON 顧客&lt;/code>。DDL 的權限多數掛在更上面一層：能不能建新表由 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/schema-namespace/" data-link-title="Schema（命名空間義）" data-link-desc="看到 permission denied for schema、或想確認這個字指的是表結構還是命名空間時">schema&lt;/a> 那一層決定，不是由某一張表決定，因為建的時候那張表還不存在。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>這條線切的是語句改動的對象，而 SQL 的其餘概念多數只活在 DML 這一側——&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/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>、分組、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">子查詢&lt;/a> 全部是在既有結構上取資料的方式。DDL 決定那個結構長什麼樣，它跑完之後才輪到那些概念。&lt;/p>
&lt;p>兩類在時間上的分布因此完全不同：DDL 集中在部署那幾分鐘，DML 散在系統活著的每一秒。權限、備份策略與事故的補救方式都跟著這條線分。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>授權的單位與最小權限的具體切法在 &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>。DDL 權限實際被用到的場合（雙寫、回填、切流、回滾）在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同一個資料庫引擎內 schema 演進與資料變更的分段驗證流程：expand-contract、回填、驗證查詢、切流與回滾；換資料庫引擎的遷移不在範圍內">資料庫轉換實作&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>SQL 的語句按它改動的對象分成兩類。<strong>DDL</strong>（Data Definition Language）改的是結構——<code>CREATE</code>、<code>ALTER</code>、<code>DROP</code>，它動的是有哪些表、每張表有哪些欄位。<strong>DML</strong>（Data Manipulation Language）改的是結構裡裝的資料——<code>INSERT</code>、<code>UPDATE</code>、<code>DELETE</code>，表還是那張表，變的是裡面有哪些列（列與欄這兩個字在本站的用法見 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>）。<code>SELECT</code> 只讀不改，各家文件有的把它算進 DML、有的另立一類。</p>
<h2 id="這條線在權限上有實際後果">這條線在權限上有實際後果</h2>
<p>兩類的<a href="/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">授權</a>是分開的，而它們在日常運行裡的頻率差很多：應用程式跑起來之後幾乎只做 DML，DDL 只在部署或遷移的時候用一次。這讓最小權限有一個明確的切法——常態運行的帳號不給 DDL，遷移用另一個帳號。</p>
<p>代價的形狀也不同。一句 DML 寫錯影響的是那些列，可以用另一句 DML 補回來；一句 DDL 寫錯（<code>DROP TABLE</code> 那一類）拿掉的是結構，補救要靠備份。</p>
<h2 id="兩類的擋法不在同一層">兩類的擋法不在同一層</h2>
<p>DML 的權限掛在表上——<code>GRANT SELECT ON 顧客</code>。DDL 的權限多數掛在更上面一層：能不能建新表由 <a href="/blog/backend/01-database/sql/knowledge-cards/schema-namespace/" data-link-title="Schema（命名空間義）" data-link-desc="看到 permission denied for schema、或想確認這個字指的是表結構還是命名空間時">schema</a> 那一層決定，不是由某一張表決定，因為建的時候那張表還不存在。</p>
<h2 id="概念位置">概念位置</h2>
<p>這條線切的是語句改動的對象，而 SQL 的其餘概念多數只活在 DML 這一側——<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/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>、分組、<a href="/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">子查詢</a> 全部是在既有結構上取資料的方式。DDL 決定那個結構長什麼樣，它跑完之後才輪到那些概念。</p>
<p>兩類在時間上的分布因此完全不同：DDL 集中在部署那幾分鐘，DML 散在系統活著的每一秒。權限、備份策略與事故的補救方式都跟著這條線分。</p>
<h2 id="往下走">往下走</h2>
<p>授權的單位與最小權限的具體切法在 <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>。DDL 權限實際被用到的場合（雙寫、回填、切流、回滾）在 <a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同一個資料庫引擎內 schema 演進與資料變更的分段驗證流程：expand-contract、回填、驗證查詢、切流與回滾；換資料庫引擎的遷移不在範圍內">資料庫轉換實作</a>。</p>
]]></content:encoded></item><item><title>DataFrame（資料框）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/dataframe/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/dataframe/</guid><description>&lt;p>DataFrame 是一張表在程式記憶體裡的表示：有具名的欄、有列，整份資料放在執行這段程式的行程的記憶體裡。Python 的 pandas 是最常見的實作，R 與 Julia 各有自己的一套。它與 &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>&lt;strong>列有沒有順序。&lt;/strong> DataFrame 的列從頭到尾有一個位置，可以問「第三列是什麼」。關係沒有順序這個屬性，&lt;code>SELECT&lt;/code> 回來的列要靠 &lt;code>ORDER BY&lt;/code> 才排得出來。&lt;/p>
&lt;p>&lt;strong>誰在算。&lt;/strong> 對 DataFrame 的每一個操作在自己的行程裡執行，資料先整批進記憶體；對關係的操作交給資料庫引擎，程式收到的只有結果。上限因此不同——一邊是這台機器的記憶體，一邊是資料庫的儲存。&lt;/p>
&lt;p>&lt;strong>寫下的是動作還是描述。&lt;/strong> DataFrame 的程式碼是一串照順序發生的動作：載入、篩選、合併。SQL 寫下的是要什麼結果，怎麼算由引擎決定。這一條是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/" data-link-title="SQL：這個語言為什麼長這樣" data-link-desc="SQL 的設計而不是它的語法表：子句為什麼這樣切、一個查詢該怎麼讀，以及代價落在哪裡">SQL 這個分類&lt;/a>每一篇的起點。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>DataFrame 在 SQL 那個分類裡的角色是對照組。宣告式這個性質單獨看不出形狀，跟一段逐步執行的程式並排才清楚——同一件事，一邊每一行指定下一步做什麼，一邊只描述要什麼。&lt;/p>
&lt;p>兩邊的底層是同一套 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&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/programming/python/basic/08-data-analysis/same-relational-algebra/" data-link-title="8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異" data-link-desc="SQL 與 DataFrame 共用的關聯代數、同一份資料上兩邊寫法的實測比對，以及照著逐句翻譯會靜默失準的位置">python 模組八 8.4&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>DataFrame 是一張表在程式記憶體裡的表示：有具名的欄、有列，整份資料放在執行這段程式的行程的記憶體裡。Python 的 pandas 是最常見的實作，R 與 Julia 各有自己的一套。它與 <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><strong>列有沒有順序。</strong> DataFrame 的列從頭到尾有一個位置，可以問「第三列是什麼」。關係沒有順序這個屬性，<code>SELECT</code> 回來的列要靠 <code>ORDER BY</code> 才排得出來。</p>
<p><strong>誰在算。</strong> 對 DataFrame 的每一個操作在自己的行程裡執行，資料先整批進記憶體；對關係的操作交給資料庫引擎，程式收到的只有結果。上限因此不同——一邊是這台機器的記憶體，一邊是資料庫的儲存。</p>
<p><strong>寫下的是動作還是描述。</strong> DataFrame 的程式碼是一串照順序發生的動作：載入、篩選、合併。SQL 寫下的是要什麼結果，怎麼算由引擎決定。這一條是 <a href="/blog/backend/01-database/sql/" data-link-title="SQL：這個語言為什麼長這樣" data-link-desc="SQL 的設計而不是它的語法表：子句為什麼這樣切、一個查詢該怎麼讀，以及代價落在哪裡">SQL 這個分類</a>每一篇的起點。</p>
<h2 id="概念位置">概念位置</h2>
<p>DataFrame 在 SQL 那個分類裡的角色是對照組。宣告式這個性質單獨看不出形狀，跟一段逐步執行的程式並排才清楚——同一件事，一邊每一行指定下一步做什麼，一邊只描述要什麼。</p>
<p>兩邊的底層是同一套 <a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</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/programming/python/basic/08-data-analysis/same-relational-algebra/" data-link-title="8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異" data-link-desc="SQL 與 DataFrame 共用的關聯代數、同一份資料上兩邊寫法的實測比對，以及照著逐句翻譯會靜默失準的位置">python 模組八 8.4</a>。</p>
]]></content:encoded></item><item><title>Relational Algebra（關聯代數）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/</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> 上、產出 relation 的運算——relation 指一組欄位固定、由若干列構成的資料集合，資料表是它的一種，查詢途中的中間結果也是。運算有這幾個：挑列（選取）、挑欄（投影）、把兩份的每一列各配一次（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">積&lt;/a>）、在積上面加條件挑出要留的（連接）、以及聯集、差集、更名。連接自己就是積加上選取的組合，同一層還有兩個這個分類反覆用到的導出運算——&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">半連接與反連接&lt;/a>，配起來之後只帶走左邊那一份。SQL 的子句是這些運算的一層語法外衣，而不是另一套獨立的規則。&lt;/p>
&lt;h2 id="封閉性是它最重要的性質">封閉性是它最重要的性質&lt;/h2>
&lt;p>每個運算的輸入是關係、輸出還是關係，所以運算可以一個接一個組下去，中間結果與來源表的地位相同。這一條買到兩件事：子查詢可以寫在任何接受關係的位置，而多層查詢的判讀方式是逐層看每一層的欄與列。&lt;/p>
&lt;p>它也解釋了為什麼&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 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>的產物可以繼續當下一個連接的輸入——那份補了 NULL 的東西仍然是一個關係。&lt;/p>
&lt;h2 id="sql-與它並非一對一">SQL 與它並非一對一&lt;/h2>
&lt;p>SQL 在代數之上多了幾樣代數本身沒有的：重複的列（代數的關係是集合，SQL 的表允許重複，所以才需要 &lt;code>DISTINCT&lt;/code>）、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL&lt;/a> 與三值邏輯、以及排序。這幾樣正好是最容易讀錯的幾處，理由是它們不受代數的規則保護。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/" data-link-title="SQL：這個語言為什麼長這樣" data-link-desc="SQL 的設計而不是它的語法表：子句為什麼這樣切、一個查詢該怎麼讀，以及代價落在哪裡">SQL 這個分類&lt;/a>講語意模型的那幾篇，判斷標準全部收在這一層：子句的先後、連接的左右、條件擺哪一邊、分組的鍵，每一項都是某個運算的組合規則，而不是設計者的偏好。知道規則從代數來，讀不熟的查詢時就有一個可以推的依據，不必靠記憶。&lt;/p>
&lt;p>同一套代數換一個介面就是 &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>，那也是這兩者多數操作對得上的原因。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>連接產出的新關係怎麼改變列數與空缺，在 &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>。同一組操作在 SQL 與 DataFrame 兩個介面上的對應、以及對應斷掉的三個位置，在 &lt;a href="https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/same-relational-algebra/" data-link-title="8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異" data-link-desc="SQL 與 DataFrame 共用的關聯代數、同一份資料上兩邊寫法的實測比對，以及照著逐句翻譯會靜默失準的位置">python 模組八 8.4&lt;/a>。&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> 上、產出 relation 的運算——relation 指一組欄位固定、由若干列構成的資料集合，資料表是它的一種，查詢途中的中間結果也是。運算有這幾個：挑列（選取）、挑欄（投影）、把兩份的每一列各配一次（<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">積</a>）、在積上面加條件挑出要留的（連接）、以及聯集、差集、更名。連接自己就是積加上選取的組合，同一層還有兩個這個分類反覆用到的導出運算——<a href="/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">半連接與反連接</a>，配起來之後只帶走左邊那一份。SQL 的子句是這些運算的一層語法外衣，而不是另一套獨立的規則。</p>
<h2 id="封閉性是它最重要的性質">封閉性是它最重要的性質</h2>
<p>每個運算的輸入是關係、輸出還是關係，所以運算可以一個接一個組下去，中間結果與來源表的地位相同。這一條買到兩件事：子查詢可以寫在任何接受關係的位置，而多層查詢的判讀方式是逐層看每一層的欄與列。</p>
<p>它也解釋了為什麼<a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>的產物可以繼續當下一個連接的輸入——那份補了 NULL 的東西仍然是一個關係。</p>
<h2 id="sql-與它並非一對一">SQL 與它並非一對一</h2>
<p>SQL 在代數之上多了幾樣代數本身沒有的：重複的列（代數的關係是集合，SQL 的表允許重複，所以才需要 <code>DISTINCT</code>）、<a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a> 與三值邏輯、以及排序。這幾樣正好是最容易讀錯的幾處，理由是它們不受代數的規則保護。</p>
<h2 id="概念位置">概念位置</h2>
<p><a href="/blog/backend/01-database/sql/" data-link-title="SQL：這個語言為什麼長這樣" data-link-desc="SQL 的設計而不是它的語法表：子句為什麼這樣切、一個查詢該怎麼讀，以及代價落在哪裡">SQL 這個分類</a>講語意模型的那幾篇，判斷標準全部收在這一層：子句的先後、連接的左右、條件擺哪一邊、分組的鍵，每一項都是某個運算的組合規則，而不是設計者的偏好。知道規則從代數來，讀不熟的查詢時就有一個可以推的依據，不必靠記憶。</p>
<p>同一套代數換一個介面就是 <a href="/blog/backend/01-database/sql/knowledge-cards/dataframe/" data-link-title="DataFrame（資料框）" data-link-desc="SQL 教材拿 pandas 或資料框當對照時，查 DataFrame 是什麼、它與資料表差在哪">DataFrame</a>，那也是這兩者多數操作對得上的原因。</p>
<h2 id="往下走">往下走</h2>
<p>連接產出的新關係怎麼改變列數與空缺，在 <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>。同一組操作在 SQL 與 DataFrame 兩個介面上的對應、以及對應斷掉的三個位置，在 <a href="/blog/programming/python/basic/08-data-analysis/same-relational-algebra/" data-link-title="8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異" data-link-desc="SQL 與 DataFrame 共用的關聯代數、同一份資料上兩邊寫法的實測比對，以及照著逐句翻譯會靜默失準的位置">python 模組八 8.4</a>。</p>
]]></content:encoded></item><item><title>Constraint（約束）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/constraint/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/constraint/</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;/p>
&lt;p>常見的幾種各自保證不同的事。&lt;code>NOT NULL&lt;/code> 保證這一欄永遠有值；&lt;code>UNIQUE&lt;/code> 保證這一欄的值不重複；&lt;code>PRIMARY KEY&lt;/code> 是前兩者合起來再加上「這是本表辨識一列的依據」；&lt;code>FOREIGN KEY&lt;/code> 保證這一欄的值在另一張表裡找得到對應的那一列；&lt;code>CHECK&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/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">空值&lt;/a>：&lt;code>NOT IN&lt;/code> 在子查詢那一欄出現 &lt;code>NULL&lt;/code> 的時候會整個回空集合而不報錯，所以否定式的成員判斷一般用 &lt;code>NOT EXISTS&lt;/code>。這條規則有一個豁免——那一欄掛了 &lt;code>NOT NULL&lt;/code>。&lt;strong>豁免的條件是約束而不是觀察&lt;/strong>：翻遍現在的資料都沒有 &lt;code>NULL&lt;/code>，只證明此刻沒有；約束證明的是往後也不會有，而查詢要活得比這一批資料久。&lt;/p>
&lt;p>同一個結構在別處反覆出現：&lt;code>UNIQUE&lt;/code> 讓「這個連接會不會讓一列配到多列」變成可以事先回答的問題（見 &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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>），&lt;code>FOREIGN KEY&lt;/code> 讓「這個 &lt;code>JOIN&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/ddl-dml/" data-link-title="DDL 與 DML（定義結構與動資料）" data-link-desc="看到「這個帳號只需要 DML 權限」或遷移文件把兩者分開講時，查這條界線切在哪裡">DDL&lt;/a> 建立與修改，而不是由日常的讀寫語句。這條歸屬有兩個後果：加約束的權限跟建表的權限走在一起，不跟常態運行的帳號走；加約束到一張已經有資料的表上會失敗，如果既有資料裡有違反它的那幾列。&lt;/p>
&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;code>UNIQUE&lt;/code> 在多數引擎底下確實靠一個索引實作，而兩者的責任不同：索引買的是查找速度，約束買的是內容的保證。刪掉一個索引查詢會變慢，刪掉一個約束則是往後的寫入不再被擋。&lt;/p>
&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="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&lt;/a>可用的資訊，所以它買到的有時候多於內容的保證。同一張五千列的表、同一個 &lt;code>EXISTS&lt;/code> 查詢、&lt;code>recordDate&lt;/code> 上同樣掛著索引，只把索引從 &lt;code>UNIQUE&lt;/code> 換成普通索引，SQLite 3.51.0 產出的位元碼就多一圈內層迴圈——唯一的時候引擎知道那次查找至多回一列，不唯一的時候它得準備好再看下一筆。這個差別在 &lt;code>EXPLAIN QUERY PLAN&lt;/code> 上看不見，兩邊都印同一行 &lt;code>SEARCH y USING INDEX ix (recordDate=?)&lt;/code>，要 &lt;code>EXPLAIN&lt;/code> 到位元碼那一層才分得開；而在這組資料上它沒有量出時間差。同一組查詢在 DuckDB 1.5.5 上的計畫與有沒有 &lt;code>UNIQUE&lt;/code> 無關。所以約束會不會影響代價是各家自己的決定，不是約束本身的性質（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機&lt;/a>）。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>&lt;code>NULL&lt;/code> 在比較、計數與分組裡各自怎麼表現，以及否定式那條規則的完整推導，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL（空值）&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> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節。約束能在寫入時擋下哪些會讓答案錯掉的資料、為什麼問錯問題的那一類錯它擋不下，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>。&lt;code>FOREIGN KEY&lt;/code> 擋下哪兩個方向的寫入、同一段宣告在各家引擎為什麼未必生效，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證&lt;/a>。約束由誰有權建立、以及它跟常態運行帳號的權限為什麼分開，在 &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></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> 的時候檢查——寫入違反它就失敗，所以一個有約束的欄位，它的內容在整張表的範圍內都成立。它與應用程式裡的檢查差在生效的位置：應用程式的檢查只涵蓋走過那段程式碼的寫入，約束涵蓋每一次寫入，包含手動改的、遷移腳本改的、以及另一個服務改的。</p>
<p>常見的幾種各自保證不同的事。<code>NOT NULL</code> 保證這一欄永遠有值；<code>UNIQUE</code> 保證這一欄的值不重複；<code>PRIMARY KEY</code> 是前兩者合起來再加上「這是本表辨識一列的依據」；<code>FOREIGN KEY</code> 保證這一欄的值在另一張表裡找得到對應的那一列；<code>CHECK</code> 保證這一欄滿足一個自己寫的條件（例如金額不為負）。</p>
<h2 id="它決定哪些推論可以做">它決定哪些推論可以做</h2>
<p>一個判斷標準能不能省掉某一步，取決於約束有沒有把那一步的前提固定住。最直接的一例是<a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">空值</a>：<code>NOT IN</code> 在子查詢那一欄出現 <code>NULL</code> 的時候會整個回空集合而不報錯，所以否定式的成員判斷一般用 <code>NOT EXISTS</code>。這條規則有一個豁免——那一欄掛了 <code>NOT NULL</code>。<strong>豁免的條件是約束而不是觀察</strong>：翻遍現在的資料都沒有 <code>NULL</code>，只證明此刻沒有；約束證明的是往後也不會有，而查詢要活得比這一批資料久。</p>
<p>同一個結構在別處反覆出現：<code>UNIQUE</code> 讓「這個連接會不會讓一列配到多列」變成可以事先回答的問題（見 <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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>），<code>FOREIGN KEY</code> 讓「這個 <code>JOIN</code> 會不會掉列」有一個不必查資料就成立的答案。</p>
<h2 id="概念位置">概念位置</h2>
<p>約束住在結構那一側，所以它由 <a href="/blog/backend/01-database/sql/knowledge-cards/ddl-dml/" data-link-title="DDL 與 DML（定義結構與動資料）" data-link-desc="看到「這個帳號只需要 DML 權限」或遷移文件把兩者分開講時，查這條界線切在哪裡">DDL</a> 建立與修改，而不是由日常的讀寫語句。這條歸屬有兩個後果：加約束的權限跟建表的權限走在一起，不跟常態運行的帳號走；加約束到一張已經有資料的表上會失敗，如果既有資料裡有違反它的那幾列。</p>
<p>它與<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>在外觀上容易混——<code>UNIQUE</code> 在多數引擎底下確實靠一個索引實作，而兩者的責任不同：索引買的是查找速度，約束買的是內容的保證。刪掉一個索引查詢會變慢，刪掉一個約束則是往後的寫入不再被擋。</p>
<p>這條分工有一個邊界：約束宣告的內容同時是<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>可用的資訊，所以它買到的有時候多於內容的保證。同一張五千列的表、同一個 <code>EXISTS</code> 查詢、<code>recordDate</code> 上同樣掛著索引，只把索引從 <code>UNIQUE</code> 換成普通索引，SQLite 3.51.0 產出的位元碼就多一圈內層迴圈——唯一的時候引擎知道那次查找至多回一列，不唯一的時候它得準備好再看下一筆。這個差別在 <code>EXPLAIN QUERY PLAN</code> 上看不見，兩邊都印同一行 <code>SEARCH y USING INDEX ix (recordDate=?)</code>，要 <code>EXPLAIN</code> 到位元碼那一層才分得開；而在這組資料上它沒有量出時間差。同一組查詢在 DuckDB 1.5.5 上的計畫與有沒有 <code>UNIQUE</code> 無關。所以約束會不會影響代價是各家自己的決定，不是約束本身的性質（<a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a>）。</p>
<h2 id="往下走">往下走</h2>
<p><code>NULL</code> 在比較、計數與分組裡各自怎麼表現，以及否定式那條規則的完整推導，在 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL（空值）</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> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節。約束能在寫入時擋下哪些會讓答案錯掉的資料、為什麼問錯問題的那一類錯它擋不下，在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>。<code>FOREIGN KEY</code> 擋下哪兩個方向的寫入、同一段宣告在各家引擎為什麼未必生效，在 <a href="/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證</a>。約束由誰有權建立、以及它跟常態運行帳號的權限為什麼分開，在 <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>
]]></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>Cartesian Product（笛卡兒積）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/</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/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>的一個運算，而連接由它與選取組合而成：全部組合先在語意上成立，條件再從裡面挑出要留的那些。&lt;/p>
&lt;p>SQL 寫成 &lt;code>CROSS JOIN&lt;/code>，也可以在 &lt;code>FROM&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-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&lt;/a>按代價選。條件把兩邊綁起來的時候，它用索引或雜湊直接找出配得上的那些列，全部組合一次都沒有被造出來。&lt;/p>
&lt;p>條件缺席的時候展開才會發生。一張逐日的觀測表跟自己做沒有條件的 &lt;code>CROSS JOIN&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="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">Weather&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">recordDate&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 class="n">temperature&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">2&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">3890&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">Weather&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="nb">date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2015-01-01&amp;#39;&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="s1">&amp;#39; day&amp;#39;&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">40&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">4&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">UNIQUE&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="n">Weather&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">recordDate&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&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，Weather 表 3891 列
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">SELECT count(*) FROM Weather a CROSS JOIN Weather b;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">-- 15139881
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">|--SCAN a USING COVERING INDEX ix
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">`--SCAN b USING COVERING INDEX ix&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>15139881 是 3891 的平方。外層每掃一列，內層就把整張表再走一遍，而這一千五百萬對配對在後面的子句拿到資料之前就要先產生出來。這張表加到 13891 列之後，列數變成 3.6 倍而配對數變成 192959881——&lt;strong>將近十三倍&lt;/strong>。成長是平方而非線性，這正是這個運算與其他多出來的列的差別。&lt;/p>
&lt;p>&lt;strong>要看的是條件把不把兩邊綁起來，而不是用了哪個關鍵字。&lt;/strong> &lt;code>CROSS JOIN&lt;/code> 加一個等值條件，與 &lt;code>JOIN ... ON&lt;/code> 寫同一個條件，對引擎是同一段查詢（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a>）；反過來，&lt;code>JOIN&lt;/code> 寫了而 &lt;code>ON&lt;/code> 的條件只提到單邊的欄位，展開照樣發生。&lt;/p>
&lt;h2 id="與列數膨脹是兩種不同的多">與列數膨脹是兩種不同的多&lt;/h2>
&lt;p>結果比預期多的時候有兩個成因，分辨它們決定要改什麼。&lt;/p>
&lt;p>&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 上的行為">列數膨脹&lt;/a>是條件正確而一列配到了多列，於是它被複製那麼多次——多出來的量由資料的重複程度決定，改法是把展開的那一層先收成一列，或改用不展開的寫法。笛卡兒積是條件從一開始就沒有把兩邊綁住——多出來的量由兩張表各自的大小決定，改法是補上那個條件。&lt;/p>
&lt;p>分辨的問句落在條件上而非結果上：&lt;strong>這個條件把一列綁到對面幾列。&lt;/strong> 綁到常數量級（幾列、幾十列）是配對條件，多出來的量由重複程度決定，那是膨脹；綁到與表的大小同量級，那個條件篩掉了一些列，而每一列對面掛著的對象數仍然隨表一起長，多出來的量由兩張表的大小決定，那是積。&lt;/p>
&lt;p>「有沒有同時引用兩邊」不足以分辨。&lt;code>ON a.v &amp;gt; b.v&lt;/code> 同時引用了兩邊，而它把左邊每一列綁到右邊將近一半——&lt;code>v&lt;/code> 的值互不相同時，一千列得 499500 對、兩千列得 1999000 對，仍然是平方。低基數的等值鍵同理：&lt;code>ON a.區域 = b.區域&lt;/code> 只有幾個區域時，一千列對一千列得五十萬對。&lt;/p>
&lt;p>這裡刻意不問「這一列是靠哪個條件配起來的」——&lt;code>JOIN&lt;/code> 寫了而 &lt;code>ON&lt;/code> 只提單邊欄位的那個形態，讀者答得出「靠那個 &lt;code>ON&lt;/code> 條件」而它並沒有把兩邊配起來，問句會把積判成膨脹。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>笛卡兒積是連接的下限——條件寫得越少，結果越靠近它，而條件寫滿的時候結果落在兩張表配得上的那些列。所以「連接的結果集會不會整個換一個量級」這個問題等於「條件綁住了多少」，與用了 &lt;code>JOIN&lt;/code> 還是 &lt;code>CROSS JOIN&lt;/code> 無關。而條件綁不綁得住，由它引用到幾個&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 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">表的出現&lt;/a>決定——與那個分辨問句是同一件事。&lt;/p>
&lt;p>它在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>裡的位置說明了漏寫連接條件的後果為什麼這麼大：查詢退回它的定義起點，全部組合重新成立。這是結果集大小的整個量級換掉，與逐步變慢是不同的一件事。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>條件正確而列數仍然變多的那一種，在 &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>EXISTS&lt;/code> 兩種寫法的實測對照，在 &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/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join（半連接與反連接）&lt;/a>。&lt;code>CROSS JOIN&lt;/code> 這個關鍵字對讀的人宣告了什麼，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a>。production 查詢的結果集大小怎麼治理，在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion&lt;/a>。&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/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>的一個運算，而連接由它與選取組合而成：全部組合先在語意上成立，條件再從裡面挑出要留的那些。</p>
<p>SQL 寫成 <code>CROSS JOIN</code>，也可以在 <code>FROM</code> 裡用逗號把兩張表隔開。</p>
<h2 id="條件決定它會不會被展開">條件決定它會不會被展開</h2>
<p>代數說連接從積開始，而那是語意層的定義，執行方式由<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>按代價選。條件把兩邊綁起來的時候，它用索引或雜湊直接找出配得上的那些列，全部組合一次都沒有被造出來。</p>
<p>條件缺席的時候展開才會發生。一張逐日的觀測表跟自己做沒有條件的 <code>CROSS JOIN</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">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">Weather</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">recordDate</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="n">temperature</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">2</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">3890</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">Weather</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="nb">date</span><span class="p">(</span><span class="s1">&#39;2015-01-01&#39;</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="s1">&#39; day&#39;</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">40</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">4</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">UNIQUE</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="n">Weather</span><span class="p">(</span><span class="n">recordDate</span><span class="p">);</span></span></span></code></pre></div>




<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，Weather 表 3891 列
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT count(*) FROM Weather a CROSS JOIN Weather b;
</span></span><span class="line"><span class="ln">3</span><span class="cl">-- 15139881
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">|--SCAN a USING COVERING INDEX ix
</span></span><span class="line"><span class="ln">6</span><span class="cl">`--SCAN b USING COVERING INDEX ix</span></span></code></pre></div><p>15139881 是 3891 的平方。外層每掃一列，內層就把整張表再走一遍，而這一千五百萬對配對在後面的子句拿到資料之前就要先產生出來。這張表加到 13891 列之後，列數變成 3.6 倍而配對數變成 192959881——<strong>將近十三倍</strong>。成長是平方而非線性，這正是這個運算與其他多出來的列的差別。</p>
<p><strong>要看的是條件把不把兩邊綁起來，而不是用了哪個關鍵字。</strong> <code>CROSS JOIN</code> 加一個等值條件，與 <code>JOIN ... ON</code> 寫同一個條件，對引擎是同一段查詢（<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>）；反過來，<code>JOIN</code> 寫了而 <code>ON</code> 的條件只提到單邊的欄位，展開照樣發生。</p>
<h2 id="與列數膨脹是兩種不同的多">與列數膨脹是兩種不同的多</h2>
<p>結果比預期多的時候有兩個成因，分辨它們決定要改什麼。</p>
<p><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 上的行為">列數膨脹</a>是條件正確而一列配到了多列，於是它被複製那麼多次——多出來的量由資料的重複程度決定，改法是把展開的那一層先收成一列，或改用不展開的寫法。笛卡兒積是條件從一開始就沒有把兩邊綁住——多出來的量由兩張表各自的大小決定，改法是補上那個條件。</p>
<p>分辨的問句落在條件上而非結果上：<strong>這個條件把一列綁到對面幾列。</strong> 綁到常數量級（幾列、幾十列）是配對條件，多出來的量由重複程度決定，那是膨脹；綁到與表的大小同量級，那個條件篩掉了一些列，而每一列對面掛著的對象數仍然隨表一起長，多出來的量由兩張表的大小決定，那是積。</p>
<p>「有沒有同時引用兩邊」不足以分辨。<code>ON a.v &gt; b.v</code> 同時引用了兩邊，而它把左邊每一列綁到右邊將近一半——<code>v</code> 的值互不相同時，一千列得 499500 對、兩千列得 1999000 對，仍然是平方。低基數的等值鍵同理：<code>ON a.區域 = b.區域</code> 只有幾個區域時，一千列對一千列得五十萬對。</p>
<p>這裡刻意不問「這一列是靠哪個條件配起來的」——<code>JOIN</code> 寫了而 <code>ON</code> 只提單邊欄位的那個形態，讀者答得出「靠那個 <code>ON</code> 條件」而它並沒有把兩邊配起來，問句會把積判成膨脹。</p>
<h2 id="概念位置">概念位置</h2>
<p>笛卡兒積是連接的下限——條件寫得越少，結果越靠近它，而條件寫滿的時候結果落在兩張表配得上的那些列。所以「連接的結果集會不會整個換一個量級」這個問題等於「條件綁住了多少」，與用了 <code>JOIN</code> 還是 <code>CROSS JOIN</code> 無關。而條件綁不綁得住，由它引用到幾個<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">表的出現</a>決定——與那個分辨問句是同一件事。</p>
<p>它在<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>裡的位置說明了漏寫連接條件的後果為什麼這麼大：查詢退回它的定義起點，全部組合重新成立。這是結果集大小的整個量級換掉，與逐步變慢是不同的一件事。</p>
<h2 id="往下走">往下走</h2>
<p>條件正確而列數仍然變多的那一種，在 <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>EXISTS</code> 兩種寫法的實測對照，在 <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/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join（半連接與反連接）</a>。<code>CROSS JOIN</code> 這個關鍵字對讀的人宣告了什麼，在 <a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>。production 查詢的結果集大小怎麼治理，在 <a href="/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion</a>。</p>
]]></content:encoded></item><item><title>Database Engine（資料庫引擎）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/database-engine/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/database-engine/</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 教材說「組成一個關係」而不確定它指的是不是資料表時">關係&lt;/a>的那個軟體。SQLite、PostgreSQL、MySQL、DuckDB 各是一個引擎，而同一個引擎的不同版本也可能有不同的規則。&lt;/p>
&lt;p>本分類反覆說「引擎」在做某件事，說的都是同一件事——差別在它做的是哪一種決定，而那決定了換一家引擎會不會得到不同的結果。&lt;/p>
&lt;h2 id="它的四種決定換引擎的後果各不相同">它的四種決定，換引擎的後果各不相同&lt;/h2>
&lt;p>&lt;strong>接不接受這段文字。&lt;/strong> 引擎檢查名字解析得到、型別配得上、子句的組合合乎規則。標準定死的那些各家一致；標準禁止而各家自行放寬的那些不一致，於是同一段查詢在一家報錯、在另一家跑得動（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象&lt;/a> 各有一個實例）。&lt;/p>
&lt;p>&lt;strong>名字指向哪個物件。&lt;/strong> 沒加引號的識別字被摺成什麼樣由引擎決定，各家的規則不同（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字&lt;/a>）。&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="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&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>&lt;strong>回哪一批列。&lt;/strong> 這一項由&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>的組合規則定死，各家一致。三值邏輯、外連接補 &lt;code>NULL&lt;/code>、&lt;code>NOT IN&lt;/code> 的展開方式都在這一層，所以那幾類錯誤換引擎不會消失（&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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&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="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&lt;/a>都住在它裡面，而權限系統是它另外管的一層（&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;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&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/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字&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="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">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;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics（統計資訊）&lt;/a>。&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 教材說「組成一個關係」而不確定它指的是不是資料表時">關係</a>的那個軟體。SQLite、PostgreSQL、MySQL、DuckDB 各是一個引擎，而同一個引擎的不同版本也可能有不同的規則。</p>
<p>本分類反覆說「引擎」在做某件事，說的都是同一件事——差別在它做的是哪一種決定，而那決定了換一家引擎會不會得到不同的結果。</p>
<h2 id="它的四種決定換引擎的後果各不相同">它的四種決定，換引擎的後果各不相同</h2>
<p><strong>接不接受這段文字。</strong> 引擎檢查名字解析得到、型別配得上、子句的組合合乎規則。標準定死的那些各家一致；標準禁止而各家自行放寬的那些不一致，於是同一段查詢在一家報錯、在另一家跑得動（<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> 與 <a href="/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象</a> 各有一個實例）。</p>
<p><strong>名字指向哪個物件。</strong> 沒加引號的識別字被摺成什麼樣由引擎決定，各家的規則不同（<a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a>）。</p>
<p><strong>要花多久。</strong> 這由<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</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><strong>回哪一批列。</strong> 這一項由<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>的組合規則定死，各家一致。三值邏輯、外連接補 <code>NULL</code>、<code>NOT IN</code> 的展開方式都在這一層，所以那幾類錯誤換引擎不會消失（<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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<h2 id="概念位置">概念位置</h2>
<p><strong>引擎是「文字之外」那幾方裡射程最大的一方</strong>——識別字規則、寬鬆度與設定、<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>都住在它裡面，而權限系統是它另外管的一層（<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>引擎不決定的只有一件事：這批列是不是那個問題的答案。那一層沒有機械判定，理由在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>。</p>
<p>錯誤訊息的措辭由引擎決定而且會隨版本改，所以各篇引的那些字串是對照用的——判讀看它拒絕了什麼，不看它怎麼說。</p>
<h2 id="往下走">往下走</h2>
<p>各家在同一段查詢上的分歧最集中的地方，在 <a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a>。挑執行方式的那個元件在 <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>。它另外存著的那份摘要在 <a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics（統計資訊）</a>。</p>
]]></content:encoded></item><item><title>Semantic Model（語意模型）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semantic-model/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semantic-model/</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 教材說「組成一個關係」而不確定它指的是不是資料表時">關係&lt;/a>」這件事的規則。它只回答結果是什麼，不回答怎麼算到、要花多久。&lt;/p>
&lt;p>規則的來源是&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&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 教材說「組成一個關係」而不確定它指的是不是資料表時">關係&lt;/a>上的運算，而查詢是那些運算的一個組合。代數是來源，語意模型是那套規則本身。&lt;/p>
&lt;h2 id="它與另外兩件事分開">它與另外兩件事分開&lt;/h2>
&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="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&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>&lt;strong>與書寫順序分開。&lt;/strong> &lt;code>SELECT&lt;/code> 寫在最前面而它在求值順序上排在後面，這正是語意模型與文字順序不同的地方（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法&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/database-engine/" data-link-title="Database Engine（資料庫引擎）" data-link-desc="文章說「引擎決定」「換一家引擎就不同」時，查那是哪一個層次的決定">引擎&lt;/a>不會變：三值邏輯讓條件的答案有第三種、外連接配不上時補 &lt;code>NULL&lt;/code>、&lt;code>NOT IN&lt;/code> 展開成一串連乘、分組把一組收成一列。三值邏輯、補 &lt;code>NULL&lt;/code> 與 &lt;code>NOT IN&lt;/code> 的展開在 &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;a href="https://tarrragon.github.io/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象&lt;/a>。&lt;/p>
&lt;p>模型之外另有幾件事在決定同一段查詢的結果：識別字被摺成什麼樣、引擎接不接受標準禁止的寫法並自己補一個值、送出這段 SQL 的人被允許讀哪些表。識別字在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字&lt;/a>，引擎自己補值在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機&lt;/a>，權限在 &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;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;strong>「符合語意模型」與「答案對了」是兩件事。&lt;/strong> 模型與&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束&lt;/a>的分工也在這條線上——約束管資料能長成什麼樣，模型管那批資料算出什麼。 模型保證的是這段文字算出哪一批列，而那批列是不是要問的那個問題的答案，模型不管也管不到——問題不在查詢裡（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&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/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">Relational Algebra（關聯代數）&lt;/a>。模型與書寫順序、執行順序怎麼分開，在 &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/knowledge-cards/database-engine/" data-link-title="Database Engine（資料庫引擎）" data-link-desc="文章說「引擎決定」「換一家引擎就不同」時，查那是哪一個層次的決定">Database Engine（資料庫引擎）&lt;/a>。&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 教材說「組成一個關係」而不確定它指的是不是資料表時">關係</a>」這件事的規則。它只回答結果是什麼，不回答怎麼算到、要花多久。</p>
<p>規則的來源是<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>：每一個子句對應一個作用在<a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">關係</a>上的運算，而查詢是那些運算的一個組合。代數是來源，語意模型是那套規則本身。</p>
<h2 id="它與另外兩件事分開">它與另外兩件事分開</h2>
<p><strong>與執行方式分開。</strong> <a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</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><strong>與書寫順序分開。</strong> <code>SELECT</code> 寫在最前面而它在求值順序上排在後面，這正是語意模型與文字順序不同的地方（<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>模型定死的那些，換一家<a href="/blog/backend/01-database/sql/knowledge-cards/database-engine/" data-link-title="Database Engine（資料庫引擎）" data-link-desc="文章說「引擎決定」「換一家引擎就不同」時，查那是哪一個層次的決定">引擎</a>不會變：三值邏輯讓條件的答案有第三種、外連接配不上時補 <code>NULL</code>、<code>NOT IN</code> 展開成一串連乘、分組把一組收成一列。三值邏輯、補 <code>NULL</code> 與 <code>NOT IN</code> 的展開在 <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>，分組在 <a href="/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象</a>。</p>
<p>模型之外另有幾件事在決定同一段查詢的結果：識別字被摺成什麼樣、引擎接不接受標準禁止的寫法並自己補一個值、送出這段 SQL 的人被允許讀哪些表。識別字在 <a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a>，引擎自己補值在 <a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a>，權限在 <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>
<h2 id="概念位置">概念位置</h2>
<p><strong>「符合語意模型」與「答案對了」是兩件事。</strong> 模型與<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>的分工也在這條線上——約束管資料能長成什麼樣，模型管那批資料算出什麼。 模型保證的是這段文字算出哪一批列，而那批列是不是要問的那個問題的答案，模型不管也管不到——問題不在查詢裡（<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>）。講語意模型的那幾篇反覆出現的安靜錯誤——列數膨脹、外連接被抵銷、空值比較落空——全部發生在模型算對了的前提下。</p>
<h2 id="往下走">往下走</h2>
<p>規則的來源與封閉性在 <a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">Relational Algebra（關聯代數）</a>。模型與書寫順序、執行順序怎麼分開，在 <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/knowledge-cards/database-engine/" data-link-title="Database Engine（資料庫引擎）" data-link-desc="文章說「引擎決定」「換一家引擎就不同」時，查那是哪一個層次的決定">Database Engine（資料庫引擎）</a>。</p>
]]></content:encoded></item><item><title>Cardinality 與 Selectivity（基數與選擇率）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cardinality-and-selectivity/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cardinality-and-selectivity/</guid><description>&lt;p>基數是一個集合裡有幾個成員。這個詞在 SQL 裡落在兩個地方：一份 &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;p>&lt;strong>「基數」這個譯名在站內還有另外三個所指。&lt;/strong> 資料建模那一側說的是兩張表之間的關係屬於一對一、一對多還是多對多；查詢結果那一側說的是一段查詢回幾列（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion&lt;/a>）；監控那一側說的是一個指標有幾種標籤組合。本卡與最佳化器讀的都是上一段那個計數義，其餘幾個只是共用一個譯名。&lt;/p>
&lt;p>選擇率是一個條件留下多少比例的列。它是條件的屬性而非欄位的屬性：&lt;code>狀態 = '已完成'&lt;/code> 與 &lt;code>狀態 = '已取消'&lt;/code> 寫在同一欄上，而兩者的選擇率可以差一個量級以上——下一節那組資料裡是十八倍。&lt;/p>
&lt;p>兩者在等值條件上有一個關係：值分布均勻的時候，&lt;code>欄 = 某值&lt;/code> 的選擇率大約是相異值個數的倒數。分布不均勻的時候這個估算失準，而那正是&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;h2 id="同一個索引最佳化器對不同的值做相反的決定">同一個索引，最佳化器對不同的值做相反的決定&lt;/h2>
&lt;p>二十萬列的訂單表，&lt;code>狀態&lt;/code> 只有兩個值（已完成佔九成五、已取消佔半成），&lt;code>顧客編號&lt;/code> 有五萬個值。兩欄各建一個索引，跑過 &lt;code>ANALYZE&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">-- PostgreSQL 18
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">SELECT 備註 FROM 訂單 WHERE 狀態 = &amp;#39;已完成&amp;#39;; -- Seq Scan（索引沒被用）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">SELECT 備註 FROM 訂單 WHERE 狀態 = &amp;#39;已取消&amp;#39;; -- Index Scan using ix狀態
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">SELECT 備註 FROM 訂單 WHERE 顧客編號 = 12345; -- Index Scan using ix顧客&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一個 &lt;code>ix狀態&lt;/code>，同一種條件形狀，兩個值得到相反的決定。走索引要先讀索引拿到位置、再回原表取 &lt;code>備註&lt;/code> 這一欄，每一列付兩次；命中九成五的列時，這筆帳輸給從頭讀一遍。&lt;/p>
&lt;p>引擎的依據寫在統計裡，而那份摘要是看得到的：&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">SELECT attname, n_distinct, most_common_freqs FROM pg_stats WHERE tablename = &amp;#39;訂單&amp;#39;;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> 狀態 | 2 | {0.94996667,0.050033335}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> 顧客編號 | -0.25113 |&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>n_distinct&lt;/code> 是基數，&lt;code>most_common_freqs&lt;/code> 是那幾個值各自的選擇率。&lt;strong>這兩欄是抽樣估出來的，每跑一次 &lt;code>ANALYZE&lt;/code> 都會在小數後幾位變動&lt;/strong>——真值是 0.95 與 0.05，而印出來的從來不是整數。&lt;code>顧客編號&lt;/code> 那一格的負數是另一種寫法——絕對值小於一時代表「相異值個數是總列數的這個比例」，0.25 乘二十萬約等於五萬。&lt;/p>
&lt;p>&lt;strong>這兩個數是估計而不是事實。&lt;/strong> 它們來自上一次收集統計的那一刻，所以同一段查詢在資料變動之後可能換一個計畫，而查詢的文字一個字都沒改（&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」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics&lt;/a>）。&lt;/p>
&lt;h2 id="同一份統計各家的取捨不同">同一份統計，各家的取捨不同&lt;/h2>
&lt;p>上面那組表搬到 SQLite 3.51，&lt;code>ANALYZE&lt;/code> 跑過之後三段查詢全部走索引，包含命中九成五的那一段。SQLite 的 &lt;code>sqlite_stat1&lt;/code> 記下的數字與 PostgreSQL 量到的是同一件事：&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">訂單 / ix狀態 → 200000 100000 -- 二十萬列，每個相異值平均十萬列
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">訂單 / ix顧客 → 200000 4 -- 二十萬列，每個相異值平均四列&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>所以「基數低的欄位上索引沒用」這句話要分兩半聽：低基數確實讓多數值的選擇率變高，而&lt;strong>要不要因此放棄索引是最佳化器的判斷，各家的門檻不同&lt;/strong>。可攜的說法是選擇率，不是計畫。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;strong>這兩關的成本模型建在一個前提上：資料按列存放，而索引是另一份按鍵排好的結構，走它要回原表取其餘欄位。&lt;/strong> 欄式引擎（DuckDB、以及讀 parquet 這一類）不是這個形狀——它按欄讀、靠區塊的最大最小值跳過整段，沒有逐列回表那筆帳，所以「命中九成五就別走索引」在那裡不成立，對應的手段是資料的排序與分區。下面兩關針對的是前一種。&lt;/p>
&lt;p>選擇率與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">sargable&lt;/a> 是索引派不派得上用場的兩個獨立條件。sargable 問的是條件的&lt;strong>形狀&lt;/strong>能不能翻成索引上的一次查找——欄位被函式包住就翻不了。&lt;strong>這一關過不了不等於索引沒用&lt;/strong>：條件裡用到的欄位都在索引上的時候，引擎仍然可能整個掃過索引再逐列過濾，省下的是回原表那筆帳（PostgreSQL 印成 &lt;code>Index Only Scan&lt;/code> 加一個 &lt;code>Filter&lt;/code>，SQLite 印成 &lt;code>SCAN ... USING COVERING INDEX&lt;/code>）。所以兩關管的是不同的索引用法，而不是同一種用法的兩道閘。選擇率問的是通過查找之後&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;/p>
&lt;p>基數也決定連接之後的列數。低基數的欄位當連接鍵時，一列會配到對面同一個值的所有列，而那個數量隨表一起長——這是&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積&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-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics（統計資訊）&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="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">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;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>。production 上結果集大小怎麼治理，在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>基數是一個集合裡有幾個成員。這個詞在 SQL 裡落在兩個地方：一份 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 的基數是它有幾列，一個欄位的基數是它有幾個相異值。兩者是同一個定義用在不同的集合上——欄位的基數就是把那一欄單獨取出來、去掉重複之後的列數。</p>
<p><strong>「基數」這個譯名在站內還有另外三個所指。</strong> 資料建模那一側說的是兩張表之間的關係屬於一對一、一對多還是多對多；查詢結果那一側說的是一段查詢回幾列（<a href="/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion</a>）；監控那一側說的是一個指標有幾種標籤組合。本卡與最佳化器讀的都是上一段那個計數義，其餘幾個只是共用一個譯名。</p>
<p>選擇率是一個條件留下多少比例的列。它是條件的屬性而非欄位的屬性：<code>狀態 = '已完成'</code> 與 <code>狀態 = '已取消'</code> 寫在同一欄上，而兩者的選擇率可以差一個量級以上——下一節那組資料裡是十八倍。</p>
<p>兩者在等值條件上有一個關係：值分布均勻的時候，<code>欄 = 某值</code> 的選擇率大約是相異值個數的倒數。分布不均勻的時候這個估算失準，而那正是<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊</a>要另外記下最常出現的幾個值的原因。</p>
<h2 id="同一個索引最佳化器對不同的值做相反的決定">同一個索引，最佳化器對不同的值做相反的決定</h2>
<p>二十萬列的訂單表，<code>狀態</code> 只有兩個值（已完成佔九成五、已取消佔半成），<code>顧客編號</code> 有五萬個值。兩欄各建一個索引，跑過 <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">-- PostgreSQL 18
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT 備註 FROM 訂單 WHERE 狀態 = &#39;已完成&#39;;     -- Seq Scan（索引沒被用）
</span></span><span class="line"><span class="ln">3</span><span class="cl">SELECT 備註 FROM 訂單 WHERE 狀態 = &#39;已取消&#39;;     -- Index Scan using ix狀態
</span></span><span class="line"><span class="ln">4</span><span class="cl">SELECT 備註 FROM 訂單 WHERE 顧客編號 = 12345;    -- Index Scan using ix顧客</span></span></code></pre></div><p>同一個 <code>ix狀態</code>，同一種條件形狀，兩個值得到相反的決定。走索引要先讀索引拿到位置、再回原表取 <code>備註</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">SELECT attname, n_distinct, most_common_freqs FROM pg_stats WHERE tablename = &#39;訂單&#39;;
</span></span><span class="line"><span class="ln">2</span><span class="cl"> 狀態     |       2    | {0.94996667,0.050033335}
</span></span><span class="line"><span class="ln">3</span><span class="cl"> 顧客編號 | -0.25113   |</span></span></code></pre></div><p><code>n_distinct</code> 是基數，<code>most_common_freqs</code> 是那幾個值各自的選擇率。<strong>這兩欄是抽樣估出來的，每跑一次 <code>ANALYZE</code> 都會在小數後幾位變動</strong>——真值是 0.95 與 0.05，而印出來的從來不是整數。<code>顧客編號</code> 那一格的負數是另一種寫法——絕對值小於一時代表「相異值個數是總列數的這個比例」，0.25 乘二十萬約等於五萬。</p>
<p><strong>這兩個數是估計而不是事實。</strong> 它們來自上一次收集統計的那一刻，所以同一段查詢在資料變動之後可能換一個計畫，而查詢的文字一個字都沒改（<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics</a>）。</p>
<h2 id="同一份統計各家的取捨不同">同一份統計，各家的取捨不同</h2>
<p>上面那組表搬到 SQLite 3.51，<code>ANALYZE</code> 跑過之後三段查詢全部走索引，包含命中九成五的那一段。SQLite 的 <code>sqlite_stat1</code> 記下的數字與 PostgreSQL 量到的是同一件事：</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">訂單 / ix狀態 → 200000 100000     -- 二十萬列，每個相異值平均十萬列
</span></span><span class="line"><span class="ln">2</span><span class="cl">訂單 / ix顧客 → 200000 4          -- 二十萬列，每個相異值平均四列</span></span></code></pre></div><p>所以「基數低的欄位上索引沒用」這句話要分兩半聽：低基數確實讓多數值的選擇率變高，而<strong>要不要因此放棄索引是最佳化器的判斷，各家的門檻不同</strong>。可攜的說法是選擇率，不是計畫。</p>
<h2 id="概念位置">概念位置</h2>
<p><strong>這兩關的成本模型建在一個前提上：資料按列存放，而索引是另一份按鍵排好的結構，走它要回原表取其餘欄位。</strong> 欄式引擎（DuckDB、以及讀 parquet 這一類）不是這個形狀——它按欄讀、靠區塊的最大最小值跳過整段，沒有逐列回表那筆帳，所以「命中九成五就別走索引」在那裡不成立，對應的手段是資料的排序與分區。下面兩關針對的是前一種。</p>
<p>選擇率與 <a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">sargable</a> 是索引派不派得上用場的兩個獨立條件。sargable 問的是條件的<strong>形狀</strong>能不能翻成索引上的一次查找——欄位被函式包住就翻不了。<strong>這一關過不了不等於索引沒用</strong>：條件裡用到的欄位都在索引上的時候，引擎仍然可能整個掃過索引再逐列過濾，省下的是回原表那筆帳（PostgreSQL 印成 <code>Index Only Scan</code> 加一個 <code>Filter</code>，SQLite 印成 <code>SCAN ... USING COVERING INDEX</code>）。所以兩關管的是不同的索引用法，而不是同一種用法的兩道閘。選擇率問的是通過查找之後<strong>還剩多少列</strong>——形狀對而留下的列佔了大半，最佳化器仍然會選擇不走它。兩關都過，<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>才換得到速度。</p>
<p>基數也決定連接之後的列數。低基數的欄位當連接鍵時，一列會配到對面同一個值的所有列，而那個數量隨表一起長——這是<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積</a>那張卡分辨「積」與「膨脹」時用的同一個量。</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」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics（統計資訊）</a>。<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>。同一道題三種寫法的排名怎麼隨索引重排，在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>。production 上結果集大小怎麼治理，在 <a href="/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion</a>。</p>
]]></content:encoded></item><item><title>Semi-join 與 Anti-join（半連接與反連接）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/</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;/p>
&lt;p>SQL 用 &lt;code>EXISTS&lt;/code> 與 &lt;code>NOT EXISTS&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">-- 半連接：下過單的顧客
&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>&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">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="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="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">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="c1">-- 反連接：從未下單的顧客
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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>&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">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&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="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="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;h2 id="名字裡的半指結果只帶走一邊">名字裡的「半」指結果只帶走一邊&lt;/h2>
&lt;p>三個運算作用在同一對關係上，差別在結果帶走什麼。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>運算&lt;/th>
 &lt;th>結果的欄位&lt;/th>
 &lt;th>結果的列數&lt;/th>
 &lt;th>SQL 寫法&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>連接&lt;/td>
 &lt;td>左右兩邊都有&lt;/td>
 &lt;td>可能超過左邊的列數&lt;/td>
 &lt;td>&lt;code>JOIN ... ON&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>半連接&lt;/td>
 &lt;td>只有左邊的&lt;/td>
 &lt;td>至多左邊的列數&lt;/td>
 &lt;td>&lt;code>EXISTS&lt;/code>、&lt;code>IN&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>反連接&lt;/td>
 &lt;td>只有左邊的&lt;/td>
 &lt;td>至多左邊的列數&lt;/td>
 &lt;td>&lt;code>NOT EXISTS&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>右邊那份資料在這兩個運算裡的角色是條件，它的欄位到不了結果。要把右邊的欄位列出來就得用連接，那條判斷標準在 &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;h2 id="列數上限省掉去重這一步">列數上限省掉去重這一步&lt;/h2>
&lt;p>半連接問的是配不配得到，所以配到幾筆不影響結果——一位顧客手上有兩張訂單或二十張訂單，&lt;code>EXISTS&lt;/code> 都只讓她出現一次。同一個問題用連接寫，她會照訂單張數各出現一次，要補 &lt;code>DISTINCT&lt;/code> 把複製出來的列收掉（&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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>）。&lt;/p>
&lt;p>需要 &lt;code>DISTINCT&lt;/code> 因此是一個訊號：這個查詢問的是有沒有，而連接順便做了它不需要的配對。&lt;/p>
&lt;h2 id="反連接有兩條描述路徑">反連接有兩條描述路徑&lt;/h2>
&lt;p>&lt;code>NOT EXISTS&lt;/code> 直接說「找不到」。&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 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>那一版先讓左邊每一列都保留下來，再挑出補了 &lt;code>NULL&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="k">SELECT&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&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">LEFT&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="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">WHERE&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="k">IS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩段回同一批列，路徑不同——一條由條件直接表達，另一條靠外連接補出來的 &lt;code>NULL&lt;/code> 當標記。&lt;/p>
&lt;p>&lt;code>NOT IN&lt;/code> 的行為與這兩條分岔：子查詢的結果裡出現一個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL&lt;/a>，整段就回零列且不報錯。反連接的安全寫法因此落在 &lt;code>NOT EXISTS&lt;/code> 與外連接這兩條上（&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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&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/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>的正典運算清單因此多半只列到連接，而「有沒有對應資料」這一族問題全部落在這兩個運算上——找出下過單的顧客、找出從未下單的顧客、找出比前一天熱的日子，題目與寫法各異而運算只有這兩個。&lt;/p>
&lt;p>計畫上看不看得到這兩個運算的名字，各家不同。DuckDB 1.5.5 把它印成 &lt;code>Join Type: SEMI&lt;/code>；SQLite 3.51.0 沒有對應的運算子，它把 &lt;code>EXISTS&lt;/code> 的子查詢編成帶一個 &lt;code>LIMIT 1&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="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>上只看得到一個子查詢節點。&lt;/p>
&lt;p>它們與&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">積&lt;/a>分別是連接結果大小的兩個極端。積是條件缺席時的上限，列數是兩邊相乘；半連接把上限壓到左邊的列數，因為右邊配到幾列都只算一次。中間那一段是連接，結果的大小由配對的重複程度決定。&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> 配起來之後只留下左邊那一份：結果的欄位全部來自左邊，而左邊的每一列至多出現一次。留下的是在右邊找得到對象的那些列。反連接留下的是另一半——左邊那些在右邊配不到任何對象的列，結果的欄位同樣只有左邊的。</p>
<p>SQL 用 <code>EXISTS</code> 與 <code>NOT EXISTS</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">-- 半連接：下過單的顧客
</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></span><span class="line"><span class="ln">3</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="err">訂單</span><span class="w"> </span><span class="k">WHERE</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">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="c1">-- 反連接：從未下單的顧客
</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</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="err">訂單</span><span class="w"> </span><span class="k">WHERE</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><h2 id="名字裡的半指結果只帶走一邊">名字裡的「半」指結果只帶走一邊</h2>
<p>三個運算作用在同一對關係上，差別在結果帶走什麼。</p>
<table>
  <thead>
      <tr>
          <th>運算</th>
          <th>結果的欄位</th>
          <th>結果的列數</th>
          <th>SQL 寫法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>連接</td>
          <td>左右兩邊都有</td>
          <td>可能超過左邊的列數</td>
          <td><code>JOIN ... ON</code></td>
      </tr>
      <tr>
          <td>半連接</td>
          <td>只有左邊的</td>
          <td>至多左邊的列數</td>
          <td><code>EXISTS</code>、<code>IN</code></td>
      </tr>
      <tr>
          <td>反連接</td>
          <td>只有左邊的</td>
          <td>至多左邊的列數</td>
          <td><code>NOT EXISTS</code></td>
      </tr>
  </tbody>
</table>
<p>右邊那份資料在這兩個運算裡的角色是條件，它的欄位到不了結果。要把右邊的欄位列出來就得用連接，那條判斷標準在 <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>
<h2 id="列數上限省掉去重這一步">列數上限省掉去重這一步</h2>
<p>半連接問的是配不配得到，所以配到幾筆不影響結果——一位顧客手上有兩張訂單或二十張訂單，<code>EXISTS</code> 都只讓她出現一次。同一個問題用連接寫，她會照訂單張數各出現一次，要補 <code>DISTINCT</code> 把複製出來的列收掉（<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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<p>需要 <code>DISTINCT</code> 因此是一個訊號：這個查詢問的是有沒有，而連接順便做了它不需要的配對。</p>
<h2 id="反連接有兩條描述路徑">反連接有兩條描述路徑</h2>
<p><code>NOT EXISTS</code> 直接說「找不到」。<a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>那一版先讓左邊每一列都保留下來，再挑出補了 <code>NULL</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="p">.</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></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">LEFT</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="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</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></span></code></pre></div><p>兩段回同一批列，路徑不同——一條由條件直接表達，另一條靠外連接補出來的 <code>NULL</code> 當標記。</p>
<p><code>NOT IN</code> 的行為與這兩條分岔：子查詢的結果裡出現一個 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a>，整段就回零列且不報錯。反連接的安全寫法因此落在 <code>NOT EXISTS</code> 與外連接這兩條上（<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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<h2 id="概念位置">概念位置</h2>
<p>半連接與反連接跟連接一樣是導出運算：半連接等於連接之後只投影左邊的欄位再去重，反連接等於左邊減去半連接的結果。<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>的正典運算清單因此多半只列到連接，而「有沒有對應資料」這一族問題全部落在這兩個運算上——找出下過單的顧客、找出從未下單的顧客、找出比前一天熱的日子，題目與寫法各異而運算只有這兩個。</p>
<p>計畫上看不看得到這兩個運算的名字，各家不同。DuckDB 1.5.5 把它印成 <code>Join Type: SEMI</code>；SQLite 3.51.0 沒有對應的運算子，它把 <code>EXISTS</code> 的子查詢編成帶一個 <code>LIMIT 1</code> 計數器的迴圈，找到第一筆就跳出去——語意相同，而<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</a>上只看得到一個子查詢節點。</p>
<p>它們與<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">積</a>分別是連接結果大小的兩個極端。積是條件缺席時的上限，列數是兩邊相乘；半連接把上限壓到左邊的列數，因為右邊配到幾列都只算一次。中間那一段是連接，結果的大小由配對的重複程度決定。</p>
<h2 id="往下走">往下走</h2>
<p><code>IN</code>、<code>EXISTS</code> 與 <code>JOIN</code> 在列數與可取用欄位上的差別、以及選哪一種只問一句話，在 <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>。從一句業務描述推到反連接、以及為什麼計數那條路走不通，在 <a href="/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象</a>。同一張表的兩列互相比較時這兩個運算長什麼樣，在 <a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</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></channel></rss>