<?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>Orm on Tarragon</title><link>https://tarrragon.github.io/blog/tags/orm/</link><description>Recent content in Orm on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 06 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/orm/index.xml" rel="self" type="application/rss+xml"/><item><title>8.2 運算發生的位置：資料庫端與記憶體端的工具、分工條件與 DuckDB</title><link>https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/where-computation-runs/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/where-computation-runs/</guid><description>&lt;p>整批資料有兩個地方可以算：留在資料庫裡，或搬進程式的記憶體。這條分界決定了工具的形狀、它的容量上限，以及它的失敗形態。本模組其餘各篇的判斷標準都折算回這一條。&lt;/p>
&lt;h2 id="資料庫端db-api查詢建構器與-orm-的寫法">資料庫端：DB-API、查詢建構器與 ORM 的寫法&lt;/h2>
&lt;p>程式送出的是一段描述要算什麼的文字，資料庫收下之後自己完成篩選、合併與彙總，把結果送回來。原始資料從頭到尾沒有進入程式。&lt;/p>
&lt;p>Python 這一側有三種寫法，差別只在那段 SQL 由誰組出來。&lt;/p>
&lt;p>&lt;strong>自己寫 SQL 字串&lt;/strong>：&lt;code>sqlite3&lt;/code>、&lt;code>psycopg2&lt;/code> 這類套件遵循 DB-API 這個標準介面，程式把 SQL 交給它，它負責送出與取回。&lt;/p>
&lt;p>&lt;strong>用方法串出 SQL&lt;/strong>：SQLAlchemy Core 這類查詢建構器提供 Python 的方法對應 SQL 的子句，組出來的仍然是一段 SQL。&lt;/p>
&lt;p>&lt;strong>用類別描述表&lt;/strong>：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM&lt;/a> 這一類工具（SQLAlchemy、Django ORM）把資料表對應成類別、把資料列對應成物件，篩選條件寫成屬性比較，框架據此組出 SQL，並把回來的每一列包成物件。&lt;/p>
&lt;p>三者的共同點比差異重要：&lt;strong>運算在資料庫那一端發生&lt;/strong>，程式收到的是算完的結果。編譯出來的 SQL 可以直接印出來看：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">sqlalchemy&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">select&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="n">stmt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">select&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Customer&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">where&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Customer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">age&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">18&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">stmt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">compile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">engine&lt;/span>&lt;span class="p">))&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"># SELECT customer.id, customer.name, customer.age FROM customer WHERE customer.age &amp;gt; ?&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="記憶體端pandas-與-dataframe">記憶體端：pandas 與 DataFrame&lt;/h2>
&lt;p>搬進記憶體端的做法是把資料整批載進程式，運算由程式自己完成。pandas 屬於這一側，它的容器是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/dataframe/" data-link-title="DataFrame（資料框）" data-link-desc="SQL 教材拿 pandas 或資料框當對照時，查 DataFrame 是什麼、它與資料表差在哪">DataFrame&lt;/a>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">df&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">df&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;age&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">18&lt;/span>&lt;span class="p">]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一行沒有連線、沒有查詢語句、沒有資料庫。&lt;code>df&lt;/code> 是一張已經在記憶體裡的表格，這一行做的是對一個布林陣列取值。資料的來源可以是資料庫，也可以是 CSV、日誌檔或某個 API 回傳的內容——載進來之後，接下來的每一步運算都寫成一樣的形式，來源是哪一種不再影響寫法。&lt;/p>
&lt;h2 id="兩端分工的條件資料量sql-的表達能力算完之後的用途">兩端分工的條件：資料量、SQL 的表達能力、算完之後的用途&lt;/h2>
&lt;p>&lt;strong>資料量。&lt;/strong> 記憶體端要把資料整批放進來，所以容量是硬上限；資料庫端沒有這個限制，它一次只需要把符合條件的部分送回來。&lt;/p>
&lt;p>&lt;strong>要算的運算 SQL 表達得出來嗎。&lt;/strong> 篩選、合併、分組彙總、排名，SQL 都寫得出來。統計檢定、時間序列的滑動窗口、把資料餵給模型，SQL 寫起來吃力或根本沒有對應語法，這些落在記憶體端。&lt;/p>
&lt;p>&lt;strong>算完之後還要做什麼。&lt;/strong> 結果如果只是要回傳給使用者或寫回另一張表，留在資料庫端的路徑最短——資料不必先進記憶體再送出去；如果後面還要接繪圖、統計或模型訓練，資料終究要進記憶體，那就沒有理由分兩段做。&lt;/p>
&lt;p>實務上的常見形態是兩端接力：用 SQL 把資料量先壓下來，再把壓縮過的結果搬進記憶體做後面的部分。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">df&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pd&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">read_sql&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SELECT * FROM orders WHERE year = 2024&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">)&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="duckdb在同一個程序裡用-sql-查-dataframe-與檔案">DuckDB：在同一個程序裡用 SQL 查 DataFrame 與檔案&lt;/h2>
&lt;p>DuckDB 兩邊都站：它接受 SQL，而查詢的對象可以是記憶體裡的 DataFrame，也可以是磁碟上的檔案。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">duckdb&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sql&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SELECT dept, count(*) c FROM emp GROUP BY dept&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># emp 是一個 DataFrame&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>它把「資料庫」這個角色從一台獨立的伺服器換成同一個程序裡的運算引擎，而軸本身仍在——差別只在資料要不要複製一份進 Python 的記憶體。&lt;/p>
&lt;h2 id="延伸閱讀orm-與-dataframe-的分辨sql-與-pandas-的寫法對應pandas-的記憶體邊界資料庫端的工程議題">延伸閱讀：ORM 與 DataFrame 的分辨、SQL 與 pandas 的寫法對應、pandas 的記憶體邊界、資料庫端的工程議題&lt;/h2>
&lt;p>&lt;strong>從回傳值認出手上是哪一類&lt;/strong>：資料庫端的工具回傳的是物件或資料列，記憶體端的工具回傳的是一張新的表。&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/orm-and-dataframe/" data-link-title="8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機" data-link-desc="ORM 產生查詢、DataFrame 執行運算，以及外觀相似的方法串接底下差了什麼">8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機&lt;/a> 寫兩者各自產出什麼，以及外觀相似的方法串接底下差了什麼。&lt;/p>
&lt;p>&lt;strong>兩端共用的那套抽象&lt;/strong>：篩選、合併、分組彙總在兩端都有，而且對應得起來。&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/same-relational-algebra/" data-link-title="8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異" data-link-desc="SQL 與 DataFrame 共用的關聯代數、同一份資料上兩邊寫法的實測比對，以及照著逐句翻譯會靜默失準的位置">8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異&lt;/a> 給篩選、連接、分組彙總與視窗函數在兩邊的實測比對，以及空值、索引與順序這幾處對應斷掉的位置。&lt;/p>
&lt;p>&lt;strong>記憶體端的容量上限&lt;/strong>：&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/memory-is-the-boundary/" data-link-title="8.5 pandas 的記憶體邊界：資料與運算過程的佔用估算，推回資料庫、換引擎與分散式的代價" data-link-desc="數值欄與字串欄的佔用怎麼得出、合併這類操作的中間結果佔多少，以及記憶體不足與跑得慢這兩種失敗怎麼分辨">8.5 pandas 的記憶體邊界：資料與運算過程的佔用估算，推回資料庫、換引擎與分散式的代價&lt;/a> 給資料量的估算方法、運算過程額外要的空間，以及越過之後三個方向各自付什麼代價。&lt;/p>
&lt;p>&lt;strong>資料庫端那一側的工程議題&lt;/strong>：連線池、交易邊界、一次請求該打幾次資料庫，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 模組一&lt;/a>。那個模組從 N+1 與延遲載入這些形態進去，並給出每請求查詢次數預算當判讀基準。&lt;/p></description><content:encoded><![CDATA[<p>整批資料有兩個地方可以算：留在資料庫裡，或搬進程式的記憶體。這條分界決定了工具的形狀、它的容量上限，以及它的失敗形態。本模組其餘各篇的判斷標準都折算回這一條。</p>
<h2 id="資料庫端db-api查詢建構器與-orm-的寫法">資料庫端：DB-API、查詢建構器與 ORM 的寫法</h2>
<p>程式送出的是一段描述要算什麼的文字，資料庫收下之後自己完成篩選、合併與彙總，把結果送回來。原始資料從頭到尾沒有進入程式。</p>
<p>Python 這一側有三種寫法，差別只在那段 SQL 由誰組出來。</p>
<p><strong>自己寫 SQL 字串</strong>：<code>sqlite3</code>、<code>psycopg2</code> 這類套件遵循 DB-API 這個標準介面，程式把 SQL 交給它，它負責送出與取回。</p>
<p><strong>用方法串出 SQL</strong>：SQLAlchemy Core 這類查詢建構器提供 Python 的方法對應 SQL 的子句，組出來的仍然是一段 SQL。</p>
<p><strong>用類別描述表</strong>：<a href="/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM</a> 這一類工具（SQLAlchemy、Django ORM）把資料表對應成類別、把資料列對應成物件，篩選條件寫成屬性比較，框架據此組出 SQL，並把回來的每一列包成物件。</p>
<p>三者的共同點比差異重要：<strong>運算在資料庫那一端發生</strong>，程式收到的是算完的結果。編譯出來的 SQL 可以直接印出來看：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">sqlalchemy</span> <span class="kn">import</span> <span class="n">select</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">stmt</span> <span class="o">=</span> <span class="n">select</span><span class="p">(</span><span class="n">Customer</span><span class="p">)</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="n">Customer</span><span class="o">.</span><span class="n">age</span> <span class="o">&gt;</span> <span class="mi">18</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">stmt</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="n">engine</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># SELECT customer.id, customer.name, customer.age FROM customer WHERE customer.age &gt; ?</span></span></span></code></pre></div><h2 id="記憶體端pandas-與-dataframe">記憶體端：pandas 與 DataFrame</h2>
<p>搬進記憶體端的做法是把資料整批載進程式，運算由程式自己完成。pandas 屬於這一側，它的容器是 <a href="/blog/backend/01-database/sql/knowledge-cards/dataframe/" data-link-title="DataFrame（資料框）" data-link-desc="SQL 教材拿 pandas 或資料框當對照時，查 DataFrame 是什麼、它與資料表差在哪">DataFrame</a>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">df</span><span class="p">[</span><span class="n">df</span><span class="p">[</span><span class="s2">&#34;age&#34;</span><span class="p">]</span> <span class="o">&gt;</span> <span class="mi">18</span><span class="p">]</span></span></span></code></pre></div><p>這一行沒有連線、沒有查詢語句、沒有資料庫。<code>df</code> 是一張已經在記憶體裡的表格，這一行做的是對一個布林陣列取值。資料的來源可以是資料庫，也可以是 CSV、日誌檔或某個 API 回傳的內容——載進來之後，接下來的每一步運算都寫成一樣的形式，來源是哪一種不再影響寫法。</p>
<h2 id="兩端分工的條件資料量sql-的表達能力算完之後的用途">兩端分工的條件：資料量、SQL 的表達能力、算完之後的用途</h2>
<p><strong>資料量。</strong> 記憶體端要把資料整批放進來，所以容量是硬上限；資料庫端沒有這個限制，它一次只需要把符合條件的部分送回來。</p>
<p><strong>要算的運算 SQL 表達得出來嗎。</strong> 篩選、合併、分組彙總、排名，SQL 都寫得出來。統計檢定、時間序列的滑動窗口、把資料餵給模型，SQL 寫起來吃力或根本沒有對應語法，這些落在記憶體端。</p>
<p><strong>算完之後還要做什麼。</strong> 結果如果只是要回傳給使用者或寫回另一張表，留在資料庫端的路徑最短——資料不必先進記憶體再送出去；如果後面還要接繪圖、統計或模型訓練，資料終究要進記憶體，那就沒有理由分兩段做。</p>
<p>實務上的常見形態是兩端接力：用 SQL 把資料量先壓下來，再把壓縮過的結果搬進記憶體做後面的部分。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">df</span> <span class="o">=</span> <span class="n">pd</span><span class="o">.</span><span class="n">read_sql</span><span class="p">(</span><span class="s2">&#34;SELECT * FROM orders WHERE year = 2024&#34;</span><span class="p">,</span> <span class="n">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 這一行之後全部在記憶體裡，與資料庫無關</span></span></span></code></pre></div><h2 id="duckdb在同一個程序裡用-sql-查-dataframe-與檔案">DuckDB：在同一個程序裡用 SQL 查 DataFrame 與檔案</h2>
<p>DuckDB 兩邊都站：它接受 SQL，而查詢的對象可以是記憶體裡的 DataFrame，也可以是磁碟上的檔案。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">duckdb</span><span class="o">.</span><span class="n">sql</span><span class="p">(</span><span class="s2">&#34;SELECT dept, count(*) c FROM emp GROUP BY dept&#34;</span><span class="p">)</span>  <span class="c1"># emp 是一個 DataFrame</span></span></span></code></pre></div><p>它把「資料庫」這個角色從一台獨立的伺服器換成同一個程序裡的運算引擎，而軸本身仍在——差別只在資料要不要複製一份進 Python 的記憶體。</p>
<h2 id="延伸閱讀orm-與-dataframe-的分辨sql-與-pandas-的寫法對應pandas-的記憶體邊界資料庫端的工程議題">延伸閱讀：ORM 與 DataFrame 的分辨、SQL 與 pandas 的寫法對應、pandas 的記憶體邊界、資料庫端的工程議題</h2>
<p><strong>從回傳值認出手上是哪一類</strong>：資料庫端的工具回傳的是物件或資料列，記憶體端的工具回傳的是一張新的表。<a href="/blog/programming/python/basic/08-data-analysis/orm-and-dataframe/" data-link-title="8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機" data-link-desc="ORM 產生查詢、DataFrame 執行運算，以及外觀相似的方法串接底下差了什麼">8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機</a> 寫兩者各自產出什麼，以及外觀相似的方法串接底下差了什麼。</p>
<p><strong>兩端共用的那套抽象</strong>：篩選、合併、分組彙總在兩端都有，而且對應得起來。<a href="/blog/programming/python/basic/08-data-analysis/same-relational-algebra/" data-link-title="8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異" data-link-desc="SQL 與 DataFrame 共用的關聯代數、同一份資料上兩邊寫法的實測比對，以及照著逐句翻譯會靜默失準的位置">8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異</a> 給篩選、連接、分組彙總與視窗函數在兩邊的實測比對，以及空值、索引與順序這幾處對應斷掉的位置。</p>
<p><strong>記憶體端的容量上限</strong>：<a href="/blog/programming/python/basic/08-data-analysis/memory-is-the-boundary/" data-link-title="8.5 pandas 的記憶體邊界：資料與運算過程的佔用估算，推回資料庫、換引擎與分散式的代價" data-link-desc="數值欄與字串欄的佔用怎麼得出、合併這類操作的中間結果佔多少，以及記憶體不足與跑得慢這兩種失敗怎麼分辨">8.5 pandas 的記憶體邊界：資料與運算過程的佔用估算，推回資料庫、換引擎與分散式的代價</a> 給資料量的估算方法、運算過程額外要的空間，以及越過之後三個方向各自付什麼代價。</p>
<p><strong>資料庫端那一側的工程議題</strong>：連線池、交易邊界、一次請求該打幾次資料庫，在 <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 模組一</a>。那個模組從 N+1 與延遲載入這些形態進去，並給出每請求查詢次數預算當判讀基準。</p>
]]></content:encoded></item><item><title>8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機</title><link>https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/orm-and-dataframe/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/orm-and-dataframe/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM&lt;/a> 的產出是一段 SQL 與一組物件，DataFrame 的產出是一張新的表格。兩者的寫法看起來相似，因為都用方法串接描述篩選條件，而底下發生的事完全不同：ORM 是查詢的產生器，DataFrame 是運算的執行者。&lt;/p>
&lt;h2 id="orm-的產出與運算發生的位置">ORM 的產出與運算發生的位置&lt;/h2>
&lt;p>ORM 把資料表對應成類別、把資料列對應成物件。篩選條件寫成屬性的比較式，框架把它翻譯成 SQL 的 &lt;code>WHERE&lt;/code> 子句。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">sqlalchemy&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">select&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">sqlalchemy.orm&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Session&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="n">stmt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">select&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Customer&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">where&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Customer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">age&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">18&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">stmt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">compile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">engine&lt;/span>&lt;span class="p">))&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"># SELECT customer.id, customer.name, customer.age FROM customer WHERE customer.age &amp;gt; ?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">Session&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">engine&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">rows&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">scalars&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">stmt&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">all&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="nb">type&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="vm">__name__&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">rows&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">rows&lt;/span>&lt;span class="p">])&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"># [&amp;#39;Customer&amp;#39;] [&amp;#39;A&amp;#39;]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>stmt&lt;/code> 本身還沒有做任何運算，它是一段待送出的查詢。運算在資料庫那一端完成，回來的每一列被包成一個 &lt;code>Customer&lt;/code> 實例——所以拿到的是物件，可以呼叫它的方法、走訪它關聯到的其他物件。&lt;/p>
&lt;h2 id="dataframe-的產出與運算發生的位置">DataFrame 的產出與運算發生的位置&lt;/h2>
&lt;p>pandas 這一側的同一件事：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">df&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pd&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">DataFrame&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;A&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;B&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;age&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">]})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="n">df&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">df&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;age&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">18&lt;/span>&lt;span class="p">]&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"># id name age&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"># 0 1 A 20&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>df[&amp;quot;age&amp;quot;] &amp;gt; 18&lt;/code> 先算出一個布林陣列 &lt;code>[True, False]&lt;/code>，外層的取值用這個陣列挑出要留下的列。整段運算發生在這個程序的記憶體裡，沒有連線、沒有 SQL、沒有另一個系統參與。&lt;/p>
&lt;p>回來的是一張新的表格，不是一組物件。它可以繼續被篩選、被合併、被彙總，因為表格正是這些操作接受的輸入。&lt;/p>
&lt;h2 id="分辨-orm-與-dataframe-的訊號回傳值的形狀連線物件查詢送出的時機">分辨 ORM 與 DataFrame 的訊號：回傳值的形狀、連線物件、查詢送出的時機&lt;/h2>
&lt;p>外觀相似的方法串接底下，有三個分得開的訊號。&lt;/p>
&lt;p>&lt;strong>回傳值的形狀。&lt;/strong> 一組各自獨立、可以呼叫方法的實例，是 ORM 那一側；一張有欄有列、可以繼續被篩選與彙總的表格，是 DataFrame 那一側。&lt;/p>
&lt;p>&lt;strong>有沒有連線物件。&lt;/strong> 資料庫端的工具一定要先有一個連線或 session，因為運算要送去別的地方。pandas 的操作不需要：資料一旦載進 DataFrame，之後每一步都在這份記憶體裡的副本上進行，原本那個資料庫或檔案後來有沒有變動都不影響它。&lt;/p>
&lt;p>&lt;strong>查詢送出的時機。&lt;/strong> ORM 允許先把查詢組好、之後才送出，所以「描述」與「執行」是兩個時刻。pandas 的每一行敘述當場就算完，中間沒有待送出的狀態。&lt;/p>
&lt;h2 id="各語言生態的資料庫存取分層手寫-sql查詢建構器orm">各語言生態的資料庫存取分層：手寫 SQL、查詢建構器、ORM&lt;/h2>
&lt;p>自己寫 SQL、用建構器組 SQL、用 ORM 對應物件，這三層在多數語言生態裡都找得到對應的套件，名字不同而分層一致。各語言的代表工具，以及手寫 SQL 再細分成驅動、結果對映、從 SQL 產生程式碼的方式，見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&amp;#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層&lt;/a>。&lt;/p>
&lt;p>DataFrame 這一層則是額外的第四種，它不在上面那條線上——它處理的是已經在記憶體裡的表格，與資料庫的存取方式無關。一般的增刪改查應用不使用它，那是 ORM 的位置。&lt;/p>
&lt;h2 id="延伸閱讀sql-與-pandas-的寫法對應運算發生的位置pandas-的記憶體邊界orm-的查詢反模式">延伸閱讀：SQL 與 pandas 的寫法對應、運算發生的位置、pandas 的記憶體邊界、ORM 的查詢反模式&lt;/h2>
&lt;p>&lt;strong>這兩側共用的抽象&lt;/strong>：ORM 產生的 SQL 與 DataFrame 的方法，做的是同一組操作。&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/same-relational-algebra/" data-link-title="8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異" data-link-desc="SQL 與 DataFrame 共用的關聯代數、同一份資料上兩邊寫法的實測比對，以及照著逐句翻譯會靜默失準的位置">8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異&lt;/a> 給篩選、連接、分組彙總與視窗函數在兩邊的實測比對，以及空值、索引與順序這幾處對應斷掉的位置。&lt;/p>
&lt;p>&lt;strong>分界本身&lt;/strong>：兩側的差異全部從「運算發生在哪一端」推出來。&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/where-computation-runs/" data-link-title="8.2 運算發生的位置：資料庫端與記憶體端的工具、分工條件與 DuckDB" data-link-desc="運算留在資料庫端或搬進記憶體端這條分界，怎麼決定工具的形狀、容量上限與失敗形態">8.2 運算發生的位置&lt;/a> 寫這條軸怎麼決定容量上限與失敗形態，以及選邊時要問的三個條件。&lt;/p>
&lt;p>&lt;strong>選了記憶體端之後的上限&lt;/strong>：&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/memory-is-the-boundary/" data-link-title="8.5 pandas 的記憶體邊界：資料與運算過程的佔用估算，推回資料庫、換引擎與分散式的代價" data-link-desc="數值欄與字串欄的佔用怎麼得出、合併這類操作的中間結果佔多少，以及記憶體不足與跑得慢這兩種失敗怎麼分辨">8.5 pandas 的記憶體邊界：資料與運算過程的佔用估算，推回資料庫、換引擎與分散式的代價&lt;/a> 給資料量的估算方法與越過之後的三個方向。&lt;/p>
&lt;p>&lt;strong>ORM 那一側的失敗形態&lt;/strong>：延遲載入什麼時候把一次請求變成一百次查詢，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&amp;#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">backend 模組一的查詢反模式&lt;/a>。那一篇把 N+1 與長交易列成可診斷的清單，並給出每請求的查詢次數預算。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM</a> 的產出是一段 SQL 與一組物件，DataFrame 的產出是一張新的表格。兩者的寫法看起來相似，因為都用方法串接描述篩選條件，而底下發生的事完全不同：ORM 是查詢的產生器，DataFrame 是運算的執行者。</p>
<h2 id="orm-的產出與運算發生的位置">ORM 的產出與運算發生的位置</h2>
<p>ORM 把資料表對應成類別、把資料列對應成物件。篩選條件寫成屬性的比較式，框架把它翻譯成 SQL 的 <code>WHERE</code> 子句。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">sqlalchemy</span> <span class="kn">import</span> <span class="n">select</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">sqlalchemy.orm</span> <span class="kn">import</span> <span class="n">Session</span>
</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 class="n">stmt</span> <span class="o">=</span> <span class="n">select</span><span class="p">(</span><span class="n">Customer</span><span class="p">)</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="n">Customer</span><span class="o">.</span><span class="n">age</span> <span class="o">&gt;</span> <span class="mi">18</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">stmt</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="n">engine</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># SELECT customer.id, customer.name, customer.age FROM customer WHERE customer.age &gt; ?</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">with</span> <span class="n">Session</span><span class="p">(</span><span class="n">engine</span><span class="p">)</span> <span class="k">as</span> <span class="n">s</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">rows</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">scalars</span><span class="p">(</span><span class="n">stmt</span><span class="p">)</span><span class="o">.</span><span class="n">all</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nb">print</span><span class="p">([</span><span class="nb">type</span><span class="p">(</span><span class="n">r</span><span class="p">)</span><span class="o">.</span><span class="vm">__name__</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">rows</span><span class="p">],</span> <span class="p">[</span><span class="n">r</span><span class="o">.</span><span class="n">name</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">rows</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="c1"># [&#39;Customer&#39;] [&#39;A&#39;]</span></span></span></code></pre></div><p><code>stmt</code> 本身還沒有做任何運算，它是一段待送出的查詢。運算在資料庫那一端完成，回來的每一列被包成一個 <code>Customer</code> 實例——所以拿到的是物件，可以呼叫它的方法、走訪它關聯到的其他物件。</p>
<h2 id="dataframe-的產出與運算發生的位置">DataFrame 的產出與運算發生的位置</h2>
<p>pandas 這一側的同一件事：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">df</span> <span class="o">=</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">({</span><span class="s2">&#34;id&#34;</span><span class="p">:</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">],</span> <span class="s2">&#34;name&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;A&#34;</span><span class="p">,</span> <span class="s2">&#34;B&#34;</span><span class="p">],</span> <span class="s2">&#34;age&#34;</span><span class="p">:</span> <span class="p">[</span><span class="mi">20</span><span class="p">,</span> <span class="mi">10</span><span class="p">]})</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">df</span><span class="p">[</span><span class="n">df</span><span class="p">[</span><span class="s2">&#34;age&#34;</span><span class="p">]</span> <span class="o">&gt;</span> <span class="mi">18</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">#    id name  age</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 0   1    A   20</span></span></span></code></pre></div><p><code>df[&quot;age&quot;] &gt; 18</code> 先算出一個布林陣列 <code>[True, False]</code>，外層的取值用這個陣列挑出要留下的列。整段運算發生在這個程序的記憶體裡，沒有連線、沒有 SQL、沒有另一個系統參與。</p>
<p>回來的是一張新的表格，不是一組物件。它可以繼續被篩選、被合併、被彙總，因為表格正是這些操作接受的輸入。</p>
<h2 id="分辨-orm-與-dataframe-的訊號回傳值的形狀連線物件查詢送出的時機">分辨 ORM 與 DataFrame 的訊號：回傳值的形狀、連線物件、查詢送出的時機</h2>
<p>外觀相似的方法串接底下，有三個分得開的訊號。</p>
<p><strong>回傳值的形狀。</strong> 一組各自獨立、可以呼叫方法的實例，是 ORM 那一側；一張有欄有列、可以繼續被篩選與彙總的表格，是 DataFrame 那一側。</p>
<p><strong>有沒有連線物件。</strong> 資料庫端的工具一定要先有一個連線或 session，因為運算要送去別的地方。pandas 的操作不需要：資料一旦載進 DataFrame，之後每一步都在這份記憶體裡的副本上進行，原本那個資料庫或檔案後來有沒有變動都不影響它。</p>
<p><strong>查詢送出的時機。</strong> ORM 允許先把查詢組好、之後才送出，所以「描述」與「執行」是兩個時刻。pandas 的每一行敘述當場就算完，中間沒有待送出的狀態。</p>
<h2 id="各語言生態的資料庫存取分層手寫-sql查詢建構器orm">各語言生態的資料庫存取分層：手寫 SQL、查詢建構器、ORM</h2>
<p>自己寫 SQL、用建構器組 SQL、用 ORM 對應物件，這三層在多數語言生態裡都找得到對應的套件，名字不同而分層一致。各語言的代表工具，以及手寫 SQL 再細分成驅動、結果對映、從 SQL 產生程式碼的方式，見 <a href="/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層</a>。</p>
<p>DataFrame 這一層則是額外的第四種，它不在上面那條線上——它處理的是已經在記憶體裡的表格，與資料庫的存取方式無關。一般的增刪改查應用不使用它，那是 ORM 的位置。</p>
<h2 id="延伸閱讀sql-與-pandas-的寫法對應運算發生的位置pandas-的記憶體邊界orm-的查詢反模式">延伸閱讀：SQL 與 pandas 的寫法對應、運算發生的位置、pandas 的記憶體邊界、ORM 的查詢反模式</h2>
<p><strong>這兩側共用的抽象</strong>：ORM 產生的 SQL 與 DataFrame 的方法，做的是同一組操作。<a href="/blog/programming/python/basic/08-data-analysis/same-relational-algebra/" data-link-title="8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異" data-link-desc="SQL 與 DataFrame 共用的關聯代數、同一份資料上兩邊寫法的實測比對，以及照著逐句翻譯會靜默失準的位置">8.4 SQL 子句與 pandas 方法的對應：篩選、連接、分組彙總、視窗函數，以及空值、索引與順序的語意差異</a> 給篩選、連接、分組彙總與視窗函數在兩邊的實測比對，以及空值、索引與順序這幾處對應斷掉的位置。</p>
<p><strong>分界本身</strong>：兩側的差異全部從「運算發生在哪一端」推出來。<a href="/blog/programming/python/basic/08-data-analysis/where-computation-runs/" data-link-title="8.2 運算發生的位置：資料庫端與記憶體端的工具、分工條件與 DuckDB" data-link-desc="運算留在資料庫端或搬進記憶體端這條分界，怎麼決定工具的形狀、容量上限與失敗形態">8.2 運算發生的位置</a> 寫這條軸怎麼決定容量上限與失敗形態，以及選邊時要問的三個條件。</p>
<p><strong>選了記憶體端之後的上限</strong>：<a href="/blog/programming/python/basic/08-data-analysis/memory-is-the-boundary/" data-link-title="8.5 pandas 的記憶體邊界：資料與運算過程的佔用估算，推回資料庫、換引擎與分散式的代價" data-link-desc="數值欄與字串欄的佔用怎麼得出、合併這類操作的中間結果佔多少，以及記憶體不足與跑得慢這兩種失敗怎麼分辨">8.5 pandas 的記憶體邊界：資料與運算過程的佔用估算，推回資料庫、換引擎與分散式的代價</a> 給資料量的估算方法與越過之後的三個方向。</p>
<p><strong>ORM 那一側的失敗形態</strong>：延遲載入什麼時候把一次請求變成一百次查詢，在 <a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">backend 模組一的查詢反模式</a>。那一篇把 N+1 與長交易列成可診斷的清單，並給出每請求的查詢次數預算。</p>
]]></content:encoded></item><item><title>10.5 GORM：model、關聯載入與 ORM 送出的 SQL</title><link>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm/</guid><description>&lt;p>這篇整理 &lt;a href="https://gorm.io/">GORM&lt;/a> v1.31.2 搭配 PostgreSQL 方言套件 v1.6.3 的用法（取得套件：&lt;code>go get gorm.io/gorm@v1.31.2 gorm.io/driver/postgres@v1.6.3&lt;/code>）。範例沿用 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/database-sql/#%e7%af%84%e4%be%8b%e8%b3%87%e6%96%99%e8%a1%a8" data-link-title="10.1 database/sql：連線池、查詢結果的讀取與交易" data-link-desc="Go 標準庫存取資料庫的模型：*sql.DB 是連線池、驅動怎麼註冊、Scan 與 NULL、查詢結果的讀取物件（*sql.Rows）沒關閉時的連線洩漏、交易的寫法，以及本模組其餘工具各自接手這一層的哪些工作">10.1 database/sql：連線池、查詢結果的讀取與交易&lt;/a> 定義的 &lt;code>customers&lt;/code> 與 &lt;code>orders&lt;/code> 兩張表。在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&amp;#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層&lt;/a> 的分類裡，GORM 是 ORM：SQL 由方法呼叫組出、查詢結果依命名慣例對映、寫入時預設包一層交易，三件事都由它接手。這篇多數節附上 GORM 實際送出的 SQL，因為用 ORM 的程式碼，要讀的是那些 SQL。&lt;/p>
&lt;p>GORM 的 PostgreSQL 方言套件（&lt;code>gorm.io/driver/postgres&lt;/code>，GORM 稱為 dialector）底層用的是 pgx 的 &lt;code>database/sql&lt;/code> 驅動，所以 &lt;code>database/sql&lt;/code> 那一篇的連線池設定照樣適用：&lt;code>db.DB()&lt;/code> 取得底下的 &lt;code>*sql.DB&lt;/code>，在它上面呼叫 &lt;code>SetMaxOpenConns&lt;/code>。&lt;/p>
&lt;h2 id="model-與命名慣例">model 與命名慣例&lt;/h2>
&lt;p>GORM 用 struct 描述資料表，GORM 稱這種 struct 為 model，欄位與表名依命名慣例推出：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Customer&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">	&lt;span class="nx">ID&lt;/span> &lt;span class="kt">int64&lt;/span> &lt;span class="c1">// 名為 ID 的欄位是主鍵&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">	&lt;span class="nx">Name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">	&lt;span class="nx">Email&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">	&lt;span class="nx">CreatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span> &lt;span class="c1">// 名為 CreatedAt 的欄位，建立時由 GORM 填入現在時間&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">	&lt;span class="nx">Orders&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Order&lt;/span> &lt;span class="c1">// 一對多關聯：orders.customer_id 指向 customers.id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Order&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">	&lt;span class="nx">ID&lt;/span> &lt;span class="kt">int64&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">	&lt;span class="nx">CustomerID&lt;/span> &lt;span class="kt">int64&lt;/span> &lt;span class="c1">// 欄位 customer_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">	&lt;span class="nx">Amount&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">	&lt;span class="nx">Note&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="kt">string&lt;/span> &lt;span class="c1">// 可以是 NULL&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">	&lt;span class="nx">OrderedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Customer&lt;/code> 對到資料表 &lt;code>customers&lt;/code>（struct 名稱轉成蛇形命名（snake_case）再加複數），&lt;code>CustomerID&lt;/code> 對到欄位 &lt;code>customer_id&lt;/code>。名稱不符合慣例時，用 &lt;code>TableName()&lt;/code> 方法或 &lt;code>gorm:&amp;quot;column:...&amp;quot;&lt;/code> tag 指定。&lt;/p>
&lt;p>&lt;code>CreatedAt&lt;/code> 這個名稱帶有行為：建立資料時 GORM 用應用程式的時鐘填入現在時間，再放進 &lt;code>INSERT&lt;/code>，所以資料表上的 &lt;code>DEFAULT now()&lt;/code> 不會被用到（下面〈寫入時的預設交易〉那一節的語句記錄裡看得到這個值被當成參數送出）。多個服務實例的時鐘不一致時，建立時間的順序就不一定等於寫入的順序；需要依時間排序的地方，改依主鍵排序，或讓資料庫給值。主鍵與資料庫給的時間反映的是配號與交易開始的順序，不是提交的順序，拿它們當「處理到哪裡」的位置會漏掉晚提交的資料（見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/high-frequency-counting/" data-link-title="1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件" data-link-desc="熱點列更新的吞吐量上限與它的成因、拆成多列／只新增事件／先在記憶體裡合併的差別、只新增事件搬去哪裡的成本、累加與重算在什麼條件下漏算或重複計算、讓彙總排程互斥的建議鎖與連線池的交互，以及重送事件的冪等寫入、推導事實的存放位置，以及 Redis 承擔即時計數、跨小時不重複數、去重與互斥時各自的邊界">1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件&lt;/a>〈已處理位置與交易的提交順序〉）。&lt;/p>
&lt;h2 id="gorm-的-sql-記錄">GORM 的 SQL 記錄&lt;/h2>
&lt;p>GORM 的記錄器（logger）設成 &lt;code>Info&lt;/code> 層級，每一個查詢都會連同耗時與影響列數印出來：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">db&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">gorm&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">postgres&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">dsn&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">gorm&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">	&lt;span class="nx">Logger&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Stdout&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">LogLevel&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Info&lt;/span>&lt;span class="p">}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>下面的範例為了簡短，直接在 &lt;code>db&lt;/code> 上呼叫；服務程式裡每個查詢都從 &lt;code>db.WithContext(ctx)&lt;/code> 開始，理由和 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/database-sql/" data-link-title="10.1 database/sql：連線池、查詢結果的讀取與交易" data-link-desc="Go 標準庫存取資料庫的模型：*sql.DB 是連線池、驅動怎麼註冊、Scan 與 NULL、查詢結果的讀取物件（*sql.Rows）沒關閉時的連線洩漏、交易的寫法，以及本模組其餘工具各自接手這一層的哪些工作">10.1 database/sql：連線池、查詢結果的讀取與交易&lt;/a> 要求一律用帶 &lt;code>Context&lt;/code> 的方法相同：請求取消或逾時時，查詢跟著中止。&lt;/p>
&lt;p>這個記錄是 GORM 的方法呼叫與實際送出的 SQL 之間的對照，下面多數的 SQL 取自它；實際每個查詢前面還有呼叫位置與耗時一行，文中的輸出只留 SQL。GORM 自動包的 &lt;code>begin&lt;/code> 與 &lt;code>commit&lt;/code> 不出現在它的記錄裡，要看交易的那幾節改用 PostgreSQL 的語句記錄（&lt;code>log_statement = 'all'&lt;/code>）。兩種記錄各記到什麼、正式環境怎麼開，見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/query-log/" data-link-title="Query Log（SQL 語句記錄）" data-link-desc="要確認 ORM 或資料存取函式庫實際送出了哪些 SQL、交易的 begin 與 commit 有沒有送出，或在正式環境追查慢查詢與某一筆資料被誰改過時，查應用程式端與資料庫端的語句記錄各記了什麼">Query Log&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理 <a href="https://gorm.io/">GORM</a> v1.31.2 搭配 PostgreSQL 方言套件 v1.6.3 的用法（取得套件：<code>go get gorm.io/gorm@v1.31.2 gorm.io/driver/postgres@v1.6.3</code>）。範例沿用 <a href="/blog/programming/go/basic/10-database-access/database-sql/#%e7%af%84%e4%be%8b%e8%b3%87%e6%96%99%e8%a1%a8" data-link-title="10.1 database/sql：連線池、查詢結果的讀取與交易" data-link-desc="Go 標準庫存取資料庫的模型：*sql.DB 是連線池、驅動怎麼註冊、Scan 與 NULL、查詢結果的讀取物件（*sql.Rows）沒關閉時的連線洩漏、交易的寫法，以及本模組其餘工具各自接手這一層的哪些工作">10.1 database/sql：連線池、查詢結果的讀取與交易</a> 定義的 <code>customers</code> 與 <code>orders</code> 兩張表。在 <a href="/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層</a> 的分類裡，GORM 是 ORM：SQL 由方法呼叫組出、查詢結果依命名慣例對映、寫入時預設包一層交易，三件事都由它接手。這篇多數節附上 GORM 實際送出的 SQL，因為用 ORM 的程式碼，要讀的是那些 SQL。</p>
<p>GORM 的 PostgreSQL 方言套件（<code>gorm.io/driver/postgres</code>，GORM 稱為 dialector）底層用的是 pgx 的 <code>database/sql</code> 驅動，所以 <code>database/sql</code> 那一篇的連線池設定照樣適用：<code>db.DB()</code> 取得底下的 <code>*sql.DB</code>，在它上面呼叫 <code>SetMaxOpenConns</code>。</p>
<h2 id="model-與命名慣例">model 與命名慣例</h2>
<p>GORM 用 struct 描述資料表，GORM 稱這種 struct 為 model，欄位與表名依命名慣例推出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Customer</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">	<span class="nx">ID</span>        <span class="kt">int64</span>     <span class="c1">// 名為 ID 的欄位是主鍵</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">Name</span>      <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">Email</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="c1">// 名為 CreatedAt 的欄位，建立時由 GORM 填入現在時間</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="nx">Orders</span>    <span class="p">[]</span><span class="nx">Order</span>   <span class="c1">// 一對多關聯：orders.customer_id 指向 customers.id</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">Order</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">	<span class="nx">ID</span>         <span class="kt">int64</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="nx">CustomerID</span> <span class="kt">int64</span>   <span class="c1">// 欄位 customer_id</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="nx">Amount</span>     <span class="kt">int</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="nx">Note</span>       <span class="o">*</span><span class="kt">string</span> <span class="c1">// 可以是 NULL</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">	<span class="nx">OrderedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Customer</code> 對到資料表 <code>customers</code>（struct 名稱轉成蛇形命名（snake_case）再加複數），<code>CustomerID</code> 對到欄位 <code>customer_id</code>。名稱不符合慣例時，用 <code>TableName()</code> 方法或 <code>gorm:&quot;column:...&quot;</code> tag 指定。</p>
<p><code>CreatedAt</code> 這個名稱帶有行為：建立資料時 GORM 用應用程式的時鐘填入現在時間，再放進 <code>INSERT</code>，所以資料表上的 <code>DEFAULT now()</code> 不會被用到（下面〈寫入時的預設交易〉那一節的語句記錄裡看得到這個值被當成參數送出）。多個服務實例的時鐘不一致時，建立時間的順序就不一定等於寫入的順序；需要依時間排序的地方，改依主鍵排序，或讓資料庫給值。主鍵與資料庫給的時間反映的是配號與交易開始的順序，不是提交的順序，拿它們當「處理到哪裡」的位置會漏掉晚提交的資料（見 <a href="/blog/backend/01-database/high-frequency-counting/" data-link-title="1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件" data-link-desc="熱點列更新的吞吐量上限與它的成因、拆成多列／只新增事件／先在記憶體裡合併的差別、只新增事件搬去哪裡的成本、累加與重算在什麼條件下漏算或重複計算、讓彙總排程互斥的建議鎖與連線池的交互，以及重送事件的冪等寫入、推導事實的存放位置，以及 Redis 承擔即時計數、跨小時不重複數、去重與互斥時各自的邊界">1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件</a>〈已處理位置與交易的提交順序〉）。</p>
<h2 id="gorm-的-sql-記錄">GORM 的 SQL 記錄</h2>
<p>GORM 的記錄器（logger）設成 <code>Info</code> 層級，每一個查詢都會連同耗時與影響列數印出來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">gorm</span><span class="p">.</span><span class="nf">Open</span><span class="p">(</span><span class="nx">postgres</span><span class="p">.</span><span class="nf">Open</span><span class="p">(</span><span class="nx">dsn</span><span class="p">),</span> <span class="o">&amp;</span><span class="nx">gorm</span><span class="p">.</span><span class="nx">Config</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="nx">Logger</span><span class="p">:</span> <span class="nx">logger</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">log</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stdout</span><span class="p">,</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span> <span class="nx">logger</span><span class="p">.</span><span class="nx">Config</span><span class="p">{</span><span class="nx">LogLevel</span><span class="p">:</span> <span class="nx">logger</span><span class="p">.</span><span class="nx">Info</span><span class="p">}),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>下面的範例為了簡短，直接在 <code>db</code> 上呼叫；服務程式裡每個查詢都從 <code>db.WithContext(ctx)</code> 開始，理由和 <a href="/blog/programming/go/basic/10-database-access/database-sql/" data-link-title="10.1 database/sql：連線池、查詢結果的讀取與交易" data-link-desc="Go 標準庫存取資料庫的模型：*sql.DB 是連線池、驅動怎麼註冊、Scan 與 NULL、查詢結果的讀取物件（*sql.Rows）沒關閉時的連線洩漏、交易的寫法，以及本模組其餘工具各自接手這一層的哪些工作">10.1 database/sql：連線池、查詢結果的讀取與交易</a> 要求一律用帶 <code>Context</code> 的方法相同：請求取消或逾時時，查詢跟著中止。</p>
<p>這個記錄是 GORM 的方法呼叫與實際送出的 SQL 之間的對照，下面多數的 SQL 取自它；實際每個查詢前面還有呼叫位置與耗時一行，文中的輸出只留 SQL。GORM 自動包的 <code>begin</code> 與 <code>commit</code> 不出現在它的記錄裡，要看交易的那幾節改用 PostgreSQL 的語句記錄（<code>log_statement = 'all'</code>）。兩種記錄各記到什麼、正式環境怎麼開，見 <a href="/blog/backend/knowledge-cards/query-log/" data-link-title="Query Log（SQL 語句記錄）" data-link-desc="要確認 ORM 或資料存取函式庫實際送出了哪些 SQL、交易的 begin 與 commit 有沒有送出，或在正式環境追查慢查詢與某一筆資料被誰改過時，查應用程式端與資料庫端的語句記錄各記了什麼">Query Log</a>。</p>
<h2 id="查一筆firsttake-與重用的變數">查一筆：First、Take 與重用的變數</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">c</span> <span class="nx">Customer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Take</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">SELECT * FROM &#34;customers&#34; WHERE &#34;customers&#34;.&#34;id&#34; = 1 ORDER BY &#34;customers&#34;.&#34;id&#34; LIMIT 1
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT * FROM &#34;customers&#34; WHERE &#34;customers&#34;.&#34;id&#34; = 1 AND &#34;customers&#34;.&#34;id&#34; = 1 LIMIT 1</span></span></code></pre></div><p><code>First</code> 在條件之外加上依主鍵排序，取排序後的第一筆；<code>Take</code> 不排序，取資料庫回傳的任何一筆。依唯一欄位查一筆時兩者結果相同，<code>Take</code> 省掉排序。</p>
<p>第二行多了一個重複的 <code>&quot;customers&quot;.&quot;id&quot; = 1</code>，原因是 <code>Take</code> 傳入的 <code>c</code> 已經被 <code>First</code> 填過，主鍵是 1，<strong>GORM 會把傳入物件上已有的主鍵加進條件</strong>。重用同一個變數去查另一筆時，這個行為會讓查詢靜默地查不到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="s">&#34;email = ?&#34;</span><span class="p">,</span> <span class="s">&#34;jiaying@example.com&#34;</span><span class="p">).</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">)</span> <span class="c1">// c.ID 還是 1</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">,</span> <span class="mi">999</span><span class="p">)</span>                                     <span class="c1">// 想查 id 999</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">SELECT * FROM &#34;customers&#34; WHERE email = &#39;jiaying@example.com&#39; AND &#34;customers&#34;.&#34;id&#34; = 1 ORDER BY &#34;customers&#34;.&#34;id&#34; LIMIT 1
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT * FROM &#34;customers&#34; WHERE &#34;customers&#34;.&#34;id&#34; = 999 AND &#34;customers&#34;.&#34;id&#34; = 1 ORDER BY &#34;customers&#34;.&#34;id&#34; LIMIT 1</span></span></code></pre></div><p>第二個查詢的條件是 <code>id = 999 AND id = 1</code>，永遠不會有結果。每一次查詢用一個新宣告的變數，就不會帶到前一次留下的主鍵。</p>
<h2 id="找不到資料的判斷">找不到資料的判斷</h2>
<p><code>First</code>、<code>Take</code>、<code>Last</code> 查不到時回傳 <code>gorm.ErrRecordNotFound</code>；<code>Find</code> 查不到時不回傳錯誤，只是 slice 是空的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">,</span> <span class="mi">999</span><span class="p">).</span><span class="nx">Error</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// record not found, errors.Is(err, gorm.ErrRecordNotFound) == true</span>
</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 class="kd">var</span> <span class="nx">cs</span> <span class="p">[]</span><span class="nx">Customer</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">err</span> <span class="p">=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="s">&#34;id = ?&#34;</span><span class="p">,</span> <span class="mi">999</span><span class="p">).</span><span class="nf">Find</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">cs</span><span class="p">).</span><span class="nx">Error</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// err == nil, len(cs) == 0</span></span></span></code></pre></div><p>用 <code>Find</code> 加上一個 struct（不是 slice）去查一筆，查不到時同樣不回錯誤，struct 保持零值；要「找不到就報錯」的地方用 <code>First</code> 或 <code>Take</code>。</p>
<h2 id="關聯迴圈裡的查詢與-preload">關聯：迴圈裡的查詢與 Preload</h2>
<p>逐一讀每個顧客的訂單時，在迴圈裡查就是 N+1：一次查顧客，再每個顧客一次查訂單。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">all</span> <span class="p">[]</span><span class="nx">Customer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">all</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">all</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="nx">db</span><span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="s">&#34;customer_id = ?&#34;</span><span class="p">,</span> <span class="nx">all</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nx">ID</span><span class="p">).</span><span class="nf">Find</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">all</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nx">Orders</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><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">SELECT * FROM &#34;customers&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT * FROM &#34;orders&#34; WHERE customer_id = 1
</span></span><span class="line"><span class="ln">3</span><span class="cl">SELECT * FROM &#34;orders&#34; WHERE customer_id = 2
</span></span><span class="line"><span class="ln">4</span><span class="cl">SELECT * FROM &#34;orders&#34; WHERE customer_id = 3</span></span></code></pre></div><p>顧客有一千個就是一千零一次查詢，每一次都是一次資料庫來回（round trip）（N+1 的成本與判讀見 <a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 應用層查詢反模式與 Query 預算</a>）。<code>Preload</code> 改成兩次查詢，第二次用 <code>IN</code> 一次取回全部顧客的訂單，再由 GORM 依 <code>customer_id</code> 分配回各個顧客：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">all</span> <span class="p">[]</span><span class="nx">Customer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Preload</span><span class="p">(</span><span class="s">&#34;Orders&#34;</span><span class="p">).</span><span class="nf">Find</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">all</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">SELECT * FROM &#34;orders&#34; WHERE &#34;orders&#34;.&#34;customer_id&#34; IN (1,2,3)
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT * FROM &#34;customers&#34;</span></span></code></pre></div><p>（記錄的順序是 GORM 印出的順序，執行時先查顧客、再依取回的主鍵查訂單。）GORM 沒有存取屬性時自動查詢的延遲載入（lazy loading，Eloquent 與 SQLAlchemy 有，對照見 <a href="/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層</a>〈ORM 送出的查詢次數：延遲載入與 N+1〉），沒有 <code>Preload</code> 的關聯欄位就是空的；所以在 GORM 裡，N+1 的來源是程式自己在迴圈裡查，而不是框架代查，GORM 的 SQL 記錄裡同一個形狀的查詢重複出現 N 次就是訊號。</p>
<h2 id="更新零值欄位與-updates">更新：零值欄位與 Updates</h2>
<p>用 struct 更新時，GORM 只寫入<strong>非零值</strong>的欄位，因為它分不出「欄位是 0」與「沒有設定這個欄位」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">o</span> <span class="nx">Order</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Take</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">o</span><span class="p">,</span> <span class="mi">103</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Model</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">o</span><span class="p">).</span><span class="nf">Updates</span><span class="p">(</span><span class="nx">Order</span><span class="p">{</span><span class="nx">Amount</span><span class="p">:</span> <span class="mi">0</span><span class="p">})</span>                <span class="c1">// 想把金額改成 0</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Model</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">o</span><span class="p">).</span><span class="nf">Updates</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">any</span><span class="p">{</span><span class="s">&#34;amount&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">})</span></span></span></code></pre></div><p>GORM 的記錄裡，第一個 <code>Updates</code> 沒有任何 <code>UPDATE</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">UPDATE &#34;orders&#34; SET &#34;amount&#34;=0 WHERE &#34;id&#34; = 103</span></span></code></pre></div><p>PostgreSQL 的語句記錄（<code>log_statement</code>）顯示第一個 <code>Updates</code> 送出了一組空的交易（<code>begin</code> 之後直接 <code>commit</code>；GORM 的寫入預設包在交易裡，見〈寫入時的預設交易〉），沒有錯誤、影響零列，程式完全不會發現金額沒有改。範例的 <code>Order</code> 沒有 <code>UpdatedAt</code>；model 帶 <code>UpdatedAt</code>（嵌入 <code>gorm.Model</code> 就有，見 <a href="/blog/programming/go/basic/10-database-access/gorm-model-and-soft-delete/" data-link-title="10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回" data-link-desc="gorm.Model 帶進來的 ID、CreatedAt、UpdatedAt、DeletedAt 與 UpdatedAt 的更新時機、DeletedAt 讓刪除變成 UPDATE 並替每個查詢加上條件、不經過那個條件的路徑（Raw、其他服務）、軟刪除與關聯載入、外鍵、唯一約束的衝突與部分索引、無條件刪除的保護，以及 Save 寫回全部欄位、找不到主鍵時改成 upsert 而把已刪除的列寫回或恢復">10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回</a>）時，GORM 仍會送出只更新 <code>updated_at</code> 的 <code>UPDATE</code>，影響列數是 1，看起來像更新成功，而金額照樣沒改。和 Eloquent、SQLAlchemy 這類追蹤變更的 ORM 不同（對照見 <a href="/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層</a>〈ORM 寫回哪些欄位：變更追蹤、全部欄位與零值〉）。要寫入零值（<code>0</code>、<code>&quot;&quot;</code>、<code>false</code>）時，用 map、用 <code>Select(&quot;amount&quot;)</code> 指定欄位，或用 <code>Update(&quot;amount&quot;, 0)</code> 更新單一欄位。可以是 NULL 的欄位宣告成指標（<code>*int</code>），<code>nil</code> 是「不寫」、指向 0 的指標是「寫成 0」，兩者就分得開。</p>
<h2 id="寫入時的預設交易">寫入時的預設交易</h2>
<p>GORM 的每一個建立、更新、刪除，預設都包在一個交易裡。在 PostgreSQL 開啟語句記錄（<code>log_statement = 'all'</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">LOG:  statement: begin
</span></span><span class="line"><span class="ln">2</span><span class="cl">LOG:  execute stmtcache_230b...: INSERT INTO &#34;customers&#34; (&#34;name&#34;,&#34;email&#34;,&#34;created_at&#34;) VALUES ($1,$2,$3) RETURNING &#34;id&#34;
</span></span><span class="line"><span class="ln">3</span><span class="cl">DETAIL:  Parameters: $1 = &#39;新顧客&#39;, $2 = &#39;new@example.com&#39;, $3 = &#39;2026-10-06 04:59:33.855131+00&#39;
</span></span><span class="line"><span class="ln">4</span><span class="cl">LOG:  statement: commit</span></span></code></pre></div><p><code>stmtcache_</code> 開頭的名字是 pgx 在連線上快取的預先解析查詢（見 <a href="/blog/backend/knowledge-cards/prepared-statement/" data-link-title="Prepared Statement（預先解析的查詢）" data-link-desc="在資料庫的語句記錄裡看到 stmtcache_、pdo_stmt_ 這類具名的查詢被重複執行，或應用程式接上 PgBouncer 交易模式後出現 prepared statement already exists、does not exist 的錯誤時，查它在哪一條連線上建立、存在多久">Prepared Statement</a>）。一個 <code>INSERT</code> 變成三次來回。GORM 這樣做，是因為一次 <code>Create</code> 可能連帶寫入關聯的資料（建立顧客時一併建立訂單），包在交易裡才會一起成功或一起失敗。寫入沒有關聯、或交易由程式自己用 <code>db.Transaction</code> 控制時，這一層是多出來的，<code>gorm.Config{SkipDefaultTransaction: true}</code> 關掉它之後，同樣的 <code>INSERT</code> 只剩一行，前後沒有 <code>begin</code> 與 <code>commit</code>。</p>
<p>這兩次多出來的來回在一般的增刪查改流量下不構成瓶頸，在寫入密集的路徑上才量得出差別；而關掉它的前提是那條路徑沒有依賴它的連帶寫入。所以這個預設值是否要關，取決於壓測或正式環境的延遲數字有沒有指向它。</p>
<p><code>db.Transaction(func(tx *gorm.DB) error { ... })</code> 是程式自己控制的交易，回傳 <code>nil</code> 提交、回傳錯誤撤銷；函式裡每一個查詢都要用參數 <code>tx</code>，用外面的 <code>db</code> 會從池裡另取一條連線、跑在交易之外（交易物件怎麼傳進各個 repository、誤用時交易外的寫入卡在交易持有的鎖上，見 <a href="/blog/backend/knowledge-cards/transaction-propagation/" data-link-title="Transaction Propagation（交易傳遞）" data-link-desc="多個 repository 的寫入要在同一個交易裡完成，或一段程式在交易失敗後仍留下部分寫入、交易裡的某個查詢卡住直到逾時時，查交易物件怎麼傳到每一個資料存取呼叫">Transaction Propagation</a>），和 <a href="/blog/programming/go/basic/10-database-access/database-sql/" data-link-title="10.1 database/sql：連線池、查詢結果的讀取與交易" data-link-desc="Go 標準庫存取資料庫的模型：*sql.DB 是連線池、驅動怎麼註冊、Scan 與 NULL、查詢結果的讀取物件（*sql.Rows）沒關閉時的連線洩漏、交易的寫法，以及本模組其餘工具各自接手這一層的哪些工作">10.1 database/sql：連線池、查詢結果的讀取與交易</a>〈交易：BeginTx、defer Rollback 與 Commit〉是同一個規則。</p>
<h2 id="錯誤判讀唯一約束">錯誤判讀：唯一約束</h2>
<p><code>jiaying@example.com</code> 已經是範例資料裡佳穎的 email，而 <code>customers_email_key</code> 是 <code>customers.email</code> 上的唯一約束。建立的資料違反唯一約束時，GORM 回傳的錯誤裡包著 pgx 的 <code>*pgconn.PgError</code>，用 <code>errors.As</code> 取出之後讀約束名稱：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;重複&#34;</span><span class="p">,</span> <span class="nx">Email</span><span class="p">:</span> <span class="s">&#34;jiaying@example.com&#34;</span><span class="p">}).</span><span class="nx">Error</span>
</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"><span class="kd">var</span> <span class="nx">pgErr</span> <span class="o">*</span><span class="nx">pgconn</span><span class="p">.</span><span class="nx">PgError</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">As</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">pgErr</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">pgErr</span><span class="p">.</span><span class="nx">ConstraintName</span> <span class="o">==</span> <span class="s">&#34;customers_email_key&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="c1">// email 已被使用</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>gorm.Config{TranslateError: true}</code> 讓 GORM 把資料庫錯誤轉成自己的錯誤值，唯一約束違反變成 <code>gorm.ErrDuplicatedKey</code>。實測轉換之後 <code>errors.As</code> 仍然取得到 <code>*pgconn.PgError</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">TranslateError: duplicated key not allowed: ERROR: duplicate key value violates unique constraint &#34;customers_email_key&#34; (SQLSTATE 23505) true true</span></span></code></pre></div><p><code>ErrDuplicatedKey</code> 讓程式不必認得 PostgreSQL 的錯誤代碼，換資料庫時判斷式不用改；它只說「有唯一約束被違反」，一張表有好幾個唯一約束時，要回報是哪一個，仍然要讀約束名稱。</p>
<h2 id="automigrate-在正式環境的限制">AutoMigrate 在正式環境的限制</h2>
<p><code>db.AutoMigrate(&amp;Customer{})</code> 依 model 建表、補上缺少的欄位與索引。它方便在開發初期快速建表，而它只往「讓資料表容得下這個 struct」的方向改。下面先用一版 model 建表，再把欄位 <code>Rate</code> 改名成 <code>Discount</code>、<code>Code</code> 加上長度限制：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Coupon</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">	<span class="nx">ID</span>   <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">Code</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">Rate</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">type</span> <span class="nx">CouponV2</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="nx">ID</span>       <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="nx">Code</span>     <span class="kt">string</span> <span class="s">`gorm:&#34;size:20&#34;`</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">	<span class="nx">Discount</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">CouponV2</span><span class="p">)</span> <span class="nf">TableName</span><span class="p">()</span> <span class="kt">string</span> <span class="p">{</span> <span class="k">return</span> <span class="s">&#34;coupons&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">AutoMigrate</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Coupon</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">AutoMigrate</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">CouponV2</span><span class="p">{})</span></span></span></code></pre></div><p>以 <code>SELECT column_name, data_type, character_maximum_length FROM information_schema.columns WHERE table_name = 'coupons'</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">[id bigint code character varying(20) rate bigint discount bigint]</span></span></code></pre></div><p>改名被當成新增一個欄位 <code>discount</code>，舊的 <code>rate</code> 和它的資料都留著；<code>code</code> 的型別直接被改成 <code>varchar(20)</code>。GORM 送出的是 <code>ALTER ... TYPE varchar(20) USING code::varchar(20)</code>，PostgreSQL 的明確轉型會把超過 20 個字元的值直接截斷，所以表裡已經有較長的資料時，這一步照樣成功、不報錯，被截掉的資料就此遺失。整個過程沒有一份檔案記錄它做了什麼，也沒有退回的方法。</p>
<p>所以正式環境的 schema 交給版本化的 migration 工具（golang-migrate、goose、Atlas）：每一次改動是一份可以審查的檔案，依序套用，可以在測試環境先跑過。GORM 只負責讀寫，model 跟著 migration 改。版本化 migration 的流程與相容性規則見 <a href="/blog/programming/go/advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a>。</p>
<h2 id="gorm-的慣例主鍵零值createdat-與預設交易">GORM 的慣例：主鍵、零值、CreatedAt 與預設交易</h2>
<p>主鍵條件、零值更新、<code>CreatedAt</code> 與預設交易這幾節的現象有同一個來源：程式碼寫的是方法呼叫，實際行為由 GORM 的慣例決定——傳入物件上的主鍵、零值的含義、<code>CreatedAt</code> 這個欄位名稱、寫入時的交易。慣例讓常見的增刪查改很短，而每一條慣例都要先知道它存在，才知道程式碼底下發生了什麼。其中三條在 GORM 的 SQL 記錄裡看得到：主鍵條件多出來、零值欄位沒有出現在 <code>UPDATE</code> 裡、<code>INSERT</code> 帶著應用程式填的建立時間；預設交易的 <code>begin</code> 與 <code>commit</code> 要開資料庫的語句記錄（<code>log_statement</code>）才看得到。</p>
<p><code>gorm.Model</code> 帶進來的 <code>UpdatedAt</code> 與 <code>DeletedAt</code>、軟刪除替每個查詢加的條件，以及 <code>Save</code> 寫回全部欄位的行為，是同一類由欄位名稱與型別決定的慣例，見 <a href="/blog/programming/go/basic/10-database-access/gorm-model-and-soft-delete/" data-link-title="10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回" data-link-desc="gorm.Model 帶進來的 ID、CreatedAt、UpdatedAt、DeletedAt 與 UpdatedAt 的更新時機、DeletedAt 讓刪除變成 UPDATE 並替每個查詢加上條件、不經過那個條件的路徑（Raw、其他服務）、軟刪除與關聯載入、外鍵、唯一約束的衝突與部分索引、無條件刪除的保護，以及 Save 寫回全部欄位、找不到主鍵時改成 upsert 而把已刪除的列寫回或恢復">10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回</a>。</p>
<p>需要報表、多表聚合這類固定而複雜的查詢時，GORM 的 <code>Raw</code> 可以直接寫 SQL 並對映回 struct；這類查詢多的專案，也常把它們交給 <a href="/blog/programming/go/basic/10-database-access/sqlc/" data-link-title="10.4 sqlc：從 SQL 產生型別安全的 Go 程式碼" data-link-desc="sqlc 的工作流程：設定檔指向 schema 與查詢檔、查詢上方的註記決定產生的函式、產生出來的參數與結果型別、欄位寫錯在產生時就被擋下、NULL 與時間欄位的型別對應（預設的 pgtype 型別與改成指標的設定）、選擇性篩選條件的寫法，以及產生步驟帶進建置流程的代價">10.4 sqlc：從 SQL 產生型別安全的 Go 程式碼</a>，兩者怎麼並存見 <a href="/blog/programming/go/basic/10-database-access/choosing-data-access/" data-link-title="10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合" data-link-desc="把語言無關的資料存取選型軸套到 Go 的工具上：驅動入口選 pgx 原生介面還是 database/sql、主力選 GORM 還是 sqlc、為什麼用 GORM 與用 sqlc 的專案都把 schema 交給 migration 工具、常見的混用組合、混用時交易能不能共用，以及讀寫分離時 dbresolver 把查詢送到主庫或副本的規則">10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合</a>。</p>
]]></content:encoded></item><item><title>10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回</title><link>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm-model-and-soft-delete/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm-model-and-soft-delete/</guid><description>&lt;p>這篇接著 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&amp;#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL&lt;/a>，整理 GORM v1.31.2 的 &lt;code>gorm.Model&lt;/code>、軟刪除（soft delete）與 &lt;code>Save&lt;/code> 的實際行為。三者的共同點是 GORM 在組 SQL 時替程式加上條件或欄位：&lt;code>gorm.Model&lt;/code> 與軟刪除的行為由欄位的&lt;strong>名稱或型別&lt;/strong>觸發，GORM 看到 &lt;code>UpdatedAt&lt;/code>、&lt;code>DeletedAt&lt;/code> 就替寫入補欄位、替每個查詢加條件；&lt;code>Save&lt;/code> 的行為來自它的寫回規則，把 struct 的每個欄位都寫回去，找不到那一列時再改成另一種寫入。這些條件與欄位都不會出現在程式碼上，只出現在 GORM 送出的 SQL 裡，所以本篇每一節都附上 GORM 的 SQL 記錄；審查用 &lt;code>gorm.Model&lt;/code> 與 &lt;code>Save&lt;/code> 的程式時，對照的也是開著記錄跑出來的語句（GORM 的 SQL 記錄與 PostgreSQL 語句記錄各記到什麼，見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/query-log/" data-link-title="Query Log（SQL 語句記錄）" data-link-desc="要確認 ORM 或資料存取函式庫實際送出了哪些 SQL、交易的 begin 與 commit 有沒有送出，或在正式環境追查慢查詢與某一筆資料被誰改過時，查應用程式端與資料庫端的語句記錄各記了什麼">Query Log 知識卡&lt;/a>）。&lt;/p>
&lt;p>範例沿用 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/database-sql/#%e7%af%84%e4%be%8b%e8%b3%87%e6%96%99%e8%a1%a8" data-link-title="10.1 database/sql：連線池、查詢結果的讀取與交易" data-link-desc="Go 標準庫存取資料庫的模型：*sql.DB 是連線池、驅動怎麼註冊、Scan 與 NULL、查詢結果的讀取物件（*sql.Rows）沒關閉時的連線洩漏、交易的寫法，以及本模組其餘工具各自接手這一層的哪些工作">10.1 database/sql：連線池、查詢結果的讀取與交易&lt;/a> 的 &lt;code>customers&lt;/code> 與 &lt;code>orders&lt;/code> 兩張表：顧客佳穎（id 1，有兩張訂單）、宗翰（id 2，有一張訂單）、雅文（id 3，沒有訂單）。每一節開始時，資料都重置成剛執行完 10.1 的建表 SQL、再套用〈gorm.Model 的欄位：ID、CreatedAt、UpdatedAt 與 DeletedAt〉那一節的 migration 之後的狀態；同一節裡的程式片段接續前一段的結果往下執行。程式裡的 &lt;code>db&lt;/code> 是 10.5 用 &lt;code>gorm.Open&lt;/code> 開好的 &lt;code>*gorm.DB&lt;/code>，記錄器設成 &lt;code>logger.Info&lt;/code>；&lt;code>Customer&lt;/code> 是〈gorm.Model 的欄位：ID、CreatedAt、UpdatedAt 與 DeletedAt〉那一節嵌入 &lt;code>gorm.Model&lt;/code> 的 struct。軟刪除本身是不分語言的做法，Laravel 等框架的對應寫法與共通的風險見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/soft-delete/" data-link-title="Soft Delete（軟刪除）" data-link-desc="資料表有 deleted_at 欄位、ORM 的刪除改成 UPDATE，或刪掉的資料再建一次卻撞唯一約束、報表數字和頁面對不上時，查軟刪除的過濾條件由誰加、哪些路徑沒有加">Soft Delete 知識卡&lt;/a>。&lt;/p>
&lt;h2 id="gormmodel-的欄位idcreatedatupdatedat-與-deletedat">gorm.Model 的欄位：ID、CreatedAt、UpdatedAt 與 DeletedAt&lt;/h2>
&lt;p>&lt;code>gorm.Model&lt;/code> 是 GORM 提供的一個 struct，原始碼只有四個欄位：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Model&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">	&lt;span class="nx">ID&lt;/span> &lt;span class="kt">uint&lt;/span> &lt;span class="s">`gorm:&amp;#34;primarykey&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">	&lt;span class="nx">CreatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">	&lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">	&lt;span class="nx">DeletedAt&lt;/span> &lt;span class="nx">DeletedAt&lt;/span> &lt;span class="s">`gorm:&amp;#34;index&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把它嵌入自己的 model，四個欄位與它們的行為就一起帶進來：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Customer&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">	&lt;span class="nx">gorm&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Model&lt;/span> &lt;span class="c1">// id、created_at、updated_at、deleted_at&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">	&lt;span class="nx">Name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">	&lt;span class="nx">Email&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">	&lt;span class="nx">Orders&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Order&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ID&lt;/code> 是主鍵，&lt;code>CreatedAt&lt;/code> 在建立時由 GORM 用應用程式的時鐘填入（見 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&amp;#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL&lt;/a>〈model 與命名慣例〉），&lt;code>UpdatedAt&lt;/code> 在每次更新時填入，&lt;code>DeletedAt&lt;/code> 讓這個 model 改用軟刪除。範例資料表原本沒有 &lt;code>updated_at&lt;/code> 與 &lt;code>deleted_at&lt;/code>，用 migration 補上：&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">ALTER&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">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ADD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COLUMN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">timestamptz&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ADD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COLUMN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">deleted_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">timestamptz&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">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers_deleted_at_idx&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">deleted_at&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>deleted_at&lt;/code> 上的索引對應 &lt;code>gorm.Model&lt;/code> 裡的 &lt;code>gorm:&amp;quot;index&amp;quot;&lt;/code>：每個查詢都會帶 &lt;code>deleted_at IS NULL&lt;/code>，而 &lt;code>AutoMigrate&lt;/code> 建表時也會建這個索引；schema 交給 migration 管理時（理由見 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&amp;#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL&lt;/a>〈AutoMigrate 在正式環境的限制〉），migration 裡要自己建這個索引。&lt;/p></description><content:encoded><![CDATA[<p>這篇接著 <a href="/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL</a>，整理 GORM v1.31.2 的 <code>gorm.Model</code>、軟刪除（soft delete）與 <code>Save</code> 的實際行為。三者的共同點是 GORM 在組 SQL 時替程式加上條件或欄位：<code>gorm.Model</code> 與軟刪除的行為由欄位的<strong>名稱或型別</strong>觸發，GORM 看到 <code>UpdatedAt</code>、<code>DeletedAt</code> 就替寫入補欄位、替每個查詢加條件；<code>Save</code> 的行為來自它的寫回規則，把 struct 的每個欄位都寫回去，找不到那一列時再改成另一種寫入。這些條件與欄位都不會出現在程式碼上，只出現在 GORM 送出的 SQL 裡，所以本篇每一節都附上 GORM 的 SQL 記錄；審查用 <code>gorm.Model</code> 與 <code>Save</code> 的程式時，對照的也是開著記錄跑出來的語句（GORM 的 SQL 記錄與 PostgreSQL 語句記錄各記到什麼，見 <a href="/blog/backend/knowledge-cards/query-log/" data-link-title="Query Log（SQL 語句記錄）" data-link-desc="要確認 ORM 或資料存取函式庫實際送出了哪些 SQL、交易的 begin 與 commit 有沒有送出，或在正式環境追查慢查詢與某一筆資料被誰改過時，查應用程式端與資料庫端的語句記錄各記了什麼">Query Log 知識卡</a>）。</p>
<p>範例沿用 <a href="/blog/programming/go/basic/10-database-access/database-sql/#%e7%af%84%e4%be%8b%e8%b3%87%e6%96%99%e8%a1%a8" data-link-title="10.1 database/sql：連線池、查詢結果的讀取與交易" data-link-desc="Go 標準庫存取資料庫的模型：*sql.DB 是連線池、驅動怎麼註冊、Scan 與 NULL、查詢結果的讀取物件（*sql.Rows）沒關閉時的連線洩漏、交易的寫法，以及本模組其餘工具各自接手這一層的哪些工作">10.1 database/sql：連線池、查詢結果的讀取與交易</a> 的 <code>customers</code> 與 <code>orders</code> 兩張表：顧客佳穎（id 1，有兩張訂單）、宗翰（id 2，有一張訂單）、雅文（id 3，沒有訂單）。每一節開始時，資料都重置成剛執行完 10.1 的建表 SQL、再套用〈gorm.Model 的欄位：ID、CreatedAt、UpdatedAt 與 DeletedAt〉那一節的 migration 之後的狀態；同一節裡的程式片段接續前一段的結果往下執行。程式裡的 <code>db</code> 是 10.5 用 <code>gorm.Open</code> 開好的 <code>*gorm.DB</code>，記錄器設成 <code>logger.Info</code>；<code>Customer</code> 是〈gorm.Model 的欄位：ID、CreatedAt、UpdatedAt 與 DeletedAt〉那一節嵌入 <code>gorm.Model</code> 的 struct。軟刪除本身是不分語言的做法，Laravel 等框架的對應寫法與共通的風險見 <a href="/blog/backend/knowledge-cards/soft-delete/" data-link-title="Soft Delete（軟刪除）" data-link-desc="資料表有 deleted_at 欄位、ORM 的刪除改成 UPDATE，或刪掉的資料再建一次卻撞唯一約束、報表數字和頁面對不上時，查軟刪除的過濾條件由誰加、哪些路徑沒有加">Soft Delete 知識卡</a>。</p>
<h2 id="gormmodel-的欄位idcreatedatupdatedat-與-deletedat">gorm.Model 的欄位：ID、CreatedAt、UpdatedAt 與 DeletedAt</h2>
<p><code>gorm.Model</code> 是 GORM 提供的一個 struct，原始碼只有四個欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Model</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="nx">ID</span>        <span class="kt">uint</span> <span class="s">`gorm:&#34;primarykey&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="nx">DeletedAt</span> <span class="nx">DeletedAt</span> <span class="s">`gorm:&#34;index&#34;`</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>把它嵌入自己的 model，四個欄位與它們的行為就一起帶進來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Customer</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="nx">gorm</span><span class="p">.</span><span class="nx">Model</span> <span class="c1">// id、created_at、updated_at、deleted_at</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="nx">Name</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="nx">Email</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="nx">Orders</span> <span class="p">[]</span><span class="nx">Order</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ID</code> 是主鍵，<code>CreatedAt</code> 在建立時由 GORM 用應用程式的時鐘填入（見 <a href="/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL</a>〈model 與命名慣例〉），<code>UpdatedAt</code> 在每次更新時填入，<code>DeletedAt</code> 讓這個 model 改用軟刪除。範例資料表原本沒有 <code>updated_at</code> 與 <code>deleted_at</code>，用 migration 補上：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">updated_at</span><span class="w"> </span><span class="n">timestamptz</span><span class="p">,</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">deleted_at</span><span class="w"> </span><span class="n">timestamptz</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">customers_deleted_at_idx</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="p">(</span><span class="n">deleted_at</span><span class="p">);</span></span></span></code></pre></div><p><code>deleted_at</code> 上的索引對應 <code>gorm.Model</code> 裡的 <code>gorm:&quot;index&quot;</code>：每個查詢都會帶 <code>deleted_at IS NULL</code>，而 <code>AutoMigrate</code> 建表時也會建這個索引；schema 交給 migration 管理時（理由見 <a href="/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL</a>〈AutoMigrate 在正式環境的限制〉），migration 裡要自己建這個索引。</p>
<p><code>ID</code> 的型別是 <code>uint</code>，對到資料表的 <code>bigint</code> 讀寫都正常；需要 <code>int64</code>（例如和其他程式共用的型別）時，不嵌入 <code>gorm.Model</code>，把需要的欄位自己寫進 struct，行為由欄位名稱與型別決定，一樣會生效。</p>
<h2 id="updatedat-的更新時機">UpdatedAt 的更新時機</h2>
<p><code>Update</code>、<code>Updates</code> 與 <code>Save</code> 都會把 <code>updated_at</code> 設成現在時間並放進 <code>UPDATE</code>；<code>UpdateColumn</code> 與 <code>UpdateColumns</code> 只寫指定的欄位，不更新 <code>updated_at</code>，也不執行 model 上的 hook（<code>BeforeUpdate</code> 這類 GORM 在寫入前後自動呼叫的方法）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Model</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{</span><span class="nx">Model</span><span class="p">:</span> <span class="nx">gorm</span><span class="p">.</span><span class="nx">Model</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="mi">2</span><span class="p">}}).</span><span class="nf">UpdateColumn</span><span class="p">(</span><span class="s">&#34;name&#34;</span><span class="p">,</span> <span class="s">&#34;宗翰 C&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Model</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{</span><span class="nx">Model</span><span class="p">:</span> <span class="nx">gorm</span><span class="p">.</span><span class="nx">Model</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="mi">2</span><span class="p">}}).</span><span class="nf">Update</span><span class="p">(</span><span class="s">&#34;name&#34;</span><span class="p">,</span> <span class="s">&#34;宗翰 D&#34;</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">UPDATE &#34;customers&#34; SET &#34;name&#34;=&#39;宗翰 C&#39; WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL AND &#34;id&#34; = 2
</span></span><span class="line"><span class="ln">2</span><span class="cl">UPDATE &#34;customers&#34; SET &#34;name&#34;=&#39;宗翰 D&#39;,&#34;updated_at&#34;=&#39;2026-10-06 14:27:15.524&#39; WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL AND &#34;id&#34; = 2</span></span></code></pre></div><p>兩個 <code>UPDATE</code> 都帶著 <code>deleted_at IS NULL</code>，那是〈軟刪除：Delete 變成 UPDATE〉那一節的 <code>DeletedAt</code> 加上的。<code>updated_at</code> 常被拿來當增量同步的依據（「拿出上次同步之後改過的列」），用 <code>UpdateColumn</code> 改的資料不會被這種同步取出。GORM 的軟刪除也一樣：<code>Delete</code> 送出的是 <code>UPDATE &quot;customers&quot; SET &quot;deleted_at&quot;=... WHERE ...</code>，只寫 <code>deleted_at</code>、不寫 <code>updated_at</code>，所以依 <code>updated_at</code> 的同步收不到刪除，要另外用 <code>Unscoped</code> 取出 <code>deleted_at</code> 晚於上次同步的列（Laravel 的軟刪除會一併更新 <code>updated_at</code>，對照見 <a href="/blog/backend/knowledge-cards/soft-delete/" data-link-title="Soft Delete（軟刪除）" data-link-desc="資料表有 deleted_at 欄位、ORM 的刪除改成 UPDATE，或刪掉的資料再建一次卻撞唯一約束、報表數字和頁面對不上時，查軟刪除的過濾條件由誰加、哪些路徑沒有加">Soft Delete 知識卡</a>）。</p>
<h2 id="軟刪除delete-變成-update">軟刪除：Delete 變成 UPDATE</h2>
<p>model 有 <code>gorm.DeletedAt</code> 型別的欄位時，<code>Delete</code> 送出的是 <code>UPDATE</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{},</span> <span class="mi">3</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">UPDATE &#34;customers&#34; SET &#34;deleted_at&#34;=&#39;2026-10-06 14:26:48.627&#39; WHERE &#34;customers&#34;.&#34;id&#34; = 3 AND &#34;customers&#34;.&#34;deleted_at&#34; IS NULL</span></span></code></pre></div><p>雅文那一列還在表裡，只是 <code>deleted_at</code> 有了值。之後這個 model 的每一個查詢都多一個條件，查不到雅文：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">c</span> <span class="nx">Customer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">,</span> <span class="mi">3</span><span class="p">).</span><span class="nx">Error</span> <span class="c1">// record not found</span>
</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 class="kd">var</span> <span class="nx">n</span><span class="p">,</span> <span class="nx">nAll</span> <span class="kt">int64</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Model</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{}).</span><span class="nf">Count</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">n</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Unscoped</span><span class="p">().</span><span class="nf">Model</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{}).</span><span class="nf">Count</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">nAll</span><span class="p">)</span> <span class="c1">// n = 2, nAll = 3</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">SELECT * FROM &#34;customers&#34; WHERE &#34;customers&#34;.&#34;id&#34; = 3 AND &#34;customers&#34;.&#34;deleted_at&#34; IS NULL ORDER BY &#34;customers&#34;.&#34;id&#34; LIMIT 1
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT count(*) FROM &#34;customers&#34; WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL
</span></span><span class="line"><span class="ln">3</span><span class="cl">SELECT count(*) FROM &#34;customers&#34;</span></span></code></pre></div><p><code>Unscoped()</code> 取消這個條件，用在要看到已刪除資料的地方（後台的回收區、稽核）；<code>db.Unscoped().Delete(&amp;Customer{}, 3)</code> 送出 <code>DELETE</code>，把那一列從表中移除。</p>
<p>對已刪除的列再刪一次、或更新它，條件裡的 <code>deleted_at IS NULL</code> 讓 <code>UPDATE</code> 影響零列，GORM 不回報錯誤：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{},</span> <span class="mi">3</span><span class="p">)</span>                                       <span class="c1">// 雅文已經刪除，再刪一次</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Model</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{</span><span class="nx">Model</span><span class="p">:</span> <span class="nx">gorm</span><span class="p">.</span><span class="nx">Model</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="mi">3</span><span class="p">}}).</span><span class="nf">Update</span><span class="p">(</span><span class="s">&#34;name&#34;</span><span class="p">,</span> <span class="s">&#34;x&#34;</span><span class="p">)</span> <span class="c1">// 更新已刪除的列</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">UPDATE &#34;customers&#34; SET &#34;deleted_at&#34;=&#39;2026-10-06 14:26:48.747&#39; WHERE &#34;customers&#34;.&#34;id&#34; = 3 AND &#34;customers&#34;.&#34;deleted_at&#34; IS NULL
</span></span><span class="line"><span class="ln">2</span><span class="cl">UPDATE &#34;customers&#34; SET &#34;name&#34;=&#39;x&#39;,&#34;updated_at&#34;=&#39;2026-10-06 14:26:48.749&#39; WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL AND &#34;id&#34; = 3</span></span></code></pre></div><p>兩個呼叫的 <code>Error</code> 都是 <code>nil</code>、<code>RowsAffected</code> 都是 0。「改一筆不存在或已刪除的資料」要回報給呼叫端時，程式要自己檢查 <code>RowsAffected</code>。</p>
<h2 id="不經過軟刪除條件的路徑">不經過軟刪除條件的路徑</h2>
<p>這個條件是 GORM 組 SQL 時加上的，所以 GORM 沒有參與組 SQL 的地方都看得到已刪除的列。<code>Raw</code> 送出的是程式寫的那一段 SQL，原樣執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{},</span> <span class="mi">3</span><span class="p">)</span> <span class="c1">// 軟刪除雅文</span>
</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"><span class="kd">var</span> <span class="nx">names</span> <span class="p">[]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Raw</span><span class="p">(</span><span class="s">&#34;SELECT name FROM customers ORDER BY id&#34;</span><span class="p">).</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">names</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// [佳穎 宗翰 雅文]</span></span></span></code></pre></div><p>已經軟刪除的雅文仍在結果裡。同樣的情形出現在用 sqlc 寫的報表查詢（混用方式見 <a href="/blog/programming/go/basic/10-database-access/choosing-data-access/" data-link-title="10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合" data-link-desc="把語言無關的資料存取選型軸套到 Go 的工具上：驅動入口選 pgx 原生介面還是 database/sql、主力選 GORM 還是 sqlc、為什麼用 GORM 與用 sqlc 的專案都把 schema 交給 migration 工具、常見的混用組合、混用時交易能不能共用，以及讀寫分離時 dbresolver 把查詢送到主庫或副本的規則">10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合</a>）、共用這個資料庫的其他服務，以及直接連進資料庫查問題的人。專案決定用軟刪除時，這些路徑的每一段 SQL 都要自己寫 <code>deleted_at IS NULL</code>，或由資料庫端提供只含未刪除列的 view 給報表與其他服務使用（做法見 <a href="/blog/backend/knowledge-cards/soft-delete/" data-link-title="Soft Delete（軟刪除）" data-link-desc="資料表有 deleted_at 欄位、ORM 的刪除改成 UPDATE，或刪掉的資料再建一次卻撞唯一約束、報表數字和頁面對不上時，查軟刪除的過濾條件由誰加、哪些路徑沒有加">Soft Delete 知識卡</a>）；頁面上的數字由 GORM 算、報表由手寫 SQL 算，兩邊差的就是已刪除的列。</p>
<h2 id="軟刪除與關聯關聯載入的結果與外鍵檢查">軟刪除與關聯：關聯載入的結果與外鍵檢查</h2>
<p>軟刪除佳穎（佳穎有兩張訂單）之後，從訂單載入顧客：</p>
<p>訂單的 model 多一個 <code>Customer</code> 欄位，GORM 依它與 <code>CustomerID</code> 知道每張訂單屬於哪位顧客：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Order</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">	<span class="nx">ID</span>         <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">CustomerID</span> <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">Customer</span>   <span class="nx">Customer</span> <span class="c1">// 所屬的顧客</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="nx">Amount</span>     <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="nx">Note</span>       <span class="o">*</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="nx">OrderedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{},</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">var</span> <span class="nx">orders</span> <span class="p">[]</span><span class="nx">Order</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Joins</span><span class="p">(</span><span class="s">&#34;Customer&#34;</span><span class="p">).</span><span class="nf">Order</span><span class="p">(</span><span class="s">&#34;orders.id&#34;</span><span class="p">).</span><span class="nf">Find</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">orders</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">SELECT &#34;orders&#34;.&#34;id&#34;, ... ,&#34;Customer&#34;.&#34;name&#34; AS &#34;Customer__name&#34;, ... FROM &#34;orders&#34; LEFT JOIN &#34;customers&#34; &#34;Customer&#34; ON &#34;orders&#34;.&#34;customer_id&#34; = &#34;Customer&#34;.&#34;id&#34; AND &#34;Customer&#34;.&#34;deleted_at&#34; IS NULL ORDER BY orders.id
</span></span><span class="line"><span class="ln">2</span><span class="cl">order 101 customer_id=1 Customer.ID=0 Name=&#34;&#34;
</span></span><span class="line"><span class="ln">3</span><span class="cl">order 102 customer_id=1 Customer.ID=0 Name=&#34;&#34;
</span></span><span class="line"><span class="ln">4</span><span class="cl">order 103 customer_id=2 Customer.ID=2 Name=&#34;宗翰&#34;</span></span></code></pre></div><p>GORM 把條件加在 <code>LEFT JOIN</code> 的 <code>ON</code> 上，所以訂單照樣查得到，關聯的顧客則是零值；<code>Preload(&quot;Customer&quot;)</code> 的結果相同：它另外送出一個查顧客的查詢（<code>WHERE id IN (1,2)</code>），在那個查詢上加同一個條件。畫面上會出現「訂單的顧客名稱是空白」，而 <code>customer_id</code> 仍然是 1。這筆資料的處理方式要由業務決定：訂單顯示「已刪除的顧客」、改用 <code>Unscoped</code> 載入顧客，或刪除顧客時一併處理訂單，三者可以擇一，也可以並用（例如畫面用 <code>Unscoped</code> 載入、再標示已刪除）。</p>
<p><code>orders.customer_id</code> 上的外鍵檢查只在 <code>customers</code> 的列被 <code>DELETE</code> 或主鍵被改時動作，而軟刪除是 <code>UPDATE</code>，所以佳穎的軟刪除成功了，外鍵沒有替程式做這個決定；之後要永久刪除這位顧客，外鍵才擋下來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">Unscoped</span><span class="p">().</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{},</span> <span class="mi">1</span><span class="p">).</span><span class="nx">Error</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">DELETE FROM &#34;customers&#34; WHERE &#34;customers&#34;.&#34;id&#34; = 1
</span></span><span class="line"><span class="ln">2</span><span class="cl">ERROR: update or delete on table &#34;customers&#34; violates foreign key constraint &#34;orders_customer_id_fkey&#34; on table &#34;orders&#34; (SQLSTATE 23503)</span></span></code></pre></div><h2 id="軟刪除與唯一約束刪掉再建立同一個-email">軟刪除與唯一約束：刪掉再建立同一個 email</h2>
<p>雅文被軟刪除之後，用同一個 email 重新建立顧客：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{},</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;雅文&#34;</span><span class="p">,</span> <span class="nx">Email</span><span class="p">:</span> <span class="s">&#34;yawen@example.com&#34;</span><span class="p">}).</span><span class="nx">Error</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">ERROR: duplicate key value violates unique constraint &#34;customers_email_key&#34; (SQLSTATE 23505)</span></span></code></pre></div><p><code>customers_email_key</code> 這個唯一約束涵蓋表裡的每一列，包括已刪除的那一列。PostgreSQL 的做法是刪掉這個約束，另外建一個只涵蓋未刪除的列的唯一索引（部分索引，partial index；索引本身的取捨見 <a href="/blog/backend/01-database/vendors/postgresql/index-selection/" data-link-title="PostgreSQL Index Selection：B-tree / GIN / GiST / BRIN / Hash 對應 workload 的決策樹" data-link-desc="PG 有 6 種 index method（B-tree / Hash / GIN / GiST / SP-GiST / BRIN）跟 partial / expression / covering 三種變體、不是「都用 B-tree 就好」。每種 index 有自己的 query pattern、儲存代價、write amplification 跟 maintenance 成本。本文走 6 種 index 的適用 workload 對照、決策樹、partial / expression / covering / multi-column 變體、5 production 踩雷（過度 index / partial 條件不對 / B-tree 對 JSON 無效 / BRIN 對非 correlated 資料無效 / multi-column 順序錯）、跟 query-optimization 的 EXPLAIN 互補">PostgreSQL Index Selection：B-tree / GIN / GiST / BRIN / Hash 對應 workload 的決策樹</a>〈Partial Index：條件式 index 救 storage〉）：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">DROP</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="n">customers_email_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="k">CREATE</span><span class="w"> </span><span class="k">UNIQUE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">customers_email_active_key</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="p">(</span><span class="n">email</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">deleted_at</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span></span></span></code></pre></div><p>換上之後，刪掉再建立同一個 email 成功；兩個未刪除的顧客用同一個 email 時仍然報錯，<code>*pgconn.PgError</code> 的 <code>ConstraintName</code> 是索引的名字 <code>customers_email_active_key</code>，依約束名稱回報錯誤的程式要跟著改名（判讀方式見 <a href="/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL</a>〈錯誤判讀：唯一約束〉）。</p>
<p>部分索引會影響 upsert（<code>INSERT</code> 遇到唯一衝突時改成更新既有那一列的寫法，PostgreSQL 寫成 <code>INSERT ... ON CONFLICT</code>）。GORM 的 <code>clause.OnConflict</code> 只寫衝突欄位時，PostgreSQL 找不到與它相符的唯一約束：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Clauses</span><span class="p">(</span><span class="nx">clause</span><span class="p">.</span><span class="nx">OnConflict</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="nx">Columns</span><span class="p">:</span>   <span class="p">[]</span><span class="nx">clause</span><span class="p">.</span><span class="nx">Column</span><span class="p">{{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;email&#34;</span><span class="p">}},</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="nx">DoUpdates</span><span class="p">:</span> <span class="nx">clause</span><span class="p">.</span><span class="nf">AssignmentColumns</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;name&#34;</span><span class="p">}),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}).</span><span class="nf">Create</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;佳穎2&#34;</span><span class="p">,</span> <span class="nx">Email</span><span class="p">:</span> <span class="s">&#34;jiaying@example.com&#34;</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">INSERT INTO &#34;customers&#34; (...) VALUES (...) ON CONFLICT (&#34;email&#34;) DO UPDATE SET &#34;name&#34;=&#34;excluded&#34;.&#34;name&#34; RETURNING &#34;id&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification (SQLSTATE 42P10)</span></span></code></pre></div><p><code>ON CONFLICT</code> 的目標要寫出和部分索引相同的條件，PostgreSQL 才認得它；<code>clause.OnConflict</code> 的 <code>TargetWhere</code> 就是那個條件：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Clauses</span><span class="p">(</span><span class="nx">clause</span><span class="p">.</span><span class="nx">OnConflict</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="nx">Columns</span><span class="p">:</span>     <span class="p">[]</span><span class="nx">clause</span><span class="p">.</span><span class="nx">Column</span><span class="p">{{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;email&#34;</span><span class="p">}},</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="nx">TargetWhere</span><span class="p">:</span> <span class="nx">clause</span><span class="p">.</span><span class="nx">Where</span><span class="p">{</span><span class="nx">Exprs</span><span class="p">:</span> <span class="p">[]</span><span class="nx">clause</span><span class="p">.</span><span class="nx">Expression</span><span class="p">{</span><span class="nx">clause</span><span class="p">.</span><span class="nx">Expr</span><span class="p">{</span><span class="nx">SQL</span><span class="p">:</span> <span class="s">&#34;deleted_at IS NULL&#34;</span><span class="p">}}},</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="nx">DoUpdates</span><span class="p">:</span>   <span class="nx">clause</span><span class="p">.</span><span class="nf">AssignmentColumns</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;name&#34;</span><span class="p">}),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}).</span><span class="nf">Create</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;佳穎2&#34;</span><span class="p">,</span> <span class="nx">Email</span><span class="p">:</span> <span class="s">&#34;jiaying@example.com&#34;</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">INSERT INTO &#34;customers&#34; (...) VALUES (...) ON CONFLICT (&#34;email&#34;)  WHERE deleted_at IS NULL DO UPDATE SET &#34;name&#34;=&#34;excluded&#34;.&#34;name&#34; RETURNING &#34;id&#34;</span></span></code></pre></div><p>MySQL 沒有部分索引，「只對未刪除的列要求 email 唯一」要改用產生欄位（generated column）或複合唯一索引，兩種做法見 <a href="/blog/backend/knowledge-cards/soft-delete/" data-link-title="Soft Delete（軟刪除）" data-link-desc="資料表有 deleted_at 欄位、ORM 的刪除改成 UPDATE，或刪掉的資料再建一次卻撞唯一約束、報表數字和頁面對不上時，查軟刪除的過濾條件由誰加、哪些路徑沒有加">Soft Delete 知識卡</a>。</p>
<h2 id="沒有條件的-delete-與-update">沒有條件的 Delete 與 Update</h2>
<p><code>Delete</code> 或 <code>Update</code> 沒有任何條件時，GORM 拒絕執行並回傳 <code>gorm.ErrMissingWhereClause</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{}).</span><span class="nx">Error</span> <span class="c1">// WHERE conditions required</span></span></span></code></pre></div><p>GORM 的記錄器（logger）印出了它組好的 <code>UPDATE &quot;customers&quot; SET &quot;deleted_at&quot;=... WHERE &quot;customers&quot;.&quot;deleted_at&quot; IS NULL</code>，而 PostgreSQL 的語句記錄（<code>log_statement</code>，見 <a href="/blog/backend/knowledge-cards/query-log/" data-link-title="Query Log（SQL 語句記錄）" data-link-desc="要確認 ORM 或資料存取函式庫實際送出了哪些 SQL、交易的 begin 與 commit 有沒有送出，或在正式環境追查慢查詢與某一筆資料被誰改過時，查應用程式端與資料庫端的語句記錄各記了什麼">Query Log 知識卡</a>）裡沒有這一句：檢查發生在送出之前。</p>
<p>這個檢查看的是「有沒有條件」，不是「條件會挑出幾列」。條件寫成永遠成立的式子，檢查就通過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="s">&#34;1 = 1&#34;</span><span class="p">).</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</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">UPDATE &#34;customers&#34; SET &#34;deleted_at&#34;=&#39;2026-10-06 14:42:48.947&#39; WHERE 1 = 1 AND &#34;customers&#34;.&#34;deleted_at&#34; IS NULL</span></span></code></pre></div><p>影響三列，表裡的顧客全部被軟刪除。依使用者輸入組條件的程式常先放一個 <code>1 = 1</code> 再逐一串接 <code>AND</code>，篩選條件全部留空時組出的就是這一句，GORM 的檢查照樣放行；確實要對整張表操作時，GORM 的做法是 <code>Session(&amp;gorm.Session{AllowGlobalUpdate: true})</code>，讓這個意圖寫在程式碼上。</p>
<h2 id="save寫回全部欄位">Save：寫回全部欄位</h2>
<p><code>Save</code> 把 struct 的每一個欄位都寫回去，包括沒有改的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">a</span> <span class="nx">Customer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">a</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">a</span><span class="p">.</span><span class="nx">Name</span> <span class="p">=</span> <span class="s">&#34;宗翰（改）&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">a</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">UPDATE &#34;customers&#34; SET &#34;created_at&#34;=&#39;2026-10-06 14:27:11.427&#39;,&#34;updated_at&#34;=&#39;2026-10-06 14:27:15.442&#39;,&#34;deleted_at&#34;=NULL,&#34;name&#34;=&#39;宗翰（改）&#39;,&#34;email&#34;=&#39;zonghan@example.com&#39; WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL AND &#34;id&#34; = 2</span></span></code></pre></div><p><code>created_at</code>、<code>email</code> 都用讀出時的值再寫一次。兩個請求同時讀出同一位顧客、各改一個欄位、各自 <code>Save</code>，後寫的那一個會用它讀出時的舊值蓋掉前一個的修改。下面的 <code>p</code> 與 <code>q</code> 代表兩個請求各自讀出的宗翰，讀出時的名字是上一個示範寫進去的「宗翰（改）」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">p</span><span class="p">,</span> <span class="nx">q</span> <span class="nx">Customer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">p</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">q</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">p</span><span class="p">.</span><span class="nx">Name</span> <span class="p">=</span> <span class="s">&#34;宗翰 A&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">p</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">q</span><span class="p">.</span><span class="nx">Email</span> <span class="p">=</span> <span class="s">&#34;zonghan.b@example.com&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">q</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">UPDATE &#34;customers&#34; SET ...,&#34;name&#34;=&#39;宗翰 A&#39;,&#34;email&#34;=&#39;zonghan@example.com&#39; WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL AND &#34;id&#34; = 2
</span></span><span class="line"><span class="ln">2</span><span class="cl">UPDATE &#34;customers&#34; SET ...,&#34;name&#34;=&#39;宗翰（改）&#39;,&#34;email&#34;=&#39;zonghan.b@example.com&#39; WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL AND &#34;id&#34; = 2</span></span></code></pre></div><p>再讀一次這位顧客，名字是「宗翰（改）」：<code>p</code> 改的名字被 <code>q</code> 帶著的舊名字蓋掉了，兩個呼叫都沒有錯誤。這種情形叫遺失更新（lost update），Django 的 <code>save()</code> 預設也是寫回全部欄位（對照見 <a href="/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層</a>〈ORM 寫回哪些欄位：變更追蹤、全部欄位與零值〉）。只改幾個欄位時，用 <code>Updates</code> 傳 map，或 <code>Select</code> 指定欄位，讓 <code>UPDATE</code> 只寫那幾個欄位；同一個欄位也可能被兩個請求同時改時，要另外加樂觀鎖（optimistic locking：<code>UPDATE</code> 的條件多帶一個讀出時的版本值，影響零列就代表被別人改過；它和悲觀鎖的取捨見 <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 強一致取捨">1.3 Transaction 與一致性邊界</a>〈Optimistic vs Pessimistic Locking〉）。另一個做法是把讀出與寫回放進 repeatable read 的交易：兩個交易時間重疊、改到同一列時，後寫入的那一個在它的 <code>UPDATE</code> 上失敗（<code>could not serialize access due to concurrent update</code>，SQLSTATE <code>40001</code>），程式重試，而不是默默蓋掉。</p>
<h3 id="找不到主鍵時改成-upsert">找不到主鍵時改成 upsert</h3>
<p><code>Save</code> 的 <code>UPDATE</code> 影響零列時，GORM 接著送出一個 <code>INSERT ... ON CONFLICT (&quot;id&quot;) DO UPDATE</code>。GORM <code>Save</code> 原始碼裡改用 upsert 的條件是「更新沒有錯誤、影響零列、沒有用 <code>Select</code> 指定欄位」。拿一個不存在的主鍵 999 去 <code>Save</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">g</span> <span class="o">:=</span> <span class="nx">Customer</span><span class="p">{</span><span class="nx">Model</span><span class="p">:</span> <span class="nx">gorm</span><span class="p">.</span><span class="nx">Model</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="mi">999</span><span class="p">},</span> <span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;幽靈&#34;</span><span class="p">,</span> <span class="nx">Email</span><span class="p">:</span> <span class="s">&#34;ghost@example.com&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">g</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">UPDATE &#34;customers&#34; SET &#34;created_at&#34;=&#39;0000-00-00 00:00:00&#39;,...,&#34;name&#34;=&#39;幽靈&#39;,&#34;email&#34;=&#39;ghost@example.com&#39; WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL AND &#34;id&#34; = 999
</span></span><span class="line"><span class="ln">2</span><span class="cl">INSERT INTO &#34;customers&#34; (&#34;created_at&#34;,&#34;updated_at&#34;,&#34;deleted_at&#34;,&#34;name&#34;,&#34;email&#34;,&#34;id&#34;) VALUES (...,999) ON CONFLICT (&#34;id&#34;) DO UPDATE SET &#34;updated_at&#34;=...,&#34;deleted_at&#34;=&#34;excluded&#34;.&#34;deleted_at&#34;,&#34;name&#34;=&#34;excluded&#34;.&#34;name&#34;,&#34;email&#34;=&#34;excluded&#34;.&#34;email&#34; RETURNING &#34;id&#34;</span></span></code></pre></div><p>表裡多了一位 id 999 的顧客。主鍵來自請求內容的程式（例如 <code>PUT /customers/999</code> 直接把 body 解成 struct 再 <code>Save</code>），會因此建立一筆新資料。</p>
<p>同一個機制碰上軟刪除時，<code>UPDATE</code> 的 <code>deleted_at IS NULL</code> 讓已刪除的列影響零列，接下來的 upsert 用主鍵衝突寫進去，那個條件不在 upsert 裡。實測用 <code>Unscoped</code> 讀出已刪除的雅文、改名字、<code>Save</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Customer</span><span class="p">{},</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">d</span> <span class="nx">Customer</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Unscoped</span><span class="p">().</span><span class="nf">First</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">d</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">d</span><span class="p">.</span><span class="nx">Name</span> <span class="p">=</span> <span class="s">&#34;雅文（改）&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">d</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">UPDATE &#34;customers&#34; SET ...,&#34;deleted_at&#34;=&#39;2026-10-06 14:27:15.513&#39;,&#34;name&#34;=&#39;雅文（改）&#39;,... WHERE &#34;customers&#34;.&#34;deleted_at&#34; IS NULL AND &#34;id&#34; = 3
</span></span><span class="line"><span class="ln">2</span><span class="cl">INSERT INTO &#34;customers&#34; (...,&#34;id&#34;) VALUES (...,3) ON CONFLICT (&#34;id&#34;) DO UPDATE SET ...,&#34;name&#34;=&#34;excluded&#34;.&#34;name&#34;,... RETURNING &#34;id&#34;</span></span></code></pre></div><p>雅文那一列仍然是已刪除的狀態，名字卻被改掉了。upsert 寫入的 <code>deleted_at</code> 是 struct 上帶的值，這裡的 struct 是從資料庫讀出來的，所以帶著原本的刪除時間。struct 若是從請求內容組出來的，<code>DeletedAt</code> 是零值，upsert 就把 <code>deleted_at</code> 寫成 NULL，已刪除的列被恢復：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><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="nx">in</span> <span class="o">:=</span> <span class="nx">Customer</span><span class="p">{</span><span class="nx">Model</span><span class="p">:</span> <span class="nx">gorm</span><span class="p">.</span><span class="nx">Model</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="mi">3</span><span class="p">},</span> <span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;雅文&#34;</span><span class="p">,</span> <span class="nx">Email</span><span class="p">:</span> <span class="s">&#34;yawen@example.com&#34;</span><span class="p">}</span> <span class="c1">// 從請求內容組出的 struct</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">in</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">INSERT INTO &#34;customers&#34; (&#34;created_at&#34;,&#34;updated_at&#34;,&#34;deleted_at&#34;,&#34;name&#34;,&#34;email&#34;,&#34;id&#34;) VALUES (...,NULL,&#39;雅文&#39;,&#39;yawen@example.com&#39;,3) ON CONFLICT (&#34;id&#34;) DO UPDATE SET ...,&#34;deleted_at&#34;=&#34;excluded&#34;.&#34;deleted_at&#34;,... RETURNING &#34;id&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT * FROM &#34;customers&#34; WHERE &#34;customers&#34;.&#34;id&#34; = 3 AND &#34;customers&#34;.&#34;deleted_at&#34; IS NULL ORDER BY &#34;customers&#34;.&#34;id&#34; LIMIT 1</span></span></code></pre></div><p>之後的 <code>First(&amp;c, 3)</code> 查得到雅文，使用者刪掉的資料被一次編輯請求恢復成未刪除。</p>
<p><code>Save</code> 適合的情形是 struct 本身就是那一列的完整內容、而且確定這一列存在且沒有被刪除（例如在交易裡用 <code>SELECT ... FOR UPDATE</code> 鎖住那一列再讀出、修改、寫回；只是放進交易而沒有鎖，PostgreSQL 預設的 read committed 照樣擋不住 <code>p</code> 與 <code>q</code> 互相蓋掉的遺失更新）；其他情形用 <code>Create</code> 建立、用 <code>Updates</code> 或 <code>Update</code> 修改，兩者都不會在找不到時建立資料。依主鍵同步外部資料的匯入工作，要的正是「有就更新、沒有就建立」，這時與其依賴 <code>Save</code> 的隱含行為，明寫 <code>clause.OnConflict</code> 讓意圖留在程式碼上。</p>
]]></content:encoded></item><item><title>1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM</title><link>https://tarrragon.github.io/blog/backend/01-database/data-access-layers/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/data-access-layers/</guid><description>&lt;p>這篇整理應用程式存取關聯式資料庫的工具分層，與語言無關：每一類工具替程式接手了哪一段工作、接手之後哪些行為從程式碼上消失、選型時比較什麼。各語言的工具名稱與 API 不同，分層相同，所以文中每一類都對照 Go、PHP（Laravel）與 Python 的代表工具。Go 的實際寫法在 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/" data-link-title="模組十：資料庫存取" data-link-desc="在 Go 服務裡接 PostgreSQL、要在 database/sql、pgx、sqlx、sqlc 與 GORM 之間選擇，或排查連線池、讀取物件與交易的行為時">Go 的資料庫存取模組&lt;/a>；PHP 與 Python 目前只有執行模型與 ORM 回傳值的相關篇章，列在文末。工具分層與資料庫的種類也無關；文中錯誤代碼、預先解析查詢與連線上限的例子用 PostgreSQL，其他資料庫的差異在各節註明。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/repository-adapter/" data-link-title="1.4 Repository Adapter 實作" data-link-desc="Port / Adapter 邊界、row mapping、error translation、ORM vs query builder 選型、contract test 設計">1.4 Repository Adapter 實作&lt;/a> 把工具分成 Raw SQL、查詢建構器（Query Builder）、ORM 三類；這篇沿用那三類，再把 Raw SQL 細分成驅動、結果對映與從 SQL 產生程式碼，因為這三種工具都由程式寫 SQL，差別在取得連線並執行、把查詢結果對映成程式的值這兩項工作由誰做。&lt;/p>
&lt;h2 id="存取資料庫的工作寫-sql取得連線與交易對映結果">存取資料庫的工作：寫 SQL、取得連線與交易、對映結果&lt;/h2>
&lt;p>不論用哪一種語言、哪一個函式庫，程式從資料庫取資料都要完成三件事：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>寫出要送出的 SQL&lt;/strong>：查詢的文字與參數。&lt;/li>
&lt;li>&lt;strong>取得連線並執行&lt;/strong>：從連線池取一條連線、送出查詢、需要時開始與結束交易、用完把連線放回去。&lt;/li>
&lt;li>&lt;strong>把查詢結果對映成程式的值&lt;/strong>：每一列的每一欄放進程式語言的變數或物件，包括 NULL 要放成什麼。&lt;/li>
&lt;/ul>
&lt;p>每一類工具接手這三件事裡的幾件。接手的那幾件程式就不必寫，代價是那幾件工作的實際行為——送出了哪些 SQL、什麼時候佔用與歸還連線、NULL 被放成什麼——從程式碼上讀不出來了。這三件事說明了工具接手之後看不見什麼；選型時另外還要看 schema 的權威、查詢的形狀、錯誤被發現的時間點與部署環境，見〈選型：從要交出的資料存取層往回看〉。&lt;/p>
&lt;h2 id="各類工具與各語言的代表工具">各類工具與各語言的代表工具&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類別&lt;/th>
 &lt;th>接手的事&lt;/th>
 &lt;th>仍由程式負責&lt;/th>
 &lt;th>Go&lt;/th>
 &lt;th>PHP（Laravel）&lt;/th>
 &lt;th>Python&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>驅動與標準介面&lt;/td>
 &lt;td>連線、通訊協定、型別轉換，多數附帶連線池&lt;/td>
 &lt;td>寫 SQL、逐欄讀取結果&lt;/td>
 &lt;td>&lt;code>database/sql&lt;/code>、pgx&lt;/td>
 &lt;td>PDO、Laravel 的 &lt;code>DB::select&lt;/code>&lt;/td>
 &lt;td>DB-API 2.0 驅動（psycopg）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結果對映&lt;/td>
 &lt;td>把整列依欄名放進 struct 或物件&lt;/td>
 &lt;td>寫 SQL&lt;/td>
 &lt;td>sqlx、pgx 的 &lt;code>RowToStructByName&lt;/code>&lt;/td>
 &lt;td>PDO 的 &lt;code>FETCH_CLASS&lt;/code>&lt;/td>
 &lt;td>psycopg 的 &lt;code>class_row&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>從 SQL 產生程式碼&lt;/td>
 &lt;td>從手寫的 SQL 產生呼叫函式與結果型別，產生時檢查欄位&lt;/td>
 &lt;td>寫 SQL（放在 &lt;code>.sql&lt;/code> 檔）&lt;/td>
 &lt;td>sqlc&lt;/td>
 &lt;td>少見&lt;/td>
 &lt;td>sqlc 搭配產生 Python 程式碼的擴充套件（plugin，sqlc-gen-python）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Query Builder&lt;/td>
 &lt;td>用方法呼叫組出 SQL，結構仍對應 SQL 的子句&lt;/td>
 &lt;td>決定查詢的形狀&lt;/td>
 &lt;td>squirrel、goqu&lt;/td>
 &lt;td>Laravel 的 &lt;code>DB::table()&lt;/code>&lt;/td>
 &lt;td>SQLAlchemy Core&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ORM&lt;/td>
 &lt;td>從 model 與方法呼叫組出 SQL、對映、決定 &lt;code>UPDATE&lt;/code> 寫哪些欄位；部分 ORM 另把寫入包進交易&lt;/td>
 &lt;td>理解它實際送出了什麼&lt;/td>
 &lt;td>GORM、ent&lt;/td>
 &lt;td>Eloquent&lt;/td>
 &lt;td>SQLAlchemy ORM、Django ORM&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表上的「多數附帶連線池」在不同語言的意思不同，見〈各語言執行模型下的連線模型〉。&lt;/p>
&lt;p>「產生程式碼」本身是另一條軸，不只出現在第三類。從 schema 產生有型別 API 的工具分兩種：讀資料庫現有 schema 的（Go 的 SQLBoiler、go-jet，Java 的 jOOQ，Rust 的 Diesel），以及從自己的 schema 定義檔產生程式碼、並由它產生 migration 的（Go 的 ent 寫在 Go 程式碼裡，TypeScript 的 Prisma 寫在 &lt;code>schema.prisma&lt;/code>），ent 與 Prisma 的 schema 權威在那份定義檔上。兩種的查詢都由方法呼叫組出，所以歸在 Query Builder 或 ORM，而欄位改名同樣在編譯時失敗。表上第三類特指從手寫的 SQL 產生程式碼的工具。&lt;/p>
&lt;h2 id="每一類接手之後看不見的行為">每一類接手之後看不見的行為&lt;/h2>
&lt;h3 id="驅動與標準介面">驅動與標準介面&lt;/h3>
&lt;p>用驅動直接寫 SQL 時三件事大多看得見，看不見的是驅動自己的預設行為：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>連線什麼時候還回池裡&lt;/strong>：各語言歸還連線的時機不同。Go 的 &lt;code>database/sql&lt;/code> 每次查詢向池借一條連線，查詢結果的讀取物件（cursor，本站譯為查詢結果的讀取物件，見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cursor/" data-link-title="Cursor（游標、查詢結果的讀取物件、接續標記）" data-link-desc="在程式碼、API 或文件裡看到 cursor 這個字，要分辨它指的是畫面上的游標、資料庫逐筆讀取查詢結果的物件，還是分頁回應裡記錄從哪裡接續的標記時">Cursor&lt;/a>）關閉時才放回去；讀到最後一列時會自動關閉，讀到一半就離開、又沒有呼叫 &lt;code>Close&lt;/code> 時，那條連線就一直被佔著。這種借了不還的情形是連線洩漏（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-leak/" data-link-title="Connection Leak（連線洩漏）" data-link-desc="請求越來越慢、最後在等連線時逾時，而資料庫本身負載不高，或連線池使用中的數量一直停在上限時，查哪一段程式借了連線沒有還">Connection Leak&lt;/a>），累積到池的上限時症狀才浮現。Python 的 SQLAlchemy 與 &lt;code>psycopg_pool&lt;/code> 由程式明確借出連線，&lt;code>with&lt;/code> 區塊結束時歸還，洩漏發生在借出連線的那一段沒有收尾（psycopg 預設的 cursor 在執行時就把結果全部讀進來）。PHP-FPM 在請求結束時一併釋放。&lt;/li>
&lt;li>&lt;strong>NULL 的對應&lt;/strong>：每種語言要有一個能表示「沒有值」（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL&lt;/a>）的型別。Go 的 &lt;code>string&lt;/code> 零值是空字串，沒有一個值代表 NULL，所以要用指標或 &lt;code>sql.NullString&lt;/code>；PHP 與 Python 的動態型別直接用 &lt;code>null&lt;/code>／&lt;code>None&lt;/code>。&lt;/li>
&lt;li>&lt;strong>預先解析的查詢&lt;/strong>：有些驅動會把執行過的查詢在連線上建成預先解析的查詢（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/prepared-statement/" data-link-title="Prepared Statement（預先解析的查詢）" data-link-desc="在資料庫的語句記錄裡看到 stmtcache_、pdo_stmt_ 這類具名的查詢被重複執行，或應用程式接上 PgBouncer 交易模式後出現 prepared statement already exists、does not exist 的錯誤時，查它在哪一條連線上建立、存在多久">prepared statement&lt;/a>），下次執行同一段 SQL 只送名字與參數。那個名字只存在於建立它的那一條資料庫連線上，而 PgBouncer 這類連線池代理在交易模式（transaction pooling）下，同一條應用程式連線的下一個交易可能被分到另一條資料庫連線，名字因此對不上。各驅動的預設不同：Go 的 pgx 每條連線都快取；PHP 的 PDO pgsql 驅動預設在伺服器端建立具名的 &lt;code>pdo_stmt_…&lt;/code>，Laravel 沒有改掉這個預設；Python 的 psycopg 3 在同一段查詢執行過 5 次之後、第 6 次執行時才建立（&lt;code>prepare_threshold&lt;/code>，預設 5）；Django 預設用用戶端綁定，不建立。接上交易模式的代理時，處理方式是讓代理追蹤這些名字（PgBouncer 1.21 起的 &lt;code>max_prepared_statements&lt;/code>，1.24 起預設開啟），或關掉驅動的這個行為。&lt;/li>
&lt;li>&lt;strong>參數一律用佔位符傳&lt;/strong>：值拼進 SQL 字串就是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sql-injection/" data-link-title="SQL Injection（SQL 注入）" data-link-desc="看到程式把變數拼進 SQL 字串、或在審查資料存取的程式碼時，查它的值是用佔位符傳給資料庫，還是成了 SQL 文字的一部分">SQL 注入（SQL injection）&lt;/a>的入口，這件事在每一層工具都成立。&lt;/li>
&lt;/ul>
&lt;h3 id="結果對映">結果對映&lt;/h3>
&lt;p>依欄名對映讓程式不必逐欄讀取，而對映發生在&lt;strong>執行時&lt;/strong>：查詢結果多了一個物件裡沒有的欄位、或欄位改了名字，錯誤在執行那一行時才出現。最常見的情形是查詢寫 &lt;code>SELECT *&lt;/code>，資料表後來新增一個欄位：在 sqlx、psycopg 的 &lt;code>class_row&lt;/code> 這類嚴格對映裡，所有對映到同一個型別的查詢從 migration 套用的那一刻起同時開始失敗，而程式碼一行都沒改；PDO 的 &lt;code>FETCH_CLASS&lt;/code> 則把多出來的欄位塞成物件的動態屬性，只發出 Deprecated 警告。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理應用程式存取關聯式資料庫的工具分層，與語言無關：每一類工具替程式接手了哪一段工作、接手之後哪些行為從程式碼上消失、選型時比較什麼。各語言的工具名稱與 API 不同，分層相同，所以文中每一類都對照 Go、PHP（Laravel）與 Python 的代表工具。Go 的實際寫法在 <a href="/blog/programming/go/basic/10-database-access/" data-link-title="模組十：資料庫存取" data-link-desc="在 Go 服務裡接 PostgreSQL、要在 database/sql、pgx、sqlx、sqlc 與 GORM 之間選擇，或排查連線池、讀取物件與交易的行為時">Go 的資料庫存取模組</a>；PHP 與 Python 目前只有執行模型與 ORM 回傳值的相關篇章，列在文末。工具分層與資料庫的種類也無關；文中錯誤代碼、預先解析查詢與連線上限的例子用 PostgreSQL，其他資料庫的差異在各節註明。</p>
<p><a href="/blog/backend/01-database/repository-adapter/" data-link-title="1.4 Repository Adapter 實作" data-link-desc="Port / Adapter 邊界、row mapping、error translation、ORM vs query builder 選型、contract test 設計">1.4 Repository Adapter 實作</a> 把工具分成 Raw SQL、查詢建構器（Query Builder）、ORM 三類；這篇沿用那三類，再把 Raw SQL 細分成驅動、結果對映與從 SQL 產生程式碼，因為這三種工具都由程式寫 SQL，差別在取得連線並執行、把查詢結果對映成程式的值這兩項工作由誰做。</p>
<h2 id="存取資料庫的工作寫-sql取得連線與交易對映結果">存取資料庫的工作：寫 SQL、取得連線與交易、對映結果</h2>
<p>不論用哪一種語言、哪一個函式庫，程式從資料庫取資料都要完成三件事：</p>
<ul>
<li><strong>寫出要送出的 SQL</strong>：查詢的文字與參數。</li>
<li><strong>取得連線並執行</strong>：從連線池取一條連線、送出查詢、需要時開始與結束交易、用完把連線放回去。</li>
<li><strong>把查詢結果對映成程式的值</strong>：每一列的每一欄放進程式語言的變數或物件，包括 NULL 要放成什麼。</li>
</ul>
<p>每一類工具接手這三件事裡的幾件。接手的那幾件程式就不必寫，代價是那幾件工作的實際行為——送出了哪些 SQL、什麼時候佔用與歸還連線、NULL 被放成什麼——從程式碼上讀不出來了。這三件事說明了工具接手之後看不見什麼；選型時另外還要看 schema 的權威、查詢的形狀、錯誤被發現的時間點與部署環境，見〈選型：從要交出的資料存取層往回看〉。</p>
<h2 id="各類工具與各語言的代表工具">各類工具與各語言的代表工具</h2>
<table>
  <thead>
      <tr>
          <th>類別</th>
          <th>接手的事</th>
          <th>仍由程式負責</th>
          <th>Go</th>
          <th>PHP（Laravel）</th>
          <th>Python</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>驅動與標準介面</td>
          <td>連線、通訊協定、型別轉換，多數附帶連線池</td>
          <td>寫 SQL、逐欄讀取結果</td>
          <td><code>database/sql</code>、pgx</td>
          <td>PDO、Laravel 的 <code>DB::select</code></td>
          <td>DB-API 2.0 驅動（psycopg）</td>
      </tr>
      <tr>
          <td>結果對映</td>
          <td>把整列依欄名放進 struct 或物件</td>
          <td>寫 SQL</td>
          <td>sqlx、pgx 的 <code>RowToStructByName</code></td>
          <td>PDO 的 <code>FETCH_CLASS</code></td>
          <td>psycopg 的 <code>class_row</code></td>
      </tr>
      <tr>
          <td>從 SQL 產生程式碼</td>
          <td>從手寫的 SQL 產生呼叫函式與結果型別，產生時檢查欄位</td>
          <td>寫 SQL（放在 <code>.sql</code> 檔）</td>
          <td>sqlc</td>
          <td>少見</td>
          <td>sqlc 搭配產生 Python 程式碼的擴充套件（plugin，sqlc-gen-python）</td>
      </tr>
      <tr>
          <td>Query Builder</td>
          <td>用方法呼叫組出 SQL，結構仍對應 SQL 的子句</td>
          <td>決定查詢的形狀</td>
          <td>squirrel、goqu</td>
          <td>Laravel 的 <code>DB::table()</code></td>
          <td>SQLAlchemy Core</td>
      </tr>
      <tr>
          <td>ORM</td>
          <td>從 model 與方法呼叫組出 SQL、對映、決定 <code>UPDATE</code> 寫哪些欄位；部分 ORM 另把寫入包進交易</td>
          <td>理解它實際送出了什麼</td>
          <td>GORM、ent</td>
          <td>Eloquent</td>
          <td>SQLAlchemy ORM、Django ORM</td>
      </tr>
  </tbody>
</table>
<p>表上的「多數附帶連線池」在不同語言的意思不同，見〈各語言執行模型下的連線模型〉。</p>
<p>「產生程式碼」本身是另一條軸，不只出現在第三類。從 schema 產生有型別 API 的工具分兩種：讀資料庫現有 schema 的（Go 的 SQLBoiler、go-jet，Java 的 jOOQ，Rust 的 Diesel），以及從自己的 schema 定義檔產生程式碼、並由它產生 migration 的（Go 的 ent 寫在 Go 程式碼裡，TypeScript 的 Prisma 寫在 <code>schema.prisma</code>），ent 與 Prisma 的 schema 權威在那份定義檔上。兩種的查詢都由方法呼叫組出，所以歸在 Query Builder 或 ORM，而欄位改名同樣在編譯時失敗。表上第三類特指從手寫的 SQL 產生程式碼的工具。</p>
<h2 id="每一類接手之後看不見的行為">每一類接手之後看不見的行為</h2>
<h3 id="驅動與標準介面">驅動與標準介面</h3>
<p>用驅動直接寫 SQL 時三件事大多看得見，看不見的是驅動自己的預設行為：</p>
<ul>
<li><strong>連線什麼時候還回池裡</strong>：各語言歸還連線的時機不同。Go 的 <code>database/sql</code> 每次查詢向池借一條連線，查詢結果的讀取物件（cursor，本站譯為查詢結果的讀取物件，見 <a href="/blog/backend/knowledge-cards/cursor/" data-link-title="Cursor（游標、查詢結果的讀取物件、接續標記）" data-link-desc="在程式碼、API 或文件裡看到 cursor 這個字，要分辨它指的是畫面上的游標、資料庫逐筆讀取查詢結果的物件，還是分頁回應裡記錄從哪裡接續的標記時">Cursor</a>）關閉時才放回去；讀到最後一列時會自動關閉，讀到一半就離開、又沒有呼叫 <code>Close</code> 時，那條連線就一直被佔著。這種借了不還的情形是連線洩漏（<a href="/blog/backend/knowledge-cards/connection-leak/" data-link-title="Connection Leak（連線洩漏）" data-link-desc="請求越來越慢、最後在等連線時逾時，而資料庫本身負載不高，或連線池使用中的數量一直停在上限時，查哪一段程式借了連線沒有還">Connection Leak</a>），累積到池的上限時症狀才浮現。Python 的 SQLAlchemy 與 <code>psycopg_pool</code> 由程式明確借出連線，<code>with</code> 區塊結束時歸還，洩漏發生在借出連線的那一段沒有收尾（psycopg 預設的 cursor 在執行時就把結果全部讀進來）。PHP-FPM 在請求結束時一併釋放。</li>
<li><strong>NULL 的對應</strong>：每種語言要有一個能表示「沒有值」（<a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a>）的型別。Go 的 <code>string</code> 零值是空字串，沒有一個值代表 NULL，所以要用指標或 <code>sql.NullString</code>；PHP 與 Python 的動態型別直接用 <code>null</code>／<code>None</code>。</li>
<li><strong>預先解析的查詢</strong>：有些驅動會把執行過的查詢在連線上建成預先解析的查詢（<a href="/blog/backend/knowledge-cards/prepared-statement/" data-link-title="Prepared Statement（預先解析的查詢）" data-link-desc="在資料庫的語句記錄裡看到 stmtcache_、pdo_stmt_ 這類具名的查詢被重複執行，或應用程式接上 PgBouncer 交易模式後出現 prepared statement already exists、does not exist 的錯誤時，查它在哪一條連線上建立、存在多久">prepared statement</a>），下次執行同一段 SQL 只送名字與參數。那個名字只存在於建立它的那一條資料庫連線上，而 PgBouncer 這類連線池代理在交易模式（transaction pooling）下，同一條應用程式連線的下一個交易可能被分到另一條資料庫連線，名字因此對不上。各驅動的預設不同：Go 的 pgx 每條連線都快取；PHP 的 PDO pgsql 驅動預設在伺服器端建立具名的 <code>pdo_stmt_…</code>，Laravel 沒有改掉這個預設；Python 的 psycopg 3 在同一段查詢執行過 5 次之後、第 6 次執行時才建立（<code>prepare_threshold</code>，預設 5）；Django 預設用用戶端綁定，不建立。接上交易模式的代理時，處理方式是讓代理追蹤這些名字（PgBouncer 1.21 起的 <code>max_prepared_statements</code>，1.24 起預設開啟），或關掉驅動的這個行為。</li>
<li><strong>參數一律用佔位符傳</strong>：值拼進 SQL 字串就是 <a href="/blog/backend/knowledge-cards/sql-injection/" data-link-title="SQL Injection（SQL 注入）" data-link-desc="看到程式把變數拼進 SQL 字串、或在審查資料存取的程式碼時，查它的值是用佔位符傳給資料庫，還是成了 SQL 文字的一部分">SQL 注入（SQL injection）</a>的入口，這件事在每一層工具都成立。</li>
</ul>
<h3 id="結果對映">結果對映</h3>
<p>依欄名對映讓程式不必逐欄讀取，而對映發生在<strong>執行時</strong>：查詢結果多了一個物件裡沒有的欄位、或欄位改了名字，錯誤在執行那一行時才出現。最常見的情形是查詢寫 <code>SELECT *</code>，資料表後來新增一個欄位：在 sqlx、psycopg 的 <code>class_row</code> 這類嚴格對映裡，所有對映到同一個型別的查詢從 migration 套用的那一刻起同時開始失敗，而程式碼一行都沒改；PDO 的 <code>FETCH_CLASS</code> 則把多出來的欄位塞成物件的動態屬性，只發出 Deprecated 警告。</p>
<h3 id="從-sql-產生程式碼">從 SQL 產生程式碼</h3>
<p>從 SQL 產生程式碼的工具讀 schema 與手寫的 SQL，產生呼叫那段 SQL 的函式，以及參數與結果的型別。欄位拼錯、型別不符在產生程式碼時就被擋下，而 SQL 仍然是人寫的、審查時看得到。代價有兩項：產生步驟變成建置流程的一部分（schema 改了要重新產生；產生出來的程式碼二選一：提交進版本控制，或在建置時產生）；以及每一段 SQL 在產生時就固定了，「使用者勾了哪些篩選條件就加哪幾個 <code>WHERE</code>」這種動態組合的查詢寫起來吃力。</p>
<h3 id="query-builder">Query Builder</h3>
<p>Query Builder 用方法呼叫組出 SQL，每個方法大致對應一個子句（<code>where</code>、<code>join</code>、<code>orderBy</code>），所以讀程式碼仍然看得出送出的 SQL 長什麼樣，而且條件可以依執行時的輸入逐步加上去，正好補上從 SQL 產生程式碼的弱項。Query Builder 不負責 model 之間的關聯，也不追蹤物件的變更。</p>
<p>動態組合的條件有兩處容易出錯。排序欄位、排序方向這類<strong>識別字</strong>沒辦法用佔位符傳，由使用者決定時要用程式裡的白名單對映成欄位名（GORM 的 <code>Order(userInput)</code> 這類寫法會把輸入原樣放進 SQL）。另外，GORM 用 struct 當查詢條件時同樣略過零值欄位，「狀態 = 0」「啟用 = false」這種篩選會被靜默丟掉，條件要用 map 或明確的 <code>Where(&quot;col = ?&quot;, v)</code>。</p>
<h3 id="orm">ORM</h3>
<p>ORM 接手寫 SQL、取得連線與交易、對映結果這三件事，所以看不見的行為最多。四類最常在正式環境造成問題。</p>
<h4 id="orm-送出的查詢次數延遲載入與-n1">ORM 送出的查詢次數：延遲載入與 N+1</h4>
<p>延遲載入（lazy loading）在程式每次存取關聯屬性時才發出一次查詢。這個存取寫在迴圈裡，迴圈跑 N 次就多出 N 次查詢，加上取主列表的那一次就是 N+1（見 <a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 應用層查詢反模式與 Query 預算</a>）。各框架都有預先載入（eager loading）的寫法：Laravel 的 <code>with</code>、SQLAlchemy 的 <code>selectinload</code>、Django 的 <code>select_related</code> 與 <code>prefetch_related</code>、GORM 的 <code>Preload</code>。也有在開發時把延遲載入變成錯誤的開關：Laravel 的 <code>Model::preventLazyLoading()</code>、SQLAlchemy 關聯設定的 <code>lazy=&quot;raise&quot;</code>。GORM 沒有延遲載入，它的 N+1 來自程式自己在迴圈裡發的查詢（見 <a href="/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL</a>）。</p>
<h4 id="orm-的交易邊界gormlaraveldjango-與-sqlalchemy">ORM 的交易邊界：GORM、Laravel、Django 與 SQLAlchemy</h4>
<p>同一段「先寫 A 再寫 B」的程式碼，在各 ORM 裡的交易邊界不同（交易範圍怎麼劃見 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">Transaction Boundary</a>）：</p>
<ul>
<li>每一次寫入自動包成一個交易（GORM 的預設行為）：A 與 B 是兩個各自提交的交易。</li>
<li>不自動包、要程式明確開始（Laravel 的 <code>DB::transaction</code>；Django 預設也是自動提交，要用 <code>transaction.atomic</code>，或設定 <code>ATOMIC_REQUESTS</code> 讓每個請求包成一個交易）：A 與 B 是兩個各自自動提交的語句。</li>
<li>第一次寫入時自動開始交易、到 <code>commit()</code> 才提交（SQLAlchemy 的 Session）：A 與 B 在同一個交易裡，忘了呼叫 <code>commit()</code> 則兩者都不會寫入。</li>
</ul>
<p>前兩種情形下，B 失敗時 A 不會撤銷，除非程式自己把兩者包進一個交易。</p>
<h4 id="orm-寫回哪些欄位變更追蹤全部欄位與零值">ORM 寫回哪些欄位：變更追蹤、全部欄位與零值</h4>
<p>ORM 要決定 <code>UPDATE</code> 要寫哪幾個欄位，常見的規則有三種：</p>
<ul>
<li><strong>變更追蹤（dirty tracking）</strong>：Eloquent 與 SQLAlchemy 追蹤哪些屬性被改過，只寫改過的。</li>
<li><strong>寫回全部欄位</strong>：Django 的 <code>save()</code> 預設把 model 的每個欄位都寫回（傳 <code>update_fields</code> 才只寫指定的），兩個請求同時改同一列的不同欄位時，後寫的請求會用它讀出時的舊值，蓋掉先寫的請求改過的欄位。</li>
<li><strong>跳過零值</strong>：GORM 的 <code>Updates</code> 傳入 struct 時不追蹤變更（<code>Save</code> 則寫回全部欄位，找不到那一列時還會改成 upsert，見 <a href="/blog/programming/go/basic/10-database-access/gorm-model-and-soft-delete/" data-link-title="10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回" data-link-desc="gorm.Model 帶進來的 ID、CreatedAt、UpdatedAt、DeletedAt 與 UpdatedAt 的更新時機、DeletedAt 讓刪除變成 UPDATE 並替每個查詢加上條件、不經過那個條件的路徑（Raw、其他服務）、軟刪除與關聯載入、外鍵、唯一約束的衝突與部分索引、無條件刪除的保護，以及 Save 寫回全部欄位、找不到主鍵時改成 upsert 而把已刪除的列寫回或恢復">10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回</a>），而是跳過值為零值的欄位。Go 的 struct 欄位沒有「未設定」這個狀態，沒給值就是 0、空字串或 false（Go 稱為零值），所以 GORM 分不出「沒有設定」與「要改成 0」。</li>
</ul>
<p>以「把金額從 300 改成 0」為例：變更追蹤看到金額被改過，寫入 0；GORM 看到金額是零值，當成沒有設定而略過金額這一欄，也不回報錯誤；model 沒有其他要寫的欄位時不送出任何 <code>UPDATE</code>，model 有 <code>UpdatedAt</code>（<code>gorm.Model</code> 就帶這個欄位）時仍會送出只更新 <code>updated_at</code> 的 <code>UPDATE</code>，影響列數是 1，看起來像更新成功。</p>
<h4 id="schema-的權威在-model-還是-migration">schema 的權威在 model 還是 migration</h4>
<p>有的 ORM 從 model 產生 schema（Django 的 <code>makemigrations</code>、GORM 的 <code>AutoMigrate</code>），有的 model 與 schema 分開定義（Laravel 的 migration 與 Eloquent model 是兩份檔案）。從 model 產生時，欄位改名最容易出錯：GORM 的 <code>AutoMigrate</code> 把它當成新增一欄、舊欄留著；Django 的 <code>makemigrations</code> 會詢問是不是改名，回答否就產生刪除舊欄加新增一欄的 migration，舊欄的資料跟著刪掉。產生出來的 migration 要逐份讀過。</p>
<p>這四類看不見的行為各有看得到它的地方：送出幾次查詢與寫回哪些欄位，看 ORM 的 SQL 記錄（應用程式端印出它送出的語句，兩種記錄的開啟方式與差別見 <a href="/blog/backend/knowledge-cards/query-log/" data-link-title="Query Log（SQL 語句記錄）" data-link-desc="要確認 ORM 或資料存取函式庫實際送出了哪些 SQL、交易的 begin 與 commit 有沒有送出，或在正式環境追查慢查詢與某一筆資料被誰改過時，查應用程式端與資料庫端的語句記錄各記了什麼">Query Log</a>）；交易邊界看資料庫端的語句記錄（PostgreSQL 的 <code>log_statement</code>），因為有的 ORM（GORM）的 SQL 記錄不印 <code>begin</code> 與 <code>commit</code>；schema 的那一類看的是產生出來的 migration 檔。程式碼審查時看的是 ORM 的 SQL 記錄、資料庫的語句記錄與 migration 檔，而不只是方法呼叫。</p>
<h2 id="各語言執行模型下的連線模型">各語言執行模型下的連線模型</h2>
<p>「工具附帶連線池」這句話在三種語言裡的意思不同，因為池活在程序（process）裡，而三種語言的服務用不同的方式使用程序（池本身的概念見 <a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Connection Pool</a>）：</p>
<table>
  <thead>
      <tr>
          <th>執行模型</th>
          <th>連線怎麼來</th>
          <th>對資料庫的連線總數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Go：一個程序，每個請求在各自的 goroutine（Go 執行期管理的輕量執行緒）裡處理，全部共用</td>
          <td>程序裡的一個連線池，所有請求共用</td>
          <td>實例數 × 池的上限</td>
      </tr>
      <tr>
          <td>Python：多個 worker 程序（例如 gunicorn），每個程序各自持有連線</td>
          <td>每個程序各自的池（SQLAlchemy 的 engine 內建池；psycopg 用 <code>psycopg_pool</code>，沒設 <code>max_size</code> 時上限等於 <code>min_size</code>，而 <code>min_size</code> 預設 4）；Django 的連線依執行緒分開，預設 <code>CONN_MAX_AGE = 0</code>，每個請求開一條連線、請求結束時關閉，Django 5.1 起可在資料庫設定的 <code>OPTIONS</code> 開啟 <code>pool</code>（用的是 <code>psycopg_pool</code>）</td>
          <td>實例數 × worker 數 × 每個程序同時持有的連線數（有池時是池的上限；沒有池時是每個程序的執行緒或協程數）</td>
      </tr>
      <tr>
          <td>PHP-FPM：每個請求從空白開始</td>
          <td>預設每個請求開一條連線、請求結束時關閉；開啟 PDO 的持久連線（persistent connection）時，每個 FPM worker 保留一條</td>
          <td>實例數 × FPM worker 數（一個 worker 同時只處理一個請求，所以兩者相同）</td>
      </tr>
  </tbody>
</table>
<p>所以「池的大小設多少」在 Go 是一個設定，在 Python 要乘上 worker 數，而 gunicorn 用多執行緒或 gevent worker 時，一個程序裡的每個執行緒或協程各自取用連線，所以每個程序同時持有的連線不只一條。PHP-FPM 沒有程序內的池，連線數跟著 FPM 的 worker 數走（FPM 的程序模型見 <a href="/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕</a>），每台機器的 <code>pm.max_children</code> 乘上機器數就是可能的連線數。PHP 改成常駐 worker（Laravel Octane、FrankenPHP 的 worker 模式）之後，應用程式跨請求留在記憶體裡，資料庫連線也跟著保留，行為往 Python 那一列靠（常駐 worker 模型本身見 <a href="/blog/programming/php/01-server-runtime/long-running-workers/" data-link-title="常駐 worker 模型：FrankenPHP worker 模式、Laravel Octane 與跨請求殘留的狀態" data-link-desc="說明讓 PHP 應用程式只啟動一次、在記憶體裡連續處理請求的常駐 worker 模型：它省下的是每個請求重新啟動框架的成本，失去的是 shared-nothing 的隔離；以 FrankenPHP 的 worker 模式與 Laravel Octane 實測靜態屬性跨請求累積、記憶體隨請求數線性增長與 max-requests 重啟，以及寫給這個模型的程式要避開的寫法">常駐 worker 模型：FrankenPHP worker 模式、Laravel Octane 與跨請求殘留的狀態</a>）。</p>
<p>三種情形都用同一條原則估算上限：全部實例加起來的連線數，要小於資料庫的 <code>max_connections</code> 扣掉維運保留的那幾條（有些託管的資料庫服務另外保留一部分給自己的管理程式）。例如 8 個 Go 實例、每個的池上限設 25，總共 200 條，超過 PostgreSQL 預設的 <code>max_connections = 100</code>；平時流量用不滿池，問題要到流量尖峰，或滾動部署時才出現——滾動部署期間新實例已經啟動、舊實例還沒關閉，實例數短時間變多，新開的連線收到 <code>FATAL: sorry, too many clients already</code>。PostgreSQL 每條連線是一個獨立的程序，上限不能無限調高（成本見 <a href="/blog/backend/01-database/vendors/postgresql/connection-scaling/" data-link-title="PostgreSQL Connection Scaling：process-per-connection model 跟為什麼 pooler 是必裝" data-link-desc="PG 每個 client connection fork 一個 backend process（不是 thread）、RAM 成本 5-15MB/connection、context switch 跟 fork() cost 在 100&#43; connection 後線性放大、所以 pooler 不是 *optional optimization* 而是 *production prerequisite*。本文走 process-per-connection model 跟 MySQL thread-per-connection 對比、max_connections &#43; shared_buffers &#43; work_mem 三 GUC 互動、application-side pool vs middleware pool vs RDS Proxy 三層選擇、5 production 踩雷（connection storm / fork() cost 在 burst 流量 / shared_buffers 跟 connection 數壓縮 / double-pool 配置錯誤 / max_connections 設太大反而慢）、跟 PgBouncer config 互補不重複">PostgreSQL Connection Scaling：process-per-connection model 跟為什麼 pooler 是必裝</a>）。</p>
<p>這張表描述的是各語言最常見的部署方式。同一種語言部署成 serverless 函式時，每個並行的執行個體各自有池，連線數跟著並行數走，行為接近 PHP-FPM 那一列；Python 用 async worker 時，一個程序內的協程共用同一個池。</p>
<p>超過時在應用程式與資料庫之間加一層連線池代理（<a href="/blog/backend/knowledge-cards/connection-pooler/" data-link-title="Connection Pooler" data-link-desc="應用層跟資料庫之間的連線複用中介層、解水平擴展時的連線數放大問題">Connection Pooler</a>），讓多個實例共用較少的資料庫連線。加了代理之後，上面那條原則的右邊換成代理接受的應用程式連線上限（PgBouncer 的 <code>max_client_conn</code>），實際連到資料庫的連線數改由代理自己的池設定決定（<code>default_pool_size</code> 是每一組使用者與資料庫各自的上限，總數還要乘上組合數）；PgBouncer 的設定與故障演練見 <a href="/blog/backend/01-database/vendors/postgresql/pgbouncer-config/" data-link-title="PostgreSQL pgBouncer 配置 &#43; 連線池治理" data-link-desc="pgBouncer transaction pooling 配置、跟 application connection pool 的分層、production 故障演練（pool exhaustion / stale connection / DNS failover）跟容量規劃">PostgreSQL pgBouncer 配置 + 連線池治理</a>。</p>
<h2 id="各語言讀取約束名稱的位置">各語言讀取約束名稱的位置</h2>
<p>資料庫回報唯一約束違反時，錯誤代碼（PostgreSQL 是 SQLSTATE <code>23505</code>）只說「有唯一約束被違反」，是哪一個約束要看約束名稱。這個轉換是把資料庫錯誤轉成業務錯誤（error translation）的一部分，約束本身見 <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint</a>。以下以 PostgreSQL 為例，它的錯誤回報帶有約束名稱欄位，三種語言的驅動都直接暴露；MySQL 的驅動只給錯誤代碼 1062（SQLSTATE <code>23000</code>）與訊息文字，索引名稱要從訊息解析；SQLite 只回報欄位名稱（<code>UNIQUE constraint failed: customers.email</code>）。</p>
<table>
  <thead>
      <tr>
          <th>語言</th>
          <th>取得約束名稱的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Go（pgx）</td>
          <td><code>*pgconn.PgError</code> 的 <code>ConstraintName</code></td>
      </tr>
      <tr>
          <td>PHP（Laravel）</td>
          <td>唯一約束違反拋出 <code>UniqueConstraintViolationException</code>（<code>QueryException</code> 的子類別），約束名稱在它帶的資料庫錯誤訊息裡；Laravel 13.2 起直接放在例外的 <code>index</code> 屬性（違反的欄位在 <code>columns</code>）</td>
      </tr>
      <tr>
          <td>Python（psycopg）</td>
          <td><code>psycopg.errors.UniqueViolation</code> 的 <code>diag.constraint_name</code>；Django 把它包成 <code>django.db.IntegrityError</code>，原本的 psycopg 例外在 <code>__cause__</code></td>
      </tr>
  </tbody>
</table>
<p>所以建表時替約束取明確的名字（SQL 寫 <code>CONSTRAINT customers_email_key UNIQUE (email)</code>；schema 由 Django model 管理時寫 <code>Meta.constraints</code> 的 <code>UniqueConstraint(fields=[&quot;email&quot;], name=&quot;customers_email_key&quot;)</code>），程式就能依名字回報「email 已被使用」而不是籠統的「資料重複」；讓資料庫自動命名，名字會隨建表工具與版本而不同。</p>
<h2 id="讀寫分離與多個資料庫連線的路由由哪一層決定">讀寫分離與多個資料庫：連線的路由由哪一層決定</h2>
<p>讀寫分離（<a href="/blog/backend/knowledge-cards/read-write-split/" data-link-title="Read-Write Split" data-link-desc="說明讀寫流量如何分流到 primary 與 replica，以及它引入的一致性責任">Read-Write Split</a>）把寫入送到主庫（primary）、把讀取送到一個或多個唯讀副本（replica）。副本的資料由主庫複製過去，會落後一段時間（<a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">Replication Lag</a>），所以每一個查詢送到哪一邊，決定了它讀不讀得到剛寫入的資料。〈存取資料庫的工作：寫 SQL、取得連線與交易、對映結果〉裡「取得連線並執行」那件工作因此多了一個問題：這個查詢要用哪一個資料庫的連線。決定的位置有三種：應用程式自己、ORM 或框架、資料庫前面的代理。</p>
<p><strong>應用程式持有多個連線池，程式明確選</strong>：主庫一個池、副本一個池，每個 repository 方法自己決定用哪一個，可以預設全部讀主庫、只把能接受落後的查詢指定給副本。驅動、結果對映與從 SQL 產生程式碼這幾類工具沒有路由功能，用的就是這一種（sqlc 產生的程式碼接受一個連線介面，傳主庫的池或副本的池都可以）。每個查詢去哪裡都寫在程式碼上，代價是每個方法都要做這個決定。</p>
<p><strong>ORM 或框架依規則路由</strong>：程式照常呼叫，框架依查詢的種類替它選連線。規則各框架不同，而這些規則就是這一層在程式碼上看不見的行為：</p>
<table>
  <thead>
      <tr>
          <th>框架</th>
          <th>一般查詢</th>
          <th>交易裡的查詢</th>
          <th>同一個請求剛寫入之後的查詢</th>
          <th>手寫 SQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GORM 的 dbresolver 外掛</td>
          <td>副本</td>
          <td>主庫</td>
          <td>副本，複製落後時查不到剛寫入的資料</td>
          <td><code>Raw</code> 的 SQL 以 <code>SELECT</code> 開頭、結尾不是 <code>FOR UPDATE</code> 才送副本，<code>WITH ... SELECT</code> 送主庫；<code>Clauses(dbresolver.Write)</code> 強制主庫</td>
      </tr>
      <tr>
          <td>Laravel 的 <code>read</code>／<code>write</code> 連線設定</td>
          <td>副本</td>
          <td>主庫</td>
          <td>設定 <code>sticky =&gt; true</code> 時改送主庫，否則送副本</td>
          <td><code>DB::select</code> 預設送副本；<code>lockForUpdate()</code> 送主庫</td>
      </tr>
      <tr>
          <td>Django 的 <code>DATABASE_ROUTERS</code></td>
          <td>由路由器的 <code>db_for_read</code> 決定</td>
          <td>路由器不知道交易的存在，照 <code>db_for_read</code> 的回傳值送</td>
          <td>照 <code>db_for_read</code> 的回傳值送，不區分</td>
          <td><code>raw()</code> 走 model 的路由；<code>connections[&quot;default&quot;]</code> 直接指定</td>
      </tr>
  </tbody>
</table>
<p>SQLAlchemy 沒有內建這個功能，要覆寫 <code>Session.get_bind</code> 自己寫規則。</p>
<p>GORM 那一列是實測結果（dbresolver v1.6.2，用兩個內容不同的資料庫當主庫與副本，看每個查詢讀到哪一邊的資料，程式與輸出見 <a href="/blog/programming/go/basic/10-database-access/choosing-data-access/" data-link-title="10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合" data-link-desc="把語言無關的資料存取選型軸套到 Go 的工具上：驅動入口選 pgx 原生介面還是 database/sql、主力選 GORM 還是 sqlc、為什麼用 GORM 與用 sqlc 的專案都把 schema 交給 migration 工具、常見的混用組合、混用時交易能不能共用，以及讀寫分離時 dbresolver 把查詢送到主庫或副本的規則">10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合</a>〈讀寫分離：GORM 的 dbresolver 與程式自己持有兩個池〉）；Laravel 那一列取自它的 <code>Connection::getReadPdo</code>：交易層數大於零、或開了 <code>sticky</code> 而這個連線物件已經寫過資料，就改用寫入的連線；Django 的官方文件明寫它的主從路由範例不處理複製延遲，也不考慮交易與路由的交互作用。表上的差異集中在剛寫入之後的讀取（<a href="/blog/backend/knowledge-cards/read-after-write/" data-link-title="Read-After-Write Consistency" data-link-desc="說明寫入後能否立即讀到該筆寫入的一致性保證">Read-After-Write Consistency</a>），而這種讀取有兩種形狀。一種在同一個請求裡：建立訂單之後，同一個請求接著讀出訂單內容放進回應；Laravel 的 <code>sticky</code> 處理的是這一種，GORM 的 dbresolver 不處理。另一種跨兩個請求：使用者送出表單，寫入的請求回應之後瀏覽器被導回列表頁，列表是一個新的請求；<code>sticky</code> 記在連線物件上，新請求不知道剛才寫過，三個框架都照常把列表查詢送到副本，複製落後時讀不到剛寫入的那一筆。同一個請求裡的讀取，由程式在那個查詢上指定主庫：GORM 寫 <code>Clauses(dbresolver.Write)</code>，Laravel 的 Query Builder 寫 <code>useWritePdo()</code>、Eloquent 寫 <code>Model::onWriteConnection()</code>，Django 寫 <code>.using(&quot;default&quot;)</code>（<code>default</code> 是主庫的資料庫別名）。跨請求的讀取要程式自己記住「這個使用者剛寫過」，常見做法是寫入時在 session 記下時間，之後幾秒內這個使用者的讀取都送主庫。</p>
<p><strong>資料庫前面的代理依規則路由</strong>：應用程式只連一個位址，由代理判斷每個語句送哪裡（MySQL 的 ProxySQL 用比對 SQL 文字的規則，見 <a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計</a>；PostgreSQL 有 Pgpool-II 這類代理）。程式碼完全不用改，而代理同樣要判斷交易內的讀取與剛寫入之後的讀取，判斷依據只剩 SQL 文字與連線狀態，比框架知道的更少。</p>
<p>另有兩個機制也決定連線去哪一台機器，但只在建立連線時決定，不逐一判斷查詢。雲端資料庫服務提供一個讀取端點（reader endpoint），用 DNS 在多個副本之間分配連線，它不知道各副本落後多少（見 <a href="/blog/backend/01-database/vendors/aurora/read-replica-scaling/" data-link-title="Aurora Read Replica Scaling：15 replica 上限、lag profile、headroom 預留與 fleet 治理" data-link-desc="Aurora 15 replica 上限、共享 storage 為什麼能養大量 replica、事件型容量分級表、DraftKings headroom 預留判讀、FanDuel 雙 SLO 並行、fleet 治理 3 條 driver（business sharding / microservice / 合規）">Aurora Read Replica Scaling：15 replica 上限、lag profile、headroom 預留與 fleet 治理</a>）；PostgreSQL 的用戶端（libpq、pgx）在連線字串裡列多台主機並設 <code>target_session_attrs</code>（<code>primary</code>、<code>prefer-standby</code> 等），在建立連線時挑出主庫或副本，它決定的是每一條連線連到哪一台，不替每一個查詢挑，用途是讓兩個池的連線字串在主庫切換之後仍然指對機器。</p>
<p>服務要不要加唯讀副本，先看讀取慢的原因：查詢次數或缺索引造成的慢（見 <a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 應用層查詢反模式與 Query 預算</a>），加副本只是把同樣的浪費分到更多機器上；同一批資料被反覆讀取的熱讀，先加快取（見 <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">模組二：快取與 Redis</a>）；寫入量大的服務加副本沒有幫助，副本還要重放同樣多的寫入，落後得更多。副本的容量與落後的量測見 <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發下的 SQL 讀寫邊界</a>〈Read Replica Scaling〉。</p>
<p>決定加副本之後，先選預設方向。框架的路由規則是「預設送副本，例外送主庫」，每一條不能讀舊資料的路徑都要被找出來列為例外；程式自己持有兩個池時可以反過來，「預設送主庫，只把報表、搜尋、統計這類能接受落後的查詢送副本」，要找出來的只剩送副本的那幾條，漏掉的路徑讀的是主庫，不會讀到舊資料。</p>
<p>選了「預設送副本」時，逐條列出三類路徑，再對照所用框架或代理的規則，決定每一條由誰把讀取送到主庫：</p>
<ul>
<li><strong>寫入之後馬上讀</strong>：分成同一個請求裡與跨請求兩種。同一個請求裡的，在那個查詢上指定主庫；跨請求的，由程式記住這個使用者剛寫過。跨請求的那一種用「寫入後幾秒內送主庫」時，落後超過那幾秒就失效；需要確定讀到的時候，改成比對複製位置：寫入時記下主庫的位置（PostgreSQL 的 <code>pg_current_wal_lsn()</code>，或 MySQL 那筆交易的 GTID），讀取前確認副本已經重放到那個位置（PostgreSQL 的 <code>pg_last_wal_replay_lsn()</code>，MySQL 的 <code>WAIT_FOR_EXECUTED_GTID_SET</code>），或在副本查不到時回主庫再查一次（剛建立的短網址被別人點開這種情形，點的人沒有建立者的 session，只能用這個做法）。Django 的路由器拿不到請求物件，記在 session 的寫入時間要另外經由 middleware 放進 context 變數，路由器才讀得到；不用 session 的 API（JWT 驗證的行動 App）把寫入時間放在客戶端帶回的標頭或 cookie 裡。</li>
<li><strong>讀出的結果決定接下來的寫入</strong>：檢查庫存再扣、檢查 email 有沒有被用過再建立。這類讀取讀到舊值就會寫錯，一律送主庫，需要鎖住那一列時用 <code>SELECT ... FOR UPDATE</code>（Laravel 的 <code>lockForUpdate()</code>、Django 的 <code>select_for_update()</code>）；Django 的路由器在 <code>transaction.atomic</code> 裡照樣把一般讀取送副本，這一類要明確指定主庫。</li>
<li><strong>背景工作讀剛寫入的資料</strong>：寫入之後派送的佇列工作（Laravel 的 job、Celery 的 task）在另一個程序裡執行，既不在同一個請求裡，也沒有 session 可記。工作要在交易提交之後才派送（Laravel 的 <code>afterCommit</code>、Django 的 <code>transaction.on_commit</code>），並由工作自己指定讀主庫，或帶著要處理的資料內容出發。</li>
</ul>
<p>分出唯讀副本之後，每個應用程式實例對資料庫開的連線總數也跟著變：每個實例對主庫與每一個副本各開一個池，所以「實例數 × 池的上限」要對主庫與每一個副本各算一次，各自小於那個資料庫的 <code>max_connections</code>（Python 的多 worker 部署再乘上 worker 數）。ORM 提供的「取得底層連線池」方法多半只回傳主庫那一個，副本的池要另外設上限與監控（GORM 的情形見 <a href="/blog/programming/go/basic/10-database-access/choosing-data-access/" data-link-title="10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合" data-link-desc="把語言無關的資料存取選型軸套到 Go 的工具上：驅動入口選 pgx 原生介面還是 database/sql、主力選 GORM 還是 sqlc、為什麼用 GORM 與用 sqlc 的專案都把 schema 交給 migration 工具、常見的混用組合、混用時交易能不能共用，以及讀寫分離時 dbresolver 把查詢送到主庫或副本的規則">10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合</a>）。</p>
<p><strong>多個資料庫</strong>是另一種情形：資料依業務拆在不同的資料庫裡（例如訂單一個、會員一個），而不是同一份資料的副本。各框架都支援一個程式連多個資料庫（Laravel 的 <code>DB::connection('name')</code>、Django 的 <code>using()</code>、GORM dbresolver 依 model 指定資料庫），而三件事跨不過資料庫的邊界：一個交易、外鍵與 <code>JOIN</code>。Django 的文件明寫不支援跨資料庫的外鍵與多對多關聯。需要一起成功的寫入落在兩個資料庫時，交易邊界要重新設計（見 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">Transaction Boundary</a>）。</p>
<h2 id="選型從要交出的資料存取層往回看">選型：從要交出的資料存取層往回看</h2>
<p>選型要交出的是專案的資料存取層：各個 repository 用哪一類工具寫、哪些例外。比較的軸取自那一層要承擔的事，而不是工具本身的特性清單；其中「schema 的擁有者」「應用程式與資料庫之間的基礎設施」與「讀取流量與資料庫拓撲」三列是部署與擁有權的環境，其餘是程式碼庫自己的性質：</p>
<table>
  <thead>
      <tr>
          <th>讀者要決定的事</th>
          <th>條件</th>
          <th>適合的類別</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>schema 的權威放哪裡</td>
          <td>schema 由 migration 的 SQL 定義，程式跟著 schema 走</td>
          <td>從 SQL 產生程式碼、驅動加結果對映</td>
      </tr>
      <tr>
          <td></td>
          <td>schema 由 model 定義（Django 的 <code>makemigrations</code> 從 model 產生 migration），或 migration 與 model 由同一個框架的慣例管理（Laravel 的 migration 與 Eloquent model）</td>
          <td>ORM</td>
      </tr>
      <tr>
          <td>schema 的擁有者</td>
          <td>schema 由另一個服務擁有（多個服務共用一個資料庫、DBA 管理，或接手既有資料庫）</td>
          <td>本服務不跑 migration；從 SQL 產生程式碼的工具把 <code>schema</code> 指向 <code>pg_dump --schema-only</code> 的輸出，或用整合測試比對 schema；約束名稱照擁有方的命名判讀</td>
      </tr>
      <tr>
          <td>應用程式與資料庫之間的基礎設施</td>
          <td>前面有交易模式的連線池代理</td>
          <td>不影響類別的選擇，但會快取預先解析查詢的驅動要調整（見〈驅動與標準介面〉）；連線池上限改對代理的 <code>max_client_conn</code> 估算</td>
      </tr>
      <tr>
          <td>查詢的形狀</td>
          <td>多數是單表或一層關聯的增刪查改</td>
          <td>ORM</td>
      </tr>
      <tr>
          <td></td>
          <td>報表、多表聚合、視窗函數這類固定而複雜的查詢</td>
          <td>從 SQL 產生程式碼，或在 ORM 裡直接寫 SQL</td>
      </tr>
      <tr>
          <td></td>
          <td>篩選條件依使用者輸入動態組合</td>
          <td>Query Builder，或 ORM 的條件串接</td>
      </tr>
      <tr>
          <td>框架的整合程度</td>
          <td>用的是 ORM 與驗證、序列化、後台管理整合在一起的框架（Laravel、Django）</td>
          <td>照框架的 ORM 寫，例外的查詢另外寫 SQL</td>
      </tr>
      <tr>
          <td></td>
          <td>語言沒有主導的全端框架（Go）</td>
          <td>依 schema 的權威與查詢的形狀決定，沒有預設答案</td>
      </tr>
      <tr>
          <td>錯誤在什麼時候被發現</td>
          <td>希望欄位改名、查詢寫錯在產生程式碼或編譯時就失敗</td>
          <td>從 SQL 產生程式碼，或從 schema 產生型別的 Query Builder 與 ORM（ent、jOOQ、Prisma）</td>
      </tr>
      <tr>
          <td></td>
          <td>接受由整合測試在執行時抓出來</td>
          <td>沒有產生步驟的驅動、結果對映、Query Builder 與 ORM</td>
      </tr>
      <tr>
          <td>程式碼審查要看到什麼</td>
          <td>審查者要直接看到每一段送出的 SQL</td>
          <td>驅動、結果對映、從 SQL 產生程式碼</td>
      </tr>
      <tr>
          <td></td>
          <td>審查者看得出查詢的子句結構即可，參數展開與方言細節靠記錄確認</td>
          <td>Query Builder</td>
      </tr>
      <tr>
          <td></td>
          <td>審查看方法呼叫，SQL 靠開發時 ORM 的 SQL 記錄確認（受稽核、要求每段 SQL 在審查時可見的服務不適用）</td>
          <td>ORM</td>
      </tr>
      <tr>
          <td>讀取流量與資料庫拓撲</td>
          <td>有唯讀副本要分流讀取</td>
          <td>不影響類別的選擇；用 ORM 時確認它的路由規則（交易內、剛寫入之後、手寫 SQL 各送哪裡），用驅動與從 SQL 產生程式碼時由程式持有兩個池自己選（見〈讀寫分離與多個資料庫：連線的路由由哪一層決定〉）</td>
      </tr>
      <tr>
          <td>資料庫的種類</td>
          <td>要支援多種資料庫</td>
          <td>ORM、Query Builder（方言由工具處理）</td>
      </tr>
      <tr>
          <td></td>
          <td>只連一種資料庫</td>
          <td>不受限；從 SQL 產生程式碼的工具要選有支援該資料庫的</td>
      </tr>
  </tbody>
</table>
<p>多數專案不只用一類。三種語言都常見同一個組合：一般的增刪查改用框架或專案選定的 ORM，報表與效能敏感的查詢直接寫 SQL（Laravel 的 <code>DB::select</code>、Django 的 <code>raw()</code>、GORM 的 <code>Raw</code>），或在 Go 裡改用從 SQL 產生程式碼的 sqlc。組合本身不是問題，要留意的是兩條路徑共用同一個連線池與交易：ORM 開的交易裡，直接寫的 SQL 也要走同一個交易物件，否則它會從池裡另取一條連線、跑在交易之外；交易物件怎麼一路傳進各個 repository，以及 Laravel、Django 為什麼不必傳，見 <a href="/blog/backend/knowledge-cards/transaction-propagation/" data-link-title="Transaction Propagation（交易傳遞）" data-link-desc="多個 repository 的寫入要在同一個交易裡完成，或一段程式在交易失敗後仍留下部分寫入、交易裡的某個查詢卡住直到逾時時，查交易物件怎麼傳到每一個資料存取呼叫">Transaction Propagation</a>。</p>
<h2 id="各語言的實作">各語言的實作</h2>
<ul>
<li>Go：<a href="/blog/programming/go/basic/10-database-access/" data-link-title="模組十：資料庫存取" data-link-desc="在 Go 服務裡接 PostgreSQL、要在 database/sql、pgx、sqlx、sqlc 與 GORM 之間選擇，或排查連線池、讀取物件與交易的行為時">Go 的資料庫存取模組</a> 依本篇的分層，逐一示範 <code>database/sql</code>、pgx、sqlx、sqlc 與 GORM，每一篇的輸出都在 PostgreSQL 上實際跑過。</li>
<li>Python：ORM 與 DataFrame 在回傳值與查詢送出時機上的差別見 <a href="/blog/programming/python/basic/08-data-analysis/orm-and-dataframe/" data-link-title="8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機" data-link-desc="ORM 產生查詢、DataFrame 執行運算，以及外觀相似的方法串接底下差了什麼">8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機</a>。</li>
<li>PHP：PHP-FPM 的程序模型見 <a href="/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕</a>，連線跨請求保留的常駐 worker 模型見 <a href="/blog/programming/php/01-server-runtime/long-running-workers/" data-link-title="常駐 worker 模型：FrankenPHP worker 模式、Laravel Octane 與跨請求殘留的狀態" data-link-desc="說明讓 PHP 應用程式只啟動一次、在記憶體裡連續處理請求的常駐 worker 模型：它省下的是每個請求重新啟動框架的成本，失去的是 shared-nothing 的隔離；以 FrankenPHP 的 worker 模式與 Laravel Octane 實測靜態屬性跨請求累積、記憶體隨請求數線性增長與 max-requests 重啟，以及寫給這個模型的程式要避開的寫法">常駐 worker 模型：FrankenPHP worker 模式、Laravel Octane 與跨請求殘留的狀態</a>。</li>
</ul>
]]></content:encoded></item><item><title>ORM（Object-Relational Mapping）</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/orm/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/orm/</guid><description>&lt;p>ORM 把資料表對映成程式語言的類別，把一列對映成一個物件。寫的時候操作的是物件與方法，而它在送出之前把那串方法翻成一段 SQL 交給資料庫執行——&lt;strong>它是查詢的產生器，不是運算的執行者&lt;/strong>。Python 的 SQLAlchemy 與 Django ORM、Ruby 的 ActiveRecord、Java 的 Hibernate 都屬於這一類，而它們送出的每一段查詢都經過同一個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a>。&lt;/p>
&lt;h2 id="它買到什麼">它買到什麼&lt;/h2>
&lt;p>&lt;strong>型別與工具&lt;/strong>：欄位變成類別的屬性，屬性名稱由編輯器補全、由編譯器或型別檢查確認，而字串拼出來的 SQL 兩者都沒有；model 與資料表的 schema 一不一致，仍要到執行時才知道。&lt;/p>
&lt;p>&lt;strong>組合性&lt;/strong>：篩選條件可以分段加上去再一次送出，不必自己拼字串。&lt;/p>
&lt;p>&lt;strong>跨引擎的方言差異&lt;/strong>：同一段程式碼在不同資料庫上產生的 SQL 不同，由 ORM 吸收。&lt;/p>
&lt;h2 id="它讓什麼變得看不見">它讓什麼變得看不見&lt;/h2>
&lt;p>程式碼與實際送出的 SQL 之間隔了一層，所以&lt;strong>這一段會打幾次資料庫&lt;/strong>在程式碼上讀不出來。兩種常見的形態：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>延遲載入&lt;/strong>：存取一個關聯屬性才去查一次，寫在迴圈裡就變成一次請求打上百次查詢，也就是 N+1；沒有延遲載入的 ORM（例如 Go 的 GORM），N+1 來自程式自己在迴圈裡發的查詢&lt;/li>
&lt;li>&lt;strong>交易邊界不明&lt;/strong>：一段程式碼是不是在同一個交易裡，由框架的設定與作用域決定而不由這幾行決定（見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary&lt;/a>）&lt;/li>
&lt;/ul>
&lt;p>處置的方向一致：讓實際送出的 SQL 現形。多數 ORM 有印出查詢的開關，開發時開著。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>ORM 站在應用程式與資料庫之間，運算仍然發生在資料庫那一端——它交出去的是描述，回來的是結果。這一點決定了它的容量上限跟著資料庫走，與把整批資料搬進記憶體再算的工具（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/dataframe/" data-link-title="DataFrame（資料框）" data-link-desc="SQL 教材拿 pandas 或資料框當對照時，查 DataFrame 是什麼、它與資料表差在哪">DataFrame&lt;/a> 那一類）分屬兩側。&lt;/p>
&lt;p>連線這一層同樣被它蓋住：每一段查詢佔用的仍然是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a> 裡的一條連線，而是哪一條、佔多久由框架的作用域與連線池的設定決定。&lt;/p>
&lt;p>它也不是查詢語言的替代品。複雜的分析查詢用 ORM 表達得很吃力，多數專案在那些地方直接寫 SQL。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>ORM 在應用程式存取資料庫的工具裡排在哪一層、和驅動、結果對映、從 SQL 產生程式碼、Query Builder 各接手哪幾件事，以及 Go、PHP（Laravel）、Python 的對應工具，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&amp;#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層&lt;/a>；Go 的 GORM 實際送出的 SQL（預設交易、零值更新、&lt;code>Preload&lt;/code>）在 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&amp;#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL&lt;/a>。它與 DataFrame 各自產出什麼、以及外觀相似的方法串接底下差了什麼，在 &lt;a href="https://tarrragon.github.io/blog/programming/python/basic/08-data-analysis/orm-and-dataframe/" data-link-title="8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機" data-link-desc="ORM 產生查詢、DataFrame 執行運算，以及外觀相似的方法串接底下差了什麼">8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機&lt;/a>。ORM 送出的 SQL 要開 SQL 記錄才看得到，應用程式端與資料庫端的記錄各記到什麼見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/query-log/" data-link-title="Query Log（SQL 語句記錄）" data-link-desc="要確認 ORM 或資料存取函式庫實際送出了哪些 SQL、交易的 begin 與 commit 有沒有送出，或在正式環境追查慢查詢與某一筆資料被誰改過時，查應用程式端與資料庫端的語句記錄各記了什麼">Query Log&lt;/a>；ORM 替每個查詢自動加上「未刪除」條件的做法見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/soft-delete/" data-link-title="Soft Delete（軟刪除）" data-link-desc="資料表有 deleted_at 欄位、ORM 的刪除改成 UPDATE，或刪掉的資料再建一次卻撞唯一約束、報表數字和頁面對不上時，查軟刪除的過濾條件由誰加、哪些路徑沒有加">Soft Delete&lt;/a>。延遲載入與長交易的可診斷清單、以及每請求的查詢次數預算，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&amp;#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 應用層查詢反模式與 Query 預算&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>ORM 把資料表對映成程式語言的類別，把一列對映成一個物件。寫的時候操作的是物件與方法，而它在送出之前把那串方法翻成一段 SQL 交給資料庫執行——<strong>它是查詢的產生器，不是運算的執行者</strong>。Python 的 SQLAlchemy 與 Django ORM、Ruby 的 ActiveRecord、Java 的 Hibernate 都屬於這一類，而它們送出的每一段查詢都經過同一個 <a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a>。</p>
<h2 id="它買到什麼">它買到什麼</h2>
<p><strong>型別與工具</strong>：欄位變成類別的屬性，屬性名稱由編輯器補全、由編譯器或型別檢查確認，而字串拼出來的 SQL 兩者都沒有；model 與資料表的 schema 一不一致，仍要到執行時才知道。</p>
<p><strong>組合性</strong>：篩選條件可以分段加上去再一次送出，不必自己拼字串。</p>
<p><strong>跨引擎的方言差異</strong>：同一段程式碼在不同資料庫上產生的 SQL 不同，由 ORM 吸收。</p>
<h2 id="它讓什麼變得看不見">它讓什麼變得看不見</h2>
<p>程式碼與實際送出的 SQL 之間隔了一層，所以<strong>這一段會打幾次資料庫</strong>在程式碼上讀不出來。兩種常見的形態：</p>
<ul>
<li><strong>延遲載入</strong>：存取一個關聯屬性才去查一次，寫在迴圈裡就變成一次請求打上百次查詢，也就是 N+1；沒有延遲載入的 ORM（例如 Go 的 GORM），N+1 來自程式自己在迴圈裡發的查詢</li>
<li><strong>交易邊界不明</strong>：一段程式碼是不是在同一個交易裡，由框架的設定與作用域決定而不由這幾行決定（見 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>）</li>
</ul>
<p>處置的方向一致：讓實際送出的 SQL 現形。多數 ORM 有印出查詢的開關，開發時開著。</p>
<h2 id="概念位置">概念位置</h2>
<p>ORM 站在應用程式與資料庫之間，運算仍然發生在資料庫那一端——它交出去的是描述，回來的是結果。這一點決定了它的容量上限跟著資料庫走，與把整批資料搬進記憶體再算的工具（<a href="/blog/backend/01-database/sql/knowledge-cards/dataframe/" data-link-title="DataFrame（資料框）" data-link-desc="SQL 教材拿 pandas 或資料框當對照時，查 DataFrame 是什麼、它與資料表差在哪">DataFrame</a> 那一類）分屬兩側。</p>
<p>連線這一層同樣被它蓋住：每一段查詢佔用的仍然是 <a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a> 裡的一條連線，而是哪一條、佔多久由框架的作用域與連線池的設定決定。</p>
<p>它也不是查詢語言的替代品。複雜的分析查詢用 ORM 表達得很吃力，多數專案在那些地方直接寫 SQL。</p>
<h2 id="往下走">往下走</h2>
<p>ORM 在應用程式存取資料庫的工具裡排在哪一層、和驅動、結果對映、從 SQL 產生程式碼、Query Builder 各接手哪幾件事，以及 Go、PHP（Laravel）、Python 的對應工具，在 <a href="/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層</a>；Go 的 GORM 實際送出的 SQL（預設交易、零值更新、<code>Preload</code>）在 <a href="/blog/programming/go/basic/10-database-access/gorm/" data-link-title="10.5 GORM：model、關聯載入與 ORM 送出的 SQL" data-link-desc="GORM 的 model 與命名慣例、開著 SQL 記錄寫程式的方式、First 與 Take 的差別與重用變數時多出的條件、找不到資料的判斷、N&#43;1 與 Preload、零值更新被略過、寫入時的預設交易、唯一約束錯誤的判讀，以及 AutoMigrate 在正式環境的限制">10.5 GORM：model、關聯載入與 ORM 送出的 SQL</a>。它與 DataFrame 各自產出什麼、以及外觀相似的方法串接底下差了什麼，在 <a href="/blog/programming/python/basic/08-data-analysis/orm-and-dataframe/" data-link-title="8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機" data-link-desc="ORM 產生查詢、DataFrame 執行運算，以及外觀相似的方法串接底下差了什麼">8.3 ORM 與 DataFrame 的分辨：回傳值的形狀、連線物件與查詢送出的時機</a>。ORM 送出的 SQL 要開 SQL 記錄才看得到，應用程式端與資料庫端的記錄各記到什麼見 <a href="/blog/backend/knowledge-cards/query-log/" data-link-title="Query Log（SQL 語句記錄）" data-link-desc="要確認 ORM 或資料存取函式庫實際送出了哪些 SQL、交易的 begin 與 commit 有沒有送出，或在正式環境追查慢查詢與某一筆資料被誰改過時，查應用程式端與資料庫端的語句記錄各記了什麼">Query Log</a>；ORM 替每個查詢自動加上「未刪除」條件的做法見 <a href="/blog/backend/knowledge-cards/soft-delete/" data-link-title="Soft Delete（軟刪除）" data-link-desc="資料表有 deleted_at 欄位、ORM 的刪除改成 UPDATE，或刪掉的資料再建一次卻撞唯一約束、報表數字和頁面對不上時，查軟刪除的過濾條件由誰加、哪些路徑沒有加">Soft Delete</a>。延遲載入與長交易的可診斷清單、以及每請求的查詢次數預算，在 <a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 應用層查詢反模式與 Query 預算</a>。</p>
]]></content:encoded></item><item><title>Soft Delete（軟刪除）</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/soft-delete/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/soft-delete/</guid><description>&lt;p>軟刪除（soft delete）是「刪除」時把那一列留在表裡，在列上記一個刪除標記（多半是 &lt;code>deleted_at&lt;/code> 時間欄位），之後的查詢一律加上「沒有刪除標記」的條件。被刪的資料因此還在表裡，可以復原、可以稽核，那一列的識別碼也繼續被佔著、不會發給別人，而對使用者來說它已經不存在。這個「一律加上條件」通常由 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM&lt;/a> 代勞，所以軟刪除的行為大半落在 ORM 的慣例裡；單一資料庫裡的軟刪除標記，要傳到副本、離線裝置或下游系統時就是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tombstone/" data-link-title="Tombstone" data-link-desc="說明刪除如何用一筆標記記錄下來，讓刪除事件能跨副本與裝置傳播">Tombstone&lt;/a>；它和資料保存期限的關係見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">Retention&lt;/a>，和永久刪除的分工見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/data-lifecycle/" data-link-title="Data Lifecycle" data-link-desc="說明資料從建立、使用、保留到刪除的責任邊界">資料生命週期&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 刪除：把標記寫上去
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">UPDATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">deleted_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">deleted_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 之後的每一個查詢都要帶這個條件
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="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">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">email&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;yawen@example.com&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">deleted_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>各語言的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM&lt;/a> 用不同的方式加上那個條件：Laravel 的 model 使用 &lt;code>SoftDeletes&lt;/code> trait 之後，查詢自動帶 &lt;code>whereNull('deleted_at')&lt;/code>，&lt;code>withTrashed()&lt;/code> 連同已刪除的一起查、&lt;code>forceDelete()&lt;/code> 永久刪除、&lt;code>restore()&lt;/code> 復原；GORM 的 model 帶 &lt;code>gorm.DeletedAt&lt;/code> 型別的欄位（嵌入 &lt;code>gorm.Model&lt;/code> 就有）時同樣自動加條件，&lt;code>Unscoped()&lt;/code> 取消它（實測見 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/gorm-model-and-soft-delete/" data-link-title="10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回" data-link-desc="gorm.Model 帶進來的 ID、CreatedAt、UpdatedAt、DeletedAt 與 UpdatedAt 的更新時機、DeletedAt 讓刪除變成 UPDATE 並替每個查詢加上條件、不經過那個條件的路徑（Raw、其他服務）、軟刪除與關聯載入、外鍵、唯一約束的衝突與部分索引、無條件刪除的保護，以及 Save 寫回全部欄位、找不到主鍵時改成 upsert 而把已刪除的列寫回或恢復">10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回&lt;/a>）；Django ORM 與 SQLAlchemy 沒有內建，要自己加條件（Django 改寫 model 預設的查詢入口 &lt;code>Manager&lt;/code>，SQLAlchemy 在查詢上加篩選），或用第三方套件。Django 改寫預設 &lt;code>Manager&lt;/code> 之後，ORM 自己仍有一條路徑不經過它：從訂單存取所屬顧客（&lt;code>order.customer&lt;/code>）這種正向關聯走的是 &lt;code>_base_manager&lt;/code>，讀得到已刪除的顧客。&lt;/p>
&lt;p>條件由 ORM 加，所以不經過那個 ORM 的路徑都看得到已刪除的資料：手寫的 SQL（GORM 的 &lt;code>Raw&lt;/code>、Laravel 的 &lt;code>DB::select&lt;/code>）、報表與資料倉儲的匯出、共用同一個資料庫的其他服務、直接連進資料庫查問題的人。刪除前就存好的讀取結果也一樣：快取裡的那一筆與搜尋索引裡的文件不會因為加了標記就消失，要在刪除時一併清掉（見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-invalidation/" data-link-title="Cache Invalidation" data-link-desc="說明快取資料何時更新、刪除或重建，以及失效策略如何影響一致性">Cache Invalidation&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/search-index/" data-link-title="Search Index" data-link-desc="說明搜尋索引如何承擔全文檢索、排序與查詢體驗">Search Index&lt;/a>）。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>刪掉再建立同一筆資料撞唯一約束&lt;/strong>：顧客刪除帳號之後用同一個 email 重新註冊，&lt;code>email&lt;/code> 上的唯一約束仍然看得到那一列，回報重複（PostgreSQL 的 SQLSTATE &lt;code>23505&lt;/code>）。PostgreSQL 與 SQLite 的處理方式是只對未刪除的列建唯一索引（部分索引（partial index），&lt;code>CREATE UNIQUE INDEX ... ON customers (email) WHERE deleted_at IS NULL&lt;/code>）；MySQL 沒有部分索引，常見做法有兩種：加一個未刪除時等於 email、刪除後為 NULL 的產生欄位（generated column），在它上面建唯一索引，因為唯一索引允許多個 NULL；或讓 &lt;code>deleted_at&lt;/code> 用 0 代表未刪除（不用 NULL），再建 &lt;code>(email, deleted_at)&lt;/code> 的複合唯一索引。換成部分索引之後，&lt;code>INSERT ... ON CONFLICT (email)&lt;/code> 要寫出同樣的 &lt;code>WHERE deleted_at IS NULL&lt;/code> 才對得上那個索引，否則 PostgreSQL 回報找不到相符的約束（&lt;code>42P10&lt;/code>）。應用程式端的唯一性檢查也要排除已刪除的列（Laravel 的驗證規則寫 &lt;code>Rule::unique('customers')-&amp;gt;withoutTrashed()&lt;/code>）。&lt;/li>
&lt;li>&lt;strong>外鍵不會因為軟刪除而動作&lt;/strong>：軟刪除是 &lt;code>UPDATE&lt;/code>，外鍵的 &lt;code>ON DELETE CASCADE&lt;/code> 與「還有子表資料就不准刪」的檢查都不會被觸發。顧客被軟刪除之後，那位顧客的訂單仍然指著那一列；訂單列表用 ORM 載入顧客時，Laravel 拿到的是 null、GORM 是零值的 struct，Django 的正向關聯則讀到已刪除的顧客。之後要永久刪除那位顧客，外鍵才擋下來（&lt;code>23503&lt;/code>）。&lt;/li>
&lt;li>&lt;strong>計數對不上&lt;/strong>：頁面上的顧客數由 ORM 算（帶條件），報表直接寫 SQL 算（不帶條件），兩個數字差的就是已刪除的那些。&lt;/li>
&lt;li>&lt;strong>刪除請求沒有刪掉個資&lt;/strong>：使用者依個資法規要求刪除時，軟刪除只加了標記，資料原封不動，要另外做永久刪除或匿名化（見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/pii/" data-link-title="PII" data-link-desc="說明可識別個人的資料如何影響權限、遮罩、保留與稽核">PII&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>增量同步收不到刪除&lt;/strong>：依 &lt;code>updated_at&lt;/code> 取「上次之後改過的列」的同步，要靠軟刪除同時更新 &lt;code>updated_at&lt;/code> 才看得到刪除。Laravel 的 &lt;code>SoftDeletes&lt;/code> 會一併更新，GORM 的軟刪除只寫 &lt;code>deleted_at&lt;/code>，那種同步永遠看不到 GORM 刪掉的列；同步要另外取 &lt;code>deleted_at&lt;/code> 晚於上次同步的列，或改用資料庫的變更紀錄（見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture&lt;/a>）。&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>用軟刪除之前先決定它要解決的是哪一件事。保留稽核紀錄、誤刪後能復原、表示業務狀態這三個目的各有替代做法，不會帶來唯一約束衝突、手寫 SQL 讀到已刪除列、計數不一致與增量同步收不到刪除這些問題；讓識別碼不被重用則是軟刪除本身要解的問題：&lt;/p></description><content:encoded><![CDATA[<p>軟刪除（soft delete）是「刪除」時把那一列留在表裡，在列上記一個刪除標記（多半是 <code>deleted_at</code> 時間欄位），之後的查詢一律加上「沒有刪除標記」的條件。被刪的資料因此還在表裡，可以復原、可以稽核，那一列的識別碼也繼續被佔著、不會發給別人，而對使用者來說它已經不存在。這個「一律加上條件」通常由 <a href="/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM</a> 代勞，所以軟刪除的行為大半落在 ORM 的慣例裡；單一資料庫裡的軟刪除標記，要傳到副本、離線裝置或下游系統時就是 <a href="/blog/backend/knowledge-cards/tombstone/" data-link-title="Tombstone" data-link-desc="說明刪除如何用一筆標記記錄下來，讓刪除事件能跨副本與裝置傳播">Tombstone</a>；它和資料保存期限的關係見 <a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">Retention</a>，和永久刪除的分工見 <a href="/blog/backend/knowledge-cards/data-lifecycle/" data-link-title="Data Lifecycle" data-link-desc="說明資料從建立、使用、保留到刪除的責任邊界">資料生命週期</a>。</p>
<h2 id="概念位置">概念位置</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 刪除：把標記寫上去
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">UPDATE</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">deleted_at</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">now</span><span class="p">()</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">3</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">deleted_at</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 之後的每一個查詢都要帶這個條件
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;yawen@example.com&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">deleted_at</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span></span></span></code></pre></div><p>各語言的 <a href="/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM</a> 用不同的方式加上那個條件：Laravel 的 model 使用 <code>SoftDeletes</code> trait 之後，查詢自動帶 <code>whereNull('deleted_at')</code>，<code>withTrashed()</code> 連同已刪除的一起查、<code>forceDelete()</code> 永久刪除、<code>restore()</code> 復原；GORM 的 model 帶 <code>gorm.DeletedAt</code> 型別的欄位（嵌入 <code>gorm.Model</code> 就有）時同樣自動加條件，<code>Unscoped()</code> 取消它（實測見 <a href="/blog/programming/go/basic/10-database-access/gorm-model-and-soft-delete/" data-link-title="10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回" data-link-desc="gorm.Model 帶進來的 ID、CreatedAt、UpdatedAt、DeletedAt 與 UpdatedAt 的更新時機、DeletedAt 讓刪除變成 UPDATE 並替每個查詢加上條件、不經過那個條件的路徑（Raw、其他服務）、軟刪除與關聯載入、外鍵、唯一約束的衝突與部分索引、無條件刪除的保護，以及 Save 寫回全部欄位、找不到主鍵時改成 upsert 而把已刪除的列寫回或恢復">10.6 GORM 的 gorm.Model、軟刪除與 Save：慣例欄位加上的查詢條件與整列寫回</a>）；Django ORM 與 SQLAlchemy 沒有內建，要自己加條件（Django 改寫 model 預設的查詢入口 <code>Manager</code>，SQLAlchemy 在查詢上加篩選），或用第三方套件。Django 改寫預設 <code>Manager</code> 之後，ORM 自己仍有一條路徑不經過它：從訂單存取所屬顧客（<code>order.customer</code>）這種正向關聯走的是 <code>_base_manager</code>，讀得到已刪除的顧客。</p>
<p>條件由 ORM 加，所以不經過那個 ORM 的路徑都看得到已刪除的資料：手寫的 SQL（GORM 的 <code>Raw</code>、Laravel 的 <code>DB::select</code>）、報表與資料倉儲的匯出、共用同一個資料庫的其他服務、直接連進資料庫查問題的人。刪除前就存好的讀取結果也一樣：快取裡的那一筆與搜尋索引裡的文件不會因為加了標記就消失，要在刪除時一併清掉（見 <a href="/blog/backend/knowledge-cards/cache-invalidation/" data-link-title="Cache Invalidation" data-link-desc="說明快取資料何時更新、刪除或重建，以及失效策略如何影響一致性">Cache Invalidation</a>、<a href="/blog/backend/knowledge-cards/search-index/" data-link-title="Search Index" data-link-desc="說明搜尋索引如何承擔全文檢索、排序與查詢體驗">Search Index</a>）。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<ul>
<li><strong>刪掉再建立同一筆資料撞唯一約束</strong>：顧客刪除帳號之後用同一個 email 重新註冊，<code>email</code> 上的唯一約束仍然看得到那一列，回報重複（PostgreSQL 的 SQLSTATE <code>23505</code>）。PostgreSQL 與 SQLite 的處理方式是只對未刪除的列建唯一索引（部分索引（partial index），<code>CREATE UNIQUE INDEX ... ON customers (email) WHERE deleted_at IS NULL</code>）；MySQL 沒有部分索引，常見做法有兩種：加一個未刪除時等於 email、刪除後為 NULL 的產生欄位（generated column），在它上面建唯一索引，因為唯一索引允許多個 NULL；或讓 <code>deleted_at</code> 用 0 代表未刪除（不用 NULL），再建 <code>(email, deleted_at)</code> 的複合唯一索引。換成部分索引之後，<code>INSERT ... ON CONFLICT (email)</code> 要寫出同樣的 <code>WHERE deleted_at IS NULL</code> 才對得上那個索引，否則 PostgreSQL 回報找不到相符的約束（<code>42P10</code>）。應用程式端的唯一性檢查也要排除已刪除的列（Laravel 的驗證規則寫 <code>Rule::unique('customers')-&gt;withoutTrashed()</code>）。</li>
<li><strong>外鍵不會因為軟刪除而動作</strong>：軟刪除是 <code>UPDATE</code>，外鍵的 <code>ON DELETE CASCADE</code> 與「還有子表資料就不准刪」的檢查都不會被觸發。顧客被軟刪除之後，那位顧客的訂單仍然指著那一列；訂單列表用 ORM 載入顧客時，Laravel 拿到的是 null、GORM 是零值的 struct，Django 的正向關聯則讀到已刪除的顧客。之後要永久刪除那位顧客，外鍵才擋下來（<code>23503</code>）。</li>
<li><strong>計數對不上</strong>：頁面上的顧客數由 ORM 算（帶條件），報表直接寫 SQL 算（不帶條件），兩個數字差的就是已刪除的那些。</li>
<li><strong>刪除請求沒有刪掉個資</strong>：使用者依個資法規要求刪除時，軟刪除只加了標記，資料原封不動，要另外做永久刪除或匿名化（見 <a href="/blog/backend/knowledge-cards/pii/" data-link-title="PII" data-link-desc="說明可識別個人的資料如何影響權限、遮罩、保留與稽核">PII</a>）。</li>
<li><strong>增量同步收不到刪除</strong>：依 <code>updated_at</code> 取「上次之後改過的列」的同步，要靠軟刪除同時更新 <code>updated_at</code> 才看得到刪除。Laravel 的 <code>SoftDeletes</code> 會一併更新，GORM 的軟刪除只寫 <code>deleted_at</code>，那種同步永遠看不到 GORM 刪掉的列；同步要另外取 <code>deleted_at</code> 晚於上次同步的列，或改用資料庫的變更紀錄（見 <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture</a>）。</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>用軟刪除之前先決定它要解決的是哪一件事。保留稽核紀錄、誤刪後能復原、表示業務狀態這三個目的各有替代做法，不會帶來唯一約束衝突、手寫 SQL 讀到已刪除列、計數不一致與增量同步收不到刪除這些問題；讓識別碼不被重用則是軟刪除本身要解的問題：</p>
<ul>
<li><strong>保留稽核紀錄</strong>：把刪除事件寫進稽核表、原資料照常刪除（見 <a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">Audit Log</a>），或整個領域改用事件記錄狀態（見 <a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判斷標準與代價">Event Sourcing</a>）。</li>
<li><strong>誤刪後能復原</strong>：刪除時把那一列搬到封存表（例如 <code>customers_archive</code>），復原時搬回來；主表上沒有已刪除的列，唯一約束、手寫 SQL 與計數都不受影響，代價是子表的資料要一起處理。</li>
<li><strong>帳號停用、訂單取消這類業務狀態</strong>：它們本來就是業務上的一個狀態，用狀態欄位表示，查詢條件寫在業務邏輯裡，不必套用刪除的慣例。</li>
<li><strong>讓識別碼不被重用、讓關聯資料不失去參照</strong>：這是軟刪除最直接的理由，例如短網址被刪除之後，短碼要一直被佔著，不能發給別人（見 <a href="/blog/backend/00-service-selection/url-shortener-implementation/" data-link-title="0.24 短網址服務的實作：短碼生成、對照表儲存、轉址快取與點擊紀錄" data-link-desc="以需求已定案為前提的實作取捨：建立與轉址兩條路徑的流量形狀、各種短碼生成方式的可猜測性與協調成本、轉址狀態碼與各層快取怎麼影響停用或修改連結多久生效、點擊事件的非同步紀錄、濫用防護、服務結束時的靜態轉址與網域去向，以及換掉任一項需求決定時要重選的實作位置">0.24 短網址服務的實作：短碼生成、對照表儲存、轉址快取與點擊紀錄</a>〈對照表：儲存選型、欄位與分片〉）。</li>
</ul>
<p>決定用軟刪除時，同時決定四件事：</p>
<ul>
<li><strong>刪除之後同一個值能不能再用</strong>：能的話（顧客刪除帳號後可以用同一個 email 重新註冊），唯一約束改成只涵蓋未刪除的列；不能的話（短碼），保留涵蓋全表的唯一約束。</li>
<li><strong>不經過 ORM 的讀取怎麼排除已刪除的列</strong>：手寫 SQL 自己加條件，或在資料庫端提供只含未刪除列的 view、對報表帳號設 <a href="/blog/backend/knowledge-cards/row-level-security/" data-link-title="Row-Level Security" data-link-desc="說明資料庫如何用 policy 限制同一張表中哪些 row 對某個角色可見或可寫">Row-Level Security</a> 政策；其他服務與直接連進資料庫的人，只有資料庫端的做法擋得到。</li>
<li><strong>子表的資料在父列被軟刪除時怎麼處理</strong>：一起軟刪除、照常保留並在畫面上標示，或禁止刪除還有子表資料的父列。</li>
<li><strong>已刪除的資料保留多久之後永久刪除</strong>：到期時子表若因為法規（例如帳務紀錄）要保留，外鍵會擋下永久刪除，這時改成把個資欄位匿名化（清空姓名、email），只留下那一列與它的主鍵。</li>
</ul>
]]></content:encoded></item><item><title>Query Log（SQL 語句記錄）</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/query-log/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/query-log/</guid><description>&lt;p>SQL 語句記錄（query log）是把送往資料庫的每一段 SQL 記下來的紀錄。它有兩個來源，記到的內容不同：&lt;strong>應用程式端&lt;/strong>由 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM&lt;/a> 或資料存取函式庫印出它送出的語句；&lt;strong>資料庫端&lt;/strong>由資料庫記錄它收到的語句。用 ORM 的程式碼上看不到 SQL，這兩種記錄是確認實際行為的地方。本站文章把應用程式端那一種稱為 ORM 的 SQL 記錄（GORM 的 SQL 記錄），資料庫端那一種稱為語句記錄（PostgreSQL 的 &lt;code>log_statement&lt;/code>）；它們和一般的應用程式記錄的關係見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">Log&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>來源&lt;/th>
 &lt;th>開啟方式&lt;/th>
 &lt;th>記到的內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>應用程式端&lt;/td>
 &lt;td>GORM 的記錄器設成 &lt;code>logger.Info&lt;/code>；Laravel 的 &lt;code>DB::listen&lt;/code> 或 &lt;code>DB::enableQueryLog()&lt;/code>；Django 的 &lt;code>django.db.backends&lt;/code> 記錄器（只在 &lt;code>DEBUG = True&lt;/code> 時記錄）；SQLAlchemy 的 &lt;code>echo=True&lt;/code>&lt;/td>
 &lt;td>這個程式送出的語句，多半附耗時、影響列數與呼叫位置；參數常被直接代入顯示，和實際送出的「SQL 加上分開的參數」形式不同&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料庫端&lt;/td>
 &lt;td>PostgreSQL 的 &lt;code>log_statement&lt;/code>（&lt;code>none&lt;/code>、&lt;code>ddl&lt;/code>、&lt;code>mod&lt;/code>、&lt;code>all&lt;/code>）與 &lt;code>log_min_duration_statement&lt;/code>（超過指定毫秒數才記）；MySQL 的 general query log 與 slow query log&lt;/td>
 &lt;td>所有用戶端送來的語句，包括驅動自動送出的交易指令與預先解析查詢的名字；加上 &lt;code>log_line_prefix&lt;/code> 的設定可以記錄資料庫名稱與連線&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩種記錄之外，分散式追蹤（tracing）也記得到資料庫呼叫：OpenTelemetry 這類工具在每個資料庫呼叫上記一段 span（Go 的 pgx 透過 &lt;code>QueryTracer&lt;/code> 介面接上），同時帶著所屬的請求與耗時，適合回答「這個請求為什麼慢」。&lt;/p>
&lt;p>兩種記錄各有記不到的內容。GORM 預設把每個寫入包進一個交易（&lt;code>SkipDefaultTransaction&lt;/code> 可以關掉），那個交易的 &lt;code>begin&lt;/code> 與 &lt;code>commit&lt;/code> 不出現在 GORM 自己的記錄裡，只看得到資料庫端；資料庫端看不到這個語句是從程式的哪一行送出的。Go 的 pgx 預設在連線上快取 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/prepared-statement/" data-link-title="Prepared Statement（預先解析的查詢）" data-link-desc="在資料庫的語句記錄裡看到 stmtcache_、pdo_stmt_ 這類具名的查詢被重複執行，或應用程式接上 PgBouncer 交易模式後出現 prepared statement already exists、does not exist 的錯誤時，查它在哪一條連線上建立、存在多久">Prepared Statement&lt;/a>，在 PostgreSQL 的記錄裡顯示成 &lt;code>execute stmtcache_…&lt;/code>，參數另外記在 &lt;code>DETAIL&lt;/code> 那一行。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>ORM 送出的查詢次數&lt;/strong>：同一個形狀的查詢在應用程式端的記錄裡連續出現 N 次，是 N+1（見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&amp;#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 應用層查詢反模式與 Query 預算&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>應該出現卻沒出現的語句&lt;/strong>：GORM 用 struct 更新時跳過零值欄位，記錄裡的 &lt;code>UPDATE&lt;/code> 少了那個欄位，要改的欄位全是零值時整句 &lt;code>UPDATE&lt;/code> 都不會出現（model 帶 &lt;code>UpdatedAt&lt;/code> 時只剩更新 &lt;code>updated_at&lt;/code> 的那一句）；交易邊界不如預期時，資料庫端的 &lt;code>begin&lt;/code> 與 &lt;code>commit&lt;/code> 位置對不上（各 ORM 的差異見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&amp;#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層&lt;/a>〈ORM 的交易邊界：GORM、Laravel、Django 與 SQLAlchemy〉）。&lt;/li>
&lt;li>&lt;strong>正式環境的慢查詢&lt;/strong>：用 &lt;code>log_min_duration_statement&lt;/code> 只記慢的語句；要看哪些查詢加總起來最耗時，PostgreSQL 的 &lt;code>pg_stat_statements&lt;/code> 依查詢形狀彙總次數與總耗時，比逐筆記錄省空間。把慢查詢記錄定期彙整、接進審查流程的做法見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/production-slow-log-loop/" data-link-title="1.14 Production Slow Log Closed Loop" data-link-desc="把 production slow log 從『偶爾看一下』變成『定期審視 &amp;#43; PR review 整合 &amp;#43; regression 偵測』的閉環、補 1.13 反模式清單後的操作層">1.14 Production Slow Log Closed Loop&lt;/a>。&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>開發與測試環境把兩種記錄都打開，審查 ORM 程式碼時對照的是它們，不只是方法呼叫。正式環境的 &lt;code>log_statement = 'all'&lt;/code> 與應用程式端的逐筆記錄量都很大，而且參數裡有 email、地址這類個人資料，所以正式環境通常只記慢查詢與 DDL，記錄的保存與遮罩比照其他含個資的記錄處理（見 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">7.4 資料保護與遮罩治理&lt;/a>）。追查「這一筆資料被誰改過」用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">Audit Log&lt;/a>（PostgreSQL 端可以用 pgaudit 擴充記錄指定的語句類別）；語句記錄保存期短、正式環境不一定開著，靠不住。&lt;/p></description><content:encoded><![CDATA[<p>SQL 語句記錄（query log）是把送往資料庫的每一段 SQL 記下來的紀錄。它有兩個來源，記到的內容不同：<strong>應用程式端</strong>由 <a href="/blog/backend/knowledge-cards/orm/" data-link-title="ORM（Object-Relational Mapping）" data-link-desc="看到程式用類別與方法描述查詢時，查它交出去的是什麼、以及哪些行為在程式碼上讀不出來">ORM</a> 或資料存取函式庫印出它送出的語句；<strong>資料庫端</strong>由資料庫記錄它收到的語句。用 ORM 的程式碼上看不到 SQL，這兩種記錄是確認實際行為的地方。本站文章把應用程式端那一種稱為 ORM 的 SQL 記錄（GORM 的 SQL 記錄），資料庫端那一種稱為語句記錄（PostgreSQL 的 <code>log_statement</code>）；它們和一般的應用程式記錄的關係見 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">Log</a>。</p>
<h2 id="概念位置">概念位置</h2>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>開啟方式</th>
          <th>記到的內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>應用程式端</td>
          <td>GORM 的記錄器設成 <code>logger.Info</code>；Laravel 的 <code>DB::listen</code> 或 <code>DB::enableQueryLog()</code>；Django 的 <code>django.db.backends</code> 記錄器（只在 <code>DEBUG = True</code> 時記錄）；SQLAlchemy 的 <code>echo=True</code></td>
          <td>這個程式送出的語句，多半附耗時、影響列數與呼叫位置；參數常被直接代入顯示，和實際送出的「SQL 加上分開的參數」形式不同</td>
      </tr>
      <tr>
          <td>資料庫端</td>
          <td>PostgreSQL 的 <code>log_statement</code>（<code>none</code>、<code>ddl</code>、<code>mod</code>、<code>all</code>）與 <code>log_min_duration_statement</code>（超過指定毫秒數才記）；MySQL 的 general query log 與 slow query log</td>
          <td>所有用戶端送來的語句，包括驅動自動送出的交易指令與預先解析查詢的名字；加上 <code>log_line_prefix</code> 的設定可以記錄資料庫名稱與連線</td>
      </tr>
  </tbody>
</table>
<p>兩種記錄之外，分散式追蹤（tracing）也記得到資料庫呼叫：OpenTelemetry 這類工具在每個資料庫呼叫上記一段 span（Go 的 pgx 透過 <code>QueryTracer</code> 介面接上），同時帶著所屬的請求與耗時，適合回答「這個請求為什麼慢」。</p>
<p>兩種記錄各有記不到的內容。GORM 預設把每個寫入包進一個交易（<code>SkipDefaultTransaction</code> 可以關掉），那個交易的 <code>begin</code> 與 <code>commit</code> 不出現在 GORM 自己的記錄裡，只看得到資料庫端；資料庫端看不到這個語句是從程式的哪一行送出的。Go 的 pgx 預設在連線上快取 <a href="/blog/backend/knowledge-cards/prepared-statement/" data-link-title="Prepared Statement（預先解析的查詢）" data-link-desc="在資料庫的語句記錄裡看到 stmtcache_、pdo_stmt_ 這類具名的查詢被重複執行，或應用程式接上 PgBouncer 交易模式後出現 prepared statement already exists、does not exist 的錯誤時，查它在哪一條連線上建立、存在多久">Prepared Statement</a>，在 PostgreSQL 的記錄裡顯示成 <code>execute stmtcache_…</code>，參數另外記在 <code>DETAIL</code> 那一行。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<ul>
<li><strong>ORM 送出的查詢次數</strong>：同一個形狀的查詢在應用程式端的記錄裡連續出現 N 次，是 N+1（見 <a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 應用層查詢反模式與 Query 預算</a>）。</li>
<li><strong>應該出現卻沒出現的語句</strong>：GORM 用 struct 更新時跳過零值欄位，記錄裡的 <code>UPDATE</code> 少了那個欄位，要改的欄位全是零值時整句 <code>UPDATE</code> 都不會出現（model 帶 <code>UpdatedAt</code> 時只剩更新 <code>updated_at</code> 的那一句）；交易邊界不如預期時，資料庫端的 <code>begin</code> 與 <code>commit</code> 位置對不上（各 ORM 的差異見 <a href="/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層</a>〈ORM 的交易邊界：GORM、Laravel、Django 與 SQLAlchemy〉）。</li>
<li><strong>正式環境的慢查詢</strong>：用 <code>log_min_duration_statement</code> 只記慢的語句；要看哪些查詢加總起來最耗時，PostgreSQL 的 <code>pg_stat_statements</code> 依查詢形狀彙總次數與總耗時，比逐筆記錄省空間。把慢查詢記錄定期彙整、接進審查流程的做法見 <a href="/blog/backend/01-database/production-slow-log-loop/" data-link-title="1.14 Production Slow Log Closed Loop" data-link-desc="把 production slow log 從『偶爾看一下』變成『定期審視 &#43; PR review 整合 &#43; regression 偵測』的閉環、補 1.13 反模式清單後的操作層">1.14 Production Slow Log Closed Loop</a>。</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>開發與測試環境把兩種記錄都打開，審查 ORM 程式碼時對照的是它們，不只是方法呼叫。正式環境的 <code>log_statement = 'all'</code> 與應用程式端的逐筆記錄量都很大，而且參數裡有 email、地址這類個人資料，所以正式環境通常只記慢查詢與 DDL，記錄的保存與遮罩比照其他含個資的記錄處理（見 <a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">7.4 資料保護與遮罩治理</a>）。追查「這一筆資料被誰改過」用 <a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">Audit Log</a>（PostgreSQL 端可以用 pgaudit 擴充記錄指定的語句類別）；語句記錄保存期短、正式環境不一定開著，靠不住。</p>
]]></content:encoded></item></channel></rss>