ActiveRecord 這層抽象本身將近四萬三千行程式碼;而 noteflakes 的作者猜測,一張表大概只會被問出一打左右不同的查詢。查詢種類數得出來,為什麼還要為它扛一整套物件對映的 DSL?
不用 ORM 的話,那用什麼
作者在 noteflakes 部落格的〈Beyond ORMs〉一文裡直接承認自己「in the last few years I've been slowly gravitating toward working more directly with databases」——從物件導向的 ORM,慢慢往資料導向的寫法靠。他要比的兩條路,一條是 Ruby 世界最主流的 entity-per-row 式 ORM,以 ActiveRecord 為代表;另一條是他自己主張的做法:per-domain store class,每個領域一個手寫類別,方法直接回傳 Ruby hash,SQL 明寫在方法本體裡。這篇要拆的不是哪個框架比較潮,而是這兩條路各自替 app 扛住了什麼、又在哪裡讓工程師自己掏成本。
這個切分本身也對應到兩種不同的心智模型:ORM 那邊假設工程師在操作「一個個東西」,每個東西有自己的生命週期、自己的驗證規則;store class 那邊假設工程師在操作「一批列」,進來的時候是一批,出去的時候通常也是一批。哪一種心智模型比較貼近實際情況,決定了後續每一個維度的權衡會往哪邊倒。
這個立場轉變不是純粹口味問題。多數團隊選 ORM,理由通常是想省下手寫 SQL 的力氣、換取一致的存取介面;但當專案跑到一定規模,SQL 到底藏在哪一層、一次操作背後發了幾次查詢、schema 改了要動哪些地方,這幾件事會直接影響維運成本。這篇文章值得拿出來對照,正是因為他把每個代價都攤開來講——不是空泛地說 ORM 不好,而是給了具體的程式碼與具體的估計數字,讓讀者可以自己核對這些代價在自己的專案裡成不成立。
先把六個常被拿出來比的維度攤開:
| 維度 | ActiveRecord 風格 ORM | per-domain store class |
|---|---|---|
| 依賴重量 | 框架本身約 43KLoC,隨專案一起載入 | 沒有額外框架,SQL 全部手寫在 store 方法裡 |
| 回傳型態 | entity object,掛著數百個 instance/class 方法 | 方法直接回傳純 Ruby hash |
| 進階 SQL | window function/CTE/RETURNING 都沒有 DSL 支援 | 方法本體直接寫 SQL,RETURNING 可以直接用 |
| N+1 可見度 | loop 裡逐筆呼叫容易不知不覺產生 | 一個方法對應一個明確的 query,好抓 |
| 非 entity 資料 | 作者形容用在 time series/job queue 上「seems clumsy and wasteful」 | 本來就是 record-set 思維,天生合適 |
| schema 變更擴散 | 新欄位多半自動反映在物件上,但隱性依賴散落在全專案的 model.column 呼叫裡 | 改動集中在該 domain 的 store 檔案,但每個受影響的方法要自己動手改 |
抽象層擋掉了什麼:ORM 的重量 vs store class 的透明
作者對 ActiveRecord 的第一個具體指控是重量。他寫道:「the ORM layer itself is a substantial dependency (the ActiveRecord codebase is about ~43KLoC), it imposes a performance cost in terms of memory and CPU time.」四萬三千行是程式碼庫大小,是可以查證的數字;但「拖累記憶體與 CPU」這句他沒有附上 benchmark,是他自己的論斷,不是量測結果。
更根本的指控是抽象本身選錯了形狀:「ActiveRecord provides the wrong abstraction for relational data. ActiveRecord concentrates on the single record, the idea that each record is an entity in and of itself.」對應到程式碼,就是每一列資料都變成一個繼承自 ActiveRecord::Base 的物件,「with its hundreds of instance and class methods」。他還借用了一個外部說法替這個設計定性:row-to-object 的映射「has been described as the Vietnam of computer science」——這句不是他自己發明的比喻,原文連到 Coding Horror 的一篇舊文,他只是借來用。
數百個方法掛在同一個類別上,帶來的不只是程式碼庫的重量,也包括開發時的認知負擔:IDE 自動完成跳出一長串繼承自框架的方法,新人要先分清楚哪些是專案自己定義的、哪些是框架內建的,才知道該讀哪一段程式碼。原文示範的那個 store 收斂到六個手寫方法,等於把這個負擔提前搬到設計階段一次處理掉,而不是留給每個第一次讀這段程式碼的人自己摸索。這種收斂帶來的好處,在程式碼審查時最容易感覺到:review 一個新增的 store 方法,看到的就是一整句可以直接在資料庫主控台貼上執行的 SQL,審查者不需要先在腦中把 DSL 展開成實際查詢,才能判斷這個查詢會不會鎖表、會不會全表掃描。
這個「entity 優先」的預設,最直接的影響是拿到一批資料之後要怎麼處理。如果操作單位本來就是一批列——例如同時調整一百篇文章的分類、或是定期清掉逾期資料——ActiveRecord 的預設路徑是先把每一列都實例化成一個物件,再對每個物件個別呼叫方法;store class 的預設路徑則是整批資料從頭到尾都留在 row/hash 的形態,直到真的需要輸出成某種結構才轉換。差別不在於哪一種「做得到」,兩種寫法理論上都做得到,差別在於哪一種是預設路徑、哪一種需要工程師自己繞路才能達到。這個錯位在讀寫比例失衡的場景最明顯:後台管理介面常見的操作是「把符合某個條件的一批列都改成某個狀態」,這種操作的自然單位是一個 SQL 陳述式;但套進 entity-per-row 的介面,就變成先查出一批物件,再對每個物件呼叫同一個 setter,最後逐一存檔——中間多出來的物件實例化與逐筆存檔,都是為了配合「entity 優先」這個預設,而不是這個操作本身需要的。
這個形狀選錯的直接後果,是 DSL 接不住資料庫真正進階的能力:「there's no support for features such as window functions, common table expressions (CTEs), or even the RETURNING clause.」少掉這幾樣支援,實務上常見的後果是工程師被迫把本來可以一次查詢做完的排名、分組小計、遞迴查詢,拆成好幾次應用層迴圈,或者退回寫 raw SQL——不管走哪一條,都等於繞過了當初選 ORM 想要的「不用自己想 SQL」這個初衷,變成兩頭都沒省到。store class 這邊反過來,因為方法裡直接寫 SQL,這些語法本來就能用,不需要框架先幫你抽象一層。
作者給自己的論點劃了一個上限:「your app might want to bring in some associations ... but their number would average, I'd guess a rough estimate, maybe a dozen different queries per table.」一打左右查得完的查詢種類,是他反問的依據——原文是「if we only need to make a few dozen different queries, why should we use a DSL in the first place?」但他自己也留了例外:「That, unless you're building a full-featured visual query builder app!」如果 app 本身要讓使用者自由拼查詢條件,這個估計就不成立,DSL 的動態組合能力才真正划算。
這篇文章反覆用到三個詞:record set一批列的集合,作者拿它跟 entity object 對比——很多場景要的是整批資料,不是把某一列當成獨立物件。、 visual query builder作者承認的例外:如果 app 本身要讓使用者自由組合篩選/排序條件,查詢種類就不再是一打左右數得完,這時動態 DSL 才划算。、 以及 structured transformExtralite 提供的功能:把 join 出來的多列結果,宣告式地轉成巢狀物件圖,不用整套 ORM 也能拿到結構化輸出。。
N+1 藏在哪裡:一次 RETURNING 省下幾次往返
作者用一段最直白的程式碼示範問題:對一批符合條件的資料逐筆呼叫 beads.each(&:delete)。他自己點名了這個寫法的名字:「we issue one SELECT query to get the beads we're interested in, and then for each post we'll issue a DELETE query, so we got a N+1 situation.」——找出一批列要一次查詢,刪除每一列各自再發一次,筆數一多,往返次數就跟著線性長。N+1 這個問題不是 Ruby 或 ActiveRecord 獨有——任何把「查一批」跟「對每一筆做點什麼」分開寫的介面,都有機會踩到同樣的陷阱,差別只在於介面把這個分界藏得越深,工程師就越不容易在寫下第一行程式碼的當下意識到自己正在製造 N+1。
他給的替代寫法是把 SQL 明寫成一句帶 RETURNING 的敘述:delete from beads where material = ? returning *;。找列跟刪列這兩件事被併進同一次資料庫往返,多出來的那批 DELETE 查詢就消失了。
往返次數之所以值得盯著看,是因為每一次資料庫往返都要付出一趟網路延遲,就算資料庫在同一台機器上,也要付出一次 context switch 與 I/O 排程的成本。當 N 只有個位數,兩種寫法的差距感覺不出來;但只要這段程式碼是排程任務、批次清理,或是使用者操作會觸發的高頻路徑,往返次數隨 N 線性增加,就會直接反映在整體延遲與資料庫的負載上。
拖動滑桿改變符合條件的資料筆數 N · 對照兩種寫法各自的資料庫往返次數
beads.each(&:delete) 逐筆送出 DELETE,他自己稱之為「N+1」情況)換算成往返次數畫出來的示意曲線;他提出的替代寫法是 delete from beads where material = ? returning *;,把找列與刪列併成一次 query。這個換算也提醒了一件容易被忽略的事:往返次數不是唯一成本,資料庫這一端要處理的鎖與 log 也會跟著查詢次數一起放大。逐筆刪除的版本裡,資料庫要對同一批列開好幾次交易內的鎖定與釋放;併成一次陳述式之後,鎖的持有時間變短、write-ahead log 也只需要記一次操作,而不是 N 次各自獨立的操作記錄。這一層成本比往返次數更隱性,通常要看資料庫自己的監控指標才會浮現。
值得說清楚的是,RETURNING 這個能力本身不是 store class 專屬——理論上一個 ActiveRecord app 一樣可以繞過 DSL、自己走 raw SQL 去用它,作者原文只說沒有現成 API 能做這種查詢,並未指名特定寫法。差別在於 store class 把「寫 SQL」當成方法的預設動作,RETURNING 只是順手就能用到的語法;ActiveRecord 的預設路徑則要先跳出 DSL 才碰得到,這也是前面那句「沒有 RETURNING 支援」想指出的落差。
從介面到省 CPU:store class 的三層遞進
切換三個分頁看 store class 怎麼一層層加上去 · 3 個分頁
作者把這個提案接回最初的部落格情境:「Let's retake the example of a simple blog app, and imagine we had an interface that's custom-made for dealing with posts.」他接著把這個角色講清楚:「The store class should function as the interface for interacting with posts.」對應到程式碼,就是這六個方法。這六個方法名稱本身就是文件——看到方法名字,大概就能猜到它背後對應哪一種 SQL 語句,不需要跳到別的檔案去確認這次呼叫到底發了幾次查詢:
posts = PostsStore.new(db)
id = posts.create(title: 'foo', body: 'bar')
post = posts.by_id(id)
posts.update_by_id(id, title: 'FOO')
posts.delete_by_id(id)
posts.all
posts.all_by_category(category: 'baz')
這六個方法回傳的都是純 Ruby hash,不是掛著「hundreds of instance and class methods」的物件——這正是前面說的 record set 而不是 entity object。少了物件那一層,不代表拿掉所有結構化能力:作者也提到他常用的 SQLite gem Extralite「can transform projections of joined rows... effectively converting a result set into an object graph」,可以把 join 出來的多列結果宣告式地轉成巢狀資料,這跟要不要用整套 ORM 是兩回事。
第三層是他自己說還在進行中的工作:「I've been working recently on automatic caching of statements at the database level, such that any query that's issued with parameters is stored in a cache, and automatically reused whenever the same SQL is given to Database#query.」重複的 SQL 字串不用每次都重新 parse/plan,直接命中快取——但這句用的是「I've been working recently on」,明講是他自己在做、還沒定案的方向,不是已經上線的功能。
什麼資料本來就不需要 entity:time series、job queue、KV
作者點名了幾種他認為 entity 抽象根本用不上的場景:「There's time series data and event logs. Nowadays relational databases are even used as key-value stores and job queues. For these types of data, we're frequently interested in record sets rather than individual records, and we don't really need the entity abstraction.」對這些用法,他形容 ActiveRecord「seems clumsy and wasteful」。這個判斷跟他前面「大部分查詢種類數得完」的估計是同一套邏輯:資料本身就是一批一批處理,個別列有沒有被包成物件無關緊要。
把資料庫拿來當 KV store 或 job queue 用,聽起來像是誤用,但這種用法其實很常見:團隊已經有一套穩定的關聯式資料庫維運經驗,不想為了一個 queue 或一個 cache 多引入一套系統,於是把既有的 Postgres 或 SQLite 拿來兼差。這種場景下,唯一需要的操作往往只有「塞一筆進去」跟「依條件撈一批出來」,entity 抽象在這裡完全用不上力。
原文沒有花篇幅替反過來的情境辯護,但這一側的論據並不難補:一張訂單在狀態機裡的每一次轉換、一個使用者帳號要跑欄位驗證與生命週期回呼,這種真正「一列就是一個有意義的業務物件」的場景,entity 物件把驗證、關聯、回呼收在同一個物件上,正是 ActiveRecord 這類框架被廣泛採用的原因——這不是原文的論點,是這類比較裡常被擺在對面的論據,供讀者自己權衡。
這也是為什麼多數團隊不會把兩種寫法當成互斥選項,而是照 domain 分工:使用者、訂單、金流這類需要驗證與狀態機的核心業務物件,留給 ORM 或至少留給 entity 風格的封裝;log、metrics、queue、cache 這類本來就是批次寫入、批次讀出的資料,改用 store class 或更輕量的存取方式。這種混用不是原文的主張,是把作者的分野套進實際專案時,工程團隊常見的做法。
怎麼選
把前面幾段對齊起來看,決定點落在兩件事上:一個 domain 的查詢種類是不是數得完,以及資料本身是不是天生就是一批一批處理的。如果查詢種類接近作者估的一打左右、你在意 SQL 看不看得見、N+1 抓不抓得到,store class 把這些成本換成透明度,划算;如果 app 需要使用者自己拼篩選/排序條件,或者 domain 本身是要驗證、要走生命週期回呼的真實業務物件,ORM 的抽象仍有位置。至於 time series、event log、job queue 這類天生 record-set 的資料,兩條路都同意 store class 式的寫法更貼合。
換成場景來想:一個內部後台工具,資料表就是使用者、角色、審核紀錄,查詢種類固定、schema 很少變——這正是作者說的「數得完」;反過來,一個要讓業務人員自己在後台拼篩選條件的報表系統,查詢種類本來就不固定,這時候硬要拔掉 ORM 換透明度,多半是把力氣花在不該花的地方。團隊規模也是一個現實因素:一個人或小團隊全職維護,手寫 SQL 的心智負擔比較容易扛;team 一大、流動率一高,ORM 那層一致的介面反而降低新人上手的門檻——這個因素原文完全沒提,但也是實務上常被拿出來反駁的理由。這篇文章也沒有處理另一個現實問題:團隊裡誰來維護這些手寫的 SQL。ORM 把 SQL 生成這件事收攏成框架的責任,出問題時至少有社群文件可以查;store class 把責任全部丟回團隊自己身上,SQL 寫錯、索引沒建對,都要靠團隊自己的 review 流程抓出來。這個責任轉移本身不是壞事,但確實是選 store class 之前該想清楚的一筆帳。
把六個維度、三個詞、三層遞進放在一起看,這篇文章真正在爭的不是「SQL 該不該手寫」,而是「抽象的預設值該設在哪裡」。ORM 把預設值設在 entity,好處是新手上手快、常見操作有現成方法;store class 把預設值設在 record set,好處是查詢的成本、SQL 的內容、N+1 的風險都攤在檯面上,代價是每一個方法都要工程師自己想清楚要寫什麼。兩種預設值都合理,差別只在於哪一種預設值比較貼近你正在維護的那個 domain。
要提醒的是,這整篇是一位工程師的立場轉變敘事——從「I've always been interested in the design of ORMs」到「gravitating toward working more directly with databases」——不是團隊共識或 benchmark 對照。他自己給的具體數字裡,四萬三千行是可以查證的程式碼庫大小,一打查詢則是他自己明講的粗估,不是量測;連 statement cache 都還在做,尚未定案。如果要把這篇文章的建議搬進自己的專案,比較實際的做法是先照他的方式數一數自己專案裡某個 domain 到底有幾種不同查詢、SQL 語句佔多少行,以及目前的 ORM 使用方式裡有沒有已經在發生但沒被注意到的 N+1——這些是可以自己核對的具體指標,不需要相信任何一方的估計。
How to choose:查詢種類數得完、想看見 SQL 的時候,store class 用手寫換透明度是划算的;查詢是使用者自己組出來的、或者資料真的是要驗證與生命週期的實體,ORM 的抽象仍然值回票價。