Golang Mysql筆記(三)--- Prepared剖析

## prepare

前面我們已經(jīng)學(xué)習(xí)了sql的基本curd操作??傮w而言,有兩類操作Query和Exec。前者返回數(shù)據(jù)庫記錄,后者返回數(shù)據(jù)庫影響或插入相關(guān)的結(jié)果。上面兩種操作,多數(shù)是針對單次操作的查詢。如果需要批量插入一堆數(shù)據(jù),就可以使用Prepared語句。golang處理prepared語句有其獨特的行為,了解其底層的實現(xiàn),對于用好它十分重要。

查詢

我們可以使用Query方式查詢記錄,Query函數(shù)提供了兩種選擇,第一種情況下參數(shù)是拼接好的sql,另外一種情況,第一參數(shù)是帶有占位符的sql,第二個參數(shù)為sql的實際參數(shù)。

rows, err := db.Query("SELECT * FROM user WHERE gid = 1")
 
rows, err := db.Query("SELECT * FROM user WHERE gid = ?", 1)

上面兩種方式都能獲取數(shù)據(jù),那么他們的底層實現(xiàn)是一樣的么?實際上,上面兩種方式的底層通信不完全一樣。一種你是plaintext方式,另外一種是prepared方式。

prepared

所謂prepared,即帶有占位符的sql語句,客戶端將該語句和參數(shù)發(fā)給mysql服務(wù)器。mysql服務(wù)器編譯成一個prepared語句,這個語句可以根據(jù)不同的參數(shù)多次調(diào)用。prepared語句執(zhí)行的方式如下:

  1. 準(zhǔn)備prepare語句
  2. 執(zhí)行prepared語句和參數(shù)
  3. 關(guān)閉prepared語句

之所以會出現(xiàn)prepare語句方式,主要因為這樣有下面的兩個好處:

  1. 避免通過引號組裝拼接sql語句。避免sql注入帶來的安全風(fēng)險
  2. 可以多次執(zhí)行的sql語句。

單純的看prepared語句發(fā)好處,會下意識的覺得既然如此,都使用prepared語句查詢不就好了么?其實不然。關(guān)于prepared語句注意事項,稍后再討論。

golang的pliantext和prepare查詢方式

現(xiàn)在我們再回顧上面調(diào)用Qeury函數(shù)的兩個操作。對于第一個操作,執(zhí)行pliantext的sql語句。先看db.Query方法:

// Query executes a query that returns rows, typically a SELECT.
// The args are for any placeholder parameters in the query.
func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
 var rows *Rows
 var err error
 for i := 0; i < maxBadConnRetries; i++ {
  rows, err = db.query(query, args, cachedOrNewConn)    // 查詢
  if err != driver.ErrBadConn {
   break
  }
 }
 if err == driver.ErrBadConn {
  return db.query(query, args, alwaysNewConn)
 }
 return rows, err
}

Query方法我們很熟悉了,它的內(nèi)部調(diào)用了db.query方法,并且根據(jù)連接重連的狀況選擇是cachedOrNewConn模式還是alwaysNewConn模式。前者會從返回一個cached連接或者等待一個可用連接,甚至也可能建立一個新的連接;后者表示打開連接時的策略為每次建立一個新的連接。這就是簽名所說的retry10次連接。

func (db *DB) query(query string, args []interface{}, strategy connReuseStrategy) (*Rows, error) {
 ci, err := db.conn(strategy)
 if err != nil {
  return nil, err
 }

 return db.queryConn(ci, ci.releaseConn, query, args)
}

query方法邏輯很簡單,通過db.conn方法返回一個新創(chuàng)建或者緩存的空閑連接。driverConn。隨機調(diào)用queryConn方法。

// queryConn executes a query on the given connection.
// The connection gets released by the releaseConn function.
func (db *DB) queryConn(dc *driverConn, releaseConn func(error), query string, args []interface{}) (*Rows, error) {

 // 判斷驅(qū)動是否實現(xiàn)了Queryer
 if queryer, ok := dc.ci.(driver.Queryer); ok {
  dargs, err := driverArgs(nil, args)
  if err != nil {
   releaseConn(err)
   return nil, err
  }
  dc.Lock()
  rowsi, err := queryer.Query(query, dargs)  // 調(diào)用驅(qū)動的查詢方法  connection.go 第305行
  dc.Unlock()
  if err != driver.ErrSkip {  // 不帶參數(shù)的返回
   if err != nil {
    releaseConn(err)
    return nil, err
   }
   // Note: ownership of dc passes to the *Rows, to be freed
   // with releaseConn.
   rows := &Rows{
    dc:          dc,
    releaseConn: releaseConn,
    rowsi:       rowsi,
   }
   return rows, nil
  }
 }

 dc.Lock()
 si, err := dc.ci.Prepare(query)  // 帶參數(shù)的返回,創(chuàng)建prepare對象
 dc.Unlock()
 if err != nil {
  releaseConn(err)
  return nil, err
 }

 ds := driverStmt{dc, si}
 rowsi, err := rowsiFromStatement(ds, args...)   // 執(zhí)行語句
 if err != nil {
  dc.Lock()
  si.Close()
  dc.Unlock()
  releaseConn(err)
  return nil, err
 }

 // Note: ownership of ci passes to the *Rows, to be freed
 // with releaseConn.
 rows := &Rows{
  dc:          dc,
  releaseConn: releaseConn,
  rowsi:       rowsi,
  closeStmt:   si,
 }
 return rows, nil
}

queryConn函數(shù)內(nèi)容比較多。先判斷驅(qū)動是否實現(xiàn)了Queryer,如果實現(xiàn)了即調(diào)用其Query方法。方法會針對sql查詢語句做查詢。例如mysql的驅(qū)動如下,connection.go 第305行左右,即:

func (mc *mysqlConn) Query(query string, args []driver.Value) (driver.Rows, error) {
 if mc.netConn == nil {
  errLog.Print(ErrInvalidConn)
  return nil, driver.ErrBadConn
 }
 if len(args) != 0 {
  if !mc.cfg.InterpolateParams {
   return nil, driver.ErrSkip
  }
  // try client-side prepare to reduce roundtrip
  prepared, err := mc.interpolateParams(query, args)
  if err != nil {
   return nil, err
  }
  query = prepared
  args = nil
 }
 // Send command
 err := mc.writeCommandPacketStr(comQuery, query)
 if err == nil {
  // Read Result
  var resLen int
  resLen, err = mc.readResultSetHeaderPacket()
  if err == nil {
   rows := new(textRows)
   rows.mc = mc

   if resLen == 0 {
    // no columns, no more data
    return emptyRows{}, nil
   }
   // Columns
   rows.columns, err = mc.readColumns(resLen)
   return rows, err
  }
 }
 return nil, err
}

Query先檢查參數(shù)是否為0,然后調(diào)用writeCommandPacketStr方法執(zhí)行sql并通過readResultSetHeaderPacket讀取數(shù)據(jù)庫服務(wù)返回的結(jié)果。
如果參數(shù)不為0,會先判斷是否是prepared語句。這里會返回一個driver.ErrSkip錯誤。把函數(shù)執(zhí)行權(quán)再返回到queryConn函數(shù)中。然后再調(diào)用si, err := dc.ci.Prepare(query)創(chuàng)建Stmt對象,接下來調(diào)用rowsiFromStatement執(zhí)行查詢:

func rowsiFromStatement(ds driverStmt, args ...interface{}) (driver.Rows, error) {
 ds.Lock()
 want := ds.si.NumInput()
 ds.Unlock()

 // -1 means the driver doesn't know how to count the number of
 // placeholders, so we won't sanity check input here and instead let the
 // driver deal with errors.
 if want != -1 && len(args) != want {
  return nil, fmt.Errorf("sql: statement expects %d inputs; got %d", want, len(args))
 }

 dargs, err := driverArgs(&ds, args)
 if err != nil {
  return nil, err
 }

 ds.Lock()
 rowsi, err := ds.si.Query(dargs)
 ds.Unlock()
 if err != nil {
  return nil, err
 }
 return rowsi, nil
}

rowsiFromStatement方法會調(diào)用驅(qū)動的ds.si.Query(dargs)方法,執(zhí)行最后的查詢。大概再statement.go的第84行

func (stmt *mysqlStmt) Query(args []driver.Value) (driver.Rows, error) {
 if stmt.mc.netConn == nil {
  errLog.Print(ErrInvalidConn)
  return nil, driver.ErrBadConn
 }

 // Send command
 err := stmt.writeExecutePacket(args)
 if err != nil {
  return nil, err
 }

 mc := stmt.mc

 // Read Result
 resLen, err := mc.readResultSetHeaderPacket()
 if err != nil {
  return nil, err
 }

 rows := new(binaryRows)

 if resLen > 0 {
  rows.mc = mc
  // Columns
  // If not cached, read them and cache them
  if stmt.columns == nil {
   rows.columns, err = mc.readColumns(resLen)
   stmt.columns = rows.columns
  } else {
   rows.columns = stmt.columns
   err = mc.readUntilEOF()
  }
 }

 return rows, err
}

調(diào)用 stmt和參數(shù)執(zhí)行sql查詢。查詢完畢之后,返回到queryConn方法中,使用releaseConn釋放查詢的數(shù)據(jù)庫連接。

自定義prepare 查詢

從query查詢可以看到,對于占位符的prepare語句,go內(nèi)部通過的dc.ci.Prepare(query)會自動創(chuàng)建一個 stmt對象。其實我們也可以自定義stmt語句,使用方式如下:

 stmt, err := db.Prepare("SELECT * FROM user WHERE gid = ?")
 if err != nil {
  log.Fatalln(err)
 }
 defer stmt.Close()

 rows, err :=  stmt.Query(1)
 if err != nil{
  log.Fatalln(err)
 }

即通過Prepare方法創(chuàng)建一個stmt對象,然后執(zhí)行stmt對象的Query(Exec)方法得到sql.Rows結(jié)果集。最后關(guān)閉stmt.Close。這個過程就和之前所說的prepare三步驟匹配了。

創(chuàng)建stmt的preprea方式是golang的一個設(shè)計,其目的是Prepare once, execute many times。為了批量執(zhí)行sql語句。但是通常會造成所謂的三次網(wǎng)絡(luò)請求( three network round-trips)。即preparing executing和closing三次請求。

對于大多數(shù)數(shù)據(jù)庫,prepread的過程都是,先發(fā)送一個帶占位符的sql語句到服務(wù)器,服務(wù)器返回一個statement id,然后再把這個id和參數(shù)發(fā)送給服務(wù)器執(zhí)行,最后再發(fā)送關(guān)閉statement命令。

golang的實現(xiàn)了連接池,處理prepare方式也需要特別注意。調(diào)用Prepare方法返回stmt的時候,golang會在某個空閑的連接上進行prepare語句,然后就把連接釋放回到連接池,可是golang會記住這個連接,當(dāng)需要執(zhí)行參數(shù)的時候,就再次找到之前記住的連接進行執(zhí)行,等到stmt.Close調(diào)用的時候,再釋放該連接。

在執(zhí)行參數(shù)的時候,如果記住的連接正處于忙碌階段,此時golang將會從新選一個新的空閑連接進行prepare(re-prepare)。當(dāng)然,即使是重新reprepare,同樣也會遇到剛才的問題。那么將會一而再再而三的進行reprepare。直到找到空閑連接進行查詢的時候。

這種情況將會導(dǎo)致leak連接的情況,尤其是再高并發(fā)的情景。將會導(dǎo)致大量的prepare過程。因此使用stmt的情況需要仔細考慮應(yīng)用場景,通常在應(yīng)用程序中。多次執(zhí)行同一個sql語句的情況并不多,因此減少prepare語句的使用。

之前有一個疑問,是不是所有sql語句都不能帶占位符,因為這是prepare語句。只要看了一遍database/sql和驅(qū)動的源碼才恍然大悟,對于query(prepare, args)的方式,正如我們前面所分析的,database/sql會使用ds.si.Query(dargs)創(chuàng)建stmt的,然后就立即執(zhí)行prepare和參數(shù),最后關(guān)閉stmt。整個過程都是同一個連接上完成,因此不存在reprepare的情況。當(dāng)然也無法使用所謂的創(chuàng)建一次,執(zhí)行多次的目。

對于prepare的使用方式,基于其好處和缺點,我們將會再后面的最佳實踐再討論。目前需要注意的大致就是:

  1. 單次查詢不需要使用prepared,每次使用stmt語句都是三次網(wǎng)絡(luò)請求次數(shù),prepared execute close

  2. 不要循環(huán)中創(chuàng)建prepare語句

  3. 注意關(guān)閉 stmt

盡管會有reprepare過程,這些操作依然是database/sql幫我們所做的,與連接retry10次一樣,開發(fā)者無需擔(dān)心。

對于Qeruy操作如此,同理Exec操作也一樣。

總結(jié)

目前我們學(xué)習(xí)database/sql提供兩類查詢操作,Query和Exec方法。他們都可以使用plaintext和preprea方式查詢。對于后者,可以有效的避免數(shù)據(jù)庫注入。而prepare方式又可以有顯示的聲明stmt對象,也有隱藏的方式。顯示的創(chuàng)建stmt會有3次網(wǎng)絡(luò)請求,創(chuàng)建->執(zhí)行->關(guān)閉,再批量操作可以考慮這種做法,另外一種方式創(chuàng)建prepare后就執(zhí)行,因此不會因為reprepare導(dǎo)致高并發(fā)下的leak連接問題。

具體使用那種方式,還得基于應(yīng)用場景,安全過濾和連接管理等考慮。至此,關(guān)于查詢和執(zhí)行操作已經(jīng)介紹了很多。關(guān)系型數(shù)據(jù)庫的另外一個特性就是關(guān)系和事務(wù)處理。下一節(jié),我們將會討論database/sql的數(shù)據(jù)庫事務(wù)功能。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容