🎯 什麼情境該想到我

當你「想追蹤一次業務交易中所有被改動的物件,最後一次統一提交或回滾」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:維護一份「受業務交易影響的物件清單」,協調變更寫回,並解決並行問題、減少資料庫往返。

做法要點:

  • 追蹤新增、修改、刪除的物件(new / dirty / removed)。
  • 交易進行中先在記憶體記錄變更,commit 時才一次寫入資料庫。
  • 把多次小異動整併,減少資料庫連線與往返;並可統一在一個資料庫交易內提交或回滾。
  • 常與 Data Mapper、Identity Map 搭配,是 ORM 的核心機制。

★★ Go 的誠實答案:不要實作它,用明確的交易

// ★★ Go 的做法 —— 交易邊界顯式、寫入時機顯式
func (s *Service) Settle(ctx context.Context, id OrderID) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil { return fmt.Errorf("begin: %w", err) }
    defer tx.Rollback()
 
    o, err := s.orders.Get(ctx, tx, id)          // ★ 明確載入
    if err != nil { return err }
    if err := o.Settle(); err != nil { return err }
    if err := s.orders.Save(ctx, tx, o); err != nil { return err }   // ★★ 明確儲存
 
    return tx.Commit()                            // ★ 交易邊界一眼可見
}

★★ 工作單元的價值是「自動」;而 Go 社群刻意放棄那個自動。
代價是你要記得呼叫 Save;換到的是沒有「你沒寫的寫入」、也沒有「以為存了其實沒存」。
★ database/sql 的 *sql.Tx 就是你的工作單元——只是它追蹤的是 SQL,不是物件。

★★ 它到底幫你解決什麼(三件事,各有 Go 的替代)

工作單元做的★ Go 的替代
① 追蹤 new/dirty/removed★ 你自己知道改了什麼(因為你明確呼叫了 Save)
② 把多次小異動整併成一次寫入★ 批次寫入(INSERT … VALUES (…),(…),(…)、COPY)由你決定
③ 統一在一個資料庫交易內提交或回滾★★ BeginTx / Commit / Rollback——這一項 Go 本來就有

★★ 關鍵洞見:③ 是最重要的,而它不需要工作單元。
①② 是「便利」與「效能」,而 Go 的文化選擇把它們變成顯式的決定。

★★ 如果你真的要在 Go 裡做一個(三個必須處理的難題)

// ★ 最小可行的工作單元
type UnitOfWork struct {
    tx      *sql.Tx
    dirty   map[any]struct{}       // ★★ 難題一:Go 沒有物件識別的通用機制
    removed []any
}
★★ 難題一:怎麼判斷「這個物件被改過了」
  ① 比對快照(載入時複製一份,commit 時 deep compare)
     → ★ 需要 reflect.DeepEqual 或 codegen;★ 而且 deep compare 很貴
  ② 讓領域物件自己標記 dirty(o.markDirty())
     → ★★ 領域物件被迫背持久化意識 —— 違反了 [[資料對應器]] 的初衷
  ③ ★ 記錄「做了哪些操作」而不是「改了哪些欄位」
     → 最乾淨(接近事件溯源),但要重新設計領域物件

★★ 難題二:寫入順序
  外鍵約束要求先插父再插子、先刪子再刪父
  → ★ ORM 用拓撲排序解決;★ 你自己做就要自己排,而漏一個就是執行期外鍵錯誤

★★ 難題三:commit 失敗後的物件狀態
  記憶體裡的物件已經是「改過」的狀態,但資料庫回滾了
  → ★★ 這些物件現在是「假的」。ORM 通常要你丟掉整個 session
  → ★ Go 裡:★ 讓那個請求整個失敗,不要重用物件

★★ 這三個難題的存在,就是「Go 生態沒有主流工作單元實作」的原因。

★★ 反而更該注意的:交易邊界的三個實務問題

// ① ★★ 巢狀交易:database/sql 不支援
tx1, _ := db.BeginTx(ctx, nil)
tx2, _ := db.BeginTx(ctx, nil)     // ★ 這是「兩個獨立的交易」,不是巢狀
// → ★ 要巢狀語義得用 SAVEPOINT(要自己發 SQL)
// → ★★ 所以:函式收 tx,不要自己開(見 [[表格資料閘道]])
 
// ② ★★ 交易持有的時間
tx, _ := db.BeginTx(ctx, nil)
callExternalAPI(ctx)               // ★★ 交易在等一個 HTTP 呼叫
tx.Commit()
// → ★ 連線被佔住、鎖被持有、連線池耗盡 → ★★ 外部呼叫絕對不要包在交易裡
 
// ③ ★ defer tx.Rollback() 是慣用寫法
defer tx.Rollback()   // ★ commit 之後再 Rollback 回傳 sql.ErrTxDone,無害

★★ ② 是最常見的生產事故:交易裡呼叫外部 API →
對方慢 3 秒 → 你的連線池在高流量下瞬間耗盡 → 整個服務掛掉。
→ ★ 正確做法:交易內只做資料庫操作,外部呼叫放交易外(配 工具-Transactional-Outbox-Pattern)。

★ 兄弟辨析

差別
識別對應★ 保證「同一列只有一個物件」;工作單元追蹤「哪些物件改了」
樂觀離線鎖★ 跨請求的併發控制;工作單元是單一請求內的
資料對應器★ 工作單元協調它做實際寫入
粗粒度鎖聚合層級的鎖

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 需要正確追蹤物件狀態,登錄機制寫錯會漏寫或重寫。

  • 極簡單、單筆立即寫入的情境不需要它。

  • 要考量並行控制(樂觀 / 悲觀鎖)搭配。

  • ★★ Go 不要實作工作單元。*sql.Tx 已經提供了最重要的那一項(統一提交/回滾),其餘兩項刻意保持顯式。

  • ★★ 外部 API 呼叫絕對不要包在交易裡。 對方慢 3 秒 → 連線池瞬間耗盡 → 整個服務掛掉。這是最常見的生產事故。

  • ★★ database/sql 不支援巢狀交易。 兩次 BeginTx 是兩個獨立交易;要巢狀語義得自己發 SAVEPOINT。

  • ★ 函式收 tx,不要自己開交易,否則呼叫端無法把多個操作放進同一個交易。

  • ★★ 若真要自製工作單元,三個難題都無解:如何判斷 dirty(Go 沒有通用機制)、寫入順序(外鍵拓撲排序)、commit 失敗後記憶體物件是假的。

  • ★ commit 失敗後不要重用領域物件。讓整個請求失敗。

  • defer tx.Rollback() 是慣用寫法,commit 後回傳 sql.ErrTxDone,無害;漏了它會在早期 return 時洩漏連線。

  • 交易不是「整併寫入」的工具。要批次就明確用批次 INSERT/COPY。

🔗 相關工具