<?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>Referential-Integrity on Tarragon</title><link>https://tarrragon.github.io/blog/tags/referential-integrity/</link><description>Recent content in Referential-Integrity 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/referential-integrity/index.xml" rel="self" type="application/rss+xml"/><item><title>SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/foreign-key-and-referential-integrity/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/foreign-key-and-referential-integrity/</guid><description>&lt;p>外鍵是一條寫在表結構上的宣告：這一欄的值，在另一張表裡找得到對應的那一列。資料庫在每一次寫入時檢查它，所以它涵蓋的是整張表，以及往後的每一筆資料。外鍵這條保證有一個名字叫&lt;strong>參照完整性&lt;/strong>——「參照」指一欄指向另一張表的某一列，「完整」指那個指向落得到實處。&lt;/p>
&lt;p>查詢的文字之外還有幾樣東西在決定結果——名字實際指向誰、送出的人被允許做什麼、引擎打算花多久。約束介入得比那幾樣都早：它決定資料能長成什麼樣，於是決定一段查詢會遇到哪幾種列。&lt;/p>
&lt;p>本篇走完這條保證的全貌：它擋下哪些寫入、什麼時候才算生效、生效之後查詢省掉哪一步、父列消失時它怎麼維持，以及它涵蓋不到的地方。這條約束該不該加是資料表設計的取捨，本篇只處理加了之後寫入與查詢的行為有什麼改變。&lt;/p>
&lt;p>本篇的表在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫&lt;/a>上重建，差別是這一次把約束寫進結構裡；顧客表的三個人是佳穎、宗翰、雅文，而只有佳穎下過單。後面談替既有的表補約束時，另外用到一張出貨表。指不到父列的那一列，下文叫&lt;strong>孤兒列&lt;/strong>。&lt;/p>
&lt;h2 id="外鍵擋下的寫入與-null-的放行">外鍵擋下的寫入與 NULL 的放行&lt;/h2>
&lt;p>外鍵管的是兩張表之間的一個指向，而破壞這個指向的寫入來自兩邊。子表這一側是填進一個指不到的值，父表那一側是把還被指著的那一列刪掉。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">評價編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">-- PostgreSQL 18
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">ERROR: insert or update on table &amp;#34;評價&amp;#34; violates foreign key constraint &amp;#34;評價_訂單編號_fkey&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">DETAIL: Key (訂單編號)=(999) is not present in table &amp;#34;訂單&amp;#34;.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">DELETE FROM 顧客 WHERE 顧客編號 = 1;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">ERROR: update or delete on table &amp;#34;顧客&amp;#34; violates foreign key constraint &amp;#34;訂單_顧客編號_fkey&amp;#34; on table &amp;#34;訂單&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">DETAIL: Key (顧客編號)=(1) is still referenced from table &amp;#34;訂單&amp;#34;.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>上面兩則錯誤訊息裡的約束名不同。第一則是評價表自己那條，第二則是訂單表那條——刪顧客這個動作在訂單表上觸發檢查，所以擋下它的約束掛在訂單表上。&lt;strong>外鍵的錯誤訊息因此要分兩層讀&lt;/strong>：訊息指名的表，未必是剛才那句 SQL 寫到的表。&lt;/p>
&lt;p>有一個值在子表與父表兩邊都放行：&lt;code>NULL&lt;/code>。單欄的外鍵讀作「這一欄有值的時候，那個值找得到對應」，所以空著的那一欄不參與檢查。&lt;strong>跨多欄的外鍵預設另有一種讀法&lt;/strong>——標準的預設 &lt;code>MATCH SIMPLE&lt;/code> 只要其中一欄是 &lt;code>NULL&lt;/code> 就整條放行，另外幾欄有值也不查。要它回到逐欄都算的讀法得寫 &lt;code>MATCH FULL&lt;/code>，而這個寫法只在 PostgreSQL 上生效。同一列 &lt;code>(999, NULL)&lt;/code> 寫進兩欄外鍵、而 &lt;code>999&lt;/code> 在父表裡不存在時，各引擎的結果是：&lt;/p>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">顧客</span><span class="p">(</span><span class="err">顧客編號</span><span class="p">),</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="p">(</span><span class="err">評價編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">                   </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">訂單</span><span class="p">(</span><span class="err">訂單編號</span><span class="p">),</span><span class="w"> </span><span class="err">星等</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- PostgreSQL 18
</span></span><span class="line"><span class="ln">2</span><span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);
</span></span><span class="line"><span class="ln">3</span><span class="cl">ERROR:  insert or update on table &#34;評價&#34; violates foreign key constraint &#34;評價_訂單編號_fkey&#34;
</span></span><span class="line"><span class="ln">4</span><span class="cl">DETAIL:  Key (訂單編號)=(999) is not present in table &#34;訂單&#34;.
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">DELETE FROM 顧客 WHERE 顧客編號 = 1;
</span></span><span class="line"><span class="ln">7</span><span class="cl">ERROR:  update or delete on table &#34;顧客&#34; violates foreign key constraint &#34;訂單_顧客編號_fkey&#34; on table &#34;訂單&#34;
</span></span><span class="line"><span class="ln">8</span><span class="cl">DETAIL:  Key (顧客編號)=(1) is still referenced from table &#34;訂單&#34;.</span></span></code></pre></div><p>上面兩則錯誤訊息裡的約束名不同。第一則是評價表自己那條，第二則是訂單表那條——刪顧客這個動作在訂單表上觸發檢查，所以擋下它的約束掛在訂單表上。<strong>外鍵的錯誤訊息因此要分兩層讀</strong>：訊息指名的表，未必是剛才那句 SQL 寫到的表。</p>
<p>有一個值在子表與父表兩邊都放行：<code>NULL</code>。單欄的外鍵讀作「這一欄有值的時候，那個值找得到對應」，所以空著的那一欄不參與檢查。<strong>跨多欄的外鍵預設另有一種讀法</strong>——標準的預設 <code>MATCH SIMPLE</code> 只要其中一欄是 <code>NULL</code> 就整條放行，另外幾欄有值也不查。要它回到逐欄都算的讀法得寫 <code>MATCH FULL</code>，而這個寫法只在 PostgreSQL 上生效。同一列 <code>(999, NULL)</code> 寫進兩欄外鍵、而 <code>999</code> 在父表裡不存在時，各引擎的結果是：</p>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- SQLite 3.51，未開 PRAGMA
</span></span><span class="line"><span class="ln">2</span><span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);   -- 成功，孤兒列進來了
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">-- 同一個檔案，開了之後
</span></span><span class="line"><span class="ln">5</span><span class="cl">PRAGMA foreign_keys = ON;
</span></span><span class="line"><span class="ln">6</span><span class="cl">INSERT INTO 評價 VALUES (9004, 888, 4);
</span></span><span class="line"><span class="ln">7</span><span class="cl">Error: stepping, FOREIGN KEY constraint failed (19)</span></span></code></pre></div><p>開關打開之後，開關關著時插進去的那筆孤兒列（9003）仍然在表裡——檢查發生在寫入的當下，對已經在裡面的資料沒有追溯力。把孤兒列找出來的指令是 <code>PRAGMA foreign_key_check</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_key_check</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- table | rowid | parent | fkid
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- 評價  | 2     | 訂單   | 0
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">--   違反者所在的表、那一列的 rowid、被指向的表，以及那是這張表上的第幾條外鍵（從 0 起算）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   評價編號宣告成 INT PRIMARY KEY 而不是 INTEGER PRIMARY KEY，所以 rowid 是另一個隱藏欄位，不等於評價編號</span></span></span></code></pre></div><p><strong>約束自己的狀態</strong>。PostgreSQL 的 <code>NOT VALID</code> 讓一條外鍵掛上去而略過既有的列，往後的寫入照常檢查。這個狀態是給大表補約束用的（既有列的檢查另用 <code>VALIDATE CONSTRAINT</code> 分開跑，避免長時間鎖表），而它留下的中間態是：表上有這條約束，表裡有孤兒列。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- PostgreSQL 18，出貨表裡已經有一列指向不存在的訂單 777
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="p">(</span><span class="err">出貨編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">102</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">777</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="err">出貨</span><span class="n">_訂單編號_fkey</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">訂單</span><span class="p">(</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">VALID</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- 成功，777 那一列沒有被檢查
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="mi">888</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- ERROR:  insert or update on table &#34;出貨&#34; violates foreign key constraint &#34;出貨_訂單編號_fkey&#34;
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="n">VALIDATE</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="err">出貨</span><span class="n">_訂單編號_fkey</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="c1">-- ERROR:  insert or update on table &#34;出貨&#34; violates foreign key constraint &#34;出貨_訂單編號_fkey&#34;
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- DETAIL:  Key (訂單編號)=(777) is not present in table &#34;訂單&#34;.</span></span></span></code></pre></div><p><strong>語法的形態</strong>。MySQL 認得欄位層的 <code>REFERENCES</code>，解析完把它丟掉，不留下任何約束，<code>SHOW WARNINGS</code> 也是空的。同一段文字在 PostgreSQL 與 SQLite 底下建得出約束，在 MySQL 底下建不出來，而 MySQL 在建表時沒有給任何警告。</p>
<p><strong>表的儲存引擎</strong>。同一家 MySQL 裡還有一條讓宣告消失的路，而它多半不是有人刻意選的——舊系統的建表模板、從舊備份還原回來的表、或複製過來的一段舊 DDL，都會把儲存引擎一起帶進來。表層的 <code>FOREIGN KEY</code> 寫法建在 MyISAM 上，語法照收、警告照樣是空的，約束同樣沒有留下。兩條路要改的地方不同：欄位層的寫法要改寫成表層的 <code>FOREIGN KEY</code>，MyISAM 要換成 InnoDB。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- MySQL 8.4，資料庫名 t；顧客表同共用資料，並以顧客編號為主鍵
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- 欄位層的 REFERENCES：建表成功，SHOW WARNINGS 是空的
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">顧客</span><span class="p">(</span><span class="err">顧客編號</span><span class="p">),</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="mi">999</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">200</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 成功，孤兒列進來了
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- 表層的 FOREIGN KEY，建在預設的 InnoDB 上
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="n">_表層</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">                        </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">顧客</span><span class="p">(</span><span class="err">顧客編號</span><span class="p">));</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="n">_表層</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="mi">999</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">200</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="c1">-- 同樣的表層寫法建在 MyISAM 上：建表成功，SHOW WARNINGS 是空的
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="n">_MyISAM</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">                          </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">顧客</span><span class="p">(</span><span class="err">顧客編號</span><span class="p">))</span><span class="w"> </span><span class="n">ENGINE</span><span class="o">=</span><span class="n">MyISAM</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="n">_MyISAM</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="mi">999</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">200</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="c1">-- 成功，孤兒列進來了
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">TABLE_NAME</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">REFERENTIAL_CONSTRAINTS</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">CONSTRAINT_SCHEMA</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;t&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w"></span><span class="c1">-- 訂單_表層    三張表裡只有這一張留下了約束</span></span></span></code></pre></div><p>這幾個位置的共同點是<strong>外鍵只在寫入的那一刻現身</strong>：查詢的文字裡沒有一個字提到約束，讀的時候也沒有差別。所以「這條保證在不在」要另外查，查的地方是系統目錄。<strong>系統目錄記的是宣告存不存在，生不生效要另外查</strong>：SQLite 在 <code>PRAGMA foreign_keys</code> 關著的時候照樣列出那條外鍵，而孤兒列插得進去；PostgreSQL 的 <code>NOT VALID</code> 外鍵同樣列在 <code>information_schema.table_constraints</code> 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 宣告存不存在
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- PostgreSQL
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">table_name</span><span class="p">,</span><span class="w"> </span><span class="k">constraint_name</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">table_constraints</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">constraint_type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;FOREIGN KEY&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- MySQL
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">TABLE_NAME</span><span class="p">,</span><span class="w"> </span><span class="k">CONSTRAINT_NAME</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">REFERENTIAL_CONSTRAINTS</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">CONSTRAINT_SCHEMA</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;t&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite：每張表各查一次
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_key_list</span><span class="p">(</span><span class="err">評價</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 0|0|訂單|訂單編號|訂單編號|NO ACTION|NO ACTION|NONE    開關關著的時候也列得出來
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 生不生效
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- SQLite：這條連線的開關
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_keys</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="c1">-- 0 是關、1 是開
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- PostgreSQL：convalidated 是 f 的外鍵是 NOT VALID，既有的列沒有檢查過
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">conname</span><span class="p">,</span><span class="w"> </span><span class="n">convalidated</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_constraint</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">contype</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;f&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="c1">-- 出貨_訂單編號_fkey | f
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1">-- 訂單_顧客編號_fkey | t
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1">-- 評價_訂單編號_fkey | t</span></span></span></code></pre></div><p>生效之後還有一個「什麼時候檢查」的維度。預設是每一句寫入當場檢查，而 PostgreSQL 的 <code>DEFERRABLE INITIALLY DEFERRED</code> 把檢查推到交易提交的那一刻——中間的每一句都通過，<code>COMMIT</code> 才回 <code>violates foreign key constraint</code>。互相指向的兩張表要一起寫入時需要這個推遲——部門有一欄指向它的主管、員工有一欄指向所屬部門，兩欄都不能是空的時候，兩張表都得先有對方的一列才寫得進去。這種形態下沒有推遲就沒有第一筆資料。代價是錯誤浮現的位置離寫錯的那一句遠了一整個交易。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- PostgreSQL 18，兩條外鍵都宣告成 DEFERRABLE INITIALLY DEFERRED
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="p">(</span><span class="err">部門編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="err">主管</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">員工</span><span class="w"> </span><span class="p">(</span><span class="err">員工編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">部門編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">DEFERRABLE</span><span class="w"> </span><span class="k">INITIALLY</span><span class="w"> </span><span class="k">DEFERRED</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">主管</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">員工</span><span class="w"> </span><span class="k">DEFERRABLE</span><span class="w"> </span><span class="k">INITIALLY</span><span class="w"> </span><span class="k">DEFERRED</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">BEGIN</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 員工 10 還不存在，這一句照樣通過
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">員工</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">10</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="k">COMMIT</span><span class="p">;</span><span class="w">                            </span><span class="c1">-- 提交時兩條外鍵都滿足
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="k">BEGIN</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">20</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 通過
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">COMMIT</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="c1">-- ERROR:  insert or update on table &#34;部門&#34; violates foreign key constraint &#34;部門_主管_fkey&#34;
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- DETAIL:  Key (主管)=(20) is not present in table &#34;員工&#34;.
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">--   同樣兩張表不宣告 DEFERRABLE 的話，兩句 INSERT 無論哪一句先寫都當場被擋下</span></span></span></code></pre></div><h2 id="約束生效之後查詢可以省掉的判斷not-null-與外鍵各自的保證">約束生效之後查詢可以省掉的判斷：NOT NULL 與外鍵各自的保證</h2>
<p>一條保證的價值落在它讓哪些判斷不必再做。同一組表的兩個版本擺在一起看得清楚：一邊的 <code>訂單.顧客編號</code> 帶著 <code>NOT NULL REFERENCES 顧客(顧客編號)</code>，另一邊什麼都沒有，而沒有約束的那一版收了一張顧客編號留空的訂單。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- SQLite 3.51，已開 PRAGMA foreign_keys
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- 有約束的版本
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">顧客</span><span class="p">(</span><span class="err">顧客編號</span><span class="p">),</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">101</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;2026-03-02&#39;</span><span class="p">,</span><span class="mi">300</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">102</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;2026-03-09&#39;</span><span class="p">,</span><span class="mi">500</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">200</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- NOT NULL constraint failed: 訂單.顧客編號
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">-- 沒有約束的版本（共用資料的建表語句）收下同一列
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 兩個版本各跑一次
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 有約束：2，訂單表 2 列
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- 沒有約束：2，訂單表 3 列；103 配不到任何顧客，從結果裡消失
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"></span><span class="c1">-- 有約束：宗翰、雅文
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">-- 沒有約束：0 列；子查詢交出 1、1、NULL，與 NULL 比較的那一項是未知</span></span></span></code></pre></div><p>沒有約束的那一版，內連接與 <code>NOT IN</code> 這兩段查詢各自出了一種錯，而兩種都不報錯。內連接掉了一列，而總數看起來仍然像一份完整的訂單清單；<code>NOT IN</code> 整段回空集合，因為子查詢交出的那一欄含一個 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時"><code>NULL</code></a>。內連接為什麼丟掉配不到對象的列，在 <a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">Outer Join（外連接）</a>；<code>NOT IN</code> 的完整推導在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節。</p>
<p>有約束的那一邊，兩段查詢都對，而它們的寫法一個字都沒改。<strong>改變的是那兩個判斷的前提從觀察變成了保證</strong>：翻遍現在的訂單表沒有一筆顧客編號是空的，只證明此刻如此；<code>NOT NULL</code> 證明的是往後也不會有，而查詢要活得比這一批資料久（<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint</a>）。</p>
<p>這裡有兩條約束在做不同的事，分開記各自買到什麼。<strong><code>NOT NULL</code> 買的是「這一欄有值」</strong>，它管掉上面兩種錯——沒有空缺，內連接就掉不了列，<code>NOT IN</code> 也碰不到那個 <code>NULL</code>。<strong>外鍵買的是「這個值指得到人」</strong>，它管的是另一批推論：這一欄有值的列，連過去必定配得到一列。子表的每一列都歸屬到某個父列、<code>JOIN</code> 與 <code>LEFT JOIN</code> 在這個方向上回同一批列，這兩項要兩條約束都在才成立——只有外鍵的時候，顧客編號留空的訂單照樣寫得進去，<code>LEFT JOIN</code> 留下它而 <code>JOIN</code> 丟掉它。兩條都在的時候，「這個連接會掉列嗎」這個問題不必查資料就答得出來。<code>UNIQUE</code> 保證值不重複、<code>CHECK</code> 保證值滿足一個自己寫的條件、<code>PRIMARY KEY</code> 是 <code>NOT NULL</code> 加 <code>UNIQUE</code> 再加上「這是辨識一列的依據」，而這幾種約束共用同一條性質——豁免一個判斷的條件必須是一條約束，不能是一次觀察；各自買到什麼、以及它們為什麼住在 <a href="/blog/backend/01-database/sql/knowledge-cards/ddl-dml/" data-link-title="DDL 與 DML（定義結構與動資料）" data-link-desc="看到「這個帳號只需要 DML 權限」或遷移文件把兩者分開講時，查這條界線切在哪裡">DDL</a> 那一側，在 <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint（約束）</a>。</p>
<p>這些推論的代價落在寫入那一側：每插進一列，資料庫要去父表確認那個值在不在，所以外鍵是拿寫入的工作換讀取的保證。這筆交換要用量測來判，而讀代價的工具在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>。要不要加這條外鍵約束，則是設計的決定：它在強制完整性與演進自由度之間取捨：正式的核心資料通常由資料庫強制，換取每一次讀取都能信任的推論；跨系統整合的資料常改由應用層保護，因為上游的結構可能先變，外鍵會讓那一次變動變成寫入失敗——<a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">Schema Design</a> 的「Table 與 Relation」一節寫這個取捨，並提醒宣告與執法要分開看。</p>
<h2 id="父列被刪除時子列的去向on-delete-的選項">父列被刪除時子列的去向：ON DELETE 的選項</h2>
<p>擋下刪除是預設，而它把問題丟回給寫入的那一方：顧客要停用了，他的訂單怎麼辦。這個決定寫在外鍵的宣告裡，跟著約束走而不是跟著每次刪除走。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">訂單</span><span class="p">(</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">CASCADE</span><span class="w">      </span><span class="c1">-- 子列一起刪掉
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">訂單</span><span class="p">(</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">NULL</span><span class="w">     </span><span class="c1">-- 子列留著，這一欄清空
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">0</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">            </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">訂單</span><span class="p">(</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w">  </span><span class="c1">-- 子列留著，改指一個哨兵列</span></span></span></code></pre></div><p>在 PostgreSQL 18 上刪掉訂單 102，三種寫法的子列各自落到宣告寫的去向：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 三張評價表照上面三行宣告，各有一列 (9001, 102, 4)；訂單 0 是事先備好的哨兵列
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">102</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_連帶刪除</span><span class="p">;</span><span class="w">   </span><span class="c1">-- ON DELETE CASCADE：0 列
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_清空</span><span class="p">;</span><span class="w">       </span><span class="c1">-- ON DELETE SET NULL：9001 |   | 4，訂單編號變成空的
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_改指哨兵</span><span class="p">;</span><span class="w">   </span><span class="c1">-- ON DELETE SET DEFAULT：9001 | 0 | 4，改指哨兵列</span></span></span></code></pre></div><p>擋下刪除的則有兩種寫法而它們不一樣：<code>RESTRICT</code> 在那一句 <code>DELETE</code> 當場擋下，而且不能推遲；<code>NO ACTION</code>（沒寫 <code>ON DELETE</code> 時的預設）在每一句結束時檢查，約束宣告成 <code>DEFERRABLE</code> 之後才能推到交易提交。同一個交易裡先刪父列、再刪子列，只有宣告成 <code>DEFERRABLE</code> 的 <code>NO ACTION</code> 過得去。</p>
<p><strong>子列離開父列之後還有沒有意義，決定它跟著刪掉還是留下。</strong> 一則評價離開它評的那張訂單之後說不出在評什麼，所以它適合跟著走；一筆出貨紀錄即使訂單被撤銷仍然是發生過的事實，清空指向比刪掉它更貼近實情。</p>
<p><strong>這一列有沒有自己的保留義務，是獨立的另一個問句，而它成立的時候蓋過「還有沒有意義」的答案。</strong> 一筆交易紀錄離開它的帳戶之後同樣說不出在記什麼，只問還有沒有意義會走到 <code>CASCADE</code>——而受稽核或法規保留的紀錄不能被任何一次刪除帶走。保留義務成立的時候，答案落在 <code>RESTRICT</code> 或 <code>SET DEFAULT</code> 上（<code>RESTRICT</code> 不准刪父列，<code>SET DEFAULT</code> 讓子列改指一個「已刪除」的哨兵列，紀錄留著而那一欄不為空），而只問還有沒有意義的時候，這兩個選項不會被想到。保留義務要優先的理由是 <code>CASCADE</code> 的一個性質：<strong>一次刪除實際刪掉多少，由表之間的結構決定，不由那句 <code>DELETE</code> 決定</strong>。它沿著外鍵一路傳下去，刪一個顧客連帶刪掉他的訂單、那些訂單的評價，以及再往下的每一層，而那句 SQL 的文字裡只提到顧客。</p>
<p><code>SET NULL</code> 與同一欄上的 <code>NOT NULL</code> 要求的條件互相排斥，而建表的時候兩者並存不會被擋下。PostgreSQL 18 收下這張表，衝突到刪除父列的那一刻才浮現，訊息指的是那個清空的動作違反了 <code>NOT NULL</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- PostgreSQL 18
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="p">(</span><span class="err">評價編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">                   </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">訂單</span><span class="p">(</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w"> </span><span class="err">星等</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 建表成功
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">9001</span><span class="p">,</span><span class="w"> </span><span class="mi">101</span><span class="p">,</span><span class="w"> </span><span class="mi">5</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">101</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- ERROR:  null value in column &#34;訂單編號&#34; of relation &#34;評價&#34; violates not-null constraint
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- DETAIL:  Failing row contains (9001, null, 5).
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- CONTEXT:  SQL statement &#34;UPDATE ONLY &#34;public&#34;.&#34;評價&#34; SET &#34;訂單編號&#34; = NULL WHERE ...&#34;</span></span></span></code></pre></div><p><strong>宣告與生效那條分界在這裡又出現一次：宣告收下了，做得到做不到要等到執行的那一刻。</strong> 而 <code>NOT NULL</code> 買掉的正是掉列與 <code>NOT IN</code> 那兩類錯誤，所以這裡要選一邊——留下子列的代價是那一欄重新可能為空，關於它的推論回到觀察那一級。</p>
<h2 id="外鍵涵蓋不到的寫入與規則">外鍵涵蓋不到的寫入與規則</h2>
<p>外鍵的檢查發生在寫入的那一刻，所以它的射程由「哪些寫入經過它」決定。</p>
<p><strong>既有的資料不在射程裡。</strong> 補一條外鍵到已經有孤兒列的表上，這個動作本身會失敗：</p>





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