<?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>Null on Tarragon</title><link>https://tarrragon.github.io/blog/tags/null/</link><description>Recent content in Null on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 02 Sep 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/null/index.xml" rel="self" type="application/rss+xml"/><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>SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/</guid><description>&lt;p>&lt;code>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;code>JOIN&lt;/code> 累積出來的，每個 &lt;code>JOIN&lt;/code> 的左運算元是什麼在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元&lt;/a>。&lt;/p>
&lt;p>列數變多與多出空缺各自帶來一類錯誤，兩類都不報錯。列數變多帶來的錯誤本篇叫&lt;strong>列數膨脹&lt;/strong>：聚合數的是連接之後的列，回了比實際大的數字。空缺帶來的錯誤本篇叫&lt;strong>比較 NULL 的錯誤&lt;/strong>：拿 NULL 做比較，答案是未知而不是真或假，&lt;code>WHERE&lt;/code> 裡的等號與 &lt;code>NOT IN&lt;/code> 因此回了零列，&lt;code>NOT EXISTS&lt;/code> 子查詢裡的不等號則多留了一列。&lt;/p>
&lt;h2 id="列數膨脹與各類聚合的修法">列數膨脹與各類聚合的修法&lt;/h2>
&lt;p>這一節反覆數的是列，也就是橫向的一筆資料——三張訂單就是三列，欄位有幾個與這件事無關。&lt;/p>
&lt;p>書店的訂單表有兩列，而共用資料裡兩張訂單只有 101 被評價過，所以接上評價表之後仍是兩列。替 101 再加一則評價，列數就變成三：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">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="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="c1">-- 共用資料：2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">9002&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">101&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">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="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 替 101 再加一則評價之後：3&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>訂單一張都沒有增加，而連接後的關係多了一列。原因是配對是逐列做的：訂單 101 配得上兩則評價，於是它在結果裡出現兩次，兩次帶著不同的評價。&lt;/p>
&lt;p>&lt;strong>後續的聚合算的是這個新關係的列，不是訂單表的列。&lt;/strong> 所以數佳穎有幾張訂單，直接數會得到錯的答案：&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="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">看起來的訂單數&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">DISTINCT&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 class="k">AS&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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="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">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">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 | 3 | 2&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>佳穎實際有兩張訂單，而 &lt;code>count(*)&lt;/code> 回報三。SQLite、DuckDB 與 PostgreSQL 的答案都是這一組。&lt;/p>
&lt;p>列數膨脹最難察覺的地方在於數字看起來合理。count(*) 的三比實際訂單數的二只多一，而且它會隨著評價增加而慢慢變大——沒有任何一刻它會大到讓人起疑。&lt;/p>
&lt;p>&lt;strong>判斷方法是問這個聚合要數的是哪一個層次的單位&lt;/strong>——訂單、還是連接之後的列。決定那個層次的是分組鍵（選鍵的判斷標準在 &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;code>count(DISTINCT 訂單編號)&lt;/code>，計數的單位就回到訂單。&lt;/p>
&lt;p>&lt;strong>加總的&lt;/strong>不能用 &lt;code>DISTINCT&lt;/code>。直接加總時同一張訂單的金額被加了兩次，而 &lt;code>sum(DISTINCT 金額)&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">-- 直接連接再加總：101 配到兩則評價，300 被加了兩次
&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="k">sum&lt;/span>&lt;span class="p">(&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 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">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="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 1100
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="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="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&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 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">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"> 8&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="p">(&lt;/span>&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="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價數&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&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="err">評價統計&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價統計&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 101 | 300 | 2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 102 | 500 |&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每張訂單在這份結果裡只有一列，對它加總金額時每張訂單也就只算一次。&lt;/p></description><content:encoded><![CDATA[<p><code>FROM</code> 走完之後得到的 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 已經不是任何一張來源表。它的列數可能變多，而它多出來一些來源表裡沒有的空缺。後面每一個子句都在這個新的關係上操作。本篇從這個關係已經成形的那一刻開始；它是逐個 <code>JOIN</code> 累積出來的，每個 <code>JOIN</code> 的左運算元是什麼在 <a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a>。</p>
<p>列數變多與多出空缺各自帶來一類錯誤，兩類都不報錯。列數變多帶來的錯誤本篇叫<strong>列數膨脹</strong>：聚合數的是連接之後的列，回了比實際大的數字。空缺帶來的錯誤本篇叫<strong>比較 NULL 的錯誤</strong>：拿 NULL 做比較，答案是未知而不是真或假，<code>WHERE</code> 裡的等號與 <code>NOT IN</code> 因此回了零列，<code>NOT EXISTS</code> 子查詢裡的不等號則多留了一列。</p>
<h2 id="列數膨脹與各類聚合的修法">列數膨脹與各類聚合的修法</h2>
<p>這一節反覆數的是列，也就是橫向的一筆資料——三張訂單就是三列，欄位有幾個與這件事無關。</p>
<p>書店的訂單表有兩列，而共用資料裡兩張訂單只有 101 被評價過，所以接上評價表之後仍是兩列。替 101 再加一則評價，列數就變成三：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">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="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：2
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">9002</span><span class="p">,</span><span class="w"> </span><span class="mi">101</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">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="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 替 101 再加一則評價之後：3</span></span></span></code></pre></div><p>訂單一張都沒有增加，而連接後的關係多了一列。原因是配對是逐列做的：訂單 101 配得上兩則評價，於是它在結果裡出現兩次，兩次帶著不同的評價。</p>
<p><strong>後續的聚合算的是這個新關係的列，不是訂單表的列。</strong> 所以數佳穎有幾張訂單，直接數會得到錯的答案：</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="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">看起來的訂單數</span><span class="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">count</span><span class="p">(</span><span class="k">DISTINCT</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 class="k">AS</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">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</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">5</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 3 | 2</span></span></span></code></pre></div><p>佳穎實際有兩張訂單，而 <code>count(*)</code> 回報三。SQLite、DuckDB 與 PostgreSQL 的答案都是這一組。</p>
<p>列數膨脹最難察覺的地方在於數字看起來合理。count(*) 的三比實際訂單數的二只多一，而且它會隨著評價增加而慢慢變大——沒有任何一刻它會大到讓人起疑。</p>
<p><strong>判斷方法是問這個聚合要數的是哪一個層次的單位</strong>——訂單、還是連接之後的列。決定那個層次的是分組鍵（選鍵的判斷標準在 <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>用 <code>count(DISTINCT 訂單編號)</code>，計數的單位就回到訂單。</p>
<p><strong>加總的</strong>不能用 <code>DISTINCT</code>。直接加總時同一張訂單的金額被加了兩次，而 <code>sum(DISTINCT 金額)</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">-- 直接連接再加總：101 配到兩則評價，300 被加了兩次
</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="k">sum</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">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="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="c1">-- 1100
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 括號裡那一段先把每張訂單的評價收成一列，接上來之後每張訂單在結果裡只有一列
</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="p">.</span><span class="err">訂單編號</span><span class="p">,</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 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">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">評價數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">評價統計</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">評價統計</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 101 | 300 | 2
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- 102 | 500 |</span></span></span></code></pre></div><p>每張訂單在這份結果裡只有一列，對它加總金額時每張訂單也就只算一次。</p>
<p><strong>不可加的</strong>（平均、中位數、百分位）不能先在每張訂單這一層算完，再拿到更粗的那一層合併。先算每張訂單的平均星等、再把這些平均值平均一次，得到的是「平均的平均」：評價則數多的訂單與只有一則的訂單權重一樣，結果就偏了。<code>DISTINCT</code> 在這裡也沒有幫助，它去掉的是重複的值，不是重複的列。再替訂單 102 加一則一星評價，讓兩張訂單的則數不同：</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">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">9003</span><span class="p">,</span><span class="w"> </span><span class="mi">102</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 平均的平均：101 的兩則平均 4、102 的一則平均 1，再平均一次
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">avg</span><span class="p">(</span><span class="err">每單平均</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="k">avg</span><span class="p">(</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">每單平均</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 2.5</span></span></span></code></pre></div><p>佳穎收到的三則評價是 5、3、1，實際的平均是 3。平均與中位數、百分位的出路不同。</p>
<p><strong>平均可以拆成兩個可加的量。</strong> 子查詢裡同時算 <code>sum</code> 與 <code>count</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="p">,</span><span class="w"> </span><span class="k">sum</span><span class="p">(</span><span class="err">評價統計</span><span class="p">.</span><span class="err">星等總和</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="k">sum</span><span class="p">(</span><span class="err">評價統計</span><span class="p">.</span><span class="err">則數</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">平均星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><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">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">4</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="k">sum</span><span class="p">(</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">星等總和</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">則數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">評價統計</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="k">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">6</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 3.0</span></span></span></code></pre></div><p><code>sum</code> 與 <code>count</code> 在每一層都可以直接相加，所以不管中間經過幾層彙總，最後相除的都是全部評價的總和與總則數。乘上 <code>1.0</code> 是為了避免兩個整數相除時被截成整數。</p>
<p><strong>中位數與百分位拆不成可加的量。</strong> 知道兩張訂單各自的中位數，推不出兩張合起來的中位數，所以它們只能在目標層次上，直接對原始的列計算，而且每一筆資料在那個集合裡只能出現一次。算「每位顧客收到的評價星等中位數」時，要計算的集合是評價的列，而顧客接訂單再接評價，每一則評價正好出現一次，這個集合可以直接用；如果連接裡還接了會讓評價重複的表，就要先把評價收成「每則一列」再算。計算本身各家的寫法不同。SQLite 的 <code>median()</code> 與 <code>percentile()</code> 屬於可選的擴充，手上那一份有沒有，跑一次 <code>SELECT median(1);</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">-- 此時佳穎收到的評價是 5、3、1
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- PostgreSQL：percentile_cont 在每一組裡依 ORDER BY 排好之後取 0.5 分位
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">SELECT</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 class="n">percentile_cont</span><span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span><span class="w"> </span><span class="n">WITHIN</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="p">(</span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">中位數</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><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"> 5</span><span class="cl"><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"> 6</span><span class="cl"><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"> 7</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 3
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite 沒有 median() 時：名次是那位顧客的評價依星等排序後的位置，則數是那位顧客的評價總數
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">WITH</span><span class="w"> </span><span class="err">排序後</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</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">13</span><span class="cl"><span class="w">         </span><span class="n">row_number</span><span class="p">()</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">名次</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">         </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w">     </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</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 class="k">AS</span><span class="w"> </span><span class="err">則數</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><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">16</span><span class="cl"><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">17</span><span class="cl"><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">18</span><span class="cl"><span class="w"></span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="k">avg</span><span class="p">(</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">中位數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">排序後</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">名次</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">((</span><span class="err">則數</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="p">(</span><span class="err">則數</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">2</span><span class="p">)</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="mi">2</span><span class="p">)</span><span class="w">   </span><span class="c1">-- 則數是奇數時兩個值相同，偶數時是中間兩列
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">姓名</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 3.0</span></span></span></code></pre></div><p>這一段的例子做完之後把 9003 刪掉：<code>DELETE FROM 評價 WHERE 評價編號 = 9003;</code>。下一節要用到訂單 102 沒有評價的狀態。</p>
<h2 id="外連接補出來的-nullcount-與-count欄位-的差別">外連接補出來的 NULL：count(*) 與 count(欄位) 的差別</h2>
<p>外連接配不上時補的 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a>，來源表裡並不存在。<code>LEFT</code> / <code>RIGHT</code> / <code>FULL</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>。<code>count</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="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">全部列</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">有星等的列</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><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="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 3 | 2</span></span></span></code></pre></div><p><code>count(*)</code> 數的是列，<code>count(欄位)</code> 數的是那一欄有值的列。差額就是空缺的數量。這個差別在沒有連接的查詢裡很少現形，因為原始資料多半沒那麼多空值。</p>
<h2 id="null-的比較結果與-is-null">NULL 的比較結果與 IS NULL</h2>
<p>要找出沒下過單的顧客，很自然會寫成「訂單編號等於 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">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="o">=</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 0 列，而且沒有任何錯誤訊息
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 同一段改成 IS NULL
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="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"> 8</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"> 9</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 class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p><code>= NULL</code> 那一段在三個引擎上都回零列、都不報錯。這個 <code>IS NULL</code> 要寫在 <code>WHERE</code> 而不是 <code>ON</code>，理由是它描述的是連接完成之後的那份結果；同一個條件放兩邊為什麼在外連接底下留下不同的列，在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>。</p>
<p>原因是 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">SELECT</span><span class="w"> </span><span class="p">(</span><span class="k">NULL</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">NULL</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="k">NULL</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- NULL | 1        SQLite 用 1 表示真</span></span></span></code></pre></div><p><code>NULL = NULL</code> 的結果既不是真也不是假，它是第三種答案。而 <code>WHERE</code> 只留下判斷為真的列，所以「未知」與「假」在這一步的下場相同——列被丟掉。</p>
<p><code>IS NULL</code> 是另一個運算子，它問的是「這個位置有沒有值」，答案只有真或假兩種。<strong>判斷空缺一律用 <code>IS NULL</code> 與 <code>IS NOT NULL</code>，比較運算子在這件事上永遠回零列。</strong></p>
<h2 id="子查詢含-null-時的-not-in-與-not-exists">子查詢含 NULL 時的 NOT IN 與 NOT EXISTS</h2>
<p>同一套三值邏輯（見 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a>）製造了一個更難發現的形態。書店要找出沒下過單的顧客，改用 <code>NOT IN</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</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="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：宗翰、雅文</span></span></span></code></pre></div><p>答案正確。接著訂單表多了一列顧客編號是 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">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">200</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</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="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 0 列
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   子查詢交出 1、1、NULL，NOT IN 對宗翰（顧客編號 2）展開成 2 &lt;&gt; 1 AND 2 &lt;&gt; 1 AND 2 &lt;&gt; NULL
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">--   與 1 比較的兩項為真，與 NULL 比較的那一項是未知，AND 的結果是未知，WHERE 不留</span></span></span></code></pre></div><p><code>NOT IN</code> 展開成一串「不等於」的 AND，與 NULL 比較的那一項答案是未知，整串的結果因此永遠不會是真。<strong>子查詢裡出現任何一個 NULL，<code>NOT IN</code> 就對每一列都回答「未知」。</strong></p>
<p>改成 <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="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">2</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 class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：宗翰、雅文
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 訂單 103 進來之後：宗翰、雅文
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 刪掉 103：留著的話，金額最高的那段查詢會把它也選進來
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">103</span><span class="p">;</span></span></span></code></pre></div><p><code>NOT EXISTS</code> 問的是「有沒有配得上的列」，那是一個關於存在性的問題，答案只有真假兩種。</p>
<p><strong>這個安全範圍限定在子查詢用等號配對的時候。</strong> 子查詢裡改用不等號比較的話，NULL 一樣會讓結果出錯。「找出每位顧客金額最高的訂單」可以寫成「同一位顧客沒有另一張金額更高的訂單」，而金額為 NULL 的那張訂單會被選成「最高」。原因不是 NULL 比較大：NULL 沒有進到 <code>NOT EXISTS</code> 的答案裡，它進到了子查詢的比較條件裡，比較的答案是未知而不是真，子查詢因此一列都找不到，<code>NOT EXISTS</code> 於是成立。在共用資料上加一張金額未填的訂單 104 就看得到：</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">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">104</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-12&#39;</span><span class="p">,</span><span class="w"> </span><span class="k">NULL</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">a</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 class="n">a</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">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="n">b</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">a</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"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 102 | 500    子查詢對 102 找不到金額更高的訂單
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">-- 104 |        子查詢比的是 b.金額 &gt; NULL，對每一張 b 都是未知，一列都找不到
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">--   101 沒有出現：102 的 500 &gt; 300 為真，子查詢找得到一列
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- 子查詢之外另寫 a.金額 IS NOT NULL，排除金額未填的那一張
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">a</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 class="n">a</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">a</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">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="k">AND</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="n">b</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">a</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">13</span><span class="cl"><span class="w"></span><span class="c1">-- 102 | 500
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">104</span><span class="p">;</span></span></span></code></pre></div><p>佳穎的「金額最高的訂單」在沒有 <code>IS NOT NULL</code> 的那段查詢裡回了兩張：真正最高的 102，以及金額未填的 104。</p>
<h2 id="列數改變空缺與-null-比較的共同來源">列數改變、空缺與 NULL 比較的共同來源</h2>
<p>列數變了、空缺出現了、空缺的比較是第三種答案——三者都來自同一件事：<strong><code>FROM</code> 產出的是一個新的關係，而後續每一步都在它上面操作。</strong> 拿來源表的直覺去讀後面的子句，就會在這三個地方各撞一次。</p>
<p>列數會變與空缺會出現，在本篇是給定的，而兩者各自在更早的時候就決定了。列數會不會膨脹，由常一起取的資料切在幾張表決定；空缺存不存在，由這一欄允不允許為空決定——兩個都是建表時下的，下的當下看不出代價。<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> 各給一節，並量出雙重展開讓聚合翻倍，以及 <code>DISTINCT</code> 這個修法對不對由資料決定。</p>
<p>列數膨脹與比較 NULL 的錯誤各有各的機制，而共通的是引擎對它們都沒有回報的管道：列數膨脹的結果列數與欄位看不出異常，而計數與總額已經多算；等號比 <code>NULL</code> 與 <code>NOT IN</code> 回零列，而零列與「確實沒有」分不開；<code>NOT EXISTS</code> 子查詢裡的不等號多留了金額未填的那張訂單，多出來的那一列也是一張真實存在的訂單。<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 拿列數膨脹當例子，說明結果的形狀正確為什麼不構成保證。</p>
]]></content:encoded></item><item><title>SQL.9 分組鍵的選擇：鍵與一組所代表的對象</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/grouping-key-decides-the-unit/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/grouping-key-decides-the-unit/</guid><description>&lt;p>&lt;code>GROUP BY&lt;/code> 把鍵的值相同的列收成一組，所以鍵決定了&lt;strong>一組代表什麼&lt;/strong>，而後續的 &lt;code>HAVING&lt;/code> 與 &lt;code>SELECT&lt;/code> 全部是對那個對象提問。&lt;/p>
&lt;p>選錯鍵的時候查詢照樣跑得動，只是每一組代表的對象與寫查詢的人預期的不同。&lt;/p>
&lt;h2 id="外連接之後用右表的欄位當分組鍵">外連接之後用右表的欄位當分組鍵&lt;/h2>
&lt;p>書店的三位顧客裡只有佳穎下過單，兩張都是她的。要找出一張單都沒下的顧客，用 &lt;code>LEFT 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">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">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">分組鍵&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">這組幾列&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&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 class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">有值幾列&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">group_concat&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="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 class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">裝了誰&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- group_concat 把一組裡的名字串起來
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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">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">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>MySQL、SQLite 與 DuckDB 的答案一致：&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">分組鍵 這組幾列 有值幾列 裝了誰
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">NULL 2 0 宗翰,雅文
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">1 2 2 佳穎,佳穎&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>宗翰與雅文被併進同一組。&lt;/strong> 他們沒有配到訂單，外連接給他們的 &lt;code>訂單.顧客編號&lt;/code> 都是 &lt;code>NULL&lt;/code>，而 &lt;code>GROUP BY&lt;/code> 把值相同的列收成一組——&lt;code>GROUP BY&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>NULL&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;code>NULL&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;p>接著用 &lt;code>HAVING count(訂單.顧客編號) = 0&lt;/code> 挑出沒下過單的那一組，這個條件本身是對的：它確實選中了那一組，因為 &lt;code>count(欄位)&lt;/code> 只數有值的列而那一組一列都沒有。&lt;strong>選中的是一組，而這一組底下是宗翰與雅文兩列。&lt;/strong>&lt;/p>
&lt;h2 id="輸出非分組欄時各家引擎的反應">輸出非分組欄時各家引擎的反應&lt;/h2>
&lt;p>輸出顧客姓名時，姓名不在分組鍵裡，於是三家引擎在這裡分岔：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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">HAVING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="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 class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>MySQL 8 預設拒絕：&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">ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">nonaggregated column ... incompatible with sql_mode=only_full_group_by&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DuckDB 同樣拒絕。SQLite 接受，從分組鍵是 &lt;code>NULL&lt;/code> 的那一組裡任意挑一列回傳——只回宗翰，&lt;strong>雅文消失了，而且沒有任何錯誤訊息&lt;/strong>。&lt;/p>
&lt;p>而拒絕的 MySQL 與 DuckDB 擋下的是輸出非分組欄這個寫法，不是選錯的鍵：把姓名補進 &lt;code>GROUP BY&lt;/code> 之後三家都照跑，在共用資料上答案也是對的，因為三位顧客不同名。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="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">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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 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="c1">-- 鍵仍是訂單表的顧客編號，只補上姓名
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">HAVING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="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 class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="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="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;宗翰&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 第二位宗翰，同樣沒下過單
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 重跑同一段查詢：宗翰、雅文
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 沒下過單的顧客有三位而結果只有兩列：兩位宗翰的鍵都是 (NULL, 宗翰)，被併成一組&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>鍵錯在它讓不同的顧客共用一個值，而這件事要等資料裡出現同名的人才看得到。選錯鍵的查詢在語法上完全合法，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a> 把它歸進問錯了問題的那一類。&lt;/p></description><content:encoded><![CDATA[<p><code>GROUP BY</code> 把鍵的值相同的列收成一組，所以鍵決定了<strong>一組代表什麼</strong>，而後續的 <code>HAVING</code> 與 <code>SELECT</code> 全部是對那個對象提問。</p>
<p>選錯鍵的時候查詢照樣跑得動，只是每一組代表的對象與寫查詢的人預期的不同。</p>
<h2 id="外連接之後用右表的欄位當分組鍵">外連接之後用右表的欄位當分組鍵</h2>
<p>書店的三位顧客裡只有佳穎下過單，兩張都是她的。要找出一張單都沒下的顧客，用 <code>LEFT 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">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">AS</span><span class="w"> </span><span class="err">分組鍵</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">這組幾列</span><span class="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">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">有值幾列</span><span class="p">,</span><span class="w"> </span><span class="n">group_concat</span><span class="p">(</span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">裝了誰</span><span class="w">   </span><span class="c1">-- group_concat 把一組裡的名字串起來
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><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">4</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span></span></span></code></pre></div><p>MySQL、SQLite 與 DuckDB 的答案一致：</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">分組鍵  這組幾列  有值幾列  裝了誰
</span></span><span class="line"><span class="ln">2</span><span class="cl">NULL    2         0         宗翰,雅文
</span></span><span class="line"><span class="ln">3</span><span class="cl">1       2         2         佳穎,佳穎</span></span></code></pre></div><p><strong>宗翰與雅文被併進同一組。</strong> 他們沒有配到訂單，外連接給他們的 <code>訂單.顧客編號</code> 都是 <code>NULL</code>，而 <code>GROUP BY</code> 把值相同的列收成一組——<code>GROUP BY</code> 對 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a> 的處理與比較運算不同：鍵同樣是 <code>NULL</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>；這些 <code>NULL</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>
<p>接著用 <code>HAVING count(訂單.顧客編號) = 0</code> 挑出沒下過單的那一組，這個條件本身是對的：它確實選中了那一組，因為 <code>count(欄位)</code> 只數有值的列而那一組一列都沒有。<strong>選中的是一組，而這一組底下是宗翰與雅文兩列。</strong></p>
<h2 id="輸出非分組欄時各家引擎的反應">輸出非分組欄時各家引擎的反應</h2>
<p>輸出顧客姓名時，姓名不在分組鍵裡，於是三家引擎在這裡分岔：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span></span></span></code></pre></div><p>MySQL 8 預設拒絕：</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">ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains
</span></span><span class="line"><span class="ln">2</span><span class="cl">nonaggregated column ... incompatible with sql_mode=only_full_group_by</span></span></code></pre></div><p>DuckDB 同樣拒絕。SQLite 接受，從分組鍵是 <code>NULL</code> 的那一組裡任意挑一列回傳——只回宗翰，<strong>雅文消失了，而且沒有任何錯誤訊息</strong>。</p>
<p>而拒絕的 MySQL 與 DuckDB 擋下的是輸出非分組欄這個寫法，不是選錯的鍵：把姓名補進 <code>GROUP BY</code> 之後三家都照跑，在共用資料上答案也是對的，因為三位顧客不同名。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">,</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">      </span><span class="c1">-- 鍵仍是訂單表的顧客編號，只補上姓名
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：宗翰、雅文
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">4</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;宗翰&#39;</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 第二位宗翰，同樣沒下過單
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- 重跑同一段查詢：宗翰、雅文
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- 沒下過單的顧客有三位而結果只有兩列：兩位宗翰的鍵都是 (NULL, 宗翰)，被併成一組</span></span></span></code></pre></div><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>
<h2 id="選分組鍵的判斷標準一組代表的對象">選分組鍵的判斷標準：一組代表的對象</h2>
<p>一組要代表一位顧客，鍵就是顧客的識別欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">,</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">4</span><span class="cl"><span class="w"></span><span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p>現在三家引擎都給出兩列。分組鍵換掉之後，沒有任何一組的鍵是 <code>NULL</code> 了——宗翰與雅文各自有不同的顧客編號，所以各自成組，而每一組的訂單計數都是零。</p>
<p><strong>判斷方法：先說出「我要每一組代表什麼」，鍵就是那個對象的識別欄位。</strong> 說不出來的時候，多半是這一題根本不需要分組。</p>
<p>鍵還要涵蓋一件事：<strong><code>SELECT</code> 裡沒有被聚合的欄位全部要進 <code>GROUP BY</code></strong>。上面補的 <code>姓名</code> 就是為此——少了它 MySQL 與 DuckDB 會拒絕（正是上一節那個錯誤）。</p>
<p><strong>而補進去的欄位必須由鍵決定——鍵的每一個值只對到這個欄位的一個值，否則分組的粒度會跟著變。</strong> 這種關係在資料庫理論裡叫函數相依（functional dependency），MySQL 的 <code>ERROR 1055</code> 完整訊息寫的 <code>not functionally dependent on columns in GROUP BY clause</code> 指的就是它。一個顧客編號只對到一個姓名，所以補姓名不影響分組；要求的方向是從鍵到欄位，反方向不必成立——兩位宗翰就是同一個姓名對到兩個顧客編號，而按顧客編號分組時他們仍然各自成組。下單日不由顧客編號決定：把它補進 <code>GROUP BY</code>，同一位顧客的每一個下單日各自成組，計數從「這位顧客有幾張單」變成「這位顧客在這一天下了幾張單」，而引擎不會報錯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</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 class="err">訂單</span><span class="p">.</span><span class="err">下單日</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">張數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">,</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 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 class="c1">-- 佳穎 | 2026-03-02 | 1
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 佳穎 | 2026-03-09 | 1      佳穎被拆成兩組
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- 宗翰 |            | 0
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">-- 雅文 |            | 0
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- 拿掉下單日、只按顧客編號與姓名分組時：佳穎 | 2、宗翰 | 0、雅文 | 0</span></span></span></code></pre></div><p>所以不由鍵決定的欄位不能靠補進鍵來輸出，要用聚合包起來（<code>max(下單日)</code> 這種），讓它回到「這一組的某個值」而不是「這一組的分法」。</p>
<p>鍵只決定一組由誰構成；算完之後原本的列還留不留，是分組與視窗函數分開的地方，<a href="/blog/backend/01-database/sql/window-keeps-rows-grouping-collapses/" data-link-title="SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列" data-link-desc="GROUP BY 與視窗函數算完之後原本的列還在不在、依結果的一列代表什麼選用哪一個、視窗函數的值怎麼拿來當篩選條件，以及 LAG 取相鄰列時分區、排序鍵與距離條件各自要確認的事">SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列</a> 用同一批訂單並排兩種產出。</p>
<h2 id="不需要分組的問題存在性判斷">不需要分組的問題：存在性判斷</h2>
<p>把分組鍵換成顧客編號、再補上姓名的那個修正讓查詢回出正確答案，而更前面還有一個問題沒被問：這一題需要分組嗎。</p>
<p>從業務事實推一次。<strong>沒消費過的顧客，在訂單表裡不會有任何一列。</strong> 這句話有一個可以直接驗證的推論——「他的訂單數是零」這件事，從訂單表本身問不出來：</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="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- (1, 2)</span></span></span></code></pre></div><p>只有佳穎。宗翰與雅文不在結果裡，因為訂單表沒有他們的列，所以沒有一組可以代表他們，也就沒有計數可以是零。</p>
<p><strong>那個零是外連接造出來的。</strong> <code>LEFT JOIN</code> 替他們各生一列全是 <code>NULL</code> 的假訂單，<code>count(欄位)</code> 數那一列得到零。所以「計數等於零」是查詢自己製造出來再讀回去的產物，而不是資料本身帶著的性質。</p>
<p>計數預設有列可以數，而這一題的對象恰好是什麼都沒有的那些人。<strong>這就是分組那條路從一開始就對不上的來源</strong>，比鍵選錯更上一層。</p>
<p>翻成 SQL 最貼字面的形式是存在性判斷：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">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 class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p>一句話對一句話——「找不到對應訂單的顧客」。沒有分組、沒有計數。同一件事也寫得成外連接，兩者都對：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="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 class="w">   </span><span class="c1">-- 訂單編號只有在外連接補出來的列上是 NULL
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p>差別在描述的路徑——<code>NOT EXISTS</code> 直接說「找不到」，外連接那一版先讓每位顧客都保留下來、再挑出補了 <code>NULL</code> 的那些。<a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a> 從條件該放哪一邊的角度走同一題，兩篇的結論一致。</p>
<p><strong><code>GROUP BY</code> 是換單位的工具</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>：這個條件需不需要看到組裡的其他列才能回答。不需要就留在 <code>WHERE</code>，連 <code>GROUP BY</code> 都不必寫。求值順序那一篇走過從 <code>FROM</code> 到 <code>LIMIT</code> 每一步處理的單位，分組前後從列換成組是其中變化最大的一步。</p>
<p>三種寫法各自描述什麼、以及 <code>NOT IN</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>。</p>
]]></content:encoded></item><item><title>SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/</guid><description>&lt;p>一份 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a> 是集合，而集合的成員沒有先後。所以一段查詢算出來的那份關係本身不帶順序，回來的那一批列排成什麼樣，是&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>走到最後剛好留下的形狀。&lt;code>ORDER BY&lt;/code> 是把順序加回去的那個子句，也是唯一的一個——而它要寫在&lt;strong>最外層&lt;/strong>才算數。寫在衍生表或視圖裡面的 &lt;code>ORDER BY&lt;/code> 引擎可以丟掉：&lt;/p>





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="n">NULLS</span><span class="w"> </span><span class="k">LAST</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL 18、SQLite 3.51、DuckDB v0.10.3：104,102,101,103
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- MySQL 8.4：ERROR 1064 語法錯誤
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 先排一個布林值：金額是 NULL 的列 (金額 IS NULL) 為真，排在為假的列之後
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="p">(</span><span class="err">金額</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">),</span><span class="w"> </span><span class="err">金額</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL 18、SQLite 3.51、MySQL 8.4、DuckDB v0.10.3：104,102,101,103</span></span></span></code></pre></div><p><code>NULL</code> 是比不出大小的那一種值；字串是比得出、而比法由另一條規則決定的那一種。排序鍵換成姓名時，大小寫算不算相同、重音字母排在哪裡，各家預設不同，同一批名字排出來的順序也不同——<a href="/blog/backend/01-database/sql/string-comparison-and-collation/" data-link-title="SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性" data-link-desc="同一段等值條件在各家回不同的列的原因、LIKE 與等號何時分岔、索引與條件的比較規則怎麼對上，以及規則該寫在哪一層">SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性</a> 用同一批名字在三家上排出三種順序。</p>
]]></content:encoded></item><item><title>SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/</guid><description>&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;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;strong>「這批列是不是那個問題的答案」不在引擎檢查的範圍裡&lt;/strong>，因為問題不在查詢裡——查詢是問題的一種表達，而表達得對不對，只有提問的人知道。&lt;/p>
&lt;p>本篇要用到這兩層的名字。&lt;strong>合法&lt;/strong>指上一段那三項檢查都通過——判它不必讀任何一列資料，但要知道是哪一家引擎在判：各家收與不收的界線不同，標準禁止的寫法有的引擎照收，而那條界線劃在哪裡、各家怎麼劃，在 &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;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 教材說「組成一個關係」而不確定它指的是不是資料表時">關係&lt;/a>（欄位固定、由若干列構成的集合，查詢的結果就是一份關係）正好是那個問題的答案，這個詞是本篇為了指認「這批列是不是那個問題的答案」這一層而取的，SQL 的標準術語裡沒有它。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semantic-model/" data-link-title="Semantic Model（語意模型）" data-link-desc="文章說「模型定死」「符合語意模型」時，查那套規則是什麼、由誰保證">語意模型&lt;/a>指這個語言規定的、由查詢的文字算出結果的那套規則，它的每一項規則都有一處與直覺相反（子句的先後、連接的左右、條件擺哪一邊、分組的鍵各是一例）。本篇處理直覺錯掉之後會發生什麼：引擎照樣算完、照樣回一批結果，而那批結果不帶任何出錯的訊號。&lt;/p>
&lt;h2 id="正解與錯的寫法不等於五漏掉星等與not-in">正解與錯的寫法：「不等於五」「漏掉星等」與「NOT IN」&lt;/h2>
&lt;p>書店要找出沒有五星評價的訂單。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料&lt;/a>裡訂單 101 有一則五星、訂單 102 沒有評價，所以答案是 102。&lt;/p>
&lt;p>正確的寫法把星等的條件放進 &lt;code>ON&lt;/code>，再挑配不到的那些（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a> 的「找沒配到的那些列：IS 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">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 class="k">AND&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="mi">5&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 class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 102&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>「不等於五」的錯法把「沒有五星」寫成「星等不等於五」。&lt;/strong> 其餘不動，只把星等條件挪進 &lt;code>WHERE&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="p">...&lt;/span>&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">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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">&amp;lt;&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：0 列
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 101 唯一那則評價是五星，5 &amp;lt;&amp;gt; 5 判成假
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 102 沒有評價，外連接替它補一列、星等是 NULL，NULL &amp;lt;&amp;gt; 5 的真值是未知，WHERE 只留判成真的列
&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">-- 訂單 101 多一則三星之後：101&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一段問的是「至少有一則非五星評價的訂單」，與「沒有五星評價的訂單」是兩個不同的問題。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/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/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">&lt;code>NULL&lt;/code>&lt;/a> 的那一列，MySQL 手冊叫 NULL-complemented row，本篇寫成&lt;strong>補出來的列&lt;/strong>；對補出來的列判不成真的篩選條件叫&lt;strong>拒絕 NULL 的條件&lt;/strong>（null-rejected condition），&lt;code>評價.星等 &amp;lt;&amp;gt; 5&lt;/code> 就是一例（&lt;code>NULL&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> 的「NULL 的比較結果與 IS NULL」一節）。&lt;strong>問錯了問題，加上拒絕 NULL 的條件把補出來的列全數濾掉，兩件疊在一起，所以這段查詢回哪些列由資料決定&lt;/strong>：訂單 101 多一則三星之後，正解仍然是 102，而它交出了恰好相反的那一列。&lt;/p>
&lt;p>補一個 &lt;code>IS 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="p">...&lt;/span>&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">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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">&amp;lt;&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OR&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 class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：102，看起來完全正確
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 訂單 101 多一則三星之後：101 與 102&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>補上的只是補出來的列，問題仍然是「至少有一則非五星評價」。&lt;/p>
&lt;p>&lt;strong>「漏掉星等」的錯法把星等條件整個漏掉，而它在現有資料上回了正確答案。&lt;/strong> 這一種的星等條件哪裡都沒出現：&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 class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：102
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 訂單 102 多一則三星評價之後：0 列（正解仍然是 102）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一段問的是「沒有任何評價的訂單」。它與正解在共用資料上是同一批列，因為有評價的那張訂單剛好只有五星那一則；兩段查詢要到某張訂單的評價全部不是五星時才分開。&lt;/p></description><content:encoded><![CDATA[<p>一段查詢送進<a href="/blog/backend/01-database/sql/knowledge-cards/database-engine/" data-link-title="Database Engine（資料庫引擎）" data-link-desc="文章說「引擎決定」「換一家引擎就不同」時，查那是哪一個層次的決定">引擎</a>，它檢查的是這段文字合不合法：名字解析得到、型別配得上、子句的組合合乎<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>的規則。通過之後它照著算，回一批列。<strong>「這批列是不是那個問題的答案」不在引擎檢查的範圍裡</strong>，因為問題不在查詢裡——查詢是問題的一種表達，而表達得對不對，只有提問的人知道。</p>
<p>本篇要用到這兩層的名字。<strong>合法</strong>指上一段那三項檢查都通過——判它不必讀任何一列資料，但要知道是哪一家引擎在判：各家收與不收的界線不同，標準禁止的寫法有的引擎照收，而那條界線劃在哪裡、各家怎麼劃，在 <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>；<strong>問對了</strong>指算出來的那份<a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">關係</a>（欄位固定、由若干列構成的集合，查詢的結果就是一份關係）正好是那個問題的答案，這個詞是本篇為了指認「這批列是不是那個問題的答案」這一層而取的，SQL 的標準術語裡沒有它。</p>
<p><a href="/blog/backend/01-database/sql/knowledge-cards/semantic-model/" data-link-title="Semantic Model（語意模型）" data-link-desc="文章說「模型定死」「符合語意模型」時，查那套規則是什麼、由誰保證">語意模型</a>指這個語言規定的、由查詢的文字算出結果的那套規則，它的每一項規則都有一處與直覺相反（子句的先後、連接的左右、條件擺哪一邊、分組的鍵各是一例）。本篇處理直覺錯掉之後會發生什麼：引擎照樣算完、照樣回一批結果，而那批結果不帶任何出錯的訊號。</p>
<h2 id="正解與錯的寫法不等於五漏掉星等與not-in">正解與錯的寫法：「不等於五」「漏掉星等」與「NOT IN」</h2>
<p>書店要找出沒有五星評價的訂單。<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料</a>裡訂單 101 有一則五星、訂單 102 沒有評價，所以答案是 102。</p>
<p>正確的寫法把星等的條件放進 <code>ON</code>，再挑配不到的那些（<a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a> 的「找沒配到的那些列：IS 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">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 class="k">AND</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="mi">5</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 class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 102</span></span></span></code></pre></div><p><strong>「不等於五」的錯法把「沒有五星」寫成「星等不等於五」。</strong> 其餘不動，只把星等條件挪進 <code>WHERE</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="p">...</span><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">2</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="o">&lt;&gt;</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：0 列
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">--   101 唯一那則評價是五星，5 &lt;&gt; 5 判成假
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   102 沒有評價，外連接替它補一列、星等是 NULL，NULL &lt;&gt; 5 的真值是未知，WHERE 只留判成真的列
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- 訂單 101 多一則三星之後：101</span></span></span></code></pre></div><p>這一段問的是「至少有一則非五星評價的訂單」，與「沒有五星評價的訂單」是兩個不同的問題。<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/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時"><code>NULL</code></a> 的那一列，MySQL 手冊叫 NULL-complemented row，本篇寫成<strong>補出來的列</strong>；對補出來的列判不成真的篩選條件叫<strong>拒絕 NULL 的條件</strong>（null-rejected condition），<code>評價.星等 &lt;&gt; 5</code> 就是一例（<code>NULL</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> 的「NULL 的比較結果與 IS NULL」一節）。<strong>問錯了問題，加上拒絕 NULL 的條件把補出來的列全數濾掉，兩件疊在一起，所以這段查詢回哪些列由資料決定</strong>：訂單 101 多一則三星之後，正解仍然是 102，而它交出了恰好相反的那一列。</p>
<p>補一個 <code>IS 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="p">...</span><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">2</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="o">&lt;&gt;</span><span class="w"> </span><span class="mi">5</span><span class="w"> </span><span class="k">OR</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 class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：102，看起來完全正確
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 訂單 101 多一則三星之後：101 與 102</span></span></span></code></pre></div><p>補上的只是補出來的列，問題仍然是「至少有一則非五星評價」。</p>
<p><strong>「漏掉星等」的錯法把星等條件整個漏掉，而它在現有資料上回了正確答案。</strong> 這一種的星等條件哪裡都沒出現：</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 class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：102
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 訂單 102 多一則三星評價之後：0 列（正解仍然是 102）</span></span></span></code></pre></div><p>這一段問的是「沒有任何評價的訂單」。它與正解在共用資料上是同一批列，因為有評價的那張訂單剛好只有五星那一則；兩段查詢要到某張訂單的評價全部不是五星時才分開。</p>
<p><strong>「NOT IN」的錯法用 <code>NOT IN</code> 表達否定的成員判斷。</strong> 星等條件回到<a href="/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">子查詢</a>裡，連接整個省掉：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：102
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">-- 評價表多一則訂單編號還沒填的五星評價之後：0 列
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">--   子查詢交出 101 與 NULL，NOT IN 對訂單 102 展開成 102 &lt;&gt; 101 AND 102 &lt;&gt; NULL
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">--   前一項為真、後一項的真值是未知，AND 的結果是未知，WHERE 不留
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- 同一個問題寫成 NOT EXISTS，加了那則評價之後仍然回 102
</span></span></span><span class="line"><span class="ln"> 9</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">10</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></span><span class="line"><span class="ln">11</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="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="p">);</span></span></span></code></pre></div><p><code>NOT IN</code> 展開成一串「不等於」的 AND，子查詢只要交出一個 <code>NULL</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> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節）。<code>NOT EXISTS</code> 逐列問「有沒有一則配得上的五星評價」，訂單編號是 <code>NULL</code> 的那則評價配不上任何一張訂單，所以不影響結果。</p>
<h2 id="答案錯掉的成因分類與引擎不報錯的原因">答案錯掉的成因分類與引擎不報錯的原因</h2>
<p>從要問的那一題到算出來的那批列，中間經過三個環節：提問的人把問題寫成查詢文字、語意模型規定這段文字算出什麼、引擎照它的實作去算。答案錯掉的成因按斷在哪一個環節分類：斷在問題寫成文字的，本篇叫<strong>問錯了問題</strong>；寫的人對某條規則的預期與語意模型的規定不同，叫<strong>誤解語意模型</strong>；引擎的行為偏離標準，叫<strong>引擎寬鬆</strong>；另有一類不斷在任何環節，而是要問的那一題本身還沒成形，叫<strong>問題還沒決定</strong>。</p>
<p>「不等於五」與「漏掉星等」問錯了問題。「不等於五」問的是「至少有一則非五星評價」，「漏掉星等」問的是「有沒有任何評價」——兩段查詢都精確地回答了它們自己問的那一題，而那一題不是要問的那一題。問錯了問題與模型的規則無關：換引擎、加約束都碰不到它，因為那段文字沒有任何一處是錯的；靜態分析判得出「不等於五」，憑的是它同時誤解了語意模型（見〈「問對了沒有」的判定：靜態分析、約束與反例資料〉）。問題與查詢文字之間還有反方向的落差，<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>LEFT</code> 宣告了一個從未發生的情況。問錯了問題的落差在問題與文字之間，宣告落空在文字與行為之間。</p>
<p>「NOT IN」的錯法誤解語意模型。<code>NOT IN</code> 的展開方式由模型定死，而寫的人預期它表現得像「不在這個集合裡」。誤解語意模型修得掉，改對之後永遠對。分頁是誤解語意模型的另一個實例：同分的列排在哪裡模型沒有規定，而逐頁取的人預期它固定，<a href="/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記</a> 寫它怎麼變成重複與遺漏。</p>
<p>「不等於五」的錯法同時屬於問錯了問題與誤解語意模型——它問的那個問題不是要問的，而它的星等條件又是拒絕 NULL 的條件、把補出來的列全數濾掉——所以它回的列隨資料變動，這是它比「漏掉星等」與「NOT IN」難診斷的原因。</p>
<p>引擎寬鬆的實例不在這三種錯法裡。引擎接受了標準禁止的寫法並自己補一個值：分組之後把非分組欄放進 <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> 與 <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>，換一家引擎就浮現。SQL.2 除了求值順序，也寫了哪些違反求值順序的寫法會被引擎當場報錯、哪些會被照收——同一個放錯邊的條件在一家報錯、在另一家回一份錯的清單。問題還沒決定的實例是「所有訂單都被評價過的顧客」：它在一張訂單都沒下過的人身上為真（邏輯裡叫空真，vacuous truth），而提問的人多半還沒想過那算不算。問題還沒決定連換誰來寫都不會浮現，因為要問的那一題還沒成形。</p>
<p>這些成因有一個共通的地方：引擎回給寫查詢的人的只有兩樣，錯誤訊息與算出來的那批列，而這些錯誤在兩樣裡都不現形。<strong>引擎保證的是兩件事：這段文字合法，而算出來的關係符合語意模型。文字合法與關係符合語意模型這兩項都不決定答案對不對，而答案對不對也不影響這兩項，所以錯誤沒有任何一條經由引擎回報的管道。</strong> 三段查詢一則錯誤訊息都沒有，沒有型別衝突，沒有一列長得奇怪。</p>
<p>回來的那批列本身也看不出答案錯了，而看不出來的情形不只一種。「漏掉星等」的錯法在原本那批資料上回了正確答案，看不出問題；「不等於五」與「NOT IN」兩種回零列，而零列既可能是「確實沒有」也可能是條件整段失效——分不出這兩者，正是 <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</a> 把否定式一律交給 <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="k">SELECT</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></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="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：800（101 是 300、102 是 500）
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 替 101 再加一則評價之後：1100
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   101 配到兩則評價，連接後那張訂單變成兩列，300 算了兩次</span></span></span></code></pre></div><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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<p>「漏掉星等」的錯法另有一個性質：它在寫下的當時通得過任何檢驗。拿現有資料驗它，答案是對的；逐句讀它，每一個子句都符合語法與模型。</p>
<h2 id="問對了沒有的判定靜態分析約束與反例資料">「問對了沒有」的判定：靜態分析、約束與反例資料</h2>
<p>「問對了沒有」需要一個答案來比對，而那個答案不在查詢裡，也不在資料裡——它在提問的人手上。所以這一層交不出一條<strong>可以機械執行的判定</strong>——任何規則都得先知道要問的是什麼，才說得出這批列對不對。</p>
<p><strong>「問對了沒有」這整條判定交不出去，而它底下有幾項較小的判斷交得出去。</strong> 一項判斷能不能交給機器，看判斷時要讀的資訊，機器在做這項判斷的那一刻讀不讀得到：引擎收到查詢的時候讀得到查詢的文字與表的結構，資料庫寫入一筆資料的時候讀得到那一筆資料與資料庫當下已有的列，而「要問的是什麼」只有提問的人知道。這些判斷因此按它們要讀什麼分開：只讀查詢文字與表結構的是<strong>靜態分析</strong>，只讀寫入那一刻資料庫裡的資料的寫成<strong>約束</strong>，要讀「要問的是什麼」的，機器能接手的只有比對——提問的人先造一筆<strong>反例資料</strong>、給出它對應的正確答案，機器負責比對查詢回的答案與它是否相同。</p>
<p><strong>靜態分析只讀查詢的文字與表的結構，判斷時不需要任何一列資料，也不需要提問的人。</strong> 「不等於五」的錯法是它判得出來的例子：這段查詢寫了外連接，而 <code>WHERE</code> 裡的星等條件是拒絕 NULL 的條件，補出來的列一列都留不住，結果與內連接在任何資料上都相同。這個等價由三值邏輯決定、與資料裡有什麼無關，所以最佳化器規劃查詢時自己就會用到它，MySQL 手冊把這一步叫 outer join simplification。最佳化器做這個改寫時不產生任何警告，要讀<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</a>才看得到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 「不等於五」的錯法
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">EXPLAIN</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">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"> 4</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"> 5</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="o">&lt;&gt;</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 星等條件拒絕 NULL：補出來的列星等是 NULL，NULL &lt;&gt; 5 判成未知，全數被濾掉
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">-- PostgreSQL 16 印出 Hash Join：最佳化器已經把 LEFT JOIN 改成內連接
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">-- MySQL 8 的 SHOW WARNINGS 印出改寫後的查詢，連接寫的是 join
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 正解
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">EXPLAIN</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="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">13</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 class="k">AND</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="mi">5</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</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 class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="c1">-- 星等條件在 ON 裡；WHERE 的 評價編號 IS NULL 對補出來的列判成真，補出來的列留下
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- PostgreSQL 16 印出 Hash Left Join：外連接保留下來</span></span></span></code></pre></div><p>外連接被最佳化器改成內連接，代表查詢裡的 <code>LEFT</code> 沒有作用，而寫下 <code>LEFT</code> 的人要的正是保留沒有評價的訂單。</p>
<p>「NOT IN」的錯法是另一個例子，靜態分析在它身上判出來的是風險而不是確定的錯。風險由表結構與查詢文字兩處合起來決定，兩處都不必讀任何一列資料：</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">-- 表結構：評價表的訂單編號沒有宣告 NOT NULL，這一欄可能是 NULL
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="p">(</span><span class="err">評價編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">星等</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 查詢文字：NOT IN 的子查詢讀的正是那一欄
</span></span></span><span class="line"><span class="ln">5</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">6</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 子查詢只要交出一個 NULL，NOT IN 對每一列都判不成真，整段回零列
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- 現有資料裡沒有訂單編號未填的評價，所以這段此刻回的仍是 102</span></span></span></code></pre></div><p>靜態分析判不出「漏掉星等」：那段查詢與正解是同一種寫法，只差 <code>ON</code> 少了星等的條件，而查詢文字與表結構裡沒有任何一處說那個條件該在。</p>
<p><strong>約束只讀寫入那一刻資料庫裡的資料，所以資料庫在寫入的那一刻就判得完。</strong> <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>是寫進資料庫、由它在每次寫入時檢查的規則，每一種讀的範圍由種類固定：<code>NOT NULL</code> 與 <code>CHECK</code> 只讀正要寫入的那一筆，<code>UNIQUE</code> 讀同一欄已有的值，外鍵讀它指向的那一列。<code>NOT NULL</code> 寫下「這一欄不會缺」，外鍵寫下「這個編號一定指得到一列」。讀的範圍超出這幾種的規則（「評價要在出貨之後」要讀另一張表的出貨日）同樣在寫入那一刻判得完，而要改用觸發器或應用層檢查。「NOT IN」的錯法要評價表裡出現一則訂單編號未填的五星評價才會回零列，而評價表的訂單編號那一欄若帶著 <code>NOT NULL</code>，那則評價一開始就寫不進來——這是「否定式一律用 <code>NOT EXISTS</code>」那條規則的豁免條件，不是對 <code>NOT IN</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> 拿參照完整性把約束走完一輪——外鍵擋下哪些寫入、宣告與生效在哪幾個位置分開，以及內連接掉列與 <code>NOT IN</code> 回零列這兩種錯各由哪一條約束管掉；五種約束各保證什麼在 <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint（約束）</a>。</p>
<p><strong>要讀「要問的是什麼」的判斷交不出去，而提問的人的判斷可以記成一筆反例資料。</strong> 反例資料是一筆讓正解與某個錯的寫法回不同答案的資料，名字取自邏輯裡的反例（counterexample）：兩段查詢在某一筆資料上回的答案不同，就證明兩者不等價。上面三種錯法各有一筆——「不等於五」要訂單 101 再多一則非五星評價，「漏掉星等」要訂單 102 多一則非五星評價，「NOT IN」要一則訂單編號未填的五星評價。</p>
<p><strong>反例資料從表的結構與查詢的條件推得出來，不必先知道哪一種寫法是錯的。</strong> 測試領域把這個做法叫<strong>等價類劃分</strong>（equivalence partitioning）：把輸入按程式對它的行為分類，每一類至少放一筆。落在這段查詢上，等價類是一張訂單配到評價的方式——一則都配不到、配到不只一則、配到的全部不是五星——再加上欄位是空值。沒有宣告約束的表全部允許這些等價類，而寫查詢的人容易當成它們不會發生；正解與錯的寫法對這些等價類的假設不同，所以兩者的答案就在這些等價類上分開。</p>
<p>一份把每個等價類各放進一次的資料，足以讓上面三種寫法各自交出不同的答案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 從建好的空表開始；101、102 與 9001 同共用資料
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">101</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;2026-03-02&#39;</span><span class="p">,</span><span class="mi">300</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">102</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;2026-03-09&#39;</span><span class="p">,</span><span class="mi">500</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">                        </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="mi">200</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">104</span><span class="p">,</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;2026-03-11&#39;</span><span class="p">,</span><span class="mi">400</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="p">(</span><span class="mi">105</span><span class="p">,</span><span class="mi">3</span><span class="p">,</span><span class="s1">&#39;2026-03-12&#39;</span><span class="p">,</span><span class="mi">600</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">VALUES</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="p">(</span><span class="mi">9001</span><span class="p">,</span><span class="mi">101</span><span class="p">,</span><span class="mi">5</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">9002</span><span class="p">,</span><span class="mi">101</span><span class="p">,</span><span class="mi">3</span><span class="p">),</span><span class="w">   </span><span class="c1">-- 101：配到不只一則，其中一則五星
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="p">(</span><span class="mi">9003</span><span class="p">,</span><span class="mi">103</span><span class="p">,</span><span class="mi">3</span><span class="p">),</span><span class="w">                 </span><span class="c1">-- 103：配到的全部不是五星
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="p">(</span><span class="mi">9004</span><span class="p">,</span><span class="mi">104</span><span class="p">,</span><span class="mi">5</span><span class="p">),</span><span class="w">                 </span><span class="c1">-- 104：只配到一則五星
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="p">(</span><span class="mi">9005</span><span class="p">,</span><span class="mi">105</span><span class="p">,</span><span class="mi">4</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">9006</span><span class="p">,</span><span class="mi">105</span><span class="p">,</span><span class="mi">2</span><span class="p">),</span><span class="w">   </span><span class="c1">-- 105：配到不只一則，全部不是五星
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="p">(</span><span class="mi">9007</span><span class="p">,</span><span class="k">NULL</span><span class="p">,</span><span class="mi">5</span><span class="p">);</span><span class="w">                </span><span class="c1">-- 訂單編號是空值的五星評價
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- 102 一則評價都配不到
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">-- 正解：102、103、105
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- 「不等於五」：101、103、105、105（105 配到兩則非五星評價，出現兩次）
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- 「漏掉星等」：102
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1">-- 「NOT IN」：0 列</span></span></span></code></pre></div><p>提問的人要出的仍然只有一件事：這五張訂單裡哪幾張是答案。造得出反例資料，代表提問的人已經把「要問的是什麼」說到分得開兩個問題的程度；造不出來，代表兩段查詢在這張表結構允許的每一種資料上都回同樣的答案，兩種寫法等價、選哪一種都對。反例資料與「問對了沒有」交不出去並不牴觸：反例資料與它對應的正確答案仍然由提問的人給出，交給機器的只有比對這一步。</p>
<p><strong>反例資料與約束改變的對象不同。</strong> 約束改變的是資料能長成什麼樣，不改變查詢——加上 <code>NOT NULL</code> 之後「NOT IN」的錯法不再回零列，是因為觸發它的那則評價寫不進來，而那段查詢照樣合法、照樣是同一個誤解，同一個人在下一個可為空的欄位上會再寫一次 <code>NOT IN</code>。反例資料不改變資料庫允許什麼資料，提問的人的判斷也仍然由他給出，改變的是那個判斷的保存形式：反例資料當輸入、他給的答案當預期值，合起來就是一則測試案例，往後每次改動那段查詢都能再跑一次，而讀者多半已經有跑測試案例的工具。<strong>反例資料只證偽、不證成</strong>——「漏掉星等」的錯法在原本那批資料上通過了一切檢驗，而通過從來不是對的證明。反例資料的射程另有一道邊界：它由提問的人依他當時對問題的理解寫下，所以它分得出「想的對而寫錯了」，分不出「想的就不對」。</p>
<p>反例資料與它的預期答案在測試領域有對應的名字，而測試那邊分得比本篇細：預期值從哪裡取得叫 <a href="/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判斷標準的來源，以及每一種來源的射程">test oracle</a>，那張卡列了規格、參照實作、人的判斷與現狀四種來源，以及各自抓不到的那些；「寫斷言的人與寫實作的人共用同一份誤解」叫 <a href="/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">test provenance</a>；而「造不出反例資料」在 <a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">Mutation Testing</a> 裡叫 equivalent mutant。本篇只講這三個概念落在 SQL 查詢上的樣子，三張卡各自涵蓋本篇沒有處理的情境。</p>
<h2 id="正確性之外查詢的代價與由引擎資料庫決定的事">正確性之外：查詢的代價與由引擎、資料庫決定的事</h2>
<p>本篇之外還有一條與正確性互不覆蓋的軸：同一段查詢要花多久。一段查詢問對了沒有，與它要花多久，各自的判斷標準管不到對方，而代價那一層有一個機械的做法——向引擎要<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" 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/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/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a> 寫各家的大小寫規則、引號的作用與保留字。執行查詢的帳號被授予哪些權限、最佳化器選了哪一個執行計畫、表上宣告了哪些約束、字串照哪一套 collation 比較，也各自影響同一段查詢回什麼，分別在 <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>、<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/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/string-comparison-and-collation/" data-link-title="SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性" data-link-desc="同一段等值條件在各家回不同的列的原因、LIKE 與等號何時分岔、索引與條件的比較規則怎麼對上，以及規則該寫在哪一層">SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性</a>。</p>
]]></content:encoded></item><item><title>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></channel></rss>