Go 語(yǔ)言 和數(shù)據(jù)庫(kù)打交道

2023-03-22 15:04 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-05-database.html


5.5 Database 和數(shù)據(jù)庫(kù)打交道

本節(jié)將對(duì) db/sql 官方標(biāo)準(zhǔn)庫(kù)作一些簡(jiǎn)單分析,并介紹一些應(yīng)用比較廣泛的開源 ORM 和 SQL Builder。并從企業(yè)級(jí)應(yīng)用開發(fā)和公司架構(gòu)的角度來(lái)分析哪種技術(shù)棧對(duì)于現(xiàn)代的企業(yè)級(jí)應(yīng)用更為合適。

5.5.1 從 database/sql 講起

Go 官方提供了 database/sql 包來(lái)給用戶進(jìn)行和數(shù)據(jù)庫(kù)打交道的工作,database/sql 庫(kù)實(shí)際只提供了一套操作數(shù)據(jù)庫(kù)的接口和規(guī)范,例如抽象好的 SQL 預(yù)處理(prepare),連接池管理,數(shù)據(jù)綁定,事務(wù),錯(cuò)誤處理等等。官方并沒(méi)有提供具體某種數(shù)據(jù)庫(kù)實(shí)現(xiàn)的協(xié)議支持。

和具體的數(shù)據(jù)庫(kù),例如 MySQL 打交道,還需要再引入 MySQL 的驅(qū)動(dòng),像下面這樣:

import "database/sql"
import _ "github.com/go-sql-driver/mysql"

db, err := sql.Open("mysql", "user:password@/dbname")
import _ "github.com/go-sql-driver/mysql"

這條 import 語(yǔ)句會(huì)調(diào)用了 mysql 包的 init 函數(shù),做的事情也很簡(jiǎn)單:

func init() {
    sql.Register("mysql", &MySQLDriver{})
}

在 sql 包的全局 map 里把 mysql 這個(gè)名字的 driver 注冊(cè)上。Driver 在 sql 包中是一個(gè)接口:

type Driver interface {
    Open(name string) (Conn, error)
}

調(diào)用 sql.Open() 返回的 db 對(duì)象就是這里的 Conn。

type Conn interface {
    Prepare(query string) (Stmt, error)
    Close() error
    Begin() (Tx, error)
}

也是一個(gè)接口。如果你仔細(xì)地查看 database/sql/driver/driver.go 的代碼會(huì)發(fā)現(xiàn),這個(gè)文件里所有的成員全都是接口,對(duì)這些類型進(jìn)行操作,還是會(huì)調(diào)用具體的 driver 里的方法。

從用戶的角度來(lái)講,在使用 database/sql 包的過(guò)程中,你能夠使用的也就是這些接口里提供的函數(shù)。來(lái)看一個(gè)使用 database/sql 和 go-sql-driver/mysql 的完整的例子:

package main

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

func main() {
    // db 是一個(gè) sql.DB 類型的對(duì)象
    // 該對(duì)象線程安全,且內(nèi)部已包含了一個(gè)連接池
    // 連接池的選項(xiàng)可以在 sql.DB 的方法中設(shè)置,這里為了簡(jiǎn)單省略了
    db, err := sql.Open("mysql",
        "user:password@tcp(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 里的內(nèi)容讀完,或者顯式調(diào)用 Close() 方法,
    // 否則在 defer 的 rows.Close() 執(zhí)行之前,連接永遠(yuǎn)不會(huì)釋放
    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)
    }
}

如果讀者想了解官方這個(gè) database/sql 庫(kù)更加詳細(xì)的用法的話,可以參考 http://go-database-sql.org/。

包括該庫(kù)的功能介紹、用法、注意事項(xiàng)和反直覺的一些實(shí)現(xiàn)方式(例如同一個(gè) goroutine 內(nèi)對(duì) sql.DB 的查詢,可能在多個(gè)連接上)都有涉及,本章中不再贅述。

聰明如你的話,在上面這段簡(jiǎn)短的程序中可能已經(jīng)嗅出了一些不好的味道。官方的 db 庫(kù)提供的功能這么簡(jiǎn)單,我們每次去數(shù)據(jù)庫(kù)里讀取內(nèi)容豈不是都要去寫這么一套差不多的代碼?或者如果我們的對(duì)象是結(jié)構(gòu)體,把 sql.Rows 綁定到對(duì)象的工作就會(huì)變得更加得重復(fù)而無(wú)聊。

是的,所以社區(qū)才會(huì)有各種各樣的 SQL Builder 和 ORM 百花齊放。

5.5.2 提高生產(chǎn)效率的 ORM 和 SQL Builder

在 Web 開發(fā)領(lǐng)域常常提到的 ORM 是什么?我們先看看萬(wàn)能的維基百科:

對(duì)象關(guān)系映射(英語(yǔ):Object Relational Mapping,簡(jiǎn)稱 ORM,或 O/RM,或 O/R mapping),是一種程序設(shè)計(jì)技術(shù),用于實(shí)現(xiàn)面向?qū)ο缶幊陶Z(yǔ)言里不同類型系統(tǒng)的數(shù)據(jù)之間的轉(zhuǎn)換。 從效果上說(shuō),它其實(shí)是創(chuàng)建了一個(gè)可在編程語(yǔ)言里使用的 “虛擬對(duì)象數(shù)據(jù)庫(kù)”。

最為常見的 ORM 做的是從 db 到程序的類或結(jié)構(gòu)體這樣的映射。所以你手邊的程序可能是從 MySQL 的表映射你的程序內(nèi)的類。我們可以先來(lái)看看其它的程序語(yǔ)言里的 ORM 寫起來(lái)是怎么樣的感覺:

>>> from blog.models import Blog
>>> b = Blog(name='Beatles Blog', tagline='All the latest Beatles news.')
>>> b.save()

完全沒(méi)有數(shù)據(jù)庫(kù)的痕跡,沒(méi)錯(cuò),ORM 的目的就是屏蔽掉 DB 層,很多語(yǔ)言的 ORM 只要把你的類或結(jié)構(gòu)體定義好,再用特定的語(yǔ)法將結(jié)構(gòu)體之間的一對(duì)一或者一對(duì)多關(guān)系表達(dá)出來(lái)。那么任務(wù)就完成了。然后你就可以對(duì)這些映射好了數(shù)據(jù)庫(kù)表的對(duì)象進(jìn)行各種操作,例如 save、create、retrieve、delete。至于 ORM 在背地里做了什么陰險(xiǎn)的勾當(dāng),你是不一定清楚的。使用 ORM 的時(shí)候,我們往往比較容易有一種忘記了數(shù)據(jù)庫(kù)的直觀感受。舉個(gè)例子,我們有個(gè)需求:向用戶展示最新的商品列表,我們?cè)偌僭O(shè),商品和商家是 1:1 的關(guān)聯(lián)關(guān)系,我們就很容易寫出像下面這樣的代碼:

# 偽代碼
shopList := []
for product in productList {
    shopList = append(shopList, product.GetShop)
}

當(dāng)然了,我們不能批判這樣寫代碼的程序員是偷懶的程序員。因?yàn)?ORM 一類的工具在出發(fā)點(diǎn)上就是屏蔽 sql,讓我們對(duì)數(shù)據(jù)庫(kù)的操作更接近于人類的思維方式。這樣很多只接觸過(guò) ORM 而且又是剛?cè)胄械某绦騿T就很容易寫出上面這樣的代碼。

這樣的代碼將對(duì)數(shù)據(jù)庫(kù)的讀請(qǐng)求放大了 N 倍。也就是說(shuō),如果你的商品列表有 15 個(gè) SKU,那么每次用戶打開這個(gè)頁(yè)面,至少需要執(zhí)行 1(查詢商品列表)+ 15(查詢相關(guān)的商鋪信息)次查詢。這里 N 是 16。如果你的列表頁(yè)很大,比如說(shuō)有 600 個(gè)條目,那么你就至少要執(zhí)行 1+600 次查詢。如果說(shuō)你的數(shù)據(jù)庫(kù)能夠承受的最大的簡(jiǎn)單查詢是 12 萬(wàn) QPS,而上述這樣的查詢正好是你最常用的查詢的話,你能對(duì)外提供的服務(wù)能力是多少呢?是 200 qps!互聯(lián)網(wǎng)系統(tǒng)的忌諱之一,就是這種無(wú)端的讀放大。

當(dāng)然,你也可以說(shuō)這不是 ORM 的問(wèn)題,如果你手寫 sql 你還是可能會(huì)寫出差不多的程序,那么再來(lái)看兩個(gè) demo:

o := orm.NewOrm()
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)

很多 ORM 都提供了這種 Filter 類型的查詢方式,不過(guò)在某些 ORM 背后可能隱藏了非常難以察覺的細(xì)節(jié),比如生成的 SQL 語(yǔ)句會(huì)自動(dòng) limit 1000。

也許喜歡 ORM 的讀者讀到這里會(huì)反駁了,你是沒(méi)有認(rèn)真閱讀文檔就瞎寫。是的,盡管這些 ORM 工具在文檔里說(shuō)明了 All 查詢?cè)诓伙@式地指定 Limit 的話會(huì)自動(dòng) limit 1000,但對(duì)于很多沒(méi)有閱讀過(guò)文檔或者看過(guò) ORM 源碼的人,這依然是一個(gè)非常難以察覺的 “魔鬼” 細(xì)節(jié)。喜歡強(qiáng)類型語(yǔ)言的人一般都不喜歡語(yǔ)言隱式地去做什么事情,例如各種語(yǔ)言在賦值操作時(shí)進(jìn)行的隱式類型轉(zhuǎn)換然后又在轉(zhuǎn)換中丟失了精度的勾當(dāng),一定讓你非常的頭疼。所以一個(gè)程序庫(kù)背地里做的事情還是越少越好,如果一定要做,那也一定要在顯眼的地方做。比如上面的例子,去掉這種默認(rèn)的自作聰明的行為,或者要求用戶強(qiáng)制傳入 limit 參數(shù)都是更好的選擇。

除了 limit 的問(wèn)題,我們?cè)倏匆槐檫@個(gè)下面的查詢:

num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)

你可以看得出來(lái)這個(gè) Filter 是有表 join 的操作么?當(dāng)然了,有深入使用經(jīng)驗(yàn)的用戶還是會(huì)覺得這是在吹毛求疵。但這樣的分析想證明的是,ORM 想從設(shè)計(jì)上隱去太多的細(xì)節(jié)。而方便的代價(jià)是其背后的運(yùn)行完全失控。這樣的項(xiàng)目在經(jīng)過(guò)幾任維護(hù)人員之后,將變得面目全非,難以維護(hù)。

當(dāng)然,我們不能否認(rèn) ORM 的進(jìn)步意義,它的設(shè)計(jì)初衷就是為了讓數(shù)據(jù)的操作和存儲(chǔ)的具體實(shí)現(xiàn)相剝離。但是在上了規(guī)模的公司的人們漸漸達(dá)成了一個(gè)共識(shí),由于隱藏重要的細(xì)節(jié),ORM 可能是失敗的設(shè)計(jì)。其所隱藏的重要細(xì)節(jié)對(duì)于上了規(guī)模的系統(tǒng)開發(fā)來(lái)說(shuō)至關(guān)重要。

相比 ORM 來(lái)說(shuō),SQL Builder 在 SQL 和項(xiàng)目可維護(hù)性之間取得了比較好的平衡。首先 sql builder 不像 ORM 那樣屏蔽了過(guò)多的細(xì)節(jié),其次從開發(fā)的角度來(lái)講,SQL Builder 進(jìn)行簡(jiǎn)單封裝后也可以非常高效地完成開發(fā),舉個(gè)例子:

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 的相關(guān)代碼,或者讀懂都不費(fèi)勁。把這些代碼腦內(nèi)轉(zhuǎn)換為 sql 也不會(huì)太費(fèi)勁。所以通過(guò)代碼就可以對(duì)這個(gè)查詢是否命中數(shù)據(jù)庫(kù)索引,是否走了覆蓋索引,是否能夠用上聯(lián)合索引進(jìn)行分析了。

說(shuō)白了 SQL Builder 是 sql 在代碼里的一種特殊方言,如果你們沒(méi)有 DBA 但研發(fā)有自己分析和優(yōu)化 sql 的能力,或者你們公司的 DBA 對(duì)于學(xué)習(xí)這樣一些 sql 的方言沒(méi)有異議。那么使用 SQL Builder 是一個(gè)比較好的選擇,不會(huì)導(dǎo)致什么問(wèn)題。

另外在一些本來(lái)也不需要 DBA 介入的場(chǎng)景內(nèi),使用 SQL Builder 也是可以的,例如你要做一套運(yùn)維系統(tǒng),且將 MySQL 當(dāng)作了系統(tǒng)中的一個(gè)組件,系統(tǒng)的 QPS 不高,查詢不復(fù)雜等等。

一旦你做的是高并發(fā)的 OLTP 在線系統(tǒng),且想在人員充足分工明確的前提下最大程度控制系統(tǒng)的風(fēng)險(xiǎn),使用 SQL Builder 就不合適了。

5.5.3 脆弱的數(shù)據(jù)庫(kù)

無(wú)論是 ORM 還是 SQL Builder 都有一個(gè)致命的缺點(diǎn),就是沒(méi)有辦法進(jìn)行系統(tǒng)上線的事前 sql 審核。雖然很多 ORM 和 SQL Builder 也提供了運(yùn)行期打印 sql 的功能,但只在查詢的時(shí)候才能進(jìn)行輸出。而 SQL Builder 和 ORM 本身提供的功能太過(guò)靈活。使得你不可能通過(guò)測(cè)試枚舉出所有可能在線上執(zhí)行的 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)

你的系統(tǒng)里有大量類似上述樣例的 if 的話,就難以通過(guò)測(cè)試用例來(lái)覆蓋到所有可能的 sql 組合了。

這樣的系統(tǒng)只要發(fā)布,就已經(jīng)孕育了初期的巨大風(fēng)險(xiǎn)。

對(duì)于現(xiàn)在 7 乘 24 服務(wù)的互聯(lián)網(wǎng)公司來(lái)說(shuō),服務(wù)不可用是非常重大的問(wèn)題。存儲(chǔ)層的技術(shù)棧雖經(jīng)歷了多年的發(fā)展,在整個(gè)系統(tǒng)中依然是最為脆弱的一環(huán)。系統(tǒng)宕機(jī)對(duì)于 24 小時(shí)對(duì)外提供服務(wù)的公司來(lái)說(shuō),意味著直接的經(jīng)濟(jì)損失。各種風(fēng)險(xiǎn)不可忽視。

從行業(yè)分工的角度來(lái)講,現(xiàn)今的互聯(lián)網(wǎng)公司都有專職的 DBA。大多數(shù) DBA 并不一定有寫代碼的能力,去閱讀 SQL Builder 的相關(guān) “拼 SQL” 代碼多多少少還是會(huì)有一點(diǎn)障礙。從 DBA 角度出發(fā),還是希望能夠有專門的事前 SQL 審核機(jī)制,并能讓其低成本地獲取到系統(tǒng)的所有 SQL 內(nèi)容,而不是去閱讀業(yè)務(wù)研發(fā)編寫的 SQL Builder 的相關(guān)代碼。

所以現(xiàn)如今,大型的互聯(lián)網(wǎng)公司核心線上業(yè)務(wù)都會(huì)在代碼中把 SQL 放在顯眼的位置提供給 DBA 評(píng)審,舉一個(gè)例子:

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 來(lái)進(jìn)行審核,就比較方便了。代碼中的 sqlutil.Named 是類似于 sqlx 中的 Named 函數(shù),同時(shí)支持 where 表達(dá)式中的比較操作符和 in。

這里為了說(shuō)明簡(jiǎn)便,函數(shù)寫得稍微復(fù)雜一些,仔細(xì)思考一下的話查詢的導(dǎo)出函數(shù)還可以進(jìn)一步進(jìn)行簡(jiǎn)化。請(qǐng)讀者朋友們自行嘗試。



以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)