Golang 資料庫操作:交易(Transactions)與預備陳述式(Prepared Statements)
簡介
在企業級應用中,資料完整性與效能往往是最重要的兩大指標。
當多筆資料必須同時寫入、更新或刪除時,若其中任何一步失敗,都可能導致資料不一致;此時 交易(Transaction) 就能提供「全有或全無」的保證。
另一方面,頻繁執行相同結構的 SQL 語句會產生大量的解析與編譯成本,甚至可能造成 SQL 注入的安全風險。使用 預備陳述式(Prepared Statement) 不僅能減少 CPU 開銷,還能讓參數自動做安全轉義。
本篇文章將以 Go 語言 為例,說明如何正確地使用交易與預備陳述式,並提供實務上常見的陷阱與最佳實踐,讓讀者從 初學者 逐步晉升為 中級開發者。
核心概念
1. 交易(Transaction)是什麼?
- 原子性(Atomicity):交易內的所有 SQL 必須全部成功,否則全部回滾。
- 一致性(Consistency):交易結束後,資料庫必須處於合法狀態。
- 隔離性(Isolation):不同交易之間不會互相干擾,常見的隔離等級有
Read Uncommitted、Read Committed、Repeatable Read、Serializable。 - 持久性(Durability):一旦交易提交,資料變更會永久寫入磁碟。
在 Go 中,database/sql 套件提供了 Begin, Commit, Rollback 三個方法來操作交易。
2. 預備陳述式(Prepared Statement)是什麼?
預備陳述式在第一次執行時會先將 SQL 文字送到資料庫編譯,之後只需要傳入參數即可執行。好處包括:
- 效能提升:重複使用同一個語句時,避免每次都重新解析。
- 安全性:參數會自動做適當的轉義,防止 SQL Injection。
- 程式可讀性:SQL 與程式碼分離,易於維護。
在 Go 中,db.Prepare(或 tx.Prepare)會回傳一個 *sql.Stmt,之後可透過 Exec、Query、QueryRow 使用。
程式碼範例
以下範例皆使用標準庫 database/sql,假設已經匯入 MySQL 驅動 github.com/go-sql-driver/mysql。
1️⃣ 基本交易範例:插入兩筆資料,失敗則回滾
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
// 建立 DB 連線(請自行替換 DSN)
db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/testdb")
if err != nil {
log.Fatalf("Open DB error: %v", err)
}
defer db.Close()
// 開始交易
tx, err := db.Begin()
if err != nil {
log.Fatalf("Begin transaction error: %v", err)
}
// 若後續發生錯誤,務必回滾
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p) // 重新拋出 panic
} else if err != nil {
_ = tx.Rollback()
fmt.Println("Transaction rolled back due to error:", err)
}
}()
// 第一次 INSERT
_, err = tx.Exec(`INSERT INTO accounts (name, balance) VALUES (?, ?)`, "Alice", 1000)
if err != nil {
return // defer 會自動 rollback
}
// 第二次 INSERT(故意寫錯欄位名稱,觸發錯誤)
_, err = tx.Exec(`INSERT INTO accounts (name, balanc) VALUES (?, ?)`, "Bob", 500)
if err != nil {
return // 觸發 rollback
}
// 若無錯誤,提交交易
err = tx.Commit()
if err != nil {
log.Fatalf("Commit error: %v", err)
}
fmt.Println("Transaction committed successfully")
}
重點:使用
defer包裝Rollback,確保任何路徑(包括panic)都能正確回滾。
2️⃣ 使用 context 控制交易逾時
package main
import (
"context"
"database/sql"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
)
func transfer(db *sql.DB, from, to string, amount int) error {
// 設定 3 秒的逾時時間
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil) // nil 代表使用預設隔離等級
if err != nil {
return err
}
defer tx.Rollback() // 若後續出錯自動回滾
// 減少來源帳戶
_, err = tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance - ? WHERE name = ? AND balance >= ?`,
amount, from, amount)
if err != nil {
return err
}
// 增加目標帳戶
_, err = tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance + ? WHERE name = ?`,
amount, to)
if err != nil {
return err
}
// 提交
return tx.Commit()
}
技巧:
ExecContext、QueryContext等方法會自動監聽ctx,在逾時或取消時即中止執行,避免長時間卡住。
3️⃣ 基本預備陳述式:批次插入
stmt, err := db.Prepare(`INSERT INTO logs (level, message, created_at) VALUES (?, ?, NOW())`)
if err != nil {
log.Fatalf("Prepare error: %v", err)
}
defer stmt.Close()
logs := []struct {
level string
message string
}{
{"INFO", "系統啟動"},
{"WARN", "磁碟空間不足"},
{"ERROR", "無法連接外部 API"},
}
for _, l := range logs {
_, err = stmt.Exec(l.level, l.message)
if err != nil {
log.Printf("Insert log failed: %v", err)
}
}
說明:
Prepare只在第一次呼叫時向資料庫送出編譯請求,之後的Exec只傳遞參數,效能提升明顯。
4️⃣ 交易內使用預備陳述式(安全又高效)
func bulkInsertUsers(db *sql.DB, users []User) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO users (username, email, created_at) VALUES (?, ?, NOW())`)
if err != nil {
return err
}
defer stmt.Close()
for _, u := range users {
if _, err = stmt.Exec(u.Username, u.Email); err != nil {
return err // 任何錯誤都會觸發 defer rollback
}
}
return tx.Commit()
}
關鍵:在交易 (
tx) 中建立的Stmt只能在同一個交易內使用,這樣可以保證 原子性 與 效能 同時達成。
5️⃣ 取得自動產生的 ID(LastInsertId)與錯誤處理
result, err := db.Exec(`INSERT INTO products (name, price) VALUES (?, ?)`, "Gadget", 1999)
if err != nil {
log.Fatalf("Insert error: %v", err)
}
id, err := result.LastInsertId()
if err != nil {
log.Fatalf("LastInsertId error: %v", err)
}
fmt.Printf("新產品的 ID 為 %d\n", id)
提示:
LastInsertId只在支援自增欄位的資料庫(如 MySQL、PostgreSQL)有效;若使用 SQLite,請改用SELECT last_insert_rowid()。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 正確做法 |
|---|---|---|
忘記 Rollback |
程式在 Commit 前直接 return,導致交易保持開啟 |
使用 defer tx.Rollback(),即使 Commit 成功,Rollback 也會被忽略 |
在不同 DB 連線上使用同一個 Stmt |
Stmt 綁定於建立它的 *sql.DB 或 *sql.Tx,跨連線會拋出錯誤 |
每個連線或交易分別 Prepare,或使用 db.Conn 取得獨立連線 |
未關閉 Rows |
Rows 內部持有連線資源,未關閉會造成連線洩漏 |
defer rows.Close(),或使用 for rows.Next() { … } 後立即 rows.Close() |
忽略 sql.ErrNoRows |
查詢不到結果時返回此錯誤,若直接當成一般錯誤處理會誤判 | 使用 if err == sql.ErrNoRows { /* 特殊處理 */ } |
| 使用過高的交易隔離等級 | Serializable 會大幅降低併發,易產生死鎖 |
依需求選擇最小的隔離等級,如 Read Committed |
在迴圈內重複 Prepare |
每次 Prepare 都要向資料庫發送編譯請求,效能低下 |
在迴圈外一次 Prepare,在迴圈內重複 Exec |
未使用 Context |
長時間阻塞的查詢會拖慢整個服務 | 為所有 DB 操作傳入 ctx,設定適當的逾時或取消機制 |
其他最佳實踐
- 統一錯誤處理:建立一個
handleDBError(err error),將常見錯誤(如唯一鍵衝突)轉換成業務層可辨識的錯誤類型。 - 使用連線池:
sql.Open會自動建立連線池,請根據實際流量調整SetMaxOpenConns、SetMaxIdleConns。 - 避免長交易:交易時間越長,持有的鎖越多,越容易造成阻塞。將需要的操作盡可能拆成小交易。
- 測試 Transactional 邏輯:在單元測試中使用
tx.Rollback()保持資料庫乾淨,或使用 SQLite 記憶體模式快速驗證。
實際應用場景
| 場景 | 為何需要 Transaction | 為何需要 Prepared Statement |
|---|---|---|
| 訂單結帳 | 必須同時扣減庫存、寫入訂單、更新用戶積分,任一失敗都要回滾 | 每筆訂單的商品清單會重複插入 order_items,使用預備陳述式可快速批次寫入 |
| 金融轉帳 | 兩個帳戶的餘額變更必須原子化,避免出現負餘額或資金遺失 | 轉帳金額、帳號等參數需安全傳遞,防止 SQL Injection |
| 批次匯入 CSV | 大量資料寫入時若中途出錯,需要一次回滾,避免半完成的資料 | 同一條 INSERT 語句會被執行上千次,預備陳述式可大幅降低 CPU 負載 |
| 即時分析報表 | 讀取多張表格的統計資料時,需要 READ COMMITTED 隔離,確保報表不受未提交交易影響 |
報表查詢往往有相同的條件(日期、類別),使用預備陳述式可重複使用執行計畫 |
總結
- 交易提供資料完整性的保證,預備陳述式則在效能與安全性上發揮關鍵作用。
- 在 Go 中,透過
database/sql的BeginTx、Commit、Rollback搭配Prepare、ExecContext,即可輕鬆實作安全、可擴充的資料庫操作。 - 別忘了:
defer處理資源釋放、使用context控制逾時、適當設定交易隔離等級、以及在迴圈外一次Prepare。 - 只要遵循上述 最佳實踐,即使是複雜的業務流程(如金融轉帳、訂單結帳),也能寫出 可讀、可維護、且具備高可靠性的程式碼。
祝你在 Golang 的資料庫開發旅程中,寫出更穩定、更高效的程式! 🚀