<?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>模組十：資料庫存取 on Tarragon</title><link>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/</link><description>Recent content in 模組十：資料庫存取 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/programming/go/basic/10-database-access/index.xml" rel="self" type="application/rss+xml"/><item><title>10.1 database/sql：連線池、查詢結果的讀取與交易</title><link>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/database-sql/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/database-sql/</guid><description>&lt;p>這篇整理 Go 標準庫 &lt;code>database/sql&lt;/code> 存取關聯式資料庫的方式，範例在 Go 1.25 與 PostgreSQL 17 上執行，驅動是 pgx v5.11.0 提供的 &lt;code>database/sql&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> 的分類裡，&lt;code>database/sql&lt;/code> 屬於驅動與標準介面：程式自己寫 SQL、逐欄讀取結果，連線與交易由它管理。sqlx 與 GORM 直接建立在這一層上；sqlc 依設定產生 &lt;code>database/sql&lt;/code> 或 pgx 原生介面的程式碼；pgx 的原生介面不經過它。四者處理的仍是同一組問題：連線從哪裡來、查詢結果怎麼讀、NULL 放到哪裡、交易怎麼收尾。&lt;/p>
&lt;h2 id="範例資料表">範例資料表&lt;/h2>
&lt;p>本模組各篇都用同一組表：一家書店的顧客與訂單。&lt;code>orders.note&lt;/code> 允許 NULL，用來示範 NULL 的讀取；&lt;code>customers.email&lt;/code> 有唯一約束，用來示範約束違反的錯誤。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">bigint&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GENERATED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IDENTITY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">timestamptz&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">CONSTRAINT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers_email_key&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNIQUE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">bigint&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GENERATED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IDENTITY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">customer_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">bigint&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&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">id&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">integer&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&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">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">note&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ordered_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">timestamptz&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;佳穎&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;jiaying@example.com&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;宗翰&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;zonghan@example.com&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;雅文&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;yawen@example.com&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customer_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">note&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">101&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">102&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;禮物包裝&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">103&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">250&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 明確指定 id 寫入 identity 欄位不會推進它的序列；不補這兩行，下一個由資料庫配號的 INSERT 會撞主鍵
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">setval&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pg_get_serial_sequence&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;customers&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;id&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">max&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="p">));&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">setval&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pg_get_serial_sequence&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;orders&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;id&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">max&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&lt;/span>&lt;span class="p">));&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>雅文沒有訂單，佳穎的兩張訂單只有一張有備註。範例程式的連線字串是 &lt;code>postgres://postgres:pw@localhost:55432/bookstore?sslmode=disable&lt;/code>，帳號、密碼、port 與資料庫名稱換成手上那一台的值。各篇的輸出都以剛執行完上面這段 SQL 的資料為起點；前一篇的示範改過資料時（例如本篇〈交易〉那一節會改金額），先 &lt;code>DROP TABLE orders, customers&lt;/code> 再重跑這段 SQL。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理 Go 標準庫 <code>database/sql</code> 存取關聯式資料庫的方式，範例在 Go 1.25 與 PostgreSQL 17 上執行，驅動是 pgx v5.11.0 提供的 <code>database/sql</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> 的分類裡，<code>database/sql</code> 屬於驅動與標準介面：程式自己寫 SQL、逐欄讀取結果，連線與交易由它管理。sqlx 與 GORM 直接建立在這一層上；sqlc 依設定產生 <code>database/sql</code> 或 pgx 原生介面的程式碼；pgx 的原生介面不經過它。四者處理的仍是同一組問題：連線從哪裡來、查詢結果怎麼讀、NULL 放到哪裡、交易怎麼收尾。</p>
<h2 id="範例資料表">範例資料表</h2>
<p>本模組各篇都用同一組表：一家書店的顧客與訂單。<code>orders.note</code> 允許 NULL，用來示範 NULL 的讀取；<code>customers.email</code> 有唯一約束，用來示範約束違反的錯誤。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="n">id</span><span class="w">         </span><span class="nb">bigint</span><span class="w"> </span><span class="k">GENERATED</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">IDENTITY</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="n">name</span><span class="w">       </span><span class="nb">text</span><span class="w">        </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="n">email</span><span class="w">      </span><span class="nb">text</span><span class="w">        </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="n">created_at</span><span class="w"> </span><span class="n">timestamptz</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="n">now</span><span class="p">(),</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="n">customers_email_key</span><span class="w"> </span><span class="k">UNIQUE</span><span class="w"> </span><span class="p">(</span><span class="n">email</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="n">id</span><span class="w">          </span><span class="nb">bigint</span><span class="w"> </span><span class="k">GENERATED</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">IDENTITY</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="n">customer_id</span><span class="w"> </span><span class="nb">bigint</span><span class="w">      </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="n">amount</span><span class="w">      </span><span class="nb">integer</span><span class="w">     </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">  </span><span class="n">note</span><span class="w">        </span><span class="nb">text</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">  </span><span class="n">ordered_at</span><span class="w">  </span><span class="n">timestamptz</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="n">now</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">email</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">  </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;佳穎&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;jiaying@example.com&#39;</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;宗翰&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;zonghan@example.com&#39;</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;雅文&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;yawen@example.com&#39;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">customer_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="n">note</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">  </span><span class="p">(</span><span class="mi">101</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">300</span><span class="p">,</span><span class="w"> </span><span class="k">NULL</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">102</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">500</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;禮物包裝&#39;</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">250</span><span class="p">,</span><span class="w"> </span><span class="k">NULL</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w"></span><span class="c1">-- 明確指定 id 寫入 identity 欄位不會推進它的序列；不補這兩行，下一個由資料庫配號的 INSERT 會撞主鍵
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">setval</span><span class="p">(</span><span class="n">pg_get_serial_sequence</span><span class="p">(</span><span class="s1">&#39;customers&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;id&#39;</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="k">max</span><span class="p">(</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">customers</span><span class="p">));</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">setval</span><span class="p">(</span><span class="n">pg_get_serial_sequence</span><span class="p">(</span><span class="s1">&#39;orders&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;id&#39;</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="k">max</span><span class="p">(</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="p">));</span></span></span></code></pre></div><p>雅文沒有訂單，佳穎的兩張訂單只有一張有備註。範例程式的連線字串是 <code>postgres://postgres:pw@localhost:55432/bookstore?sslmode=disable</code>，帳號、密碼、port 與資料庫名稱換成手上那一台的值。各篇的輸出都以剛執行完上面這段 SQL 的資料為起點；前一篇的示範改過資料時（例如本篇〈交易〉那一節會改金額），先 <code>DROP TABLE orders, customers</code> 再重跑這段 SQL。</p>
<p>範例程式的 Go 模組要先取得驅動（後面各篇另外取得各自的套件）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go mod init example.com/bookstore   <span class="c1"># 模組名稱自訂</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">go get github.com/jackc/pgx/v5@v5.11.0</span></span></code></pre></div><h2 id="驅動與-sqlopen">驅動與 sql.Open</h2>
<p><code>database/sql</code> 只定義介面，實際和資料庫通訊的是資料庫驅動程式（database driver，以下簡稱驅動）。驅動在自己的 <code>init</code> 函式裡向 <code>database/sql</code> 註冊一個名字，程式用空白匯入（blank import）觸發註冊，之後用那個名字開啟：</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="s">&#34;database/sql&#34;</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="nx">_</span> <span class="s">&#34;github.com/jackc/pgx/v5/stdlib&#34;</span> <span class="c1">// 註冊名為 &#34;pgx&#34; 的驅動</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="nx">db</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">sql</span><span class="p">.</span><span class="nf">Open</span><span class="p">(</span><span class="s">&#34;pgx&#34;</span><span class="p">,</span> <span class="nx">dsn</span><span class="p">)</span></span></span></code></pre></div><p><code>sql.Open</code> 只建立 <code>*sql.DB</code> 這個物件並檢查參數格式，<strong>不連線</strong>。密碼錯了，<code>sql.Open</code> 照樣回傳 <code>nil</code> 錯誤，要到第一次需要連線時才失敗。所以服務啟動時接著呼叫 <code>PingContext</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">open with wrong password, err = &lt;nil&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">ping: failed to connect to `user=postgres database=bookstore`: ... failed SASL auth: FATAL: password authentication failed for user &#34;postgres&#34; (SQLSTATE 28P01)</span></span></code></pre></div><h2 id="sqldb-與連線池">*sql.DB 與連線池</h2>
<p><code>*sql.DB</code> 代表的是一個連線池（connection pool），不是一條連線。每次查詢時它從池裡取一條閒置的連線，沒有閒置的就新開一條，查詢結束後放回池裡。它可以被多個 goroutine 同時使用，所以一個程序（process）建立一個、在整個服務裡共用；每個請求各開一個 <code>*sql.DB</code> 會讓每個請求都重新建立連線，也讓池的上限失去作用。</p>
<p>池的大小由四個設定決定：</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>管什麼</th>
          <th>預設</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SetMaxOpenConns(n)</code></td>
          <td>同時開著的連線上限（使用中加閒置）</td>
          <td>0，代表不設上限</td>
      </tr>
      <tr>
          <td><code>SetMaxIdleConns(n)</code></td>
          <td>閒置時保留幾條，不關掉</td>
          <td>2</td>
      </tr>
      <tr>
          <td><code>SetConnMaxLifetime(d)</code></td>
          <td>一條連線最多用多久就關掉重開</td>
          <td>不限</td>
      </tr>
      <tr>
          <td><code>SetConnMaxIdleTime(d)</code></td>
          <td>一條連線閒置多久就關掉</td>
          <td>不限</td>
      </tr>
  </tbody>
</table>
<p><code>MaxOpenConns</code> 預設不設上限，意思是流量尖峰時 Go 會一直開新連線，直到資料庫的 <code>max_connections</code> 拒絕為止；多個服務實例共用同一個資料庫時，全部實例的上限加起來要小於資料庫的 <code>max_connections</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>〈各語言執行模型下的連線模型〉）。<code>ConnMaxLifetime</code> 設成比中間的負載平衡器或防火牆的閒置逾時短，連線就不會在池裡被對方悄悄斷掉。</p>
<p><code>db.Stats()</code> 回傳池的即時狀態，〈查詢結果讀取物件（*sql.Rows）的關閉與連線洩漏〉那一節會用到其中三個欄位：<code>InUse</code>（使用中的連線數）、<code>Idle</code>（閒置的連線數）、<code>WaitCount</code>（因為池滿而排隊等連線的累計次數）。</p>
<h2 id="查詢querycontextqueryrowcontext-與-execcontext">查詢：QueryContext、QueryRowContext 與 ExecContext</h2>
<p>三個方法依「預期回幾列」分工：</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>用途</th>
          <th>回傳</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>QueryContext</code></td>
          <td>可能回多列的查詢</td>
          <td><code>*sql.Rows</code>，要逐列讀、讀完要關閉</td>
      </tr>
      <tr>
          <td><code>QueryRowContext</code></td>
          <td>預期最多回一列的查詢</td>
          <td><code>*sql.Row</code>，呼叫 <code>Scan</code> 時才回報錯誤</td>
      </tr>
      <tr>
          <td><code>ExecContext</code></td>
          <td>不回資料列的語句（<code>INSERT</code>、<code>UPDATE</code>、<code>DELETE</code>）</td>
          <td><code>sql.Result</code>，可讀影響的列數</td>
      </tr>
  </tbody>
</table>
<p>查詢的參數用佔位符（placeholder）傳，不要把值拼進 SQL 字串。佔位符的寫法由驅動決定：PostgreSQL 的驅動用 <code>$1</code>、<code>$2</code>，MySQL 的驅動用 <code>?</code>。值拼進字串會讓使用者輸入的內容被當成 SQL 的一部分執行，也就是 <a href="/blog/backend/knowledge-cards/sql-injection/" data-link-title="SQL Injection（SQL 注入）" data-link-desc="看到程式把變數拼進 SQL 字串、或在審查資料存取的程式碼時，查它的值是用佔位符傳給資料庫，還是成了 SQL 文字的一部分">SQL 注入（SQL injection）</a>；佔位符把值交給驅動，pgx 預設把值和 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="kd">var</span> <span class="nx">name</span> <span class="kt">string</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">QueryRowContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;SELECT name FROM customers WHERE id = $1&#34;</span><span class="p">,</span> <span class="mi">999</span><span class="p">).</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">sql</span><span class="p">.</span><span class="nx">ErrNoRows</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="c1">// 沒有這個顧客：這是業務上的「找不到」，不是系統錯誤</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>QueryRowContext</code> 查不到資料時，<code>Scan</code> 回傳 <code>sql.ErrNoRows</code>。repository 通常在這裡把它轉成自己的「找不到」錯誤，讓上層不必認得 <code>database/sql</code> 的型別（轉換的位置見 <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 設計">Repository Adapter 實作</a>）。</p>
<p>每個方法都有不帶 <code>Context</code> 的版本（<code>Query</code>、<code>QueryRow</code>、<code>Exec</code>），服務程式一律用帶 <code>Context</code> 的版本：請求被取消或逾時時，驅動會通知資料庫中止那一段查詢；pgx 接著關閉那條連線，<code>database/sql</code> 把它從池裡丟掉，空出的名額由下一個請求開新連線補上。不帶 <code>Context</code> 的版本等同傳入 <code>context.Background()</code>，查詢會一直跑到資料庫回應為止。</p>
<h2 id="scan-與-null">Scan 與 NULL</h2>
<p><code>Scan</code> 把一列的欄位依序寫進傳入的指標。欄位值是 NULL 而目標是 <code>string</code> 時，<code>Scan</code> 回傳錯誤，因為 <code>string</code> 沒有一個值可以代表「沒有值」。以 <code>SELECT note FROM orders WHERE id = $1</code> 讀訂單 101 的備註進 <code>var note string</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">scan NULL into string: sql: Scan error on column index 0, name &#34;note&#34;: converting NULL to string is unsupported</span></span></code></pre></div><p>可以是 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="kd">var</span> <span class="nx">note</span> <span class="nx">sql</span><span class="p">.</span><span class="nx">NullString</span> <span class="c1">// NULL 時 note.Valid == false，非 NULL 時值在 note.String</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">notePtr</span> <span class="o">*</span><span class="kt">string</span>     <span class="c1">// NULL 時 notePtr == nil</span></span></span></code></pre></div><p>兩種都能分辨「NULL」與「空字串」。<code>sql.NullString</code> 這類型別（還有 <code>NullInt64</code>、<code>NullTime</code>，以及泛型的 <code>sql.Null[T]</code>）是 <code>database/sql</code> 自己的型別，放進 domain 的 struct 會讓上層也依賴資料庫套件；指標是語言本身的寫法，轉成 JSON 時 <code>nil</code> 直接變成 <code>null</code>。多數專案在 repository 的 row struct 用其中一種，轉成 domain 型別時再決定 NULL 代表什麼。</p>
<h2 id="查詢結果讀取物件sqlrows的關閉與連線洩漏">查詢結果讀取物件（*sql.Rows）的關閉與連線洩漏</h2>
<p><code>QueryContext</code> 回傳的 <code>*sql.Rows</code> 是查詢結果的讀取物件（其他中文資料常寫作游標，英文是 cursor，譯名的分辨見 <a href="/blog/backend/knowledge-cards/cursor/" data-link-title="Cursor（游標、查詢結果的讀取物件、接續標記）" data-link-desc="在程式碼、API 或文件裡看到 cursor 這個字，要分辨它指的是畫面上的游標、資料庫逐筆讀取查詢結果的物件，還是分頁回應裡記錄從哪裡接續的標記時">Cursor</a>）。它在讀取期間一直佔著池裡的一條連線，直到被關閉才放回去。關閉有兩種途徑：<code>Next</code> 讀到最後一列之後回傳 <code>false</code> 時自動關閉，或者程式呼叫 <code>Close</code>。</p>
<p>讀到一半就離開、又沒有呼叫 <code>Close</code>，那條連線就一直被佔著。把池的上限設成 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="nx">db</span><span class="p">.</span><span class="nf">SetMaxOpenConns</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">rows</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">QueryContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;SELECT id FROM orders&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">rows</span><span class="p">.</span><span class="nf">Next</span><span class="p">()</span> <span class="c1">// 只讀一列就離開，沒有 Close</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">ctx2</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="mi">2</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">defer</span> <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">7</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">QueryRowContext</span><span class="p">(</span><span class="nx">ctx2</span><span class="p">,</span> <span class="s">&#34;SELECT 1&#34;</span><span class="p">).</span><span class="nf">Scan</span><span class="p">(</span><span class="nb">new</span><span class="p">(</span><span class="kt">int</span><span class="p">))</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">stats after leaking rows: InUse=1 Idle=0
</span></span><span class="line"><span class="ln">2</span><span class="cl">second query: context deadline exceeded WaitCount = 1
</span></span><span class="line"><span class="ln">3</span><span class="cl">after Close: InUse=0 Idle=1</span></span></code></pre></div><p>第二個查詢排隊等那唯一的一條連線，等了兩秒仍然等不到。正式環境的池上限比 1 大，同樣的連線洩漏（<a href="/blog/backend/knowledge-cards/connection-leak/" data-link-title="Connection Leak（連線洩漏）" data-link-desc="請求越來越慢、最後在等連線時逾時，而資料庫本身負載不高，或連線池使用中的數量一直停在上限時，查哪一段程式借了連線沒有還">connection leak</a>）要累積到把池佔滿才浮現，症狀是請求慢慢變慢、最後全部逾時，而資料庫本身的負載並不高。<code>db.Stats()</code> 的 <code>InUse</code> 一直接近上限、<code>WaitCount</code> 持續增加，是這個狀況的訊號。</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">rows</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">QueryContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;SELECT id, amount FROM orders ORDER BY id&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">defer</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span> <span class="c1">// 中途 return 也會關閉</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="k">for</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">Next</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="kd">var</span> <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="kd">var</span> <span class="nx">amount</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">id</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">amount</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">		<span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="c1">// 使用 id、amount</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="k">return</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span> <span class="c1">// 讀取途中斷線之類的錯誤在迴圈結束後才回報</span></span></span></code></pre></div><p><code>rows.Err()</code> 不能省：<code>Next</code> 回傳 <code>false</code> 有兩種原因，一種是正常讀完，一種是讀取途中出錯，只有 <code>rows.Err()</code> 分得出來。少了它，讀到一半斷線的結果會被當成完整的結果交給呼叫端。</p>
<h2 id="交易begintxdefer-rollback-與-commit">交易：BeginTx、defer Rollback 與 Commit</h2>
<p>交易（transaction）綁在一條連線上：<code>BeginTx</code> 從池裡取一條連線、送出 <code>BEGIN</code>，回傳的 <code>*sql.Tx</code> 之後的每個查詢都走這條連線，直到 <code>Commit</code> 或 <code>Rollback</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="c1">// 把訂單 101 的 50 元折抵改記到同一位顧客的訂單 102：只成功一半的話，顧客會多拿到或少拿到 50 元</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">moveDiscount</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">db</span> <span class="o">*</span><span class="nx">sql</span><span class="p">.</span><span class="nx">DB</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">tx</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">BeginTx</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="k">defer</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">Rollback</span><span class="p">()</span> <span class="c1">// Commit 成功之後再呼叫只會回 ErrTxDone，不會撤銷</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="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">ExecContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;UPDATE orders SET amount = amount - 50 WHERE id = $1&#34;</span><span class="p">,</span> <span class="mi">101</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="k">return</span> <span class="nx">err</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 class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">ExecContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;UPDATE orders SET amount = amount + 50 WHERE id = $1&#34;</span><span class="p">,</span> <span class="mi">102</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="k">return</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">Commit</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>defer tx.Rollback()</code> 讓每一條提早 <code>return</code> 的錯誤路徑都會撤銷交易並放回連線。<code>Commit</code> 成功之後，那個 <code>Rollback</code> 仍然會執行，只是回傳錯誤、什麼都不做。下面這一行的最後一個值是 <code>errors.Is(err, sql.ErrTxDone)</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">rollback after commit: sql: transaction has already been committed or rolled back true</span></span></code></pre></div><p>交易裡的查詢一定要用 <code>tx</code> 的方法，不能用 <code>db</code> 的。<code>db.ExecContext</code> 會從池裡另取一條連線，那個查詢就跑在交易之外：交易撤銷時它不會跟著撤銷，而且交易裡尚未提交的修改它也看不到。池上限是 1 的時候，這個錯誤直接變成卡住，因為唯一的連線被交易佔著。</p>
<p>隔離等級由 <code>BeginTx</code> 的第二個參數 <code>&amp;sql.TxOptions{Isolation: sql.LevelSerializable}</code> 指定，<code>nil</code> 代表用資料庫的預設值（PostgreSQL 是 read committed）。等級之間的差別見 <a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">Isolation Level</a>，交易範圍要劃在哪裡見 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">Transaction Boundary</a>；交易要跨好幾個 repository 共用時，交易物件怎麼傳進各個 repository（以及傳錯時的徵兆，見 <a href="/blog/backend/knowledge-cards/transaction-propagation/" data-link-title="Transaction Propagation（交易傳遞）" data-link-desc="多個 repository 的寫入要在同一個交易裡完成，或一段程式在交易失敗後仍留下部分寫入、交易裡的某個查詢卡住直到逾時時，查交易物件怎麼傳到每一個資料存取呼叫">Transaction Propagation</a>），見 <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>〈Transaction 傳遞〉。</p>
<h2 id="本模組其餘工具各自接手哪一段">本模組其餘工具各自接手哪一段</h2>
<p><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> 把存取資料庫的工作分成寫出 SQL、取得連線並執行（連線池、交易）、把查詢結果對映成程式的值。用 <code>database/sql</code> 時，連線池由它管理，程式仍然要寫 SQL 字串、逐欄 <code>Scan</code> 進變數，並負責讀取物件與交易的收尾。本模組其餘的工具各自接手其中幾件：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>接手的工作</th>
          <th>仍由程式負責的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">pgx 原生介面</a></td>
          <td>依欄名把整列對映進 struct；PostgreSQL 專有的型別、<code>COPY</code> 大量寫入</td>
          <td>SQL 字串；只能用在 PostgreSQL</td>
      </tr>
      <tr>
          <td><a href="/blog/programming/go/basic/10-database-access/sqlx/" data-link-title="10.3 sqlx：手寫 SQL 加上 struct 對映" data-link-desc="sqlx 在 database/sql 之上補的 struct 對映：Get 與 Select 依 db tag 把查詢結果放進 struct、查詢結果的欄位多於 struct 時的錯誤（missing destination name）與 SELECT * 的風險、具名參數與 IN 的展開，以及 SQL 仍是字串所留下的檢查缺口">sqlx</a></td>
          <td>依 <code>db</code> tag 把整列對映進 struct、具名參數</td>
          <td>SQL 字串，寫錯要到執行時才發現</td>
      </tr>
      <tr>
          <td><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 型別與改成指標的設定）、選擇性篩選條件的寫法，以及產生步驟帶進建置流程的代價">sqlc</a></td>
          <td>從 SQL 產生呼叫函式、參數與結果的 struct，產生時檢查欄位</td>
          <td>SQL 寫在 <code>.sql</code> 檔；動態組合的條件</td>
      </tr>
      <tr>
          <td><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 在正式環境的限制">GORM</a></td>
          <td>從方法呼叫組出 SQL、對映、寫入時的交易</td>
          <td>理解它實際送出了什麼 SQL</td>
      </tr>
  </tbody>
</table>
<p>sqlx、pgx 原生介面與 sqlc 接手之後，送出的 SQL 仍然寫在程式碼或 <code>.sql</code> 檔裡看得見；GORM 接手之後，SQL 也從程式碼上消失，要靠它的 SQL 記錄才看得到。怎麼在這些工具之間選，見 <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.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入</title><link>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/pgx/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/pgx/</guid><description>&lt;p>這篇整理 &lt;a href="https://github.com/jackc/pgx">pgx&lt;/a> v5.11.0 的原生介面。在 &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> 的分類裡，pgx 是驅動，它的 &lt;code>RowToStructByName&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> 兩張表。database/sql 那一篇已經把 pgx 當成 &lt;code>database/sql&lt;/code> 的驅動用過，這篇看的是另一種用法：不經過 &lt;code>database/sql&lt;/code>，直接呼叫 pgx 自己的 API。&lt;/p>
&lt;h2 id="兩種用法databasesql-驅動與原生介面">兩種用法：database/sql 驅動與原生介面&lt;/h2>
&lt;p>pgx 同一個模組提供兩種入口：&lt;/p>
&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;code>database/sql&lt;/code> 驅動&lt;/td>
 &lt;td>&lt;code>_ &amp;quot;github.com/jackc/pgx/v5/stdlib&amp;quot;&lt;/code>，用 &lt;code>sql.Open(&amp;quot;pgx&amp;quot;, dsn)&lt;/code>&lt;/td>
 &lt;td>&lt;code>*sql.DB&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>原生介面&lt;/td>
 &lt;td>&lt;code>github.com/jackc/pgx/v5&lt;/code> 與 &lt;code>github.com/jackc/pgx/v5/pgxpool&lt;/code>&lt;/td>
 &lt;td>&lt;code>*pgxpool.Pool&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者連的是同一個 PostgreSQL、用的是同一套通訊協定實作，差別在程式碼看到的 API：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>原生介面多出來的能力&lt;/strong>：PostgreSQL 專有型別的直接對應（陣列、&lt;code>jsonb&lt;/code>、&lt;code>inet&lt;/code> 對應到 &lt;code>netip.Prefix&lt;/code>）、&lt;code>COPY&lt;/code> 協定的大量寫入、一次送出多個查詢的批次（&lt;code>Batch&lt;/code>）、&lt;code>LISTEN&lt;/code>／&lt;code>NOTIFY&lt;/code>。&lt;code>database/sql&lt;/code> 的介面是為所有資料庫設計的，這些功能放不進去。&lt;/li>
&lt;li>&lt;strong>&lt;code>database/sql&lt;/code> 驅動保留的相容性&lt;/strong>：只接受 &lt;code>*sql.DB&lt;/code> 的函式庫——sqlx、GORM、多數 migration 工具——都要透過這一個入口。&lt;/li>
&lt;/ul>
&lt;p>所以程式只連 PostgreSQL、而且沒有依賴只吃 &lt;code>*sql.DB&lt;/code> 的函式庫時，原生介面是比較直接的選擇；要和那些函式庫在同一個交易裡寫入時，用驅動那一個；只要共用連線池的話，pgx 的 &lt;code>stdlib.OpenDBFromPool(pool)&lt;/code> 可以從 &lt;code>pgxpool&lt;/code> 做出一個共用同一個池的 &lt;code>*sql.DB&lt;/code>，交給那些函式庫。兩者可以在同一個程式裡並存；分別建立時各有一個連線池（連線數要合起來算），而且不論池是否共用， &lt;code>pgx.Tx&lt;/code> 與 &lt;code>*sql.Tx&lt;/code> 互不相容，一段要一起成功的寫入必須全部走同一個入口。&lt;/p>
&lt;h2 id="pgxpool連線池">pgxpool：連線池&lt;/h2>
&lt;p>原生介面的連線池是 &lt;code>pgxpool&lt;/code>。&lt;code>pgx.Connect&lt;/code> 只建立一條連線、不能被多個 goroutine 同時使用，服務程式用 &lt;code>pgxpool.New&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="c1">// 連線字串的帳號、密碼、port 與資料庫名稱換成手上那一台的值；pool_max_conns 是池的上限&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">pool&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">pgxpool&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">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;postgres://postgres:pw@localhost:55432/bookstore?sslmode=disable&amp;amp;pool_max_conns=10&amp;#34;&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="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&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="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&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="k">defer&lt;/span> &lt;span class="nx">pool&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&lt;/span>&lt;span class="p">()&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>池的設定可以寫在連線字串裡（&lt;code>pool_max_conns&lt;/code>、&lt;code>pool_max_conn_lifetime&lt;/code>、&lt;code>pool_max_conn_idle_time&lt;/code> 等），也可以先用 &lt;code>pgxpool.ParseConfig&lt;/code> 解析成設定物件再改欄位。&lt;code>pool_max_conns&lt;/code> 不寫時，上限是 4 與執行機器 CPU 核心數兩者中較大的那一個；這個預設值和 &lt;code>database/sql&lt;/code> 的「不設上限」相反，流量大的服務常常是排隊等連線，而不是把資料庫連線開滿。&lt;/p>
&lt;p>&lt;code>pool.Stat()&lt;/code> 回傳池的狀態，可以直接輸出成監控指標：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>意思&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>MaxConns()&lt;/code>&lt;/td>
 &lt;td>池的上限&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>TotalConns()&lt;/code>&lt;/td>
 &lt;td>目前開著的連線數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>AcquiredConns()&lt;/code>&lt;/td>
 &lt;td>使用中的連線數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>IdleConns()&lt;/code>&lt;/td>
 &lt;td>閒置的連線數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>EmptyAcquireCount()&lt;/code>&lt;/td>
 &lt;td>取連線時池裡沒有閒置連線、必須等待或新開的累計次數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>AcquireDuration()&lt;/code>&lt;/td>
 &lt;td>取連線花掉的累計時間&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>EmptyAcquireCount&lt;/code> 持續增加、&lt;code>AcquireDuration&lt;/code> 跟著變長，代表請求在等連線；這和 &lt;code>database/sql&lt;/code> 連線池的 &lt;code>WaitCount&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>）。&lt;/p>
&lt;h2 id="讀取結果collectrows-與-rowtostructbyname">讀取結果：CollectRows 與 RowToStructByName&lt;/h2>
&lt;p>原生介面也可以逐欄 &lt;code>Scan&lt;/code>，寫法和 &lt;code>database/sql&lt;/code> 幾乎一樣。多出來的是一組把整列對映進 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">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"> 2&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"> 3&lt;/span>&lt;span class="cl">	&lt;span class="nx">CustomerID&lt;/span> &lt;span class="kt">int64&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">Amount&lt;/span> &lt;span class="kt">int&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">Note&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="kt">string&lt;/span> &lt;span class="c1">// NULL 時是 nil&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">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"> 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="nx">rows&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">pool&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&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="s">&amp;#34;SELECT id, customer_id, amount, note, ordered_at FROM orders WHERE customer_id = $1 ORDER BY id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&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="nx">orders&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">pgx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">CollectRows&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rows&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">pgx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RowToStructByName&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">Order&lt;/span>&lt;span class="p">])&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>pool.Query&lt;/code> 的錯誤可以先忽略，因為 &lt;code>CollectRows&lt;/code> 會把查詢的錯誤一併回傳，並在結束時關閉讀取物件，&lt;code>database/sql&lt;/code> 那種忘了 &lt;code>Close&lt;/code> 而佔住連線的問題在這個寫法裡不會發生。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理 <a href="https://github.com/jackc/pgx">pgx</a> v5.11.0 的原生介面。在 <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> 的分類裡，pgx 是驅動，它的 <code>RowToStructByName</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> 兩張表。database/sql 那一篇已經把 pgx 當成 <code>database/sql</code> 的驅動用過，這篇看的是另一種用法：不經過 <code>database/sql</code>，直接呼叫 pgx 自己的 API。</p>
<h2 id="兩種用法databasesql-驅動與原生介面">兩種用法：database/sql 驅動與原生介面</h2>
<p>pgx 同一個模組提供兩種入口：</p>
<table>
  <thead>
      <tr>
          <th>入口</th>
          <th>匯入</th>
          <th>拿到的物件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>database/sql</code> 驅動</td>
          <td><code>_ &quot;github.com/jackc/pgx/v5/stdlib&quot;</code>，用 <code>sql.Open(&quot;pgx&quot;, dsn)</code></td>
          <td><code>*sql.DB</code></td>
      </tr>
      <tr>
          <td>原生介面</td>
          <td><code>github.com/jackc/pgx/v5</code> 與 <code>github.com/jackc/pgx/v5/pgxpool</code></td>
          <td><code>*pgxpool.Pool</code></td>
      </tr>
  </tbody>
</table>
<p>兩者連的是同一個 PostgreSQL、用的是同一套通訊協定實作，差別在程式碼看到的 API：</p>
<ul>
<li><strong>原生介面多出來的能力</strong>：PostgreSQL 專有型別的直接對應（陣列、<code>jsonb</code>、<code>inet</code> 對應到 <code>netip.Prefix</code>）、<code>COPY</code> 協定的大量寫入、一次送出多個查詢的批次（<code>Batch</code>）、<code>LISTEN</code>／<code>NOTIFY</code>。<code>database/sql</code> 的介面是為所有資料庫設計的，這些功能放不進去。</li>
<li><strong><code>database/sql</code> 驅動保留的相容性</strong>：只接受 <code>*sql.DB</code> 的函式庫——sqlx、GORM、多數 migration 工具——都要透過這一個入口。</li>
</ul>
<p>所以程式只連 PostgreSQL、而且沒有依賴只吃 <code>*sql.DB</code> 的函式庫時，原生介面是比較直接的選擇；要和那些函式庫在同一個交易裡寫入時，用驅動那一個；只要共用連線池的話，pgx 的 <code>stdlib.OpenDBFromPool(pool)</code> 可以從 <code>pgxpool</code> 做出一個共用同一個池的 <code>*sql.DB</code>，交給那些函式庫。兩者可以在同一個程式裡並存；分別建立時各有一個連線池（連線數要合起來算），而且不論池是否共用， <code>pgx.Tx</code> 與 <code>*sql.Tx</code> 互不相容，一段要一起成功的寫入必須全部走同一個入口。</p>
<h2 id="pgxpool連線池">pgxpool：連線池</h2>
<p>原生介面的連線池是 <code>pgxpool</code>。<code>pgx.Connect</code> 只建立一條連線、不能被多個 goroutine 同時使用，服務程式用 <code>pgxpool.New</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="c1">// 連線字串的帳號、密碼、port 與資料庫名稱換成手上那一台的值；pool_max_conns 是池的上限</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">pool</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">pgxpool</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;postgres://postgres:pw@localhost:55432/bookstore?sslmode=disable&amp;pool_max_conns=10&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="k">return</span> <span class="nx">err</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 class="k">defer</span> <span class="nx">pool</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span></span></span></code></pre></div><p>池的設定可以寫在連線字串裡（<code>pool_max_conns</code>、<code>pool_max_conn_lifetime</code>、<code>pool_max_conn_idle_time</code> 等），也可以先用 <code>pgxpool.ParseConfig</code> 解析成設定物件再改欄位。<code>pool_max_conns</code> 不寫時，上限是 4 與執行機器 CPU 核心數兩者中較大的那一個；這個預設值和 <code>database/sql</code> 的「不設上限」相反，流量大的服務常常是排隊等連線，而不是把資料庫連線開滿。</p>
<p><code>pool.Stat()</code> 回傳池的狀態，可以直接輸出成監控指標：</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>意思</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>MaxConns()</code></td>
          <td>池的上限</td>
      </tr>
      <tr>
          <td><code>TotalConns()</code></td>
          <td>目前開著的連線數</td>
      </tr>
      <tr>
          <td><code>AcquiredConns()</code></td>
          <td>使用中的連線數</td>
      </tr>
      <tr>
          <td><code>IdleConns()</code></td>
          <td>閒置的連線數</td>
      </tr>
      <tr>
          <td><code>EmptyAcquireCount()</code></td>
          <td>取連線時池裡沒有閒置連線、必須等待或新開的累計次數</td>
      </tr>
      <tr>
          <td><code>AcquireDuration()</code></td>
          <td>取連線花掉的累計時間</td>
      </tr>
  </tbody>
</table>
<p><code>EmptyAcquireCount</code> 持續增加、<code>AcquireDuration</code> 跟著變長，代表請求在等連線；這和 <code>database/sql</code> 連線池的 <code>WaitCount</code> 是同一種訊號，持續發生而查詢本身不慢時，先查有沒有借了連線不還（<a href="/blog/backend/knowledge-cards/connection-leak/" data-link-title="Connection Leak（連線洩漏）" data-link-desc="請求越來越慢、最後在等連線時逾時，而資料庫本身負載不高，或連線池使用中的數量一直停在上限時，查哪一段程式借了連線沒有還">Connection Leak</a>）。</p>
<h2 id="讀取結果collectrows-與-rowtostructbyname">讀取結果：CollectRows 與 RowToStructByName</h2>
<p>原生介面也可以逐欄 <code>Scan</code>，寫法和 <code>database/sql</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">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">Amount</span>     <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="nx">Note</span>       <span class="o">*</span><span class="kt">string</span> <span class="c1">// NULL 時是 nil</span>
</span></span><span class="line"><span class="ln"> 6</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"> 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="nx">rows</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">pool</span><span class="p">.</span><span class="nf">Query</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">	<span class="s">&#34;SELECT id, customer_id, amount, note, ordered_at FROM orders WHERE customer_id = $1 ORDER BY id&#34;</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 class="nx">orders</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">pgx</span><span class="p">.</span><span class="nf">CollectRows</span><span class="p">(</span><span class="nx">rows</span><span class="p">,</span> <span class="nx">pgx</span><span class="p">.</span><span class="nx">RowToStructByName</span><span class="p">[</span><span class="nx">Order</span><span class="p">])</span></span></span></code></pre></div><p><code>pool.Query</code> 的錯誤可以先忽略，因為 <code>CollectRows</code> 會把查詢的錯誤一併回傳，並在結束時關閉讀取物件，<code>database/sql</code> 那種忘了 <code>Close</code> 而佔住連線的問題在這個寫法裡不會發生。</p>
<p><code>RowToStructByName</code> 依欄位名稱對映：查詢結果的 <code>customer_id</code> 對到 struct 的 <code>CustomerID</code>，比對時忽略大小寫與底線；名稱不同時用 <code>db:&quot;欄位名&quot;</code> 這個 struct tag 指定。對映發生在執行時，查詢結果有一個 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">Bad</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">Total</span> <span class="kt">int</span> <span class="c1">// 查詢結果的欄位叫 amount</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">rows</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">pool</span><span class="p">.</span><span class="nf">Query</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;SELECT id, amount FROM orders&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="p">=</span> <span class="nx">pgx</span><span class="p">.</span><span class="nf">CollectRows</span><span class="p">(</span><span class="nx">rows</span><span class="p">,</span> <span class="nx">pgx</span><span class="p">.</span><span class="nx">RowToStructByName</span><span class="p">[</span><span class="nx">Bad</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">mismatch: struct doesn&#39;t have corresponding row field amount</span></span></code></pre></div><p>反方向的情形是 struct 有、查詢結果沒有的欄位：<code>RowToStructByName</code> 報錯，<code>RowToStructByNameLax</code> 讓那些欄位保持零值。查詢結果多出來的欄位，兩者都報錯，要改的是查詢列出的欄位。</p>
<p>查不到資料時，<code>QueryRow(...).Scan</code> 回傳的是 <code>pgx.ErrNoRows</code>，不是 <code>sql.ErrNoRows</code>；從 <code>database/sql</code> 換到原生介面時，判斷「找不到」的那幾行要一起改。</p>
<h2 id="錯誤pgconnpgerror">錯誤：*pgconn.PgError</h2>
<p>資料庫回報的錯誤會被包成 <code>*pgconn.PgError</code>，裡面有 PostgreSQL 的錯誤代碼（SQLSTATE）與被違反的約束名稱：</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">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">pool</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;INSERT INTO customers (name, email) VALUES ($1, $2)&#34;</span><span class="p">,</span> <span class="s">&#34;重複&#34;</span><span class="p">,</span> <span class="s">&#34;jiaying@example.com&#34;</span><span class="p">)</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">Code</span> <span class="o">==</span> <span class="s">&#34;23505&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="c1">// 23505 是唯一約束違反；pgErr.ConstraintName 說是哪一個</span>
</span></span><span class="line"><span class="ln">6</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">pgerr: 23505 customers_email_key</span></span></code></pre></div><p>一張表通常有好幾個唯一約束（email、帳號名稱），錯誤代碼只說「有唯一約束被違反」，要回報「email 已被使用」還是「帳號已被使用」得看 <code>ConstraintName</code>。所以建表時替約束取明確的名字（<code>CONSTRAINT customers_email_key UNIQUE (email)</code>），程式就能依名字判斷，不必解析錯誤訊息的文字。</p>
<h2 id="交易beginfunc">交易：BeginFunc</h2>
<p>原生介面的交易同樣是 <code>Begin</code> 加 <code>defer tx.Rollback(ctx)</code> 加 <code>Commit</code>。pgx 另外提供 <code>pgx.BeginFunc</code>，把這三步包成一個函式：傳入的函式回傳 <code>nil</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">pgx</span><span class="p">.</span><span class="nf">BeginFunc</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">pool</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">tx</span> <span class="nx">pgx</span><span class="p">.</span><span class="nx">Tx</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;UPDATE orders SET amount = amount - 10 WHERE id = $1&#34;</span><span class="p">,</span> <span class="mi">101</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">		<span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="k">return</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;中途失敗&#34;</span><span class="p">)</span> <span class="c1">// 回傳錯誤，前面的 UPDATE 一起撤銷</span>
</span></span><span class="line"><span class="ln">6</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">beginfunc err: 中途失敗 amount still: 300</span></span></code></pre></div><h2 id="大量寫入copyfrom">大量寫入：CopyFrom</h2>
<p>一次寫入大量資料列時，逐列 <code>INSERT</code> 每一列都是一次來回（round trip）；多列寫成一句 <code>INSERT ... VALUES (...), (...)</code> 可以減少來回，但參數數量有上限（PostgreSQL 的單一查詢最多 65535 個參數）。<code>CopyFrom</code> 走 PostgreSQL 的 <code>COPY</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">n</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">pool</span><span class="p">.</span><span class="nf">CopyFrom</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="nx">pgx</span><span class="p">.</span><span class="nx">Identifier</span><span class="p">{</span><span class="s">&#34;orders&#34;</span><span class="p">},</span>              <span class="c1">// 寫入哪張表</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;customer_id&#34;</span><span class="p">,</span> <span class="s">&#34;amount&#34;</span><span class="p">},</span>      <span class="c1">// 寫入哪些欄位</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="nx">pgx</span><span class="p">.</span><span class="nf">CopyFromRows</span><span class="p">([][]</span><span class="kt">any</span><span class="p">{{</span><span class="mi">3</span><span class="p">,</span> <span class="mi">120</span><span class="p">},</span> <span class="p">{</span><span class="mi">3</span><span class="p">,</span> <span class="mi">80</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">copy: 2 &lt;nil&gt;</span></span></code></pre></div><p><code>COPY</code> 是整批成功或整批失敗，其中一列違反約束，整批都不會寫入；它也不支援 <code>ON CONFLICT</code>，所以要「已存在就略過」的寫入不適合用它（重送的事件用 <code>INSERT ... ON CONFLICT DO NOTHING</code> 冪等寫入的做法見 <a href="/blog/backend/01-database/high-frequency-counting/" data-link-title="1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件" data-link-desc="熱點列更新的吞吐量上限與它的成因、拆成多列／只新增事件／先在記憶體裡合併的差別、只新增事件搬去哪裡的成本、累加與重算在什麼條件下漏算或重複計算、讓彙總排程互斥的建議鎖與連線池的交互，以及重送事件的冪等寫入、推導事實的存放位置，以及 Redis 承擔即時計數、跨小時不重複數、去重與互斥時各自的邊界">1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件</a>）。資料來源很大時，用 <code>pgx.CopyFromFunc</code> 逐列產生資料，不必先把全部資料放進記憶體。</p>
<h2 id="pgx-的語句快取與-pgbouncer-交易模式">pgx 的語句快取與 PgBouncer 交易模式</h2>
<p>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>）並快取起來，同一段 SQL 第二次執行時，資料庫不必再解析；執行計畫是否重用，由 PostgreSQL 的計畫快取決定（見 <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>ALTER DATABASE bookstore SET log_statement = 'all';</code> 之後新開的連線生效；PostgreSQL 跑在容器裡時用 <code>docker logs &lt;容器名稱&gt;</code> 讀記錄）：用 <code>pool.QueryRow</code> 對兩個顧客各執行一次 <code>SELECT count(*) FROM orders WHERE customer_id = $1</code>，兩次都以同一個 <code>stmtcache_</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:  execute stmtcache_209ed55a1131ce455f4cfc90494ae99f1af9c243b09d5ff6: SELECT count(*) FROM orders WHERE customer_id = $1
</span></span><span class="line"><span class="ln">2</span><span class="cl">DETAIL:  Parameters: $1 = &#39;1&#39;
</span></span><span class="line"><span class="ln">3</span><span class="cl">LOG:  execute stmtcache_209ed55a1131ce455f4cfc90494ae99f1af9c243b09d5ff6: SELECT count(*) FROM orders WHERE customer_id = $1
</span></span><span class="line"><span class="ln">4</span><span class="cl">DETAIL:  Parameters: $1 = &#39;2&#39;</span></span></code></pre></div><p>預先解析的查詢存在資料庫端的那一條連線上。應用程式和 PostgreSQL 之間有一層 PgBouncer 這類連線池代理（<a href="/blog/backend/knowledge-cards/connection-pooler/" data-link-title="Connection Pooler" data-link-desc="應用層跟資料庫之間的連線複用中介層、解水平擴展時的連線數放大問題">connection pooler</a>），而它用交易模式（transaction pooling，<code>pool_mode = transaction</code>）時，應用程式連到 PgBouncer 的那條連線不變，而 PgBouncer 每個交易都可能從它自己對 PostgreSQL 的連線裡挑另一條，名字就對不上。pgx 的 <code>stmtcache_</code> 名字由查詢內容雜湊而成，同一段 SQL 在每條應用程式連線上都叫同一個名字，所以另一條應用程式連線先在那條資料庫連線上建了同名的查詢，這一條再建一次就撞名。PgBouncer 1.25.2 設成交易模式、<code>max_prepared_statements = 0</code>，六條 pgx 連線同時執行同一段查詢 300 次，失敗的 200 次全是同一個錯誤：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">ERROR: prepared statement &#34;stmtcache_209ed55a1131ce455f4cfc90494ae99f1af9c243b09d5ff6&#34; already exists (SQLSTATE 42P05)</span></span></code></pre></div><p>同樣的程式改用下面任一種處理方式之後，300 次全部成功。處理方式有兩種：PgBouncer 1.21 起可以在交易模式下追蹤預先解析的查詢，要把 <code>max_prepared_statements</code> 設成非 0，1.24 起預設就是 200（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>）；或者在 pgx 的連線字串加上 <code>default_query_exec_mode=exec</code>，改成每次執行都不留下具名的預先解析查詢。交易模式對其他連線層級設定（session state）的限制見 <a href="/blog/backend/knowledge-cards/transaction-pooling/" data-link-title="Transaction Pooling" data-link-desc="說明 connection pooler 的 transaction 綁定模式如何壓縮連線並改變 session 語意">Transaction Pooling</a>。</p>
<h2 id="pgx-原生介面的適用範圍只連-postgresql-的程式">pgx 原生介面的適用範圍：只連 PostgreSQL 的程式</h2>
<p>pgx 的原生介面接手了 PostgreSQL 型別的轉換、讀取物件的關閉與依欄名的對映，代價是程式只能連 PostgreSQL，而且只吃 <code>*sql.DB</code> 的函式庫用不上它的連線池。SQL 仍然是程式裡的字串，欄位名稱拼錯要到執行那一行才知道。<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> 從同樣的 SQL 產生呼叫 pgx 的程式碼，把這類錯誤提前到產生程式碼時（<code>sqlc generate</code>）；只想在 <code>database/sql</code> 上補齊依欄名對映的，是 <a href="/blog/programming/go/basic/10-database-access/sqlx/" data-link-title="10.3 sqlx：手寫 SQL 加上 struct 對映" data-link-desc="sqlx 在 database/sql 之上補的 struct 對映：Get 與 Select 依 db tag 把查詢結果放進 struct、查詢結果的欄位多於 struct 時的錯誤（missing destination name）與 SELECT * 的風險、具名參數與 IN 的展開，以及 SQL 仍是字串所留下的檢查缺口">10.3 sqlx：手寫 SQL 加上 struct 對映</a>。</p>
]]></content:encoded></item><item><title>10.3 sqlx：手寫 SQL 加上 struct 對映</title><link>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/sqlx/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/sqlx/</guid><description>&lt;p>這篇整理 &lt;a href="https://github.com/jmoiron/sqlx">sqlx&lt;/a> v1.4.0 的用法。範例沿用 &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> 的分類裡，sqlx 屬於結果對映：SQL 仍然由程式手寫，它接手的是把查詢結果的每一列放進 struct。取得套件的指令是 &lt;code>go get github.com/jmoiron/sqlx@v1.4.0&lt;/code>。&lt;/p>
&lt;h2 id="sqlxdb-與-databasesql-的關係">sqlx.DB 與 database/sql 的關係&lt;/h2>
&lt;p>&lt;code>*sqlx.DB&lt;/code> 內嵌了 &lt;code>*sql.DB&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>SetMaxOpenConns&lt;/code>、同樣要關閉的讀取物件、同樣的交易規則。sqlx 只在上面加方法，驅動也沿用 &lt;code>database/sql&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="kn">import&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">_&lt;/span> &lt;span class="s">&amp;#34;github.com/jackc/pgx/v5/stdlib&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="s">&amp;#34;github.com/jmoiron/sqlx&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&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">sqlx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ConnectContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;pgx&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">dsn&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// Open 加上 Ping&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>sqlx.ConnectContext&lt;/code> 等於 &lt;code>sql.Open&lt;/code> 再 &lt;code>PingContext&lt;/code>，設定錯誤在這一行就回報。手上已經有一個 &lt;code>*sql.DB&lt;/code> 時，用 &lt;code>sqlx.NewDb(db, &amp;quot;pgx&amp;quot;)&lt;/code> 包起來，兩者共用同一個連線池。&lt;/p>
&lt;h2 id="get-與-select依-db-tag-對映">Get 與 Select：依 db tag 對映&lt;/h2>
&lt;p>sqlx 依 struct 欄位上的 &lt;code>db&lt;/code> tag 對應查詢結果的欄位名稱：&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">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"> 2&lt;/span>&lt;span class="cl">	&lt;span class="nx">ID&lt;/span> &lt;span class="kt">int64&lt;/span> &lt;span class="s">`db:&amp;#34;id&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">CustomerID&lt;/span> &lt;span class="kt">int64&lt;/span> &lt;span class="s">`db:&amp;#34;customer_id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">	&lt;span class="nx">Amount&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="s">`db:&amp;#34;amount&amp;#34;`&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">Note&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="kt">string&lt;/span> &lt;span class="s">`db:&amp;#34;note&amp;#34;`&lt;/span> &lt;span class="c1">// NULL 時是 nil&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">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 class="s">`db:&amp;#34;ordered_at&amp;#34;`&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">var&lt;/span> &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">10&lt;/span>&lt;span class="cl">&lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SelectContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">orders&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="s">&amp;#34;SELECT id, customer_id, amount, note, ordered_at FROM orders WHERE customer_id = $1 ORDER BY id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">o&lt;/span> &lt;span class="nx">Order&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">err&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">GetContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">o&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;SELECT * FROM orders WHERE id = $1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">999&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">select: 2 &amp;lt;nil&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">get none: sql: no rows in result set&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>SelectContext&lt;/code> 讀出多列放進 slice，內部做完 &lt;code>database/sql&lt;/code> 讀多列的那一整套「&lt;code>Next&lt;/code>、&lt;code>Scan&lt;/code>、&lt;code>Close&lt;/code>、&lt;code>Err&lt;/code>」；&lt;code>GetContext&lt;/code> 讀一列，查不到時回傳的仍然是 &lt;code>sql.ErrNoRows&lt;/code>，判斷「找不到」的寫法和 &lt;code>database/sql&lt;/code> 相同。沒有 &lt;code>db&lt;/code> tag 的欄位，sqlx 預設用欄位名稱的小寫去比對（&lt;code>CustomerID&lt;/code> 比對 &lt;code>customerid&lt;/code>），和資料庫慣用的 &lt;code>customer_id&lt;/code> 對不上，所以每個欄位都寫 tag。&lt;/p>
&lt;h2 id="查詢結果的欄位多於-struct-時">查詢結果的欄位多於 struct 時&lt;/h2>
&lt;p>對映發生在執行時。查詢結果有一個 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">Small&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="s">`db:&amp;#34;id&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="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="kd">var&lt;/span> &lt;span class="nx">s&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Small&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">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SelectContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">s&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;SELECT id, amount FROM orders&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">extra column: missing destination name amount in *[]main.Small&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個行為在查詢寫 &lt;code>SELECT *&lt;/code> 時會變成部署事故：資料表新增一個欄位之後，所有用 &lt;code>SELECT *&lt;/code> 讀進同一個 struct 的查詢，從 migration 套用的那一刻起同時開始失敗，而 Go 程式一行都沒改。反方向（struct 有、查詢結果沒有的欄位）不會報錯，那個欄位保持零值，同樣不會被發現。所以用 sqlx 時查詢明確列出欄位，struct 與欄位清單一起改。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理 <a href="https://github.com/jmoiron/sqlx">sqlx</a> v1.4.0 的用法。範例沿用 <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> 的分類裡，sqlx 屬於結果對映：SQL 仍然由程式手寫，它接手的是把查詢結果的每一列放進 struct。取得套件的指令是 <code>go get github.com/jmoiron/sqlx@v1.4.0</code>。</p>
<h2 id="sqlxdb-與-databasesql-的關係">sqlx.DB 與 database/sql 的關係</h2>
<p><code>*sqlx.DB</code> 內嵌了 <code>*sql.DB</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>SetMaxOpenConns</code>、同樣要關閉的讀取物件、同樣的交易規則。sqlx 只在上面加方法，驅動也沿用 <code>database/sql</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="nx">_</span> <span class="s">&#34;github.com/jackc/pgx/v5/stdlib&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="s">&#34;github.com/jmoiron/sqlx&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">db</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">sqlx</span><span class="p">.</span><span class="nf">ConnectContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;pgx&#34;</span><span class="p">,</span> <span class="nx">dsn</span><span class="p">)</span> <span class="c1">// Open 加上 Ping</span></span></span></code></pre></div><p><code>sqlx.ConnectContext</code> 等於 <code>sql.Open</code> 再 <code>PingContext</code>，設定錯誤在這一行就回報。手上已經有一個 <code>*sql.DB</code> 時，用 <code>sqlx.NewDb(db, &quot;pgx&quot;)</code> 包起來，兩者共用同一個連線池。</p>
<h2 id="get-與-select依-db-tag-對映">Get 與 Select：依 db tag 對映</h2>
<p>sqlx 依 struct 欄位上的 <code>db</code> tag 對應查詢結果的欄位名稱：</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 class="s">`db:&#34;id&#34;`</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 class="s">`db:&#34;customer_id&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">Amount</span>     <span class="kt">int</span>       <span class="s">`db:&#34;amount&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="nx">Note</span>       <span class="o">*</span><span class="kt">string</span>   <span class="s">`db:&#34;note&#34;`</span> <span class="c1">// NULL 時是 nil</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="nx">OrderedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`db:&#34;ordered_at&#34;`</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">var</span> <span class="nx">orders</span> <span class="p">[]</span><span class="nx">Order</span>
</span></span><span class="line"><span class="ln">10</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">SelectContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">orders</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="s">&#34;SELECT id, customer_id, amount, note, ordered_at FROM orders WHERE customer_id = $1 ORDER BY id&#34;</span><span class="p">,</span> <span class="mi">1</span><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">var</span> <span class="nx">o</span> <span class="nx">Order</span>
</span></span><span class="line"><span class="ln">14</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">GetContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">o</span><span class="p">,</span> <span class="s">&#34;SELECT * FROM orders WHERE id = $1&#34;</span><span class="p">,</span> <span class="mi">999</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: 2 &lt;nil&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">get none: sql: no rows in result set</span></span></code></pre></div><p><code>SelectContext</code> 讀出多列放進 slice，內部做完 <code>database/sql</code> 讀多列的那一整套「<code>Next</code>、<code>Scan</code>、<code>Close</code>、<code>Err</code>」；<code>GetContext</code> 讀一列，查不到時回傳的仍然是 <code>sql.ErrNoRows</code>，判斷「找不到」的寫法和 <code>database/sql</code> 相同。沒有 <code>db</code> tag 的欄位，sqlx 預設用欄位名稱的小寫去比對（<code>CustomerID</code> 比對 <code>customerid</code>），和資料庫慣用的 <code>customer_id</code> 對不上，所以每個欄位都寫 tag。</p>
<h2 id="查詢結果的欄位多於-struct-時">查詢結果的欄位多於 struct 時</h2>
<p>對映發生在執行時。查詢結果有一個 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">Small</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="s">`db:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="kd">var</span> <span class="nx">s</span> <span class="p">[]</span><span class="nx">Small</span>
</span></span><span class="line"><span class="ln">5</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">SelectContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">s</span><span class="p">,</span> <span class="s">&#34;SELECT id, amount FROM orders&#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">extra column: missing destination name amount in *[]main.Small</span></span></code></pre></div><p>這個行為在查詢寫 <code>SELECT *</code> 時會變成部署事故：資料表新增一個欄位之後，所有用 <code>SELECT *</code> 讀進同一個 struct 的查詢，從 migration 套用的那一刻起同時開始失敗，而 Go 程式一行都沒改。反方向（struct 有、查詢結果沒有的欄位）不會報錯，那個欄位保持零值，同樣不會被發現。所以用 sqlx 時查詢明確列出欄位，struct 與欄位清單一起改。</p>
<p>sqlx 提供 <code>db.Unsafe()</code>，回傳一個會略過多出來欄位的 <code>*sqlx.DB</code>。它讓上面那種事故不發生，代價是 struct 和查詢對不上時一律安靜通過。</p>
<h2 id="具名參數與-in">具名參數與 IN</h2>
<p>sqlx 補了兩個 <code>database/sql</code> 沒有的參數寫法。具名參數用 <code>:名稱</code>，值從 map 或 struct（依 <code>db</code> tag）取：</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">res</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">NamedExecContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="s">&#34;UPDATE orders SET note = :note WHERE id = :id&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<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;id&#34;</span><span class="p">:</span> <span class="mi">103</span><span class="p">,</span> <span class="s">&#34;note&#34;</span><span class="p">:</span> <span class="s">&#34;改地址&#34;</span><span class="p">})</span></span></span></code></pre></div><p><code>IN</code> 後面要放一組值時，佔位符的數量要跟著值的數量變。<code>sqlx.In</code> 把一個 slice 展開成對應數量的 <code>?</code>，<code>Rebind</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="nx">q</span><span class="p">,</span> <span class="nx">args</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">sqlx</span><span class="p">.</span><span class="nf">In</span><span class="p">(</span><span class="s">&#34;SELECT id FROM orders WHERE id IN (?)&#34;</span><span class="p">,</span> <span class="p">[]</span><span class="kt">int64</span><span class="p">{</span><span class="mi">101</span><span class="p">,</span> <span class="mi">103</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">q</span> <span class="p">=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">Rebind</span><span class="p">(</span><span class="nx">q</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">var</span> <span class="nx">ids</span> <span class="p">[]</span><span class="kt">int64</span>
</span></span><span class="line"><span class="ln">4</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">SelectContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">ids</span><span class="p">,</span> <span class="nx">q</span><span class="p">,</span> <span class="nx">args</span><span class="o">...</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">in: SELECT id FROM orders WHERE id IN (?, ?) [101 103] &lt;nil&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">rebind: SELECT id FROM orders WHERE id IN ($1, $2)
</span></span><span class="line"><span class="ln">3</span><span class="cl">ids: [101 103] &lt;nil&gt;</span></span></code></pre></div><p>只連 PostgreSQL 時另一種寫法是 <code>WHERE id = ANY($1)</code>，把整個 slice 當成一個陣列參數傳，佔位符的數量固定，查詢文字也不隨值的數量改變。</p>
<h2 id="sqlx-留給程式的同步工作struct-與查詢的欄位清單">sqlx 留給程式的同步工作：struct 與查詢的欄位清單</h2>
<p>sqlx 接手了逐欄 <code>Scan</code> 與讀取物件的收尾，而 struct 的欄位與每一段查詢列出的欄位是兩份清單，要在兩處同步改：查詢多一欄是執行時的錯誤，struct 多一欄是安靜的零值，兩者都要測試實際跑過才看得到。把這類檢查提前到產生程式碼時（<code>sqlc generate</code>）的是 <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>；只連 PostgreSQL、不需要 <code>*sql.DB</code> 相容性的話，<a href="/blog/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入</a> 的 <code>RowToStructByName</code> 提供同樣依欄名的對映，不必再加一個函式庫；它在 struct 有、查詢結果沒有的欄位上會報錯，sqlx 則讓那個欄位保持零值。</p>
]]></content:encoded></item><item><title>10.4 sqlc：從 SQL 產生型別安全的 Go 程式碼</title><link>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/sqlc/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/sqlc/</guid><description>&lt;p>這篇整理 &lt;a href="https://sqlc.dev/">sqlc&lt;/a> v1.31.1 的用法。範例沿用 &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/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入&lt;/a> 的 pgx v5。在 &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> 的分類裡，sqlc 屬於從 SQL 產生程式碼：SQL 由人手寫在 &lt;code>.sql&lt;/code> 檔裡，sqlc 讀 schema 與這些 SQL，產生呼叫它們的 Go 函式，以及參數與結果的 struct。&lt;/p>
&lt;p>sqlc 是開發時執行的命令列工具，不是程式在執行時匯入的函式庫；執行時的程式碼只依賴它產生出來的檔案與 pgx。產生出來的 &lt;code>store&lt;/code> 套件放在專案的 Go 模組裡，呼叫端以 &lt;code>import &amp;quot;&amp;lt;go.mod 的 module 名稱&amp;gt;/sqlcdemo/store&amp;quot;&lt;/code> 引用。&lt;/p>
&lt;h2 id="設定檔與目錄">設定檔與目錄&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">migrations/ # 專案的 migration 目錄，與 sqlcdemo/ 同一層
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── 001_init.up.sql
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">└── 001_init.down.sql
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">sqlcdemo/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">├── sqlc.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">├── query/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ └── orders.sql # 手寫的查詢
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">└── store/ # sqlc 產生，不手動修改
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├── db.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ├── models.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> └── orders.sql.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&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="nt">sql&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">engine&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;postgresql&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schema&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;../migrations&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 建表的 SQL；指向 migration 目錄&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">queries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 手寫查詢所在的目錄&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gen&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">go&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">package&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;store&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">out&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;store&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 產生的程式碼放哪裡&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sql_package&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;pgx/v5&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 產生的程式碼呼叫 pgx 的原生介面&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不寫 &lt;code>sql_package&lt;/code> 時，產生的是呼叫 &lt;code>database/sql&lt;/code> 的程式碼。要和 GORM 在同一個交易裡執行就選它，見 &lt;a href="https://tarrragon.github.io/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 的組合&lt;/a>〈混用時共用同一個交易〉。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理 <a href="https://sqlc.dev/">sqlc</a> v1.31.1 的用法。範例沿用 <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/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入</a> 的 pgx v5。在 <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> 的分類裡，sqlc 屬於從 SQL 產生程式碼：SQL 由人手寫在 <code>.sql</code> 檔裡，sqlc 讀 schema 與這些 SQL，產生呼叫它們的 Go 函式，以及參數與結果的 struct。</p>
<p>sqlc 是開發時執行的命令列工具，不是程式在執行時匯入的函式庫；執行時的程式碼只依賴它產生出來的檔案與 pgx。產生出來的 <code>store</code> 套件放在專案的 Go 模組裡，呼叫端以 <code>import &quot;&lt;go.mod 的 module 名稱&gt;/sqlcdemo/store&quot;</code> 引用。</p>
<h2 id="設定檔與目錄">設定檔與目錄</h2>





<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">migrations/                 # 專案的 migration 目錄，與 sqlcdemo/ 同一層
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── 001_init.up.sql
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">└── 001_init.down.sql
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">sqlcdemo/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">├── sqlc.yaml
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">├── query/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── orders.sql      # 手寫的查詢
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">└── store/              # sqlc 產生，不手動修改
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── db.go
</span></span><span class="line"><span class="ln">10</span><span class="cl">    ├── models.go
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── orders.sql.go</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">sql</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span>- <span class="nt">engine</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;postgresql&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">schema</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;../migrations&#34;</span><span class="w">   </span><span class="c"># 建表的 SQL；指向 migration 目錄</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">queries</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;query&#34;</span><span class="w">          </span><span class="c"># 手寫查詢所在的目錄</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">gen</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">go</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">package</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;store&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">        </span><span class="nt">out</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;store&#34;</span><span class="w">            </span><span class="c"># 產生的程式碼放哪裡</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">sql_package</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;pgx/v5&#34;</span><span class="w">   </span><span class="c"># 產生的程式碼呼叫 pgx 的原生介面</span></span></span></code></pre></div><p>不寫 <code>sql_package</code> 時，產生的是呼叫 <code>database/sql</code> 的程式碼。要和 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><code>schema</code> 指向專案的 migration 目錄（資料表結構的版本化變更檔，見 <a href="/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">Schema Migration</a>），sqlc 依序讀裡面的檔案、算出最終的資料表結構。它認得幾種 migration 工具的檔名慣例，例如 golang-migrate 的 <code>001_init.up.sql</code> 與 <code>001_init.down.sql</code> 放在同一個目錄時，只讀 <code>up</code> 那一份；把 <code>down</code> 也讀進去的話，<code>DROP TABLE</code> 會讓後面的查詢全部找不到表。schema 的權威因此留在 migration，sqlc 只是讀它。</p>
<h2 id="查詢檔與註記">查詢檔與註記</h2>
<p>每一段查詢上方的註記（query annotation）決定產生的函式名稱與回傳形狀：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- name: ListOrdersByCustomer :many
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">customer_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="n">note</span><span class="p">,</span><span class="w"> </span><span class="n">ordered_at</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">$</span><span class="mi">1</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">id</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- name: GetCustomer :one
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">customers</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="err">$</span><span class="mi">1</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- name: CreateCustomer :one
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">email</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="err">$</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="err">$</span><span class="mi">2</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="n">RETURNING</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="c1">-- name: TotalByCustomer :many
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="k">sum</span><span class="p">(</span><span class="n">o</span><span class="p">.</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">total</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">c</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">id</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">name</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">name</span><span class="p">;</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>註記</th>
          <th>產生的函式回傳</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>:one</code></td>
          <td>一個 struct；查不到時回傳 <code>pgx.ErrNoRows</code></td>
      </tr>
      <tr>
          <td><code>:many</code></td>
          <td>struct 的 slice</td>
      </tr>
      <tr>
          <td><code>:exec</code></td>
          <td>只回傳錯誤</td>
      </tr>
      <tr>
          <td><code>:execrows</code></td>
          <td>影響的列數與錯誤</td>
      </tr>
      <tr>
          <td><code>:copyfrom</code></td>
          <td>用 <a href="/blog/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入</a> 介紹的 <code>COPY</code> 協定批次寫入</td>
      </tr>
  </tbody>
</table>
<p>產生指令在設定檔所在的目錄執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><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">go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
</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="c1"># 安裝成執行檔之後，每次產生只要不到一秒；執行檔放在 $(go env GOPATH)/bin，那個目錄要在 PATH 裡</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1
</span></span><span class="line"><span class="ln">6</span><span class="cl">sqlc generate</span></span></code></pre></div><p>sqlc v1.31.1 要求 Go 1.26 以上來編譯它自己；手上的 Go 版本較舊時，Go 會依 <code>GOTOOLCHAIN</code> 的預設設定自動下載符合的工具鏈，這不影響專案本身用的 Go 版本。版本號寫在指令裡，團隊每個人產生的程式碼才會一致；產生出來的檔案開頭也會記下產生它的 sqlc 版本。</p>
<h2 id="產生出來的程式碼">產生出來的程式碼</h2>
<p><code>ListOrdersByCustomer</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> 讀多列的標準寫法，只是由工具寫出來：</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">func</span> <span class="p">(</span><span class="nx">q</span> <span class="o">*</span><span class="nx">Queries</span><span class="p">)</span> <span class="nf">ListOrdersByCustomer</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">customerID</span> <span class="kt">int64</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Order</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">	<span class="nx">rows</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">q</span><span class="p">.</span><span class="nx">db</span><span class="p">.</span><span class="nf">Query</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">listOrdersByCustomer</span><span class="p">,</span> <span class="nx">customerID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">		<span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</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 class="k">defer</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="kd">var</span> <span class="nx">items</span> <span class="p">[]</span><span class="nx">Order</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="k">for</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">Next</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">		<span class="kd">var</span> <span class="nx">i</span> <span class="nx">Order</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">i</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">i</span><span class="p">.</span><span class="nx">CustomerID</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">i</span><span class="p">.</span><span class="nx">Amount</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">i</span><span class="p">.</span><span class="nx">Note</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">i</span><span class="p">.</span><span class="nx">OrderedAt</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="nx">items</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">items</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">Err</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">		<span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="k">return</span> <span class="nx">items</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>參數與回傳型別依查詢決定：查詢選的欄位剛好是一整張表時，回傳那張表對應的 struct（<code>Order</code>，定義在 <code>models.go</code>）；選了部分欄位或含運算結果時，另外產生一個 <code>XxxRow</code> struct（<code>TotalByCustomer</code> 回傳 <code>[]TotalByCustomerRow</code>，<code>sum(o.amount)</code> 推成 <code>int64</code>）；參數超過一個時產生一個 <code>XxxParams</code> struct（<code>CreateCustomerParams{Name, Email}</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">pool</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">pgxpool</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">dsn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">q</span> <span class="o">:=</span> <span class="nx">store</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">pool</span><span class="p">)</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="nx">orders</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">q</span><span class="p">.</span><span class="nf">ListOrdersByCustomer</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">totals</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">q</span><span class="p">.</span><span class="nf">TotalByCustomer</span><span class="p">(</span><span class="nx">ctx</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">[{Name:佳穎 Total:800} {Name:宗翰 Total:250}] &lt;nil&gt;</span></span></code></pre></div><p><code>store.New</code> 接受的是 <code>DBTX</code> 這個介面（<code>Exec</code>、<code>Query</code>、<code>QueryRow</code> 三個方法），<code>*pgxpool.Pool</code> 與 <code>pgx.Tx</code> 都符合。交易裡執行時用 <code>q.WithTx(tx)</code> 取得一個綁在那個交易上的 <code>*Queries</code>，交易的開始與收尾仍然照 pgx 那一篇的寫法由程式負責。</p>
<h2 id="產生時擋下的錯誤">產生時擋下的錯誤</h2>
<p>sqlc 在產生時解析每一段查詢、對照 schema，欄位或表名寫錯就不產生程式碼。把 <code>GetCustomer</code> 那一行的 <code>name</code> 打成 <code>nmae</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"># package store
</span></span><span class="line"><span class="ln">2</span><span class="cl">query/orders.sql:8:12: column &#34;nmae&#34; does not exist</span></span></code></pre></div><p>同樣的錯字在 sqlx 或 pgx 裡是一個要等到執行那一行才會出現的錯誤。換成 sqlc 之後，schema 改了欄位名稱，重新產生時所有用到舊名稱的查詢一起報錯，連帶呼叫端用到的 struct 欄位也跟著改名，Go 編譯器接著指出每一個要改的呼叫位置。</p>
<h2 id="null-與型別對應">NULL 與型別對應</h2>
<p>pgx v5 的預設對應裡，可以是 NULL 的欄位與 <code>timestamptz</code> 都產生成 pgx 的 <code>pgtype</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">Amount</span>     <span class="kt">int32</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="nx">Note</span>       <span class="nx">pgtype</span><span class="p">.</span><span class="nx">Text</span>        <span class="c1">// 可以是 NULL 的 text</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">	<span class="nx">OrderedAt</span>  <span class="nx">pgtype</span><span class="p">.</span><span class="nx">Timestamptz</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>pgtype</code> 型別讓產生的 struct 依賴 pgx 的套件，放進 domain 型別或 JSON 回應前都要轉換一次。兩個設定改掉這個預設，加在前面 <code>sqlc.yaml</code> 的 <code>gen.go</code> 底下、和 <code>sql_package</code> 同一層：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">gen</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="nt">go</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="nt">emit_pointers_for_null_types</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">   </span><span class="c"># 可以是 NULL 的欄位改成指標，例如 *string</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">overrides</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">      </span>- <span class="nt">db_type</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;timestamptz&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">        </span><span class="nt">go_type</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;time.Time&#34;</span><span class="w">             </span><span class="c"># NOT NULL 的 timestamptz 對應成 time.Time</span></span></span></code></pre></div>




<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">Amount</span>     <span class="kt">int32</span>
</span></span><span class="line"><span class="ln">5</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">6</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">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>依 <code>db_type</code> 寫的 override 預設只套用在 NOT NULL 的欄位；可以是 NULL 的 <code>timestamptz</code> 欄位要另寫一條加上 <code>nullable: true</code> 的 override（例如 <code>go_type: {type: &quot;time.Time&quot;, pointer: true}</code>），否則仍然是 <code>pgtype.Timestamptz</code>。</p>
<p><code>integer</code> 欄位產生的是 <code>int32</code>，因為 PostgreSQL 的 <code>integer</code> 就是 32 位元；要 <code>int64</code> 的話建表時用 <code>bigint</code>，或同樣用 <code>overrides</code> 指定。</p>
<h2 id="選擇性的篩選條件">選擇性的篩選條件</h2>
<p>sqlc 產生的函式裡，SQL 文字在產生時就固定了，「有給最低金額才加這個條件」這類依輸入增減的條件，寫法是把條件寫成「參數是 NULL 時永遠成立」。把下面這段加進 <code>query/orders.sql</code> 再重新產生：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- name: SearchOrders :many
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">$</span><span class="mi">1</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="p">(</span><span class="n">sqlc</span><span class="p">.</span><span class="n">narg</span><span class="p">(</span><span class="n">min_amount</span><span class="p">)::</span><span class="nb">int</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">sqlc</span><span class="p">.</span><span class="n">narg</span><span class="p">(</span><span class="n">min_amount</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">id</span><span class="p">;</span></span></span></code></pre></div><p><code>sqlc.narg</code> 宣告一個可以是 NULL 的具名參數。在開了 <code>emit_pointers_for_null_types</code> 的設定下，產生的參數 struct 裡它是指標（預設是 <code>pgtype.Int4</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">SearchOrdersParams</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">CustomerID</span> <span class="kt">int64</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="nx">MinAmount</span>  <span class="o">*</span><span class="kt">int32</span> <span class="c1">// nil 代表不篩選</span>
</span></span><span class="line"><span class="ln">4</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">MinAmount = nil  → [{101 300} {102 500}]
</span></span><span class="line"><span class="ln">2</span><span class="cl">MinAmount = 400  → [{102 500}]</span></span></code></pre></div><p>這個寫法在條件只有兩三個時清楚好讀。條件多到十幾個、排序欄位也由使用者決定時，<code>OR</code> 堆疊的查詢讀起來吃力，資料庫為它選的執行計畫也可能不是最好的；那類查詢交給查詢建構器（Query Builder）或 ORM 的條件串接，和 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>）。</p>
<h2 id="sqlc-帶進來的工作">sqlc 帶進來的工作</h2>
<p>sqlc 把 SQL 的錯誤提前到產生時，代價是多了一個產生步驟：</p>
<ul>
<li>schema 或查詢改了要重新產生，產生的檔案通常提交進版本控制，審查時看得到產生結果的差異。</li>
<li>CI 裡跑一次產生，再確認工作目錄沒有差異，可以擋下「改了 SQL 卻忘了重新產生」的提交。</li>
<li>產生的程式碼不手動修改；要改行為就改 SQL、設定檔，或在產生的函式外面再包一層。</li>
</ul>
<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>。</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>10.7 Go 專案的資料庫存取工具選型：GORM、sqlc 與 pgx 的組合</title><link>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/choosing-data-access/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/go/basic/10-database-access/choosing-data-access/</guid><description>&lt;p>這篇把 &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 的工具上。那一篇給的是跨語言的軸（schema 的權威、查詢的形狀、框架的整合程度、審查要看到什麼）；Go 的選型多出兩件事要決定：驅動入口用哪一種，以及沒有主導框架時主力工具由誰來定；主力工具的選擇另外加上團隊背景這一軸。本模組前面各篇示範的行為是這篇的材料：&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;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/programming/go/basic/10-database-access/sqlx/" data-link-title="10.3 sqlx：手寫 SQL 加上 struct 對映" data-link-desc="sqlx 在 database/sql 之上補的 struct 對映：Get 與 Select 依 db tag 把查詢結果放進 struct、查詢結果的欄位多於 struct 時的錯誤（missing destination name）與 SELECT * 的風險、具名參數與 IN 的展開，以及 SQL 仍是字串所留下的檢查缺口">10.3 sqlx：手寫 SQL 加上 struct 對映&lt;/a>、&lt;a href="https://tarrragon.github.io/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 程式碼&lt;/a>、&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>、&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>。&lt;/p>
&lt;h2 id="go-資料庫存取工具的生態現況">Go 資料庫存取工具的生態現況&lt;/h2>
&lt;p>Go 沒有一個像 Laravel 或 Django 那樣把 ORM、驗證、後台整合在一起的主導框架，資料庫存取是各專案自己挑的。從 GitHub 的星數（2026 年 10 月查詢）看各工具受到的關注：GORM 約四萬，sqlc、sqlx、ent 各約一萬七千到一萬八千多，pgx 約一萬四千。星數量的是關注度，不是採用率；ent 是另一種 ORM，schema 寫成 Go 程式碼、再產生型別安全的查詢 API，同時帶有 ORM 與產生程式碼的性質，本模組不示範。pgx 也是 GORM 的 PostgreSQL 方言套件底下用的驅動，所以用 GORM 的專案同時也在用 pgx。&lt;/p>
&lt;p>本模組示範兩種常見的做法：用 GORM 這個關注度最高的 ORM，或用 sqlc 加 pgx 這組「SQL 手寫、工具產生程式碼」的組合。團隊選哪一種，比較的是驅動入口、查詢的形狀、審查要看到什麼、團隊背景與錯誤被發現的時間點。&lt;/p>
&lt;h2 id="驅動入口pgx-原生介面還是-databasesql">驅動入口：pgx 原生介面還是 database/sql&lt;/h2>
&lt;p>這個決定先做，因為它決定後面哪些函式庫用得上：&lt;/p></description><content:encoded><![CDATA[<p>這篇把 <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 的工具上。那一篇給的是跨語言的軸（schema 的權威、查詢的形狀、框架的整合程度、審查要看到什麼）；Go 的選型多出兩件事要決定：驅動入口用哪一種，以及沒有主導框架時主力工具由誰來定；主力工具的選擇另外加上團隊背景這一軸。本模組前面各篇示範的行為是這篇的材料：<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>、<a href="/blog/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入</a>、<a href="/blog/programming/go/basic/10-database-access/sqlx/" data-link-title="10.3 sqlx：手寫 SQL 加上 struct 對映" data-link-desc="sqlx 在 database/sql 之上補的 struct 對映：Get 與 Select 依 db tag 把查詢結果放進 struct、查詢結果的欄位多於 struct 時的錯誤（missing destination name）與 SELECT * 的風險、具名參數與 IN 的展開，以及 SQL 仍是字串所留下的檢查缺口">10.3 sqlx：手寫 SQL 加上 struct 對映</a>、<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/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>、<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>
<h2 id="go-資料庫存取工具的生態現況">Go 資料庫存取工具的生態現況</h2>
<p>Go 沒有一個像 Laravel 或 Django 那樣把 ORM、驗證、後台整合在一起的主導框架，資料庫存取是各專案自己挑的。從 GitHub 的星數（2026 年 10 月查詢）看各工具受到的關注：GORM 約四萬，sqlc、sqlx、ent 各約一萬七千到一萬八千多，pgx 約一萬四千。星數量的是關注度，不是採用率；ent 是另一種 ORM，schema 寫成 Go 程式碼、再產生型別安全的查詢 API，同時帶有 ORM 與產生程式碼的性質，本模組不示範。pgx 也是 GORM 的 PostgreSQL 方言套件底下用的驅動，所以用 GORM 的專案同時也在用 pgx。</p>
<p>本模組示範兩種常見的做法：用 GORM 這個關注度最高的 ORM，或用 sqlc 加 pgx 這組「SQL 手寫、工具產生程式碼」的組合。團隊選哪一種，比較的是驅動入口、查詢的形狀、審查要看到什麼、團隊背景與錯誤被發現的時間點。</p>
<h2 id="驅動入口pgx-原生介面還是-databasesql">驅動入口：pgx 原生介面還是 database/sql</h2>
<p>這個決定先做，因為它決定後面哪些函式庫用得上：</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>選擇</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>會用到 GORM、sqlx，而且要和主程式在同一個交易裡寫入</td>
          <td><code>database/sql</code>（驅動用 pgx 的 <code>stdlib</code>）</td>
          <td>這些函式庫的交易是 <code>*sql.Tx</code>，和 <code>pgx.Tx</code> 不相容</td>
      </tr>
      <tr>
          <td>只連 PostgreSQL，主力是 sqlc 或直接寫 pgx</td>
          <td>pgx 原生介面（<code>pgxpool</code>）</td>
          <td>少一層轉換，<code>COPY</code>、陣列、<code>jsonb</code> 這些 PostgreSQL 功能直接可用（例如每晚用 <code>CopyFrom</code> 匯入數十萬筆對帳資料、把標籤存成 <code>text[]</code> 用 <code>= ANY($1)</code> 查詢）</td>
      </tr>
      <tr>
          <td>程式可能換資料庫，或要同時支援 MySQL 與 PostgreSQL</td>
          <td><code>database/sql</code></td>
          <td>換驅動時 Go 的呼叫方式（<code>QueryContext</code>、<code>Scan</code>、<code>*sql.Tx</code>）不變；SQL 字串裡的佔位符（<code>$1</code> 對 <code>?</code>）與方言（<code>RETURNING</code>、<code>ANY</code>）仍要逐段改。例如賣給客戶自行部署的軟體，要接客戶手上現有的資料庫</td>
      </tr>
  </tbody>
</table>
<p>只要共用連線池、不必共用交易時，主程式可以用 <code>pgxpool</code>，再用 <code>stdlib.OpenDBFromPool(pool)</code> 做出一個共用同一個池的 <code>*sql.DB</code>，交給 GORM（<code>postgres.New(postgres.Config{Conn: sqlDB})</code>）或 sqlx；實測兩者共用之後池裡只開一條連線。migration 工具多半在部署步驟或啟動時另開一個 <code>*sql.DB</code>、跑完就關，不影響主程式選哪個入口。應用程式和 PostgreSQL 之間有一層交易模式的 PgBouncer 時，兩個入口都要另外處理：pgx 不論走哪個入口，預設都會在連線上快取預先解析的查詢，實測會出現 <code>prepared statement &quot;stmtcache_…&quot; already exists (SQLSTATE 42P05)</code>，處理方式見 <a href="/blog/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入</a>〈pgx 的語句快取與 PgBouncer 交易模式〉。兩個入口可以在同一個程式裡並存；分別用 <code>sql.Open</code> 與 <code>pgxpool.New</code> 建立時各有一個連線池（連線數要合起來算），而不論池是否共用，交易都不能互通：<code>pgx.Tx</code> 與 <code>*sql.Tx</code> 是兩個不相容的型別，一段要在同一個交易裡完成的寫入，必須全部走同一個入口。</p>
<h2 id="主力工具gorm-還是-sqlc">主力工具：GORM 還是 sqlc</h2>
<p><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>
<table>
  <thead>
      <tr>
          <th>讀者要決定的事</th>
          <th>條件</th>
          <th>傾向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>查詢的形狀</td>
          <td>多數是單表或一層關聯的增刪查改，後台管理頁面多</td>
          <td>GORM</td>
      </tr>
      <tr>
          <td></td>
          <td>報表、多表聚合、視窗函數佔了查詢的一大部分</td>
          <td>sqlc</td>
      </tr>
      <tr>
          <td></td>
          <td>列表頁的篩選條件多、依使用者輸入動態組合</td>
          <td>GORM 的條件串接，或另加 Query Builder</td>
      </tr>
      <tr>
          <td>審查要看到什麼</td>
          <td>審查者要在 diff 裡直接看到每一段 SQL</td>
          <td>sqlc</td>
      </tr>
      <tr>
          <td></td>
          <td>審查看方法呼叫，SQL 靠開發時 GORM 的 SQL 記錄確認</td>
          <td>GORM</td>
      </tr>
      <tr>
          <td>團隊的背景</td>
          <td>成員從 Laravel、Rails、Django 轉過來，習慣 ORM 的寫法</td>
          <td>GORM</td>
      </tr>
      <tr>
          <td></td>
          <td>成員熟 SQL，查詢常要調執行計畫</td>
          <td>sqlc</td>
      </tr>
      <tr>
          <td>錯誤在什麼時候被發現</td>
          <td>希望欄位改名、查詢寫錯在產生程式碼時（<code>sqlc generate</code>）就失敗</td>
          <td>sqlc</td>
      </tr>
      <tr>
          <td></td>
          <td>接受由整合測試在執行時抓出來</td>
          <td>GORM</td>
      </tr>
  </tbody>
</table>
<p>團隊背景這一軸的理由在於改 SQL 的位置：用 sqlc 時，送出的 SQL 就是 <code>.sql</code> 檔裡那一段，調執行計畫就是改那一段；用 GORM 時要先把那個查詢改寫成 <code>Raw</code>。熟 SQL 的團隊在前一種位置上工作，習慣 ORM 的團隊在後一種。</p>
<p>表裡沒有 sqlx，因為它和 sqlc 處在同一側（SQL 手寫、審查看得到），差別在檢查的時間點與步驟：sqlx 不需要產生步驟，欄位錯誤到執行時才出現；只連 PostgreSQL 時，pgx 原生介面的 <code>RowToStructByName</code> 提供同樣的對映而不必多一個函式庫。所以 sqlx 的落點是：要走 <code>database/sql</code>（例如和 GORM 共用連線池，或要支援 PostgreSQL 以外的資料庫）、想手寫 SQL、又不想加產生步驟。</p>
<p>不同的軸給出相反的傾向時（例如團隊的背景傾向 GORM、查詢的形狀傾向 sqlc），不必二選一，看下面的混用組合。判錯的代價各軸不同：後台列表有十幾個篩選條件卻全交給 sqlc，每加一個條件就多一組 <code>sqlc.narg ... IS NULL OR</code>，查詢越來越長、執行計畫也可能變差；以報表為主的服務選了 GORM，多數查詢最後寫進 <code>Raw</code>，SQL 回到字串，產生時的檢查與 ORM 的對映兩邊都沒拿到；習慣 ORM 的團隊被要求全用 sqlc，初期每一段增刪查改都要寫 SQL 加註記，交付速度明顯變慢。</p>
<h2 id="go-專案裡-schema-的權威migration-檔gorm-model-與-atlas">Go 專案裡 schema 的權威：migration 檔、GORM model 與 Atlas</h2>
<p><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> 把「schema 的權威放哪裡」列為選型軸：schema 由 model 定義時傾向 ORM，由 migration 的 SQL 定義時傾向從 SQL 產生程式碼。GORM 本身的 <code>AutoMigrate</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>），所以用 GORM 的專案有兩種做法。一種是同樣用版本化 migration 工具、由手寫的 migration 檔當 schema 的權威，這時這個軸分不開 GORM 與 sqlc。另一種是用 Atlas 的 GORM provider（<code>ariga/atlas-provider-gorm</code>）從 model 算出版本化的 migration 檔，model 仍然是 schema 的權威，產生出來的每一份檔案照樣要審查；這時軸又分得開：model 是權威選 GORM 加 Atlas，migration 的 SQL 是權威選 sqlc。golang-migrate 與 goose 都是依序執行手寫的 SQL 檔；Atlas 另外能比對資料庫現況與期望的 schema、產生差異的 migration，三者在這個用途上擇一即可。</p>
<p>以 migration 檔當權威時，用 GORM 與用 sqlc 的差別落在「model 怎麼跟上 schema」：</p>
<ul>
<li><strong>sqlc</strong>：讀 migration 目錄產生 struct，schema 改了重新產生，對不上的地方在產生或編譯時報錯。</li>
<li><strong>GORM</strong>：model 是手寫的 struct，schema 改了要手動同步 model，對不上的地方在執行時才出現，而且不一定報錯：欄位從資料表移除之後，讀取送出的是 <code>SELECT *</code>，不會報錯，那個欄位保持零值，寫入時才報錯；資料表多出來的欄位則一律被忽略。</li>
</ul>
<p>用 GORM 的專案通常靠整合測試補上「model 與 schema 不一致要到執行時才發現」這個缺口：在測試環境依序套用全部 migration，再讓每個 model 實際讀寫一次。</p>
<p>以上都假設 schema 由這個 Go 服務擁有。資料庫被多個服務共用、schema 由另一個服務（例如 Laravel 的 migration）管理時，Go 這一側不跑 migration、也不用 <code>AutoMigrate</code>：sqlc 的 <code>schema</code> 改指向 <code>pg_dump --schema-only</code> 匯出的檔案，GORM 的 model 則靠整合測試對著擁有方建出的 schema 驗證；約束名稱也要照擁有方的命名判讀（Laravel 預設把唯一約束命名成 <code>customers_email_unique</code>）。</p>
<h2 id="常見的混用組合">常見的混用組合</h2>
<p>多數 Go 專案不只用一個工具。三種組合最常見：</p>
<table>
  <thead>
      <tr>
          <th>組合</th>
          <th>分工</th>
          <th>要留意的地方</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GORM 加 <code>Raw</code></td>
          <td>增刪查改用 GORM，報表用 <code>db.Raw(sql).Scan(&amp;rows)</code> 直接寫 SQL</td>
          <td><code>Raw</code> 裡的 SQL 是字串，錯誤到執行時才出現，和 sqlx 一樣；model 用軟刪除時，<code>Raw</code> 不會自動加 <code>deleted_at IS NULL</code>，要自己寫（見 10.6〈不經過軟刪除條件的路徑〉）</td>
      </tr>
      <tr>
          <td>GORM 加 sqlc</td>
          <td>增刪查改用 GORM，固定而複雜的查詢用 sqlc</td>
          <td>sqlc 要產生 <code>database/sql</code> 版本的程式碼，才能和 GORM 共用交易（見〈混用時共用同一個交易〉）；GORM 的 model 用軟刪除時，sqlc 的查詢要自己寫 <code>deleted_at IS NULL</code></td>
      </tr>
      <tr>
          <td>sqlc 加 pgx 加 Query Builder</td>
          <td>多數查詢用 sqlc，條件多變的搜尋用 squirrel 這類 Query Builder 組出 SQL 再交給 pgx 執行</td>
          <td>Query Builder 組出的 SQL 沒有產生時的檢查，要有測試涵蓋；它在審查時看不到最終的 SQL，要求每段 SQL 在審查時可見的服務不適合；squirrel 預設的佔位符是 <code>?</code>，接 PostgreSQL 要設 <code>PlaceholderFormat(sq.Dollar)</code></td>
      </tr>
  </tbody>
</table>
<h2 id="混用時共用同一個交易">混用時共用同一個交易</h2>
<p>GORM 與 sqlc 寫在同一個交易裡的前提，是兩者都走 <code>database/sql</code>。sqlc 的設定檔不寫 <code>sql_package</code>（或寫 <code>database/sql</code>）時，產生的 <code>DBTX</code> 介面用的是 <code>ExecContext</code>、<code>QueryContext</code> 這組 <code>database/sql</code> 的方法，<code>*sql.Tx</code> 符合它。GORM 的交易函式裡，<code>tx.Statement.ConnPool</code> 就是那個 <code>*sql.Tx</code>（<code>gorm.Config</code> 開了 <code>PrepareStmt</code> 時不是，下面的型別斷言會 panic）。sqlc 這一側的查詢檔裡有這一段：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- name: AddNote :exec
</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">orders</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">note</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">$</span><span class="mi">2</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="err">$</span><span class="mi">1</span><span class="p">;</span></span></span></code></pre></div><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">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">WithContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">).</span><span class="nf">Transaction</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">tx</span> <span class="o">*</span><span class="nx">gorm</span><span class="p">.</span><span class="nx">DB</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">Model</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">Order</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="mi">103</span><span class="p">}).</span><span class="nf">Update</span><span class="p">(</span><span class="s">&#34;amount&#34;</span><span class="p">,</span> <span class="mi">999</span><span class="p">).</span><span class="nx">Error</span><span class="p">;</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">		<span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="nx">sqlTx</span> <span class="o">:=</span> <span class="nx">tx</span><span class="p">.</span><span class="nx">Statement</span><span class="p">.</span><span class="nx">ConnPool</span><span class="p">.(</span><span class="o">*</span><span class="nx">sql</span><span class="p">.</span><span class="nx">Tx</span><span class="p">)</span> <span class="c1">// GORM 這個交易底下的 *sql.Tx</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="nx">q</span> <span class="o">:=</span> <span class="nx">store</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">sqlTx</span><span class="p">)</span>                     <span class="c1">// sqlc 產生的程式碼跑在同一個交易裡</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">q</span><span class="p">.</span><span class="nf">AddNote</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">store</span><span class="p">.</span><span class="nx">AddNoteParams</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">		<span class="nx">ID</span><span class="p">:</span>   <span class="mi">103</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="nx">Note</span><span class="p">:</span> <span class="nx">sql</span><span class="p">.</span><span class="nx">NullString</span><span class="p">{</span><span class="nx">String</span><span class="p">:</span> <span class="s">&#34;同一個交易&#34;</span><span class="p">,</span> <span class="nx">Valid</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="p">});</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">	<span class="k">return</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;撤銷&#34;</span><span class="p">)</span> <span class="c1">// 回傳錯誤：GORM 與 sqlc 的兩筆修改一起撤銷</span>
</span></span><span class="line"><span class="ln">15</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">ConnPool is *sql.Tx: true
</span></span><span class="line"><span class="ln">2</span><span class="cl">tx err: 撤銷 amount: 250 note nil: true</span></span></code></pre></div><p>金額仍是原來的 250、備註仍是 NULL，兩邊的修改都被撤銷了。sqlc 改成產生 pgx 原生版本（<code>sql_package: &quot;pgx/v5&quot;</code>）的話，它要的是 <code>pgx.Tx</code>，和 GORM 的 <code>*sql.Tx</code> 接不起來；這時有兩個做法：GORM 與 sqlc 各自開交易，接受它們不在同一個交易裡；或把需要一起成功的寫入全部交給 GORM，或全部交給 sqlc。</p>
<h2 id="讀寫分離gorm-的-dbresolver-與程式自己持有兩個池">讀寫分離：GORM 的 dbresolver 與程式自己持有兩個池</h2>
<p>讀取量大到要把查詢分給唯讀副本（replica）時，用 GORM 的專案可以用 GORM 官方維護的 dbresolver 外掛；主力是 sqlc 或 pgx 時沒有這樣的外掛，由程式自己持有兩個池。dbresolver 的路由規則在外掛裡，自己持有兩個池時寫在程式碼上，語言無關的比較見 <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>dbresolver 註冊之後，程式照常呼叫 GORM，外掛依查詢的種類選連線。<code>db</code> 原本用主庫的連線字串開啟；dbresolver 的設定裡另有一個 <code>Sources</code> 欄位可以列主庫，沒有列時主庫就是 <code>db</code> 本身的連線；<code>replicaDSN</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">Use</span><span class="p">(</span><span class="nx">dbresolver</span><span class="p">.</span><span class="nf">Register</span><span class="p">(</span><span class="nx">dbresolver</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">Replicas</span><span class="p">:</span> <span class="p">[]</span><span class="nx">gorm</span><span class="p">.</span><span class="nx">Dialector</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">replicaDSN</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>go get gorm.io/plugin/dbresolver@v1.6.2</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">c</span> <span class="nx">Customer</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">var</span> <span class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">n</span> <span class="o">:=</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;new@example.com&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><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 class="c1">// 一般查詢</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">Clauses</span><span class="p">(</span><span class="nx">dbresolver</span><span class="p">.</span><span class="nx">Write</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 class="c1">// 指定主庫</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">Create</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">n</span><span class="p">);</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="nx">n</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>              <span class="c1">// 剛建立就讀</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Transaction</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">tx</span> <span class="o">*</span><span class="nx">gorm</span><span class="p">.</span><span class="nx">DB</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span> <span class="nx">tx</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 class="k">return</span> <span class="kc">nil</span> <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</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 WHERE id = 1&#34;</span><span class="p">).</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">name</span><span class="p">)</span>
</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">Raw</span><span class="p">(</span><span class="s">&#34;WITH x AS (SELECT 1) SELECT name FROM customers WHERE id = 1&#34;</span><span class="p">).</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">name</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">First                                    &#34;佳穎（副本）&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">Clauses(dbresolver.Write).First          &#34;佳穎&#34;
</span></span><span class="line"><span class="ln">3</span><span class="cl">Create then First(new id)                id=4 err=record not found
</span></span><span class="line"><span class="ln">4</span><span class="cl">First inside Transaction                 &#34;佳穎&#34;
</span></span><span class="line"><span class="ln">5</span><span class="cl">Raw SELECT                               &#34;佳穎（副本）&#34;
</span></span><span class="line"><span class="ln">6</span><span class="cl">Raw WITH ... SELECT                      &#34;佳穎&#34;</span></span></code></pre></div><p>一般查詢送副本、寫入與交易裡的查詢送主庫。剛建立的顧客馬上用 <code>First</code> 讀，查詢送到副本而查不到；真實的副本在複製追上之後就讀得到，所以這種錯誤只在寫入後立刻讀、而且複製剛好落後的時候出現。同一個請求裡寫完就讀的查詢要寫 <code>Clauses(dbresolver.Write)</code>，或整段放進交易；寫完之後由下一個請求讀的路徑（建立後導向詳情頁、改完設定馬上重新載入），dbresolver 不知道上一個請求寫過，要程式自己記住這個使用者剛寫過（例如在 session 記下寫入時間，幾秒內的讀取都加 <code>Clauses(dbresolver.Write)</code>）。<code>Raw</code> 的路由看 SQL 文字：外掛的原始碼只在 SQL 以 <code>SELECT</code> 開頭、結尾不是 <code>FOR UPDATE</code> 時送副本，所以 <code>WITH</code> 開頭的查詢送主庫。</p>
<p>程式自己持有兩個池時，這些規則都寫在程式碼上：sqlc 產生的 <code>New</code> 收一個連線介面（<code>DBTX</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">primary</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">pgxpool</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">primaryDSN</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">replica</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">pgxpool</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">replicaDSN</span><span class="p">)</span> <span class="c1">// 副本</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="nx">orders</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">store</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">replica</span><span class="p">).</span><span class="nf">ListOrdersByCustomer</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> <span class="c1">// 可以接受落後的訂單列表</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">c</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">store</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">primary</span><span class="p">).</span><span class="nf">GetCustomer</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>               <span class="c1">// 剛寫完就讀的那一筆</span></span></span></code></pre></div><p><code>store</code> 是 <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> 以 <code>sql_package: &quot;pgx/v5&quot;</code> 產生的套件，<code>ListOrdersByCustomer</code> 與 <code>GetCustomer</code> 是那一篇定義的查詢。每一個查詢送到哪裡都在呼叫的那一行看得到，代價是每個呼叫端都要做這個選擇。主庫與副本各有一個池，連線數要分開算上限；dbresolver 的情形也一樣，<code>db.DB()</code> 只回傳主庫的 <code>*sql.DB</code>，副本的池要用 dbresolver 的 <code>SetMaxOpenConns</code> 這組方法設定。這組方法對主庫與每一個副本設同一個值（設定裡沒有列 <code>Sources</code> 時主庫也包含在內），主庫要另一個上限時，在呼叫 dbresolver 的 <code>SetMaxOpenConns</code> 之後，用 <code>db.DB()</code> 取得的 <code>*sql.DB</code> 重設。</p>
<h2 id="選型結果裡要統一的項目連線池例外路徑migration-工具錯誤值null交易傳遞唯讀副本與記錄">選型結果裡要統一的項目：連線池、例外路徑、migration 工具、錯誤值、NULL、交易傳遞、唯讀副本與記錄</h2>
<p>選型的結果不只是一個工具名稱，因為同一個工具兩個開發者各自決定細節時，會在下面這幾處分岔：</p>
<ul>
<li><strong>連線池由哪一段程式建立、上限多少</strong>：兩處各建一個池，連線數加倍、各自的上限也就失效（池的設定見 <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>）。</li>
<li><strong>主力工具與例外</strong>：一人把報表寫成 GORM 的 <code>Raw</code>、另一人寫成 sqlc，同一類查詢有兩種檢查時間點，例外要寫成規則（例如「報表類查詢放 <code>query/</code> 用 sqlc」）。</li>
<li><strong>schema 由哪個 migration 工具管理</strong>：一人用 <code>AutoMigrate</code>、另一人寫 migration 檔，schema 會漂移，改名還會留下舊欄（見 <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 在正式環境的限制〉）。</li>
<li><strong>唯一約束的錯誤怎麼判讀</strong>：一人讀錯誤代碼、另一人讀約束名稱，回報給使用者的訊息就不一致（見 <a href="/blog/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入</a>〈錯誤：*pgconn.PgError〉）。</li>
<li><strong>前面有沒有交易模式的連線池代理，以及驅動的查詢執行模式</strong>：一人在本機直連 PostgreSQL、另一人在正式環境經過 PgBouncer，預先解析查詢的錯誤只在經過 PgBouncer 的正式環境出現（見 <a href="/blog/programming/go/basic/10-database-access/pgx/" data-link-title="10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入" data-link-desc="pgx 當 database/sql 驅動與用原生介面的差別、pgxpool 連線池、依欄名把查詢結果對映進 struct、從 *pgconn.PgError 讀出約束名稱、交易輔助函式、CopyFrom 大量寫入，以及語句快取與連線池代理的關係">10.2 pgx：PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入</a>）。</li>
<li><strong>主力是 GORM 時，開發與測試環境的兩種記錄</strong>：GORM 的 SQL 記錄看得到送出的語句，零值更新被略過時那裡會少一行 <code>UPDATE</code>；預設交易的 <code>begin</code> 與 <code>commit</code> 只在資料庫的語句記錄（<code>log_statement</code>）裡看得到。</li>
<li><strong>NULL 的表示</strong>：<code>sql.NullString</code>、<code>*string</code> 與 sqlc 預設的 <code>pgtype.Text</code> 混在同一個專案裡，轉成 domain 型別的程式就要寫三套（見 <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>〈Scan 與 NULL〉）。</li>
<li><strong>「找不到」的錯誤值</strong>：<code>sql.ErrNoRows</code>、<code>pgx.ErrNoRows</code>、<code>gorm.ErrRecordNotFound</code>，以及 GORM 的 <code>Find</code> 不回錯誤；混用的專案三種都會出現，repository 要在邊界把它們轉成同一個錯誤。</li>
<li><strong>交易物件怎麼傳進各個 repository</strong>：一人用參數傳 <code>*sql.Tx</code>、另一人放進 <code>context</code>，混用時就接不起來（傳遞方式見 <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>〈Transaction 傳遞〉，誤用時的徵兆見 <a href="/blog/backend/knowledge-cards/transaction-propagation/" data-link-title="Transaction Propagation（交易傳遞）" data-link-desc="多個 repository 的寫入要在同一個交易裡完成，或一段程式在交易失敗後仍留下部分寫入、交易裡的某個查詢卡住直到逾時時，查交易物件怎麼傳到每一個資料存取呼叫">Transaction Propagation</a>）。</li>
<li><strong>要不要用 <code>gorm.Model</code> 的軟刪除、<code>Save</code> 用在哪裡</strong>：一人用軟刪除、另一人寫的報表 SQL 不加 <code>deleted_at IS NULL</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>）。</li>
<li><strong>有唯讀副本時，哪些路徑強制讀主庫</strong>：寫入後立刻讀的路徑一人加了 <code>Clauses(dbresolver.Write)</code>、另一人沒加，同一類畫面時好時壞，只在複製落後時出錯（見〈讀寫分離：GORM 的 dbresolver 與程式自己持有兩個池〉）。</li>
<li><strong><code>gorm.Config</code> 的旗標由同一處設定</strong>：<code>PrepareStmt</code>、<code>SkipDefaultTransaction</code>、<code>TranslateError</code> 都會改變行為；開了 <code>PrepareStmt</code>，〈混用時共用同一個交易〉的 <code>ConnPool.(*sql.Tx)</code> 型別斷言會 panic。</li>
</ul>
<p>這幾條決定了 repository 背後的 adapter 長什麼樣；adapter 對上層暴露的 port 怎麼切，見 <a href="/blog/programming/go/basic/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">6.6 如何新增 repository port</a>。</p>
]]></content:encoded></item></channel></rss>