<?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>Database-Sql on Tarragon</title><link>https://tarrragon.github.io/blog/tags/database-sql/</link><description>Recent content in Database-Sql on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 06 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/database-sql/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></channel></rss>