記一次 Golang 資料庫查詢元件的優化。

搬磚程式設計師帶你飛發表於2021-10-29

歡迎到我的部落格中檢視

線上有一塊業務,需要做大量的資料庫查詢以及編碼落盤的任務。資料庫查詢20分鐘左右,大約有2kw條sql被執行。如果可以優化資料庫查詢的方法,可以節省一筆很大的開銷。

由於程式碼比較久遠,未能考證當時的資料查詢選型為什麼不適用orm,而是使用原生的方式自己構建。下面是核心的資料查詢程式碼:

func QueryHelperOne(db *sql.DB, result interface{}, query string, args ...interface{}) (err error) {

    // 資料庫查詢
        var rows *sql.Rows
        log.Debug(query, args)
        rows, err = db.Query(query, args...)
        if err != nil {
                return err
        }
        defer rows.Close()

        // 獲取列名稱,並轉換首字母大寫,用於和struct Field 匹配
        var columns []string
        columns, err = rows.Columns()
        if err != nil {
                return err
        }

        fields := make([]string, len(columns))
        for i, columnName := range columns {
                fields[i] = server.firstCharToUpper(columnName)
        }

    // 傳參必須是陣列 slice 指標
        rv := reflect.ValueOf(result)
        if rv.Kind() == reflect.Ptr {
                rv = rv.Elem()
        } else {
                return errors.New("Parameter result must be a slice pointer")
        }
        if rv.Kind() == reflect.Slice {
                elemType := rv.Type().Elem()
                if elemType.Kind() == reflect.Struct {
                        ev := reflect.New(elemType)
            // 申請slice 資料,之後賦值給result
                        nv := reflect.MakeSlice(rv.Type(), 0, 0)
                        ignoreData := make([][]byte, len(columns))

                        for rows.Next() { // for each rows
                // scanArgs 是掃描每行資料的引數
                // scanArgs 中儲存的是 struct 中field 的指標
                                scanArgs := make([]interface{}, len(fields))
                                for i, fieldName := range fields {
                                        fv := ev.Elem().FieldByName(fieldName)
                                        if fv.Kind() != reflect.Invalid {
                                                scanArgs[i] = fv.Addr().Interface()
                                        } else {
                                                ignoreData[i] = []byte{}
                                                scanArgs[i] = &ignoreData[i]
                                        }
                                }
                                err = rows.Scan(scanArgs...)
                                if err != nil {
                                        return err
                                }
                                nv = reflect.Append(nv, ev.Elem())
                        }
                        rv.Set(nv)
                }
        } else {
                return errors.New("Parameter result must be a slice pointer")
        }

        return
}

方法通過如下方式呼叫:

type TblUser struct {
    Id          int64
    Name        string
    Addr        string
    UpdateTime  string
}

result := []TblUser{}
QueryHelperOne(db, &result, query, 10)

直接看上面的程式碼,發現沒有什麼大的問題,但是從細節上不斷調優,可以讓效能壓榨到極致。

網路優化

golang 提供的db.Query(sql, args...) 方法,內部的實現,也是基於prepare 方法實現的。
prepare 有三個好處:

- 可以讓 mysql 省去每次語法分析的過程
- 可以避免出現sql 注入
- 可以重複使用prepare 的結果,只傳送引數即可做查詢

但是,也有不好的地方。一次 db.Query 會有三次網路請求。

  • prepare
  • execute
  • closing

而如果有多次相同SQL 查詢的話,這種方式是非常佔優的。因此,可以使用prepare 替換 db.Query 減少一次網路消耗。


var stmts = sync.Map{}
func QueryHelperOne(db *sql.DB, result interface{}, query string, args ...interface{}) (err error) {

    // 使用sync.Map 快取 query 對應的stmt
    // 減少不必要的prepare 請求
    var stmt *sql.Stmt
    if v, ok := stmts.Load(query); ok {
        stmt = v.(*sql.Stmt)
    } else {
        if stmt, err = db.Prepare(query); err != nil {
            return err
        } else {
            stmts.Store(query, stmt)
        }
    }

    var rows *sql.Rows
    log.Debug(query, args)
    rows, err = stmt.Query(args...)
    if err != nil {
        _ = stmt.Close()
        stmts.Delete(query)
        return err
    }
    defer rows.Close()

    // 後面程式碼省略 ...
}

通過此番修改,作業的效能提升了17%,效果還是非常明顯的。

GC 優化

優化1

在服務中,會預申請slice空間,因此無需每次構建的時候重新申請slice 記憶體。


// old code
// nv := reflect.MakeSlice(rv.Type(), 0, 0)
// new code
nv := rv.Slice(0, 0)

優化2

從程式碼56 行可以看到,每次會append 資料到陣列中。由於 結構體切片在append 時,是做記憶體拷貝;scanArgs 的資料由於每次scan 都會覆蓋,因此可以複用,不需要每次rows 的時候對映。

ev := reflect.New(elemType)
// 申請slice 資料,之後賦值給result
nv := reflect.MakeSlice(rv.Type(), 0, 0)
ignoreData := make([][]byte, len(columns))
// scanArgs 是掃描每行資料的引數
// scanArgs 中儲存的是 struct 中field 的指標
scanArgs := make([]interface{}, len(fields))
for i, fieldName := range fields {
        fv := ev.Elem().FieldByName(fieldName)
        if fv.Kind() != reflect.Invalid {
                scanArgs[i] = fv.Addr().Interface()
        } else {
                ignoreData[i] = []byte{}
                scanArgs[i] = &ignoreData[i]
        }
}
for rows.Next() { // for each rows
    err = rows.Scan(scanArgs...)
        if err != nil {
                return err
        }
        nv = reflect.Append(nv, ev.Elem())
}
rv.Set(nv)

減少了每行掃描的時候,新申請scanArgs

優化 3

對於不在field中的資料,需要使用一個空的值代替,上面程式碼使用的是一個[]byte 的切片,其實只需要一個[]byte 即可。程式碼如下:

ignoreData := []byte{}
// scanArgs 是掃描每行資料的引數
// scanArgs 中儲存的是 struct 中field 的指標
scanArgs := make([]interface{}, len(fields))
for i, fieldName := range fields {
        fv := ev.Elem().FieldByName(fieldName)
        if fv.Kind() != reflect.Invalid {
                scanArgs[i] = fv.Addr().Interface()
        } else {
                scanArgs[i] = &ignoreData
        }
}

優化 4

由於相同的sql會查詢次數在千萬級;因此可以把每次掃描行所需要的行元素ev,以及對應的掃描引數列表 scanArgs 都快取起來,再使用時從記憶體中載入即可。

// 定義資料池,用於儲存每個sql 對應的掃描行item 以及掃描引數
// 全域性程式碼
var datapools = sync.Map{}

type ReflectItem struct {
    Item     reflect.Value
    scanArgs []interface{}
}


///////// 方法呼叫內部

// 從資料池中載入query 對應的 ReflectItem
if v, ok := datapools.Load(query); ok {
    pool = v.(*sync.Pool)
} else {
    // 構建reflectItem
        var columns []string
        columns, err = rows.Columns()
        if err != nil {
                return err
        }

    pool = &sync.Pool{
        New: func() interface{} {
            fields := make([]string, len(columns))
            for i, columnName := range columns {
                fields[i] = server.firstCharToUpper(columnName)
            }

            ev := reflect.New(elemType) // New slice struct element
            // nv := reflect.MakeSlice(rv.Type(), 0, 0) // New slice for fill
            ignored := []byte{}
            scanArgs := make([]interface{}, len(fields))
            for i, fieldName := range fields {
                fv := ev.Elem().FieldByName(fieldName)
                if fv.Kind() != reflect.Invalid {
                    scanArgs[i] = fv.Addr().Interface()
                } else {
                    scanArgs[i] = &ignored
                }
            }
            return ReflectItem{
                Item:     ev,
                scanArgs: scanArgs,
            }
        },
    }
    datapools.Store(query, pool)
}
ri = pool.Get().(ReflectItem)

// 複用 ev 和 scanArgs
ev = ri.Item
scanArgs = ri.scanArgs

// 開始掃描
nv := rv.Slice(0, 0)
for rows.Next() { // for each rows
    err = rows.Scan(scanArgs...)
    if err != nil {
        return err
    }
    nv = reflect.Append(nv, ev.Elem())
}
rv.Set(nv) // return rows data back to caller
pool.Put(ri)
// 結束掃描

經過幾次優化,24分鐘執行完的作業,成功減少到了18分鐘。

總結

  • golang prepare 的實現,需要進一步瞭解,在使用prepare的情況下,連線是如何複用的,比較困惑。
  • 對於相同query 的情況,但是掃描struct 型別不同的情況,會有問題。掃描引數的資料池,應該使用結構體型別做key。

相關文章