<?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>Portability on Tarragon</title><link>https://tarrragon.github.io/blog/tags/portability/</link><description>Recent content in Portability 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/portability/index.xml" rel="self" type="application/rss+xml"/><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>Database Engine（資料庫引擎）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/database-engine/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/database-engine/</guid><description>&lt;p>資料庫引擎是收下查詢的文字、按自己的規則算出結果並交回一份&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">關係&lt;/a>的那個軟體。SQLite、PostgreSQL、MySQL、DuckDB 各是一個引擎，而同一個引擎的不同版本也可能有不同的規則。&lt;/p>
&lt;p>本分類反覆說「引擎」在做某件事，說的都是同一件事——差別在它做的是哪一種決定，而那決定了換一家引擎會不會得到不同的結果。&lt;/p>
&lt;h2 id="它的四種決定換引擎的後果各不相同">它的四種決定，換引擎的後果各不相同&lt;/h2>
&lt;p>&lt;strong>接不接受這段文字。&lt;/strong> 引擎檢查名字解析得到、型別配得上、子句的組合合乎規則。標準定死的那些各家一致；標準禁止而各家自行放寬的那些不一致，於是同一段查詢在一家報錯、在另一家跑得動（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象&lt;/a> 各有一個實例）。&lt;/p>
&lt;p>&lt;strong>名字指向哪個物件。&lt;/strong> 沒加引號的識別字被摺成什麼樣由引擎決定，各家的規則不同（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字&lt;/a>）。&lt;/p>
&lt;p>&lt;strong>要花多久。&lt;/strong> 這由&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&lt;/a>決定，而它受一個約束：輸出的列集合要與語意模型算出來的一致。所以換引擎會換掉代價，不換掉答案（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響&lt;/a>）。&lt;/p>
&lt;p>&lt;strong>回哪一批列。&lt;/strong> 這一項由&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>的組合規則定死，各家一致。三值邏輯、外連接補 &lt;code>NULL&lt;/code>、&lt;code>NOT IN&lt;/code> 的展開方式都在這一層，所以那幾類錯誤換引擎不會消失（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;strong>引擎是「文字之外」那幾方裡射程最大的一方&lt;/strong>——識別字規則、寬鬆度與設定、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&lt;/a>都住在它裡面，而權限系統是它另外管的一層（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限&lt;/a>）。所以「換一家引擎」這句話涵蓋的變動比它聽起來的多。&lt;/p>
&lt;p>引擎不決定的只有一件事：這批列是不是那個問題的答案。那一層沒有機械判定，理由在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>。&lt;/p>
&lt;p>錯誤訊息的措辭由引擎決定而且會隨版本改，所以各篇引的那些字串是對照用的——判讀看它拒絕了什麼，不看它怎麼說。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>各家在同一段查詢上的分歧最集中的地方，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字&lt;/a>。挑執行方式的那個元件在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">Query Optimizer（查詢最佳化器）&lt;/a>，它產出的那份計畫在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">Query Plan（執行計畫）&lt;/a>。它另外存著的那份摘要在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics（統計資訊）&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>資料庫引擎是收下查詢的文字、按自己的規則算出結果並交回一份<a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">關係</a>的那個軟體。SQLite、PostgreSQL、MySQL、DuckDB 各是一個引擎，而同一個引擎的不同版本也可能有不同的規則。</p>
<p>本分類反覆說「引擎」在做某件事，說的都是同一件事——差別在它做的是哪一種決定，而那決定了換一家引擎會不會得到不同的結果。</p>
<h2 id="它的四種決定換引擎的後果各不相同">它的四種決定，換引擎的後果各不相同</h2>
<p><strong>接不接受這段文字。</strong> 引擎檢查名字解析得到、型別配得上、子句的組合合乎規則。標準定死的那些各家一致；標準禁止而各家自行放寬的那些不一致，於是同一段查詢在一家報錯、在另一家跑得動（<a href="/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法</a> 與 <a href="/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象</a> 各有一個實例）。</p>
<p><strong>名字指向哪個物件。</strong> 沒加引號的識別字被摺成什麼樣由引擎決定，各家的規則不同（<a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a>）。</p>
<p><strong>要花多久。</strong> 這由<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>決定，而它受一個約束：輸出的列集合要與語意模型算出來的一致。所以換引擎會換掉代價，不換掉答案（<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a>）。</p>
<p><strong>回哪一批列。</strong> 這一項由<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>的組合規則定死，各家一致。三值邏輯、外連接補 <code>NULL</code>、<code>NOT IN</code> 的展開方式都在這一層，所以那幾類錯誤換引擎不會消失（<a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<h2 id="概念位置">概念位置</h2>
<p><strong>引擎是「文字之外」那幾方裡射程最大的一方</strong>——識別字規則、寬鬆度與設定、<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>都住在它裡面，而權限系統是它另外管的一層（<a href="/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限</a>）。所以「換一家引擎」這句話涵蓋的變動比它聽起來的多。</p>
<p>引擎不決定的只有一件事：這批列是不是那個問題的答案。那一層沒有機械判定，理由在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>。</p>
<p>錯誤訊息的措辭由引擎決定而且會隨版本改，所以各篇引的那些字串是對照用的——判讀看它拒絕了什麼，不看它怎麼說。</p>
<h2 id="往下走">往下走</h2>
<p>各家在同一段查詢上的分歧最集中的地方，在 <a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a>。挑執行方式的那個元件在 <a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">Query Optimizer（查詢最佳化器）</a>，它產出的那份計畫在 <a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">Query Plan（執行計畫）</a>。它另外存著的那份摘要在 <a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics（統計資訊）</a>。</p>
]]></content:encoded></item><item><title>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>