線上有一塊業務,需要做大量的資料庫查詢以及編碼落盤的任務。資料庫查詢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。