【升職加薪祕籍】我在服務監控方面的實踐(6)-業務維度的mysql監控

2023-08-22 18:02:24

大家好,我是藍胖子,關於效能分析的視訊和文章我也大大小小出了有一二十篇了,算是已經有了一個系列,之前的程式碼已經上傳到github.com/HobbyBear/performance-analyze,接下來這段時間我將在之前內容的基礎上,結合自己在公司生產上構建監控系統的經驗,詳細的展示如何對線上服務進行監控,內容涉及到的指標設計,軟體設定,監控方案等等你都可以拿來直接復刻到你的專案裡,這是一套非常適合中小企業的監控體系。

在上一節我們是講解了如何對應用服務進行監控,這一節我將會介紹如何對mysql進行監控,在傳統監控mysql(對mysql整體服務質量的監控)的情況下,建立對錶級別的監控,以及長事務,複雜sql的監控,並能定位到具體程式碼。

監控系列的程式碼已經上傳到github

github.com/HobbyBear/easymonitor

無論是前文提到的 機器監控還是應用監控,我們都提到了四大黃金指標原則,對mysql 建立監控指標,我們依然可以從這幾個維度去對mysql的指標進行分析。

四大黃金指標是流量,延遲,飽和度,錯誤數。

對於流量而言可以體現在mysql資料庫操作的qps,資料庫伺服器進行流量大小。對於延遲,可以體現在慢查詢記錄上,飽和度可以用資料庫的連線數,執行緒數,或者磁碟空間,cpu,記憶體等各種硬體資源來反映資料庫的飽和情況。錯誤數則可以用,資料庫存取報錯資訊來反應,比如連線不足,超時等錯誤。

由於我們是用的雲資料庫,上面提到的這些監控維度以及面板在雲廠商那裡其實都基本覆蓋了,我稱這些監控面板或者維度是資料庫的傳統監控指標。 這些指標能夠反應資料庫監控狀況,但對於開發來講,去進行問題排查還遠遠不足的,下面我講下如果只有此型別的監控會有什麼缺點以及我的解決思路。

傳統監控指標痛點

在使用它們對mysql進行監控時當異常發生時,不是能很好的確定是哪部分業務導致的問題。比如,當你發現資料庫的qps突然升高,但是介面qps比較低的時候,如何確定資料庫qps升高的原因呢? 這中間存在的問題在於mysql的資料監控指標和應用服務程式碼邏輯沒有很好的關聯性,我們要如何去建立這種關聯絡?

答案就是建立表級別的監控,你可以發現傳統的監控指標都是對mysql整體服務質量進行的監控,而應用業務邏輯程式碼本質上是對錶進行操作,如果建立了表級別的監控,就能將業務與資料庫監控指標聯絡起來。比如按表級別建立單個表的qps,當發現資料庫整體的qps升高時,可以發現這是由於哪張表引起的,進而定位到具體業務,檢視程式碼邏輯看看是哪部分邏輯會操作這張表那麼多次。

下面我們就來看看如何建立表級別的監控。

建立表級別的監控

mysql的performance schema其實已經暴露了表級別的某些監控項,不過由於某些原因我們線上並沒有開啟它,並且由於直接使用performance schema暴露的監控指標不能客製化化,所以我將介紹一種在應用伺服器端埋點的方式建立表級別的qps。我們生產上是golang的應用服務,所以我會用它來舉例。

用github.com/go-sql-driver/mysql 在golang中開啟一個mysql連線是這樣做:

db, err = sql.Open("mysql", connStr)

sql.Open的第一個引數是驅動名,預設的驅動名是mysql,這個驅動是引入github.com/go-sql-driver/mysql時自動去建立的。

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

所以,我們完全可以包裝預設驅動,自定義一個自己的驅動,驅動實現了open介面返回一個連線Conn的介面型別。

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

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

type Stmt interface {
Close() error
NumInput() int
Exec(args []Value) (Result, error)
Query(args []Value) (Rows, error)

}

自定義的驅動型別在實現Open方法時,也可以自定義一個Conn連線型別,然後再實現它的查詢介面,進行sql語句分析,解析表名後進行埋點統計。

完整的定義驅動程式碼已經上傳到文章開頭的github地址,總之,你需要明白,通過對預設驅動的包裝,我們可以在sql執行前後做一些自定義的監控分析。我們定義了3個勾點函數,分別針對sql執行前後以及報錯做了監控分析。

對於sql埋點的原理更詳細的講解可以參考go database sql介面分析及sql埋點實現

// sql執行前做監控
if ctx, err = stmt.hooks.Before(ctx, stmt.query, list...); err != nil {  
   return nil, err  
}  
// sql執行  
results, err := stmt.execContext(ctx, args)  
if err != nil { 
   // sql 報錯時做監控
   return results, handlerErr(ctx, stmt.hooks, err, stmt.query, list...)  
}
// sql執行後做監控
if _, err := stmt.hooks.After(ctx, stmt.query, list...); err != nil {  
   return nil, err  
}

在sql執行前,通過SqlMonitor.parseTable對sql語句的分析,解析出當前sql涉及的表名,以及操作型別,是insert,select,delete,還是update,並且如果sql涉及到了多張表,那麼會對其打上MultiTable的標籤(這在下面講sql審計時會提到),sql執行前的勾點函數如下所示:

func (h *HookDb) Before(ctx context.Context, query string, args ...interface{}) (context.Context, error) {  
   ctx = context.WithValue(ctx, ctxKeyBeginTime, time.Now())  

   // 得到sql涉及的表名,以及這條sql是屬於什麼crud的哪種型別
   tables, op, err := SqlMonitor.parseTable(query)  
   if err != nil || op == Unknown {  
      log.WithError(err).WithField(ctxKeySql, query).WithField("op", op).Error("parse sql fail")  
   }  
   if len(tables) >= 2 {  
      ctx = context.WithValue(ctx, ctxKeyMultiTable, 1)  
   }  
   if len(tables) >= 1 {  
      ctx = context.WithValue(ctx, ctxKeyTbName, tables[0])  
   }  
   if op != Unknown {  
      ctx = context.WithValue(ctx, ctxKeyOp, op)  
   }  
   return ctx, nil  
}

分析出了表名並且記錄上了sql的執行時長,我們可以利用prometheus的histogram 型別的指標建立表維度的p99延遲分位數,並且能夠知道表級別的qps數量,如下,我們可以在sql執行後的勾點函數裡完成統計,MetricMonitor.RecordClientHandlerSeconds封裝了這個邏輯。

func (h *HookDb) After(ctx context.Context, query string, args ...interface{}) (context.Context, error) {  
  // ....
   if tbnameInf := ctx.Value(ctxKeyTbName); tbnameInf != nil && len(tbnameInf.(string)) != 0 {  
      tableName = tbnameInf.(string)  
      MetricMonitor.RecordClientHandlerSeconds(TypeMySQL, string(ctx.Value(ctxKeyOp).(SqlOp)), tbnameInf.(string), h.dbName, now.Sub(beginTime).Seconds())  
      // .....
   }

我們可以利用這個指標在grafana上完成表級別的監控面板。

對於資料庫還有要需要注意的地方,那就是長事務和複雜sql,慢sql的監控,往往出現上述情況時,就容易出現資料庫的效能問題。現在我們來看看如何監控它們。

長事務監控

首先,來看下長事務的監控,我們可以為連線型別實現BeginTx方法,對原始driver.ConnBeginTx 事務型別進行包裝,讓事務攜帶上開始時間。

func (conn *Conn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {  
   tx, err := conn.Conn.(driver.ConnBeginTx).BeginTx(ctx, opts)  
   if err != nil {  
      return tx, err  
   }  
   return &DriveTx{tx: tx, start: time.Now()}, nil  
}

接著,在提交事務的時候,判斷時間是不是超過某個8s,超過了則記錄一條錯誤紀錄檔,並把堆疊資訊也列印出來,這樣方便定位是哪段邏輯產生的長事務。由於我們的錯誤等級的紀錄檔會被收集起來自動報警,這樣就完成了長事務的實時監控報警。

func (d *DriveTx) Commit() error {  
   err := d.tx.Commit()  
   d.cost = time.Now().Sub(d.start).Milliseconds()  
   if d.cost > 8000 {  
      data := log.Fields{  
         Cost:       d.cost,  
         MetricType: "longTx",  
         Stack:      fmt.Sprintf("%+v", getStack()),  
      }  
      log.WithFields(data).Errorf("mysqlongTxlog ")  
   }  
   return err  
}

sql審計

接著,我們看下sql審計如何做,mysql可以開啟sql審計的設定項,不過開啟後將會採集所有執行的sql語句,這樣會導致sql太多,我們往往只用關心那些影響效能的sql或者讓資料產生變化的sql。

程式碼如下,我們在sql完成執行後,通過sql的執行時長,對慢sql進行告警出來,並且對涉及到兩個表的sql進行紀錄檔列印,也會對修改資料的sql語句(insert,update,delete)進行記錄,這對我們排查業務資料會很有幫助。

func (h *HookDb) After(ctx context.Context, query string, args ...interface{}) (context.Context, error) {  
   beginTime := time.Now()  
   if begin := ctx.Value(ctxKeyBeginTime); begin != nil {  
      beginTime = begin.(time.Time)  
   }  
   now := time.Now()  
   tableName := ""  
   if tbnameInf := ctx.Value(ctxKeyTbName); tbnameInf != nil && len(tbnameInf.(string)) != 0 {  
      tableName = tbnameInf.(string)  
      MetricMonitor.RecordClientHandlerSeconds(TypeMySQL, string(ctx.Value(ctxKeyOp).(SqlOp)), tbnameInf.(string), h.dbName, now.Sub(beginTime).Seconds())
   }  
   // 對慢sql進行實時監控,超過1s則認為是慢sql
   slowquery := false  
   if now.Sub(beginTime).Seconds() >= 1 {  
      slowquery = true  
      data := log.Fields{  
         Cost:        now.Sub(beginTime).Milliseconds(),  
         "query":     truncateKey(1024, query),  
         "args":      truncateKey(1024, fmt.Sprintf("%v", args)),  
         MetricType:  "slowLog",  
         "app":       h.app,  
         "dbName":    h.dbName,  
         "tableName": tableName,  
         "op":        ctx.Value(ctxKeyOp),  
      }  
      log.WithFields(data).Errorf("mysqlslowlog")  
   }  
   op := ctx.Value(ctxKeyOp).(SqlOp)  
   multitable := ctx.Value(ctxKeyMultiTable)  
   if !slowquery && (multitable != nil && multitable.(int) == 1) && op == Select {  
      //  對複雜sql進行監控,如果不是慢sql,但是sql涉及到兩個表也會紀錄檔進行記錄
      data := log.Fields{  
         Cost:        now.Sub(beginTime).Milliseconds(),  
         "query":     truncateKey(1024, query),  
         "args":      truncateKey(1024, fmt.Sprintf("%v", args)),  
         MetricType:  "multiTables",  
         "app":       h.app,  
         "dbName":    h.dbName,  
         "tableName": tableName,  
         "op":        ctx.Value(ctxKeyOp),  
      }  
      log.WithFields(data).Warnf("mysqlmultitableslog")  
   }  
   // 對修改資料的sql進行紀錄檔記錄  
   if op != Select && op != Unknown {  
      data := log.Fields{  
         Cost:        now.Sub(beginTime).Milliseconds(),  
         "query":     truncateKey(1024, query),  
         "args":      truncateKey(1024, fmt.Sprintf("%v", args)),  
         MetricType:  "oplog",  
         "app":       h.app,  
         "dbName":    h.dbName,  
         "tableName": tableName,  
         "op":        ctx.Value(ctxKeyOp),  
      }  
      log.WithFields(data).Infof("mysqloplog")  
   }  
   return ctx, nil  
}

總結

這一節我們完成了對mysql的監控,不過這個監控指標是在傳統資料庫監控項基礎上建立的,目的是為了讓監控指標更加容易反映到業務上,方便問題定位,在下一節我將會演示如何對redis進行監控,與mysql監控類似,我們也需要從業務維度思考對redis的監控。