🎯 什麼情境該想到我

當你「想把資料表查詢結果,當成記憶體中的表格資料直接使用」的時候。

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

意圖:把查詢結果表示成記憶體中的表格資料結構,供 UI 與模組直接使用。

做法要點:

  • 用列(row)與欄(column)組成的記憶體表格,鏡射資料庫的表格結構(如 .NET 的 DataSet)。
  • UI 元件與商業模組可直接綁定、讀寫這份記錄集,開發直觀。
  • 常見於「資料匯流排」風格:讓表格資料在各層之間流動、被繫結。

★★ 誠實的結論:這個模式在 Go 裡不存在,而那是好事

★ 它的原生環境是 .NET 的 ADO.NET DataSet(2002 年):
  ★★ 一個「通用的、離線的、可導覽的表格結構」
  → UI 控件可以直接綁定它、可以離線編輯、可以追蹤變更、可以整批寫回
  → ★ 它是 [[表格模組]] 與「快速開發」的基礎設施

★★ Go 只有 database/sql 的 *sql.Rows,而它是:
  ① ★ 游標(cursor),不是集合 —— 只能往前,讀完就沒了
  ② ★★ 必須 Close,而且不 Close 會洩漏連線
  ③ ★ 沒有型別資訊,要靠 Scan 到具體變數
  ④ ★★ 不能離線、不能編輯、不追蹤變更

★★ 所以 Go 的做法是:立刻 Scan 成具體的 struct slice,然後忘掉 rows。
★ 這就是 sqlc 產生的程式碼在做的事(見 表格資料閘道)。

★★ *sql.Rows 的四個必守規則

rows, err := q.Query(ctx, sqlText, args...)
if err != nil { return nil, fmt.Errorf("query: %w", err) }
defer rows.Close()                        // ★★ ① 必須 Close,否則連線不會還回池裡
 
var out []Order
for rows.Next() {
    var o Order
    if err := rows.Scan(&o.ID, &o.Status, &o.Total); err != nil {   // ★ ② 順序必須和 SELECT 一致
        return nil, fmt.Errorf("scan: %w", err)
    }
    out = append(out, o)
}
if err := rows.Err(); err != nil {         // ★★ ③ 必查!
    return nil, fmt.Errorf("rows: %w", err)
}
return out, nil                            // ★ ④ 回傳具體型別,不要回傳 rows

★★ ③ 是 Go 資料存取最常被漏掉的一行:

★ 迴圈因為網路中斷、查詢被取消、伺服器關閉而提早結束時,
  rows.Next() 只是回 false —— ★★ 和「正常讀完」完全一樣
→ ★ 你會拿到一份「不完整但看起來成功」的資料,而且沒有任何錯誤
→ ★★ 而這種 bug 在低流量時幾乎不會出現,上線後在尖峰時段偶發

★ ④ 也很重要:把 *sql.Rows 從函式回傳出去,
呼叫端必須記得 Close,而且那個連線在此期間一直被佔著——
★ 這是連線池耗盡的一條常見路徑。

★★ 什麼時候你真的需要「串流」而不是 slice

// ★ 一千萬列不能全部載進記憶體
// ★★ Go 1.23+ 的做法:回傳 iter.Seq2,讓呼叫端 range
func (q *Queries) StreamOrders(ctx context.Context) iter.Seq2[Order, error] {
    return func(yield func(Order, error) bool) {
        rows, err := q.db.QueryContext(ctx, sqlText)
        if err != nil { yield(Order{}, err); return }
        defer rows.Close()                           // ★ 由 iterator 負責關閉
        for rows.Next() {
            var o Order
            if err := rows.Scan(&o.ID, …); err != nil { yield(Order{}, err); return }
            if !yield(o, nil) { return }              // ★★ 呼叫端 break 時,defer 會關閉
        }
        if err := rows.Err(); err != nil { yield(Order{}, err) }
    }
}

★★ iter.Seq2 讓「串流」與「資源管理」同時成立:
★ 呼叫端只要 for o, err := range …,而 Close 由迭代器負責——
★★ 這是 Go 1.23 之後處理大結果集最乾淨的形態(見 封裝集合)。
⚠️ 但串流有一個代價:連線在整個迭代期間被佔用。
★ 若迭代中還要做慢的處理(呼叫外部 API),★★ 連線會被佔住很久 →
★ 那時應該「分批查詢」(keyset pagination)而不是串流。

★★ 這個模式留給我們的唯一教訓

★★ 「通用的表格結構」聽起來很美,實際上它讓:
  ① ★ 沒有型別 ⇒ 打錯欄位名執行期才知道
  ② ★★ 沒有領域語義 ⇒ 業務邏輯只能寫成「對表格操作」(見 [[表格模組]])
  ③ ★ 離線編輯與變更追蹤 ⇒ 一套複雜的機制,而它解決的問題今天由
    明確的交易與樂觀鎖處理得更好
→ ★★ Go 選了另一條路:★ 具體型別、明確的查詢、立刻 Scan 成 struct

★ 判準:你想要「一個能裝任何查詢結果的東西」嗎?
★★ 想要 → 先問為什麼。通常答案是「動態查詢」(→ 查詢物件)
或「報表」(→ 專門的 DTO),而兩者都不需要一個通用容器。

★ 兄弟辨析

差別
表格資料閘道★ Go 的答案:具體型別、立刻 Scan
表格模組★ 記錄集是它的前提,而那個前提今天不成立
資料傳輸物件★ 具名的、型別化的「一包資料」
查詢物件動態查詢的正確做法
封裝集合iter.Seq 的用法

🧪 我實際套用的紀錄

  • (待填)

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

  • 以表格為中心,較偏離物件導向的領域模型;領域邏輯複雜時未必合適。

  • 環境沒有內建記錄集型別時,自造成本高。

  • 讓 UI 直接依賴資料庫結構,schema 變動的影響會擴散。

  • ★★ rows.Err() 必查。 迴圈因網路中斷提早結束時 Next() 只回 false,和正常讀完完全一樣——你會拿到不完整但看起來成功的資料。

  • ★★ 這種 bug 在低流量幾乎不出現,上線後在尖峰時段偶發,極難重現。

  • ★★ 不要把 *sql.Rows 從函式回傳出去。 呼叫端要記得 Close,而連線在此期間一直被佔著——這是連線池耗盡的常見路徑。

  • ★ defer rows.Close() 不可省略,否則連線不會還回池裡。

  • ★ Scan 的參數順序必須和 SELECT 一致,加一個欄位就整排錯位。

  • ★ 大結果集用 iter.Seq2 串流(Go 1.23+),Close 由迭代器負責。

  • ★★ 但串流會佔住連線整段時間。 迭代中要做慢處理 → 改用分批查詢(keyset pagination)。

  • ★ 「通用的表格結構」在 Go 裡不該追求。 想要它通常代表你需要的是 查詢物件 或一個報表 DTO。

🔗 相關工具