<?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>Standard on Tarragon</title><link>https://tarrragon.github.io/blog/tags/standard/</link><description>Recent content in Standard on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 02 Sep 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/standard/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>