TiDB show processlist命令原始碼分析
背景
因為豐巢自去年年底開始在推送平臺上嘗試了TiDB,最近又要將承接豐巢所有交易的支付平臺切到TiDB上。我本人一直沒有抽出時間對TiDB的原始碼進行學習,最近準備開始一系列的學習和分享。由於我本人沒有資料庫相關的經驗,本著學習的心態和大家一起探討,歡迎高手隨時指正。總結一下本次學習分享的目的:
- 豐巢把最重要的兩個基礎業務都放到了TiDB上,後續應該會有更多的核心繫統跑在TiDB上,我們豐巢中介軟體團隊作為引入TiDB到豐巢的推動人和執行者,對於TiDB的穩定性和突發事件的處理,一定要做足功課;
- 以TiDB為代表的newsql代表的是現在和未來,作為個人來說,有著充足的動力去學習;
- 我們不滿足於只是作為TiDB的使用者,我們需要在TiDB上定製開發對於豐巢更有意義的模組,如果能給社群做貢獻,那更是非常棒的一件事;
言歸正傳,說一下本文的產生原因:去年我們在推送平臺上使用TiDB的過程中,就發現老版本的TiDB是無法通過外部手段kill呼叫慢查詢的,而慢查詢的危害對於資料庫來說會有致命的風險,後來pingcap公司在2.1版本(具體的版本參見TiDB的說明)中增加了show processlist和kill tidb命令,但是因為TiDB本身是無狀態的,這兩個命令屬於單機命令,在使用的過程中,大家還是要提前做好準備,要直連到具體的TiDB的server上才可使用,不要通過nginx等服務進行轉發請求,到時不但不能解決問題,還有可能帶來意外的風險。今天第一章,我們先來看一下show processlist這個比較簡單的命令的原始碼,下一章,我們再分析kill tidb這個命令。
原始碼分析
環境資訊
- 軟體:TiDB2.1.7、PD2.1.4、TiKV2.1.4;
- 硬體:為了隨時除錯,TiDB跑在本機的mac上、PD和TiKV跑在linux虛擬機器上;
操作過程
- 開啟一個直連TiDB的客戶端,輸入命令:show PROCESSLIST;
- 客戶端會輸出下圖的列表;
上面的列表中展示了當前TiDB正在處理每個連線的sql語句詳情。
問題
在我分析原始碼之前,我問了自己本次分析原始碼要搞清楚的兩個問題,在這裡和大家分享一下:
- show processlist到底是不是單機的命令,和TiKV、PD有沒有啥關係?
- kill tidb需要使用的id欄位到底代表的是什麼?
接收命令
首先,啟動TiDB server.程式碼在tidb-server/main.go裡面,主要方法是:runServer方法
func runServer() { err := svr.Run() }
再來看一下:server/server.go原始碼:
func (s *Server) Run() error { for { conn, err := s.listener.Accept() go s.onConn(conn) } }
重點程式碼是監聽埠,並建立連線,啟動另一協程去服務新來的連線,接下來再看看server.go中的onConn方法:
func (s *Server) onConn(c net.Conn) { conn := s.newConn(c) conn.Run() }
其中,s.newConn方法會將net. Conn連線包裝成clientConn連線,並分配在這個TiDB server下唯一的connectionID,此connectionID為原子變數,每次新連線自增加1,我們先記住這個id,後面分析的時候會用到它。我們來看看server/conn.go下的Run方法:
func (cc *clientConn) Run() { for { data, err := cc.readPacket() cc.dispatch(data) } }
Run方法主要就是不斷的輪訓讀取clientConn中的內容,並將它交給dispatch方法進行下面的分析及返回結果操作,至此關於接收show processlist命令部分已經分析完畢,當然其它的sql語句也是經過這個過程進入到dispatch方法中的。
show processlist的構建Executor
接著分析dispatch方法在處理show processlist命令的流程:
func (cc *clientConn) dispatch(data []byte) error { switch cmd { case mysql.ComQuery: return cc.handleQuery(ctx1, hack.String(data)) } }
show processlist命令屬於mysql.ComQuery,因此流程會走到handleQuery方法裡面,我們來看一下:
func (cc *clientConn) handleQuery(ctx context.Context, sql string) (err error) { rs, err := cc.ctx.Execute(ctx, sql) err = cc.writeResultset(ctx, rs[0], false, 0, 0) }
handleQuery中處理show processlist命令的重點程式碼就是上面的兩行,我們先來看一下server/driver_tidb.go中的Execute方法:
rsList, err := tc.session.Execute(ctx, sql)
Execute中的重點就是呼叫session/session.go中的Execute方法:
func (s *session) execute(ctx context.Context, sql string) (recordSets []sqlexec.RecordSet, err error) { s.PrepareTxnCtx(ctx) stmtNodes, warns, err := s.ParseSQL(ctx, sql, charsetInfo, collation) compiler := executor.Compiler{Ctx: s} for _, stmtNode := range stmtNodes { recordSets, err = s.executeStatement(ctx, connID, stmtNode, stmt, recordSets); } }
上面的execute方法中會對sql語句進行處理及制定執行計劃,處理完成後呼叫executeStatement方法,executeStatement中的重點方法是runStmt:
recordSet, err := runStmt(ctx, s, stmt)
我們再來看看session/tidb.go中的runStmt方法:
func runStmt(ctx context.Context, sctx sessionctx.Context, s sqlexec.Statement) (sqlexec.RecordSet, error) { rs, err = s.Exec(ctx) err = finishStmt(ctx, sctx, se, sessVars, err) }
繼續來分析executor/adapter中的(a *ExecStmt) Exec方法,一樣採取劃重點的方式:
func (a *ExecStmt) Exec(ctx context.Context) (sqlexec.RecordSet, error) { e, err := a.buildExecutor(sctx) e.Open(ctx) var pi processinfoSetter if raw, ok := sctx.(processinfoSetter); ok { pi = raw sql := a.OriginText() if simple, ok := a.Plan.(*plannercore.Simple); ok && simple.Statement != nil { if ss, ok := simple.Statement.(ast.SensitiveStmtNode); ok { // Use SecureText to avoid leak password information. sql = ss.SecureText() } } // Update processinfo, ShowProcess() will use it. pi.SetProcessInfo(sql) //fmt.Println(sql) a.Ctx.GetSessionVars().StmtCtx.StmtType = GetStmtLabel(a.StmtNode) } return &recordSet{ executor:e, stmt:a, processinfo: pi, txnStartTS:txnStartTS, }, nil }
(a *ExecStmt) Exec方法中raw, ok := sctx.(processinfoSetter)這段邏輯就是把當前連線正在執行的語句儲存到processinfo裡面取,關於這部分細節比較簡單,在這裡不展開來分析。我們先來看看buildExecutor中做了什麼事情?
b := newExecutorBuilder(ctx, a.InfoSchema) e := b.build(a.Plan)
重點要來了,在executor/builder.go中的build方法做了啥事?
case *plannercore.Show: return b.buildShow(v)
build方法會根據不同的語句型別來構建不同的Executor並返回,show processlist命令會匹配到plannercore.Show型別,我們看看buildShow方法的實現:
e := &ShowExec{ baseExecutor: newBaseExecutor(b.ctx, v.Schema(), v.ExplainID()), Tp:v.Tp, DBName:model.NewCIStr(v.DBName), Table:v.Table, Column:v.Column, User:v.User, Flag:v.Flag, Full:v.Full, GlobalScope:v.GlobalScope, is:b.is, } if len(v.Conditions) == 0 { return e } sel := &SelectionExec{ baseExecutor: newBaseExecutor(b.ctx, v.Schema(), v.ExplainID(), e), filters:v.Conditions, } return sel
因為v.Conditions為0,所以返回型別為ShowExec的Executor,我們接下來再剛才的Exec方法中的e.Open方法,其實就是ShowExec的Open方法,ShowExec位於executor/show.go檔案中,我們查詢後發現ShowExec中沒有Open方法,我當時是被搞蒙了,後來發現這是go的一個語言特性,它使用的是baseExecutor的Open方法:
func (e *baseExecutor) Open(ctx context.Context) error { for _, child := range e.children { err := child.Open(ctx) if err != nil { return errors.Trace(err) } } return nil }
上面的方法會遍歷baseExecutor中的children的Executor,然後呼叫它們的Open方法,但是因為ShowExec在建立它的baseExecutor的時候,沒有任何的children,所以在show processlist這個操作過程中,Open方法相當於啥也沒幹,但是大家在分析其它語句時,這個Open方法是一個很重要的方法。我們再來看剛才Exec中的最後的return塊裡面,返回了包裝executor、processinfo等資訊的recordSet型別。至此關於show processlist命令如何包裝成Executor並和processinfo等資訊作為recordSet型別的返回值返回給上層函式分析完畢。
show processlist的獲取各個連線的processinfo資訊
接下來我們再來看handleQuery中的writeResultset方法:
err = cc.writeResultset(ctx, rs[0], false, 0, 0)
在server/conn.go中的writeResultset主要的邏輯就是下面的邏輯:
err = cc.writeChunks(ctx, rs, binary, serverStatus)
我們繼續來分析writeChunks中的重要部分:
func (cc *clientConn) writeChunks(ctx context.Context, rs ResultSet, binary bool, serverStatus uint16) error { for { err := rs.Next(ctx, chk) } }
writeChunks裡面主要就是迴圈呼叫rs.Next的方法,直到滿足條件為止,rs的型別實際上是server/driver_tidb.go下的tidbResultSet型別,我們來看一下它的Next方法:
func (trs *tidbResultSet) Next(ctx context.Context, chk *chunk.Chunk) error { return trs.recordSet.Next(ctx, chk) }
tidbResultSet的Next方法主要是呼叫了executor/adapter.go中的recordSet型別的Next方法,我們來看看這個Next方法:
func (a *recordSet) Next(ctx context.Context, chk *chunk.Chunk) error { err := a.executor.Next(ctx, chk) }
recordSet方法的重點就是呼叫它的executor的Next方法,我們在上一個小節 結尾處分析出recordSet的executor就是之前生成的ShowExec(可算是找到它了,我已經累暈)。那麼,我們接著分析它的Next方法:
e.fetchAll()
ShowExec中的Next方法的主要邏輯就是呼叫它的fetchAll方法,接著往下看:
case ast.ShowProcessList: return e.fetchShowProcessList()
因為匹配到了這個case,所以會呼叫它的fetchShowProcessList方法:
func (e *ShowExec) fetchShowProcessList() error { sm := e.ctx.GetSessionManager() pl := sm.ShowProcessList() }
上面的sm型別的server/server.go中的Server型別,我們來看看它的ShowProcessList方法:
func (s *Server) ShowProcessList() map[uint64]util.ProcessInfo { s.rwlock.RLock() rs := make(map[uint64]util.ProcessInfo, len(s.clients)) for _, client := range s.clients { if atomic.LoadInt32(&client.status) == connStatusWaitShutdown { continue } pi := client.ctx.ShowProcess() rs[pi.ID] = pi } s.rwlock.RUnlock() return rs }
它主要是遍歷當前所有的客戶端,並獲取到所有客戶端的ShowProcess,其中的client.ctx型別為server.TiDBContext,我們來看看它的ShowProcess:
func (tc *TiDBContext) ShowProcess() util.ProcessInfo { return tc.session.ShowProcess() }
邏輯比較簡單,就是呼叫型別為session.session的ShowProcess方法,接著往下看:
func (s *session) ShowProcess() util.ProcessInfo { var pi util.ProcessInfo tmp := s.processInfo.Load() if tmp != nil { pi = tmp.(util.ProcessInfo) pi.Mem = s.GetSessionVars().StmtCtx.MemTracker.BytesConsumed() } return pi }
session的ShowProcess方法會從記憶體中載入當前session的processInfo資訊。至此我們分析show processlist命令的原始碼分析完畢,關於每個連線如何設定自身的processinfo資訊,邏輯也比較簡單,大家有興趣可以自己去研究一下。
總結
我們可以回答一下開頭提出的兩個問題:
- show processlist到底是不是單機的命令,和TiKV、PD有沒有啥關係?答案是show processlist確實是一個單機命令,和TiKV、PD沒有任何關係。
- kill tidb需要使用的id欄位到底代表的是什麼?id欄位就是在建立連線時,分配的connectionId,它在單個TiDB服務內唯一。
通過上面的分析,我們還可以總結以下的特點:
- TiDB的連線在客戶端不能夠複用,因為它處理請求時,主流程是在單協程中處理的,處理完一個再處理下一個;
- show processlist命令的處理中關於ShowExec的Open方法呼叫,其實是它內部的baseExecutor的Open方法;
- 每個連線的session負責獨立管理此連線的processinfo資訊;
- TiDB的Executor機制靠next的方式不斷在它的鏈式處理結構上傳遞;
- show processlist因為沒有其它條件,所以它在處理時的Executor型別為ShowExec,沒有再包裝SelectionExec型別;
- 真正的語句執行(獲取show processlist的資訊)其實是在write的時候,我在分析這點的時候,花了不少時間;
原始碼閱讀方法
最後,我想和大家分享一下,我自己在原始碼閱讀裡面用到的一些方法和技巧,大的方面會有兩種方法:
- 由因導果:就是由某一行程式碼,開始自頂向下的正向閱讀;
- 執果索因:就是從結果處出發,開始自底向上的反向閱讀和推導;
上面的兩種方法,會伴隨大家在原始碼閱讀的各個階段,但是有了這兩種方法還是遠遠不夠的,我再分享一下我的相關技巧:
- 編譯執行:當我們在下定決心要閱讀某個框架的原始碼時,第一步要做的就是,將這個框架的原始碼從原始碼庫拉下來後,用我們的IDE工具編譯執行起來,對於有些框架執行的難度會比較高,就比如說我這次選擇的TiDB,在編譯過程中花費了好多的時間。建議大家在這個過程中,不要放棄,第一步是一定要把它編譯執行起來;
- 資料參考:一般來講只要不是太冷門的元件,一般網上都會有比較多的原始碼分享,我們需要甄別出寫的好的原始碼分析資料,然後參考驗證我們的原始碼閱讀;
- 重要類的結構關係圖整理:我們都知道,java體系的元件(golang的也一樣),在設計時都會有各種複雜介面和抽象類繼承關係,在閱讀原始碼時,我們很容易便陷入到這種複雜的繼承關係中去,所以利用IDE工具繪出類的結構關係圖,會在我們閱讀原始碼時,有很大的幫助;
- 掌握除錯技巧:有較好的除錯技巧可以便於我們分析程式碼流程和上下游關係;
- 修改原始碼:在我們不能完全確定流程分支等情況下,可以靠修改原始碼去理解;
- 提問題:在本文分析show processlist原始碼的過程中,提問題一直都伴隨著我們的原始碼閱讀過程,提問題能讓我們更好的理解背後的含義,便於深入到原始碼的架構設計中去;
- 聚焦:對於類似於TiDB如此複雜的元件,我們在一開始分析的過程中,一定要先選定分析的主線路,比如:本章的“show processlist”,在這個過程中,有意的忽略我們本次分析主線路之外的邏輯分支,目標明確,才能不會陷入到框架各種複雜的設計中去;
- 總結分享:這一點是最重要的,原始碼閱讀完後,如果不進行總結,過一段時間,我們便很容易遺忘了,同時分享也很重要,開源軟體本身就是一種眾包思想,我們既然是受益方,同時也要通過知識分享回饋他人;