🎯 什麼情境該想到我
當你「想追蹤一次業務交易中所有被改動的物件,最後一次統一提交或回滾」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:維護一份「受業務交易影響的物件清單」,協調變更寫回,並解決並行問題、減少資料庫往返。
做法要點:
- 追蹤新增、修改、刪除的物件(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。
🔗 相關工具
- 識別對應 Identity Map(常一起確保物件唯一)
- 資料對應器 Data Mapper(工作單元協調它做實際寫入)
- 企業應用架構模式
- 識別對應 —— 同一列只有一個物件
- 延遲載入 —— 另一個 ORM 內部機制
- 資料對應器 —— 工作單元協調它寫入
- 樂觀離線鎖 —— 跨請求的併發控制
- 表格資料閘道 —— 用 DBTX 介面傳遞 tx
- 工具-交易與隔離等級 —— 交易邊界與隔離級別
- 工具-Transactional-Outbox-Pattern —— 外部副作用移出交易