PD源码阅读系列:PD节点启动
李雷,神州數碼武漢云基地,目前在研究TiDB的PD模塊。
在TiDB生態中,PD作為調度模塊,負責整個集群的調度以及保存整個集群的云信息。這篇文章將從PD的啟動作為入手點,簡單剖析PD節點啟動的步驟,了解PD啟動的流程,學習PD讀取配置、啟動日志和監控、設置并啟動PD節點服務并通過協程的方式監聽退出命令等知識點。PD簡介
Placement Driver (后續以 PD 簡稱) 是 TiDB 里面全局中心總控節點,它負責整個集群的調度,負責全局 ID 的生成,以及全局時間戳 TSO 的生成等。PD 還保存著整個集群 TiKV 的元信息,負責給 client 提供路由功能。
在架構上面,PD 所有的數據都是通過 TiKV 主動上報獲知的。同時,PD 對整個 TiKV 集群的調度等操作,也只會在 TiKV 發送 heartbeat 命令的結果里面返回相關的命令,讓 TiKV 自行去處理,而不是主動去給 TiKV 發命令。這樣設計上面就非常簡單,我們完全可以認為 PD 是一個無狀態的服務(當然,PD 仍然會將一些信息持久化到 etcd),所有的操作都是被動觸發,即使 PD 掛掉,新選出的 PD leader 也能立刻對外服務,無需考慮任何之前的中間狀態。
Why PD?
根據上文,我們了解到PD節點的主要作用在于元數據的存儲以及TiKV節點的調度。那么我們不禁要問,為什么需要PD?
當我們只有一個TiKV時,那就根本不需要調度,因為數據只可能存在于這一臺機器上,各種客戶端也只可能與這一個TiKV節點進行交互。在分布式存儲領域,這種情況不可能一直持續下去,因為數據的增量一定會超過這臺機器的存儲極限。到時我們必須將部分數據遷移到其他機器上去。
了解過TiKV的同學們都知道TiKV使用range的方式將數據進行切分。我們使用Region來表示一個數據range。每個Region都有多個副本Peer。通常為了數據可靠性,我們至少使用三個副本。
最開始系統初始化的時候,我們只有一個Region。當數據量持續增大而超過Region設置的最大Size(64MB)閾值時,Region就會分裂,生成兩個新的Region。Region是調度TiKV的基本單位。當我們新增一個TiKV的時候,PD就會將原來TiKV中的一些Region調度到這個新增的TiKV中去。這樣就能保證整個數據均衡的分布在TiKV集群上面。因為一個Region通常是64MB,將一個Region從一個TiKV移動到另一個TiKV的過程中,數據量變更其實不大。所以可以直接使用Region的數量來大概的做數據的平衡。
上面我們對TiKV數據的調度做了簡單的介紹,但是實際的情況要比這個復雜很多。我們不僅要考慮數據的均衡,也要考慮計算的均衡。這樣才能保證整個TiKV集群更快更好的對外提供服務。因為TiKV使用的是Raft一致性算法。Raft有一個強約束就是為了保證線性一致性。所有的讀寫都必須通過Leader發起。假設現在有三個TiKV,如果幾乎所有的Leader都集中在某一個TiKV上,那么會造成這個TiKV成為性能瓶頸,最好的做法是Leader也能夠均衡地分布在不同的TiKV上,這樣整個系統都能對外提供服務。
總的來說,在分布式存儲TiKV中,調度任務及其重要。這關乎系統向外提供服務的質量。我們必須同時考慮存儲Storage和計算Leader等資源。所以我們得出一個觀點,分布式存儲系統是必須要有一個調度模塊的。那么,調度模塊的實現形式是什么樣的?今天我們都知道了在TIDB生態中,有PD作為TiKV集群的調度模塊。那么為什么需要單獨拿出來作為一個項目?我認為這樣做的最大好處就是降低耦合。TiDB生態中,TiDB server負責查詢,TiKV負責存儲,PD則負責TiKV調度。如果將調度模塊寫在TiDB或者TiKV里,當TiDB或TiKV擴展節點時,PD也會跟著1:1地擴展。這將會造成一定的性能浪費,因為我們實際上并不一定需要與TIDB或TiKV節點數一樣多的PD模塊。另外也可以說這是遵守了軟件設計原則中的單一職責原則。
PD相關技術
- Go:PD完全由Go開發。Go語言簡單易用,天生支持高并發。PD源碼體積很小,不到5M,但是性能相當不錯。
- Etcd:分布式系統中最關鍵的分布式可靠鍵值存儲。PD將Region meta信息持久化在etcd,以保證切換 PD Leader 節點后能快速繼續提供 Region 路由服務。
- Raft:Etcd實現數據可靠性靠的是分布式一致性算法Raft。
- Prometheus:PD集成Prometheus來達到指標監控的目的。每個PD啟動時都會配置Prometheus,將系統運行的指標傳給Prometheus。
- Zap Logger:Go系統庫自帶的日志包存在一定的性能與功能缺乏。PD集成了由 Ubder 開源的高性能日志框架Zap Logger來提高PD的性能。
- TOML:PD配置文件書寫語法,由前GitHub CEO, Tom Preston-Werner,于2013年創建。其目標是成為一個小規模的易于使用的語義化配置文件格式。
PD本地編譯運行
PD代碼開源,可以從github獲取:
https://github.com/tikv/pd
源碼閱讀需要在本地編譯運行PD源碼。首先需要準備PD所需環境。我本地運行的是Win10 系統,安裝了如下依賴:go 1.14.7 + cmake3 + mingw64,使用intellij idea本地編譯運行。
這里需要注意的是,我一開始安裝的 go 版本為1.15。結果每次本地編譯都會報類似于內存泄漏等問題。解決方法是降低 go 的版本。我降到1.14版本后即可正常編譯運行PD server。
還有另外一點是PD源碼有個ui模塊中文件 embedded_assets_rewriter 可能會報錯,報錯原因是未識別的變量。我在相關論壇提問也沒得到回應,于是只能選擇注釋掉未聲明的變量并將相關方法返回nil。處理完這些問題就能跑起PD server來了。
PD源碼閱讀
今天將解讀pd源碼的開始部分:啟動一個pd server。
閱讀從根目錄下的cmd/pd-server/main.go開始,由此展開。
一、讀取配置
PD的配置信息有三個來源。分別是Config對象默認配置,外部配置文件和命令行參數。它們的優先級分別是命令行參數 > 外部配置文件 > 默認。下面第一塊代碼就是讀取配置的兩行代碼。config.NewConfig()獲取到系統的默認配置。系統默認配置文件在/conf/config.toml里。在Config 的結構體中,可以利用第三方包 http://github.com/BurntSushi/toml 直接讀取 toml 格式的配置文件中的值。下面的第二段代碼就是config結構體中使用 toml 工具包讀取 toml 格式的配置文件中的值來設置屬性的默認值的部分代碼。通過 toml:"配置文件中屬性名"的形式獲取到配置的值。從而設置為該屬性的默認值。Parse 方法讀取命令行參數并將參數設置到config對象中去。
讀取配置
cfg := config.NewConfig()err := cfg.Parse(os.Args[1:])Config結構體部分代碼
type Config struct {flagSet *flag.FlagSetVersion bool `json:"-"`ConfigCheck bool `json:"-"`ClientUrls string`toml:"client-urls" json:"client-urls"`PeerUrls string`toml:"peer-urls" json:"peer-urls"`AdvertiseClientUrlsstring `toml:"advertise-client-urls"json:"advertise-client-urls"`AdvertisePeerUrls string`toml:"advertise-peer-urls" json:"advertise-peer-urls"`}創建默認配置對象cfg時,NewConfig 方法內部還將利用 flagSet 對象對cfg各個屬性做屬性說明。對于bool類型的屬性將調用flagSet的BoolVar方法對其進行說明。具體過程會聲明該變量的簡稱,值以及用處。同理 StringVar 就是對 string 類型的變量做說明的。
下面的示例代碼就展示了 BoolVar 和 StringVar 的內部邏輯以及使用這些方法對config對象的屬性做說明的過程。我們可以看到使用 StringVar 對屬性 configFile 做了說明。其簡稱為 config 。它的值默認為 "" 。它的用處就是作為配置文件。同理,BoolVar也對bool類型的屬性 ConfigCheck 做了說明。說明它是檢查配置文件的合規性的。
New Config()
cfg := &Config{}cfg.flagSet =flag.NewFlagSet("pd", flag.ContinueOnError)fs := cfg.flagSetfs.StringVar(&cfg.configFile,"config", "", "config file")fs.BoolVar(&cfg.ConfigCheck,"config-check", false, "check config file validity and exit")func (f *FlagSet) BoolVar(p *bool, namestring, value bool, usage string) {f.Var(newBoolValue(value, p), name, usage)}func (f *FlagSet) StringVar(p *string,name string, value string, usage string) {f.Var(newStringValue(value, p), name, usage)}以上是默認配置的一些處理操作。接下來講講獲取外部配置文件和命令行中的配置信息。
PD 在啟動時可以攜帶外部的配置文件對 PD 的屬性做配置。具體操作是用命令行啟動 PD 時,使用命令行參數 --config 指明外部配置文件的位置。例如 --config "/usr/local/config.toml" 將指定 PD 啟動時讀取本機文件目錄 /usr/local/config.toml 的配置文件。
接著我們在代碼層面看一下這個過程:
首先在 main 方法中獲取命令行參數信息。這一步驟是通過 go 的os包支持的。通過 os.Args獲取命令行參數數組。然后傳入到 config 對象的 Parse 方法中。
接著在 Parse 方法中,調用 flagSet 的 Parse 方法將命令行參數都設置到config對象對應的屬性上。在隨后的代碼中將判斷 config 對象中 configFile 屬性是否非空。因為這個屬性默認是空字符串,只有設置了值,才能進行下一步讀取指定路徑的配置文件。當它的值非空時將調用 configFromFile 方法讀取指定目錄的配置文件,讀取的結果放到 toml.MetaData 對象中。然后將這個對象傳入到 config 對象的Adjust 方法中用于調整 config 的各個屬性值。
PD 的配置文件描述全面的資料可以參考:
PD 配置文件描述
命令行參數描述可以參考:
PD 配置參數
讀取完配置后,Parse 方法將返回err對象以幫助判斷Parse過程是否成功。err 如果是 nil,則說明Parse是沒有問題的。如果是ErrHelp,則說明輸入命令行的是-h或者是-help。輸入這個命令說明我只是想查看pd啟動時可以攜帶哪些配置參數而不是直接啟動pd。所以在這個case下將調用 exit 方法退出啟動程序。除此之外,其他情況就是parse過程錯誤,輸出錯誤提示信息。
Parse結果檢查
switch errors.Cause(err) { case nil: case flag.ErrHelp:exit(0) default:log.Fatal("parse cmd flags error", errs.ZapError(errs.ErrParseFlags)) }二、啟動logger服務并打印PD Server的信息和警告信息
PD使用zap Logger替代go原生的log組件以此來提高整體運行的性能。我們都知道go原生的logger使用起來十分簡單。我們通過設置任何io.writer作為日志記錄輸出并向其發送要寫入的日志就行。但是簡單歸簡單,原生logger也有很多不足的地方。例如:僅限基本日志級別、只有一個Print選項、Fatal日志通過調用os.Exit(1)來結束程序、Panic日志在寫入日志消息之后拋出一個panic、不提供日志切割的能力、缺乏日志格式化能力等。綜合這些原因,pd使用uber開源的日志框架zap logger來替換原生的logger。zap logger有兩個優點。其一是提供了結構化日志記錄和printf風格的日志記錄。其二是它非常的快。關于zap logger高性能的設計思路可以參考它家github地址:
https://github.com/uber-go/zap#performance
下方代碼就是PD創建zap logger來替換原生logger的過程:
首先調用cfg對象的 SetupLogger 方法設置cfg的logger和logProps屬性。在SetupLogger 方法內部,使用PingCAP自家的log包里的初始化方法 InitLogger獲得zap.logger 和ZapProperties對象并將二者分別賦給cfg的 logger 和 logProps屬性。接著使用 ReplaceGlobals替換全局的logger。然后刷新緩存,最后使用 InitLogger 初始化zap logger。
logger組件設置啟動好之后,打印PD信息和警告。
啟動logger:
err = cfg.SetupLogger() if err == nil {log.ReplaceGlobals(cfg.GetZapLogger(), cfg.GetZapLogProperties()) } else {log.Fatal("initialize logger error", errs.ZapError(err)) } // Flushing any buffered log entries defer log.Sync()// The old logger err = logutil.InitLogger(&cfg.Log) if err != nil {log.Fatal("initialize logger error", errs.ZapError(err)) }server.LogPDInfo()for _, msg := range cfg.WarningMsgs {log.Warn(msg) }三、Prometheus監控
在 main 方法中調用 EnableHandlingTimeHistogram 。在 PD 啟動時,會初始化一個默認的 ServerMetrics 對象來記錄 PD server服務運行的指標。默認不開啟 Histogram metrics 這個指標監控。因為這個指標監控耗費性能較高。在源碼的注釋中也說明,開啟 Histogram metrics 監控可能會耗費較大性能。如果機器性能有限,那么可以選擇不開啟。
接著就會調用 Push 方法將指標發送到 Prometheus 的推送網關上。具體推送方法是 prometheusPushClinet。在該方法內首先構造推送者對象pusher。pusher的構造使用了建造者模式。首先使用推送的地址和任務初始化pusher,添加了為其添加了收集器以及分組標簽。
Prometheus監控:
grpcprometheus.EnableHandlingTimeHistogram()metricutil.Push(&cfg.Metric) Gatherer(prometheus.DefaultGatherer).Grouping("instance", instanceName())for {err := pusher.Push()if err != nil {log.Error("could not push metrics to Prometheus Pushgateway", errs.ZapError(errs.ErrPrometheusPushMetrics, err))}time.Sleep(interval)} }四、動態添加節點
PD使用 PrepareJoinCluster 方法將當前節點 Join指定的集群當中去并且在Join成功后持久化Join配置,當PD節點宕機后重啟時,讀取本地配置就能快速重新加入集群。
下面簡單聊聊從PD節點首次加入到一個集群以及PD停機再次加入集群的情況。
當PD節點首次Join某集群時,我們進入PrepareJoinCluster 方法,攜帶的參數時cfg,也就是PD的配置對象。當我們想Join某個集群時,首先保證目標集群能夠正常工作。在啟動PD節點時。命令行攜帶參數--join="target-urls",target-urls就是目標集群里任意PD的advertise-clinet-url。PD啟動時通過os.Args讀取這些額外參數并設置到cfg對象中去。首先要做基本的差錯檢測,排除Join信息錯誤的情況。然后嘗試讀取本地保存的Join信息。我們是第一次Join到一個陌生的集群,這些信息以及目錄還沒有創建。接下來將創建一個etcd的client,創建時傳入Join信息、TLS憑證配置、超時限制等信息。下一步,ListEtcdMember 方法列出目標集群所有的etcd成員。隨后判斷當前PD節點是否與集群中的節點重名。重名則無法加入集群,直接退出。如果滿足條件名字不沖突。隨后使用 AddEtcdMenber方法嘗試加入集群。結果將返回到類型為*clientv3.MenberAddResponse的對象中。隨后再次調用 ListEtcdMenber 獲取最新的etcd集群成員信息并對集群情況進行驗證,并將最新的集群信息更新到cfg對象中。最后將節點配置信息保存到本地。
當PD停機再次重啟時,直接讀取本地文件獲取集群信息并加入到集群中去。
Join節點:
err = join.PrepareJoinCluster(cfg) if err != nil {log.Fatal("join meet error", errs.ZapError(err)) }五、創建并運行PD Server
這一步驟主要做兩件事情。第一個就是創建PD Server并運行。第二就是監聽退出信號。
首先使用 CreateServer 方法創建Server對象并且傳入所需要的參數:上下文對象ctx、配置cfg、服務數組servcieBuilders。接著調用server的Run方法啟動Server。在Run方法內,首先會通過協程開啟監控。隨后開啟etcd和Server服務。最后通過Server的startServerLoop方法使得服務處于不斷運行的狀態而不退出。
另外一個部分就是監聽退出信號。通過監聽四種信號來判斷是否要中止服務。這四種信號及含義如下表所示。監聽程序通過協程的方式監聽退出信號,一旦監聽到退出信號,調用cancle方法即會向ctx對象的Done通道發送消息。Done通道一旦接收到消息運行Server的線程就會退出。接著就會打印退出信息返回退出碼。
| SIGHUP | 1 | Term | 終端控制進程結束(終端連接斷開) |
| SIGHINT | 2 | Term | 用戶發送INTR字符(Ctrl+C)觸發 |
| SIGTERM | 15 | Term | 結束程序(可以被捕獲、阻塞或忽略) |
| SIGQUIT | 3 | Core | 用戶發送QUIT字符(Ctrl+/)觸發 |
創建 PD Server:
ctx, cancel := context.WithCancel(context.Background()) serviceBuilders := []server.HandlerBuilder{api.NewHandler, swaggerserver.NewHandler, autoscaling.NewHandler} serviceBuilders = append(serviceBuilders, dashboard.GetServiceBuilders()...) svr, err := server.CreateServer(ctx, cfg, serviceBuilders...) if err != nil {log.Fatal("create server failed", errs.ZapError(err)) }總的來說,PD節點的啟動會經歷讀取配置、設置logger、啟動prometheus監控、join集群、啟動server、監聽退出命令后退出等步驟。
我們今天主要了解了PD節點啟動的基本步驟,也了解到PD對zap logger和Prometheus等中間件的集成使用。最后學習了使用協程監聽退出命令。
整個PD的啟動流程用下面流程圖表示一下:
?本篇文章只是對PD節點啟動做的一個粗略的解讀,有些地方可能存在錯誤希望有真知灼見的大神能不吝賜教,指出我的問題,多多交流。
總結
以上是生活随笔為你收集整理的PD源码阅读系列:PD节点启动的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 365抽奖软件 v6.1.7
- 下一篇: SAP之FPM卷一:FPM是什么