<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>SQL：這個語言為什麼長這樣 on Tarragon</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/</link><description>Recent content in SQL：這個語言為什麼長這樣 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 31 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/01-database/sql/index.xml" rel="self" type="application/rss+xml"/><item><title>SQL.0 一段查詢從需求到上線的決定：追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界與交付</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/decisions-from-requirement-to-shipping/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/decisions-from-requirement-to-shipping/</guid><description>&lt;p>這篇整理一段 SQL 查詢從業務的一句話走到上線之間要做的決定——追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界、交付——每一個決定要決定什麼、決錯之後看到什麼症狀，以及它背後的機制在哪一篇展開。範圍從拿到需求的那一刻開始，到查詢上線、交給下一個環境為止。&lt;/p>
&lt;h2 id="各個決定的內容決錯之後的症狀與機制所在的篇章">各個決定的內容、決錯之後的症狀與機制所在的篇章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>決定&lt;/th>
 &lt;th>要決定的&lt;/th>
 &lt;th>決錯之後的症狀&lt;/th>
 &lt;th>機制在&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>追問需求&lt;/td>
 &lt;td>這句話真正問的是哪一個資料問題&lt;/td>
 &lt;td>查詢寫對了，而它答的是另一題&lt;/td>
 &lt;td>答案要去問提出需求的人&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設定判斷標準&lt;/td>
 &lt;td>什麼樣的輸出算對&lt;/td>
 &lt;td>改完查詢不知道有沒有改好&lt;/td>
 &lt;td>&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;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>選寫法&lt;/td>
 &lt;td>除了判斷有沒有，還要不要用到被比對那張表的欄位&lt;/td>
 &lt;td>列數不是預期的，而後面的加總跟著錯&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接&lt;/a>、&lt;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;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>補前提&lt;/td>
 &lt;td>這段寫法默默假設了什麼&lt;/td>
 &lt;td>資料長成某個樣子之後答案就錯，而不報錯&lt;/td>
 &lt;td>&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;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 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>量代價&lt;/td>
 &lt;td>這段要跑多久、跑幾次&lt;/td>
 &lt;td>開發時很快，正式資料上拖垮請求&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable&lt;/a> 條件的形狀決定索引用不用得上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對邊界&lt;/td>
 &lt;td>需求沒說而資料會有的那些&lt;/td>
 &lt;td>平常都對，特定幾筆資料上靜默算錯&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>交付&lt;/td>
 &lt;td>換引擎、換資料量、換讀的人之後還成不成立&lt;/td>
 &lt;td>會報錯的差異搬家當天就浮現，不報錯的要等某一筆資料才現形或始終不出聲&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機&lt;/a> 管換引擎、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a> 管換讀的人&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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;/p>
&lt;h2 id="最常被跳過的決定追問需求設定判斷標準與對邊界">最常被跳過的決定：追問需求、設定判斷標準與對邊界&lt;/h2>
&lt;p>&lt;strong>追問需求與設定判斷標準。&lt;/strong> 這兩個決定發生在打開編輯器之前，所以不留下任何痕跡——跳過了的查詢跟沒跳過的長得一模一樣。而跳過追問需求的代價是整段查詢答錯題，跳過設定判斷標準的代價是後面每一次修改都不知道有沒有改好。&lt;/p>
&lt;p>&lt;strong>對邊界。&lt;/strong> 一句需求描述的是資料正常時的樣子，而正式資料裡會出現提出需求的人沒想到、所以沒說的情形。以「哪幾天氣溫比前一天高」為例：同一個測站同一天有兩筆紀錄（感測器重送、補登、換設備之後兩套系統各寫一筆）、觀測日存的是哪一個時區的日期、三個月後才補進來的舊資料讓昨天的答案今天變了、同一個測站代號在某一天之後指向另一個地點。對邊界這個決定，就是在查詢寫好之後把這些情形逐一列出來，並決定每一種出現時查詢該回什麼。&lt;/p>
&lt;p>這份清單沒有地方可以查。需求裡沒有它，schema 也只記下有哪些欄位、各是什麼型別，不記下資料在現實中會怎麼偏離預期。列得出幾種，取決於看過多少這個領域的資料：看過感測器重送，才會想到同一天可能有兩筆；看過補登，才會想到歷史答案會變。所以這一步的能力靠經驗累積，每在正式資料上遇到一種新的情形，就把它加進這個領域的查詢的檢查清單。&lt;/p>
&lt;p>跳過它的症狀最難察覺：查詢在大部分資料上都對，只在碰到那幾筆特殊資料時算錯，而且不報錯。那幾筆往往要到有人拿結果去對帳，才被發現。&lt;/p>
&lt;h2 id="決定的前提建表時定下的表的形狀">決定的前提：建表時定下的表的形狀&lt;/h2>
&lt;p>從追問需求到交付，每一個決定都做在一組已經建好的表上，而表的形狀是&lt;strong>建表時的決定&lt;/strong>造出來的：欄位允不允許為空、鍵唯不唯一、長欄位放哪一張表、常一起取的資料切在幾張表、字串的比較規則寫在哪一層、外鍵生不生效。選寫法、補前提的時候，鍵唯不唯一、欄位允不允許為空已經定了；量代價的時候，長欄位放哪一張表、常一起取的資料切在幾張表也已經定了。這些建表時的決定替往後每一段查詢定了價——手上要交的是一份 schema 設計而不是一段查詢時，問題落在追問需求之前。以鍵唯不唯一為例：「哪幾天氣溫比前一天高」用自連接、視窗函數與相關子查詢三種寫法，共同押的前提，是同一個測站同一天只有一筆；表上有 &lt;code>UNIQUE (測站, 觀測日)&lt;/code> 時這個前提由資料庫保證，沒有的話每一段查詢都得自己處理重複，否則自連接會把一列配成多列。每一個決定各自替查詢定了什麼代價，實測在 &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;h2 id="在哪幾天氣溫比前一天高這句需求上做完全部決定的示範">在「哪幾天氣溫比前一天高」這句需求上做完全部決定的示範&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/requirement-to-shipped-query/" data-link-title="SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線" data-link-desc="以「哪幾天氣溫比前一天高」為例，把一句業務的話追問成可驗收的資料問題，比較自連接、視窗函數與相關子查詢各自押的前提與實測代價，以及需求沒說而資料會有的重複紀錄、時區與補登">SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線&lt;/a> 拿「哪幾天氣溫比前一天高」這句需求從頭做完這些決定：把「前一天」追問成日曆上的前一天還是資料上的前一筆；用自連接、視窗函數與相關子查詢各寫一次，找出三者共同押的前提（同一個測站同一天只有一筆）；看漏掉相隔天數與漏掉分區的兩個錯誤版本：漏掉相隔天數的多報，漏掉分區的錯在哪個方向要看同一天兩個測站誰排在前面；把自連接再分成日期差與等值兩種寫法，在同一組資料上量這兩種自連接、視窗函數與相關子查詢的時間，差到三個量級，其中一種加了索引反而變慢；最後列出重複紀錄、時區、補登與測站搬家這幾種需求沒說的情形。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理一段 SQL 查詢從業務的一句話走到上線之間要做的決定——追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界、交付——每一個決定要決定什麼、決錯之後看到什麼症狀，以及它背後的機制在哪一篇展開。範圍從拿到需求的那一刻開始，到查詢上線、交給下一個環境為止。</p>
<h2 id="各個決定的內容決錯之後的症狀與機制所在的篇章">各個決定的內容、決錯之後的症狀與機制所在的篇章</h2>
<table>
  <thead>
      <tr>
          <th>決定</th>
          <th>要決定的</th>
          <th>決錯之後的症狀</th>
          <th>機制在</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>追問需求</td>
          <td>這句話真正問的是哪一個資料問題</td>
          <td>查詢寫對了，而它答的是另一題</td>
          <td>答案要去問提出需求的人</td>
      </tr>
      <tr>
          <td>設定判斷標準</td>
          <td>什麼樣的輸出算對</td>
          <td>改完查詢不知道有沒有改好</td>
          <td><a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a></td>
      </tr>
      <tr>
          <td>選寫法</td>
          <td>除了判斷有沒有，還要不要用到被比對那張表的欄位</td>
          <td>列數不是預期的，而後面的加總跟著錯</td>
          <td><a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a>、<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>、<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></td>
      </tr>
      <tr>
          <td>補前提</td>
          <td>這段寫法默默假設了什麼</td>
          <td>資料長成某個樣子之後答案就錯，而不報錯</td>
          <td><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>、<a href="/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價</a></td>
      </tr>
      <tr>
          <td>量代價</td>
          <td>這段要跑多久、跑幾次</td>
          <td>開發時很快，正式資料上拖垮請求</td>
          <td><a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a>、<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable</a> 條件的形狀決定索引用不用得上</td>
      </tr>
      <tr>
          <td>對邊界</td>
          <td>需求沒說而資料會有的那些</td>
          <td>平常都對，特定幾筆資料上靜默算錯</td>
          <td><a href="/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</a>、<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a></td>
      </tr>
      <tr>
          <td>交付</td>
          <td>換引擎、換資料量、換讀的人之後還成不成立</td>
          <td>會報錯的差異搬家當天就浮現，不報錯的要等某一筆資料才現形或始終不出聲</td>
          <td><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/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a> 管換讀的人</td>
      </tr>
  </tbody>
</table>
<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> 把跨引擎的差異按發聲的位置分級，第一次執行就報錯的叫當場報錯，其餘的差異要麼等某一筆資料才報錯、要麼始終靜默。其餘的決定決錯時欄位正常、列數合理，沒有任何東西提示要去檢查。</p>
<h2 id="最常被跳過的決定追問需求設定判斷標準與對邊界">最常被跳過的決定：追問需求、設定判斷標準與對邊界</h2>
<p><strong>追問需求與設定判斷標準。</strong> 這兩個決定發生在打開編輯器之前，所以不留下任何痕跡——跳過了的查詢跟沒跳過的長得一模一樣。而跳過追問需求的代價是整段查詢答錯題，跳過設定判斷標準的代價是後面每一次修改都不知道有沒有改好。</p>
<p><strong>對邊界。</strong> 一句需求描述的是資料正常時的樣子，而正式資料裡會出現提出需求的人沒想到、所以沒說的情形。以「哪幾天氣溫比前一天高」為例：同一個測站同一天有兩筆紀錄（感測器重送、補登、換設備之後兩套系統各寫一筆）、觀測日存的是哪一個時區的日期、三個月後才補進來的舊資料讓昨天的答案今天變了、同一個測站代號在某一天之後指向另一個地點。對邊界這個決定，就是在查詢寫好之後把這些情形逐一列出來，並決定每一種出現時查詢該回什麼。</p>
<p>這份清單沒有地方可以查。需求裡沒有它，schema 也只記下有哪些欄位、各是什麼型別，不記下資料在現實中會怎麼偏離預期。列得出幾種，取決於看過多少這個領域的資料：看過感測器重送，才會想到同一天可能有兩筆；看過補登，才會想到歷史答案會變。所以這一步的能力靠經驗累積，每在正式資料上遇到一種新的情形，就把它加進這個領域的查詢的檢查清單。</p>
<p>跳過它的症狀最難察覺：查詢在大部分資料上都對，只在碰到那幾筆特殊資料時算錯，而且不報錯。那幾筆往往要到有人拿結果去對帳，才被發現。</p>
<h2 id="決定的前提建表時定下的表的形狀">決定的前提：建表時定下的表的形狀</h2>
<p>從追問需求到交付，每一個決定都做在一組已經建好的表上，而表的形狀是<strong>建表時的決定</strong>造出來的：欄位允不允許為空、鍵唯不唯一、長欄位放哪一張表、常一起取的資料切在幾張表、字串的比較規則寫在哪一層、外鍵生不生效。選寫法、補前提的時候，鍵唯不唯一、欄位允不允許為空已經定了；量代價的時候，長欄位放哪一張表、常一起取的資料切在幾張表也已經定了。這些建表時的決定替往後每一段查詢定了價——手上要交的是一份 schema 設計而不是一段查詢時，問題落在追問需求之前。以鍵唯不唯一為例：「哪幾天氣溫比前一天高」用自連接、視窗函數與相關子查詢三種寫法，共同押的前提，是同一個測站同一天只有一筆；表上有 <code>UNIQUE (測站, 觀測日)</code> 時這個前提由資料庫保證，沒有的話每一段查詢都得自己處理重複，否則自連接會把一列配成多列。每一個決定各自替查詢定了什麼代價，實測在 <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>
<h2 id="在哪幾天氣溫比前一天高這句需求上做完全部決定的示範">在「哪幾天氣溫比前一天高」這句需求上做完全部決定的示範</h2>
<p><a href="/blog/backend/01-database/sql/requirement-to-shipped-query/" data-link-title="SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線" data-link-desc="以「哪幾天氣溫比前一天高」為例，把一句業務的話追問成可驗收的資料問題，比較自連接、視窗函數與相關子查詢各自押的前提與實測代價，以及需求沒說而資料會有的重複紀錄、時區與補登">SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線</a> 拿「哪幾天氣溫比前一天高」這句需求從頭做完這些決定：把「前一天」追問成日曆上的前一天還是資料上的前一筆；用自連接、視窗函數與相關子查詢各寫一次，找出三者共同押的前提（同一個測站同一天只有一筆）；看漏掉相隔天數與漏掉分區的兩個錯誤版本：漏掉相隔天數的多報，漏掉分區的錯在哪個方向要看同一天兩個測站誰排在前面；把自連接再分成日期差與等值兩種寫法，在同一組資料上量這兩種自連接、視窗函數與相關子查詢的時間，差到三個量級，其中一種加了索引反而變慢；最後列出重複紀錄、時區、補登與測站搬家這幾種需求沒說的情形。</p>
]]></content:encoded></item><item><title>SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/</guid><description>&lt;p>SQL 寫的是要什麼結果，不是怎麼算出來。這一條是這個語言其餘每個特徵的來源，包括它最容易讓人讀錯的那些。它最直接的一個後果是：同一個問題換兩種寫法送進去回來的結果一模一樣，而引擎在底下做的事可能完全不同——差在哪裡，查詢的文字一個字都沒說。&lt;/p>
&lt;p>&lt;strong>與逐步執行的程式對照一次，這個性質最清楚。&lt;/strong> 用 pandas 或任何一種 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/dataframe/" data-link-title="DataFrame（資料框）" data-link-desc="SQL 教材拿 pandas 或資料框當對照時，查 DataFrame 是什麼、它與資料表差在哪">DataFrame&lt;/a> 寫「找出下過單的顧客」，寫下的是一串動作：先載入這張表、再依條件篩選、再與另一張表合併，每一行都指定了下一步做什麼，而執行就照那個順序發生。SQL 寫下的是一段描述——哪些表參與、配對的條件是什麼、要留下哪些列——至於先讀哪一張、用什麼演算法配對、要不要臨時建索引，一個字都沒說。&lt;/p>
&lt;p>紅利是描述與實作脫鉤。一段查詢在一萬列與一億列的資料上都不必改寫，換一台機器、換一個索引配置也不必改寫，因為怎麼算從來就不在寫下的查詢文字裡面。代價是紅利的另一面——怎麼算不在寫的東西裡面，所以從查詢的文字看不出它要花多少。&lt;/p>
&lt;h2 id="書寫順序求值順序與執行順序">書寫順序、求值順序與執行順序&lt;/h2>
&lt;p>&lt;strong>書寫順序&lt;/strong>是子句在文字上的先後：&lt;code>SELECT&lt;/code> 在最前面，&lt;code>FROM&lt;/code> 跟在後面，&lt;code>WHERE&lt;/code>、&lt;code>GROUP BY&lt;/code>、&lt;code>HAVING&lt;/code>、&lt;code>ORDER BY&lt;/code> 依序往下。這個順序是給人讀的，它的作用是讓查詢讀起來像一句話。&lt;/p>
&lt;p>&lt;strong>求值順序&lt;/strong>是語意模型規定的先後。它與書寫順序不同：先由 &lt;code>FROM&lt;/code> 決定要處理哪個資料集合，再用 &lt;code>WHERE&lt;/code> 篩掉列，接著 &lt;code>GROUP BY&lt;/code> 分組、&lt;code>HAVING&lt;/code> 篩掉組，最後才輪到 &lt;code>SELECT&lt;/code> 把要輸出的欄位算出來、&lt;code>ORDER BY&lt;/code> 排序。寫在最前面的 &lt;code>SELECT&lt;/code> 在語意上排在倒數第二步。&lt;/p>
&lt;p>&lt;strong>執行順序&lt;/strong>是引擎實際做的事，由 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer&lt;/a> 決定，而最佳化器只受一個約束：輸出的列集合要與語意模型算出來的一致。在這個約束之內，它可以任意重排、可以換演算法、可以臨時建資料結構。&lt;/p>
&lt;p>書寫順序、求值順序、執行順序這三者不一致，是宣告式這個選擇本身帶來的。讀查詢時的多數誤判來自把其中兩種當成同一種——最常見的是拿書寫順序去猜執行順序，以為「寫在前面的表會先被讀」。&lt;/p>
&lt;h2 id="執行計畫的依據統計資訊與書寫順序">執行計畫的依據：統計資訊與書寫順序&lt;/h2>
&lt;p>拿書店的兩張表來看這件事。顧客表五十列，訂單表二十萬列，要數的是兩者配得上的訂單有幾筆。同一個問題寫成兩種順序：&lt;/p>





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite：misuse of aggregate: COUNT()
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- DuckDB：Binder Error: WHERE clause cannot contain aggregates!
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="p">,</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">張數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">HAVING</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 兩個引擎都回 (1, 2)：顧客編號 1 的佳穎，兩張訂單</span></span></span></code></pre></div><p><strong>兩則訊息都只說了不准，理由要從求值順序讀出來。</strong> <code>WHERE</code> 那一步分組還沒發生，沒有組就沒有組的計數，聚合函數拿不到它要用的那個值；<code>HAVING</code> 排在 <code>GROUP BY</code> 之後，那時每一組的計數已經算得出來。這條限制由語意模型規定，與寫法好不好看無關。</p>
<p>求值順序與執行順序不同的地方在於它是語意模型規定死的，換引擎不變，所以「每一步能用到哪些欄位與值」是一個有固定答案的問題。<a href="/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序</a> 逐步走過求值順序這一串步驟，並分開模型層擋得住的限制與各家引擎自己放寬的——後者包括一種不報錯而給出錯答案的形態。</p>
<h2 id="代價的驗證輸出與執行計畫各自回答的問題">代價的驗證：輸出與執行計畫各自回答的問題</h2>
<p>宣告式把「怎麼算」交給引擎決定，代價也跟著由引擎決定。查詢的文字裡沒有任何一處表示它會掃全表還是走索引、會不會臨時建一個索引、會不會把中間結果寫到磁碟——上面那段計畫證明了這一點，<code>SCAN</code>、<code>BLOOM FILTER</code> 與那個臨時索引，三者一個都不在查詢裡。</p>
<p>所以驗證分成兩件互相獨立的事。<strong>結果對不對</strong>看輸出，從查詢本身推得出來。<strong>代價可不可以接受</strong>看計畫，非得問引擎不可，而且同一段查詢在不同資料量、不同索引、不同統計資訊底下的答案不一樣。</p>
<p>開發用的小資料只驗得到結果對不對這一件：資料量小到任何寫法都夠快，所以查詢在開發環境通過，只代表描述得對；代價可不可以接受，要到正式的資料量上才量得出來。</p>
<p>跑 <code>ANALYZE</code> 前後計畫跟著改變的那一組對照，在真實系統上有它的故障形態：統計資訊過時、或者欄位之間的相關性沒有被統計到，最佳化器就拿著錯的資訊選計畫。<a href="/blog/backend/01-database/vendors/postgresql/query-optimization/" data-link-title="PostgreSQL Query Optimization：EXPLAIN、auto_explain、pg_hint_plan 與 planner 選錯 plan 的 production case" data-link-desc="PG query 慢的根因常是 *planner 選錯 plan 或 statistics 過時*。本文從 production case 開場（seq scan vs index / hash vs nested loop / 多 column 統計缺 / parallel query 沒觸發）、走 EXPLAIN / EXPLAIN ANALYZE / auto_explain、pg_hint_plan extension 跟 planner GUC 取捨、production 踩雷（ANALYZE 過時 / multi-column statistics / random_page_cost 沿用 HDD 預設 / effective_cache_size 不對齊 RAM / parallel query 配置）、跟 MySQL query-optimization sibling 對比">PostgreSQL Query Optimization</a> 收了這幾種讓計畫選錯的實際案例，以及讀懂 production 計畫要用的 <code>EXPLAIN</code>、<code>EXPLAIN ANALYZE</code>、<code>auto_explain</code> 三層工具。</p>
]]></content:encoded></item><item><title>SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/clause-evaluation-order/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/clause-evaluation-order/</guid><description>&lt;p>求值順序是語意模型規定的先後：&lt;code>FROM&lt;/code> 決定要處理哪個資料集合、&lt;code>WHERE&lt;/code> 篩掉列、&lt;code>GROUP BY&lt;/code> 分組、&lt;code>HAVING&lt;/code> 篩掉組、&lt;code>SELECT&lt;/code> 算出要輸出的欄位、&lt;code>ORDER BY&lt;/code> 排序、&lt;code>LIMIT&lt;/code> 取前面幾列。它與書寫順序不同，寫在最前面的 &lt;code>SELECT&lt;/code> 在這裡排在倒數第二。&lt;/p>
&lt;p>求值順序規定的是&lt;strong>每一步能用到哪些欄位與值&lt;/strong>——而不在這一步能用的欄位與值裡的，那一步就用不了，引擎因此會擋下某些寫法。擋下來的理由分兩種，分法看換一家引擎結果會不會變：這一步確實拿不到那個值的，本篇叫&lt;strong>模型層的限制&lt;/strong>，換哪一家引擎都一樣；照模型拿不到、而有的引擎替使用者補上一個決定的，叫&lt;strong>引擎寬鬆&lt;/strong>，換一家就變。在擋下它的那一家引擎上，模型層的限制與引擎寬鬆的錯誤訊息長得一樣。引擎實際照什麼次序做事是另一種順序（執行順序），由最佳化器決定而不在本篇範圍，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價&lt;/a> 把書寫、求值、執行三種順序分開。&lt;/p>
&lt;h2 id="求值順序各步驟可用的欄位與值">求值順序各步驟可用的欄位與值&lt;/h2>
&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>WHERE&lt;/code> 因此只能引用這些欄位，一列一列判斷留不留。本篇把 &lt;code>FROM&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;code>ON&lt;/code> 引用得到哪些欄位。&lt;/p>
&lt;p>&lt;code>GROUP BY&lt;/code> 之後中間結果的單位從列換成組，這是整個順序裡變化最大的一步。原本每一列各自獨立，之後每一組是一個單位，而組裡那些列的個別值不再直接可用。&lt;/p>
&lt;p>&lt;code>HAVING&lt;/code> 篩掉的是組，所以它能用&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/aggregate-function/" data-link-title="Aggregate Function（聚合函數）" data-link-desc="想知道為什麼聚合只能寫在某幾個位置、或連接之後的總和算出來不對時">聚合函數&lt;/a>——每一組的計數、總和、最大值到這一步都算得出來了。&lt;code>SELECT&lt;/code> 到這裡才把要輸出的欄位算出來，別名也是在這一步才誕生。&lt;code>ORDER BY&lt;/code> 排的是已經算好的輸出，所以它用得到別名。&lt;code>LIMIT&lt;/code> 在最後，它切的是排序完的結果——&lt;strong>沒有 &lt;code>ORDER BY&lt;/code> 的 &lt;code>LIMIT&lt;/code> 取到哪一列沒有保證&lt;/strong>，因為前面沒有任何一步決定過順序。&lt;/p>
&lt;h2 id="模型層的限制把聚合放進-where">模型層的限制：把聚合放進 WHERE&lt;/h2>
&lt;p>書店有三位顧客，訂單表裡兩張都掛在佳穎名下。要找出下過一張以上訂單的顧客，把「訂單張數大於一」這個條件寫進 &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="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="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SQLite 回 &lt;code>misuse of aggregate: COUNT()&lt;/code>，DuckDB 回 &lt;code>WHERE clause cannot contain aggregates&lt;/code>。同一個問題移到 &lt;code>HAVING&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="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="w"> &lt;/span>&lt;span class="k">HAVING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>&lt;span 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">-- 兩個引擎都回 (1, 2)：顧客編號 1 的佳穎，兩張訂單&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>差別在分組發生了沒有。&lt;code>WHERE&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="引擎寬鬆where-用-select-的別名">引擎寬鬆：WHERE 用 SELECT 的別名&lt;/h2>
&lt;p>按求值順序，&lt;code>WHERE&lt;/code> 早於 &lt;code>SELECT&lt;/code>，別名那時候還沒誕生。標準照這個推論禁止，PostgreSQL 也照這個推論拒絕。&lt;/p>
&lt;p>而 SQLite 與 DuckDB 都接受。書店查佳穎這一列，把姓名欄改名之後在 &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="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">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">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="s1">&amp;#39;佳穎&amp;#39;&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">-- SQLite 與 DuckDB 都回 (&amp;#39;佳穎&amp;#39;,)；PostgreSQL 報 column &amp;#34;客戶&amp;#34; does not exist&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>這個放寬的範圍不是照求值順序推出來的。&lt;/strong> &lt;code>GROUP BY&lt;/code> 與 &lt;code>HAVING&lt;/code> 同樣早於 &lt;code>SELECT&lt;/code>，而 SQLite 與 DuckDB 在這兩個位置接受別名：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">substr&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">7&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="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>&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">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="c1">-- GROUP BY 引用的是 SELECT 那一步才取的別名
&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">-- SQLite：2026-03 | 2&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>所以「這一步還沒算到別名」只推得出候選，推不出答案：各家對哪幾個位置放寬、放寬到什麼程度，要各自查。&lt;/p>
&lt;p>而放寬是疊在語意模型上面的，沒有取代它。可以從一個細節看出疊的位置：來源表本來就有同名欄位的時候，&lt;code>WHERE&lt;/code> 綁的是來源欄位而不是別名。實測用一張只有 &lt;code>x&lt;/code> 與 &lt;code>y&lt;/code> 兩欄的表，把 &lt;code>y&lt;/code> 取名成 &lt;code>x&lt;/code> 之後在 &lt;code>WHERE&lt;/code> 寫 &lt;code>x = 1&lt;/code>：&lt;/p></description><content:encoded><![CDATA[<p>求值順序是語意模型規定的先後：<code>FROM</code> 決定要處理哪個資料集合、<code>WHERE</code> 篩掉列、<code>GROUP BY</code> 分組、<code>HAVING</code> 篩掉組、<code>SELECT</code> 算出要輸出的欄位、<code>ORDER BY</code> 排序、<code>LIMIT</code> 取前面幾列。它與書寫順序不同，寫在最前面的 <code>SELECT</code> 在這裡排在倒數第二。</p>
<p>求值順序規定的是<strong>每一步能用到哪些欄位與值</strong>——而不在這一步能用的欄位與值裡的，那一步就用不了，引擎因此會擋下某些寫法。擋下來的理由分兩種，分法看換一家引擎結果會不會變：這一步確實拿不到那個值的，本篇叫<strong>模型層的限制</strong>，換哪一家引擎都一樣；照模型拿不到、而有的引擎替使用者補上一個決定的，叫<strong>引擎寬鬆</strong>，換一家就變。在擋下它的那一家引擎上，模型層的限制與引擎寬鬆的錯誤訊息長得一樣。引擎實際照什麼次序做事是另一種順序（執行順序），由最佳化器決定而不在本篇範圍，<a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價</a> 把書寫、求值、執行三種順序分開。</p>
<h2 id="求值順序各步驟可用的欄位與值">求值順序各步驟可用的欄位與值</h2>
<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>WHERE</code> 因此只能引用這些欄位，一列一列判斷留不留。本篇把 <code>FROM</code> 當成一個交出關係的整體；多張表的時候它自己是一個過程，按書寫位置逐個把表併進來，<a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a> 寫這個累積怎麼決定每個 <code>ON</code> 引用得到哪些欄位。</p>
<p><code>GROUP BY</code> 之後中間結果的單位從列換成組，這是整個順序裡變化最大的一步。原本每一列各自獨立，之後每一組是一個單位，而組裡那些列的個別值不再直接可用。</p>
<p><code>HAVING</code> 篩掉的是組，所以它能用<a href="/blog/backend/01-database/sql/knowledge-cards/aggregate-function/" data-link-title="Aggregate Function（聚合函數）" data-link-desc="想知道為什麼聚合只能寫在某幾個位置、或連接之後的總和算出來不對時">聚合函數</a>——每一組的計數、總和、最大值到這一步都算得出來了。<code>SELECT</code> 到這裡才把要輸出的欄位算出來，別名也是在這一步才誕生。<code>ORDER BY</code> 排的是已經算好的輸出，所以它用得到別名。<code>LIMIT</code> 在最後，它切的是排序完的結果——<strong>沒有 <code>ORDER BY</code> 的 <code>LIMIT</code> 取到哪一列沒有保證</strong>，因為前面沒有任何一步決定過順序。</p>
<h2 id="模型層的限制把聚合放進-where">模型層的限制：把聚合放進 WHERE</h2>
<p>書店有三位顧客，訂單表裡兩張都掛在佳穎名下。要找出下過一張以上訂單的顧客，把「訂單張數大於一」這個條件寫進 <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="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客編號</span><span class="p">;</span></span></span></code></pre></div><p>SQLite 回 <code>misuse of aggregate: COUNT()</code>，DuckDB 回 <code>WHERE clause cannot contain aggregates</code>。同一個問題移到 <code>HAVING</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="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">張數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">HAVING</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 兩個引擎都回 (1, 2)：顧客編號 1 的佳穎，兩張訂單</span></span></span></code></pre></div><p>差別在分組發生了沒有。<code>WHERE</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="引擎寬鬆where-用-select-的別名">引擎寬鬆：WHERE 用 SELECT 的別名</h2>
<p>按求值順序，<code>WHERE</code> 早於 <code>SELECT</code>，別名那時候還沒誕生。標準照這個推論禁止，PostgreSQL 也照這個推論拒絕。</p>
<p>而 SQLite 與 DuckDB 都接受。書店查佳穎這一列，把姓名欄改名之後在 <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="k">SELECT</span><span class="w"> </span><span class="err">姓名</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">=</span><span class="w"> </span><span class="s1">&#39;佳穎&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite 與 DuckDB 都回 (&#39;佳穎&#39;,)；PostgreSQL 報 column &#34;客戶&#34; does not exist</span></span></span></code></pre></div><p><strong>這個放寬的範圍不是照求值順序推出來的。</strong> <code>GROUP BY</code> 與 <code>HAVING</code> 同樣早於 <code>SELECT</code>，而 SQLite 與 DuckDB 在這兩個位置接受別名：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">substr</span><span class="p">(</span><span class="err">下單日</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">7</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></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">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="c1">-- GROUP BY 引用的是 SELECT 那一步才取的別名
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- SQLite：2026-03 | 2</span></span></span></code></pre></div><p>所以「這一步還沒算到別名」只推得出候選，推不出答案：各家對哪幾個位置放寬、放寬到什麼程度，要各自查。</p>
<p>而放寬是疊在語意模型上面的，沒有取代它。可以從一個細節看出疊的位置：來源表本來就有同名欄位的時候，<code>WHERE</code> 綁的是來源欄位而不是別名。實測用一張只有 <code>x</code> 與 <code>y</code> 兩欄的表，把 <code>y</code> 取名成 <code>x</code> 之後在 <code>WHERE</code> 寫 <code>x = 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="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">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="n">y</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">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">2</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">1</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">y</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">x</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">WHERE</span><span class="w"> </span><span class="n">x</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 2</span></span></span></code></pre></div><p><code>WHERE</code> 綁的若是別名，比對的是 <code>y = 1</code>，會回第二列的 <code>1</code>；實際回的是 <code>2</code>，代表 SQLite 拿來源欄位 <code>x</code> 去比對，DuckDB 也是這樣。<strong>來源表沒有同名欄位的時候，引擎才會讓別名接手這個名字。</strong></p>
<h2 id="列的條件放進-havingduckdbmysql-與-sqlite-各自的反應">列的條件放進 HAVING：DuckDB、MySQL 與 SQLite 各自的反應</h2>
<p>把聚合放進 <code>WHERE</code> 這種放錯邊，每一家都會擋下來。反過來把列的條件放進 <code>HAVING</code>，後果分兩家，而其中一家不報錯。</p>
<p>這一節要多幾則評價才看得出來，所以換掉共用資料裡那一則，並多加一張訂單 103——換完之後訂單 101 有五星與三星各一則，訂單 102 有兩則五星，訂單 103 有一則三星：</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="mi">2</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-20&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">400</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="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="err">評價</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="mi">101</span><span class="p">,</span><span class="mi">5</span><span class="p">),(</span><span class="mi">2</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="mi">3</span><span class="p">,</span><span class="mi">102</span><span class="p">,</span><span class="mi">5</span><span class="p">),(</span><span class="mi">4</span><span class="p">,</span><span class="mi">102</span><span class="p">,</span><span class="mi">5</span><span class="p">),(</span><span class="mi">5</span><span class="p">,</span><span class="mi">103</span><span class="p">,</span><span class="mi">3</span><span class="p">);</span></span></span></code></pre></div><p>要問的是「哪些訂單有兩則以上的五星評價」。正確的寫法把星等這個列的條件放 <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="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></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="o">=</span><span class="w"> </span><span class="mi">5</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">2</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 | 2</span></span></span></code></pre></div><p>只有 102，因為 101 的兩則評價裡只有一則是五星。走一遍就看得到為什麼：<code>WHERE</code> 先砍掉三星那兩列，剩下三列拿去分組，組 101 只剩一列而組 102 有兩列，<code>HAVING</code> 再篩掉組 101。</p>
<p>把星等的條件搬進 <code>HAVING</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="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></span><span class="line"><span class="ln">2</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="w"> </span><span class="k">HAVING</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="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">2</span><span class="p">;</span></span></span></code></pre></div><p>DuckDB 拒絕，訊息是 <code>column 星等 must appear in the GROUP BY clause</code>——分組之後組裡有兩個不同的星等，「這一組的星等」沒有定義。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 1054 (42S22): Unknown column &#39;星等&#39; in &#39;having clause&#39;</span></span></code></pre></div><p>MySQL 的 <code>HAVING</code> 只認得分組鍵、聚合，以及 <code>SELECT</code> 裡取好的別名，而 <code>星等</code> 三樣都不是，所以它在解析名字那一步就停了。這一種擋法與設定無關——把 <code>sql_mode</code> 清空再跑，訊息一個字都沒變。</p>
<p>管寬鬆度的那個設定叫 <code>ONLY_FULL_GROUP_BY</code>，從 MySQL 5.7 起是預設值，而它管的是另一個形態：把非分組欄放進 <code>SELECT</code> 的寫法（<code>SELECT 訂單編號, 星等 FROM 評價 GROUP BY 訂單編號</code>）開著時回 <code>ERROR 1055</code> 並在訊息裡點名這個設定，關掉之後就與 SQLite 一樣從組裡任意挑一列。</p>
<p><strong>SQLite 接受，回 101 與 102。</strong> 兩張訂單都被報成「有兩則以上五星」，而 101 只有一則。SQLite 從組裡取了其中一列的星等拿去比對，而它取到的剛好是 101 那組裡五星的那一列，於是整組通過。</p>
<p>至於它會取到哪一列，多數時候規格不保證。唯一的例外是同一個 <code>SELECT</code> 裡只有一個 <code>min()</code> 或 <code>max()</code> 的時候——那時 SQLite 保證非分組欄取自那個極值所在的列，這一點有明文規定。除此之外不要依賴它挑中哪一列。</p>
<p><strong>SQLite 收下並給出錯的清單，比 DuckDB 與 MySQL 當場擋下危害更高，因為輸出看起來完全合理。</strong> 每組一列、欄位齊全、訂單編號都是實際存在的訂單——沒有任何一處會讓人停下來檢查。放錯邊的那個條件在報錯的引擎上寫的當下就會發現，在寬鬆的引擎上會一路帶著錯的清單走下去。</p>
<p>同一個機制還有一個比較溫和的形態：<code>SELECT 顧客編號, 下單日 FROM 訂單 GROUP BY 顧客編號</code>，DuckDB 同樣拒絕、SQLite 回一個從組裡任意挑的日期。非分組欄放進 <code>SELECT</code> 只讓輸出的值不確定，列的條件放進 <code>HAVING</code> 讓整份清單錯掉。</p>
<h2 id="模型層的限制與引擎寬鬆分辨的依據與換引擎時的表現">模型層的限制與引擎寬鬆：分辨的依據與換引擎時的表現</h2>
<p>分開模型層的限制與引擎寬鬆的，是引擎手上有沒有一個值可以補上。分組還沒發生的時候，「這一組有幾張」這個值本身還沒有被算出來，引擎沒有值可補，所以聚合放進 <code>WHERE</code> 是模型層的限制。<code>SELECT</code> 那一步還沒算的時候，別名這個名字還沒誕生，而它要指的值早就算好了——差的只是一個稱呼，有些引擎因此願意替使用者把名字對回去。分組之後「這一組的星等」在模型裡沒有定義，而組裡每一列都有自己的星等，SQLite 從中挑一列的值補上。別名放進 <code>WHERE</code> 與列的條件放進 <code>HAVING</code> 都是引擎寬鬆：模型沒給的決定，由引擎替使用者補上。</p>
<p>模型層的限制與引擎寬鬆在換引擎時的表現不同。碰到模型層的限制的寫法，第一次執行就會被擋下來；靠引擎寬鬆跑起來的寫法會安靜跑過，等到換引擎才出現差異——而其中最麻煩的是從組裡任意挑一列的寬鬆：在兩家都寬鬆的引擎之間搬家（SQLite，以及關掉 <code>ONLY_FULL_GROUP_BY</code> 的 MySQL），連報錯都沒有，只是挑到的那一列可能不同。</p>
<h2 id="求值順序在連接上的實例條件放-on-還是-where">求值順序在連接上的實例：條件放 ON 還是 WHERE</h2>
<p><code>HAVING</code> 那一節已經露出一個問題：同一個條件擺在哪一步，決定的是留下哪些列，而寬鬆的引擎不會替擺錯的那一種報錯。這個問題在連接上重演一次。<code>ON</code> 是 <code>FROM</code> 那一步內部的條件，<code>WHERE</code> 是下一步，所以條件放 <code>ON</code> 還是 <code>WHERE</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> 用同一組書店資料把兩種擺法的結果並排，並給一個代進 <code>NULL</code> 就看得出差別的測試。兩種擺法的差別由語意模型規定，SQLite、PostgreSQL 與 MySQL 對同一種擺法回同一批列，所以條件擺錯位置的時候，換一家引擎也不會浮現。在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 按斷在哪一個環節分的成因裡，它屬於誤解語意模型：寫的人預期 <code>WHERE</code> 裡的條件只影響配對，而模型規定它篩的是配對之後的結果。換一家引擎就浮現的是引擎寬鬆，<code>HAVING</code> 那一節 SQLite 收下的列條件是一例。</p>
]]></content:encoded></item><item><title>SQL.3 連接的起手式：配對條件的位置與連接種類的選擇</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/join-starts-from-the-relationship/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/join-starts-from-the-relationship/</guid><description>&lt;p>寫一個連接要先決定的是&lt;strong>哪兩列該配在一起&lt;/strong>，而 SQL 給了這個決定一個專屬的位置：&lt;code>ON&lt;/code>。本篇把這件事叫&lt;strong>配對&lt;/strong>——關係還在組的那個過程，哪一列跟哪一列湊成一列；這是為了指認它而取的稱呼，SQL 的標準術語裡沒有這個詞。條件寫進那裡，配對關係就成為查詢結構的一部分；條件留在 &lt;code>WHERE&lt;/code>，配對關係就退化成一道篩子。&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 與 102，金額 300 與 500）。&lt;/p>
&lt;p>這一篇處理的是&lt;strong>起手式&lt;/strong>——寫連接時第一個下筆的地方，而它決定了條件之後能放哪裡。&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;strong>成品&lt;/strong>（關係組完之後得到的那一批列）並排；本篇兩個詞都會用到，而判斷一個條件屬於配對還是成品的完整判定表在那一篇。順序上這一篇在前：先有配對關係，才有條件該放哪裡的問題。&lt;/p>
&lt;h2 id="從全部配對縮小的起手式與關聯代數的定義">從全部配對縮小的起手式與關聯代數的定義&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>把連接定義成兩個運算的組合：先取&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積&lt;/a>，再用條件選取。所以下面這個寫法是把定義翻成 SQL：&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="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">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">CROSS&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>&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="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>它讀起來自然是有來由的——每一種配對先擺出來，再挑走不要的。而它是&lt;strong>語意層的定義&lt;/strong>：代數用它說明連接的結果集是什麼，沒有規定寫法要照這個順序展開。引擎也沒有照著做，這一段的計畫與 &lt;code>JOIN ... ON&lt;/code> 寫同一個條件逐字相同（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a>）。&lt;/p>
&lt;p>這個起手式在內連接底下一直行得通，因為條件放 &lt;code>ON&lt;/code> 與放 &lt;code>WHERE&lt;/code> 在內連接底下回同一批列。行得通的經驗會累積，直到問題需要保留配不到的列。&lt;/p>
&lt;h2 id="從全部配對縮小與寫進-on-在外連接處的差別">從全部配對縮小與寫進 ON 在外連接處的差別&lt;/h2>
&lt;p>書店要一份名單：每位顧客與他 300 元以上的訂單，而沒有這種訂單的顧客也要列出來。&lt;/p>
&lt;p>從全部配對縮小：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">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">CROSS&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>&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">=&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">&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&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>宗翰與雅文不在名單上。&lt;code>CROSS JOIN&lt;/code> 這個關鍵字沒有外連接的形式，所以要讓宗翰與雅文這種沒有訂單的顧客也留在結果裡（這是外連接做的事，下文叫&lt;strong>保護&lt;/strong>：&lt;strong>被保護的表&lt;/strong>裡配不到對象的列照樣留下，另一張表——下文叫&lt;strong>補 &lt;code>NULL&lt;/code> 的表&lt;/strong>——的欄位在那一列補上 &lt;code>NULL&lt;/code>），這個起手式本身就得換掉。&lt;/p>
&lt;p>把 &lt;code>CROSS JOIN&lt;/code> 換成 &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">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">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;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&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩人仍然不在。&lt;code>LEFT&lt;/code> 保護下來的那些列右邊全是 &lt;code>NULL&lt;/code>，而 &lt;code>WHERE&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>）。&lt;strong>換掉關鍵字補不回那些列，因為要動的是條件的位置。&lt;/strong>&lt;/p>
&lt;p>把配對關係整個寫進 &lt;code>ON&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">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>&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">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">&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 / 宗翰 （空）/ 雅文 （空）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三位顧客都在。&lt;strong>從全部配對縮小與把配對關係寫進 &lt;code>ON&lt;/code> 這兩個起手式，差別在這裡從「寫法偏好」變成「答得出來與答不出來」&lt;/strong>：從全部配對縮小的起手式把所有條件都放進 &lt;code>WHERE&lt;/code>，在成品上篩，而外連接的保護正是在成品那一步被抵銷的。&lt;/p>
&lt;h2 id="連接種類的選擇配不到的列在答案裡有沒有位置">連接種類的選擇：配不到的列在答案裡有沒有位置&lt;/h2>
&lt;p>哪兩列該配在一起說完之後，接著要決定的是配不到對象的列怎麼處理。判斷標準是一句話：&lt;strong>這些列在答案裡有沒有位置。&lt;/strong>&lt;/p>
&lt;p>有位置就用外連接，而哪一側有位置決定寫 &lt;code>LEFT&lt;/code>、&lt;code>RIGHT&lt;/code> 還是 &lt;code>FULL&lt;/code>；沒有位置就用內連接（&lt;code>JOIN&lt;/code>）（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>那張卡寫三者各保護誰）。「哪些顧客一張單都沒下」這個問題的答案整個在補 &lt;code>NULL&lt;/code> 的那幾列上，內連接會讓它無從回答。&lt;/p>
&lt;p>還有第三種情形：只問配不配得到，右表的欄位一個都不輸出。那時要的運算是半連接——配起來之後只帶走左邊那一份，右邊只當條件（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join&lt;/a>）。寫成 &lt;code>JOIN&lt;/code> 會讓配得到多列的那些左列重複出現，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN&lt;/a> 寫這三種寫法在列數上的差別，以及選哪一種的判斷標準。&lt;/p></description><content:encoded><![CDATA[<p>寫一個連接要先決定的是<strong>哪兩列該配在一起</strong>，而 SQL 給了這個決定一個專屬的位置：<code>ON</code>。本篇把這件事叫<strong>配對</strong>——關係還在組的那個過程，哪一列跟哪一列湊成一列；這是為了指認它而取的稱呼，SQL 的標準術語裡沒有這個詞。條件寫進那裡，配對關係就成為查詢結構的一部分；條件留在 <code>WHERE</code>，配對關係就退化成一道篩子。</p>
<p>本篇的查詢跑在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫</a>上：三位顧客佳穎、宗翰、雅文，而只有佳穎下過兩張訂單（101 與 102，金額 300 與 500）。</p>
<p>這一篇處理的是<strong>起手式</strong>——寫連接時第一個下筆的地方，而它決定了條件之後能放哪裡。<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> 處理的是條件寫下之後放哪一邊——那一篇會把配對與<strong>成品</strong>（關係組完之後得到的那一批列）並排；本篇兩個詞都會用到，而判斷一個條件屬於配對還是成品的完整判定表在那一篇。順序上這一篇在前：先有配對關係，才有條件該放哪裡的問題。</p>
<h2 id="從全部配對縮小的起手式與關聯代數的定義">從全部配對縮小的起手式與關聯代數的定義</h2>
<p><a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>把連接定義成兩個運算的組合：先取<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積</a>，再用條件選取。所以下面這個寫法是把定義翻成 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="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">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">CROSS</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span></span></span></code></pre></div><p>它讀起來自然是有來由的——每一種配對先擺出來，再挑走不要的。而它是<strong>語意層的定義</strong>：代數用它說明連接的結果集是什麼，沒有規定寫法要照這個順序展開。引擎也沒有照著做，這一段的計畫與 <code>JOIN ... ON</code> 寫同一個條件逐字相同（<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>）。</p>
<p>這個起手式在內連接底下一直行得通，因為條件放 <code>ON</code> 與放 <code>WHERE</code> 在內連接底下回同一批列。行得通的經驗會累積，直到問題需要保留配不到的列。</p>
<h2 id="從全部配對縮小與寫進-on-在外連接處的差別">從全部配對縮小與寫進 ON 在外連接處的差別</h2>
<p>書店要一份名單：每位顧客與他 300 元以上的訂單，而沒有這種訂單的顧客也要列出來。</p>
<p>從全部配對縮小：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">CROSS</span><span class="w"> </span><span class="k">JOIN</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="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">&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</span></span></span></code></pre></div><p>宗翰與雅文不在名單上。<code>CROSS JOIN</code> 這個關鍵字沒有外連接的形式，所以要讓宗翰與雅文這種沒有訂單的顧客也留在結果裡（這是外連接做的事，下文叫<strong>保護</strong>：<strong>被保護的表</strong>裡配不到對象的列照樣留下，另一張表——下文叫<strong>補 <code>NULL</code> 的表</strong>——的欄位在那一列補上 <code>NULL</code>），這個起手式本身就得換掉。</p>
<p>把 <code>CROSS JOIN</code> 換成 <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">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">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">&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</span></span></span></code></pre></div><p>兩人仍然不在。<code>LEFT</code> 保護下來的那些列右邊全是 <code>NULL</code>，而 <code>WHERE</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>）。<strong>換掉關鍵字補不回那些列，因為要動的是條件的位置。</strong></p>
<p>把配對關係整個寫進 <code>ON</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">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></span><span class="line"><span class="ln">2</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 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">&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 / 宗翰 （空）/ 雅文 （空）</span></span></span></code></pre></div><p>三位顧客都在。<strong>從全部配對縮小與把配對關係寫進 <code>ON</code> 這兩個起手式，差別在這裡從「寫法偏好」變成「答得出來與答不出來」</strong>：從全部配對縮小的起手式把所有條件都放進 <code>WHERE</code>，在成品上篩，而外連接的保護正是在成品那一步被抵銷的。</p>
<h2 id="連接種類的選擇配不到的列在答案裡有沒有位置">連接種類的選擇：配不到的列在答案裡有沒有位置</h2>
<p>哪兩列該配在一起說完之後，接著要決定的是配不到對象的列怎麼處理。判斷標準是一句話：<strong>這些列在答案裡有沒有位置。</strong></p>
<p>有位置就用外連接，而哪一側有位置決定寫 <code>LEFT</code>、<code>RIGHT</code> 還是 <code>FULL</code>；沒有位置就用內連接（<code>JOIN</code>）（<a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>那張卡寫三者各保護誰）。「哪些顧客一張單都沒下」這個問題的答案整個在補 <code>NULL</code> 的那幾列上，內連接會讓它無從回答。</p>
<p>還有第三種情形：只問配不配得到，右表的欄位一個都不輸出。那時要的運算是半連接——配起來之後只帶走左邊那一份，右邊只當條件（<a href="/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join</a>）。寫成 <code>JOIN</code> 會讓配得到多列的那些左列重複出現，<a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN</a> 寫這三種寫法在列數上的差別，以及選哪一種的判斷標準。</p>
<p><strong>配不到的列要不要保護，這個決定由問題決定，不由現有資料決定。</strong> 「翻遍現在的訂單表，每一列都配得到顧客」只證明此刻如此，而查詢要活得比這一批資料久。反過來說，資料保證配得到的時候寫 <code>LEFT</code> 也有代價——它宣告了一個從未發生的情況，下一個讀的人會以為那些列存在（<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>）。要判斷配對有沒有保證，去看<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>，而那條保證要真的生效才算數（<a href="/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證</a>）。</p>
<h2 id="業務描述對應到查詢的位置on連接種類與-where">業務描述對應到查詢的位置：ON、連接種類與 WHERE</h2>
<p>「每位顧客與他 300 元以上的訂單，沒有這種訂單的顧客也要列出來」這句話裡有三個決定，各自對應查詢的一個位置。</p>
<p><strong>哪兩列該配在一起。</strong> 顧客的那一列與訂單的那一列，靠顧客編號對上，而且只跟 300 元以上的訂單配。這一整句進 <code>ON</code>——「只跟 300 元以上的訂單配」描述的是配對，所以它屬於那裡。</p>
<p><strong>配不到的列要不要。</strong> 「沒有這種訂單的顧客也要列出來」說了要，而要保護的是顧客那一側，所以是 <code>LEFT JOIN</code>，左邊放顧客。<strong>兩張表的時候這樣判就夠</strong>；三張以上時「左邊」是前面幾個連接累積出來的結果，鏈中間放一個 <code>RIGHT</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> 這句話沒有再提條件，所以 <code>WHERE</code> 是空的。業務描述裡沒有針對顧客那一側的限制時，空的 <code>WHERE</code> 是一個合理的結果。</p>
<p>順序反過來走也能檢查已經寫好的查詢：<code>WHERE</code> 裡引用右表欄位的條件，回頭問它描述的是配對，還是成品——多數形態上它描述的是配對，而它待在那裡正在抵銷某個保護。<strong>有一個形態剛好相反</strong>：<code>WHERE 右表.某欄 IS NULL</code> 挑的正是外連接補上 <code>NULL</code> 的那些列，它描述的是成品，待在 <code>WHERE</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> 有完整的判定表，<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> 用它示範查孤兒訂單）。</p>
<h2 id="只提到被保護的表的條件該放的位置">只提到被保護的表的條件該放的位置</h2>
<p>上一節那句業務描述裡，「哪兩列該配在一起」與「配不到的列要不要」都在問條件與連接種類該怎麼配。而條件提到哪一側，會讓「哪兩列該配在一起」的答案整個不同。換一對表看得清楚。「每張訂單與它的評價，只要三月五日以後下的單」——被保護的是訂單，而「三月五日以後」只提到訂單。條件寫進 <code>ON</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="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">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></span><span class="line"><span class="ln">3</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 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">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-03-05&#39;</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  2026-03-02  （空）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 102  2026-03-09  （空）</span></span></span></code></pre></div><p>三月二日那張照樣回來，篩選沒有發生——<code>LEFT</code> 的定義是左邊每一列都留著，而 <code>ON</code> 只決定右邊配不配得上，配不上就補 <code>NULL</code>。<strong>這個條件改變的是配對</strong>：101 本來配得上那筆五星評價，多了這個條件之後整個 <code>ON</code> 對它判假，於是連評價一起丟了。同一個條件改放 <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="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="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">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">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">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-03-05&#39;</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  2026-03-09  （空）</span></span></span></code></pre></div><p>只提到被保護的表的條件描述的是成品，屬於 <code>WHERE</code>。判斷標準因此要分兩步：先問這個條件提到哪幾張表，只提被保護的表的直接進 <code>WHERE</code>；兩張表都提到、或只提補 <code>NULL</code> 的表的，才問它描述配對還是成品。<strong>自連接寫成外連接時也是這樣</strong>。自連接指同一張表在一段查詢裡出現兩次、各自扮演一個角色，例如同一張訂單表一次代表較早的訂單、一次代表較晚的訂單；兩次出現裡被保護的那一次，它自己的過濾條件寫進 <code>ON</code> 同樣篩不掉列。配對的兩側是同一張表時，兩次出現要各自有名字才分得開，<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a> 用三個引擎的錯誤訊息示範為什麼非取名不可，並把自連接處理的關係分成前後配對、同組比較與層級關係三種形態。放右表的條件另有一組判定，以及它為什麼對左表條件恆為真，在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>。</p>
<p>所以空的 <code>WHERE</code> 不是一個要去追求的形狀。上一節那句業務描述留下空的 <code>WHERE</code>，是因為它確實沒有再提條件；把一個只提被保護的表的條件塞進 <code>ON</code> 去換一個空的 <code>WHERE</code>，換來的是一個篩不掉列、還會丟掉本來成立的配對的條件。</p>
]]></content:encoded></item><item><title>SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/</guid><description>&lt;p>&lt;code>FROM&lt;/code> 子句由上往下把表併進來，每一個 &lt;code>JOIN&lt;/code> 的左運算元是它上面全部合起來的結果。第一個 &lt;code>JOIN&lt;/code> 的左邊剛好是一張表，從第二個開始就不是了。&lt;/p>
&lt;p>左運算元會累積這件事決定三件事：每個 &lt;code>ON&lt;/code> 引用得到哪些欄位、每個 &lt;code>LEFT&lt;/code> &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">保護&lt;/a>的是哪些列（保護指 &lt;code>LEFT&lt;/code> 左邊配不到對象的列也留在結果裡、右邊那幾欄補 &lt;code>NULL&lt;/code>）、以及在鏈中間換成 &lt;code>RIGHT&lt;/code> 會發生什麼。&lt;/p>
&lt;p>累積這件事在只有一個 &lt;code>JOIN&lt;/code> 的查詢裡不現形，從第二個開始才有分量：某個 &lt;code>LEFT&lt;/code> 保護了哪些列，取決於它上面累積出來的是什麼。&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="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="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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">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">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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>回來四列：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">佳穎 101 5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">佳穎 102 NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">宗翰 NULL NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">雅文 NULL NULL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第二個 &lt;code>LEFT JOIN&lt;/code> 的左邊是顧客與訂單併出來的結果，包含宗翰與雅文那兩列補 NULL 的訂單欄。問第二個 &lt;code>LEFT JOIN&lt;/code>「左邊是哪一張表」得不到答案，因為左邊已經不是一張表。&lt;strong>判讀的作法回到查詢的文字：在要判讀的那個 &lt;code>JOIN&lt;/code> 所在的那一行上方畫一條橫線，橫線以上寫到的表合起來是左運算元，那一行自己掛的那張表是右運算元。&lt;/strong>&lt;/p>
&lt;p>由此得到兩個可以直接用的結論。第二個 &lt;code>ON&lt;/code> 寫得出顧客表的欄位，因為顧客在左運算元裡面。第二個 &lt;code>JOIN&lt;/code> 若換成 &lt;code>INNER&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="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="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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">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="c1">-- 橫線以上：佳穎 101、佳穎 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">&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 class="c1">-- 內連接：四列裡配不到評價的三列一起砍掉
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎 101 5&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>他們那兩列訂單欄裡的 &lt;code>NULL&lt;/code> 是連接的產物，不是資料裡本來的空值，而後續對這欄做的計數與比較分不出這件事。&lt;code>LEFT&lt;/code> 保護左側、&lt;code>RIGHT&lt;/code> 保護右側、&lt;code>FULL&lt;/code> 兩側都保護，這三種方向與這些 &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;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> 接手。&lt;/p>
&lt;h2 id="鏈中間的-right-join-保護的對象">鏈中間的 RIGHT JOIN 保護的對象&lt;/h2>
&lt;p>把最後一個 &lt;code>LEFT&lt;/code> 換成 &lt;code>RIGHT&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">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">RIGHT&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>結果從四列變成一列，只剩 &lt;code>佳穎 101 5&lt;/code>。&lt;/p>
&lt;p>&lt;code>RIGHT&lt;/code> 保護的是右運算元，而右運算元是評價這一張表。左運算元——顧客與訂單累積出來的那四列——整批降級成可選的，配不上評價的就丟掉。宗翰、雅文、以及佳穎那張沒被評價的訂單，全部消失。&lt;/p>
&lt;p>&lt;strong>語意上這等於宣告「每一則評價都要留著，而顧客與訂單可有可無」。&lt;/strong> 書店大概不會想問這個問題。它幾乎不會是任何人在鏈尾接上評價那個 &lt;code>JOIN&lt;/code> 時想表達的事，而它讀起來只像是把 &lt;code>LEFT&lt;/code> 打成了 &lt;code>RIGHT&lt;/code>。&lt;/p>
&lt;h2 id="只用-left-join-的鏈保護方向的判讀與-full-join-的例外">只用 LEFT JOIN 的鏈：保護方向的判讀與 FULL JOIN 的例外&lt;/h2>
&lt;p>不用 &lt;code>RIGHT&lt;/code> 的話，鏈裡每個 &lt;code>JOIN&lt;/code> 都是同一個方向：要保護的那張表寫在最上面，往下一行一行掛可選的表。要判斷查詢裡任何一個 &lt;code>JOIN&lt;/code> 保護了什麼，在它那一行上方畫橫線就夠了。&lt;/p>
&lt;p>真正需要保護的表放到 &lt;code>FROM&lt;/code> 之後、寫成鏈最上面那一張就好。&lt;/p>
&lt;p>&lt;strong>兩張表都需要保護的時候這個做法不適用&lt;/strong>，那時要的是 &lt;code>FULL JOIN&lt;/code>——它兩側都保護，配不上的兩邊各補一次 &lt;code>NULL&lt;/code>。鏈裡出現它，「最上面那張表受保護」這個規律就不成立了，判讀要回到逐個連接看保護的方向。要「每一則評價都在、順便看得到它屬於誰」，就從評價開始往下掛訂單與顧客：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="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="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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">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">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">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 101 5，與鏈尾換成 RIGHT 的那段查詢是同一個列集合&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段查詢的每個 &lt;code>JOIN&lt;/code> 都是 &lt;code>LEFT&lt;/code>，鏈的方向沒有中途反轉。&lt;/p></description><content:encoded><![CDATA[<p><code>FROM</code> 子句由上往下把表併進來，每一個 <code>JOIN</code> 的左運算元是它上面全部合起來的結果。第一個 <code>JOIN</code> 的左邊剛好是一張表，從第二個開始就不是了。</p>
<p>左運算元會累積這件事決定三件事：每個 <code>ON</code> 引用得到哪些欄位、每個 <code>LEFT</code> <a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">保護</a>的是哪些列（保護指 <code>LEFT</code> 左邊配不到對象的列也留在結果裡、右邊那幾欄補 <code>NULL</code>）、以及在鏈中間換成 <code>RIGHT</code> 會發生什麼。</p>
<p>累積這件事在只有一個 <code>JOIN</code> 的查詢裡不現形，從第二個開始才有分量：某個 <code>LEFT</code> 保護了哪些列，取決於它上面累積出來的是什麼。</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="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="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">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">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></span></code></pre></div><p>回來四列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">佳穎  101   5
</span></span><span class="line"><span class="ln">2</span><span class="cl">佳穎  102   NULL
</span></span><span class="line"><span class="ln">3</span><span class="cl">宗翰  NULL  NULL
</span></span><span class="line"><span class="ln">4</span><span class="cl">雅文  NULL  NULL</span></span></code></pre></div><p>第二個 <code>LEFT JOIN</code> 的左邊是顧客與訂單併出來的結果，包含宗翰與雅文那兩列補 NULL 的訂單欄。問第二個 <code>LEFT JOIN</code>「左邊是哪一張表」得不到答案，因為左邊已經不是一張表。<strong>判讀的作法回到查詢的文字：在要判讀的那個 <code>JOIN</code> 所在的那一行上方畫一條橫線，橫線以上寫到的表合起來是左運算元，那一行自己掛的那張表是右運算元。</strong></p>
<p>由此得到兩個可以直接用的結論。第二個 <code>ON</code> 寫得出顧客表的欄位，因為顧客在左運算元裡面。第二個 <code>JOIN</code> 若換成 <code>INNER</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="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">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">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="c1">-- 橫線以上：佳穎 101、佳穎 102、宗翰、雅文四列
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></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 class="c1">-- 內連接：四列裡配不到評價的三列一起砍掉
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 佳穎  101  5</span></span></span></code></pre></div><p>他們那兩列訂單欄裡的 <code>NULL</code> 是連接的產物，不是資料裡本來的空值，而後續對這欄做的計數與比較分不出這件事。<code>LEFT</code> 保護左側、<code>RIGHT</code> 保護右側、<code>FULL</code> 兩側都保護，這三種方向與這些 <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>；這些 <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> 接手。</p>
<h2 id="鏈中間的-right-join-保護的對象">鏈中間的 RIGHT JOIN 保護的對象</h2>
<p>把最後一個 <code>LEFT</code> 換成 <code>RIGHT</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">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">RIGHT</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></span></code></pre></div><p>結果從四列變成一列，只剩 <code>佳穎 101 5</code>。</p>
<p><code>RIGHT</code> 保護的是右運算元，而右運算元是評價這一張表。左運算元——顧客與訂單累積出來的那四列——整批降級成可選的，配不上評價的就丟掉。宗翰、雅文、以及佳穎那張沒被評價的訂單，全部消失。</p>
<p><strong>語意上這等於宣告「每一則評價都要留著，而顧客與訂單可有可無」。</strong> 書店大概不會想問這個問題。它幾乎不會是任何人在鏈尾接上評價那個 <code>JOIN</code> 時想表達的事，而它讀起來只像是把 <code>LEFT</code> 打成了 <code>RIGHT</code>。</p>
<h2 id="只用-left-join-的鏈保護方向的判讀與-full-join-的例外">只用 LEFT JOIN 的鏈：保護方向的判讀與 FULL JOIN 的例外</h2>
<p>不用 <code>RIGHT</code> 的話，鏈裡每個 <code>JOIN</code> 都是同一個方向：要保護的那張表寫在最上面，往下一行一行掛可選的表。要判斷查詢裡任何一個 <code>JOIN</code> 保護了什麼，在它那一行上方畫橫線就夠了。</p>
<p>真正需要保護的表放到 <code>FROM</code> 之後、寫成鏈最上面那一張就好。</p>
<p><strong>兩張表都需要保護的時候這個做法不適用</strong>，那時要的是 <code>FULL JOIN</code>——它兩側都保護，配不上的兩邊各補一次 <code>NULL</code>。鏈裡出現它，「最上面那張表受保護」這個規律就不成立了，判讀要回到逐個連接看保護的方向。要「每一則評價都在、順便看得到它屬於誰」，就從評價開始往下掛訂單與顧客：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="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="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">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">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">5</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎  101  5，與鏈尾換成 RIGHT 的那段查詢是同一個列集合</span></span></span></code></pre></div><p>這段查詢的每個 <code>JOIN</code> 都是 <code>LEFT</code>，鏈的方向沒有中途反轉。</p>
<h2 id="書寫的左右與引擎的執行順序">書寫的左右與引擎的執行順序</h2>
<p>左右是書寫位置決定的語意，與引擎實際先處理誰無關。同一個連接寫成 <code>FROM 訂單 JOIN 顧客</code> 與 <code>FROM 顧客 JOIN 訂單</code>，在跑過 <code>ANALYZE</code> 的資料庫上，SQLite 兩次給出完全相同的 <a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">query plan</a>——它掃二十萬列的訂單、對五十列的顧客做查找，與誰寫在前面無關。沒有統計時它會改照文字順序走，而那只是它沒有別的資訊時的退路——<a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價</a> 對照有無統計資訊時引擎怎麼選計畫。</p>
<p><strong>左右決定哪一側的列被保護，執行順序決定誰先被掃，兩者互不相干。</strong> 混用這兩個直覺會讓人以為調換書寫順序可以調效能，或以為從查詢文字看得出引擎的動作。</p>
<h2 id="where-引用鏈尾那張表時被撤掉保護的列">WHERE 引用鏈尾那張表時被撤掉保護的列</h2>
<p><code>WHERE</code> 篩的是整條鏈累積完的那四列。條件引用鏈尾評價表的欄位時，佳穎的 102、宗翰與雅文那三列的評價欄是補出來的 <code>NULL</code>，一起被篩掉——宗翰與雅文跟評價表毫無關係，他們的保護是第一個 <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="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="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">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">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">=</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span><span class="w">   </span><span class="c1">-- 補出來的 NULL 判成未知，那三列被篩掉
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- 佳穎  101  5</span></span></span></code></pre></div><p>在這份資料上，消失的正好是把第二個 <code>JOIN</code> 換成 <code>INNER</code> 時消失的同一批列。所以判讀一個 <code>WHERE</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>
]]></content:encoded></item><item><title>SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/</guid><description>&lt;p>&lt;code>ON&lt;/code> 說的是 &lt;code>JOIN&lt;/code> 左右兩邊的列怎麼配成一個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a>，&lt;code>WHERE&lt;/code> 說的是從配好的關係裡篩掉什麼。這兩件事落在求值順序的不同步：&lt;code>FROM&lt;/code> 那一步把關係組出來，&lt;code>WHERE&lt;/code> 那一步才開始篩。&lt;/p>
&lt;p>本篇要反覆用到這兩件事的名字。&lt;strong>配對&lt;/strong>指關係還在組的那個過程——哪一列跟哪一列湊成一列；&lt;strong>成品&lt;/strong>指關係組完之後得到的那一批列——已經湊好、包含補了 NULL 的那些列在內的整批資料。這兩個詞是本篇為了指認這兩個時刻而取的稱呼，SQL 的標準術語裡沒有它們。&lt;/p>
&lt;p>內連接底下，條件寫在 &lt;code>ON&lt;/code> 與寫在 &lt;code>WHERE&lt;/code> 的結果相同，於是它們看起來可以互換。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>底下不同，而不同的地方正好是外連接存在的理由。條件擺在哪一邊不影響查詢跑不跑得動，只影響外連接剛保護下來的那些列留不留得住。保護指 &lt;code>LEFT JOIN&lt;/code> 把左表配不到對象的列也留在結果裡、右表那幾欄補上 &lt;code>NULL&lt;/code>。&lt;code>LEFT JOIN&lt;/code> 退化成內連接的名單，來源就是把條件放進 &lt;code>WHERE&lt;/code> 這一步。&lt;/p>
&lt;h2 id="同一個條件放-on-與放-where-的結果">同一個條件放 ON 與放 WHERE 的結果&lt;/h2>
&lt;p>書店想要一份名單：全部顧客都要列出來，而評價欄只顯示五星的那些。條件寫進 &lt;code>ON&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="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>&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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">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">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="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>回來四列：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">佳穎 5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">佳穎 NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">宗翰 NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">雅文 NULL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個顧客都在。佳穎出現兩次，因為她有兩張訂單——第一張有五星評價，第二張沒有評價可配，補 NULL。（一位顧客變成兩列是連接在改變列數，本篇只處理條件決定的留不留，連接怎麼改變列數在 &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;/p>
&lt;p>同一個星等條件改放 &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="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="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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">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">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">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="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">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 5&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;code>LEFT&lt;/code> 仍然寫在那裡，照字面讀的人會以為沒配到的顧客都在，那是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a> 的主題。&lt;/p>
&lt;h2 id="條件放-on-與放-where-在外連接底下分岔的原因">條件放 ON 與放 WHERE 在外連接底下分岔的原因&lt;/h2>
&lt;p>&lt;code>LEFT JOIN&lt;/code> 的定義是左邊每一列都留著，右邊配不上的補 NULL。條件寫在 &lt;code>ON&lt;/code> 裡的時候，它參與的是「配不配得上」這個判斷——宗翰沒有訂單所以配不上任何評價，佳穎的第二張訂單配不上五星評價，照定義都補 NULL 留下來。&lt;/p>
&lt;p>條件寫在 &lt;code>WHERE&lt;/code> 裡的時候，關係已經組完了，補上的那些 NULL 也已經在裡面。這時候問「星等等於 5」，那些補 NULL 的列答不出 5，於是被篩掉。&lt;strong>外連接剛剛保護下來的列，&lt;code>WHERE&lt;/code> 這一步又把它們拿掉了。&lt;/strong>&lt;/p>
&lt;p>內連接底下看不出差別，是因為它本來就不保留配不上的列。沒有被保護的列可以被抵銷，所以兩種寫法自然同值。&lt;/p>
&lt;p>開頭那段顧客接訂單、再接評價的查詢有三張表，第二個 &lt;code>LEFT 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;h2 id="保護存不存活的測試把補了-null-的那一列代進條件">保護存不存活的測試：把補了 NULL 的那一列代進條件&lt;/h2>
&lt;p>&lt;code>WHERE&lt;/code> 只留下判成真的列。而條件的答案有三種——真、假、以及&lt;strong>未知&lt;/strong>：拿 &lt;code>NULL&lt;/code> 去跟任何值比較，答案既不是真也不是假（三值邏輯的推導，以及它讓 &lt;code>NOT IN&lt;/code> 整段失效的形態，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>）。&lt;/p>
&lt;p>外連接補上的那些列，右表欄位全是 &lt;code>NULL&lt;/code>，所以問題變成一句話：&lt;strong>這個條件對「什麼都沒有」這一列給出什麼答案。&lt;/strong> 真的留下，假與未知都被丟掉。&lt;/p></description><content:encoded><![CDATA[<p><code>ON</code> 說的是 <code>JOIN</code> 左右兩邊的列怎麼配成一個 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>，<code>WHERE</code> 說的是從配好的關係裡篩掉什麼。這兩件事落在求值順序的不同步：<code>FROM</code> 那一步把關係組出來，<code>WHERE</code> 那一步才開始篩。</p>
<p>本篇要反覆用到這兩件事的名字。<strong>配對</strong>指關係還在組的那個過程——哪一列跟哪一列湊成一列；<strong>成品</strong>指關係組完之後得到的那一批列——已經湊好、包含補了 NULL 的那些列在內的整批資料。這兩個詞是本篇為了指認這兩個時刻而取的稱呼，SQL 的標準術語裡沒有它們。</p>
<p>內連接底下，條件寫在 <code>ON</code> 與寫在 <code>WHERE</code> 的結果相同，於是它們看起來可以互換。<a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>底下不同，而不同的地方正好是外連接存在的理由。條件擺在哪一邊不影響查詢跑不跑得動，只影響外連接剛保護下來的那些列留不留得住。保護指 <code>LEFT JOIN</code> 把左表配不到對象的列也留在結果裡、右表那幾欄補上 <code>NULL</code>。<code>LEFT JOIN</code> 退化成內連接的名單，來源就是把條件放進 <code>WHERE</code> 這一步。</p>
<h2 id="同一個條件放-on-與放-where-的結果">同一個條件放 ON 與放 WHERE 的結果</h2>
<p>書店想要一份名單：全部顧客都要列出來，而評價欄只顯示五星的那些。條件寫進 <code>ON</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="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">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">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="p">;</span></span></span></code></pre></div><p>回來四列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">佳穎  5
</span></span><span class="line"><span class="ln">2</span><span class="cl">佳穎  NULL
</span></span><span class="line"><span class="ln">3</span><span class="cl">宗翰  NULL
</span></span><span class="line"><span class="ln">4</span><span class="cl">雅文  NULL</span></span></code></pre></div><p>三個顧客都在。佳穎出現兩次，因為她有兩張訂單——第一張有五星評價，第二張沒有評價可配，補 NULL。（一位顧客變成兩列是連接在改變列數，本篇只處理條件決定的留不留，連接怎麼改變列數在 <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>。）</p>
<p>同一個星等條件改放 <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="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="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">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">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">=</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">-- 佳穎  5</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>。需求若恰好只要有五星評價的那些，答案就對了，剩下的是文字的問題：<code>LEFT</code> 仍然寫在那裡，照字面讀的人會以為沒配到的顧客都在，那是 <a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a> 的主題。</p>
<h2 id="條件放-on-與放-where-在外連接底下分岔的原因">條件放 ON 與放 WHERE 在外連接底下分岔的原因</h2>
<p><code>LEFT JOIN</code> 的定義是左邊每一列都留著，右邊配不上的補 NULL。條件寫在 <code>ON</code> 裡的時候，它參與的是「配不配得上」這個判斷——宗翰沒有訂單所以配不上任何評價，佳穎的第二張訂單配不上五星評價，照定義都補 NULL 留下來。</p>
<p>條件寫在 <code>WHERE</code> 裡的時候，關係已經組完了，補上的那些 NULL 也已經在裡面。這時候問「星等等於 5」，那些補 NULL 的列答不出 5，於是被篩掉。<strong>外連接剛剛保護下來的列，<code>WHERE</code> 這一步又把它們拿掉了。</strong></p>
<p>內連接底下看不出差別，是因為它本來就不保留配不上的列。沒有被保護的列可以被抵銷，所以兩種寫法自然同值。</p>
<p>開頭那段顧客接訂單、再接評價的查詢有三張表，第二個 <code>LEFT JOIN</code> 保護的「左邊」因此是顧客與訂單累積出來的結果；鏈越長，保護的範圍怎麼跟著累積走，是 <a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a> 的題目。</p>
<h2 id="保護存不存活的測試把補了-null-的那一列代進條件">保護存不存活的測試：把補了 NULL 的那一列代進條件</h2>
<p><code>WHERE</code> 只留下判成真的列。而條件的答案有三種——真、假、以及<strong>未知</strong>：拿 <code>NULL</code> 去跟任何值比較，答案既不是真也不是假（三值邏輯的推導，以及它讓 <code>NOT IN</code> 整段失效的形態，在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<p>外連接補上的那些列，右表欄位全是 <code>NULL</code>，所以問題變成一句話：<strong>這個條件對「什麼都沒有」這一列給出什麼答案。</strong> 真的留下，假與未知都被丟掉。</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>對補 NULL 的那一列</th>
          <th>保護</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>評價.星等 = 5</code></td>
          <td>NULL 比不出來，判成未知</td>
          <td>取消</td>
      </tr>
      <tr>
          <td><code>評價.星等 IS NOT NULL</code></td>
          <td>判成假</td>
          <td>取消</td>
      </tr>
      <tr>
          <td><code>評價.星等 IS NULL</code></td>
          <td>判成真</td>
          <td>保住</td>
      </tr>
      <tr>
          <td><code>評價.星等 IS NULL OR 評價.星等 = 5</code></td>
          <td>前半判成真，整串就是真</td>
          <td>保住</td>
      </tr>
      <tr>
          <td><code>COALESCE(評價.星等, 0) &gt;= 0</code></td>
          <td>NULL 先被換成 0，判成真</td>
          <td>保住</td>
      </tr>
  </tbody>
</table>
<p><strong>能不能保住不看用了哪個運算子。</strong> <code>IS NOT NULL</code> 看起來與 <code>IS NULL</code> 成對，而在表上的「保護」欄一個是取消、一個是保住，正是因為它對空缺判成假；而 <code>OR</code> 與 <code>COALESCE</code> 這兩種寫法把未知轉成真，保護就留住了。</p>
<p><strong>這張表上的條件全部提到右表，而左表的條件進不了這張表。</strong> 補 <code>NULL</code> 的那一列上左表欄位仍然是原本的值，拿它去問什麼都判得出真假，所以保護一定保得住——這個測試對左表條件恆為真，什麼都判別不出來。左表條件實際的問題出在寫進 <code>ON</code> 的時候：它篩不掉左表的任何一列，還會讓本來配得上的那些配對判假。同一個條件 <code>顧客.姓名 &lt;&gt; '佳穎'</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">-- 放 WHERE：佳穎被篩掉，宗翰與雅文的保護完好
</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="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="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></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">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"> 6</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="s1">&#39;佳穎&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰  NULL  NULL
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">-- 雅文  NULL  NULL
</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">-- 放進顧客接訂單的 ON：佳穎與她的兩張訂單判假、配不上，照 LEFT 的定義補 NULL 留下
</span></span></span><span class="line"><span class="ln">11</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="p">.</span><span class="err">星等</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">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">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;佳穎&#39;</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">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="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">-- 佳穎  NULL  NULL     三位顧客全回來，佳穎的兩張訂單一起消失
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- 宗翰  NULL  NULL
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">-- 雅文  NULL  NULL
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="c1">-- 放進接評價的 ON：顧客與訂單在上一步已經配好，佳穎的兩張訂單留著，只是配不到評價
</span></span></span><span class="line"><span class="ln">20</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="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</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">22</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">23</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 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">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;佳穎&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎  101   NULL
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1">-- 佳穎  102   NULL
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="c1">-- 宗翰  NULL  NULL
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="c1">-- 雅文  NULL  NULL</span></span></span></code></pre></div><p>所以左表條件根本不需要走這個測試，它一開始就屬於 <code>WHERE</code>。</p>
<p><strong>這個測試回答的是保護存不存活，不回答條件該放哪一邊。</strong> 歸屬要另外問一句：這個條件描述的是配對，還是成品。「只跟五星的評價配」描述的是配對，屬於 <code>ON</code>；「最後的名單裡只要五星那幾列」描述的是成品，屬於 <code>WHERE</code>。兩句話說的是不同的事，而內連接讓這兩種寫法碰巧回同一批列。</p>
<p>多數形態上「保護存不存活」與「條件該放哪一邊」這兩個問題同向——取消了保護的條件，通常正是因為它在描述成品。<code>IS NULL</code> 是這兩個問題答案不同向的那一個形態：它保住了保護，而它要的正好是那些被保護下來的列，所以它同時是「保護存活」與「屬於 <code>WHERE</code>」。下一節寫的就是這個形態。</p>
<h2 id="找沒配到的那些列is-null-條件的歸屬">找沒配到的那些列：IS NULL 條件的歸屬</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></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="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">-- 宗翰、雅文</span></span></span></code></pre></div><p>這個條件描述的是成品，所以它屬於 <code>WHERE</code>。它與上面那個「退化成內連接」的形態看起來很像——同樣是在 <code>WHERE</code> 裡引用右表欄位——而要的結果剛好相反：這一個挑的正是外連接補上 NULL 的那些列。</p>
<p><strong>分辨這兩種形態的仍然是那個問題：這個條件描述的是配對，還是成品。</strong> 「一張單都沒下過」是關於成品的敘述，所以它在 <code>WHERE</code> 是對的位置。</p>
<p>同一題「找沒消費過的顧客」換一個切面，走得到同一個結論。<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> 從「沒消費的人不會出現在訂單表」這個業務事實走到 <code>NOT EXISTS</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.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/</guid><description>&lt;p>&lt;code>FROM&lt;/code> 裡寫下的表名指的是那張表在這個查詢裡的一次出現，而那個名字是這次出現的稱呼。標準把這件事叫 table reference，關聯演算（relational calculus）裡的傳統名稱是 range variable，各家文件裡也常寫成 correlation name——三個名字說的是同一件事。同一張表因此可以出現兩次、各自扮演不同角色——這就是自連接。&lt;/p>
&lt;p>一張表只出現一次的時候，這個區別沒有作用。出現兩次就有：兩次出現共用同一個名字，引擎分不出某個欄位屬於哪一次，於是擋下來。&lt;/p>
&lt;p>引擎把這件事講得很明白，而擋下來的時機分兩種。PostgreSQL 與 DuckDB 看到重複的名字就停，訊息分別是 &lt;code>table name &amp;quot;訂單&amp;quot; specified more than once&lt;/code> 與 &lt;code>Duplicate alias &amp;quot;訂單&amp;quot; in query!&lt;/code>；SQLite 讓查詢往下走，等到某個欄位引用無從分辨時才回 &lt;code>ambiguous column name&lt;/code>。&lt;strong>表名本身就是預設的別名&lt;/strong>，所以同一張表的兩次出現共用同一個名字時，等於兩次出現叫同一個稱呼，之後每一次引用都無從分辨。&lt;/p>
&lt;h2 id="兩張表共用欄名時的欄位歸屬">兩張表共用欄名時的欄位歸屬&lt;/h2>
&lt;p>欄位的歸屬要靠名字決定，所以兩張不同的表共用一個欄名時同樣分不開。書店的顧客表與訂單表都有 &lt;code>顧客編號&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="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">-- MySQL: ERROR 1052: Column &amp;#39;顧客編號&amp;#39; in field list is ambiguous
&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">-- SQLite: ambiguous column name: 顧客編號
&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">-- DuckDB: Ambiguous reference to column name &amp;#34;顧客編號&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>id&lt;/code> 是這件事最常撞到的欄位名，因為多數表都有一個。&lt;strong>連接之後每個欄位引用都要能追回它屬於哪一次出現&lt;/strong>，加上表名或別名當前綴就解決。&lt;/p>
&lt;p>自連接只是這個規則的極端情形：兩次出現連表名都一樣，所以連前綴都救不了，非取別名不可。&lt;/p>
&lt;p>表一多，要判讀的就變成每個 &lt;code>ON&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;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="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="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>&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">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">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">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">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">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">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 101 | 2026-03-02 | 102 | 2026-03-09&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>早&lt;/code> 與 &lt;code>晚&lt;/code> 是同一張表的兩次出現。連接條件在做兩件事：&lt;code>顧客編號&lt;/code> 相同把配對限制在同一個人身上，&lt;code>下單日&lt;/code> 的大小關係決定哪一次出現扮演「早」、哪一次扮演「晚」。&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="c1">-- 拿掉日期的比較，只留顧客編號相同
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="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>&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">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"> 4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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="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"> 5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：101|101、101|102、102|101、102|102（兩個方向各一次，加上各自跟自己配）
&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="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 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="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">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">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">10&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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="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">&amp;lt;&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">11&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：101|102
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎多一張 103（下單日 2026-03-15）之後：101|102、101|103、102|103&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="自連接的適用問題前後配對同組比較與層級關係">自連接的適用問題：前後配對、同組比較與層級關係&lt;/h2>
&lt;p>自連接處理的是&lt;strong>同一批資料裡兩筆資料之間的關係&lt;/strong>——兩列，不是兩個欄位。一列一列的處理看不到這種關係，因為每一列在被判斷的時候不知道其他列長什麼樣子。&lt;/p>
&lt;p>自連接常見的形態有三種。&lt;strong>前後配對&lt;/strong>：同一位顧客的訂單先後配對，把時間上相鄰或有先後關係的兩列擺在一起。&lt;strong>同組比較&lt;/strong>：找出訂單金額高於同一位顧客其他訂單的那些，比較的對象是同一張表裡的兄弟列。&lt;strong>層級關係&lt;/strong>：一張員工表裡每個人有一個主管欄位指向同一張表的另一列，要把人與他的主管一起列出來時，員工表出現兩次，一次當員工、一次當主管。&lt;/p>
&lt;p>前後配對、同組比較與層級關係三種的共同點是&lt;strong>要比較的兩列住在同一張表裡&lt;/strong>。需不需要自連接由兩個獨立的問題決定：這個問題要不要拿一列去跟同一張表的另一列比——不比的問題不屬於這一族；以及要不要把另一列的欄位輸出出來——這個問題決定族裡選哪一種寫法。&lt;/p>
&lt;p>要不要輸出另一列的欄位，就是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理&lt;/a> 那條判斷標準——自連接也是一種 &lt;code>JOIN&lt;/code>，所以它同樣適用。只是判斷「有沒有另一列符合條件」而不輸出它，用 &lt;code>EXISTS&lt;/code> 比自連接省一次配對展開；要把兩列並排列出來（像上面的訂單先後配對），才非自連接不可。&lt;/p></description><content:encoded><![CDATA[<p><code>FROM</code> 裡寫下的表名指的是那張表在這個查詢裡的一次出現，而那個名字是這次出現的稱呼。標準把這件事叫 table reference，關聯演算（relational calculus）裡的傳統名稱是 range variable，各家文件裡也常寫成 correlation name——三個名字說的是同一件事。同一張表因此可以出現兩次、各自扮演不同角色——這就是自連接。</p>
<p>一張表只出現一次的時候，這個區別沒有作用。出現兩次就有：兩次出現共用同一個名字，引擎分不出某個欄位屬於哪一次，於是擋下來。</p>
<p>引擎把這件事講得很明白，而擋下來的時機分兩種。PostgreSQL 與 DuckDB 看到重複的名字就停，訊息分別是 <code>table name &quot;訂單&quot; specified more than once</code> 與 <code>Duplicate alias &quot;訂單&quot; in query!</code>；SQLite 讓查詢往下走，等到某個欄位引用無從分辨時才回 <code>ambiguous column name</code>。<strong>表名本身就是預設的別名</strong>，所以同一張表的兩次出現共用同一個名字時，等於兩次出現叫同一個稱呼，之後每一次引用都無從分辨。</p>
<h2 id="兩張表共用欄名時的欄位歸屬">兩張表共用欄名時的欄位歸屬</h2>
<p>欄位的歸屬要靠名字決定，所以兩張不同的表共用一個欄名時同樣分不開。書店的顧客表與訂單表都有 <code>顧客編號</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">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">-- MySQL:  ERROR 1052: Column &#39;顧客編號&#39; in field list is ambiguous
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- SQLite: ambiguous column name: 顧客編號
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- DuckDB: Ambiguous reference to column name &#34;顧客編號&#34;</span></span></span></code></pre></div><p><code>id</code> 是這件事最常撞到的欄位名，因為多數表都有一個。<strong>連接之後每個欄位引用都要能追回它屬於哪一次出現</strong>，加上表名或別名當前綴就解決。</p>
<p>自連接只是這個規則的極端情形：兩次出現連表名都一樣，所以連前綴都救不了，非取別名不可。</p>
<p>表一多，要判讀的就變成每個 <code>ON</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>
<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="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 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">FROM</span><span class="w"> </span><span class="err">訂單</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">JOIN</span><span class="w"> </span><span class="err">訂單</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">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">&lt;</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">-- 101 | 2026-03-02 | 102 | 2026-03-09</span></span></span></code></pre></div><p><code>早</code> 與 <code>晚</code> 是同一張表的兩次出現。連接條件在做兩件事：<code>顧客編號</code> 相同把配對限制在同一個人身上，<code>下單日</code> 的大小關係決定哪一次出現扮演「早」、哪一次扮演「晚」。</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="c1">-- 拿掉日期的比較，只留顧客編號相同
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">早</span><span class="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"> 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">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">JOIN</span><span class="w"> </span><span class="err">訂單</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">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"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：101|101、101|102、102|101、102|102（兩個方向各一次，加上各自跟自己配）
</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="c1">-- 日期的比較放回去
</span></span></span><span class="line"><span class="ln"> 8</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="w">
</span></span></span><span class="line"><span class="ln"> 9</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">AS</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">JOIN</span><span class="w"> </span><span class="err">訂單</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">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">&lt;</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">11</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：101|102
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">-- 佳穎多一張 103（下單日 2026-03-15）之後：101|102、101|103、102|103</span></span></span></code></pre></div><h2 id="自連接的適用問題前後配對同組比較與層級關係">自連接的適用問題：前後配對、同組比較與層級關係</h2>
<p>自連接處理的是<strong>同一批資料裡兩筆資料之間的關係</strong>——兩列，不是兩個欄位。一列一列的處理看不到這種關係，因為每一列在被判斷的時候不知道其他列長什麼樣子。</p>
<p>自連接常見的形態有三種。<strong>前後配對</strong>：同一位顧客的訂單先後配對，把時間上相鄰或有先後關係的兩列擺在一起。<strong>同組比較</strong>：找出訂單金額高於同一位顧客其他訂單的那些，比較的對象是同一張表裡的兄弟列。<strong>層級關係</strong>：一張員工表裡每個人有一個主管欄位指向同一張表的另一列，要把人與他的主管一起列出來時，員工表出現兩次，一次當員工、一次當主管。</p>
<p>前後配對、同組比較與層級關係三種的共同點是<strong>要比較的兩列住在同一張表裡</strong>。需不需要自連接由兩個獨立的問題決定：這個問題要不要拿一列去跟同一張表的另一列比——不比的問題不屬於這一族；以及要不要把另一列的欄位輸出出來——這個問題決定族裡選哪一種寫法。</p>
<p>要不要輸出另一列的欄位，就是 <a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a> 那條判斷標準——自連接也是一種 <code>JOIN</code>，所以它同樣適用。只是判斷「有沒有另一列符合條件」而不輸出它，用 <code>EXISTS</code> 比自連接省一次配對展開；要把兩列並排列出來（像上面的訂單先後配對），才非自連接不可。</p>
<p><strong>省下的量由配對會不會膨脹決定，所以這條判斷標準要連著連接鍵唯不唯一一起看。</strong> 連接鍵在對面那一次出現裡唯一的時候，一列只配得到一列，兩種寫法產出的列數相同，<code>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> 寫的列數膨脹。判斷的動作是問這個連接鍵在對面那一次出現裡唯不唯一，而唯一與否要由<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>回答，不由現在這批資料回答。</p>
<p><strong>同組比較最常見的題型是每組最大值，而它套用「要不要輸出另一列的欄位」這條判斷標準時，答案是不輸出。</strong> 「找出每位顧客金額最高的訂單」輸出的是那張訂單自己的欄位，同顧客的其他訂單只拿來比大小，所以寫成 <code>NOT EXISTS</code> 就夠。前後配對的「找出每筆訂單與同顧客下一筆的差額」則要用到另一列的金額，那就非自連接不可。</p>
<p>每組最大值的條件是比大小，而比大小自己會帶出兩個邊界：<strong>並列的最大值</strong>與<strong>可為空的比較欄</strong>。<code>NOT EXISTS</code> 會把並列的那幾筆全部留下，要只留一筆得另外加一個決勝的條件，通常取編號最小的那筆。<code>NULL</code> 跟誰比都判不成真，所以「同顧客裡沒有比它大的」對金額是 <code>NULL</code> 的那一張恆成立，它會被選成最大；要排除它，<code>IS NOT 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>）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 每位顧客金額最高的訂單：同顧客裡找不到比它大的那一張
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">o</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">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">o</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="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="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">i</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="n">i</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</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">i</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">o</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="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">-- 共用資料：102 | 500
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">-- 佳穎多一張 103（金額 500）之後：102 | 500、103 | 500（並列的兩張都留下）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- 佳穎多一張 104（金額 NULL）之後：102 | 500、104 | NULL（NULL 那一張被選成最大）
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 決勝的條件：金額相同時，編號較小的那一張也算「比它大」
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</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">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">o</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">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="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </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">i</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">WHERE</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</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="k">AND</span><span class="w"> </span><span class="p">(</span><span class="n">i</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">o</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</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">i</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">o</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">19</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎多一張 103（金額 500）之後：102 | 500
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"></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">-- 排除金額是 NULL 的那一張：條件寫在外層
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</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="n">o</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">o</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">26</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="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">    </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">i</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">    </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</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">i</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">o</span><span class="p">.</span><span class="err">金額</span><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w">  </span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎多一張 104（金額 NULL）之後：102 | 500
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="c1">-- 同一句改寫進子查詢（AND i.金額 IS NOT NULL）：仍回 102 | 500、104 | NULL</span></span></span></code></pre></div><h2 id="表只出現一次時別名的作用">表只出現一次時別名的作用</h2>
<p>同一張表只出現一次的時候，別名的作用是縮短欄位引用。長表名在有三四張表的查詢裡會讓每個欄位引用都變得很長，取一個短別名之後可讀性差很多。</p>
<p>而作用不只是省字。取了別名之後原本的表名在這個查詢裡就不再是它的稱呼，三個引擎都會擋下來：PostgreSQL 回 <code>invalid reference to FROM-clause entry for table &quot;訂單&quot;</code> 並附上一句提示要改用別名，SQLite 回 <code>no such column</code>，DuckDB 回 <code>Referenced table &quot;訂單&quot; not found</code>。<code>FROM 訂單 AS 早</code> 宣告的是「這次出現叫早」，而宣告過後就只有這一個稱呼。</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="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="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">FROM</span><span class="w"> </span><span class="err">訂單</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="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">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">&gt;</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">下單日</span><span class="p">;</span></span></span></code></pre></div><p>把兩個別名換成 <code>a</code> 與 <code>b</code>，查詢的行為一模一樣，而 <code>b.金額 - a.金額</code> 這一行要回頭看 <code>ON</code> 才知道哪一個是後來那張單。判斷標準落在這裡：<strong>讀到用它的那一行時，方向自己說得出來。</strong> 名字取得對的時候，<code>晚.金額 - 早.金額</code> 讀起來就是它的意思。</p>
<p>引擎對這個名字沒有意見。把兩個別名對調，條件也跟著對調：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</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="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">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">&gt;</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">下單日</span></span></span></code></pre></div><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 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">宣告</a>——它說明這次出現代表什麼，兌現與否由寫的人負責。</p>
<h2 id="表的別名與欄位別名各自可用的時機">表的別名與欄位別名各自可用的時機</h2>
<p><code>SELECT</code> 裡的 <code>AS</code> 替算出來的欄位取名，<code>FROM</code> 裡的 <code>AS</code> 替表的一次出現取名。兩者的作用範圍不同，可用的時機也不同。</p>
<p>表的別名在 <code>FROM</code> 那一步就成立，所以 <code>WHERE</code>、<code>GROUP BY</code>、<code>ON</code> 全部用得到它。欄位別名到 <code>SELECT</code> 那一步才誕生，所以 <code>WHERE</code> 照語意模型用不到。兩個 <code>AS</code> 長得一樣而落在求值順序的兩端。</p>
<p>某些引擎讓 <code>WHERE</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> 走過每一步能用到哪些欄位與值，並把模型層擋得住的限制與各家引擎自己放寬的分開——表的別名不在它的範圍裡，因為別名在 <code>FROM</code> 那一步就成立了。</p>
]]></content:encoded></item><item><title>SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/</guid><description>&lt;p>「找出有對應資料的那些」這類問題有 &lt;code>IN&lt;/code>、&lt;code>EXISTS&lt;/code> 與 &lt;code>JOIN&lt;/code> 三種常見寫法，而它們在很多情況下回同樣的答案。這種巧合讓它們看起來可以互換，而三者描述的是不同的事，回的列數也不一定相同。&lt;/p>
&lt;p>分開它們的問題只有一個：&lt;strong>除了判斷有沒有，還要不要用到被比對的那張表的欄位。&lt;/strong>&lt;/p>
&lt;h2 id="同一題用-inexists-與-join-各寫一次">同一題用 IN、EXISTS 與 JOIN 各寫一次&lt;/h2>
&lt;p>書店的三位顧客裡只有佳穎下過單，兩張都是她的。要找出下過單的顧客。&lt;/p>
&lt;p>&lt;code>IN&lt;/code> 問的是成員資格——這個顧客編號在不在括號裡那個&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">子查詢&lt;/a>交出的清單裡：&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="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="k">IN&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="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="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">-- 佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>EXISTS&lt;/code> 問的是存在性——有沒有至少一張訂單屬於這位顧客：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎、佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一個「找出下過單的顧客」的問題，&lt;code>IN&lt;/code> 與 &lt;code>EXISTS&lt;/code> 回一列而 &lt;code>JOIN&lt;/code> 回兩列——&lt;strong>佳穎在 &lt;code>JOIN&lt;/code> 的結果裡出現了兩次&lt;/strong>。&lt;/p>
&lt;h2 id="列數條件與連接的差別">列數：條件與連接的差別&lt;/h2>
&lt;p>&lt;code>IN&lt;/code> 與 &lt;code>EXISTS&lt;/code> 出現在 &lt;code>WHERE&lt;/code> 裡，它們是條件。條件對每一列只做一件事——留下或丟掉——所以顧客表有三列，過完條件最多剩三列，一列都不會多出來。&lt;/p>
&lt;p>&lt;code>JOIN&lt;/code> 出現在 &lt;code>FROM&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>DISTINCT&lt;/code> 之後結果才與 &lt;code>IN&lt;/code>、&lt;code>EXISTS&lt;/code> 一致：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DISTINCT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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 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">-- 佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>需要 &lt;code>DISTINCT&lt;/code> 這件事本身是一個訊號&lt;/strong>：它表示這個查詢只是要判斷有沒有，而 &lt;code>JOIN&lt;/code> 順便做了不需要的配對。這種時候 &lt;code>IN&lt;/code> 或 &lt;code>EXISTS&lt;/code> 更貼合意圖，因為它們一開始就沒有把列複製出來。&lt;/p>
&lt;p>&lt;code>IN&lt;/code> 與 &lt;code>EXISTS&lt;/code> 做的運算在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>裡叫半連接：顧客表與訂單表配對之後只留下顧客表的列，每一列至多出現一次。否定式的 &lt;code>NOT EXISTS&lt;/code> 留下顧客表裡配不到任何訂單的列，叫反連接（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join（半連接與反連接）&lt;/a>）。&lt;/p>
&lt;h2 id="可取用的欄位被比對的那張表在不在-from-裡">可取用的欄位：被比對的那張表在不在 FROM 裡&lt;/h2>
&lt;p>要列出每位顧客的訂單金額，&lt;code>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="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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 300 / 佳穎 500&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一件事用 &lt;code>IN&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="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 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="k">IN&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="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="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">-- SQLite: no such column: 訂單.金額
&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">-- DuckDB: Referenced table &amp;#34;訂單&amp;#34; not found!&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>錯誤訊息說得很直接：&lt;code>訂單&lt;/code> 這張表不在 &lt;code>FROM&lt;/code> 裡面。子查詢是一個獨立的查詢，它算完之後只交出一份值的清單，而訂單表沒有成為外層 &lt;code>FROM&lt;/code> 的一部分——外層因此引用不到它的任何欄位。&lt;/p>
&lt;p>帶欄位出來還有另一條路——把子查詢寫在 &lt;code>SELECT&lt;/code> 裡並讓它引用外層的欄位。引用外層欄位的子查詢叫&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">相關子查詢&lt;/a>（correlated subquery）：&lt;/p></description><content:encoded><![CDATA[<p>「找出有對應資料的那些」這類問題有 <code>IN</code>、<code>EXISTS</code> 與 <code>JOIN</code> 三種常見寫法，而它們在很多情況下回同樣的答案。這種巧合讓它們看起來可以互換，而三者描述的是不同的事，回的列數也不一定相同。</p>
<p>分開它們的問題只有一個：<strong>除了判斷有沒有，還要不要用到被比對的那張表的欄位。</strong></p>
<h2 id="同一題用-inexists-與-join-各寫一次">同一題用 IN、EXISTS 與 JOIN 各寫一次</h2>
<p>書店的三位顧客裡只有佳穎下過單，兩張都是她的。要找出下過單的顧客。</p>
<p><code>IN</code> 問的是成員資格——這個顧客編號在不在括號裡那個<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 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="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><code>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">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><code>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">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、佳穎</span></span></span></code></pre></div><p>同一個「找出下過單的顧客」的問題，<code>IN</code> 與 <code>EXISTS</code> 回一列而 <code>JOIN</code> 回兩列——<strong>佳穎在 <code>JOIN</code> 的結果裡出現了兩次</strong>。</p>
<h2 id="列數條件與連接的差別">列數：條件與連接的差別</h2>
<p><code>IN</code> 與 <code>EXISTS</code> 出現在 <code>WHERE</code> 裡，它們是條件。條件對每一列只做一件事——留下或丟掉——所以顧客表有三列，過完條件最多剩三列，一列都不會多出來。</p>
<p><code>JOIN</code> 出現在 <code>FROM</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>DISTINCT</code> 之後結果才與 <code>IN</code>、<code>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">DISTINCT</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 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">-- 佳穎</span></span></span></code></pre></div><p><strong>需要 <code>DISTINCT</code> 這件事本身是一個訊號</strong>：它表示這個查詢只是要判斷有沒有，而 <code>JOIN</code> 順便做了不需要的配對。這種時候 <code>IN</code> 或 <code>EXISTS</code> 更貼合意圖，因為它們一開始就沒有把列複製出來。</p>
<p><code>IN</code> 與 <code>EXISTS</code> 做的運算在<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>裡叫半連接：顧客表與訂單表配對之後只留下顧客表的列，每一列至多出現一次。否定式的 <code>NOT EXISTS</code> 留下顧客表裡配不到任何訂單的列，叫反連接（<a href="/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join（半連接與反連接）</a>）。</p>
<h2 id="可取用的欄位被比對的那張表在不在-from-裡">可取用的欄位：被比對的那張表在不在 FROM 裡</h2>
<p>要列出每位顧客的訂單金額，<code>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="p">,</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 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">-- 佳穎 300 / 佳穎 500</span></span></span></code></pre></div><p>同一件事用 <code>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="p">,</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 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="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">-- SQLite: no such column: 訂單.金額
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- DuckDB: Referenced table &#34;訂單&#34; not found!</span></span></span></code></pre></div><p>錯誤訊息說得很直接：<code>訂單</code> 這張表不在 <code>FROM</code> 裡面。子查詢是一個獨立的查詢，它算完之後只交出一份值的清單，而訂單表沒有成為外層 <code>FROM</code> 的一部分——外層因此引用不到它的任何欄位。</p>
<p>帶欄位出來還有另一條路——把子查詢寫在 <code>SELECT</code> 裡並讓它引用外層的欄位。引用外層欄位的子查詢叫<a href="/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">相關子查詢</a>（correlated subquery）：</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="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="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">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">LIMIT</span><span class="w"> </span><span class="mi">1</span><span class="p">)</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">3</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 300 / 宗翰 NULL / 雅文 NULL</span></span></span></code></pre></div><p>它一次只交得出一個值，所以要多欄或多列時仍然回到 <code>JOIN</code>；而它的執行方式是逐列的，代價怎麼算見 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a>。</p>
<p>這時候佳穎出現兩次是對的，因為問的是每一張訂單的金額，而她確實有兩張。<strong>這一題本來就要每張訂單各佔一列，所以列數變多正是這個寫法要交出來的東西。</strong></p>
<h2 id="選用的判斷標準要不要用到被比對那張表的欄位">選用的判斷標準：要不要用到被比對那張表的欄位</h2>
<p><strong>要不要用到被比對的那張表的欄位。</strong> 要用到就得 <code>JOIN</code>，因為只有它把那張表帶進 <code>FROM</code>。這時候要順帶檢查列數是不是預期內的——後面如果接聚合，<code>sum</code> 與 <code>count</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>。</p>
<p>只是判斷有沒有，就用 <code>IN</code> 或 <code>EXISTS</code>。它們是條件，對每一列只做留或丟，寫出來的意圖比 <code>JOIN</code> 加 <code>DISTINCT</code> 清楚——而哪一種比較快是另一個問題，答案取決於資料與索引。<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a> 用一道重複值的題目量分組、自連接與 <code>EXISTS</code> 三種寫法，並示範加一個索引之後排名重排——<code>EXISTS</code> 從最慢掉到與分組同一個量級。</p>
<h2 id="in-與-exists-的分岔子查詢含-null-時的否定式">IN 與 EXISTS 的分岔：子查詢含 NULL 時的否定式</h2>
<p><code>IN</code> 與 <code>EXISTS</code> 在「有沒有」這件事上同值，而在一個地方分岔：<strong>子查詢的結果裡有 NULL 的時候。</strong></p>
<p>清單裡只要混了一個 NULL，<code>NOT IN</code> 就再也不會為真：值在清單裡的列判成明確的假，值不在清單裡的列因為那個 NULL 判成未知，兩種都被 <code>WHERE</code> 丟掉，整段查詢回零列且不報錯；<code>NOT EXISTS</code> 不受影響。在共用資料上多一張沒填顧客編號的訂單就看得到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="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-20&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 訂單 103 沒有填顧客編號
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></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="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">-- 共用資料：宗翰、雅文
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 多了訂單 103 之後：零列，不報錯（子查詢交出的清單是 1、1、NULL）
</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">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">8</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">9</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料與多了訂單 103 之後都是：宗翰、雅文</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>，包括 <code>NOT EXISTS</code> 的安全範圍為什麼限定在等號配對。零列與「確實沒有」在結果上分不開，所以 <code>NOT IN</code> 碰到 NULL 的錯只能靠寫的人預先知道「子查詢有 NULL 時 NOT IN 會失效」那條規則——<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 把它歸成<strong>誤解語意模型</strong>：寫的人對某條規則的預期與語意模型的規定不同，而誤解語意模型修得掉，改對之後永遠對。</p>
<p>所以肯定式的 <code>IN</code> 與 <code>EXISTS</code> 可以按可讀性挑，而<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>而不是來自習慣：翻遍現在的資料都沒有 NULL 只證明此刻沒有，而查詢要活得比這一批資料久。</p>
<p>還有一個形態上的差別：本篇 <code>IN</code> 的子查詢與外層無關，可以單獨拿出來執行，叫獨立子查詢；<code>EXISTS</code> 的子查詢引用了外層的欄位，離開外層就跑不動，是相關子查詢。讀陌生的查詢時，看子查詢裡有沒有出現外層的表名，就分得出它是算一次的獨立子查詢，還是對外層每一列各問一次的相關子查詢。</p>
<p>「語意相同就按可讀性挑」這個處置的適用範圍不只 <code>IN</code>、<code>EXISTS</code> 與 <code>JOIN</code> 這三種寫法：哪幾類寫法差異是免費的、哪一類會與效能分岔，在 <a href="/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形</a>。</p>
]]></content:encoded></item><item><title>SQL.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.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/window-keeps-rows-grouping-collapses/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/window-keeps-rows-grouping-collapses/</guid><description>&lt;p>&lt;code>GROUP BY&lt;/code> 與視窗函數都對一組列算出一個值。分開兩者的是算完之後原本那些列還在不在：&lt;strong>分組把一組收成一列，視窗函數把值貼回每一列上。&lt;/strong>&lt;/p>
&lt;p>這一篇接在 &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;a href="https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的訂單表&lt;/a>上多加兩列，讓兩位顧客各有兩張訂單。共用的那兩列是佳穎（顧客 1）的 101 與 102，下單日 3/02 與 3/09；加的兩列屬於宗翰（顧客 2）：&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">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">105&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-05&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">106&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-20&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">450&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="同一個總額用分組與用視窗函數寫的輸出">同一個總額用分組與用視窗函數寫的輸出&lt;/h2>
&lt;p>「每位顧客的總消費」用分組寫，四列訂單收成兩列：&lt;/p>





&lt;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="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="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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">1 800
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2 650&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="w"> &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="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">sum&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="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">PARTITION&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">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="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">101 1 300 800
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">102 1 500 800
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">105 2 200 650
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">106 2 450 650&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>PARTITION BY&lt;/code> 承擔的是 &lt;code>GROUP BY&lt;/code> 那個鍵的角色——它說明一組由誰構成。差別全在輸出：&lt;code>GROUP BY&lt;/code> 那一段的結果只剩兩列，而每一張訂單的金額已經被收進總額裡取不回來了。&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="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="n">round&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">100&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&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="w"> &lt;/span>&lt;span class="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">PARTITION&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="mi">1&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="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">101 300 37.5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">102 500 62.5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">105 200 30.8
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">106 450 69.2&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="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>&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="n">round&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="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">0&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 class="mi">1&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>&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">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">sum&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">-- 讀第一次：收成每位顧客一列總額
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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"> 6&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 class="c1">-- 讀第二次：每一張訂單接上它那位顧客的總額
&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">-- 101 300 37.5
&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">-- 102 500 62.5
&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">-- 105 200 30.8
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 106 450 69.2（與視窗函數那一段的結果相同）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>視窗函數把這兩次寫成一次，明細與組的值在同一趟裡就都算出來。&lt;strong>這裡說的是結構省了一次連接，不是它一定跑得比較快&lt;/strong>：兩者的代價差多少由資料量、分組數與可用的索引決定，而那要向引擎要計畫——&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價&lt;/a> 量了同一道題三種寫法在有無索引下的排名對調。&lt;/p>
&lt;h2 id="選用分組或視窗函數的依據結果的一列代表什麼">選用分組或視窗函數的依據：結果的一列代表什麼&lt;/h2>
&lt;p>判斷標準是一句話：&lt;strong>結果的一列代表什麼。&lt;/strong> 一列代表一位顧客，用 &lt;code>GROUP BY&lt;/code>；一列代表一張訂單而順便帶著顧客層的值，用視窗函數。&lt;/p>
&lt;p>這一句對得上 &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></description><content:encoded><![CDATA[<p><code>GROUP BY</code> 與視窗函數都對一組列算出一個值。分開兩者的是算完之後原本那些列還在不在：<strong>分組把一組收成一列，視窗函數把值貼回每一列上。</strong></p>
<p>這一篇接在 <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>本篇在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的訂單表</a>上多加兩列，讓兩位顧客各有兩張訂單。共用的那兩列是佳穎（顧客 1）的 101 與 102，下單日 3/02 與 3/09；加的兩列屬於宗翰（顧客 2）：</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">105</span><span class="p">,</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;2026-03-05&#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">106</span><span class="p">,</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;2026-03-20&#39;</span><span class="p">,</span><span class="mi">450</span><span class="p">);</span></span></span></code></pre></div><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="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="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></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1  800
</span></span><span class="line"><span class="ln">2</span><span class="cl">2  650</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="w"> </span><span class="err">顧客編號</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">2</span><span class="cl"><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="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="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="p">;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">101  1  300  800
</span></span><span class="line"><span class="ln">2</span><span class="cl">102  1  500  800
</span></span><span class="line"><span class="ln">3</span><span class="cl">105  2  200  650
</span></span><span class="line"><span class="ln">4</span><span class="cl">106  2  450  650</span></span></code></pre></div><p><code>PARTITION BY</code> 承擔的是 <code>GROUP BY</code> 那個鍵的角色——它說明一組由誰構成。差別全在輸出：<code>GROUP BY</code> 那一段的結果只剩兩列，而每一張訂單的金額已經被收進總額裡取不回來了。</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="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="n">round</span><span class="p">(</span><span class="err">金額</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">100</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="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="w"> </span><span class="mi">1</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="p">;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">101  300  37.5
</span></span><span class="line"><span class="ln">2</span><span class="cl">102  500  62.5
</span></span><span class="line"><span class="ln">3</span><span class="cl">105  200  30.8
</span></span><span class="line"><span class="ln">4</span><span class="cl">106  450  69.2</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="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"> 2</span><span class="cl"><span class="w">       </span><span class="n">round</span><span class="p">(</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">100</span><span class="p">.</span><span class="mi">0</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 class="mi">1</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></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="w">      </span><span class="c1">-- 讀第一次：收成每位顧客一列總額
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">      </span><span 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"> 6</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 class="c1">-- 讀第二次：每一張訂單接上它那位顧客的總額
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">-- 101 300 37.5
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">-- 102 500 62.5
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">-- 105 200 30.8
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- 106 450 69.2（與視窗函數那一段的結果相同）</span></span></span></code></pre></div><p>視窗函數把這兩次寫成一次，明細與組的值在同一趟裡就都算出來。<strong>這裡說的是結構省了一次連接，不是它一定跑得比較快</strong>：兩者的代價差多少由資料量、分組數與可用的索引決定，而那要向引擎要計畫——<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>
<h2 id="選用分組或視窗函數的依據結果的一列代表什麼">選用分組或視窗函數的依據：結果的一列代表什麼</h2>
<p>判斷標準是一句話：<strong>結果的一列代表什麼。</strong> 一列代表一位顧客，用 <code>GROUP BY</code>；一列代表一張訂單而順便帶著顧客層的值，用視窗函數。</p>
<p>這一句對得上 <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>後續的子句跟著這個選擇走。<code>GROUP BY</code> 算出來的值可以放進 <code>HAVING</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>），而視窗函數算出來的值到 <code>SELECT</code> 那一步才誕生，<code>WHERE</code> 與 <code>HAVING</code> 都拿不到它——要拿它當條件，得把整段包成一層子查詢再從外面篩（部分引擎另有 <code>QUALIFY</code> 這個子句直接做這件事，標準 SQL 沒有）。算 <code>佔該顧客百分比</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">-- 直接在同一層寫 WHERE 佔該顧客百分比 &gt; 50，SQLite 3.51 拒絕：
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">--   misuse of aliased window function 佔該顧客百分比
</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="w"> </span><span class="err">佔該顧客百分比</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="k">SELECT</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="n">round</span><span class="p">(</span><span class="err">金額</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">100</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="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="w"> </span><span class="mi">1</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"> 6</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"> 7</span><span class="cl"><span class="w"></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 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">50</span><span class="p">;</span><span class="w">       </span><span class="c1">-- 外層的 WHERE 讀得到它
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">-- 102 62.5
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- 106 69.2</span></span></span></code></pre></div><h2 id="lag-與-lead取同一組裡相鄰的列">LAG 與 LEAD：取同一組裡相鄰的列</h2>
<p>明細列還在，於是「同一位顧客的上一張訂單」這個概念才存在——分組把明細收掉之後，能取的相鄰只剩組與組之間的。所以視窗函數除了對整組算值，還能直接取同一組裡相鄰的某一列。<code>LAG</code> 往前取、<code>LEAD</code> 往後取，而相鄰的定義由 <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="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">顧客編號</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">2</span><span class="cl"><span class="w">       </span><span class="err">金額</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">LAG</span><span class="p">(</span><span class="err">金額</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="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="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="p">;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">101  1  300  （空）
</span></span><span class="line"><span class="ln">2</span><span class="cl">102  1  500  200
</span></span><span class="line"><span class="ln">3</span><span class="cl">105  2  200  （空）
</span></span><span class="line"><span class="ln">4</span><span class="cl">106  2  450  250</span></span></code></pre></div><p>每一位顧客的第一張訂單前面沒有列可取，值是 <code>NULL</code>。取同一組裡相鄰一列的問題用自連接也寫得出來，差別在自連接要把兩列配起來，而 <code>LAG</code> 沿著已經排好的順序直接往前數一列。<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a> 走的是配起來那條路，並給了配對會不會膨脹這個條件。</p>
<h2 id="排序相鄰與問題要的相鄰分區排序鍵與距離條件">排序相鄰與問題要的相鄰：分區、排序鍵與距離條件</h2>
<p><code>LAG</code> 交出的是<strong>排序相鄰</strong>——照 <code>ORDER BY</code> 排好之後緊鄰的那一列，而<strong>問題要的相鄰</strong>往往是另一種：同一位顧客的上一張、前一天、上一個月。排序相鄰與問題要的相鄰對不上的時候，查詢照樣回結果。</p>
<p>把上面那段的 <code>PARTITION BY</code> 拿掉，其餘不動。少了分區之後 <code>LAG</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="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="err">金額</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">LAG</span><span class="p">(</span><span class="err">金額</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</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="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="c1">-- 沿全表的下單日往前取一列
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="w">       </span><span class="err">金額</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">LAG</span><span class="p">(</span><span class="err">金額</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="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="w"> </span><span class="k">AS</span><span class="w"> </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">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">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="w">                  </span><span class="c1">-- 列序照全表的下單日：3/02、3/05、3/09、3/20</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">訂單  顧客  沒分區的差額   分區後的差額
</span></span><span class="line"><span class="ln">2</span><span class="cl">101   1     （空）         （空）
</span></span><span class="line"><span class="ln">3</span><span class="cl">105   2     -100           （空）
</span></span><span class="line"><span class="ln">4</span><span class="cl">102   1     300            200
</span></span><span class="line"><span class="ln">5</span><span class="cl">106   2     -50            250</span></span></code></pre></div><p>非首列的三列全部錯了。宗翰的第一張訂單拿佳穎的訂單當「上一張」算出 -100，佳穎的第二張拿宗翰的算出 300。<strong>這些數字沒有一個報錯，而它們都是拿兩位不同顧客的訂單相減得來的。</strong></p>
<p>排序相鄰與問題要的相鄰對不上，除了跨到另一位顧客，也會發生在時間上：<code>ORDER BY 下單日</code> 排出來的相鄰兩列，日期上可能隔了半個月。問題若要的是「比前一天高」，那就要另外寫一個距離條件，因為排序只保證先後、不保證間隔。</p>
<p>那個條件的形狀是把距離也算成一欄，再從外面篩：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">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="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</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">3</span><span class="cl"><span class="w">         </span><span class="n">LAG</span><span class="p">(</span><span class="err">金額</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</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">4</span><span class="cl"><span class="w">         </span><span class="n">julianday</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="n">julianday</span><span class="p">(</span><span class="n">LAG</span><span class="p">(</span><span class="err">下單日</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</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">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">WINDOW</span><span class="w"> </span><span class="n">w</span><span class="w"> </span><span class="k">AS</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="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="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="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </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">1</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">-- 本篇的四列訂單：零列（102 與佳穎的上一張相隔 7 天，106 與宗翰的上一張相隔 15 天）
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- 拿掉 AND 相隔天數 = 1：102、106</span></span></span></code></pre></div><p>少了距離條件，102 與 106 都會被當成「前一天」的比較。</p>
<p>這個缺口在逐日資料上更難察覺。逐日觀測只要偶爾缺一天，少了距離檢查的版本就會把斷點兩側當成相鄰而多算幾十次比較，而多出來的量相對於總列數小到不會有任何一處顯得異常——查詢照跑，結果集的規模也看不出差別。</p>
<p>所以 <code>OVER</code> 括號裡有三件事要分開確認：<strong><code>PARTITION BY</code> 說明相鄰限定在哪個範圍之內，<code>ORDER BY</code> 說明沿著什麼排，而那個排序鍵要能把同一組裡的列排成唯一的次序。</strong> 排序唯一最容易漏——排序鍵有並列時「前一列」是哪一列沒有定義，同一組資料與同一個窗規格因此可以給出不同的答案，而同一段 <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="c1">-- 本篇的四列訂單再加一張與 102 同一天下單的 107（只在這一段成立）
</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">107</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">100</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">LAG</span><span class="p">(</span><span class="err">訂單編號</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="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="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">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"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 102 的上一張是 101，107 的上一張是 102
</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="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">LAG</span><span class="p">(</span><span class="err">訂單編號</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="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="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"> 9</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="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="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">金額名次</span><span class="w">   </span><span class="c1">-- 多一個按金額排序的視窗；上一張那一欄的寫法沒動
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></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">11</span><span class="cl"><span class="w"></span><span class="c1">-- 107 的上一張變成 101，102 的上一張變成 107（SQLite 3.51）</span></span></span></code></pre></div><p>並列時要補一個決勝的次鍵：窗規格寫成 <code>ORDER BY 下單日, 訂單編號</code> 之後，兩段查詢的上一張那一欄都是 102 接在 101 後面、107 接在 102 後面。分區、排序鍵與排序唯一都確認之後，還要問排序相鄰是不是就等於問題要的相鄰——答案是否定的時候，那個距離條件得自己補上。</p>
<h2 id="over-括號裡的分區與排序作為宣告">OVER 括號裡的分區與排序作為宣告</h2>
<p><code>PARTITION BY 顧客編號</code> 宣告的是「相鄰限定在同一位顧客之內」。寫了它而它沒在做事也是可能的——每位顧客都只有一張單的時候，拿掉它結果不變，那一類「宣告在、行為不需要它」的情形在 <a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>。漏寫它則是另一回事：文字裡沒有那份宣告，錯的是答案。上一節的兩個缺口（漏分區、漏距離條件）共同的形狀是查詢照跑、結果集的規模也看不出差別，<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.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.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/pagination-needs-a-total-order/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/pagination-needs-a-total-order/</guid><description>&lt;p>分頁把一批列切成好幾次取回，而&lt;strong>每一頁是一次獨立的查詢&lt;/strong>——這是無狀態介面帶進來的前提，不是 SQL 的性質。資料庫伺服器端開一個&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cursor/" data-link-title="Cursor（游標、查詢結果的讀取物件、接續標記）" data-link-desc="在程式碼、API 或文件裡看到 cursor 這個字，要分辨它指的是畫面上的游標、資料庫逐筆讀取查詢結果的物件，還是分頁回應裡記錄從哪裡接續的標記時">查詢結果的讀取物件（cursor）&lt;/a>、一次執行逐批取回的時候，下面這一整篇的問題不會發生。把翻頁包在同一個交易裡則不夠：它擋得住的只有插入造成的位移，而且要 &lt;code>REPEATABLE READ&lt;/code> 以上的隔離等級，同分的列在同一個交易裡逐頁取，先後照樣會變（原因在〈排序鍵不唯一時同分的列的先後〉）。&lt;/p>
&lt;p>在每一頁各查一次的前提底下，兩次查詢之間伺服器不持有任何狀態，能拿來對齊兩頁的只有排序本身。&lt;/p>
&lt;p>而 &lt;code>ORDER BY&lt;/code> 只規定排序鍵分得出高下的列之間的先後；排序鍵的值相同的列（下面叫同分的列）彼此誰在前面，它沒有規定。那個規定怎麼運作、空值為什麼比不出大小，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍&lt;/a>。整批取回的時候，同分的列先後不定無害；逐頁取回的時候，它變成同一筆資料出現兩次、另一筆一次都不出現。&lt;/p>
&lt;p>本篇處理三件事：那個機制、把順序補到全序（任兩列都比得出先後）的判斷標準，以及位置式與值式兩種接續標記（cursor）各自成立什麼。&lt;strong>對外要選哪一種分頁還牽涉到跳頁與總筆數要不要放棄、接續標記怎麼編碼、以及一致性承諾怎麼寫進契約，那些不在本篇裡&lt;/strong>：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/pagination-debate/" data-link-title="分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款" data-link-desc="定位機制、一致性模型與表示法各自的取捨：offset 與 keyset 的成本與能力差、不透明接續標記給服務端的自由、未宣告的接續標記性質會被誰依賴、offset 留下來的條件，以及選型的判定順序">分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款&lt;/a> 的〈選型的順序：跳頁與總數需求、定位機制、表示法〉一節給選型的三步（先問跳頁與總數、再選定位機制、最後定表示法）與各步的出口，〈不透明接續標記隱含的承諾與條款清單〉一節寫接續標記要對外承諾哪些條款，〈offset 與 keyset 定位機制的成本曲線與能力差〉一節另列出快照分頁與時間視窗這兩條本篇沒有的路；接續標記式的複雜度對照與 tiebreaker 設計在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/keyset-pagination/" data-link-title="Keyset Pagination" data-link-desc="用上一頁最後一筆的 key 當下一頁起點、避開 OFFSET 大表時的線性退化">Keyset Pagination&lt;/a>。&lt;/p>
&lt;p>本篇沿用 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍&lt;/a> 那一批訂單：在&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、再加 103 到 105 三張，讓五張訂單裡有三張同樣是 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="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="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">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">300&lt;/span>&lt;span class="p">),&lt;/span>&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">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">3&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;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 新加的三張沒有填顧客與下單日，本篇的查詢只用到訂單編號與金額&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="排序鍵不唯一時同分的列的先後">排序鍵不唯一時同分的列的先後&lt;/h2>
&lt;p>&lt;code>ORDER BY 金額 DESC&lt;/code> 說的是金額大的在前。三張 500 元的訂單彼此之間沒有被規定，所以它們的先後與沒有 &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">-- SQLite 3.51，逐頁取一列：第 n 頁是 OFFSET n-1
&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">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="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OFFSET&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">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- OFFSET 1 到 OFFSET 4 各跑一次同一段查詢
&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 102 103 105 104
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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">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="w"> &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 105 103 102 104&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>整批看的時候這個差別無害——五張訂單一張不多一張不少，只是中間三張換了位置。&lt;strong>分頁把它變成錯誤，因為每一頁是一次獨立的查詢&lt;/strong>：兩次查詢之間換了計畫的話，前一頁的最後一列可能在下一頁再出現一次，而另一列一次都不出現。計畫不換也會發生：PostgreSQL 依 &lt;code>LIMIT&lt;/code> 加 &lt;code>OFFSET&lt;/code> 的大小選排序方法，第一頁走 top-N heapsort、後面的頁走 quicksort，兩種方法對同分列給出不同的先後——一次 PostgreSQL 18 上的實測，一千列、同分很多，在同一個交易裡逐頁取完只拿到 757 個不同的列。&lt;/p>
&lt;p>另一種形態不必換計畫就會發生：一邊翻頁一邊有人寫入，排在前面的列多了或少了，後面每一列的位置就跟著移動。下面叫它翻頁途中的寫入造成的位移。&lt;/p></description><content:encoded><![CDATA[<p>分頁把一批列切成好幾次取回，而<strong>每一頁是一次獨立的查詢</strong>——這是無狀態介面帶進來的前提，不是 SQL 的性質。資料庫伺服器端開一個<a href="/blog/backend/knowledge-cards/cursor/" data-link-title="Cursor（游標、查詢結果的讀取物件、接續標記）" data-link-desc="在程式碼、API 或文件裡看到 cursor 這個字，要分辨它指的是畫面上的游標、資料庫逐筆讀取查詢結果的物件，還是分頁回應裡記錄從哪裡接續的標記時">查詢結果的讀取物件（cursor）</a>、一次執行逐批取回的時候，下面這一整篇的問題不會發生。把翻頁包在同一個交易裡則不夠：它擋得住的只有插入造成的位移，而且要 <code>REPEATABLE READ</code> 以上的隔離等級，同分的列在同一個交易裡逐頁取，先後照樣會變（原因在〈排序鍵不唯一時同分的列的先後〉）。</p>
<p>在每一頁各查一次的前提底下，兩次查詢之間伺服器不持有任何狀態，能拿來對齊兩頁的只有排序本身。</p>
<p>而 <code>ORDER BY</code> 只規定排序鍵分得出高下的列之間的先後；排序鍵的值相同的列（下面叫同分的列）彼此誰在前面，它沒有規定。那個規定怎麼運作、空值為什麼比不出大小，在 <a href="/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</a>。整批取回的時候，同分的列先後不定無害；逐頁取回的時候，它變成同一筆資料出現兩次、另一筆一次都不出現。</p>
<p>本篇處理三件事：那個機制、把順序補到全序（任兩列都比得出先後）的判斷標準，以及位置式與值式兩種接續標記（cursor）各自成立什麼。<strong>對外要選哪一種分頁還牽涉到跳頁與總筆數要不要放棄、接續標記怎麼編碼、以及一致性承諾怎麼寫進契約，那些不在本篇裡</strong>：<a href="/blog/backend/11-api-design/pagination-debate/" data-link-title="分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款" data-link-desc="定位機制、一致性模型與表示法各自的取捨：offset 與 keyset 的成本與能力差、不透明接續標記給服務端的自由、未宣告的接續標記性質會被誰依賴、offset 留下來的條件，以及選型的判定順序">分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款</a> 的〈選型的順序：跳頁與總數需求、定位機制、表示法〉一節給選型的三步（先問跳頁與總數、再選定位機制、最後定表示法）與各步的出口，〈不透明接續標記隱含的承諾與條款清單〉一節寫接續標記要對外承諾哪些條款，〈offset 與 keyset 定位機制的成本曲線與能力差〉一節另列出快照分頁與時間視窗這兩條本篇沒有的路；接續標記式的複雜度對照與 tiebreaker 設計在 <a href="/blog/backend/knowledge-cards/keyset-pagination/" data-link-title="Keyset Pagination" data-link-desc="用上一頁最後一筆的 key 當下一頁起點、避開 OFFSET 大表時的線性退化">Keyset Pagination</a>。</p>
<p>本篇沿用 <a href="/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</a> 那一批訂單：在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫</a>上把 101 的金額改成 700、再加 103 到 105 三張，讓五張訂單裡有三張同樣是 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="p">(</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="p">)</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">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">300</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">105</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">3</span><span class="cl"><span class="w"></span><span class="c1">-- 101 是 700 元；102、103、105 是 500 元；104 是 300 元
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 新加的三張沒有填顧客與下單日，本篇的查詢只用到訂單編號與金額</span></span></span></code></pre></div><h2 id="排序鍵不唯一時同分的列的先後">排序鍵不唯一時同分的列的先後</h2>
<p><code>ORDER BY 金額 DESC</code> 說的是金額大的在前。三張 500 元的訂單彼此之間沒有被規定，所以它們的先後與沒有 <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">-- SQLite 3.51，逐頁取一列：第 n 頁是 OFFSET n-1
</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">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="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">OFFSET</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">3</span><span class="cl"><span class="w"></span><span class="c1">-- OFFSET 1 到 OFFSET 4 各跑一次同一段查詢
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 沒有索引：五頁依序是 101  102  103  105  104
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">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="w"> </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  105  103  102  104</span></span></span></code></pre></div><p>整批看的時候這個差別無害——五張訂單一張不多一張不少，只是中間三張換了位置。<strong>分頁把它變成錯誤，因為每一頁是一次獨立的查詢</strong>：兩次查詢之間換了計畫的話，前一頁的最後一列可能在下一頁再出現一次，而另一列一次都不出現。計畫不換也會發生：PostgreSQL 依 <code>LIMIT</code> 加 <code>OFFSET</code> 的大小選排序方法，第一頁走 top-N heapsort、後面的頁走 quicksort，兩種方法對同分列給出不同的先後——一次 PostgreSQL 18 上的實測，一千列、同分很多，在同一個交易裡逐頁取完只拿到 757 個不同的列。</p>
<p>另一種形態不必換計畫就會發生：一邊翻頁一邊有人寫入，排在前面的列多了或少了，後面每一列的位置就跟著移動。下面叫它翻頁途中的寫入造成的位移。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- ORDER BY 金額 DESC LIMIT 2
</span></span><span class="line"><span class="ln">2</span><span class="cl">第 1 頁（OFFSET 0）              101, 102
</span></span><span class="line"><span class="ln">3</span><span class="cl">   ← 這時插入 106，金額 900
</span></span><span class="line"><span class="ln">4</span><span class="cl">第 2 頁（OFFSET 2）              102, 103
</span></span><span class="line"><span class="ln">5</span><span class="cl">第 3 頁（OFFSET 4）              105, 104</span></span></code></pre></div><p>102 出現在兩頁上。<code>OFFSET 2</code> 的意思是「跳過排好之後的前兩列」，而新插入的 106 排在最前面，把所有列往後推了一格。原本在第二個位置的 102 被推到第三個，於是它同時是第一頁的最後一列與第二頁的第一列。</p>
<p>翻完三頁之後讀者手上是 101、102、102、103、105、104——<strong>六列裡有一列重複，而金額最高的那一筆 106 一次都沒出現</strong>。兩種偏差都不帶任何訊號：每一頁單獨看都是正確的查詢結果。查詢照跑、結果形狀正確而答案錯了，這一類錯誤共同的性質在 <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-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- ORDER BY 金額 DESC, 訂單編號   全表順序：101,102,103,105,104
</span></span><span class="line"><span class="ln">2</span><span class="cl">OFFSET 0 → 101,102     OFFSET 2 → 103,105     OFFSET 4 → 104</span></span></code></pre></div><p>同一段查詢跑幾次都是這個結果，換計畫也是。<strong>判斷標準是排序鍵的組合在「排序用的那條比較規則下」兩兩可分</strong>，這個性質叫全序：任兩列都比得出先後——「值不重複」還不夠，有兩種形態會讓不重複的鍵仍然排不出先後。一種是空值：一欄掛著 <code>UNIQUE</code> 而三列都是 <code>NULL</code>，約束成立而 <code>NULL</code> 之間比不出高下，SQLite 3.51 上建了索引之後同一段查詢回的順序就翻轉。另一種是比較規則把不同的值判成相等：MySQL 預設的 collation 底下 <code>Anna</code>／<code>anna</code>／<code>ANNA</code>／<code>Ánna</code> 四個值 <code>COUNT(DISTINCT BINARY 姓名)</code> 是 4 而 <code>COUNT(DISTINCT 姓名)</code> 是 1。看起來唯一的字串鍵因此可能不唯一，那條比較規則住在哪一層、各家預設是什麼，在 <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>
<p>把主鍵接在排序鍵最後面之所以省事，是因為它同時避開了空值與比較規則這兩種形態——主鍵<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>保證它不重複也不是 <code>NULL</code>，而數值型別的比較規則不會把兩個不同的數判成相等。SQLite 在這裡是例外：只有宣告成 <code>INTEGER PRIMARY KEY</code> 的那一欄擋得住 <code>NULL</code>，寫成 <code>INT PRIMARY KEY</code> 的主鍵照樣收得下好幾個 <code>NULL</code>，要當決勝鍵就得另外宣告 <code>NOT NULL</code>。所以排序鍵分不分得出高下，多半在寫 <code>CREATE TABLE</code> 的那一刻就定了：那一欄有沒有唯一約束、決勝鍵有沒有現成的欄位可用。<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>OFFSET</code> 數的是位置，而位置會被前面新增或刪除的列改變。要讓分頁不受<strong>排在接續標記前面的</strong>寫入影響，接續標記得從位置換成值——記下上一頁最後一列的排序鍵，下一頁從那個值之後接著取。（排在接續標記後面的新插入照樣會進到後面的頁上；要讓整趟翻頁看到同一個時點的資料是另一回事，那需要快照，<a href="/blog/backend/11-api-design/pagination-debate/" data-link-title="分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款" data-link-desc="定位機制、一致性模型與表示法各自的取捨：offset 與 keyset 的成本與能力差、不透明接續標記給服務端的自由、未宣告的接續標記性質會被誰依賴、offset 留下來的條件，以及選型的判定順序">分頁之爭</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">-- 上一頁最後一列是 (金額 500, 訂單編號 102)
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">500</span><span class="w"> </span><span class="k">OR</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">500</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">&gt;</span><span class="w"> </span><span class="mi">102</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">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 class="err">訂單編號</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">2</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">-- 103,105</span></span></span></code></pre></div><p>這個條件描述的是「排在那一列後面」，而它由排序鍵自己構成，所以中間插進來的 900 元訂單不影響它——那一列排在接續標記前面，本來就不該出現在後面的頁上。</p>
<p><strong>條件要跟著排序方向寫。</strong> SQL 有一種列值比較的簡寫 <code>WHERE (金額, 訂單編號) &lt; (500, 102)</code>，它只在所有排序鍵同方向時與上面那段等價。金額遞減而訂單編號遞增的時候，同一批資料上兩種寫法回不同的列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">展開式  WHERE 金額 &lt; 500 OR (金額 = 500 AND 訂單編號 &gt; 102)   → 103,105
</span></span><span class="line"><span class="ln">2</span><span class="cl">列值式  WHERE (金額, 訂單編號) &lt; (500, 102)                    → 104</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="c1">-- 兩個排序鍵都遞減，全表順序是 101,105,103,102,104
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">-- 上一頁最後一列是 (金額 500, 訂單編號 105)
</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="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">WHERE</span><span class="w"> </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="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="p">(</span><span class="mi">500</span><span class="p">,</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">5</span><span class="cl"><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 class="err">訂單編號</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">2</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">-- 103,102，與同一個排序下 OFFSET 2 LIMIT 2 取到的那一頁相同</span></span></span></code></pre></div><h2 id="offset-的代價與頁數的關係">OFFSET 的代價與頁數的關係</h2>
<p><code>OFFSET n</code> 要引擎按順序產出前 n 列再把它們丟掉，所以翻得越後面做的功越多。<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">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">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="n">k</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">ix_k</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="p">(</span><span class="n">k</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 排序鍵 k, id 整組都在索引裡，取 id 不必回表
</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="c1">-- OFFSET 式：先走過並丟掉排在前面的 n 列
</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="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">id</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">20</span><span class="w"> </span><span class="k">OFFSET</span><span class="w"> </span><span class="mi">900000</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="c1">-- 接續標記式：括號裡填上一頁最後一列的 (k, id)；兩個排序鍵同方向，列值比較可以直接用
</span></span></span><span class="line"><span class="ln">8</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="n">t</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="p">(</span><span class="n">k</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="p">(:</span><span class="err">上一頁的</span><span class="n">k</span><span class="p">,</span><span class="w"> </span><span class="p">:</span><span class="err">上一頁的</span><span class="n">id</span><span class="p">)</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">id</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">20</span><span class="p">;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- SQLite 3.51，五次的實測，單位秒
</span></span><span class="line"><span class="ln">2</span><span class="cl">OFFSET 0        0.001  0.000  0.001  0.000  0.001
</span></span><span class="line"><span class="ln">3</span><span class="cl">OFFSET 100000   0.002  0.002  0.002  0.003  0.001
</span></span><span class="line"><span class="ln">4</span><span class="cl">OFFSET 500000   0.007  0.005  0.005  0.005  0.005
</span></span><span class="line"><span class="ln">5</span><span class="cl">OFFSET 900000   0.009  0.010  0.012  0.009  0.008
</span></span><span class="line"><span class="ln">6</span><span class="cl">接續標記式（深度 900000） 0.000  0.000  0.000  0.000  0.001</span></span></code></pre></div><p>絕對值在這個規模上都很小，要看的是形狀：<code>OFFSET</code> 那一欄隨深度線性成長，接續標記式那一列與深度無關——它直接在索引上找到起點。所以「第一頁很快，翻到後面越來越慢」這個症狀有一個不必量測就成立的解釋：<code>OFFSET</code> 要先走過並丟掉排在前面的每一列，頁數越深要丟的越多。而它與索引建得好不好無關——上面那張表就是在索引已經覆蓋排序鍵的前提下量的。</p>
<p><code>OFFSET</code> 的成長是代價問題的一個實例，而代價的一般形狀由資料與索引決定、不由寫法決定。代價的一般形狀在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>，而 <code>OFFSET</code> 這一條特別之處在於它的成長與索引建得好不好無關。</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>SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/identifier-rules/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/identifier-rules/</guid><description>&lt;p>表名與欄名（統稱識別字）寫進查詢之後，引擎會先按自己的規則處理一次，才拿去對照真正存在的名字。這道改寫各家不同，所以同一段 SQL 在不同引擎上指向的可能是不同的名字——或者什麼都指不到。&lt;/p>
&lt;p>查詢的文字之外，還有幾方決定一段 SQL 最後做什麼：識別字規則、權限、最佳化器、約束、collation，以及引擎寬鬆度——標準禁止的寫法這一家引擎收不收（寬鬆度那一方在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機&lt;/a>）。識別字規則是離查詢文字最近的那一方：名字還沒指到任何一張表之前，引擎就已經按這套規則把它改寫過一次。同一張表被誰允許讀寫是另一方的事，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限&lt;/a>；最佳化器那一方，執行順序怎麼從書寫順序分出來在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價&lt;/a>。&lt;/p>
&lt;p>這與命名慣例是兩件事。取什麼名字是設計問題，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/schema-design/#naming-%e8%88%87%e4%b8%80%e8%87%b4%e6%80%a7" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">Schema Design 的「Naming 與一致性」段&lt;/a> 給表、欄、外鍵、布林、時間戳、索引各自的慣例，以及縮寫不一致、隱性意義這幾種反模式；這裡談的是取好的名字送進引擎會發生什麼。改寫的後果在單一引擎上看不見，換一個引擎才浮現：一段建表語句在原本那個引擎上建好了表，同樣的名字搬到另一個引擎去查卻找不到——名字在送進引擎的路上會先被改寫一次，而兩個引擎改寫的規則不一樣。&lt;/p>
&lt;h2 id="postgresql-的大小寫摺疊與引號">PostgreSQL 的大小寫摺疊與引號&lt;/h2>
&lt;p>在 PostgreSQL 上建一張大小寫混合的表，名字加了引號：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Orders&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;OrderId&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&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">Orders&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">-- ERROR: relation &amp;#34;orders&amp;#34; does not exist&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>錯誤訊息把發生的事直接印出來了：查詢裡寫的是 &lt;code>Orders&lt;/code>，而引擎去找的是 &lt;code>orders&lt;/code>。沒加引號的識別字一律被摺成小寫，之後才拿去比對。&lt;/p>
&lt;p>反過來也成立。建表時不加引號，存進去的就是摺過的版本：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">MixedCase&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 實際存進系統目錄的表名是 mixedcase&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">-- PostgreSQL，兩組各在一個空的資料庫上跑
&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">MixedCase&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 存成 mixedcase
&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">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&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">mixedcase&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">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">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;MixedCase&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 加了引號，存成 MixedCase
&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="o">*&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">MixedCase&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 摺成 mixedcase 去找
&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">-- ERROR: relation &amp;#34;mixedcase&amp;#34; does not exist&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加引號的效果是關掉「沒加引號就摺成小寫」這道摺疊，要求逐字比對。&lt;/p>
&lt;p>這道摺疊只作用在名字上。同一家引擎把 &lt;code>Orders&lt;/code> 摺成 &lt;code>orders&lt;/code> 之後，拿 &lt;code>'Anna'&lt;/code> 去比 &lt;code>'anna'&lt;/code> 時用的是另一套規則，叫 collation，而 PostgreSQL 預設的 collation 判這兩個值不相等——值的大小寫與名字的大小寫由兩套彼此獨立的規則管。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/string-comparison-and-collation/" data-link-title="SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性" data-link-desc="同一段等值條件在各家回不同的列的原因、LIKE 與等號何時分岔、索引與條件的比較規則怎麼對上，以及規則該寫在哪一層">SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性&lt;/a> 寫 collation 這套規則住在哪一層，以及索引與條件的規則為什麼要對得上。&lt;/p>
&lt;h2 id="sqlite-與-duckdb-對識別字大小寫的處理">SQLite 與 DuckDB 對識別字大小寫的處理&lt;/h2>
&lt;p>同一張 &lt;code>&amp;quot;Orders&amp;quot;&lt;/code> 表搬到 SQLite 與 DuckDB，四種查法全部找得到那張表：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Orders&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;OrderId&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&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">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&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">orders&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">4&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="o">*&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">Orders&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">5&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="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Orders&amp;#34;&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">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="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;orders&amp;#34;&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">7&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite、DuckDB：四句都找得到
&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">-- PostgreSQL：只有加引號、大小寫一致的那一句找得到，其餘三句都回 relation &amp;#34;orders&amp;#34; does not exist&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這兩個引擎對識別字的大小寫不敏感，連引號都不會讓它變敏感。&lt;/p>
&lt;p>這造成一個很難發現的可攜性問題：在 SQLite 上開發、程式碼裡大小寫混著寫，一路都正常；搬到 PostgreSQL 之後，凡是建表時加了引號而查詢時沒加的地方全部找不到表。錯誤出現的時機離寫下它的時機很遠。&lt;/p>
&lt;h2 id="保留字與各家的引號寫法">保留字與各家的引號寫法&lt;/h2>
&lt;p>&lt;code>select&lt;/code>、&lt;code>order&lt;/code>、&lt;code>group&lt;/code> 這些是語法的一部分，直接拿來當表名會在剖析階段（引擎把文字切成語法結構的那一步）就失敗：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">select&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- SQLite: near &amp;#34;select&amp;#34;: syntax error
&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">-- DuckDB: Parser Error: syntax error at or near &amp;#34;select&amp;#34;
&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">-- PostgreSQL: syntax error at or near &amp;#34;select&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加上引號之後三家都接受，因為引號告訴剖析器這是一個名字而不是關鍵字。「要加引號」這件事各家一致，引號的寫法則不一致：&lt;/p></description><content:encoded><![CDATA[<p>表名與欄名（統稱識別字）寫進查詢之後，引擎會先按自己的規則處理一次，才拿去對照真正存在的名字。這道改寫各家不同，所以同一段 SQL 在不同引擎上指向的可能是不同的名字——或者什麼都指不到。</p>
<p>查詢的文字之外，還有幾方決定一段 SQL 最後做什麼：識別字規則、權限、最佳化器、約束、collation，以及引擎寬鬆度——標準禁止的寫法這一家引擎收不收（寬鬆度那一方在 <a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a>）。識別字規則是離查詢文字最近的那一方：名字還沒指到任何一張表之前，引擎就已經按這套規則把它改寫過一次。同一張表被誰允許讀寫是另一方的事，在 <a href="/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限</a>；最佳化器那一方，執行順序怎麼從書寫順序分出來在 <a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價</a>。</p>
<p>這與命名慣例是兩件事。取什麼名字是設計問題，<a href="/blog/backend/01-database/schema-design/#naming-%e8%88%87%e4%b8%80%e8%87%b4%e6%80%a7" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">Schema Design 的「Naming 與一致性」段</a> 給表、欄、外鍵、布林、時間戳、索引各自的慣例，以及縮寫不一致、隱性意義這幾種反模式；這裡談的是取好的名字送進引擎會發生什麼。改寫的後果在單一引擎上看不見，換一個引擎才浮現：一段建表語句在原本那個引擎上建好了表，同樣的名字搬到另一個引擎去查卻找不到——名字在送進引擎的路上會先被改寫一次，而兩個引擎改寫的規則不一樣。</p>
<h2 id="postgresql-的大小寫摺疊與引號">PostgreSQL 的大小寫摺疊與引號</h2>
<p>在 PostgreSQL 上建一張大小寫混合的表，名字加了引號：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="s2">&#34;Orders&#34;</span><span class="w"> </span><span class="p">(</span><span class="s2">&#34;OrderId&#34;</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Orders</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">-- ERROR: relation &#34;orders&#34; does not exist</span></span></span></code></pre></div><p>錯誤訊息把發生的事直接印出來了：查詢裡寫的是 <code>Orders</code>，而引擎去找的是 <code>orders</code>。沒加引號的識別字一律被摺成小寫，之後才拿去比對。</p>
<p>反過來也成立。建表時不加引號，存進去的就是摺過的版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">MixedCase</span><span class="w"> </span><span class="p">(</span><span class="n">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 實際存進系統目錄的表名是 mixedcase</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="c1">-- PostgreSQL，兩組各在一個空的資料庫上跑
</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">MixedCase</span><span class="w"> </span><span class="p">(</span><span class="n">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">     </span><span class="c1">-- 存成 mixedcase
</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="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">mixedcase</span><span class="p">;</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="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">TABLE</span><span class="w"> </span><span class="s2">&#34;MixedCase&#34;</span><span class="w"> </span><span class="p">(</span><span class="n">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 加了引號，存成 MixedCase
</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="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">MixedCase</span><span class="p">;</span><span class="w">            </span><span class="c1">-- 摺成 mixedcase 去找
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">-- ERROR: relation &#34;mixedcase&#34; does not exist</span></span></span></code></pre></div><p>加引號的效果是關掉「沒加引號就摺成小寫」這道摺疊，要求逐字比對。</p>
<p>這道摺疊只作用在名字上。同一家引擎把 <code>Orders</code> 摺成 <code>orders</code> 之後，拿 <code>'Anna'</code> 去比 <code>'anna'</code> 時用的是另一套規則，叫 collation，而 PostgreSQL 預設的 collation 判這兩個值不相等——值的大小寫與名字的大小寫由兩套彼此獨立的規則管。<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> 寫 collation 這套規則住在哪一層，以及索引與條件的規則為什麼要對得上。</p>
<h2 id="sqlite-與-duckdb-對識別字大小寫的處理">SQLite 與 DuckDB 對識別字大小寫的處理</h2>
<p>同一張 <code>&quot;Orders&quot;</code> 表搬到 SQLite 與 DuckDB，四種查法全部找得到那張表：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="s2">&#34;Orders&#34;</span><span class="w"> </span><span class="p">(</span><span class="s2">&#34;OrderId&#34;</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">
</span></span></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="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="p">;</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Orders</span><span class="p">;</span><span class="w">     </span><span class="c1">-- 不加引號、照原樣
</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="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="s2">&#34;Orders&#34;</span><span class="p">;</span><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="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="s2">&#34;orders&#34;</span><span class="p">;</span><span class="w">   </span><span class="c1">-- 加引號、大小寫不一致
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">-- SQLite、DuckDB：四句都找得到
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- PostgreSQL：只有加引號、大小寫一致的那一句找得到，其餘三句都回 relation &#34;orders&#34; does not exist</span></span></span></code></pre></div><p>這兩個引擎對識別字的大小寫不敏感，連引號都不會讓它變敏感。</p>
<p>這造成一個很難發現的可攜性問題：在 SQLite 上開發、程式碼裡大小寫混著寫，一路都正常；搬到 PostgreSQL 之後，凡是建表時加了引號而查詢時沒加的地方全部找不到表。錯誤出現的時機離寫下它的時機很遠。</p>
<h2 id="保留字與各家的引號寫法">保留字與各家的引號寫法</h2>
<p><code>select</code>、<code>order</code>、<code>group</code> 這些是語法的一部分，直接拿來當表名會在剖析階段（引擎把文字切成語法結構的那一步）就失敗：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">select</span><span class="w"> </span><span class="p">(</span><span class="n">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite:    near &#34;select&#34;: syntax error
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- DuckDB:    Parser Error: syntax error at or near &#34;select&#34;
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- PostgreSQL: syntax error at or near &#34;select&#34;</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">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="s2">&#34;select&#34;</span><span class="w"> </span><span class="p">(</span><span class="n">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite、DuckDB、PostgreSQL：接受
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- MySQL：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="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="o">`</span><span class="k">select</span><span class="o">`</span><span class="w"> </span><span class="p">(</span><span class="n">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">   </span><span class="c1">-- MySQL 的識別字要用反引號
</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">SET</span><span class="w"> </span><span class="k">SESSION</span><span class="w"> </span><span class="n">sql_mode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">CONCAT</span><span class="p">(</span><span class="o">@@</span><span class="n">sql_mode</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;,ANSI_QUOTES&#39;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="s2">&#34;order&#34;</span><span class="w"> </span><span class="p">(</span><span class="n">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">    </span><span class="c1">-- 開了 ANSI_QUOTES 之後，MySQL 的雙引號才代表識別字</span></span></span></code></pre></div><p>同一份 DDL 要跑在 MySQL 與另外幾家上，引號要逐家寫，或者乾脆不用保留字當名字。</p>
<p>保留字清單各家不完全相同，而且新版本會往清單裡加新的字——今天合法的名字在下個大版本可能變成保留字。這是「所有識別字一律加引號」這個慣例的主要理由。採用這個慣例的代價是每個名字都變長，而且從此大小寫必須逐字一致。</p>
<h2 id="中文識別字在各家引擎上的處理">中文識別字在各家引擎上的處理</h2>
<p><code>CREATE TABLE 顧客 (姓名 TEXT)</code> 與 <code>SELECT 姓名 FROM 顧客</code> 在 SQLite、DuckDB 與 PostgreSQL 上都不用加引號就能跑。摺疊規則對中文沒有作用，因為那些字沒有大小寫。</p>
<h2 id="引號的做法全部不加全部加與混用">引號的做法：全部不加、全部加與混用</h2>
<p><strong>全部不加引號、名字一律用小寫加底線。</strong> 這是 PostgreSQL 生態的主流，摺疊不會改變任何東西，換引擎也不受影響。代價是撞到保留字時仍然要加引號。</p>
<p><strong>全部加引號。</strong> 名字逐字保存，保留字不成問題。代價是每個名字都要引號、大小寫從此一字不能錯，而且在不區分大小寫的引擎上，大小寫要逐字一致這個約束不會被驗證出來。</p>
<p>中間地帶——有些地方加、有些地方不加、名字又是大小寫混合的——在單一引擎上可能一直正常，換引擎時整批失效。<strong>選哪一種都行，混用不行。</strong></p>
]]></content:encoded></item><item><title>SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/string-comparison-and-collation/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/string-comparison-and-collation/</guid><description>&lt;p>兩個字串相不相等，答案由一條叫 &lt;strong>collation&lt;/strong> 的規則給出——前提是兩邊都是字串型別。型別先發言：PostgreSQL 18 上 &lt;code>'a '::char(2) = 'a'&lt;/code> 為真而 &lt;code>'a '::text = 'a'::text&lt;/code> 為假，同一條 collation，差別在 &lt;code>char(n)&lt;/code> 的等號自己會剝掉尾隨空白。下文談的都是兩邊都是變長字串的情形。collation 是掛在資料庫、表或欄位上的一份比較規則，它規定兩個字串怎麼判相等、以及誰排在前面——大小寫算不算差異、重音符號算不算差異、字元的先後怎麼定。這條規則不在查詢的文字裡，所以同一段 &lt;code>WHERE 姓名 = 'anna'&lt;/code> 在不同的地方會回不同的列。&lt;/p>
&lt;p>大小寫這件事在 SQL 裡落在兩個彼此獨立的層。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字&lt;/a> 處理的是&lt;strong>名字&lt;/strong>送進引擎會被怎麼摺疊，這一篇處理的是&lt;strong>值&lt;/strong>被拿去比較時套的是哪一把尺。兩層的規則互不影響：PostgreSQL 把沒加引號的表名摺成小寫，卻不會把 &lt;code>'Anna'&lt;/code> 這個值摺成小寫。&lt;/p>
&lt;p>本篇把同一條規則作用的四個表面走完：什麼算相等、&lt;code>LIKE&lt;/code> 為什麼會與等號分岔、誰排在前面、以及索引走不走得了。四者收在同一個處置上——規則要寫出來，而寫在資料庫、表或欄位哪一層決定它涵蓋到哪裡。&lt;/p>
&lt;p>本篇的顧客表有五列：&lt;code>Anna&lt;/code>、&lt;code>anna&lt;/code>、&lt;code>ANNA&lt;/code>、&lt;code>Ánna&lt;/code>、&lt;code>佳穎&lt;/code>。&lt;/p>
&lt;h2 id="等值比較在各家引擎的預設-collation-下命中的列">等值比較在各家引擎的預設 collation 下命中的列&lt;/h2>
&lt;p>每一家的預設規則對大小寫與重音的規定不同，所以同一個等值條件在三家上命中的列不是同一批：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">WHERE 姓名 = &amp;#39;anna&amp;#39;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">SQLite 3.51 anna
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">PostgreSQL 18 anna
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">MySQL 8.4 Anna, anna, ANNA, Ánna&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>MySQL 的預設 collation 是 &lt;code>utf8mb4_0900_ai_ci&lt;/code>，名字裡的 &lt;code>ai&lt;/code> 與 &lt;code>ci&lt;/code> 就是它的規定：accent-insensitive 與 case-insensitive——重音與大小寫都不算差異，所以 &lt;code>Ánna&lt;/code> 與 &lt;code>anna&lt;/code> 在這條規則底下是同一個值。SQLite 與 PostgreSQL 的預設把這兩者都算差異，於是只有逐字相同的那一列命中。&lt;/p>
&lt;p>&lt;strong>這三個答案都正確&lt;/strong>，因為「相等」的定義本來就由那條規則給，而查詢的文字沒有選過它。一段在 MySQL 上寫好的登入查詢搬到 PostgreSQL，比對帳號的那一段會突然變成大小寫敏感，而它照樣執行、照樣回一批形狀正確的列。&lt;/p>
&lt;h2 id="sqlite-的-like-與等號各自套用的比較規則">SQLite 的 LIKE 與等號各自套用的比較規則&lt;/h2>
&lt;p>&lt;code>LIKE&lt;/code> 是另一個做字串比較的運算子，而它套的規則未必與 &lt;code>=&lt;/code> 相同：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl"> SQLite 3.51 PostgreSQL 18 MySQL 8.4
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">= &amp;#39;anna&amp;#39; anna anna Anna, anna, ANNA, Ánna
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">LIKE &amp;#39;anna&amp;#39; Anna, anna, ANNA anna Anna, anna, ANNA, Ánna&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>PostgreSQL 與 MySQL 的兩列一致，SQLite 的兩列分岔：它的 &lt;code>=&lt;/code> 逐位元組比較，而它的 &lt;code>LIKE&lt;/code> 預設不分大小寫。所以在 SQLite 上把 &lt;code>=&lt;/code> 換成 &lt;code>LIKE&lt;/code>（例如為了加一個 &lt;code>%&lt;/code>）會順帶換掉比較規則，而這件事沒有任何提示。&lt;/p>
&lt;p>SQLite 這個不分大小寫的範圍限定在 ASCII 字母：&lt;code>LIKE 'ánna'&lt;/code> 在同一批資料上回零列，&lt;code>Ánna&lt;/code> 沒有被當成同一個字串。所以那條規則的完整敘述是「A 到 Z 這二十六個字母摺疊，其餘逐位元組」，而不是「不分大小寫」。&lt;/p>
&lt;p>要把它調成一致，&lt;code>PRAGMA case_sensitive_like = ON&lt;/code> 讓 &lt;code>LIKE&lt;/code> 跟著 &lt;code>=&lt;/code> 走；反向的做法是在條件上寫 &lt;code>COLLATE NOCASE&lt;/code>，讓 &lt;code>=&lt;/code> 跟著 &lt;code>LIKE&lt;/code> 走。&lt;strong>選哪一邊都行，而選了要寫出來&lt;/strong>——沒寫出來的時候讀查詢的人得先知道它跑在哪一家引擎上，才知道這一句在比什麼。&lt;/p>
&lt;h2 id="collation-規定的排序先後">collation 規定的排序先後&lt;/h2>
&lt;p>collation 除了規定兩個字串相不相等，也規定誰排在前面。同一批五個名字，三家排出三種順序：&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">ORDER BY 姓名
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">SQLite 3.51 ANNA, Anna, anna, Ánna, 佳穎
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">PostgreSQL 18 anna, Anna, ANNA, Ánna, 佳穎
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">MySQL 8.4 Anna, anna, ANNA, Ánna, 佳穎&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SQLite 那一列是位元組的先後——大寫字母的碼位小於小寫，所以 &lt;code>ANNA&lt;/code> 整批排在前面。PostgreSQL 這一列取決於資料庫建立時選的 collation，量測用的這個是 &lt;code>en_US.utf8&lt;/code>（查法是 &lt;code>SELECT datcollate FROM pg_database&lt;/code>），它照人類語言的習慣把大小寫視為同一個字母的變體、再用大小寫決定同分時的先後。同一段排序在 PostgreSQL 上改用 &lt;code>C&lt;/code> 這條逐位元組比較的規則，順序就與 SQLite 一致：&lt;/p></description><content:encoded><![CDATA[<p>兩個字串相不相等，答案由一條叫 <strong>collation</strong> 的規則給出——前提是兩邊都是字串型別。型別先發言：PostgreSQL 18 上 <code>'a '::char(2) = 'a'</code> 為真而 <code>'a '::text = 'a'::text</code> 為假，同一條 collation，差別在 <code>char(n)</code> 的等號自己會剝掉尾隨空白。下文談的都是兩邊都是變長字串的情形。collation 是掛在資料庫、表或欄位上的一份比較規則，它規定兩個字串怎麼判相等、以及誰排在前面——大小寫算不算差異、重音符號算不算差異、字元的先後怎麼定。這條規則不在查詢的文字裡，所以同一段 <code>WHERE 姓名 = 'anna'</code> 在不同的地方會回不同的列。</p>
<p>大小寫這件事在 SQL 裡落在兩個彼此獨立的層。<a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a> 處理的是<strong>名字</strong>送進引擎會被怎麼摺疊，這一篇處理的是<strong>值</strong>被拿去比較時套的是哪一把尺。兩層的規則互不影響：PostgreSQL 把沒加引號的表名摺成小寫，卻不會把 <code>'Anna'</code> 這個值摺成小寫。</p>
<p>本篇把同一條規則作用的四個表面走完：什麼算相等、<code>LIKE</code> 為什麼會與等號分岔、誰排在前面、以及索引走不走得了。四者收在同一個處置上——規則要寫出來，而寫在資料庫、表或欄位哪一層決定它涵蓋到哪裡。</p>
<p>本篇的顧客表有五列：<code>Anna</code>、<code>anna</code>、<code>ANNA</code>、<code>Ánna</code>、<code>佳穎</code>。</p>
<h2 id="等值比較在各家引擎的預設-collation-下命中的列">等值比較在各家引擎的預設 collation 下命中的列</h2>
<p>每一家的預設規則對大小寫與重音的規定不同，所以同一個等值條件在三家上命中的列不是同一批：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">WHERE 姓名 = &#39;anna&#39;
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">SQLite 3.51        anna
</span></span><span class="line"><span class="ln">4</span><span class="cl">PostgreSQL 18      anna
</span></span><span class="line"><span class="ln">5</span><span class="cl">MySQL 8.4          Anna, anna, ANNA, Ánna</span></span></code></pre></div><p>MySQL 的預設 collation 是 <code>utf8mb4_0900_ai_ci</code>，名字裡的 <code>ai</code> 與 <code>ci</code> 就是它的規定：accent-insensitive 與 case-insensitive——重音與大小寫都不算差異，所以 <code>Ánna</code> 與 <code>anna</code> 在這條規則底下是同一個值。SQLite 與 PostgreSQL 的預設把這兩者都算差異，於是只有逐字相同的那一列命中。</p>
<p><strong>這三個答案都正確</strong>，因為「相等」的定義本來就由那條規則給，而查詢的文字沒有選過它。一段在 MySQL 上寫好的登入查詢搬到 PostgreSQL，比對帳號的那一段會突然變成大小寫敏感，而它照樣執行、照樣回一批形狀正確的列。</p>
<h2 id="sqlite-的-like-與等號各自套用的比較規則">SQLite 的 LIKE 與等號各自套用的比較規則</h2>
<p><code>LIKE</code> 是另一個做字串比較的運算子，而它套的規則未必與 <code>=</code> 相同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">                    SQLite 3.51        PostgreSQL 18      MySQL 8.4
</span></span><span class="line"><span class="ln">2</span><span class="cl">= &#39;anna&#39;            anna               anna               Anna, anna, ANNA, Ánna
</span></span><span class="line"><span class="ln">3</span><span class="cl">LIKE &#39;anna&#39;         Anna, anna, ANNA   anna               Anna, anna, ANNA, Ánna</span></span></code></pre></div><p>PostgreSQL 與 MySQL 的兩列一致，SQLite 的兩列分岔：它的 <code>=</code> 逐位元組比較，而它的 <code>LIKE</code> 預設不分大小寫。所以在 SQLite 上把 <code>=</code> 換成 <code>LIKE</code>（例如為了加一個 <code>%</code>）會順帶換掉比較規則，而這件事沒有任何提示。</p>
<p>SQLite 這個不分大小寫的範圍限定在 ASCII 字母：<code>LIKE 'ánna'</code> 在同一批資料上回零列，<code>Ánna</code> 沒有被當成同一個字串。所以那條規則的完整敘述是「A 到 Z 這二十六個字母摺疊，其餘逐位元組」，而不是「不分大小寫」。</p>
<p>要把它調成一致，<code>PRAGMA case_sensitive_like = ON</code> 讓 <code>LIKE</code> 跟著 <code>=</code> 走；反向的做法是在條件上寫 <code>COLLATE NOCASE</code>，讓 <code>=</code> 跟著 <code>LIKE</code> 走。<strong>選哪一邊都行，而選了要寫出來</strong>——沒寫出來的時候讀查詢的人得先知道它跑在哪一家引擎上，才知道這一句在比什麼。</p>
<h2 id="collation-規定的排序先後">collation 規定的排序先後</h2>
<p>collation 除了規定兩個字串相不相等，也規定誰排在前面。同一批五個名字，三家排出三種順序：</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">ORDER BY 姓名
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">SQLite 3.51        ANNA, Anna, anna, Ánna, 佳穎
</span></span><span class="line"><span class="ln">4</span><span class="cl">PostgreSQL 18      anna, Anna, ANNA, Ánna, 佳穎
</span></span><span class="line"><span class="ln">5</span><span class="cl">MySQL 8.4          Anna, anna, ANNA, Ánna, 佳穎</span></span></code></pre></div><p>SQLite 那一列是位元組的先後——大寫字母的碼位小於小寫，所以 <code>ANNA</code> 整批排在前面。PostgreSQL 這一列取決於資料庫建立時選的 collation，量測用的這個是 <code>en_US.utf8</code>（查法是 <code>SELECT datcollate FROM pg_database</code>），它照人類語言的習慣把大小寫視為同一個字母的變體、再用大小寫決定同分時的先後。同一段排序在 PostgreSQL 上改用 <code>C</code> 這條逐位元組比較的規則，順序就與 SQLite 一致：</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">-- PostgreSQL 18，資料庫的 collation 是 en_US.utf8
</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">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="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- anna, Anna, ANNA, Ánna, 佳穎
</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="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">COLLATE</span><span class="w"> </span><span class="s2">&#34;C&#34;</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">-- ANNA, Anna, anna, Ánna, 佳穎（與 SQLite 的預設順序相同）</span></span></span></code></pre></div><p><strong>同一家引擎、同一批資料，換一條規則就換一種順序</strong>，而 SQLite 的預設順序就是位元組序這件事也因此驗得出來。</p>
<p>MySQL 那一列要換個方式讀。在 <code>ai_ci</code> 底下四個 A 開頭的名字<strong>彼此相等</strong>，所以它們之間沒有先後可言——那一列印的是它們進表的順序。<strong>同一個排序鍵改由 <code>GROUP_CONCAT</code> 取回，四個名字的順序整個倒過來</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="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">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="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">-- Anna, anna, ANNA, Ánna, 佳穎（進表的順序）
</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="n">GROUP_CONCAT</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="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">5</span><span class="cl"><span class="w"></span><span class="c1">-- Ánna,ANNA,anna,Anna,佳穎（四個 A 開頭的名字倒過來，佳穎仍在最後）</span></span></span></code></pre></div><p>兩次都沒有違反 collation，因為 collation 對這四個之間什麼都沒有規定。這是 collation 與分頁交會的地方：一條把大小寫與重音都忽略的規則，會讓原本以為唯一的排序鍵變成不唯一，而沒有規定的那一段在分頁時會變成同一列出現兩次、另一列一次都不出現——<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>
<h2 id="索引的比較規則與條件的比較規則">索引的比較規則與條件的比較規則</h2>
<p>索引把值按某一條規則排好，所以它只服務照同一條規則發問的條件。索引與條件的規則對不上，在小表上感覺不出來——資料長到某個量之後前綴搜尋開始變慢，而查詢與索引都沒有動過。這一節的計畫也要在夠大的表上才看得出來——五列的表無論條件寫成什麼，引擎都直接掃完，所以下面兩組輸出量在二十萬列的同結構表上。SQLite 上的三種問法，索引是同一個：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">CREATE INDEX ixn ON 顧客(姓名);            -- 預設規則，逐位元組
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">WHERE 姓名 = &#39;anna&#39;          SEARCH ... (姓名=?)      查找
</span></span><span class="line"><span class="ln">4</span><span class="cl">WHERE lower(姓名) = &#39;anna&#39;   SCAN                     整段掃過
</span></span><span class="line"><span class="ln">5</span><span class="cl">WHERE 姓名 LIKE &#39;ann%&#39;       SCAN                     整段掃過</span></span></code></pre></div><p>第二行是欄位被函式包住，索引上排好的是原值而條件問的是摺過的值，兩者對不起來（<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable</a>）。<strong>第三行的成因不同</strong>：前綴比對本來可以翻成索引上的一段範圍，而 <code>LIKE</code> 預設不分大小寫，索引卻是逐位元組排的——規則對不上，範圍就算不出來。條件的規則與索引的規則，換掉任何一個都讓計畫回到範圍查找，而兩種換法回的列不同——改條件的規則也改掉了 <code>LIKE</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">-- 改條件的規則：LIKE 改成分大小寫，回到位元組規則
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">case_sensitive_like</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">ON</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">EXPLAIN</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="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">LIKE</span><span class="w"> </span><span class="s1">&#39;ann%&#39;</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">-- SEARCH ... (姓名&gt;? AND 姓名&lt;?)
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">-- 本篇五列的顧客表上回 anna
</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="c1">-- 改索引的規則：LIKE 維持預設的不分大小寫，另建一個按摺疊規則排的索引；SQLite 的索引一定要取名字
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">case_sensitive_like</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">OFF</span><span class="p">;</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">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">ixn_nocase</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="k">COLLATE</span><span class="w"> </span><span class="n">NOCASE</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="k">EXPLAIN</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="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">LIKE</span><span class="w"> </span><span class="s1">&#39;ann%&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- SEARCH ... (姓名&gt;? AND 姓名&lt;?)
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">-- 本篇五列的顧客表上回 Anna, anna, ANNA</span></span></span></code></pre></div><p>PostgreSQL 上，索引與條件套的規則對不上，同樣會讓前綴搜尋用不到索引，只是外觀不同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">WHERE 姓名 = &#39;anna&#39;          Index Cond: (姓名 = &#39;anna&#39;)          查找
</span></span><span class="line"><span class="ln">2</span><span class="cl">WHERE lower(姓名) = &#39;anna&#39;   Filter: (lower(姓名) = &#39;anna&#39;)       逐列過濾
</span></span><span class="line"><span class="ln">3</span><span class="cl">WHERE 姓名 LIKE &#39;ann%&#39;       Filter: (姓名 ~~ &#39;ann%&#39;)             逐列過濾</span></span></code></pre></div><p><code>en_US.utf8</code> 的排序規則與位元組序不同，所以 <code>ann</code> 這個前綴在索引上不對應一段連續的區間。PostgreSQL 的解法是另建一個按位元組排的索引；<code>lower(姓名)</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">-- 前綴搜尋：text_pattern_ops 讓這個索引逐字元比較，不照資料庫的 en_US.utf8
</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">INDEX</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="n">text_pattern_ops</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">EXPLAIN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="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">LIKE</span><span class="w"> </span><span class="s1">&#39;ann%&#39;</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">-- Index Cond: (姓名 ~&gt;=~ &#39;ann&#39; AND 姓名 ~&lt;~ &#39;ano&#39;)：前綴翻成一段範圍
</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">-- 欄位被函式包住：索引直接建在 lower(姓名) 上
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">(</span><span class="k">lower</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="k">EXPLAIN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">lower</span><span class="p">(</span><span class="err">姓名</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;anna&#39;</span><span class="p">;</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">-- Index Cond: (lower(姓名) = &#39;anna&#39;)</span></span></span></code></pre></div><p>三種修法的共同形狀是<strong>讓索引與條件套同一條規則</strong>，而它們把改動放在不同的一邊：改條件、改索引的 collation、或另建一個按別的規則排的索引。比較規則對得上只是索引派得上用場的條件之一：條件的形狀是另一個（<code>lower(姓名)</code> 那一行就是形狀出的問題），而形狀與規則都對了之後，引擎還要看這個條件留下多少列才決定值不值得走索引，留下多少列怎麼估計在 <a href="/blog/backend/01-database/sql/knowledge-cards/cardinality-and-selectivity/" data-link-title="Cardinality 與 Selectivity（基數與選擇率）" data-link-desc="看到「這一欄基數太低、索引沒用」，或想知道最佳化器憑什麼決定要不要走索引時，查這兩個數各自量什麼">Cardinality 與 Selectivity</a>。</p>
<h2 id="collation-宣告在資料庫表欄位或條件上各自的涵蓋範圍">collation 宣告在資料庫、表、欄位或條件上各自的涵蓋範圍</h2>
<p>預設值在單一環境底下是隱形的——查詢跑得對，沒有人會去問「相等」現在是怎麼定義的。它在三個時刻現形：換一家引擎、換一個資料庫（同一家引擎的不同資料庫可以有不同的 collation）、以及有人在一欄上單獨指定了規則而其餘欄位沒有。要同時支援好幾家引擎的時候，collation 只是要逐項盤點的預設差異之一，識別字的摺疊是另一項，換引擎才浮現的形態在 <a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a> 的「SQLite 與 DuckDB 對識別字大小寫的處理」一節；各家預設差異的完整並置與分級在 <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>
<p>所以規則的住址要選定，而可選的住址比多數人用到的多。開頭那一句說 collation 掛在資料庫、表或欄位上——那是三層，<strong>越外層涵蓋越廣而越容易被裡層蓋掉</strong>：建庫時選的那一條是所有沒有另外指定的欄位的預設，建表時選的蓋過它，欄位上寫的又蓋過表。來歷不明的建表模板最常把規則留在表層或庫層，所以查一欄的實際規則要從欄位往外找，找到第一個明寫的為止。</p>
<p><strong>寫在欄位上</strong>是最貼近資料的一種：<code>姓名 VARCHAR(50) COLLATE utf8mb4_0900_as_cs</code>（<code>as_cs</code> 是 accent-sensitive、case-sensitive）把這一欄固定住，往後每一段查詢、每一個索引都套它。<strong>寫在條件上</strong>（<code>WHERE 姓名 = 'anna' COLLATE NOCASE</code>）只涵蓋那一句。<strong>還有一個住址不在 collation 這條路上</strong>：寫入的時候就把值正規化，另存一欄摺過的版本，讀的時候比那一欄。開頭提過的登入查詢——帳號比對在 MySQL 上不分大小寫、搬到 PostgreSQL 突然變成分大小寫——實際的解法多半是這一種：比較規則變成一次寫入時的決定，而讀的那一句與它的索引都回到最單純的等值比較。同一個登入功能裡兩種住址各有位置：帳號欄整欄要不分大小寫，那是欄位的事；而某支後台工具要逐字比對，那是那一句的事。代價是寫在條件上的那一句走不了按預設規則建的索引。</p>
<p>判斷標準與<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>那一條同源：一個保證要涵蓋往後的每一次使用，它就得寫在結構上，而不是靠每一句查詢各自記得。差別在於約束擋的是寫入，collation 決定的是讀出來的時候什麼算相等。</p>
]]></content:encoded></item><item><title>SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/privilege-model/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/privilege-model/</guid><description>&lt;p>&lt;code>GRANT&lt;/code> 與 &lt;code>REVOKE&lt;/code> 是 SQL 的一部分，與 &lt;code>SELECT&lt;/code> 一樣由標準定義。它們管的是誰被允許對哪個物件做哪件事，而預設是什麼都不允許——權限是一項一項加上去的，不是先給再收回來。這個預設之下，一個帳號能做什麼等於它被授予過什麼，兩者之間沒有隱含的推導。&lt;/p>
&lt;p>以下的輸出來自 PostgreSQL 18。SQLite 沒有使用者的概念，整個檔案的存取由檔案系統決定，所以本篇談的授權這一層在 SQLite 上不存在。&lt;/p>
&lt;h2 id="新建角色的預設權限">新建角色的預設權限&lt;/h2>
&lt;p>書店的資料裡有三位顧客與兩張訂單，現在要開一個只跑報表的帳號。PostgreSQL 用「角色」這一個概念同時涵蓋使用者與群組——帶 &lt;code>LOGIN&lt;/code> 屬性的角色可以連線，不帶的當群組用，兩者授權的方式相同。先建一個能登入的角色，什麼權限都還沒給：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ROLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">報表&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LOGIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PASSWORD&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>&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">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ROLE&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">3&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="o">*&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="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">-- ERROR: permission denied for table 顧客&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個角色存在、登得進來、而它讀不到任何一張表。&lt;strong>能連線與能做事是兩件分開的事&lt;/strong>：能連線由角色的 &lt;code>LOGIN&lt;/code> 屬性決定，能做事由授權決定。&lt;/p>
&lt;p>給了讀取權之後才讀得到：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">RESET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ROLE&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 切回表的擁有者；以報表的身分執行這條 GRANT 會回 permission denied
&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">GRANT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&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="w"> &lt;/span>&lt;span class="k">TO&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">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ROLE&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">4&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="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;/code>&lt;/pre>&lt;/div>&lt;h2 id="授權的單位角色表與動作">授權的單位：角色、表與動作&lt;/h2>
&lt;p>上面只給了 &lt;code>SELECT&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">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="s1">&amp;#39;柏宇&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- permission denied for table 顧客
&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">DELETE&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="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- permission denied for table 顧客&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個都被擋下來，錯誤訊息與完全沒授權時一模一樣。權限的單位是「哪個角色、對哪張表、做哪一種動作」，&lt;code>SELECT&lt;/code>、&lt;code>INSERT&lt;/code>、&lt;code>UPDATE&lt;/code>、&lt;code>DELETE&lt;/code> 各自獨立，給一項不會順帶給另一項。「哪張表」由識別字指認，而那個名字送進引擎之前會先被改寫一次——&lt;code>GRANT SELECT ON Orders&lt;/code> 在 PostgreSQL 上授權的對象是 &lt;code>orders&lt;/code>，這個改寫的規則在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字&lt;/a>。&lt;/p>
&lt;p>目前有哪些授權查得到，收回之後那個角色又回到 &lt;code>permission denied&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">RESET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ROLE&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"> 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="n">grantee&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">privilege_type&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">information_schema&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">role_table_grants&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">table_name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;顧客&amp;#39;&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">-- 表的擁有者：INSERT、SELECT、UPDATE、DELETE、TRUNCATE、REFERENCES、TRIGGER 各一列
&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">-- 報表 | SELECT
&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">REVOKE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&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="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="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ROLE&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"> 9&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="o">*&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="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">-- ERROR: permission denied for table 顧客&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="建立物件的權限與-schema">建立物件的權限與 schema&lt;/h2>
&lt;p>&lt;code>GRANT SELECT&lt;/code> 給的是對既有表的存取，與「能不能建新的表」無關。報表角色去建表：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">偷建的&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- ERROR: permission denied for schema public&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/schema-namespace/" data-link-title="Schema（命名空間義）" data-link-desc="看到 permission denied for schema、或想確認這個字指的是表結構還是命名空間時">schema&lt;/a> 而不是 table。PostgreSQL 15 之後 &lt;code>public&lt;/code> schema 預設不再開放給所有人建立物件，在那之前這個操作會成功。&lt;strong>同一段 SQL 在不同版本上的結果不同，而差別在預設值而不在語法。&lt;/strong>&lt;/p>
&lt;h2 id="最小權限應用程式帳號的授權範圍">最小權限：應用程式帳號的授權範圍&lt;/h2>
&lt;p>應用程式連資料庫用的帳號，開的權限涵蓋它實際會執行的動作就夠。一個只跑報表的程式從來不寫入，所以它的帳號有沒有 &lt;code>INSERT&lt;/code> 對它的功能沒有差別；處理訂單的程式不會刪顧客，所以它的帳號有沒有顧客表的 &lt;code>DELETE&lt;/code> 對它的功能也沒有差別。遷移是唯一需要 &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;a href="https://tarrragon.github.io/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同一個資料庫引擎內 schema 演進與資料變更的分段驗證流程：expand-contract、回填、驗證查詢、切流與回滾；換資料庫引擎的遷移不在範圍內">資料庫轉換實作&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p><code>GRANT</code> 與 <code>REVOKE</code> 是 SQL 的一部分，與 <code>SELECT</code> 一樣由標準定義。它們管的是誰被允許對哪個物件做哪件事，而預設是什麼都不允許——權限是一項一項加上去的，不是先給再收回來。這個預設之下，一個帳號能做什麼等於它被授予過什麼，兩者之間沒有隱含的推導。</p>
<p>以下的輸出來自 PostgreSQL 18。SQLite 沒有使用者的概念，整個檔案的存取由檔案系統決定，所以本篇談的授權這一層在 SQLite 上不存在。</p>
<h2 id="新建角色的預設權限">新建角色的預設權限</h2>
<p>書店的資料裡有三位顧客與兩張訂單，現在要開一個只跑報表的帳號。PostgreSQL 用「角色」這一個概念同時涵蓋使用者與群組——帶 <code>LOGIN</code> 屬性的角色可以連線，不帶的當群組用，兩者授權的方式相同。先建一個能登入的角色，什麼權限都還沒給：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">ROLE</span><span class="w"> </span><span class="err">報表</span><span class="w"> </span><span class="n">LOGIN</span><span class="w"> </span><span class="n">PASSWORD</span><span class="w"> </span><span class="s1">&#39;...&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="k">ROLE</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">3</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="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">-- ERROR: permission denied for table 顧客</span></span></span></code></pre></div><p>這個角色存在、登得進來、而它讀不到任何一張表。<strong>能連線與能做事是兩件分開的事</strong>：能連線由角色的 <code>LOGIN</code> 屬性決定，能做事由授權決定。</p>
<p>給了讀取權之後才讀得到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">RESET</span><span class="w"> </span><span class="k">ROLE</span><span class="p">;</span><span class="w">                     </span><span class="c1">-- 切回表的擁有者；以報表的身分執行這條 GRANT 會回 permission denied
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">GRANT</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">TO</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">3</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="k">ROLE</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="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">5</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、宗翰、雅文</span></span></span></code></pre></div><h2 id="授權的單位角色表與動作">授權的單位：角色、表與動作</h2>
<p>上面只給了 <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">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="s1">&#39;柏宇&#39;</span><span class="p">);</span><span class="w">   </span><span class="c1">-- permission denied for table 顧客
</span></span></span><span class="line"><span class="ln">2</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="o">=</span><span class="mi">1</span><span class="p">;</span><span class="w">    </span><span class="c1">-- permission denied for table 顧客</span></span></span></code></pre></div><p>兩個都被擋下來，錯誤訊息與完全沒授權時一模一樣。權限的單位是「哪個角色、對哪張表、做哪一種動作」，<code>SELECT</code>、<code>INSERT</code>、<code>UPDATE</code>、<code>DELETE</code> 各自獨立，給一項不會順帶給另一項。「哪張表」由識別字指認，而那個名字送進引擎之前會先被改寫一次——<code>GRANT SELECT ON Orders</code> 在 PostgreSQL 上授權的對象是 <code>orders</code>，這個改寫的規則在 <a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a>。</p>
<p>目前有哪些授權查得到，收回之後那個角色又回到 <code>permission denied</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">RESET</span><span class="w"> </span><span class="k">ROLE</span><span class="p">;</span><span class="w">                     </span><span class="c1">-- 查詢與收回都由表的擁有者執行
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">grantee</span><span class="p">,</span><span class="w"> </span><span class="n">privilege_type</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">role_table_grants</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">table_name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;顧客&#39;</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">-- 表的擁有者：INSERT、SELECT、UPDATE、DELETE、TRUNCATE、REFERENCES、TRIGGER 各一列
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">-- 報表 | SELECT
</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">REVOKE</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="k">ON</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"> 8</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="k">ROLE</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"> 9</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="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">-- ERROR: permission denied for table 顧客</span></span></span></code></pre></div><h2 id="建立物件的權限與-schema">建立物件的權限與 schema</h2>
<p><code>GRANT 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">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="n">x</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- ERROR: permission denied for schema public</span></span></span></code></pre></div><p>被擋的層次不同——這次擋在 <a href="/blog/backend/01-database/sql/knowledge-cards/schema-namespace/" data-link-title="Schema（命名空間義）" data-link-desc="看到 permission denied for schema、或想確認這個字指的是表結構還是命名空間時">schema</a> 而不是 table。PostgreSQL 15 之後 <code>public</code> schema 預設不再開放給所有人建立物件，在那之前這個操作會成功。<strong>同一段 SQL 在不同版本上的結果不同，而差別在預設值而不在語法。</strong></p>
<h2 id="最小權限應用程式帳號的授權範圍">最小權限：應用程式帳號的授權範圍</h2>
<p>應用程式連資料庫用的帳號，開的權限涵蓋它實際會執行的動作就夠。一個只跑報表的程式從來不寫入，所以它的帳號有沒有 <code>INSERT</code> 對它的功能沒有差別；處理訂單的程式不會刪顧客，所以它的帳號有沒有顧客表的 <code>DELETE</code> 對它的功能也沒有差別。遷移是唯一需要 <a href="/blog/backend/01-database/sql/knowledge-cards/ddl-dml/" data-link-title="DDL 與 DML（定義結構與動資料）" data-link-desc="看到「這個帳號只需要 DML 權限」或遷移文件把兩者分開講時，查這條界線切在哪裡">DDL</a> 的場合，而它不是常態運行的一部分——那些權限跟著它自己的帳號走，平常沒有任何一條應用程式的連線用它登入。遷移本身的流程（雙寫、回填、切流與回滾）在 <a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同一個資料庫引擎內 schema 演進與資料變更的分段驗證流程：expand-contract、回填、驗證查詢、切流與回滾；換資料庫引擎的遷移不在範圍內">資料庫轉換實作</a>。</p>
<p>每一種用途各用一個帳號、只開它用得到的權限，這樣分的效益在事故發生時才顯示出來。應用程式如果用超級使用者連線，一個注入漏洞——攻擊者讓自己寫的片段被當成 SQL 的一部分送出去——的可觸及範圍就是整個資料庫，包含刪表與讀取其他應用的資料；同一個漏洞在只有 <code>SELECT</code> 權限的帳號上，可觸及範圍縮到被授予 <code>SELECT</code> 的那幾張表的讀取。<strong>漏洞一樣，可觸及的範圍差很多，而差別在事發前就決定好了。</strong> 授權只是資料層攻擊面的其中一層，<a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">backend 模組一的攻擊者視角</a> 走注入、授權繞過、資料外洩、競態與資源耗盡五類，前三類各附真實事件對照。這個推論的前提是應用程式的每一種用途各用一個資料庫帳號；所有用途共用一個帳號、由應用層自己做授權的架構下，資料庫這一層的最小權限就使不上力，防線要落在別處。</p>
<p>授權查得到、也審計得到，所以「這個帳號能做什麼」是一個有明確答案的問題，不必靠讀應用程式的程式碼推測。</p>
]]></content:encoded></item><item><title>SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/</guid><description>&lt;p>同一段 SQL 的代價由資料分布與可用的索引決定，不由寫法決定。同一組寫法的快慢排名會隨著索引改變而對調，而分組、自連接與 &lt;code>EXISTS&lt;/code> 三段查詢的文字一個字都沒有動。&lt;/p>
&lt;h2 id="找重複-email-的分組自連接與-exists-寫法">找重複 email 的分組、自連接與 EXISTS 寫法&lt;/h2>
&lt;p>一張 &lt;code>Person&lt;/code> 表，欄位是 &lt;code>id&lt;/code> 與 &lt;code>email&lt;/code>，要找出重複出現的 email。&lt;/p>
&lt;p>&lt;strong>用分組。&lt;/strong> 把列按 email 收成組，問這一組有幾列：&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">-- PostgreSQL 18
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">ERROR: insert or update on table &amp;#34;評價&amp;#34; violates foreign key constraint &amp;#34;評價_訂單編號_fkey&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">DETAIL: Key (訂單編號)=(999) is not present in table &amp;#34;訂單&amp;#34;.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">DELETE FROM 顧客 WHERE 顧客編號 = 1;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">ERROR: update or delete on table &amp;#34;顧客&amp;#34; violates foreign key constraint &amp;#34;訂單_顧客編號_fkey&amp;#34; on table &amp;#34;訂單&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">DETAIL: Key (顧客編號)=(1) is still referenced from table &amp;#34;訂單&amp;#34;.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>上面兩則錯誤訊息裡的約束名不同。第一則是評價表自己那條，第二則是訂單表那條——刪顧客這個動作在訂單表上觸發檢查，所以擋下它的約束掛在訂單表上。&lt;strong>外鍵的錯誤訊息因此要分兩層讀&lt;/strong>：訊息指名的表，未必是剛才那句 SQL 寫到的表。&lt;/p>
&lt;p>有一個值在子表與父表兩邊都放行：&lt;code>NULL&lt;/code>。單欄的外鍵讀作「這一欄有值的時候，那個值找得到對應」，所以空著的那一欄不參與檢查。&lt;strong>跨多欄的外鍵預設另有一種讀法&lt;/strong>——標準的預設 &lt;code>MATCH SIMPLE&lt;/code> 只要其中一欄是 &lt;code>NULL&lt;/code> 就整條放行，另外幾欄有值也不查。要它回到逐欄都算的讀法得寫 &lt;code>MATCH FULL&lt;/code>，而這個寫法只在 PostgreSQL 上生效。同一列 &lt;code>(999, NULL)&lt;/code> 寫進兩欄外鍵、而 &lt;code>999&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">-- 父表以 (a, b) 兩欄為主鍵，999 在父表裡不存在
&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="err">父&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&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">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">子&lt;/span>&lt;span class="n">_預設&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FOREIGN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">父&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">));&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">子&lt;/span>&lt;span class="n">_完整&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FOREIGN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">父&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MATCH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FULL&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="c1">-- 預設 MATCH SIMPLE：有一欄是 NULL，整條放行，另一欄不查
&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">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="n">_預設&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">999&lt;/span>&lt;span class="p">,&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"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- PostgreSQL 18：收下
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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">-- 宣告成 MATCH FULL：逐欄都算
&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">&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="n">_完整&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">999&lt;/span>&lt;span class="p">,&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">12&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- PostgreSQL 18：拒絕，MATCH FULL does not allow mixing of null and nonnull key values
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite 3.51（已開 PRAGMA foreign_keys）、MySQL 8.4：收下 MATCH FULL 這個語法，照樣放行同一列&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>在 SQLite 與 MySQL 上要擋下混著空與非空的組合，得在每一欄各自加 &lt;code>NOT NULL&lt;/code>。訂單編號留空的評價插得進去，而它是一則指不到任何訂單的評價——&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> 裡「NOT IN」的錯法要回零列，要的正是這樣一列。要連訂單編號留空的評價也擋掉，評價表的訂單編號另外要有 &lt;code>NOT NULL&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<p>外鍵是一條寫在表結構上的宣告：這一欄的值，在另一張表裡找得到對應的那一列。資料庫在每一次寫入時檢查它，所以它涵蓋的是整張表，以及往後的每一筆資料。外鍵這條保證有一個名字叫<strong>參照完整性</strong>——「參照」指一欄指向另一張表的某一列，「完整」指那個指向落得到實處。</p>
<p>查詢的文字之外還有幾樣東西在決定結果——名字實際指向誰、送出的人被允許做什麼、引擎打算花多久。約束介入得比那幾樣都早：它決定資料能長成什麼樣，於是決定一段查詢會遇到哪幾種列。</p>
<p>本篇走完這條保證的全貌：它擋下哪些寫入、什麼時候才算生效、生效之後查詢省掉哪一步、父列消失時它怎麼維持，以及它涵蓋不到的地方。這條約束該不該加是資料表設計的取捨，本篇只處理加了之後寫入與查詢的行為有什麼改變。</p>
<p>本篇的表在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫</a>上重建，差別是這一次把約束寫進結構裡；顧客表的三個人是佳穎、宗翰、雅文，而只有佳穎下過單。後面談替既有的表補約束時，另外用到一張出貨表。指不到父列的那一列，下文叫<strong>孤兒列</strong>。</p>
<h2 id="外鍵擋下的寫入與-null-的放行">外鍵擋下的寫入與 NULL 的放行</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">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="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></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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 class="nb">TEXT</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 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="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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">                   </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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 class="nb">INT</span><span class="p">);</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- PostgreSQL 18
</span></span><span class="line"><span class="ln">2</span><span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);
</span></span><span class="line"><span class="ln">3</span><span class="cl">ERROR:  insert or update on table &#34;評價&#34; violates foreign key constraint &#34;評價_訂單編號_fkey&#34;
</span></span><span class="line"><span class="ln">4</span><span class="cl">DETAIL:  Key (訂單編號)=(999) is not present in table &#34;訂單&#34;.
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">DELETE FROM 顧客 WHERE 顧客編號 = 1;
</span></span><span class="line"><span class="ln">7</span><span class="cl">ERROR:  update or delete on table &#34;顧客&#34; violates foreign key constraint &#34;訂單_顧客編號_fkey&#34; on table &#34;訂單&#34;
</span></span><span class="line"><span class="ln">8</span><span class="cl">DETAIL:  Key (顧客編號)=(1) is still referenced from table &#34;訂單&#34;.</span></span></code></pre></div><p>上面兩則錯誤訊息裡的約束名不同。第一則是評價表自己那條，第二則是訂單表那條——刪顧客這個動作在訂單表上觸發檢查，所以擋下它的約束掛在訂單表上。<strong>外鍵的錯誤訊息因此要分兩層讀</strong>：訊息指名的表，未必是剛才那句 SQL 寫到的表。</p>
<p>有一個值在子表與父表兩邊都放行：<code>NULL</code>。單欄的外鍵讀作「這一欄有值的時候，那個值找得到對應」，所以空著的那一欄不參與檢查。<strong>跨多欄的外鍵預設另有一種讀法</strong>——標準的預設 <code>MATCH SIMPLE</code> 只要其中一欄是 <code>NULL</code> 就整條放行，另外幾欄有值也不查。要它回到逐欄都算的讀法得寫 <code>MATCH FULL</code>，而這個寫法只在 PostgreSQL 上生效。同一列 <code>(999, NULL)</code> 寫進兩欄外鍵、而 <code>999</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">-- 父表以 (a, b) 兩欄為主鍵，999 在父表裡不存在
</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="n">a</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</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">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">子</span><span class="n">_預設</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">父</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="p">));</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">子</span><span class="n">_完整</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">父</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="p">)</span><span class="w"> </span><span class="k">MATCH</span><span class="w"> </span><span class="k">FULL</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="c1">-- 預設 MATCH SIMPLE：有一欄是 NULL，整條放行，另一欄不查
</span></span></span><span class="line"><span class="ln"> 7</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="n">_預設</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">999</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"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL 18：收下
</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">-- 宣告成 MATCH FULL：逐欄都算
</span></span></span><span class="line"><span class="ln">11</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="n">_完整</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">999</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">12</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL 18：拒絕，MATCH FULL does not allow mixing of null and nonnull key values
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- SQLite 3.51（已開 PRAGMA foreign_keys）、MySQL 8.4：收下 MATCH FULL 這個語法，照樣放行同一列</span></span></span></code></pre></div><p>在 SQLite 與 MySQL 上要擋下混著空與非空的組合，得在每一欄各自加 <code>NOT NULL</code>。訂單編號留空的評價插得進去，而它是一則指不到任何訂單的評價——<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 裡「NOT IN」的錯法要回零列，要的正是這樣一列。要連訂單編號留空的評價也擋掉，評價表的訂單編號另外要有 <code>NOT NULL</code>。</p>
<p><code>NULL</code> 放行這件事在一種結構裡是必要的：外鍵指回自己所在的那張表。員工表的主管欄指向同一張表的另一列時，一列的上層是同一張表的另一列，而最頂層那一列的主管欄只能留空。這個結構的查詢要把同一張表擺兩次，兩次出現需要各自的名字，<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a> 寫自連接為什麼非取名不可，並把它處理的關係分成前後配對、同組比較與層級關係三種形態——員工與主管屬於層級關係。</p>
<h2 id="宣告與生效分開的位置連線設定約束狀態語法形態與儲存引擎">宣告與生效分開的位置：連線設定、約束狀態、語法形態與儲存引擎</h2>
<p>同一段 <code>REFERENCES</code> 送進不同的引擎，會落在不同的狀態。宣告與生效在連線的設定、約束自己的狀態、語法的形態、表的儲存引擎這幾個位置各自分開過一次，而它們讓人察覺宣告與生效已經分岔的難易程度差很多。</p>
<p><strong>連線的設定</strong>。SQLite 認得 REFERENCES 這段語法、也把約束記進結構裡，而檢查預設關閉，開關掛在每一條連線上：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- SQLite 3.51，未開 PRAGMA
</span></span><span class="line"><span class="ln">2</span><span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);   -- 成功，孤兒列進來了
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">-- 同一個檔案，開了之後
</span></span><span class="line"><span class="ln">5</span><span class="cl">PRAGMA foreign_keys = ON;
</span></span><span class="line"><span class="ln">6</span><span class="cl">INSERT INTO 評價 VALUES (9004, 888, 4);
</span></span><span class="line"><span class="ln">7</span><span class="cl">Error: stepping, FOREIGN KEY constraint failed (19)</span></span></code></pre></div><p>開關打開之後，開關關著時插進去的那筆孤兒列（9003）仍然在表裡——檢查發生在寫入的當下，對已經在裡面的資料沒有追溯力。把孤兒列找出來的指令是 <code>PRAGMA foreign_key_check</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="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_key_check</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">-- table | rowid | parent | fkid
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- 評價  | 2     | 訂單   | 0
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">--   違反者所在的表、那一列的 rowid、被指向的表，以及那是這張表上的第幾條外鍵（從 0 起算）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   評價編號宣告成 INT PRIMARY KEY 而不是 INTEGER PRIMARY KEY，所以 rowid 是另一個隱藏欄位，不等於評價編號</span></span></span></code></pre></div><p><strong>約束自己的狀態</strong>。PostgreSQL 的 <code>NOT VALID</code> 讓一條外鍵掛上去而略過既有的列，往後的寫入照常檢查。這個狀態是給大表補約束用的（既有列的檢查另用 <code>VALIDATE CONSTRAINT</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">-- PostgreSQL 18，出貨表裡已經有一列指向不存在的訂單 777
</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="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="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 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">1</span><span class="p">,</span><span class="w"> </span><span class="mi">102</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">777</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="err">出貨</span><span class="n">_訂單編號_fkey</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">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</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">NOT</span><span class="w"> </span><span class="k">VALID</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">-- 成功，777 那一列沒有被檢查
</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="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">3</span><span class="p">,</span><span class="w"> </span><span class="mi">888</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">-- ERROR:  insert or update on table &#34;出貨&#34; violates foreign key constraint &#34;出貨_訂單編號_fkey&#34;
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="n">VALIDATE</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="err">出貨</span><span class="n">_訂單編號_fkey</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">-- ERROR:  insert or update on table &#34;出貨&#34; violates foreign key constraint &#34;出貨_訂單編號_fkey&#34;
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- DETAIL:  Key (訂單編號)=(777) is not present in table &#34;訂單&#34;.</span></span></span></code></pre></div><p><strong>語法的形態</strong>。MySQL 認得欄位層的 <code>REFERENCES</code>，解析完把它丟掉，不留下任何約束，<code>SHOW WARNINGS</code> 也是空的。同一段文字在 PostgreSQL 與 SQLite 底下建得出約束，在 MySQL 底下建不出來，而 MySQL 在建表時沒有給任何警告。</p>
<p><strong>表的儲存引擎</strong>。同一家 MySQL 裡還有一條讓宣告消失的路，而它多半不是有人刻意選的——舊系統的建表模板、從舊備份還原回來的表、或複製過來的一段舊 DDL，都會把儲存引擎一起帶進來。表層的 <code>FOREIGN KEY</code> 寫法建在 MyISAM 上，語法照收、警告照樣是空的，約束同樣沒有留下。兩條路要改的地方不同：欄位層的寫法要改寫成表層的 <code>FOREIGN KEY</code>，MyISAM 要換成 InnoDB。</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，資料庫名 t；顧客表同共用資料，並以顧客編號為主鍵
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- 欄位層的 REFERENCES：建表成功，SHOW WARNINGS 是空的
</span></span></span><span class="line"><span class="ln"> 3</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="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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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 class="nb">TEXT</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"> 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 class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="mi">999</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"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 成功，孤兒列進來了
</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">-- 表層的 FOREIGN KEY，建在預設的 InnoDB 上
</span></span></span><span class="line"><span class="ln"> 9</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="n">_表層</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</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="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span 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">10</span><span class="cl"><span class="w">                        </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</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">11</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="n">_表層</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">999</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">12</span><span class="cl"><span class="w"></span><span class="c1">-- ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="c1">-- 同樣的表層寫法建在 MyISAM 上：建表成功，SHOW WARNINGS 是空的
</span></span></span><span class="line"><span class="ln">15</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="n">_MyISAM</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</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="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span 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">16</span><span class="cl"><span class="w">                          </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</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">ENGINE</span><span class="o">=</span><span class="n">MyISAM</span><span class="p">;</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">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="n">_MyISAM</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">999</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">18</span><span class="cl"><span class="w"></span><span class="c1">-- 成功，孤兒列進來了
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></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">SELECT</span><span class="w"> </span><span class="k">TABLE_NAME</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">REFERENTIAL_CONSTRAINTS</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">CONSTRAINT_SCHEMA</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;t&#39;</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">-- 訂單_表層    三張表裡只有這一張留下了約束</span></span></span></code></pre></div><p>這幾個位置的共同點是<strong>外鍵只在寫入的那一刻現身</strong>：查詢的文字裡沒有一個字提到約束，讀的時候也沒有差別。所以「這條保證在不在」要另外查，查的地方是系統目錄。<strong>系統目錄記的是宣告存不存在，生不生效要另外查</strong>：SQLite 在 <code>PRAGMA foreign_keys</code> 關著的時候照樣列出那條外鍵，而孤兒列插得進去；PostgreSQL 的 <code>NOT VALID</code> 外鍵同樣列在 <code>information_schema.table_constraints</code> 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 宣告存不存在
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- PostgreSQL
</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="k">table_name</span><span class="p">,</span><span class="w"> </span><span class="k">constraint_name</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">table_constraints</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="n">constraint_type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;FOREIGN KEY&#39;</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">-- MySQL
</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="k">TABLE_NAME</span><span class="p">,</span><span class="w"> </span><span class="k">CONSTRAINT_NAME</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">REFERENTIAL_CONSTRAINTS</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">CONSTRAINT_SCHEMA</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;t&#39;</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">-- SQLite：每張表各查一次
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_key_list</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">-- 0|0|訂單|訂單編號|訂單編號|NO ACTION|NO ACTION|NONE    開關關著的時候也列得出來
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 生不生效
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- SQLite：這條連線的開關
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_keys</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">-- 0 是關、1 是開
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- PostgreSQL：convalidated 是 f 的外鍵是 NOT VALID，既有的列沒有檢查過
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">conname</span><span class="p">,</span><span class="w"> </span><span class="n">convalidated</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_constraint</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">contype</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;f&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="c1">-- 出貨_訂單編號_fkey | f
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1">-- 訂單_顧客編號_fkey | t
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1">-- 評價_訂單編號_fkey | t</span></span></span></code></pre></div><p>生效之後還有一個「什麼時候檢查」的維度。預設是每一句寫入當場檢查，而 PostgreSQL 的 <code>DEFERRABLE INITIALLY DEFERRED</code> 把檢查推到交易提交的那一刻——中間的每一句都通過，<code>COMMIT</code> 才回 <code>violates foreign key constraint</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">-- PostgreSQL 18，兩條外鍵都宣告成 DEFERRABLE INITIALLY DEFERRED
</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="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="err">主管</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</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="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="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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">部門編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">DEFERRABLE</span><span class="w"> </span><span class="k">INITIALLY</span><span class="w"> </span><span class="k">DEFERRED</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">主管</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">員工</span><span class="w"> </span><span class="k">DEFERRABLE</span><span class="w"> </span><span class="k">INITIALLY</span><span class="w"> </span><span class="k">DEFERRED</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">BEGIN</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">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">10</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 員工 10 還不存在，這一句照樣通過
</span></span></span><span class="line"><span class="ln"> 9</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">10</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">10</span><span class="cl"><span class="w"></span><span class="k">COMMIT</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"></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">BEGIN</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="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">2</span><span class="p">,</span><span class="w"> </span><span class="mi">20</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 通過
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">COMMIT</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">-- ERROR:  insert or update on table &#34;部門&#34; violates foreign key constraint &#34;部門_主管_fkey&#34;
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- DETAIL:  Key (主管)=(20) is not present in table &#34;員工&#34;.
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">--   同樣兩張表不宣告 DEFERRABLE 的話，兩句 INSERT 無論哪一句先寫都當場被擋下</span></span></span></code></pre></div><h2 id="約束生效之後查詢可以省掉的判斷not-null-與外鍵各自的保證">約束生效之後查詢可以省掉的判斷：NOT NULL 與外鍵各自的保證</h2>
<p>一條保證的價值落在它讓哪些判斷不必再做。同一組表的兩個版本擺在一起看得清楚：一邊的 <code>訂單.顧客編號</code> 帶著 <code>NOT NULL REFERENCES 顧客(顧客編號)</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">-- SQLite 3.51，已開 PRAGMA foreign_keys
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- 有約束的版本
</span></span></span><span class="line"><span class="ln"> 3</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="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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</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 class="nb">TEXT</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"> 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 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"> 6</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="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"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- NOT NULL constraint failed: 訂單.顧客編號
</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"></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">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 有約束：2，訂單表 2 列
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- 沒有約束：2，訂單表 3 列；103 配不到任何顧客，從結果裡消失
</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">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">16</span><span class="cl"><span class="w"></span><span class="c1">-- 有約束：宗翰、雅文
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">-- 沒有約束：0 列；子查詢交出 1、1、NULL，與 NULL 比較的那一項是未知</span></span></span></code></pre></div><p>沒有約束的那一版，內連接與 <code>NOT IN</code> 這兩段查詢各自出了一種錯，而兩種都不報錯。內連接掉了一列，而總數看起來仍然像一份完整的訂單清單；<code>NOT IN</code> 整段回空集合，因為子查詢交出的那一欄含一個 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時"><code>NULL</code></a>。內連接為什麼丟掉配不到對象的列，在 <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>NOT IN</code> 的完整推導在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節。</p>
<p>有約束的那一邊，兩段查詢都對，而它們的寫法一個字都沒改。<strong>改變的是那兩個判斷的前提從觀察變成了保證</strong>：翻遍現在的訂單表沒有一筆顧客編號是空的，只證明此刻如此；<code>NOT NULL</code> 證明的是往後也不會有，而查詢要活得比這一批資料久（<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint</a>）。</p>
<p>這裡有兩條約束在做不同的事，分開記各自買到什麼。<strong><code>NOT NULL</code> 買的是「這一欄有值」</strong>，它管掉上面兩種錯——沒有空缺，內連接就掉不了列，<code>NOT IN</code> 也碰不到那個 <code>NULL</code>。<strong>外鍵買的是「這個值指得到人」</strong>，它管的是另一批推論：這一欄有值的列，連過去必定配得到一列。子表的每一列都歸屬到某個父列、<code>JOIN</code> 與 <code>LEFT JOIN</code> 在這個方向上回同一批列，這兩項要兩條約束都在才成立——只有外鍵的時候，顧客編號留空的訂單照樣寫得進去，<code>LEFT JOIN</code> 留下它而 <code>JOIN</code> 丟掉它。兩條都在的時候，「這個連接會掉列嗎」這個問題不必查資料就答得出來。<code>UNIQUE</code> 保證值不重複、<code>CHECK</code> 保證值滿足一個自己寫的條件、<code>PRIMARY KEY</code> 是 <code>NOT NULL</code> 加 <code>UNIQUE</code> 再加上「這是辨識一列的依據」，而這幾種約束共用同一條性質——豁免一個判斷的條件必須是一條約束，不能是一次觀察；各自買到什麼、以及它們為什麼住在 <a href="/blog/backend/01-database/sql/knowledge-cards/ddl-dml/" data-link-title="DDL 與 DML（定義結構與動資料）" data-link-desc="看到「這個帳號只需要 DML 權限」或遷移文件把兩者分開講時，查這條界線切在哪裡">DDL</a> 那一側，在 <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint（約束）</a>。</p>
<p>這些推論的代價落在寫入那一側：每插進一列，資料庫要去父表確認那個值在不在，所以外鍵是拿寫入的工作換讀取的保證。這筆交換要用量測來判，而讀代價的工具在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>。要不要加這條外鍵約束，則是設計的決定：它在強制完整性與演進自由度之間取捨：正式的核心資料通常由資料庫強制，換取每一次讀取都能信任的推論；跨系統整合的資料常改由應用層保護，因為上游的結構可能先變，外鍵會讓那一次變動變成寫入失敗——<a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">Schema Design</a> 的「Table 與 Relation」一節寫這個取捨，並提醒宣告與執法要分開看。</p>
<h2 id="父列被刪除時子列的去向on-delete-的選項">父列被刪除時子列的去向：ON DELETE 的選項</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="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">CASCADE</span><span class="w">      </span><span class="c1">-- 子列一起刪掉
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">NULL</span><span class="w">     </span><span class="c1">-- 子列留著，這一欄清空
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">0</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">REFERENCES</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">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w">  </span><span class="c1">-- 子列留著，改指一個哨兵列</span></span></span></code></pre></div><p>在 PostgreSQL 18 上刪掉訂單 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="c1">-- 三張評價表照上面三行宣告，各有一列 (9001, 102, 4)；訂單 0 是事先備好的哨兵列
</span></span></span><span class="line"><span class="ln">2</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">102</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_連帶刪除</span><span class="p">;</span><span class="w">   </span><span class="c1">-- ON DELETE CASCADE：0 列
</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="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_清空</span><span class="p">;</span><span class="w">       </span><span class="c1">-- ON DELETE SET NULL：9001 |   | 4，訂單編號變成空的
</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="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_改指哨兵</span><span class="p">;</span><span class="w">   </span><span class="c1">-- ON DELETE SET DEFAULT：9001 | 0 | 4，改指哨兵列</span></span></span></code></pre></div><p>擋下刪除的則有兩種寫法而它們不一樣：<code>RESTRICT</code> 在那一句 <code>DELETE</code> 當場擋下，而且不能推遲；<code>NO ACTION</code>（沒寫 <code>ON DELETE</code> 時的預設）在每一句結束時檢查，約束宣告成 <code>DEFERRABLE</code> 之後才能推到交易提交。同一個交易裡先刪父列、再刪子列，只有宣告成 <code>DEFERRABLE</code> 的 <code>NO ACTION</code> 過得去。</p>
<p><strong>子列離開父列之後還有沒有意義，決定它跟著刪掉還是留下。</strong> 一則評價離開它評的那張訂單之後說不出在評什麼，所以它適合跟著走；一筆出貨紀錄即使訂單被撤銷仍然是發生過的事實，清空指向比刪掉它更貼近實情。</p>
<p><strong>這一列有沒有自己的保留義務，是獨立的另一個問句，而它成立的時候蓋過「還有沒有意義」的答案。</strong> 一筆交易紀錄離開它的帳戶之後同樣說不出在記什麼，只問還有沒有意義會走到 <code>CASCADE</code>——而受稽核或法規保留的紀錄不能被任何一次刪除帶走。保留義務成立的時候，答案落在 <code>RESTRICT</code> 或 <code>SET DEFAULT</code> 上（<code>RESTRICT</code> 不准刪父列，<code>SET DEFAULT</code> 讓子列改指一個「已刪除」的哨兵列，紀錄留著而那一欄不為空），而只問還有沒有意義的時候，這兩個選項不會被想到。保留義務要優先的理由是 <code>CASCADE</code> 的一個性質：<strong>一次刪除實際刪掉多少，由表之間的結構決定，不由那句 <code>DELETE</code> 決定</strong>。它沿著外鍵一路傳下去，刪一個顧客連帶刪掉他的訂單、那些訂單的評價，以及再往下的每一層，而那句 SQL 的文字裡只提到顧客。</p>
<p><code>SET NULL</code> 與同一欄上的 <code>NOT NULL</code> 要求的條件互相排斥，而建表的時候兩者並存不會被擋下。PostgreSQL 18 收下這張表，衝突到刪除父列的那一刻才浮現，訊息指的是那個清空的動作違反了 <code>NOT 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="c1">-- PostgreSQL 18
</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="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></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">                   </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</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">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">NULL</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">4</span><span class="cl"><span class="w"></span><span class="c1">-- 建表成功
</span></span></span><span class="line"><span class="ln">5</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">9001</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">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="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">101</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">-- ERROR:  null value in column &#34;訂單編號&#34; of relation &#34;評價&#34; violates not-null constraint
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- DETAIL:  Failing row contains (9001, null, 5).
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- CONTEXT:  SQL statement &#34;UPDATE ONLY &#34;public&#34;.&#34;評價&#34; SET &#34;訂單編號&#34; = NULL WHERE ...&#34;</span></span></span></code></pre></div><p><strong>宣告與生效那條分界在這裡又出現一次：宣告收下了，做得到做不到要等到執行的那一刻。</strong> 而 <code>NOT NULL</code> 買掉的正是掉列與 <code>NOT IN</code> 那兩類錯誤，所以這裡要選一邊——留下子列的代價是那一欄重新可能為空，關於它的推論回到觀察那一級。</p>
<h2 id="外鍵涵蓋不到的寫入與規則">外鍵涵蓋不到的寫入與規則</h2>
<p>外鍵的檢查發生在寫入的那一刻，所以它的射程由「哪些寫入經過它」決定。</p>
<p><strong>既有的資料不在射程裡。</strong> 補一條外鍵到已經有孤兒列的表上，這個動作本身會失敗：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- PostgreSQL 18，出貨表裡有一列的訂單編號是 777
</span></span><span class="line"><span class="ln">2</span><span class="cl">ALTER TABLE 出貨 ADD FOREIGN KEY (訂單編號) REFERENCES 訂單(訂單編號);
</span></span><span class="line"><span class="ln">3</span><span class="cl">ERROR:  insert or update on table &#34;出貨&#34; violates foreign key constraint &#34;出貨_訂單編號_fkey&#34;
</span></span><span class="line"><span class="ln">4</span><span class="cl">DETAIL:  Key (訂單編號)=(777) is not present in table &#34;訂單&#34;.</span></span></code></pre></div><p>這則錯誤說的是資料裡已經有孤兒列，而約束加得上去的條件，是既有的每一列都已經滿足它。給有流量的大表補約束的完整順序在 <a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同一個資料庫引擎內 schema 演進與資料變更的分段驗證流程：expand-contract、回填、驗證查詢、切流與回滾；換資料庫引擎的遷移不在範圍內">資料庫轉換實作</a> 的 「Type I：加約束（CHECK / FK / NOT NULL 收緊）」一節。</p>
<p><strong>同一個資料庫之外的指向不在射程裡。</strong> 外鍵認的是另一張表，所以一個指向別的服務的識別碼、或指向物件儲存的一個鍵，資料庫無從檢查。這一類的完整性要在資料庫以外的層落地。應用程式碼內部的落點分幾層、各層違反規則時發生什麼，在 <a href="/blog/software-design/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>——那一篇的作用域是單一物件的規則。跨服務的一致性靠什麼維持（Saga、outbox 這一類），在 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">Transaction 與一致性邊界</a>，那一篇談的是一致性，參照完整性要落在哪一層它沒有單獨處理。</p>
<p><strong>不經過這條連線的寫入不在射程裡。</strong> 這一點是 SQLite 把 <code>PRAGMA foreign_keys</code> 開關掛在每一條連線上的直接後果——約束的檢查掛在連線上的時候，遷移工具、測試框架與別人的第三方程式各自開自己的連線，而它們未必開了那個開關。射程因此不是「這個資料庫」，是「經過設定正確的連線的那些寫入」。</p>
<p><strong>業務規則不在射程裡。</strong> 外鍵回答的問題只有一個：這個值在那張表裡找不找得到。「評價要在出貨之後」「同一張訂單只能退款一次」沒有這個形狀，而兩條規則各自落在不同的機制上：「同一張訂單只能退款一次」要比對退款表裡同一張訂單的其他列，寫成退款表訂單編號上的 <code>UNIQUE</code>；「評價要在出貨之後」要讀出貨表裡那張訂單的出貨時間，而 <code>CHECK</code> 只讀正要寫入的那一列、不接受子查詢，所以它落在觸發器或應用層。</p>
<p>業務規則那一項接上 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 的一個結論：「答案對不對」整條交不出去，而它底下的判斷能不能交給機器，看<strong>判斷時要讀什麼</strong>——在寫入那一刻就判得完、而且讀的範圍對得上某一種約束的，寫得成約束：<code>CHECK</code> 只讀正要寫入的那一筆，<code>UNIQUE</code> 讀同一欄已有的值，外鍵讀另一張表裡被指向的那一列。外鍵是約束裡讀得最遠的一種，而讀的範圍到被指向的那一列為止；「評價要在出貨之後」要讀另一張表的出貨日，同樣在寫入那一刻判得完，卻超出了每一種約束的範圍。</p>
<h2 id="文字的宣告被執行的程度外鍵引擎補上的決定與-left-join">文字的宣告被執行的程度：外鍵、引擎補上的決定與 LEFT JOIN</h2>
<p>一段文字說的話什麼時候會被執行，外鍵是答案最明確的那一端：它有生效的狀態可查，查得到之後保證就在。<code>LEFT JOIN</code> 的 <code>LEFT</code> 也宣告了一件事——預期有配不到的列——而引擎從不查證它，照著算完就結束，宣告落空時查詢照樣回正確答案；那一端在 <a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>。夾在中間的是引擎替文字補上的決定：外鍵在某一家被靜默丟掉就是其中一例，這一類差異按發聲的位置從當場報錯分到完全靜默、並依同一段 SQL 要跑在幾種引擎與設定的組合上，決定哪幾種要在寫的當下就處理掉，在 <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>
]]></content:encoded></item><item><title>SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/</guid><description>&lt;p>SQL 標準留下許多沒有規定的地方，而每一家&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;strong>寬鬆度指的是引擎在一段文字不合標準、或標準沒有規定的時候，選擇收下它並自己補上那個決定&lt;/strong>——收不收、以及補什麼，各家不同。&lt;/p>
&lt;p>查詢的文字之外還有幾方在決定結果：識別字規則決定名字指向誰、collation 決定字串怎麼比、權限決定誰能送出這段 SQL、約束決定資料能長成什麼樣、最佳化器決定要花多久。寬鬆度與那五方的關係不同——&lt;strong>它決定的是那五方各自收與不收的界線畫在哪裡&lt;/strong>，所以同一家引擎的寬鬆度會同時出現在名字、比較、分組等好幾個位置上，本篇把那些位置並置起來比較。&lt;/p>
&lt;p>本篇先整理形態學——各家的分割長什麼樣、差異在什麼位置發聲；再談決策——哪幾種差異要在寫的當下就處理掉、什麼時候要重算。&lt;strong>形態學是決策的輸入&lt;/strong>：不知道一個差異會在什麼時候發聲，就算不出提前處理它要付多少。&lt;/p>
&lt;p>本篇的量測跑在四家引擎上：SQLite 3.51、DuckDB v0.10.3、PostgreSQL 18、MySQL 8.4。兩張表各兩列，下表的每一個寫法是這個區塊裡的一段查詢：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="p">(&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"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span 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">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;Anna&amp;#39;&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="s1">&amp;#39;anna&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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">101&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">700&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">102&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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"> 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="c1">-- 未分組欄位：訂單編號不在 GROUP BY 裡，也沒有被聚合
&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="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 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>&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">-- SQLite：1 101（這一組有兩列，它挑了其中一列）
&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">-- DuckDB、PostgreSQL、MySQL：拒絕
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">-- MySQL 在同一條連線先 SET SESSION sql_mode = &amp;#39;&amp;#39; 之後：1 101
&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">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- WHERE 用別名：兩倍在 SELECT 那一步才算出來
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&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="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">2&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">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">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">14&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- SQLite、DuckDB：1400
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="c1">-- PostgreSQL、MySQL：拒絕
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&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">17&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">18&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">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="p">,&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">19&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- SQLite：4；引用欄位時才拒，SELECT 訂單編號 FROM 訂單, 訂單 回 ambiguous column name
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="c1">-- DuckDB、PostgreSQL、MySQL：FROM 就拒絕，連 count(*) 都不收
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&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">22&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">23&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">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;anna&amp;#39;&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">24&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- SQLite、DuckDB、PostgreSQL：anna
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="c1">-- MySQL：Anna、anna（預設 collation 不分大小寫）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&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">27&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">28&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">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="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">29&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- SQLite、MySQL：102、101（空的排最前）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">&lt;span class="c1">-- DuckDB、PostgreSQL：101、102（空的排最後）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="同一組寫法在各家引擎上收與不收的分割">同一組寫法在各家引擎上收與不收的分割&lt;/h2>
&lt;p>下面這組寫法擺在 SQLite、DuckDB、PostgreSQL、MySQL 這四家引擎上，每一個寫法把這四家切成兩邊，而切法彼此對不上：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>寫法&lt;/th>
 &lt;th>寫出來的樣子&lt;/th>
 &lt;th>SQLite&lt;/th>
 &lt;th>DuckDB&lt;/th>
 &lt;th>PostgreSQL&lt;/th>
 &lt;th>MySQL&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>未分組欄位&lt;/td>
 &lt;td>&lt;code>GROUP BY&lt;/code> 之外的欄位進 &lt;code>SELECT&lt;/code>&lt;/td>
 &lt;td>接受&lt;/td>
 &lt;td>拒絕&lt;/td>
 &lt;td>拒絕&lt;/td>
 &lt;td>拒絕&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>WHERE 用別名&lt;/td>
 &lt;td>&lt;code>SELECT&lt;/code> 的別名用在 &lt;code>WHERE&lt;/code>&lt;/td>
 &lt;td>接受&lt;/td>
 &lt;td>接受&lt;/td>
 &lt;td>拒絕&lt;/td>
 &lt;td>拒絕&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同表未取別名&lt;/td>
 &lt;td>同一張表出現兩次沒取別名&lt;/td>
 &lt;td>引用該欄位才拒&lt;/td>
 &lt;td>FROM 就拒絕&lt;/td>
 &lt;td>FROM 就拒絕&lt;/td>
 &lt;td>FROM 就拒絕&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>字串比對&lt;/td>
 &lt;td>&lt;code>WHERE 姓名 = 'anna'&lt;/code>&lt;/td>
 &lt;td>回 1 列&lt;/td>
 &lt;td>回 1 列&lt;/td>
 &lt;td>回 1 列&lt;/td>
 &lt;td>回 2 列&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>空值排序&lt;/td>
 &lt;td>&lt;code>ORDER BY 金額&lt;/code>（一列為空）&lt;/td>
 &lt;td>空的排最前&lt;/td>
 &lt;td>空的排最後&lt;/td>
 &lt;td>空的排最後&lt;/td>
 &lt;td>空的排最前&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>沒有一個寫法的分割能預測下一個寫法的分割。&lt;/strong> 未分組欄位只有 SQLite 收；WHERE 用別名換成 SQLite 與 DuckDB 一組；字串比對只有 MySQL 給另一個答案；空值排序又切成 SQLite 加 MySQL 對上 DuckDB 與 PostgreSQL。同表未取別名的分割與未分組欄位相同（都是 SQLite 對上其餘三家），這一點不減損上面那句——&lt;strong>同一組引擎可以在兩個寫法上一起站在同一邊，而那不預測第三個寫法&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<p>SQL 標準留下許多沒有規定的地方，而每一家<a href="/blog/backend/01-database/sql/knowledge-cards/database-engine/" data-link-title="Database Engine（資料庫引擎）" data-link-desc="文章說「引擎決定」「換一家引擎就不同」時，查那是哪一個層次的決定">引擎</a>都得替那些地方做一個決定。<strong>寬鬆度指的是引擎在一段文字不合標準、或標準沒有規定的時候，選擇收下它並自己補上那個決定</strong>——收不收、以及補什麼，各家不同。</p>
<p>查詢的文字之外還有幾方在決定結果：識別字規則決定名字指向誰、collation 決定字串怎麼比、權限決定誰能送出這段 SQL、約束決定資料能長成什麼樣、最佳化器決定要花多久。寬鬆度與那五方的關係不同——<strong>它決定的是那五方各自收與不收的界線畫在哪裡</strong>，所以同一家引擎的寬鬆度會同時出現在名字、比較、分組等好幾個位置上，本篇把那些位置並置起來比較。</p>
<p>本篇先整理形態學——各家的分割長什麼樣、差異在什麼位置發聲；再談決策——哪幾種差異要在寫的當下就處理掉、什麼時候要重算。<strong>形態學是決策的輸入</strong>：不知道一個差異會在什麼時候發聲，就算不出提前處理它要付多少。</p>
<p>本篇的量測跑在四家引擎上：SQLite 3.51、DuckDB v0.10.3、PostgreSQL 18、MySQL 8.4。兩張表各兩列，下表的每一個寫法是這個區塊裡的一段查詢：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">20</span><span class="p">));</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span 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">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;Anna&#39;</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="s1">&#39;anna&#39;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="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="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">700</span><span class="p">),</span><span class="w"> </span><span class="p">(</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 class="k">NULL</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="c1">-- 未分組欄位：訂單編號不在 GROUP BY 裡，也沒有被聚合
</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="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"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite：1 101（這一組有兩列，它挑了其中一列）
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">-- DuckDB、PostgreSQL、MySQL：拒絕
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- MySQL 在同一條連線先 SET SESSION sql_mode = &#39;&#39; 之後：1 101
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- WHERE 用別名：兩倍在 SELECT 那一步才算出來
</span></span></span><span class="line"><span class="ln">13</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="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">0</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="c1">-- SQLite、DuckDB：1400
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1">-- PostgreSQL、MySQL：拒絕
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="c1">-- 同表未取別名
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></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="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">19</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite：4；引用欄位時才拒，SELECT 訂單編號 FROM 訂單, 訂單 回 ambiguous column name
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1">-- DuckDB、PostgreSQL、MySQL：FROM 就拒絕，連 count(*) 都不收
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"></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">-- 字串比對
</span></span></span><span class="line"><span class="ln">23</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">=</span><span class="w"> </span><span class="s1">&#39;anna&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite、DuckDB、PostgreSQL：anna
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1">-- MySQL：Anna、anna（預設 collation 不分大小寫）
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w"></span><span class="c1">-- 空值排序
</span></span></span><span class="line"><span class="ln">28</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="err">金額</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite、MySQL：102、101（空的排最前）
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="c1">-- DuckDB、PostgreSQL：101、102（空的排最後）</span></span></span></code></pre></div><h2 id="同一組寫法在各家引擎上收與不收的分割">同一組寫法在各家引擎上收與不收的分割</h2>
<p>下面這組寫法擺在 SQLite、DuckDB、PostgreSQL、MySQL 這四家引擎上，每一個寫法把這四家切成兩邊，而切法彼此對不上：</p>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>寫出來的樣子</th>
          <th>SQLite</th>
          <th>DuckDB</th>
          <th>PostgreSQL</th>
          <th>MySQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>未分組欄位</td>
          <td><code>GROUP BY</code> 之外的欄位進 <code>SELECT</code></td>
          <td>接受</td>
          <td>拒絕</td>
          <td>拒絕</td>
          <td>拒絕</td>
      </tr>
      <tr>
          <td>WHERE 用別名</td>
          <td><code>SELECT</code> 的別名用在 <code>WHERE</code></td>
          <td>接受</td>
          <td>接受</td>
          <td>拒絕</td>
          <td>拒絕</td>
      </tr>
      <tr>
          <td>同表未取別名</td>
          <td>同一張表出現兩次沒取別名</td>
          <td>引用該欄位才拒</td>
          <td>FROM 就拒絕</td>
          <td>FROM 就拒絕</td>
          <td>FROM 就拒絕</td>
      </tr>
      <tr>
          <td>字串比對</td>
          <td><code>WHERE 姓名 = 'anna'</code></td>
          <td>回 1 列</td>
          <td>回 1 列</td>
          <td>回 1 列</td>
          <td>回 2 列</td>
      </tr>
      <tr>
          <td>空值排序</td>
          <td><code>ORDER BY 金額</code>（一列為空）</td>
          <td>空的排最前</td>
          <td>空的排最後</td>
          <td>空的排最後</td>
          <td>空的排最前</td>
      </tr>
  </tbody>
</table>
<p><strong>沒有一個寫法的分割能預測下一個寫法的分割。</strong> 未分組欄位只有 SQLite 收；WHERE 用別名換成 SQLite 與 DuckDB 一組；字串比對只有 MySQL 給另一個答案；空值排序又切成 SQLite 加 MySQL 對上 DuckDB 與 PostgreSQL。同表未取別名的分割與未分組欄位相同（都是 SQLite 對上其餘三家），這一點不減損上面那句——<strong>同一組引擎可以在兩個寫法上一起站在同一邊，而那不預測第三個寫法</strong>。</p>
<p><strong>同一家引擎也可以坐在同一個寫法的兩邊。</strong> 未分組欄位在 MySQL 上是拒絕，而把 <code>sql_mode</code> 清空之後同一段查詢回 <code>1 101</code>——寬鬆度是「哪一家加上哪一組設定」，不是「哪一家」。SQLite 收下同一段查詢的時候也在補一個決定，只是補得更隱蔽：一組裡有幾列，它就挑其中一列的值交出來，而挑哪一列由執行方式決定、查詢的文字裡沒有說。<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> 就收不收而言 SQLite 確實比 PostgreSQL 寬鬆——未分組欄位、WHERE 用別名、同表未取別名都是，字串比對上兩家一樣。問題在於字串比對與空值排序問的不是收不收：這兩個寫法四家全部收下，而答案不同。所以帶著「SQLite 最寬鬆、PostgreSQL 最嚴」去搬家，會準確地預期到「本來能跑的變成報錯」，而錯過完全沒有報錯的字串比對與空值排序。</p>
<p>各個寫法的完整推導住在它們自己的篇裡：未分組欄位在 <a href="/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法</a> 與 <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>（SQLite 收下之後自己從那一組挑一列的值），WHERE 用別名在 <a href="/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</a>（<code>WHERE</code> 那一步發生在算出別名之前），同表未取別名在 <a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>（同一張表出現兩次為什麼非取名不可），字串比對在 <a href="/blog/backend/01-database/sql/string-comparison-and-collation/" data-link-title="SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性" data-link-desc="同一段等值條件在各家回不同的列的原因、LIKE 與等號何時分岔、索引與條件的比較規則怎麼對上，以及規則該寫在哪一層">SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性</a>（「相等」由哪一條比較規則定義），空值排序在 <a href="/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</a>（空值與任何值都比不出大小，落在哪一端由引擎規定）。表上沒有列出的一個實例形態特別隱蔽：識別字的大小寫。SQLite 與 DuckDB 連引號都不讓名字變敏感，所以在它們上面開發時混用引號的地方一路正常，搬到 PostgreSQL 才整批找不到表——<a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a> 寫各家的摺疊規則，以及兩種一致做法之間的中間地帶。</p>
<h2 id="跨引擎差異的分級當場報錯等資料才報錯靜默而輸出不同靜默且輸出相同">跨引擎差異的分級：當場報錯、等資料才報錯、靜默而輸出不同、靜默且輸出相同</h2>
<p>上面那組寫法落在不同的級別上，而分級的依據是<strong>這個差異在正常的執行路徑上會不會發出聲音、在哪裡發</strong>。比較的單位是組態——不是「這一家與那一家」，是<strong>這一家的這個版本配上這一組設定，與另一家的另一個版本配上另一組設定</strong>。同一家跨一個大版本、或同一個資料庫換一條連線設定，都會產生下面每一種級別的差異。</p>
<p><strong>當場報錯：剖析或名稱解析就出聲。</strong> 未分組欄位與 WHERE 用別名從寬鬆的那一家搬到嚴格的那一家時屬於這一級——第一次跑就報錯，訊息指名了那個欄位。當場報錯的差異最省事：它自己會現身，而且現身得早。</p>
<p><strong>等資料才報錯：執行到某一筆資料才出聲。</strong> <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>的差異多半落在這一級——搬過去之後查詢照跑，直到第一筆違反它的資料寫進來。所以等資料才報錯的差異，現身時機由資料決定，而測試資料多半不含觸發它的那一筆。</p>
<p><strong>靜默而輸出不同：不出聲，而輸出有差。</strong> 字串比對與空值排序在這一級。四家沒有一家報錯，回來的都是形狀正確的列，而列不一樣。要發現它得<strong>把同一段查詢在兩家上各跑一次、比對輸出</strong>。而這個比對不會有人自發去做，因為兩邊都沒有報錯——沒有任何東西提示這裡需要被比對。</p>
<p><strong>靜默且輸出相同：不出聲，輸出也沒差。</strong> <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>REFERENCES</code> 在一家建得出約束、在另一家被靜默丟掉，而建表語句成功、查詢也成功。靜默而輸出不同的差異至少在輸出上留下痕跡，靜默且輸出相同的差異連痕跡都不在輸出裡——它在系統目錄裡，要主動去查才看得到。</p>
<p><strong>級別量的是發聲的位置，不是嚴重性、也不是偵測要花多少工夫。</strong> 修起來多麻煩與級別無關（當場報錯的差異可能要重寫整批查詢，靜默且輸出相同的差異可能只要加一行設定）；連偵測成本都不跟著級別排序——靜默且輸出相同的差異查一次系統目錄就有答案，而靜默而輸出不同的差異要持續做跨引擎的輸出比對，成本反而更高。分級真正決定的是<strong>什麼時候會知道有這件事</strong>：當場報錯的差異不必準備，它自己會現身；靜默且輸出相同的差異在往後任何一個時刻都不會自己浮現，所以知道它存在的唯一途徑落在搬家之前那一次盤點裡。盤點之外，搬家本身的流程——分階段、雙寫、切換——在 <a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">大規模 DB 遷移實戰</a>。</p>
<h2 id="可攜性的操作對象引擎代為決定的位置與把決定寫出來的宣告">可攜性的操作對象：引擎代為決定的位置與把決定寫出來的宣告</h2>
<p>一段 SQL 單獨拿出來問「它可不可攜」是答不出來的。上面字串比對那個寫法在四家上有兩種答案，而把比較規則寫進欄位宣告之後，四家的答案就統一了（<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> 的〈collation 宣告在資料庫、表、欄位或條件上各自的涵蓋範圍〉一節）；統一到分大小寫或不分大小寫都做得到，兩個方向在四家上都量過。統一的是答案，宣告的文字本身仍然要逐家寫。下面是 <code>顧客</code> 表 <code>姓名</code> 那一欄的宣告，放進各家的 <code>CREATE TABLE</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">-- 分大小寫：WHERE 姓名 = &#39;anna&#39; 在四家都回 1 列
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="err">姓名</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">COLLATE</span><span class="w"> </span><span class="nb">BINARY</span><span class="w">                      </span><span class="c1">-- SQLite
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="err">姓名</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">COLLATE</span><span class="w"> </span><span class="k">C</span><span class="w">                           </span><span class="c1">-- DuckDB；寫 COLLATE BINARY 是語法錯誤
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="err">姓名</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">COLLATE</span><span class="w"> </span><span class="s2">&#34;C&#34;</span><span class="w">                         </span><span class="c1">-- PostgreSQL
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="err">姓名</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span><span class="w"> </span><span class="k">COLLATE</span><span class="w"> </span><span class="n">utf8mb4_0900_as_cs</span><span class="w">   </span><span class="c1">-- MySQL
</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="c1">-- 不分大小寫：同一句在四家都回 2 列
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="err">姓名</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">COLLATE</span><span class="w"> </span><span class="n">NOCASE</span><span class="w">                      </span><span class="c1">-- SQLite
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="err">姓名</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">COLLATE</span><span class="w"> </span><span class="n">NOCASE</span><span class="w">                      </span><span class="c1">-- DuckDB
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="err">姓名</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">COLLATE</span><span class="w"> </span><span class="n">ci</span><span class="w">                          </span><span class="c1">-- PostgreSQL，ci 要先建成一條非確定性的規則：
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="w">    </span><span class="c1">-- CREATE COLLATION ci (provider = icu, locale = &#39;und-u-ks-level2&#39;, deterministic = false);
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="err">姓名</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span><span class="w"> </span><span class="k">COLLATE</span><span class="w"> </span><span class="n">utf8mb4_0900_ai_ci</span><span class="w">   </span><span class="c1">-- MySQL</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="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">2</span><span class="cl"><span class="w"></span><span class="c1">-- 四家都回 101、102：金額 IS NULL 對空的那一列是真，真排在假後面
</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="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">4</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite、DuckDB、PostgreSQL：101、102
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- MySQL 8.4：ERROR 1064 語法錯誤</span></span></span></code></pre></div><p>所以可攜性的操作對象多半是<strong>那些原本讓引擎代為決定的位置</strong>，而不是查詢本身。判斷標準是一句話：<strong>這段文字裡有沒有一個決定是它沒說、而由收下它的那一方補上的。</strong> 有，那個位置就是可攜性的斷點，不論它現在跑得多正常。這個判斷標準只涵蓋「有人補上的決定」；另一層是沒有任何一家引擎會代為決定的——查詢問對了沒有。那一層與寬鬆度無關，任何一家引擎的設定都管不到它，<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 寫它的判斷標準為什麼只能來自查詢之外。</p>
<p>「有沒有一個決定由收下它的那一方補上」這個判斷標準也解釋了為什麼可攜性在<strong>單一一組設定</strong>上測不出來——同一段查詢跑一萬次，那個被補上的決定每一次都一樣。「單一引擎」還不夠：同一個 MySQL 實例上，一條連線 <code>SET SESSION sql_mode = ''</code>、另一條用預設，同一段帶未分組欄位的查詢一邊回結果、一邊回 <code>ERROR 1055</code>。可攜性是查詢與<strong>那一組組態</strong>之間的性質，不是查詢的性質。</p>
<h2 id="寫的當下要處理哪幾級差異按目標組態數分的做法">寫的當下要處理哪幾級差異：按目標組態數分的做法</h2>
<p>「一律照最嚴格的標準寫」這條路有價錢：每一個原本可以讓引擎決定的位置都要自己寫出來，查詢變長，寫的人要知道每一條預設是什麼。這筆錢值不值得花，主要落在一個可以現在就數出來的量上——<strong>這段 SQL 現在跑在幾種組態上</strong>。「組態」指一家引擎的某個版本配上一組設定：換一家算一種，同一家換一個大版本或換一組連線設定也算一種，所以升級一次 MySQL 與換去 PostgreSQL 在組態數上是同一類事。</p>
<p>數出來是一的時候，還要問第二句：<strong>這個一會不會被別人改成二</strong>。數出來已經大於一的時候這一句不必問，答案在成本上沒有分別。「會不會被別人改成二」這個變數比「現在跑在幾種組態上」難察覺，因為它不在系統裡：資料庫由自己選的系統，與資料庫由客戶指定的系統，現在可能都只跑在一種組態上，而它們對同一段 SQL 的要求完全不同——資料庫由自己選的系統可以把整筆帳推遲，資料庫由客戶指定的系統推遲不了。</p>
<p><strong>現在只有一種，而換不換由自己決定的時候</strong>，寬鬆度是引擎給的便利。代價全部推遲到換引擎的那一天，而那一天的工作量由靜默而輸出不同、靜默且輸出相同這兩級差異的數量決定——當場報錯的那些第一次執行就會出現。這條路的風險不在「會不會換引擎」，在<strong>換的時候有沒有辦法知道要查哪些位置</strong>：推遲的不只是工作，還有那份清單。</p>
<p><strong>現在只有一種，而換不換由別人決定的時候</strong>（客戶指定的資料庫、或遲早要跟上的版本升級），那一天會來而日期不在自己手上，所以成本前置到寫的當下——但只前置靜默而輸出不同、靜默且輸出相同這兩級差異：把引擎自己補的決定寫出來（collation、空值的位置、識別字的引號）。當場報錯的差異留著不動，因為它搬家時會當場現身。這條路把「什麼時候知道」從搬家那一天提前到編譯那一刻，而付的是幾個子句的長度。</p>
<p><strong>現在就不只一種的時候</strong>（產品裝在客戶各自的資料庫上、或測試與正式本來就不同家——測試與正式不同家是自己選的，裝在客戶的資料庫上不是，而在組態數大於一這個條件下兩者的成本相同），答案要完全一致，而完全一致無法只靠寫出那些決定——還要一份跨引擎的對照，同一批查詢在目標引擎上各跑一次、比對輸出。這一步的成本是持續的（每次改查詢都要重跑），換到的是靜默而輸出不同的差異不再靠人記得去查。<strong>而它有一個取得性的問題正好落在最需要它的那個情形上</strong>：產品裝在客戶的資料庫上時，客戶那一組版本加設定多半拿不到也不見得授權得起，這時退而求其次的是把自己手上那幾種組態的對照跑起來，並把「我們驗過哪幾種組態」寫成一份可以交出去的清單。跨引擎的對照無法由閱讀代替，理由與 <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>那一層相同：宣稱的真值住在文字之外。</p>
<p><strong>還有一條路不看組態數</strong>：用查詢建構器或物件關聯對映把 SQL 包起來。按組態數分的那三種做法是自己決定哪幾種差異要提前處理，這一條是把整個決定交出去——那一層自己要對每一家引擎做同樣的決定，而它做了哪些決定通常沒有寫在讀得到的地方。所以它換掉的是「誰來查這些引擎代為決定的位置」，不是「有沒有這些位置」。</p>
<h2 id="按組態數做的選擇成立的前提組態清單引擎設定與測試環境">按組態數做的選擇成立的前提：組態清單、引擎設定與測試環境</h2>
<p>按組態數做的選擇綁在幾個前提上，前提換了就要重算。</p>
<p><strong>目標組態的清單會變長。</strong> 維持一致的成本隨組態數量成長，而不是隨查詢數量成長，所以清單變長那一刻的重算幅度比想像中大。讓清單變長的事件多半來自外部——客戶要求裝在自己的資料庫上、併購進來一套用別家引擎的系統，或者一次跨大版本的升級。升級這一種特別容易被漏掉，因為它在直覺上不算「換引擎」，而上面那組寫法裡至少有一個（未分組欄位）收不收由 <code>sql_mode</code> 決定，這個變數的預設值跟著版本走，換了版本，收不收就可能跟著換。清單變長時，各家引擎對同一個寫法收不收的差異只是遷移要處理的其中一項：換引擎或把併購進來的系統搬過來，另外還有資料的搬運、雙寫與切換；每一步要留下什麼證明，<a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">Schema Migration Rollout 證據</a> 用一次同庫內的欄位拆分示範證據長什麼樣。</p>
<p><strong>引擎的設定會變。</strong> 未分組欄位在 MySQL 上收不收由 <code>sql_mode</code> 決定，而那個變數可以在連線層設定——同一個資料庫、同一段查詢，換一個連線設定就換一邊。這一類的差異不在查詢裡也不在 schema 裡，所以讀程式碼看不到它。</p>
<p><strong>測試環境與正式環境要用同一家引擎。</strong> 測試跑 SQLite、正式跑 PostgreSQL 是最常見的一種不同家的配對，而靜默而輸出不同、靜默且輸出相同這兩級差異在這種配對下會延到正式環境才第一次浮現——那時它們看起來像資料問題。</p>
<h2 id="跨引擎的一致之外文字與引擎行為的一致">跨引擎的一致之外：文字與引擎行為的一致</h2>
<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>，它處理文字說的與引擎做的分岔時，下一個讀者會被帶到哪裡。</p>
]]></content:encoded></item><item><title>SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/</guid><description>&lt;p>一段查詢有兩個讀者。引擎讀的是行為——這段文字要求它算出哪一批列；人讀的是宣告——寫的人選了 &lt;code>LEFT&lt;/code> 而不選 &lt;code>INNER&lt;/code>，選了 &lt;code>CROSS&lt;/code> 而不選 &lt;code>JOIN ... ON&lt;/code>，每一個選擇都在說明他預期資料長什麼樣。&lt;strong>引擎執行行為，而它從不檢查宣告有沒有兌現。&lt;/strong>&lt;/p>
&lt;p>宣告與行為分岔的時候，查詢照常回正確答案，測試照常通過，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>上也看不出異常。付出代價的是下一個改它的人：他讀到的是宣告，於是他手上的前提從一開始就是錯的。宣告與行為之外，還有一種落差在查詢與要問的問題之間：查詢在語法上完全合法、引擎照實執行，而它回答的不是要問的那一題，那一種在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>；同一段文字換一家引擎會做什麼，是跨引擎的那一種，在 &lt;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;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，金額 300 與 500）。讀這一篇需要先有三件事：&lt;code>LEFT&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;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;/p>
&lt;h2 id="各個關鍵字的宣告與落空的形態">各個關鍵字的宣告與落空的形態&lt;/h2>
&lt;p>&lt;code>LEFT JOIN&lt;/code> 說的是「右邊配不到對象的左邊那些列也要留著」。寫的人選它，等於宣告他預期有配不到的列，而且那些列對答案有意義。&lt;/p>
&lt;p>&lt;code>CROSS JOIN&lt;/code> 說的是「我要每一種配對」。&lt;code>DISTINCT&lt;/code> 說的是「這一組值本身會重複，而重複對我沒有意義」。&lt;code>COALESCE&lt;/code> 說的是「這裡會有空值」。表的別名也是一種宣告：&lt;code>FROM 訂單 AS 早&lt;/code> 說的是「這一次出現代表較早的那張單」。這些宣告是免費的，寫下去沒有人會查證。&lt;/p>
&lt;p>落空的樣子不只一種。宣告可能&lt;strong>被同一段查詢的別的部分撤銷&lt;/strong>（&lt;code>LEFT&lt;/code> 加上一個問右表的 &lt;code>WHERE&lt;/code>）、可能&lt;strong>描述一個引擎並沒有執行的動作&lt;/strong>（&lt;code>CROSS&lt;/code> 加等值條件）、可能&lt;strong>描述一個從一開始就不存在的情況&lt;/strong>（配對已有保證時的 &lt;code>LEFT&lt;/code>），也可能&lt;strong>指向錯的成因而答案仍然正確&lt;/strong>（&lt;code>DISTINCT&lt;/code> 收掉連接製造的重複），還可能&lt;strong>名字與它代表的那一次出現對調&lt;/strong>（叫「早」的別名拿到後來那張單，條件跟著對調，結果不變，這個例子在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接&lt;/a>）。這幾種落空在現有資料上都回正確答案，代價都落在下一個照著宣告動手的人身上：&lt;code>DISTINCT&lt;/code> 指向錯成因，他多要一個欄位就回出錯的結果；被撤銷的 &lt;code>LEFT&lt;/code> 讓他改了 &lt;code>SELECT&lt;/code> 之後仍然少了沒有訂單的顧客，而原因在他沒有動的 &lt;code>WHERE&lt;/code>；其餘幾種連錯的結果都不會出現，只讓他下錯判斷。&lt;/p>
&lt;h2 id="被-where-撤銷的-left-join">被 WHERE 撤銷的 LEFT JOIN&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>&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">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"> 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="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&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">-- 佳穎、佳穎
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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">-- 同一段把 LEFT JOIN 換成 JOIN，其餘不動
&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="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"> 8&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"> 9&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;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&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">-- 佳穎、佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>LEFT&lt;/code> 在這裡宣告「沒有訂單的顧客也要留著」。而下一行的 &lt;code>WHERE&lt;/code> 問了一個右表欄位，宗翰與雅文那兩列右邊全是 &lt;code>NULL&lt;/code>，&lt;code>NULL &amp;gt; 200&lt;/code> 答不出真，於是被篩掉，外連接與內連接兩段因此回同樣兩列。&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;code>ON&lt;/code> 參與配對，放 &lt;code>WHERE&lt;/code> 篩成品，而外連接補上的那些列過不了成品這一關。這裡要看的是另一件事——&lt;strong>這段查詢的行為是內連接，而它的文字說的是外連接&lt;/strong>。&lt;/p>
&lt;p>下一個維護的人讀到 &lt;code>LEFT&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="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">COALESCE&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="mi">0&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">-- 他加的欄位：沒有訂單的顧客顯示 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">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">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="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&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">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎 300
&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">-- 佳穎 500&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑出來仍然只有佳穎，而他找不出原因——因為問題在他沒有動的那一行。&lt;/p></description><content:encoded><![CDATA[<p>一段查詢有兩個讀者。引擎讀的是行為——這段文字要求它算出哪一批列；人讀的是宣告——寫的人選了 <code>LEFT</code> 而不選 <code>INNER</code>，選了 <code>CROSS</code> 而不選 <code>JOIN ... ON</code>，每一個選擇都在說明他預期資料長什麼樣。<strong>引擎執行行為，而它從不檢查宣告有沒有兌現。</strong></p>
<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/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>；同一段文字換一家引擎會做什麼，是跨引擎的那一種，在 <a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a>。本篇的宣告落空時答案仍然正確，錯的是文字對下一個讀它的人說的話。</p>
<p>本篇的查詢跑在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫</a>上：三位顧客佳穎、宗翰、雅文，而只有佳穎下過兩張訂單（101 與 102，金額 300 與 500）。讀這一篇需要先有三件事：<code>LEFT</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>，它與條件位置的互動在 <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>。那三篇答的是「這個關鍵字做什麼」，這一篇不重寫那些機制，它問的是同一段查詢對下一個讀它的人說了什麼。</p>
<h2 id="各個關鍵字的宣告與落空的形態">各個關鍵字的宣告與落空的形態</h2>
<p><code>LEFT JOIN</code> 說的是「右邊配不到對象的左邊那些列也要留著」。寫的人選它，等於宣告他預期有配不到的列，而且那些列對答案有意義。</p>
<p><code>CROSS JOIN</code> 說的是「我要每一種配對」。<code>DISTINCT</code> 說的是「這一組值本身會重複，而重複對我沒有意義」。<code>COALESCE</code> 說的是「這裡會有空值」。表的別名也是一種宣告：<code>FROM 訂單 AS 早</code> 說的是「這一次出現代表較早的那張單」。這些宣告是免費的，寫下去沒有人會查證。</p>
<p>落空的樣子不只一種。宣告可能<strong>被同一段查詢的別的部分撤銷</strong>（<code>LEFT</code> 加上一個問右表的 <code>WHERE</code>）、可能<strong>描述一個引擎並沒有執行的動作</strong>（<code>CROSS</code> 加等值條件）、可能<strong>描述一個從一開始就不存在的情況</strong>（配對已有保證時的 <code>LEFT</code>），也可能<strong>指向錯的成因而答案仍然正確</strong>（<code>DISTINCT</code> 收掉連接製造的重複），還可能<strong>名字與它代表的那一次出現對調</strong>（叫「早」的別名拿到後來那張單，條件跟著對調，結果不變，這個例子在 <a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>）。這幾種落空在現有資料上都回正確答案，代價都落在下一個照著宣告動手的人身上：<code>DISTINCT</code> 指向錯成因，他多要一個欄位就回出錯的結果；被撤銷的 <code>LEFT</code> 讓他改了 <code>SELECT</code> 之後仍然少了沒有訂單的顧客，而原因在他沒有動的 <code>WHERE</code>；其餘幾種連錯的結果都不會出現，只讓他下錯判斷。</p>
<h2 id="被-where-撤銷的-left-join">被 WHERE 撤銷的 LEFT JOIN</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></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="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">&gt;</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"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、佳穎
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 同一段把 LEFT JOIN 換成 JOIN，其餘不動
</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></span><span class="line"><span class="ln"> 8</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"> 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="o">&gt;</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">10</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、佳穎</span></span></span></code></pre></div><p><code>LEFT</code> 在這裡宣告「沒有訂單的顧客也要留著」。而下一行的 <code>WHERE</code> 問了一個右表欄位，宗翰與雅文那兩列右邊全是 <code>NULL</code>，<code>NULL &gt; 200</code> 答不出真，於是被篩掉，外連接與內連接兩段因此回同樣兩列。</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>：條件放 <code>ON</code> 參與配對，放 <code>WHERE</code> 篩成品，而外連接補上的那些列過不了成品這一關。這裡要看的是另一件事——<strong>這段查詢的行為是內連接，而它的文字說的是外連接</strong>。</p>
<p>下一個維護的人讀到 <code>LEFT</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="n">COALESCE</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="mi">0</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">-- 他加的欄位：沒有訂單的顧客顯示 0
</span></span></span><span class="line"><span class="ln">2</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">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">&gt;</span><span class="w"> </span><span class="mi">200</span><span class="p">;</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">-- 佳穎 300
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 佳穎 500</span></span></span></code></pre></div><p>跑出來仍然只有佳穎，而他找不出原因——因為問題在他沒有動的那一行。</p>
<h2 id="cross-join-加等值條件與內連接的計畫">CROSS JOIN 加等值條件與內連接的計畫</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></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="err">昨天</span><span class="w"> </span><span class="k">CROSS</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">今天</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="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">&gt;</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">金額</span><span class="p">;</span></span></span></code></pre></div><p><code>CROSS JOIN</code> 宣告「我要每一種配對」，讀起來像是先造出所有配對再挑。而等值條件寫進去之後，<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>產出的計畫與內連接的計畫一模一樣。改寫成 <code>JOIN ... ON</code> 的版本把同一個條件搬進 <code>ON</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></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="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="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">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">&gt;</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">-- 102（CROSS JOIN 那一段也回 102）</span></span></span></code></pre></div><p>同一組資料上兩種寫法的計畫逐字相同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- CROSS JOIN 加 WHERE 等值（SQLite 3.51，共用的範例資料庫，沒有另外建索引）
</span></span><span class="line"><span class="ln">2</span><span class="cl">|--SCAN 昨天
</span></span><span class="line"><span class="ln">3</span><span class="cl">|--BLOOM FILTER ON 今天 (顧客編號=?)
</span></span><span class="line"><span class="ln">4</span><span class="cl">`--SEARCH 今天 USING AUTOMATIC COVERING INDEX (顧客編號=?)
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">-- JOIN ... ON 同一個條件
</span></span><span class="line"><span class="ln">7</span><span class="cl">|--SCAN 昨天
</span></span><span class="line"><span class="ln">8</span><span class="cl">|--BLOOM FILTER ON 今天 (顧客編號=?)
</span></span><span class="line"><span class="ln">9</span><span class="cl">`--SEARCH 今天 USING AUTOMATIC COVERING INDEX (顧客編號=?)</span></span></code></pre></div><p><code>AUTOMATIC</code> 表示這個索引是引擎為了這一次查詢臨時建的——訂單表上沒有 <code>顧客編號</code> 的索引，而它判斷建一個比逐列掃描划算。兩段查詢連這個決定都一樣。</p>
<p><code>CROSS JOIN</code> 在代數上確實對應<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積</a>，而那張卡寫明展開由條件決定：條件把兩邊綁起來時全部組合一次都沒有被造出來。</p>
<p><code>CROSS JOIN</code> 這個宣告的誤導方向與 <code>LEFT</code> 相反。<code>LEFT</code> 那一節是宣告承諾了引擎沒做的事，這一節是宣告讓讀的人誤判了代價——看到 <code>CROSS</code> 就以為它是列數的平方，於是花力氣改寫一段本來就是內連接的查詢。兩段文字比不出快慢，快慢要向引擎要計畫——<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a> 在有無索引的兩種狀態下各量一次；而在這裡要先分清楚：<code>CROSS JOIN</code> 加等值條件與 <code>JOIN ... ON</code> 是同一段查詢的兩種寫法，換掉它省下的是讀的人的疑慮，不是執行時間。</p>
<h2 id="配對已有保證時的-left-join">配對已有保證時的 LEFT JOIN</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="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">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></span></code></pre></div><p><code>LEFT</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">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="c1">-- count(*) 數列數，count(欄位) 只數那一欄不是 NULL 的列
</span></span></span><span class="line"><span class="ln">2</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="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">-- 2  2：沒有任何一列是 LEFT 補出來的</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></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="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">-- 配不到顧客的訂單，姓名會是 LEFT 補上的 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>拿到零列，於是回報「資料乾淨」。零列在這裡有兩種來源：確實沒有孤兒訂單，或者這個宣告本來就沒有內容。這段查詢分不出兩者。</p>
<p>真正保證配對的是<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>——外鍵存在時每一張訂單都指得到顧客，那時 <code>LEFT</code> 描述的是一個資料庫已經排除掉的情況。宣告與約束衝突時，讀的人多半相信宣告，因為它就在眼前，而約束在另一個檔案裡。</p>
<h2 id="收掉連接重複的-distinct">收掉連接重複的 DISTINCT</h2>
<p>顧客與訂單連起來之後，佳穎出現兩次，因為她有兩張訂單。要一份「下過單的顧客」名單，加 <code>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="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</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">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">-- 佳穎</span></span></span></code></pre></div><p><code>DISTINCT</code> 宣告「姓名這一欄有重複值」。而顧客表裡佳穎只有一列——重複是連接製造出來的（<a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。宣告指向了錯的成因，答案卻是對的。</p>
<p>這種宣告落空的代價由下一次修改觸發，而觸發它的動作很小——多要一個欄位就夠：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="err">姓名</span><span class="p">,</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">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">-- 佳穎 300
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- 佳穎 500</span></span></span></code></pre></div><p><code>DISTINCT</code> 作用在整列上，兩列的金額不同，於是它一列都收不掉。上一個人的修法在這裡失效，而失效的理由要回到「重複從哪裡來」這個問題——那個問題當初被 <code>DISTINCT</code> 跳過了。</p>
<h2 id="查證宣告的動作把關鍵字換成最弱的版本再跑一次">查證宣告的動作：把關鍵字換成最弱的版本再跑一次</h2>
<p>文字本身看不出宣告有沒有兌現——<code>LEFT</code> 寫在那裡，而它保護下來的列可能一列都不剩。差別要現形，得讓 <code>LEFT</code> 這類關鍵字消失一次：<strong>把它換成最弱的版本再跑一次，結果變不變就是它有沒有在做事。</strong> 這個動作在軟體測試裡叫 <a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">mutation testing</a>：在現有資料上結果不變，是那張卡說的突變「存活」；它是不是等價突變（改寫之後行為在任何資料上都相同），要看造不造得出一筆讓改寫前後回不同答案的反例資料（<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 用的名字與造法）。</p>
<table>
  <thead>
      <tr>
          <th>宣告</th>
          <th>換成</th>
          <th>結果不變表示</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>LEFT JOIN</code> / <code>RIGHT JOIN</code></td>
          <td><code>JOIN</code></td>
          <td>在現有資料上行為是內連接</td>
      </tr>
      <tr>
          <td><code>CROSS JOIN</code> 加 <code>WHERE</code> 等值</td>
          <td><code>JOIN ... ON</code> 同一個條件</td>
          <td>這段本來就是內連接（限等值）</td>
      </tr>
      <tr>
          <td><code>DISTINCT</code></td>
          <td>拿掉</td>
          <td>這一組值原本就各不相同</td>
      </tr>
      <tr>
          <td><code>COALESCE(欄位, 預設值)</code></td>
          <td>直接用該欄位</td>
          <td>這一欄在現有資料上沒有空值</td>
      </tr>
  </tbody>
</table>
<p>結果變了的時候，換掉關鍵字這個動作只證明被換掉的那個關鍵字在做事，還沒證明宣告是對的。<code>DISTINCT</code> 那一格就是這種：拿掉之後結果會變，而變的理由是連接把列複製了，與「這一欄有重複值」無關。所以結果變了之後還要問：<strong>變化的成因與宣告說的是不是同一件事</strong>。</p>
<p>配對已有保證時的 <code>LEFT</code>（〈配對已有保證時的 LEFT JOIN〉那一節）是換掉關鍵字再跑一次這個動作測不出來的：配對由外鍵保證的時候，<code>LEFT</code> 與 <code>JOIN</code> 在現有資料上結果相同，而換掉之後仍然看不出宣告當初有沒有意義。那一種要去查<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>，判斷標準是資料庫有沒有替這個配對背書——而約束寫進 schema 也不一定生效，各家在哪一步把寫下與生效分開，在 <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>。</p>
<p>對讀別人查詢的人來說，這個寫下與生效是否一致的差別回答的是「我可以相信這段文字說的話嗎」——而那個問題在讀的當下沒有別的入口。文字該先為哪一個讀者而寫——人還是引擎——是 <a href="/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形</a> 的題目。</p>
]]></content:encoded></item><item><title>SQL.21 可讀性與執行代價：兩者一致與分岔的情形</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/readable-and-fast-mostly-align/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/readable-and-fast-mostly-align/</guid><description>&lt;p>一段 SQL 同時要給引擎執行與給人維護，而這兩個需求在 SQL 上多數時候指向同一個寫法。理由落在這個語言的性質上：&lt;strong>引擎執行的是計畫，不是那段文字&lt;/strong>（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定&lt;/a>），所以文字裡為了讓人看懂而多出來的結構，只要最佳化器攤得平就在計畫上留不下痕跡。決定執行次數的結構是例外——相關子查詢與 CTE 都屬於這一類，後面那一節量的正是它。&lt;/p>
&lt;p>這一篇與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a> 都問文字寫給誰。那一篇問文字對讀的人說了什麼——宣告落空的幾種形態與查證它的動作——這一篇問文字該為誰而寫。兩篇底下還有一層是答案本身對不對，引擎對那一層沒有回報的管道，好讀與否也碰不到它，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a> 寫它為什麼不報錯。&lt;/p>
&lt;h2 id="計畫相同或語意相同的寫法差異">計畫相同或語意相同的寫法差異&lt;/h2>
&lt;p>同一批列的兩種寫法送進&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&lt;/a>之後常常收斂成同一個計畫。&lt;code>CROSS JOIN&lt;/code> 加等值條件與 &lt;code>JOIN ... ON&lt;/code> 寫同一個條件，計畫逐字相同（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a> 有並排的輸出）。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理&lt;/a> 從另一條路走到同一個處置：肯定式的 &lt;code>IN&lt;/code> 與 &lt;code>EXISTS&lt;/code> 語意相同，所以選哪一個按可讀性挑。它的依據是語意而非計畫：這兩種寫法在 SQLite 上各自走不同的計畫，而語意相同已經足以讓選擇落在可讀性上。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 共用資料：下過單的顧客，兩段都回 佳穎
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">EXPLAIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">QUERY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PLAN&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">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="k">IN&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="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="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">-- SCAN 顧客
&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">-- LIST SUBQUERY 1：子查詢跑一次，把顧客編號收成一份清單
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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">EXPLAIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">QUERY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PLAN&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">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="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- SCAN 顧客
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">-- CORRELATED SCALAR SUBQUERY 1：子查詢對顧客的每一列各跑一次&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一個寫法差異免不免費，判斷起來很省事——兩種寫法各要一次計畫，一樣就按好讀的挑。&lt;strong>這一步預設了拿得到計畫&lt;/strong>；拿不到的時候本篇的方法整個用不上，剩下的是條件形狀那一層的排除法（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable&lt;/a>）。&lt;/p>
&lt;h2 id="可讀性與代價分岔的位置條件的形狀">可讀性與代價分岔的位置：條件的形狀&lt;/h2>
&lt;p>本篇量到寫法影響代價的位置有兩處，第一處是條件的形狀：條件把索引欄位包進函式或運算裡的時候，索引的查找就用不上了（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable&lt;/a>）。而那個寫法常常正是好讀的那一個。&lt;/p>
&lt;p>三十萬列的訂單表，一年的日期平均分佈而三月佔兩萬五千列上下，&lt;code>下單日&lt;/code> 上有索引：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">RECURSIVE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ALL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">299999&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="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2021-01-01&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">365&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="s1">&amp;#39; day&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>問三月有幾張訂單（SQLite 3.51，&lt;code>SELECT count(*)&lt;/code>，各跑二十次取最小，計時器解析度一毫秒）：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>寫法&lt;/th>
 &lt;th>時間&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>strftime('%Y-%m',下單日) = '2021-03'&lt;/code>&lt;/td>
 &lt;td>49 ms&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>下單日 &amp;gt;= '2021-03-01' AND 下單日 &amp;lt; '2021-04-01'&lt;/code>&lt;/td>
 &lt;td>不到 1 ms&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>strftime&lt;/code> 那一行直接說出「這一列屬於哪個年月」，日期範圍那一行要讀的人自己把兩個邊界拼回一個月份，還要留意右邊是開區間。兩種寫法的執行時間差距大到日期範圍寫法落在計時器的解析度以下，而好讀的是慢的那一個。&lt;/p></description><content:encoded><![CDATA[<p>一段 SQL 同時要給引擎執行與給人維護，而這兩個需求在 SQL 上多數時候指向同一個寫法。理由落在這個語言的性質上：<strong>引擎執行的是計畫，不是那段文字</strong>（<a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定</a>），所以文字裡為了讓人看懂而多出來的結構，只要最佳化器攤得平就在計畫上留不下痕跡。決定執行次數的結構是例外——相關子查詢與 CTE 都屬於這一類，後面那一節量的正是它。</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> 都問文字寫給誰。那一篇問文字對讀的人說了什麼——宣告落空的幾種形態與查證它的動作——這一篇問文字該為誰而寫。兩篇底下還有一層是答案本身對不對，引擎對那一層沒有回報的管道，好讀與否也碰不到它，<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 寫它為什麼不報錯。</p>
<h2 id="計畫相同或語意相同的寫法差異">計畫相同或語意相同的寫法差異</h2>
<p>同一批列的兩種寫法送進<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>之後常常收斂成同一個計畫。<code>CROSS JOIN</code> 加等值條件與 <code>JOIN ... ON</code> 寫同一個條件，計畫逐字相同（<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a> 有並排的輸出）。<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> 從另一條路走到同一個處置：肯定式的 <code>IN</code> 與 <code>EXISTS</code> 語意相同，所以選哪一個按可讀性挑。它的依據是語意而非計畫：這兩種寫法在 SQLite 上各自走不同的計畫，而語意相同已經足以讓選擇落在可讀性上。</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 class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</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="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">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">-- SCAN 顧客
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">-- LIST SUBQUERY 1：子查詢跑一次，把顧客編號收成一份清單
</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">EXPLAIN</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</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="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"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- SCAN 顧客
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- CORRELATED SCALAR SUBQUERY 1：子查詢對顧客的每一列各跑一次</span></span></span></code></pre></div><p>一個寫法差異免不免費，判斷起來很省事——兩種寫法各要一次計畫，一樣就按好讀的挑。<strong>這一步預設了拿得到計畫</strong>；拿不到的時候本篇的方法整個用不上，剩下的是條件形狀那一層的排除法（<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable</a>）。</p>
<h2 id="可讀性與代價分岔的位置條件的形狀">可讀性與代價分岔的位置：條件的形狀</h2>
<p>本篇量到寫法影響代價的位置有兩處，第一處是條件的形狀：條件把索引欄位包進函式或運算裡的時候，索引的查找就用不上了（<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable</a>）。而那個寫法常常正是好讀的那一個。</p>
<p>三十萬列的訂單表，一年的日期平均分佈而三月佔兩萬五千列上下，<code>下單日</code> 上有索引：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span 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">2</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">299999</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="err">訂單</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">500</span><span class="p">,</span><span class="w"> </span><span class="nb">date</span><span class="p">(</span><span class="s1">&#39;2021-01-01&#39;</span><span class="p">,</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">365</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39; day&#39;</span><span class="p">),</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">900</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">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></span></code></pre></div><p>問三月有幾張訂單（SQLite 3.51，<code>SELECT count(*)</code>，各跑二十次取最小，計時器解析度一毫秒）：</p>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>strftime('%Y-%m',下單日) = '2021-03'</code></td>
          <td>49 ms</td>
      </tr>
      <tr>
          <td><code>下單日 &gt;= '2021-03-01' AND 下單日 &lt; '2021-04-01'</code></td>
          <td>不到 1 ms</td>
      </tr>
  </tbody>
</table>
<p><code>strftime</code> 那一行直接說出「這一列屬於哪個年月」，日期範圍那一行要讀的人自己把兩個邊界拼回一個月份，還要留意右邊是開區間。兩種寫法的執行時間差距大到日期範圍寫法落在計時器的解析度以下，而好讀的是慢的那一個。</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">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="n">strftime</span><span class="p">(</span><span class="s1">&#39;%Y-%m&#39;</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></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 查詢與表格裡 strftime 那一行相同
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">EXPLAIN</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</span><span class="w">
</span></span></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="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">WHERE</span><span class="w"> </span><span class="n">strftime</span><span class="p">(</span><span class="s1">&#39;%Y-%m&#39;</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="s1">&#39;2021-03&#39;</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">-- 建 ix_月 之前：SCAN 訂單 USING COVERING INDEX ix_日（整份索引掃過一遍）
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">-- 建 ix_月 之後：SEARCH 訂單 USING COVERING INDEX ix_月 (&lt;expr&gt;=?)（直接查到那個月份）</span></span></span></code></pre></div><p>再量一次，時間也落在計時器的解析度以下。原本的查詢一個字都沒有改。</p>
<p>所以碰到這種分岔時，第一個問題是<strong>這個代價能不能在 schema 那一層買掉</strong>，而不是要不要把查詢寫醜。索引的代價落在寫入端（<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>那張卡寫這一條），那是一個可以評估的交換；把查詢改成需要讀的人自己拼日期範圍的邊界，換到的速度一樣，而付出的是往後每一次維護。</p>
<h2 id="相關子查詢與拆成-cte-的實測">相關子查詢與拆成 CTE 的實測</h2>
<p>一般語言裡「拆成小塊比較好讀但比較慢」是常見的直覺，SQL 上量到的常常相反。同一個問題——「金額高於自己所屬顧客平均的訂單有幾張」——寫成一整段的相關子查詢，與拆成 CTE：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 一整段
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="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="n">o</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="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="p">(</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="err">訂單</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 拆開
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">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="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">7</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="n">o</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">每人平均</span><span class="w"> </span><span class="n">p</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</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">WHERE</span><span class="w"> </span><span class="n">o</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">p</span><span class="p">.</span><span class="err">均</span><span class="p">;</span></span></span></code></pre></div><p>這一篇另外造自己的表，<strong>顧客數固定在 500 而列數翻倍</strong>（<code>N</code> 依序是 5000、10000、20000、40000）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="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">2</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">N</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">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="err">訂單</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">500</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">37</span><span class="p">)</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">900</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">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></span></code></pre></div><p>SQLite 3.51，四個資料量各跑二十次取最小：</p>
<table>
  <thead>
      <tr>
          <th>列數</th>
          <th>一整段</th>
          <th>拆成 CTE</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>5000</td>
          <td>9 ms</td>
          <td>1 ms</td>
      </tr>
      <tr>
          <td>10000</td>
          <td>33 ms</td>
          <td>3 ms</td>
      </tr>
      <tr>
          <td>20000</td>
          <td>126 ms</td>
          <td>7 ms</td>
      </tr>
      <tr>
          <td>40000</td>
          <td>551 ms</td>
          <td>15 ms</td>
      </tr>
  </tbody>
</table>
<p>列數翻倍，一整段那一欄的三段比值是 3.7、3.8、4.4，而 CTE 那一欄最後一段是 2.1——列數翻倍、時間跟著翻倍。CTE 那一欄前兩段的比值貼著計時器的解析度，讀不出來。<strong>這個結論依賴顧客數固定這個條件</strong>：列數翻倍而顧客數不動，等於每位顧客的訂單數也翻倍，相關子查詢每一列要掃的量因此跟著漲；顧客數若隨列數一起成長，那一欄的成長會平緩得多。兩段查詢的計畫說明了原因：相關子查詢對外層的每一列各執行一次（<a href="/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">子查詢</a>那張卡寫這個區別），而 CTE 把每位顧客的平均算成一次，再拿去連接。</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">SCAN o
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">CORRELATED SCALAR SUBQUERY 1                   對外層 o 的每一列各執行一次
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">   SEARCH i USING INDEX ix_顧 (顧客編號=?)
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">拆成 CTE
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">CO-ROUTINE 每人平均                             每位顧客的平均算一次
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   SCAN 訂單 USING INDEX ix_顧
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">SCAN p
</span></span><span class="line"><span class="ln">10</span><span class="cl">SEARCH o USING INDEX ix_顧 (顧客編號=?)         拿每人平均去連接訂單</span></span></code></pre></div><p><strong>拆開讓引擎看得見「這個平均每位顧客只有一個」這件事</strong>，而寫成相關子查詢等於把它藏起來。人讀得懂的結構，引擎往往也算得動。</p>
<h2 id="為速度扭曲寫法所依賴的資料庫狀態">為速度扭曲寫法所依賴的資料庫狀態</h2>
<p>真要在好讀的寫法與為速度扭曲的寫法之間排序的話，理由落在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a> 已經證過的一件事上：同一組寫法的快慢排名會隨索引對調——那一篇拿一道重複值的題目，示範加一個索引之後最慢的寫法變成最快之一，而查詢的文字一個字沒改。<strong>為當下的代價而扭曲的文字，在別人加了一個索引之後就失去理由，而那段文字會留著。</strong> 可讀性沒有這個問題——它不隨資料庫的狀態改變。</p>
<p>這也對得上優化該動哪裡：<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a> 列的那四項（資料量、分布、索引、統計資訊）全部是資料庫本身的狀態，不在查詢的文字裡，所以調整的位置在 schema 與計畫，而查詢的文字負責把意圖說清楚。</p>
<h2 id="schema-動不了時為速度改寫查詢成立的條件與會遺失的理由">schema 動不了時為速度改寫查詢：成立的條件與會遺失的理由</h2>
<p>為速度寫醜只在 schema 那一層動不了的時候有理由：唯讀複本、改不動的 vendor schema、或索引的寫入代價付不起，那時文字層是唯一能動的地方。</p>
<p><strong>難讀的寫法自己不會說明它為什麼難讀</strong>，而下一個人看到的只是一段可以「順手整理一下」的查詢。那個約束是這段文字唯一的存在理由，而它不在查詢裡的任何一個位置上——整理掉的時候會遺失的正是它（哪一層動不了、量測差多少）。</p>
]]></content:encoded></item><item><title>SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/requirement-to-shipped-query/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/requirement-to-shipped-query/</guid><description>&lt;p>本篇的對象是一句需求——「&lt;strong>想知道哪幾天氣溫比前一天高&lt;/strong>」——以及它到一段跑在正式資料上的查詢之間要做的每一個決定。涵蓋從這句話開始、到查詢交付為止，從追問需求到交付全部在同一個需求上走完；每個決定背後的機制在這裡只用到為止，展開在各自的專篇。&lt;/p>
&lt;p>這句需求離一段可以執行的查詢還隔著幾個決定，而那些決定的答案都不在資料庫裡，也不在 SQL 裡。&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>——氣溫資料需要缺測與多測站才顯示得出要講的那幾件事。輸出都是實際跑出來的，引擎是 SQLite 3.51.0。&lt;/p>
&lt;h2 id="寫查詢之前的追問結果的用途前一天的定義缺測與測站數">寫查詢之前的追問：結果的用途、「前一天」的定義、缺測與測站數&lt;/h2>
&lt;p>下面四個追問，答案要在寫下任何一段查詢之前拿到，因為每一個的答案都會換掉後面全部的東西：&lt;/p>
&lt;p>&lt;strong>這個結果要餵給哪一個決定。&lt;/strong> 是要在超過門檻時發告警、是月報上的一張圖、還是模型的一個特徵。答案決定它跑幾次——一次性的查詢、每天排程一次、還是每次有人開頁面就跑一次。而跑的次數決定代價預算，代價預算決定後面代價量測那張表要看哪一欄。&lt;/p>
&lt;p>&lt;strong>「前一天」是日曆上的前一天，還是資料上的前一筆。&lt;/strong> 這兩個是不同的問題，而&lt;strong>資料完整的時候它們的答案相同&lt;/strong>——所以在開發用的乾淨資料上分不出來，要等到缺測發生。&lt;/p>
&lt;p>&lt;strong>缺一天的時候要什麼答案。&lt;/strong> 三種都合理：跳過（沒有前一天就不算）、視為斷點（明確標示這裡量不到）、或者補值（用前後內插）。三種對應三段不同的查詢。&lt;/p>
&lt;p>&lt;strong>一個測站還是幾百個。&lt;/strong> 一個的時候整批資料就是一條時間序列；幾百個的時候「前一天」必須限定在同一個測站之內，而漏掉這個限定不會報錯。&lt;/p>
&lt;p>這些追問的答案&lt;strong>不在資料庫裡，也不在 SQL 裡，要去問人&lt;/strong>。而不問的話，每一個追問都會有一個預設答案被偷偷選走：多數人預設這是一次性的查詢、不去想它以後要跑幾次，預設寫成「資料上的前一筆」、預設跳過缺測、預設只有一個測站。&lt;/p>
&lt;h2 id="可驗收的需求敘述與帶缺測的驗收資料">可驗收的需求敘述與帶缺測的驗收資料&lt;/h2>
&lt;p>追問的答案決定了要寫的是哪一段查詢。本篇往下取這一組：要餵給每天一次的排程報表、「前一天」指日曆上的前一天、缺測的那一天不算、而且有很多個測站。&lt;/p>
&lt;p>翻成一句話：&lt;/p>
&lt;blockquote>
&lt;p>對每一個測站，找出所有「同測站、日期剛好早一天」的那筆觀測存在、而且氣溫比它高的那些觀測。&lt;/p>&lt;/blockquote>
&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 的 DROP 一次只收一張表）
&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">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&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"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">氣溫&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="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="err">紀錄編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&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="err">測站&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="err">觀測日&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="err">攝氏&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="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="p">);&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>&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">-- 台北的 3/05 缺測（感測器當天離線），其餘每天一筆
&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">&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="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="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">VALUES&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-01&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">18&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-02&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">22&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-03&amp;#39;&lt;/span>&lt;span class="p">,&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">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-04&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">15&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-06&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">25&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">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-07&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">24&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">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-01&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">26&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-02&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">25&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-03&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">28&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">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-04&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">27&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-05&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">29&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-06&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">30&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">17&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-07&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">28&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>照上面那句話逐筆判，正確答案是四筆：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">台北 2026-03-02 22 &amp;gt; 18
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">高雄 2026-03-03 28 &amp;gt; 25
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">高雄 2026-03-05 29 &amp;gt; 27
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">高雄 2026-03-06 30 &amp;gt; 29&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>台北的 3/06（25 度）不算——它的前一天是 3/05，而那一天沒有觀測。&lt;strong>這一筆是整組資料的判斷標準&lt;/strong>：任何一段回它的查詢，都答錯了上面那句話。&lt;/p>
&lt;h2 id="自連接視窗函數與相關子查詢各自押的前提">自連接、視窗函數與相關子查詢各自押的前提&lt;/h2>
&lt;p>&lt;strong>自連接&lt;/strong>把同一張表當成兩次出現，一次扮演「今天」、一次扮演「昨天」（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接&lt;/a>）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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>&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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">氣溫&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">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">氣溫&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">4&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="w"> &lt;/span>&lt;span class="c1">-- 限定在同一個測站之內
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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="nb">date&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="s1">&amp;#39;-1 day&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">6&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="o">&amp;gt;&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">7&lt;/span>&lt;span class="cl">&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="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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>date(今.觀測日, '-1 day')&lt;/code> 是 SQLite 的日期運算，把字串日期往前推一天。別名 &lt;code>今&lt;/code> 與 &lt;code>昨&lt;/code> 各自標示這一次出現扮演什麼角色——欄位一定要帶前綴，兩次出現共用同一個表名的時候不帶前綴會直接被擋下（&lt;code>ambiguous column name: 測站&lt;/code>）。&lt;/p>
&lt;p>&lt;strong>視窗函數&lt;/strong>沿著排好的順序往前取一列（&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;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="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="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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="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">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">LAG&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="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">w&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">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">julianday&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="n">julianday&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">LAG&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="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">w&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">5&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">6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">WINDOW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">w&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">PARTITION&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="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="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="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">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="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="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="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">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="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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>WINDOW w AS (...)&lt;/code> 把窗的定義取個名字，讓兩個 &lt;code>LAG&lt;/code> 共用同一個窗；&lt;code>julianday()&lt;/code> 把日期換成可以相減的數值，兩筆相差幾天由它算出來。&lt;code>相隔 = 1&lt;/code> 這個條件是必要的——&lt;code>LAG&lt;/code> 交出的是&lt;strong>排序後的相鄰一列&lt;/strong>，而需求要的是&lt;strong>日曆上的前一天&lt;/strong>，兩者只在資料連續時相同。&lt;/p></description><content:encoded><![CDATA[<p>本篇的對象是一句需求——「<strong>想知道哪幾天氣溫比前一天高</strong>」——以及它到一段跑在正式資料上的查詢之間要做的每一個決定。涵蓋從這句話開始、到查詢交付為止，從追問需求到交付全部在同一個需求上走完；每個決定背後的機制在這裡只用到為止，展開在各自的專篇。</p>
<p>這句需求離一段可以執行的查詢還隔著幾個決定，而那些決定的答案都不在資料庫裡，也不在 SQL 裡。</p>
<p>本篇的查詢跑在自己的表上，不用<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫</a>——氣溫資料需要缺測與多測站才顯示得出要講的那幾件事。輸出都是實際跑出來的，引擎是 SQLite 3.51.0。</p>
<h2 id="寫查詢之前的追問結果的用途前一天的定義缺測與測站數">寫查詢之前的追問：結果的用途、「前一天」的定義、缺測與測站數</h2>
<p>下面四個追問，答案要在寫下任何一段查詢之前拿到，因為每一個的答案都會換掉後面全部的東西：</p>
<p><strong>這個結果要餵給哪一個決定。</strong> 是要在超過門檻時發告警、是月報上的一張圖、還是模型的一個特徵。答案決定它跑幾次——一次性的查詢、每天排程一次、還是每次有人開頁面就跑一次。而跑的次數決定代價預算，代價預算決定後面代價量測那張表要看哪一欄。</p>
<p><strong>「前一天」是日曆上的前一天，還是資料上的前一筆。</strong> 這兩個是不同的問題，而<strong>資料完整的時候它們的答案相同</strong>——所以在開發用的乾淨資料上分不出來，要等到缺測發生。</p>
<p><strong>缺一天的時候要什麼答案。</strong> 三種都合理：跳過（沒有前一天就不算）、視為斷點（明確標示這裡量不到）、或者補值（用前後內插）。三種對應三段不同的查詢。</p>
<p><strong>一個測站還是幾百個。</strong> 一個的時候整批資料就是一條時間序列；幾百個的時候「前一天」必須限定在同一個測站之內，而漏掉這個限定不會報錯。</p>
<p>這些追問的答案<strong>不在資料庫裡，也不在 SQL 裡，要去問人</strong>。而不問的話，每一個追問都會有一個預設答案被偷偷選走：多數人預設這是一次性的查詢、不去想它以後要跑幾次，預設寫成「資料上的前一筆」、預設跳過缺測、預設只有一個測站。</p>
<h2 id="可驗收的需求敘述與帶缺測的驗收資料">可驗收的需求敘述與帶缺測的驗收資料</h2>
<p>追問的答案決定了要寫的是哪一段查詢。本篇往下取這一組：要餵給每天一次的排程報表、「前一天」指日曆上的前一天、缺測的那一天不算、而且有很多個測站。</p>
<p>翻成一句話：</p>
<blockquote>
<p>對每一個測站，找出所有「同測站、日期剛好早一天」的那筆觀測存在、而且氣溫比它高的那些觀測。</p></blockquote>
<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 的 DROP 一次只收一張表）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">DROP</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">EXISTS</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"> 3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">氣溫</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="err">紀錄編號</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="err">測站</span><span class="w">     </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="err">觀測日</span><span class="w">   </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="err">攝氏</span><span class="w">     </span><span class="nb">INT</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 台北的 3/05 缺測（感測器當天離線），其餘每天一筆
</span></span></span><span class="line"><span class="ln">11</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="p">(</span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="p">,</span><span class="w"> </span><span class="err">攝氏</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-01&#39;</span><span class="p">,</span><span class="mi">18</span><span class="p">),(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-02&#39;</span><span class="p">,</span><span class="mi">22</span><span class="p">),(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-03&#39;</span><span class="p">,</span><span class="mi">20</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="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-04&#39;</span><span class="p">,</span><span class="mi">15</span><span class="p">),</span><span class="w">                          </span><span class="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-06&#39;</span><span class="p">,</span><span class="mi">25</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="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-07&#39;</span><span class="p">,</span><span class="mi">24</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="p">(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-01&#39;</span><span class="p">,</span><span class="mi">26</span><span class="p">),(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-02&#39;</span><span class="p">,</span><span class="mi">25</span><span class="p">),(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-03&#39;</span><span class="p">,</span><span class="mi">28</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-04&#39;</span><span class="p">,</span><span class="mi">27</span><span class="p">),(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-05&#39;</span><span class="p">,</span><span class="mi">29</span><span class="p">),(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-06&#39;</span><span class="p">,</span><span class="mi">30</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-07&#39;</span><span class="p">,</span><span class="mi">28</span><span class="p">);</span></span></span></code></pre></div><p>照上面那句話逐筆判，正確答案是四筆：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">台北  2026-03-02    22 &gt; 18
</span></span><span class="line"><span class="ln">2</span><span class="cl">高雄  2026-03-03    28 &gt; 25
</span></span><span class="line"><span class="ln">3</span><span class="cl">高雄  2026-03-05    29 &gt; 27
</span></span><span class="line"><span class="ln">4</span><span class="cl">高雄  2026-03-06    30 &gt; 29</span></span></code></pre></div><p>台北的 3/06（25 度）不算——它的前一天是 3/05，而那一天沒有觀測。<strong>這一筆是整組資料的判斷標準</strong>：任何一段回它的查詢，都答錯了上面那句話。</p>
<h2 id="自連接視窗函數與相關子查詢各自押的前提">自連接、視窗函數與相關子查詢各自押的前提</h2>
<p><strong>自連接</strong>把同一張表當成兩次出現，一次扮演「今天」、一次扮演「昨天」（<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="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">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="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="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">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="c1">-- 限定在同一個測站之內
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></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="nb">date</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="s1">&#39;-1 day&#39;</span><span class="p">)</span><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">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">&gt;</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">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="err">今</span><span class="p">.</span><span class="err">觀測日</span><span class="p">;</span></span></span></code></pre></div><p><code>date(今.觀測日, '-1 day')</code> 是 SQLite 的日期運算，把字串日期往前推一天。別名 <code>今</code> 與 <code>昨</code> 各自標示這一次出現扮演什麼角色——欄位一定要帶前綴，兩次出現共用同一個表名的時候不帶前綴會直接被擋下（<code>ambiguous column name: 測站</code>）。</p>
<p><strong>視窗函數</strong>沿著排好的順序往前取一列（<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>





<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="err">觀測日</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</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">3</span><span class="cl"><span class="w">         </span><span class="n">LAG</span><span class="p">(</span><span class="err">攝氏</span><span class="p">)</span><span class="w">   </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</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">4</span><span class="cl"><span class="w">         </span><span class="n">julianday</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="n">julianday</span><span class="p">(</span><span class="n">LAG</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</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">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="n">WINDOW</span><span class="w"> </span><span class="n">w</span><span class="w"> </span><span class="k">AS</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="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="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">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="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">1</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">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="w"> </span><span class="err">觀測日</span><span class="p">;</span></span></span></code></pre></div><p><code>WINDOW w AS (...)</code> 把窗的定義取個名字，讓兩個 <code>LAG</code> 共用同一個窗；<code>julianday()</code> 把日期換成可以相減的數值，兩筆相差幾天由它算出來。<code>相隔 = 1</code> 這個條件是必要的——<code>LAG</code> 交出的是<strong>排序後的相鄰一列</strong>，而需求要的是<strong>日曆上的前一天</strong>，兩者只在資料連續時相同。</p>
<p><strong>相關子查詢</strong>對每一列去問一次「我的前一天是幾度」（<a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="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">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="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="w"> </span><span class="o">&gt;</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="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">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></span><span class="line"><span class="ln">5</span><span class="cl"><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="nb">date</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="s1">&#39;-1 day&#39;</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="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="w"> </span><span class="err">觀測日</span><span class="p">;</span></span></span></code></pre></div><p>前一天不存在的時候括號裡那一段回零列，純量子查詢因此給出 <code>NULL</code>，而拿任何數字跟 <code>NULL</code> 比大小的答案是未知，那一列被 <code>WHERE</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>）。<strong>台北 3/06 正是靠這個機制被排除的</strong>——排除它的是空值的比較落在真、假之外的「未知」上，而查詢裡沒有任何一個條件在擋它。</p>
<p>三段都回同一批四列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">台北  2026-03-02
</span></span><span class="line"><span class="ln">2</span><span class="cl">高雄  2026-03-03
</span></span><span class="line"><span class="ln">3</span><span class="cl">高雄  2026-03-05
</span></span><span class="line"><span class="ln">4</span><span class="cl">高雄  2026-03-06</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>）。視窗函數押「排序鍵在每個分區裡分得出唯一的次序」——並列的時候「前一列」是哪一列沒有定義（<a href="/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價</a>）。相關子查詢押「括號裡那一段最多回一列」——而<strong>回多列的時候兩家引擎的反應相反</strong>：PostgreSQL 18.6 報 <code>more than one row returned by a subquery used as an expression</code>，SQLite 3.51.0 靜默取其中一列往下算。這一條落在 <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>
<p><strong>三個前提說的是同一件事</strong>：<code>(測站, 觀測日)</code> 這一組唯不唯一。那是 schema 上的一條約束，而三段查詢沒有一段查得到它有沒有成立——<a href="/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">外鍵與約束要去系統目錄查</a>，設計側的完整推導在 <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>
<h2 id="漏掉相隔天數與漏掉分區的錯誤版本">漏掉相隔天數與漏掉分區的錯誤版本</h2>
<p>上面那個 <code>相隔 = 1</code> 與 <code>PARTITION BY 測站</code>，漏掉任何一個查詢都照跑。</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="err">觀測日</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</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">3</span><span class="cl"><span class="w">         </span><span class="n">LAG</span><span class="p">(</span><span class="err">攝氏</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="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="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="p">)</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="err">前溫</span><span class="p">;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">台北  2026-03-02
</span></span><span class="line"><span class="ln">2</span><span class="cl">台北  2026-03-06      ← 多出來的
</span></span><span class="line"><span class="ln">3</span><span class="cl">高雄  2026-03-03
</span></span><span class="line"><span class="ln">4</span><span class="cl">高雄  2026-03-05
</span></span><span class="line"><span class="ln">5</span><span class="cl">高雄  2026-03-06</span></span></code></pre></div><p>五筆。台北 3/06 拿 3/04 當「前一天」比，25 大於 15，於是被選中——<strong>它跨過了缺測的那一天，而缺測在結果裡沒有留下任何痕跡</strong>。</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="err">觀測日</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</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">3</span><span class="cl"><span class="w">         </span><span class="n">LAG</span><span class="p">(</span><span class="err">攝氏</span><span class="p">)</span><span class="w">   </span><span class="n">OVER</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="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">4</span><span class="cl"><span class="w">         </span><span class="n">julianday</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="n">julianday</span><span class="p">(</span><span class="n">LAG</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</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="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">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="p">)</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="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">1</span><span class="p">;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">高雄  2026-03-05</span></span></code></pre></div><p>一筆，少了三筆。沒有分區的時候，<code>ORDER BY 觀測日</code> 把兩個測站同一天的列排在相鄰的位置，而同一天台北與高雄誰排在前面，這個排序鍵沒有決定——這正是視窗函數押的前提「排序鍵分得出唯一的次序」被打破的情形。SQLite 在這份資料上把台北排在前面，於是高雄 3/05 以外的高雄各列，前一列都是台北同一天的觀測，相隔天數是零，被 <code>相隔 = 1</code> 擋掉；台北 3/02 起的各列，前一列都是高雄前一天的觀測，相隔天數是一，比的卻是高雄的氣溫，而這組資料裡高雄每一天都比台北暖，台北一列都選不上。剩下的高雄 3/05 是因為台北 3/05 缺測，它的前一列才落在高雄 3/04。</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">-- 漏掉分區的查詢，窗的排序鍵補上測站，把同一天兩個測站的先後寫死
</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="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </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">SELECT</span><span class="w"> </span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</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"> 4</span><span class="cl"><span class="w">         </span><span class="n">LAG</span><span class="p">(</span><span class="err">攝氏</span><span class="p">)</span><span class="w">   </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</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"> 5</span><span class="cl"><span class="w">         </span><span class="n">julianday</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="n">julianday</span><span class="p">(</span><span class="n">LAG</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</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"> 6</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"> 7</span><span class="cl"><span class="w">  </span><span class="n">WINDOW</span><span class="w"> </span><span class="n">w</span><span class="w"> </span><span class="k">AS</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="w"> </span><span class="err">測站</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 class="p">)</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="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">1</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">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="w"> </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">-- ORDER BY 觀測日, 測站：      高雄 3/05，一筆（與不寫死次序時 SQLite 回的相同）
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- ORDER BY 觀測日, 測站 DESC： 高雄 3/02、3/03、3/04、3/05、3/06、3/07，六筆
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">--   高雄 3/02、3/04、3/07 的前一列是台北前一天，比的是台北的氣溫：多報
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">--   台北 3/02 的前一列是高雄同一天，相隔天數是零：少報</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>版本</th>
          <th>回幾筆</th>
          <th>方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>三種正解</td>
          <td>4</td>
          <td>—</td>
      </tr>
      <tr>
          <td>漏掉相隔天數</td>
          <td>5</td>
          <td>多報</td>
      </tr>
      <tr>
          <td>漏掉 <code>PARTITION BY</code></td>
          <td>1 或 6</td>
          <td>看同一天兩個測站誰排在前面：少報，或多報與少報同時發生</td>
      </tr>
  </tbody>
</table>
<p><strong>兩個錯版都不報錯、輸出的欄位也完全正常。</strong> 漏掉相隔天數的版本每次都多報台北 3/06；漏掉分區的版本錯在哪個方向，由引擎怎麼排同一天的兩列決定，在本篇的資料上 SQLite 給的是少報。少報尤其危險——多報的還有機會被人發現數字怪，少報的看起來只是「那幾天沒有升溫」。</p>
<p>分辨它們要靠寫查詢之前就定下的正確答案。漏掉相隔天數的版本回了台北 3/06，撞上寫成可驗收的那句話時定下的「台北 3/06 不該出現」這個判斷標準；漏掉分區的版本在兩種排法下都沒有回台北 3/06，要拿四筆的正確答案逐筆比對才看得出錯。<strong>那份正確答案要在寫查詢之前就決定好</strong>，寫完再回頭挑資料的人，會挑到讓自己通過的那一組。</p>
<h2 id="代價日期差自連接等值自連接視窗函數與相關子查詢的實測">代價：日期差自連接、等值自連接、視窗函數與相關子查詢的實測</h2>
<p>三種寫法都答對了，而「哪個比較快」要向引擎問（<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a>）。把資料放大到二十個測站、兩年、每隔九十七天缺一天，共 14460 列：</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 的 DROP 一次只收一張表）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">DROP</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">EXISTS</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"> 3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">氣溫</span><span class="w"> </span><span class="p">(</span><span class="err">紀錄編號</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="err">測站</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span 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"> 4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- d 產生 0 到 729 的天數位移，s 產生 1 到 20 的測站編號
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">-- (i+j) % 97 &lt;&gt; 0 這個條件讓每個測站隔一段時間缺一天
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">d</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">d</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">729</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="n">s</span><span class="p">(</span><span class="n">j</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">j</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">j</span><span class="w"> </span><span class="o">&lt;</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"> 9</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="p">(</span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</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">10</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="s1">&#39;站&#39;</span><span class="o">||</span><span class="n">j</span><span class="p">,</span><span class="w"> </span><span class="nb">date</span><span class="p">(</span><span class="s1">&#39;2024-01-01&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">i</span><span class="o">||</span><span class="s1">&#39; day&#39;</span><span class="p">),</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="o">*</span><span class="mi">7</span><span class="o">+</span><span class="n">j</span><span class="o">*</span><span class="mi">13</span><span class="p">)</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">25</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">FROM</span><span class="w"> </span><span class="n">s</span><span class="p">,</span><span class="w"> </span><span class="n">d</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="o">+</span><span class="n">j</span><span class="p">)</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">97</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="mi">0</span><span class="p">;</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="c1">-- 以下是同一段自連接的 ON 子句，兩種寫法只差這一行，其餘逐字相同。
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">-- 這個區塊是片段，不能單獨執行——完整的自連接查詢在〈自連接、視窗函數與相關子查詢各自押的前提〉那一節。
</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="c1">-- 日期差自連接：等號兩邊的欄位都包進函式，算日期差
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></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="n">julianday</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="n">julianday</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="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="c1">-- 等值自連接：等值比較，函式只作用在「今」那一側，「昨」那一側是索引上找得到的值
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></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="nb">date</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="s1">&#39;-1 day&#39;</span><span class="p">)</span></span></span></code></pre></div><p>量的是兩種自連接加上視窗函數與相關子查詢這四段（<code>count(*)</code> 版本，不帶 <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="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 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></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">-- DROP INDEX ix_站日;</span></span></span></code></pre></div><p>計畫用 <code>EXPLAIN QUERY PLAN</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">EXPLAIN</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">氣溫</span><span class="w"> </span><span class="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="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">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="n">julianday</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="n">julianday</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="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="err">今</span><span class="p">.</span><span class="err">攝氏</span><span class="w"> </span><span class="o">&gt;</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>四種形態各跑三次取最小值。<strong>計時在行程內</strong>——不是 <code>sqlite3</code> 的 <code>.timer</code>，那個量的是整個 CLI 行程；這裡是在一支宿主程式裡繞迴圈（Python 的 <code>sqlite3</code> 模組加 <code>time.perf_counter()</code>），因為行程啟動是個大常數，會把十五倍壓成不到兩倍。表格之後另外對日期差自連接做了一輪七次的重複性量測，用來確認那個反直覺的結果不是離群值：</p>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>沒有索引</th>
          <th>有 <code>(測站, 觀測日)</code> 索引</th>
          <th>答案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>日期差自連接</td>
          <td>739 ms</td>
          <td><strong>1045 ms</strong></td>
          <td>10297</td>
      </tr>
      <tr>
          <td>等值自連接</td>
          <td>10 ms</td>
          <td>6 ms</td>
          <td>10297</td>
      </tr>
      <tr>
          <td>視窗函數</td>
          <td>13 ms</td>
          <td>12 ms</td>
          <td>10297</td>
      </tr>
      <tr>
          <td>相關子查詢</td>
          <td>3971 ms</td>
          <td>8 ms</td>
          <td>10297</td>
      </tr>
  </tbody>
</table>
<p>四段查詢的答案完全相同，而時間橫跨三個量級。其中四個現象要逐個看。</p>
<p><strong>日期差自連接與等值自連接的差別只在條件的形狀，而它是七十倍。</strong> 同一個自連接、同一個演算法，把 <code>julianday(今.觀測日) - julianday(昨.觀測日) = 1</code> 換成 <code>昨.觀測日 = date(今.觀測日, '-1 day')</code> 就從 739 毫秒掉到 10 毫秒。原因是日期差自連接把「今」與「昨」的觀測日都包進函式裡，引擎沒有辦法拿索引上的值去比；等值自連接的函式只作用在「今」的觀測日上，引擎在「昨」裡要找的是一個算好的值（<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable</a>）。在幾十列的開發資料上，兩種形狀都是零毫秒，所以這個差別要到資料量上來之後才看得到。</p>
<p><strong>日期差自連接加了索引之後變慢。</strong> 739 毫秒變成 1045 毫秒。這一條反直覺，所以單獨對它再跑一輪七次量測確認：兩段的範圍不重疊（735–780 對上 955–1252），不是離群值。計畫說明了原因：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">沒有索引    SCAN 今
</span></span><span class="line"><span class="ln">2</span><span class="cl">            SEARCH 昨 USING AUTOMATIC COVERING INDEX (測站=?)
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">有索引      SCAN 今
</span></span><span class="line"><span class="ln">5</span><span class="cl">            SEARCH 昨 USING INDEX ix_站日 (測站=?)</span></span></code></pre></div><p>沒有索引的時候 SQLite 當場替這段查詢建了一個<strong>覆蓋</strong>索引，內層要的欄位全部在索引裡。有了 <code>ix_站日</code> 之後它改用那一個，而那個索引只含 <code>(測站, 觀測日)</code>、不含 <code>攝氏</code>，於是每一列內層都要多回表一次取攝氏。<strong>手動建的那個索引比引擎當場建的差，所以加了之後變慢</strong>——這件事沒有辦法從查詢的文字推出來，只能問計畫。</p>
<p><strong>相關子查詢從四段裡最慢的變成僅次於等值自連接。</strong> 沒有索引時 3971 毫秒，有索引時 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">沒有索引    SCAN 今
</span></span><span class="line"><span class="ln">2</span><span class="cl">            CORRELATED SCALAR SUBQUERY 1
</span></span><span class="line"><span class="ln">3</span><span class="cl">              SCAN 昨                                            ← 「今」的每一列都把「昨」整張掃一遍
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">有索引      SCAN 今
</span></span><span class="line"><span class="ln">6</span><span class="cl">            CORRELATED SCALAR SUBQUERY 1
</span></span><span class="line"><span class="ln">7</span><span class="cl">              SEARCH 昨 USING INDEX ix_站日 (測站=? AND 觀測日=?)   ← 「今」的每一列在索引上查一次</span></span></code></pre></div><p>子查詢逐列執行這個結構沒有變，變的是每一次的單價。<strong>所以「相關子查詢慢」這個說法要加一個條件</strong>：它慢在那個逐列查找沒有索引可用的時候。</p>
<p><strong>視窗函數對索引幾乎無感</strong>（13 對 12 毫秒），而計畫上有變化（只列產生窗那一層）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">沒有索引    SCAN 氣溫
</span></span><span class="line"><span class="ln">2</span><span class="cl">            USE TEMP B-TREE FOR ORDER BY       ← 先把整張表排成 PARTITION BY 測站 ORDER BY 觀測日 的順序
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">有索引      SCAN 氣溫 USING INDEX ix_站日       ← 沿索引讀出來就是這個順序，排序那一行消失</span></span></code></pre></div><p>索引本身就提供了窗要的順序，省下的排序成本在這個資料量上還看不出來。</p>
<p>回到「<strong>這個結果要餵給哪一個決定</strong>」那個追問。每天排程跑一次的報表，四種形態都可以接受，那就挑最好讀的；每次開頁面就跑一次的話，日期差自連接那一列的數字會直接變成使用者等待的時間。</p>
<h2 id="需求沒說而資料會有的情形同日重複時區補登與測站搬家">需求沒說而資料會有的情形：同日重複、時區、補登與測站搬家</h2>
<p>「對每一個測站，找出所有同測站、日期剛好早一天的那筆觀測存在、而且氣溫比它高的那些觀測」這句可驗收的話管住了缺測。下面這幾件需求同樣沒說，而資料遲早會有。</p>
<p><strong>同一個測站、同一天有兩筆。</strong> 感測器重送、補登、或者換了設備之後兩套系統各寫一筆。這打破的是三種寫法共同押的那個前提——<code>(測站, 觀測日)</code> 唯一——而三種寫法被打破之後的反應各不相同：自連接的一列會配到多列，結果多出來；視窗函數的「前一列」在並列之間沒有定義，同一段查詢可以給出不同答案；相關子查詢在 PostgreSQL 上報錯、在 SQLite 上靜默取其中一列。<strong>三種寫法對同一件事的反應完全不同，而在本篇用的 SQLite 上沒有一種會出聲</strong>——會出聲的那一種要換到 PostgreSQL 才看得到。 要決定的是哪一筆算數（最後寫入的、還是某個來源優先），而這個決定屬於 schema——一條 <code>UNIQUE (測站, 觀測日)</code> 約束把它前移到寫入的那一刻。</p>
<p><strong>時區。</strong> <code>觀測日</code> 存的是哪個時區的日期。跨時區的測站各自用當地日期的話，「前一天」在資料上是對的而在比較上不可跨站；統一存 UTC 日期的話，當地的「一天」會被切開。這件事在單一時區的開發環境裡量不出來。</p>
<p><strong>補登的資料改變歷史答案。</strong> 台北 3/05 的觀測三個月後補進來了，同一段查詢在同一段日期上回的列跟著變——<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="c1">-- 台北 3/05 補登，當天 12 度
</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="p">(</span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="p">,</span><span class="w"> </span><span class="err">攝氏</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-05&#39;</span><span class="p">,</span><span class="mi">12</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">-- 〈自連接、視窗函數與相關子查詢各自押的前提〉那一節的自連接，原樣再跑一次
</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="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"> 6</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="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">JOIN</span><span class="w"> </span><span class="err">氣溫</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">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">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="nb">date</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="s1">&#39;-1 day&#39;</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="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">&gt;</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">11</span><span class="cl"><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="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">12</span><span class="cl"><span class="w"></span><span class="c1">-- 補登之前：四筆
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- 補登 12 度之後：五筆，多出台北 2026-03-06（25 &gt; 12），它現在有前一天了
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- 補登的是 17 度的話：六筆，台北 2026-03-05（17 &gt; 15）也被選中</span></span></span></code></pre></div><p>報表若把結果存下來，存的那一份與重跑的那一份會不同。要決定的是這份報表回答的是「現在資料庫怎麼說」還是「當時我們知道什麼」，兩種要的儲存方式不同。</p>
<p><strong>測站搬家。</strong> 同一個 <code>測站</code> 值在某一天之後指的是另一個地點。<code>PARTITION BY 測站</code> 會把搬家前後接成同一條序列，而那一天的「比前一天高」比的是兩個地方。這一類問題不在查詢裡，在<strong>識別碼的語意有沒有隨時間改變</strong>。</p>
<p>四件事有一個共同點：<strong>它們都讓查詢繼續回出形狀正確的列</strong>。沒有一件會讓程式停下來（<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>追問、判斷標準、寫法、前提、代價與邊界都處理過之後，這段查詢會跑、答案對、代價也量過了。剩下的三件事有一個共同點：<strong>它們全部不在這段文字裡</strong>——資料會變、計畫會重選、方言會換，而查詢的字面對這三件事完全沉默。</p>
<p><strong>答案變了要不要有人知道。</strong> 台北 3/05 補登那種情形會讓同一段日期的答案改變。要不要偵測，取決於「餵給哪一個決定」那個追問——告警要知道，月報的圖不必。</p>
<p><strong>資料量長大之後排名會重排。</strong> 代價量測那張表是 14460 列上的結果。列數再大一個量級，日期差自連接與相關子查詢的數字會變，而等值自連接與視窗函數未必。判斷要重問一次計畫，而且<strong>要在改動索引之後再問一次</strong>——日期差自連接已經示範過加索引可以讓事情更糟。</p>
<p><strong>換一家引擎，這四段都要改寫。</strong> 四段全部用了 SQLite 的日期函式——兩種自連接與相關子查詢用 <code>date(x, '-1 day')</code> 或 <code>julianday()</code>，視窗函數用 <code>julianday()</code>。同一個日期運算，PostgreSQL 要先把欄位轉成日期，寫成 <code>CAST(觀測日 AS DATE) - 1</code>（本篇的觀測日宣告成 <code>TEXT</code>，直接寫 <code>觀測日 - INTERVAL '1 day'</code> 會回 <code>operator does not exist: text - interval</code>）；MySQL 寫成 <code>DATE_SUB(觀測日, INTERVAL 1 DAY)</code>，文字欄會被隱含轉成日期；<code>WINDOW w AS (...)</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>每個決定要決定什麼、決錯之後的症狀、背後的機制在哪一篇，都在 <a href="/blog/backend/01-database/sql/decisions-from-requirement-to-shipping/" data-link-title="SQL.0 一段查詢從需求到上線的決定：追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界與交付" data-link-desc="每個決定要決定什麼、決錯之後查詢出現什麼症狀、機制在哪一篇展開，以及這些決定共同依賴的建表時的決定">SQL.0 一段查詢從需求到上線的決定：追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界與交付</a>；下表只對照這一題在每個決定上做了什麼：</p>
<table>
  <thead>
      <tr>
          <th>決定</th>
          <th>這一題在這個決定上做了什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>追問需求</td>
          <td>結果餵給哪一個決定、「前一天」指日曆還是資料、缺測那天怎麼算、有幾個測站</td>
      </tr>
      <tr>
          <td>設定判斷標準</td>
          <td>寫成可驗收的一句話，並指定台北 3/06 當判斷標準</td>
      </tr>
      <tr>
          <td>選寫法</td>
          <td>自連接、視窗函數、相關子查詢並排</td>
      </tr>
      <tr>
          <td>補前提</td>
          <td>三種寫法押的前提收斂到那一組鍵唯不唯一，再用兩個漏掉前提的錯版驗收</td>
      </tr>
      <tr>
          <td>量代價</td>
          <td>兩種自連接加上視窗函數與相關子查詢，各量有索引與沒有索引</td>
      </tr>
      <tr>
          <td>對邊界</td>
          <td>同日重複、時區、補登、測站搬家</td>
      </tr>
      <tr>
          <td>交付</td>
          <td>答案變了要不要有人知道、資料量長大後重問計畫、換引擎要改寫的日期函式</td>
      </tr>
  </tbody>
</table>
<h2 id="查詢之外的接點schema-設計計畫工具與查詢預算">查詢之外的接點：schema 設計、計畫工具與查詢預算</h2>
<p>交付之後，這一題就交出去了。而從需求到上線的這些決定裡，有三個各自連到查詢之外的一個領域。</p>
<p>選寫法與補前提的接點在<strong>設計那一側</strong>。三種寫法押的同一個前提是 <code>(測站, 觀測日)</code> 唯不唯一，而那不是查詢能決定的事——它是建表時的一個決定。同樣的決定還有五個（可空性、表有多寬、常一起取的資料切在幾張表、字串的比較規則寫在哪一層、外鍵生不生效），每一個都在替往後每一次查詢定價，逐條實測在 <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>量代價的接點在<strong>工具那一側</strong>。本篇讀的計畫只有三四行，真實系統的計畫有巢狀節點與估計列數，而且要分得出「估計錯了」與「真的很慢」。<a href="/blog/backend/01-database/vendors/postgresql/query-optimization/" data-link-title="PostgreSQL Query Optimization：EXPLAIN、auto_explain、pg_hint_plan 與 planner 選錯 plan 的 production case" data-link-desc="PG query 慢的根因常是 *planner 選錯 plan 或 statistics 過時*。本文從 production case 開場（seq scan vs index / hash vs nested loop / 多 column 統計缺 / parallel query 沒觸發）、走 EXPLAIN / EXPLAIN ANALYZE / auto_explain、pg_hint_plan extension 跟 planner GUC 取捨、production 踩雷（ANALYZE 過時 / multi-column statistics / random_page_cost 沿用 HDD 預設 / effective_cache_size 不對齊 RAM / parallel query 配置）、跟 MySQL query-optimization sibling 對比">PostgreSQL Query Optimization</a> 給 <code>EXPLAIN</code> / <code>EXPLAIN ANALYZE</code> / <code>auto_explain</code> 三層工具的分工，以及統計過時讓計畫選錯的實際案例。</p>
<p>追問需求的接點在<strong>預算那一側</strong>。追問需求時問的是這份結果餵給哪一個下游用途，而那個答案決定它跑幾次；一個請求裡跑幾段查詢則有另一套預算，兩者相乘才是真正的代價。<a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">backend 1.13 應用層查詢反模式與 Query 預算</a> 處理一個請求裡跑幾段查詢的那一套預算。</p>
<p>換一個題目的話，這些決定照樣要做，而追問需求得到的答案與需求沒說的那些邊界會全部不同——那正是為什麼它們是追問而不是清單。</p>
]]></content:encoded></item><item><title>SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/</link><pubDate>Thu, 24 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/</guid><description>&lt;p>SQL 分類多數篇的查詢跑在同一組表上：一家小書店的三張表，顧客、訂單、評價。談代價與計畫的那幾篇需要大量資料，另外造自己的表並在篇內標明。&lt;/p>
&lt;h2 id="建表語句與初始資料">建表語句與初始資料&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">評價編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">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">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">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;佳穎&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="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="s1">&amp;#39;宗翰&amp;#39;&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="s1">&amp;#39;雅文&amp;#39;&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="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">101&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-02&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">102&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-09&amp;#39;&lt;/span>&lt;span class="p">,&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">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">9001&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">101&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個顧客裡只有佳穎下過單，她的兩張訂單裡只有第一張被評價過。&lt;strong>空缺是刻意留的&lt;/strong>——外連接、NULL 與條件放哪裡的差別，全部要靠這些空缺才顯示得出來。&lt;/p>
&lt;p>&lt;code>RIGHT JOIN&lt;/code> 與 &lt;code>FULL OUTER JOIN&lt;/code> 是後來才加進 SQLite 的，舊版回語法錯誤；遇到的話先用 &lt;code>sqlite3 --version&lt;/code> 對一下手上這一份。&lt;/p>
&lt;h2 id="各篇輸出所用的引擎版本與閱讀輸出的約定">各篇輸出所用的引擎版本與閱讀輸出的約定&lt;/h2>
&lt;p>各篇的輸出都是實際跑出來的，多數在 SQLite 3.51 上。談權限的那一篇整篇在 PostgreSQL 18；比較各家行為的那幾篇另外用到 MySQL 8.4 與 DuckDB v0.10.3，牽涉到各家行為不同的地方都標明是哪一個引擎與哪一版。&lt;/p>
&lt;p>&lt;strong>錯誤訊息&lt;/strong>：引擎的錯誤訊息會隨版本改措辭，所以各篇引的那些字串是對照用的，判讀看它拒絕了什麼、不看它怎麼說。&lt;/p>
&lt;p>&lt;strong>列序&lt;/strong>：各篇印出來的結果集為了對照固定了列序，而沒有 &lt;code>ORDER BY&lt;/code> 的查詢實際回來的順序沒有保證（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍&lt;/a> 拿同一段查詢在建索引前後回不同順序示範這一條）。&lt;/p>
&lt;p>&lt;strong>資料的增刪&lt;/strong>：有幾篇會在共用資料上增刪幾列以顯示某個效果，那些改動只在該篇之內成立——換一篇之前回到這一頁最上面那組初始資料。&lt;/p></description><content:encoded><![CDATA[<p>SQL 分類多數篇的查詢跑在同一組表上：一家小書店的三張表，顧客、訂單、評價。談代價與計畫的那幾篇需要大量資料，另外造自己的表並在篇內標明。</p>
<h2 id="建表語句與初始資料">建表語句與初始資料</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</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 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">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">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">1</span><span class="p">,</span><span class="s1">&#39;佳穎&#39;</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;宗翰&#39;</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">3</span><span class="p">,</span><span class="s1">&#39;雅文&#39;</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="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">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">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></span></code></pre></div><p>三個顧客裡只有佳穎下過單，她的兩張訂單裡只有第一張被評價過。<strong>空缺是刻意留的</strong>——外連接、NULL 與條件放哪裡的差別，全部要靠這些空缺才顯示得出來。</p>
<p><code>RIGHT JOIN</code> 與 <code>FULL OUTER JOIN</code> 是後來才加進 SQLite 的，舊版回語法錯誤；遇到的話先用 <code>sqlite3 --version</code> 對一下手上這一份。</p>
<h2 id="各篇輸出所用的引擎版本與閱讀輸出的約定">各篇輸出所用的引擎版本與閱讀輸出的約定</h2>
<p>各篇的輸出都是實際跑出來的，多數在 SQLite 3.51 上。談權限的那一篇整篇在 PostgreSQL 18；比較各家行為的那幾篇另外用到 MySQL 8.4 與 DuckDB v0.10.3，牽涉到各家行為不同的地方都標明是哪一個引擎與哪一版。</p>
<p><strong>錯誤訊息</strong>：引擎的錯誤訊息會隨版本改措辭，所以各篇引的那些字串是對照用的，判讀看它拒絕了什麼、不看它怎麼說。</p>
<p><strong>列序</strong>：各篇印出來的結果集為了對照固定了列序，而沒有 <code>ORDER BY</code> 的查詢實際回來的順序沒有保證（<a href="/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</a> 拿同一段查詢在建索引前後回不同順序示範這一條）。</p>
<p><strong>資料的增刪</strong>：有幾篇會在共用資料上增刪幾列以顯示某個效果，那些改動只在該篇之內成立——換一篇之前回到這一頁最上面那組初始資料。</p>
]]></content:encoded></item></channel></rss>