本節將對 db/sql 官方標準庫作一些簡單分析,並介紹一些應用比較廣泛的開源 ORM 和 SQL Builder。並從企業級應用開發和公司架構的角度來分析哪種技術棧對於現代的企業級應用更為合適。
從 database/sql 講起
Go語言官方提供了 database/sql 包來給使用者進行和資料庫打交道的工作,實際上 database/sql 庫就只是提供了一套運算元據庫的介面和規範,例如抽象好的 SQL 預處理(prepare),連線池管理,資料系結,事務,錯誤處理等等。官方並沒有提供具體某種資料庫實現的協定支援。
和具體的資料庫,例如 MySQL 打交道,還需要再引入 MySQL 的驅動,像下面這樣:
import "database/sql"
import _ "github.com/go-sql-driver/mysql"
db, err := sql.Open("mysql", "user:[email protected]/dbname")
import _ "github.com/go-sql-driver/mysql"
這一句 import,實際上是呼叫了 mysql 包的 init 函數,做的事情也很簡單:
func init() {
sql.Register("mysql", &MySQLDriver{})
}
在 sql 包的全域性 map 裡把 mysql 這個名字的 driver 註冊上。實際上 Driver 在 sql 包中是一個介面:
type Driver interface {
Open(name string) (Conn, error)
}
呼叫 sql.Open() 返回的 db 物件實際上就是這裡的 Conn。
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error)
}
也是一個介面。實際上如果你仔細地檢視 database/sql/driver/driver.go 的程式碼會發現,這個檔案裡所有的成員全都是介面,對這些型別進行操作,實際上還是會呼叫具體的 driver 裡的方法。
從使用者的角度來講,在使用 database/sql 包的過程中,能夠使用的也就是這些介面裡提供的函數。來看一個使用 database/sql 和 go-sql-driver/mysql 的完整的例子:
package main
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
func main() {
// db 是一個 sql.DB 型別的物件
// 該物件執行緒安全,且內部已包含了一個連線池
// 連線池的選項可以在 sql.DB 的方法中設定,這裡為了簡單省略了
db, err := sql.Open("mysql", "user:[email protected](127.0.0.1:3306)/hello")
if err != nil {
log.Fatal(err)
}
defer db.Close()
var (
id int
name string
)
rows, err := db.Query("select id, name from users where id = ?", 1)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
// 必須要把 rows 裡的內容讀完,或者顯式呼叫 Close() 方法,
// 否則在 defer 的 rows.Close() 執行之前,連線永遠不會釋放
for rows.Next() {
err := rows.Scan(&id, &name)
if err != nil {
log.Fatal(err)
}
log.Println(id, name)
}
err = rows.Err()
if err != nil {
log.Fatal(err)
}
}
如果大家想了解官方這個 database/sql 庫更加詳細的用法的話,可以參考 http://go-database-sql.org/ 。
包括該庫的功能介紹、用法、注意事項和反直覺的一些實現方式(例如同一個 goroutine 內對 sql.DB 的查詢,可能在多個連線上)都有涉及,本章中不再贅述。
通過上面的介紹,也許大家已經發現了一些問題。官方的 db 庫提供的功能這麼簡單,我們每次去資料庫裡讀取內容豈不是都要去寫這麼一套差不多的程式碼?或者如果我們的物件是結構體,把 sql.Rows 係結到物件的工作就會變得更加得重複而無聊,所以社群才會有各種各樣的 SQL Builder 和 ORM 百花齊放。
提高生產效率的 ORM 和 SQL Builder
在 Web 開發領域常常提到的 ORM 是什麼?我們先看看萬能的維基百科:
物件關係對映(英語:Object Relational Mapping,簡稱 ORM,或 O/RM,或 O/Rmapping),是一種程式設計技術,用於實現物件導向程式語言裡不同型別系統的資料之間的轉換。
從效果上說,它其實是建立了一個可在程式語言裡使用的“虛擬物件資料庫”。
最為常見的 ORM 實際上做的是從 db 到程式的類或結構體這樣的對映。所以你手邊的程式可能是從 MySQL 的表對映你的程式內的類。我們可以先來看看其它的程式語言裡的 ORM 寫起來是怎麼樣的感覺:
>>> from blog.models import Blog
>>> b = Blog(name='Beatles Blog', tagline='All the latest Beatles news.')
>>> b.save()
完全沒有資料庫的痕跡,沒錯 ORM 的目的就是遮蔽掉 DB 層,實際上很多語言的 ORM 只要把你的類或結構體定義好,再用特定的語法將結構體之間的一對一或者一對多關係表達出來。那麼任務就完成了。然後你就可以對這些對映好了資料庫表的物件進行各種操作,例如 save,create,retrieve,delete。
至於 ORM 在背地裡做了什麼陰險的勾當,你是不一定清楚的。使用 ORM 的時候,我們往往比較容易有一種忘記了資料庫的直觀感受。舉個例子,我們有個需求:向使用者展示最新的商品列表,我們再假設,商品和商家是1:1的關聯關係,我們就很容易寫出像下面這樣的程式碼:
# 虛擬碼
shopList := []
for product in productList {
shopList = append(shopList, product.GetShop)
}
當然了,我們不能批判這樣寫程式碼的程式設計師是偷懶的程式設計師。因為 ORM 一類的工具在出發點上就是遮蔽 sql,讓我們對資料庫的操作更接近於人類的思維方式。這樣很多只接觸過 ORM 而且又是剛入行的程式設計師就很容易寫出上面這樣的程式碼。
這樣的程式碼將對資料庫的讀請求放大了 N 倍。也就是說,如果你的商品列表有 15 個 SKU,那麼每次使用者開啟這個頁面,至少需要執行 1(查詢商品列表)+ 15(查詢相關的商鋪資訊)次查詢。這裡 N 是 16。
如果你的列表頁很大,比如說有 600 個條目,那麼就至少要執行 1+600 次查詢。如果說你的資料庫能夠承受的最大的簡單查詢是 12 萬 QPS,而上述這樣的查詢正好是最常用的查詢的話,實際上能對外提供的服務能力是多少呢?是 200 qps!網際網路系統的忌諱之一,就是這種無端的讀放大。
當然,也可以說這不是 ORM 的問題,如果手寫 sql 還是可能會寫出差不多的程式,那麼再來看兩個 demo:
o := orm.NewOrm()
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)
很多 ORM 都提供了這種 Filter 型別的查詢方式,不過實際上在某些 ORM 背後甚至隱藏了非常難以察覺的細節,比如生成的 SQL 語句會自動 limit 1000。
也許喜歡 ORM 的讀者讀到這裡會反駁了,你是沒有認真閱讀文件就瞎寫。
是的,儘管這些 ORM 工具在文件裡說明了 All 查詢在不顯式地指定 Limit 的話會自動 limit 1000,但對於很多沒有閱讀過文件或者看過 ORM 原始碼的人,這依然是一個非常難以察覺的“魔鬼”細節。
喜歡強型別語言的人一般都不喜歡語言隱式地去做什麼事情,例如各種語言在賦值操作時進行的隱式型別轉換然後又在轉換中丟失了精度的勾當,一定讓你非常的頭疼。所以一個程式庫背地裡做的事情還是越少越好,如果一定要做,那也一定要在顯眼的地方做。比如上面的例子,去掉這種預設的自作聰明的行為,或者要求使用者強制傳入 limit 引數都是更好的選擇。
除了 limit 的問題,我們再看一遍這個下面的查詢:
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)
可以看得出來這個 Filter 是有表 join 的操作麼?當然了,有深入使用經驗的使用者還是會覺得這是在吹毛求疵。但這樣的分析想證明的是,ORM 想從設計上隱去太多的細節。而方便的代價是其背後的執行完全失控。這樣的專案在經過幾任維護人員之後,將變得面目全非,難以維護。
當然,我們不能否認 ORM 的進步意義,它的設計初衷就是為了讓資料的操作和儲存的具體實現所剝離。但是在上了規模的公司的人們漸漸達成了一個共識,由於隱藏重要的細節,ORM 可能是失敗的設計。其所隱藏的重要細節對於上了規模的系統開發來說至關重要。
相比 ORM 來說,SQL Builder 在 SQL 和專案可維護性之間取得了比較好的平衡。首先 sql builer 不像 ORM 那樣遮蔽了過多的細節,其次從開發的角度來講,SQL Builder 簡單進行封裝後也可以非常高效地完成開發,舉個例子:
where := map[string]interface{} {
"order_id > ?" : 0,
"customer_id != ?" : 0,
}
limit := []int{0,100}
orderBy := []string{"id asc", "create_time desc"}
orders := orderModel.GetList(where, limit, orderBy)
寫 SQL Builder 的相關程式碼,或者讀懂都不費勁。把這些程式碼腦內轉換為 sql 也不會太費勁。所以通過程式碼就可以對這個查詢是否命中資料庫索引,是否走了覆蓋索引,是否能夠用上聯合索引進行分析了。
說白了 SQL Builder 是 sql 在程式碼裡的一種特殊方言,如果你們沒有 DBA 但研發有自己分析和優化 sql 的能力,或者你們公司的 DBA 對於學習這樣一些 sql 的方言沒有異議。那麼使用 SQL Builder 是一個比較好的選擇,不會導致什麼問題。
另外在一些本來也不需要 DBA 介入的場景內,使用 SQL Builder 也是可以的,例如要做一套運維系統,且將 MySQL 當作了系統中的一個元件,系統的 QPS 不高,查詢不複雜等等。
一旦你做的是高並行的 OLTP 線上系統,且想在人員充足分工明確的前提下最大程度控制系統的風險,使用 SQL Builder 就不合適了。
脆弱的資料庫
無論是 ORM 還是 SQL Builder 都有一個致命的缺點,就是沒有辦法進行系統上線的事前 sql 稽核。雖然很多 ORM 和 SQL Builder 也提供了執行期列印 sql 的功能,但只在查詢的時候才能進行輸出。而 SQL Builder 和 ORM 本身提供的功能太過靈活。使得你不可能通過測試列舉出所有可能線上上執行的 sql。例如你可能用 SQL Builder 寫出下面這樣的程式碼:
where := map[string]interface{} {
"product_id = ?" : 10,
"user_id = ?" : 1232,
}
if order_id != 0 {
where["order_id = ?"] = order_id
}
res, err := historyModel.GetList(where, limit, orderBy)
你的系統裡有類似上述樣例的大量 if 的話,就難以通過測試用例來覆蓋到所有可能的 sql 組合了。這樣的系統只要發布,就已經孕育了初期的巨大風險。
對於現在 7 乘 24 服務的網際網路公司來說,服務不可用是非常重大的問題。儲存層的技術棧雖經歷了多年的發展,在整個系統中依然是最為脆弱的一環。系統宕機對於 24 小時對外提供服務的公司來說,意味著直接的經濟損失。個中風險不可忽視。
從行業分工的角度來講,現今的網際網路公司都有專職的 DBA。大多數 DBA 並不一定有寫程式碼的能力,去閱讀 SQL Builder 的相關“拼 SQL”程式碼多多少少還是會有一點障礙。從 DBA 角度出發,還是希望能夠有專門的事前 SQL 稽核機制,並能讓其低成本
地獲取到系統的所有 SQL 內容,而不是去閱讀業務研發編寫的 SQL Builder 的相關程式碼。
所以現如今,大型的網際網路公司核心線上業務都會在程式碼中把 SQL 放在顯眼的位置提供給 DBA 評審,舉一個例子:
const (
getAllByProductIDAndCustomerID = `select * from p_orders where product_id in (:product_id) and customer_id=:customer_id`
)
// GetAllByProductIDAndCustomerID
// @param driver_id
// @param rate_date
// @return []Order, error
func GetAllByProductIDAndCustomerID(ctx context.Context, productIDs []uint64, customerID uint64) ([]Order, error) {
var orderList []Order
params := map[string]interface{}{
"product_id" : productIDs,
"customer_id": customerID,
}
// getAllByProductIDAndCustomerID 是 const 型別的 sql 字串
sql, args, err := sqlutil.Named(getAllByProductIDAndCustomerID, params)
if err != nil {
return nil, err
}
err = dao.QueryList(ctx, sqldbInstance, sql, args, &orderList)
if err != nil {
return nil, err
}
return orderList, err
}
像這樣的程式碼,在上線之前把 DAO 層的變更集的 const 部分直接拿給 DBA 來進行稽核,就比較方便了。程式碼中的 sqlutil.Named 是類似於 sqlx 中的 Named 函數,同時支援 where 表示式中的比較操作符和 in。