爲什麼要旗幟鮮明地反對 orm 和 sql builder

這個問題在五六年前和前同事討論過,沒想到這麼多年過去了,又要跟其他工程師再討論一遍,有點恍如隔世。

因爲我比較懶,所以記錄一篇文章,以後碰到類似的問題就不再摻和了。

toC 場景的系統大多要面對較高的 QPS,即使是小型 / 中型公司使用 MySQL,沒有那麼高的查詢量,單表數據在百萬量級也屬常見。

無狀態的服務當前用 k8s 的 HPA 能力能夠做到很好的擴容,結合基本的優化知識,大多數問題能夠較好地被初 / 中級工程師解決。但 DB 一直是非常脆弱的一環,只要一個工程師不慎將不帶索引的查詢代碼帶上線,就會導致線上事故。這樣的事故在各家公司都不少。哦,當然,公司爲了自己的形象考慮,這樣的低級事故一般是不對外說的。

政府 / 企業對事故的管理一般有一套 “事前 - 事中 - 事後” 的分析框架,可以作爲我們處理事故的很好的參考。在開始分析之前,要先簡單瞭解一下這個框架:

所謂事前,就是做事情之前應該有統籌的規劃,考慮可能出現的問題和困難,最好提前做實驗驗證下,以保證在真實現場不出問題,或者即使出現問題也有應對方案,不至於手忙腳亂、大動干戈。

所謂事中,就是做事情的過程中對事前規劃的事情進行執行,以及記錄過程中出現的問題;整個事中起着承前啓後的作用,上對事前的規劃進行驗證,下對事後的總結反思進行鋪墊。

所謂事後,就是做事情結束後對整個事情進行總結反思、問題分類和根因分析等。追蹤並確認所有的問題有效進行了解決和應對,以確保下次不會再犯之前同樣的錯誤。

我們就用這套框架來看看現在被人們奉爲圭臬的 orm 和 sql builder 到底有啥問題。

事前

無論使用 orm 還是 sql builder,研發人員其實都是不太清楚自己的代碼會產生哪些 SQL 的,比如很多工程師會參考一些開源的模板代碼,將他們的數據查詢層定義類似下面的接口:

package iface

type CustomerRepository interface {
    func GetCustomer(ctx context.Context, query QueryOptions) (Customer, error)
    func ListCustomer(ctx context.Context, query QueryOptions) ([]Customer, error)
    func DeleteCustomer(...)...
    func UpdateCustomer(...)...
}

接口定義非常簡單,查詢條件可以隨意傳入,在整潔架構的核心圈中依賴這套接口可以靈活地組合出產品的業務邏輯。

而靈活和穩定在這裏是矛盾的,比如在 domain 中,可能會有人根據用戶傳入的參數來組裝查詢條件:

// 僞代碼,別在意語法問題
func GetCustomers(ctx context.Context, userParam map[string]interface{})  ([]Customer, error) {
    var query QueryOptions
    if param["id"] > 0 {
    query.ID = param["id"]
    }
    if ...
}

用戶傳入的條件完全不受控,在 MySQL 中,我們不可能爲一個笛卡爾積的可能組合去創建索引。所以發生事故就只是運氣問題。

若工程師學習過 DDD 的理論,知道基本的貧血模型和充血模型概念,按照充血模型去設計 Repository 的接口,相對來說會稍有改善,但從過往接觸過的從業人員來看,懂得這些概念的人很少。

無論使用 orm 還是 sql builder,最終我們都沒有辦法爲可能出現的線上事故做任何充分準備。這本身就是靈活的代價。你又怎麼爲事故來準備預案呢?

事中

簡單來說,就是線上的數據庫被打爆了。因爲查詢沒有走索引,MySQL 使用的 CPU 直接飈升。若公司建立了完善的雲平臺,DBA 或研發可從雲平臺看到使 DB 出現異常的 slow query。

但問題在於 slow query 中的 SQL 很難與代碼直接關聯起來,我們還是來看看上面定義的 Repo 接口:

type CustomerRepository interface {
    func GetCustomer(ctx context.Context, query QueryOptions) (Customer, error)
    func ListCustomer(ctx context.Context, query QueryOptions) ([]Customer, error)
    func DeleteCustomer(...)...
    func UpdateCustomer(...)...
}

從 DB 平臺中已經可以知道是下面這樣的 SQL 有問題:

select * from customer where id != 2

怎麼知道是什麼樣的業務調用鏈路觸發的呢?只能去慢慢讀代碼了。

在很多公司都見過類似這樣的場景:

DBA:"線上 db 快掛了,你看看這條查詢,緊急處理一下代碼"

RD:“先容我讀一下代碼,找到了才能改”

DBA:“。。。”

當然,現在的 sql builder 和 orm 框架也可以在 debug 模式打印一些 slow log,但打印行爲需要在查詢觸發時纔會有。若未建索引的查詢是個萬分之一概率進入的分支,碰到事故再開 debug log 大概也來不及了。

事後

別想了,基本也覆盤不出個所以然,無非是:

都解決不了本質問題。

比較好的方案

爲了讓脆弱的 DB 不要出事,最好的方案還是能夠在上線前攔截出所有可能出問題的 SQL。

現在有不少研發會買一些 MySQL 相關的八股課去學習 SQL 知識,基本都是爲了應付面試,實際工作中難以良好地運用。真的碰到了 MySQL 的死鎖問題,比如 unique key 導致的死鎖,還是要去求助 DBA,所以我不是很理解研發去卷 redo log/buffer pool 這些實現原理到底有什麼用,有點扯遠了。

對於 SQL、索引、死鎖,還是 DBA 最爲專業,這是他們賴以生存的本事。從工作流程上來講,我們完全可以讓 DBA 參與到研發流程中來,但直接讓 DBA 去猜代碼中會生成的 SQL 也是不現實的。個人認爲 Go 社區中目前比較優秀的解決方案是 sqlc:

從 SQL 文件生成 dao 中的查詢代碼,將所有查詢 SQL 全部顯式定義在代碼中,在上線流程中可以直接邀請 DBA 參與 CR 中的 SQL Review 環節。

再進一步,可以直接從 information schema 和 sys 庫中讀取到所有表的索引和訪問信息,DB 平臺可以自動對代碼中的 SQL 進行分析,將不會命中索引的查詢進行攔截,從而做到 100% 的事前預防。

由於 sqlc 的作者主要在 pg 環境下工作,所以對於 MySQL 查詢的支持並不好,但 sqlc 是當前社區中設計思路最好的數據訪問組件,未來我們會參考實現一套對 MySQL 支持更好的工具,希望幾個月內能夠開源出來。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5DIRKlWpr3pwx2h5YJhnAQ