🎯 什麼情境該想到我
當你要設計「物件如何對應到資料庫」,或在不同 ORM 風格間抉擇、遇到 ORM 效能問題時。
⚙️ 怎麼用(認識底層模式)
- Active Record:物件自己包含資料庫存取邏輯(
user.save())。簡單、快速,適合邏輯不複雜的 CRUD(Rails、Eloquent)。 - Data Mapper:物件與資料庫完全分離,由 Mapper 負責搬運。乾淨、可測、領域物件不依賴 DB,適合複雜領域(Hibernate、Prisma、TypeORM 的 mapper 風格)。
- Unit of Work:一次交易內追蹤所有變更,最後統一提交/回滾 → 減少 DB 往返、保證一致。
- Identity Map:同一物件一次請求只載入一次,避免重複查詢與不一致。
★ 根本問題:阻抗不匹配(impedance mismatch)
| 物件模型 | 關聯模型 |
|---|---|
| 巢狀、有繼承、有參考 | ★ 平的表格、只有外鍵 |
| 以「身分」識別(指標) | ★ 以「值」識別(主鍵) |
| 沒有集合操作 | ★ 集合導向(JOIN、GROUP BY) |
| 一次操作一個物件 | ★ 一次操作一批列(這是它快的原因) |
★ ORM 就是在翻譯這兩者之間的落差,而所有 ORM 的痛苦都來自「翻譯有損」。
★ Active Record vs Data Mapper
// ★ Active Record:物件自己知道怎麼存
user.Email = "new@x.com"
user.Save() // ★ 領域物件依賴資料庫
// ★ Data Mapper:物件完全不知道資料庫存在
user.ChangeEmail("new@x.com") // 純領域行為
repo.Save(ctx, user) // ★ 由外部的 mapper 負責搬運| Active Record | Data Mapper | |
|---|---|---|
| 上手 | ★ 快 | 慢 |
| 樣板程式 | ★ 少 | 多 |
| 領域物件可測性 | ⚠️ 需要 DB 或 mock | ★ 純記憶體可測 |
| 適合 | ★ CRUD、表結構≈物件結構 | ★ 複雜領域、模型與表差很多 |
| 例子 | Rails、Eloquent、GORM | Hibernate、Prisma、SQLAlchemy(classical) |
★ 選擇判準和 工具-領域邏輯組織模式 一致:
Transaction Script → Active Record;Domain Model → Data Mapper。 兩者要配對,混用會很怪。
★★ N+1 查詢:ORM 最常見也最貴的問題
orders := repo.FindAll() // ① 1 次查詢
for _, o := range orders {
fmt.Println(o.Customer.Name) // ★ 每一筆都觸發一次查詢 → N 次
}
// ★ 100 筆訂單 = 101 次查詢 = 101 次網路往返★ 它的致命之處是「在開發環境完全看不出來」(10 筆資料時很快),
上線之後資料量一大就爆。
四種修法:
| 方法 | 做法 |
|---|---|
| ★ Eager loading | Preload("Customer") / include —— 一次 JOIN 或兩次查詢帶回 |
| ★ DataLoader(批次 + 快取) | 把同一輪的 N 個查詢合併成 WHERE id IN (...)。GraphQL 生態的標配 |
| 直接寫 SQL | 需要什麼查什麼 |
| ★ 偵測 | 在測試或開發環境開「查詢計數斷言」——超過 N 次就讓測試失敗 |
★ 最後一項最重要:N+1 是「可以被自動偵測」的,不要靠人工 review。
★ 另外兩個核心模式
Unit of Work(工作單元)
uow := NewUnitOfWork(db)
uow.Register(order) // 追蹤變更
uow.Register(inventory)
if err := uow.Commit(ctx); err != nil { ... } // ★ 一次交易、統一提交★ 價值:(1) 一次交易邊界 (2) 減少往返(批次寫入)(3) 寫入順序由 UoW 決定(避免外鍵順序問題)。
Identity Map(身分映射)
同一個請求內,同一個 ID 只載入一次,回傳同一個物件實例。
★ 價值:避免重複查詢,也避免「同一筆資料在記憶體裡有兩個版本」導致的詭異 bug。
★ Go 的實務選擇(和 Java/Ruby 生態很不一樣)
| 工具 | 定位 |
|---|---|
database/sql + 手寫 SQL | ★ Go 社群的主流。明確、可控、沒有魔法 |
sqlc | ★ 從 SQL 產生型別安全的 Go 程式碼——寫 SQL 但拿到編譯期檢查。近年最受推薦的做法 |
sqlx | database/sql 的薄封裝(掃描到 struct) |
| GORM | Active Record 風格。方便但魔法多、效能陷阱多、N+1 容易發生 |
ent | schema-as-code + 產生程式碼,偏 Data Mapper |
★ Go 社群偏好「明確的 SQL + 程式碼產生」而非「執行期的 ORM 魔法」,
理由和 Go 的整體哲學一致:可讀、可預測、沒有隱藏的成本。
sqlc是這個哲學的代表——它讓你同時擁有 SQL 的控制力與型別安全(見 工具-型別推導還是解耦 的同源思路)。
🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ N+1 是最常見的效能殺手(見上)。在 CI 加查詢計數斷言。
- ★ ORM 隱藏了成本。
user.Orders這一行可能是一次 JOIN、也可能是 100 次查詢——
從程式碼上看不出來。 → 一定要開 SQL log 看實際發出的查詢。 - ★ 惰性載入 + 交易邊界外 = 爆炸。交易關閉後才存取 lazy 欄位 → 錯誤或額外查詢
(Java 的LazyInitializationException是經典)。 - 複雜查詢不要硬用 ORM。報表、分析、多表聚合 → 直接寫 SQL。
★ 「全部都用 ORM」和「全部都手寫 SQL」都是教條,混用才務實。 - ORM 的遷移功能要小心。自動產生的 migration 可能鎖表或重寫整張表(見 工具-資料編碼與演進)。
- 批次操作要用批次 API。迴圈裡
save()一萬次 = 一萬次往返。 - 不要讓 ORM 決定你的領域模型。為了 ORM 方便而把領域物件變成貧血的資料袋,是本末倒置。
🔗 相關工具
- 工具-領域邏輯組織模式 —— ★ 要配對選擇(Transaction Script↔Active Record)
- 工具-交易與隔離等級 —— Unit of Work 的交易邊界
- 工具-資料編碼與演進 —— migration 的安全做法
- 工具-儲存引擎B-Tree與LSM-Tree —— 索引與查詢效能
- 工具-型別推導還是解耦 ——
sqlc的「從 schema 推導型別」 - 工具-程式碼調校 —— 先量再優化
- 企業應用架構模式