本文 AI 產出,尚未審核

Golang 資料庫操作:交易(Transactions)與預備陳述式(Prepared Statements)


簡介

在企業級應用中,資料完整性效能往往是最重要的兩大指標。
當多筆資料必須同時寫入、更新或刪除時,若其中任何一步失敗,都可能導致資料不一致;此時 交易(Transaction) 就能提供「全有或全無」的保證。

另一方面,頻繁執行相同結構的 SQL 語句會產生大量的解析與編譯成本,甚至可能造成 SQL 注入的安全風險。使用 預備陳述式(Prepared Statement) 不僅能減少 CPU 開銷,還能讓參數自動做安全轉義。

本篇文章將以 Go 語言 為例,說明如何正確地使用交易與預備陳述式,並提供實務上常見的陷阱與最佳實踐,讓讀者從 初學者 逐步晉升為 中級開發者


核心概念

1. 交易(Transaction)是什麼?

  • 原子性(Atomicity):交易內的所有 SQL 必須全部成功,否則全部回滾。
  • 一致性(Consistency):交易結束後,資料庫必須處於合法狀態。
  • 隔離性(Isolation):不同交易之間不會互相干擾,常見的隔離等級有 Read UncommittedRead CommittedRepeatable ReadSerializable
  • 持久性(Durability):一旦交易提交,資料變更會永久寫入磁碟。

在 Go 中,database/sql 套件提供了 Begin, Commit, Rollback 三個方法來操作交易。

2. 預備陳述式(Prepared Statement)是什麼?

預備陳述式在第一次執行時會先將 SQL 文字送到資料庫編譯,之後只需要傳入參數即可執行。好處包括:

  • 效能提升:重複使用同一個語句時,避免每次都重新解析。
  • 安全性:參數會自動做適當的轉義,防止 SQL Injection。
  • 程式可讀性:SQL 與程式碼分離,易於維護。

在 Go 中,db.Prepare(或 tx.Prepare)會回傳一個 *sql.Stmt,之後可透過 ExecQueryQueryRow 使用。


程式碼範例

以下範例皆使用標準庫 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()
}

技巧ExecContextQueryContext 等方法會自動監聽 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,設定適當的逾時或取消機制

其他最佳實踐

  1. 統一錯誤處理:建立一個 handleDBError(err error),將常見錯誤(如唯一鍵衝突)轉換成業務層可辨識的錯誤類型。
  2. 使用連線池sql.Open 會自動建立連線池,請根據實際流量調整 SetMaxOpenConnsSetMaxIdleConns
  3. 避免長交易:交易時間越長,持有的鎖越多,越容易造成阻塞。將需要的操作盡可能拆成小交易。
  4. 測試 Transactional 邏輯:在單元測試中使用 tx.Rollback() 保持資料庫乾淨,或使用 SQLite 記憶體模式快速驗證。

實際應用場景

場景 為何需要 Transaction 為何需要 Prepared Statement
訂單結帳 必須同時扣減庫存、寫入訂單、更新用戶積分,任一失敗都要回滾 每筆訂單的商品清單會重複插入 order_items,使用預備陳述式可快速批次寫入
金融轉帳 兩個帳戶的餘額變更必須原子化,避免出現負餘額或資金遺失 轉帳金額、帳號等參數需安全傳遞,防止 SQL Injection
批次匯入 CSV 大量資料寫入時若中途出錯,需要一次回滾,避免半完成的資料 同一條 INSERT 語句會被執行上千次,預備陳述式可大幅降低 CPU 負載
即時分析報表 讀取多張表格的統計資料時,需要 READ COMMITTED 隔離,確保報表不受未提交交易影響 報表查詢往往有相同的條件(日期、類別),使用預備陳述式可重複使用執行計畫

總結

  • 交易提供資料完整性的保證,預備陳述式則在效能與安全性上發揮關鍵作用。
  • 在 Go 中,透過 database/sqlBeginTxCommitRollback 搭配 PrepareExecContext,即可輕鬆實作安全、可擴充的資料庫操作。
  • 別忘了defer 處理資源釋放、使用 context 控制逾時、適當設定交易隔離等級、以及在迴圈外一次 Prepare
  • 只要遵循上述 最佳實踐,即使是複雜的業務流程(如金融轉帳、訂單結帳),也能寫出 可讀、可維護、且具備高可靠性的程式碼

祝你在 Golang 的資料庫開發旅程中,寫出更穩定、更高效的程式! 🚀