原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch6-cloud/ch6-06-config.html
在分布式系統(tǒng)中,常困擾我們的還有上線問題。雖然目前有一些優(yōu)雅重啟方案,但實際應(yīng)用中可能受限于我們系統(tǒng)內(nèi)部的運行情況而沒有辦法做到真正的 “優(yōu)雅”。比如我們?yōu)榱藢θハ掠蔚牧髁窟M行限制,在內(nèi)存中堆積一些數(shù)據(jù),并對堆積設(shè)定時間或總量的閾值。在任意閾值達到之后將數(shù)據(jù)統(tǒng)一發(fā)送給下游,以避免頻繁的請求超出下游的承載能力而將下游打垮。這種情況下重啟要做到優(yōu)雅就比較難了。
所以我們的目標(biāo)還是盡量避免采用或者繞過上線的方式,對線上程序做一些修改。比較典型的修改內(nèi)容就是程序的配置項。
在一些偏 OLAP 或者離線的數(shù)據(jù)平臺中,經(jīng)過長期的迭代開發(fā),整個系統(tǒng)的功能模塊已經(jīng)漸漸穩(wěn)定??勺儎拥捻椫怀霈F(xiàn)在數(shù)據(jù)層,而數(shù)據(jù)層的變動大多可以認為是 SQL 的變動,架構(gòu)師們自然而然地會想著把這些變動項抽離到系統(tǒng)外部。比如本節(jié)所述的配置管理系統(tǒng)。
當(dāng)業(yè)務(wù)提出了新的需求時,我們的需求是將新的 SQL 錄入到系統(tǒng)內(nèi)部,或者簡單修改一下老的 SQL。不對系統(tǒng)進行上線,就可以直接完成這些修改。
大公司的平臺部門服務(wù)眾多業(yè)務(wù)線,在平臺內(nèi)為各業(yè)務(wù)線分配唯一 id。平臺本身也由多個模塊構(gòu)成,這些模塊需要共享相同的業(yè)務(wù)線定義(要不然就亂套了)。當(dāng)公司新開產(chǎn)品線時,需要能夠在短時間內(nèi)打通所有平臺系統(tǒng)的流程。這時候每個系統(tǒng)都走上線流程肯定是來不及的。另外需要對這種公共配置進行統(tǒng)一管理,同時對其增減邏輯也做統(tǒng)一管理。這些信息變更時,需要自動通知到業(yè)務(wù)方的系統(tǒng),而不需要人力介入(或者只需要很簡單的介入,比如點擊審核通過)。
除業(yè)務(wù)線管理之外,很多互聯(lián)網(wǎng)公司會按照城市來鋪展自己的業(yè)務(wù)。在某個城市未開城之前,理論上所有模塊都應(yīng)該認為帶有該城市 id 的數(shù)據(jù)是臟數(shù)據(jù)并自動過濾掉。而如果業(yè)務(wù)開城,在系統(tǒng)中就應(yīng)該自己把這個新的城市 id 自動加入到白名單中。這樣業(yè)務(wù)流程便可以自動運轉(zhuǎn)。
再舉個例子,互聯(lián)網(wǎng)公司的運營系統(tǒng)中會有各種類型的運營活動,有些運營活動推出后可能出現(xiàn)了超出預(yù)期的事件(比如公關(guān)危機),需要緊急將系統(tǒng)下線。這時候會用到一些開關(guān)來快速關(guān)閉相應(yīng)的功能。或者快速將想要剔除的活動 id 從白名單中剔除。在 Web 章節(jié)中的 AB 測試一節(jié)中,我們也提到,有時需要有這樣的系統(tǒng)來告訴我們當(dāng)前需要放多少流量到相應(yīng)的功能代碼上。我們可以像那一節(jié)中,使用遠程 RPC 來獲知這些信息,但同時,也可以結(jié)合分布式配置系統(tǒng),主動地拉取到這些信息。
我們使用 etcd 實現(xiàn)一個簡單的配置讀取和動態(tài)更新流程,以此來了解線上的配置更新流程。
簡單的配置,可以將內(nèi)容完全存儲在 etcd 中。比如:
etcdctl get /configs/remote_config.json
{
"addr" : "127.0.0.1:1080",
"aes_key" : "01B345B7A9ABC00F0123456789ABCDAF",
"https" : false,
"secret" : "",
"private_key_path" : "",
"cert_file_path" : ""
}
cfg := client.Config{
Endpoints: []string{"http://127.0.0.1:2379"},
Transport: client.DefaultTransport,
HeaderTimeoutPerRequest: time.Second,
}
直接用 etcd client 包中的結(jié)構(gòu)體初始化,沒什么可說的。
resp, err = kapi.Get(context.Background(), "/path/to/your/config", nil)
if err != nil {
log.Fatal(err)
} else {
log.Printf("Get is done. Metadata is %q\n", resp)
log.Printf("%q key has %q value\n", resp.Node.Key, resp.Node.Value)
}
獲取配置使用 etcd KeysAPI 的 Get()
方法,比較簡單。
kapi := client.NewKeysAPI(c)
w := kapi.Watcher("/path/to/your/config", nil)
go func() {
for {
resp, err := w.Next(context.Background())
log.Println(resp, err)
log.Println("new values is", resp.Node.Value)
}
}()
通過訂閱 config 路徑的變動事件,在該路徑下內(nèi)容發(fā)生變化時,客戶端側(cè)可以收到變動通知,并收到變動后的字符串值。
package main
import (
"log"
"time"
"golang.org/x/net/context"
"github.com/coreos/etcd/client"
)
var configPath = `/configs/remote_config.json`
var kapi client.KeysAPI
type ConfigStruct struct {
Addr string `json:"addr"`
AesKey string `json:"aes_key"`
HTTPS bool `json:"https"`
Secret string `json:"secret"`
PrivateKeyPath string `json:"private_key_path"`
CertFilePath string `json:"cert_file_path"`
}
var appConfig ConfigStruct
func init() {
cfg := client.Config{
Endpoints: []string{"http://127.0.0.1:2379"},
Transport: client.DefaultTransport,
HeaderTimeoutPerRequest: time.Second,
}
c, err := client.New(cfg)
if err != nil {
log.Fatal(err)
}
kapi = client.NewKeysAPI(c)
initConfig()
}
func watchAndUpdate() {
w := kapi.Watcher(configPath, nil)
go func() {
// watch 該節(jié)點下的每次變化
for {
resp, err := w.Next(context.Background())
if err != nil {
log.Fatal(err)
}
log.Println("new values is", resp.Node.Value)
err = json.Unmarshal([]byte(resp.Node.Value), &appConfig)
if err != nil {
log.Fatal(err)
}
}
}()
}
func initConfig() {
resp, err = kapi.Get(context.Background(), configPath, nil)
if err != nil {
log.Fatal(err)
}
err := json.Unmarshal(resp.Node.Value, &appConfig)
if err != nil {
log.Fatal(err)
}
}
func getConfig() ConfigStruct {
return appConfig
}
func main() {
// init your app
}
如果業(yè)務(wù)規(guī)模不大,使用本節(jié)中的例子就可以實現(xiàn)功能了。
這里只需要注意一點,我們在更新配置時,進行了一系列操作:watch 響應(yīng),json 解析,這些操作都不具備原子性。當(dāng)單個業(yè)務(wù)請求流程中多次獲取 config 時,有可能因為中途 config 發(fā)生變化而導(dǎo)致單個請求前后邏輯不一致。因此,在使用類似這樣的方式來更新配置時,需要在單個請求的生命周期內(nèi)使用同樣的配置。具體實現(xiàn)方式可以是只在請求開始的時候獲取一次配置,然后依次向下透傳等等,具體情況具體分析。
隨著業(yè)務(wù)的發(fā)展,配置系統(tǒng)本身所承載的壓力可能也會越來越大,配置文件可能成千上萬。客戶端同樣上萬,將配置內(nèi)容存儲在 etcd 內(nèi)部便不再合適了。隨著配置文件數(shù)量的膨脹,除了存儲系統(tǒng)本身的吞吐量問題,還有配置信息的管理問題。我們需要對相應(yīng)的配置進行權(quán)限管理,需要根據(jù)業(yè)務(wù)量進行配置存儲的集群劃分。如果客戶端太多,導(dǎo)致了配置存儲系統(tǒng)無法承受瞬時大量的 QPS,那可能還需要在客戶端側(cè)進行緩存優(yōu)化,等等。
這也就是為什么大公司都會針對自己的業(yè)務(wù)額外開發(fā)一套復(fù)雜配置系統(tǒng)的原因。
在配置管理過程中,難免出現(xiàn)用戶誤操作的情況,例如在更新配置時,輸入了無法解析的配置。這種情況下我們可以通過配置校驗來解決。
有時錯誤的配置可能不是格式上有問題,而是在邏輯上有問題。比如我們寫 SQL 時少 select 了一個字段,更新配置時,不小心丟掉了 json 字符串中的一個 field 而導(dǎo)致程序無法理解新的配置而進入詭異的邏輯。為了快速止損,最快且最有效的辦法就是進行版本管理,并支持按版本回滾。
在配置進行更新時,我們要為每份配置的新內(nèi)容賦予一個版本號,并將修改前的內(nèi)容和版本號記錄下來,當(dāng)發(fā)現(xiàn)新配置出問題時,能夠及時地回滾回來。
常見的做法是,使用 MySQL 來存儲配置文件或配置字符串的不同版本內(nèi)容,在需要回滾時,只要進行簡單的查詢即可。
在業(yè)務(wù)系統(tǒng)的配置被剝離到配置中心之后,并不意味著我們的系統(tǒng)可以高枕無憂了。當(dāng)配置中心本身宕機時,我們也需要一定的容錯能力,至少保證在其宕機期間,業(yè)務(wù)依然可以運轉(zhuǎn)。這要求我們的系統(tǒng)能夠在配置中心宕機時,也能拿到需要的配置信息。哪怕這些信息不夠新。
具體來講,在給業(yè)務(wù)提供配置讀取的 SDK 時,最好能夠?qū)⒛玫降呐渲迷跇I(yè)務(wù)機器的磁盤上也緩存一份。這樣遠程配置中心不可用時,可以直接用硬盤上的內(nèi)容來做兜底。當(dāng)重新連接上配置中心時,再把相應(yīng)的內(nèi)容進行更新。
加入緩存之后務(wù)必需要考慮的是數(shù)據(jù)一致性問題,當(dāng)個別業(yè)務(wù)機器因為網(wǎng)絡(luò)錯誤而與其它機器配置不一致時,我們也應(yīng)該能夠從監(jiān)控系統(tǒng)中知曉。
我們使用一種手段解決了我們配置更新痛點,但同時可能因為使用的手段而帶給我們新的問題。實際開發(fā)中,我們要對每一步?jīng)Q策多多思考,以使自己不在問題到來時手足無措。
![]() | ![]() |
更多建議: