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