原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch6-cloud/ch6-06-config.html
在分布式系統(tǒng)中,常困擾我們的還有上線問題。雖然目前有一些優(yōu)雅重啟方案,但實(shí)際應(yīng)用中可能受限于我們系統(tǒng)內(nèi)部的運(yùn)行情況而沒有辦法做到真正的 “優(yōu)雅”。比如我們?yōu)榱藢θハ掠蔚牧髁窟M(jìn)行限制,在內(nèi)存中堆積一些數(shù)據(jù),并對堆積設(shè)定時(shí)間或總量的閾值。在任意閾值達(dá)到之后將數(shù)據(jù)統(tǒng)一發(fā)送給下游,以避免頻繁的請求超出下游的承載能力而將下游打垮。這種情況下重啟要做到優(yōu)雅就比較難了。
所以我們的目標(biāo)還是盡量避免采用或者繞過上線的方式,對線上程序做一些修改。比較典型的修改內(nèi)容就是程序的配置項(xiàng)。
在一些偏 OLAP 或者離線的數(shù)據(jù)平臺中,經(jīng)過長期的迭代開發(fā),整個(gè)系統(tǒng)的功能模塊已經(jīng)漸漸穩(wěn)定??勺儎?dòng)的項(xiàng)只出現(xiàn)在數(shù)據(jù)層,而數(shù)據(jù)層的變動(dòng)大多可以認(rèn)為是 SQL 的變動(dòng),架構(gòu)師們自然而然地會想著把這些變動(dòng)項(xiàng)抽離到系統(tǒng)外部。比如本節(jié)所述的配置管理系統(tǒng)。
當(dāng)業(yè)務(wù)提出了新的需求時(shí),我們的需求是將新的 SQL 錄入到系統(tǒng)內(nèi)部,或者簡單修改一下老的 SQL。不對系統(tǒng)進(jìn)行上線,就可以直接完成這些修改。
大公司的平臺部門服務(wù)眾多業(yè)務(wù)線,在平臺內(nèi)為各業(yè)務(wù)線分配唯一 id。平臺本身也由多個(gè)模塊構(gòu)成,這些模塊需要共享相同的業(yè)務(wù)線定義(要不然就亂套了)。當(dāng)公司新開產(chǎn)品線時(shí),需要能夠在短時(shí)間內(nèi)打通所有平臺系統(tǒng)的流程。這時(shí)候每個(gè)系統(tǒng)都走上線流程肯定是來不及的。另外需要對這種公共配置進(jìn)行統(tǒng)一管理,同時(shí)對其增減邏輯也做統(tǒng)一管理。這些信息變更時(shí),需要自動(dòng)通知到業(yè)務(wù)方的系統(tǒng),而不需要人力介入(或者只需要很簡單的介入,比如點(diǎn)擊審核通過)。
除業(yè)務(wù)線管理之外,很多互聯(lián)網(wǎng)公司會按照城市來鋪展自己的業(yè)務(wù)。在某個(gè)城市未開城之前,理論上所有模塊都應(yīng)該認(rèn)為帶有該城市 id 的數(shù)據(jù)是臟數(shù)據(jù)并自動(dòng)過濾掉。而如果業(yè)務(wù)開城,在系統(tǒng)中就應(yīng)該自己把這個(gè)新的城市 id 自動(dòng)加入到白名單中。這樣業(yè)務(wù)流程便可以自動(dòng)運(yùn)轉(zhuǎn)。
再舉個(gè)例子,互聯(lián)網(wǎng)公司的運(yùn)營系統(tǒng)中會有各種類型的運(yùn)營活動(dòng),有些運(yùn)營活動(dòng)推出后可能出現(xiàn)了超出預(yù)期的事件(比如公關(guān)危機(jī)),需要緊急將系統(tǒng)下線。這時(shí)候會用到一些開關(guān)來快速關(guān)閉相應(yīng)的功能。或者快速將想要剔除的活動(dòng) id 從白名單中剔除。在 Web 章節(jié)中的 AB 測試一節(jié)中,我們也提到,有時(shí)需要有這樣的系統(tǒng)來告訴我們當(dāng)前需要放多少流量到相應(yīng)的功能代碼上。我們可以像那一節(jié)中,使用遠(yuǎn)程 RPC 來獲知這些信息,但同時(shí),也可以結(jié)合分布式配置系統(tǒng),主動(dòng)地拉取到這些信息。
我們使用 etcd 實(shí)現(xiàn)一個(gè)簡單的配置讀取和動(dòng)態(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 路徑的變動(dòng)事件,在該路徑下內(nèi)容發(fā)生變化時(shí),客戶端側(cè)可以收到變動(dòng)通知,并收到變動(dòng)后的字符串值。
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é)點(diǎn)下的每次變化
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é)中的例子就可以實(shí)現(xiàn)功能了。
這里只需要注意一點(diǎn),我們在更新配置時(shí),進(jìn)行了一系列操作:watch 響應(yīng),json 解析,這些操作都不具備原子性。當(dāng)單個(gè)業(yè)務(wù)請求流程中多次獲取 config 時(shí),有可能因?yàn)橹型?config 發(fā)生變化而導(dǎo)致單個(gè)請求前后邏輯不一致。因此,在使用類似這樣的方式來更新配置時(shí),需要在單個(gè)請求的生命周期內(nèi)使用同樣的配置。具體實(shí)現(xiàn)方式可以是只在請求開始的時(shí)候獲取一次配置,然后依次向下透傳等等,具體情況具體分析。
隨著業(yè)務(wù)的發(fā)展,配置系統(tǒng)本身所承載的壓力可能也會越來越大,配置文件可能成千上萬??蛻舳送瑯由先f,將配置內(nèi)容存儲在 etcd 內(nèi)部便不再合適了。隨著配置文件數(shù)量的膨脹,除了存儲系統(tǒng)本身的吞吐量問題,還有配置信息的管理問題。我們需要對相應(yīng)的配置進(jìn)行權(quán)限管理,需要根據(jù)業(yè)務(wù)量進(jìn)行配置存儲的集群劃分。如果客戶端太多,導(dǎo)致了配置存儲系統(tǒng)無法承受瞬時(shí)大量的 QPS,那可能還需要在客戶端側(cè)進(jìn)行緩存優(yōu)化,等等。
這也就是為什么大公司都會針對自己的業(yè)務(wù)額外開發(fā)一套復(fù)雜配置系統(tǒng)的原因。
在配置管理過程中,難免出現(xiàn)用戶誤操作的情況,例如在更新配置時(shí),輸入了無法解析的配置。這種情況下我們可以通過配置校驗(yàn)來解決。
有時(shí)錯(cuò)誤的配置可能不是格式上有問題,而是在邏輯上有問題。比如我們寫 SQL 時(shí)少 select 了一個(gè)字段,更新配置時(shí),不小心丟掉了 json 字符串中的一個(gè) field 而導(dǎo)致程序無法理解新的配置而進(jìn)入詭異的邏輯。為了快速止損,最快且最有效的辦法就是進(jìn)行版本管理,并支持按版本回滾。
在配置進(jìn)行更新時(shí),我們要為每份配置的新內(nèi)容賦予一個(gè)版本號,并將修改前的內(nèi)容和版本號記錄下來,當(dāng)發(fā)現(xiàn)新配置出問題時(shí),能夠及時(shí)地回滾回來。
常見的做法是,使用 MySQL 來存儲配置文件或配置字符串的不同版本內(nèi)容,在需要回滾時(shí),只要進(jìn)行簡單的查詢即可。
在業(yè)務(wù)系統(tǒng)的配置被剝離到配置中心之后,并不意味著我們的系統(tǒng)可以高枕無憂了。當(dāng)配置中心本身宕機(jī)時(shí),我們也需要一定的容錯(cuò)能力,至少保證在其宕機(jī)期間,業(yè)務(wù)依然可以運(yùn)轉(zhuǎn)。這要求我們的系統(tǒng)能夠在配置中心宕機(jī)時(shí),也能拿到需要的配置信息。哪怕這些信息不夠新。
具體來講,在給業(yè)務(wù)提供配置讀取的 SDK 時(shí),最好能夠?qū)⒛玫降呐渲迷跇I(yè)務(wù)機(jī)器的磁盤上也緩存一份。這樣遠(yuǎn)程配置中心不可用時(shí),可以直接用硬盤上的內(nèi)容來做兜底。當(dāng)重新連接上配置中心時(shí),再把相應(yīng)的內(nèi)容進(jìn)行更新。
加入緩存之后務(wù)必需要考慮的是數(shù)據(jù)一致性問題,當(dāng)個(gè)別業(yè)務(wù)機(jī)器因?yàn)榫W(wǎng)絡(luò)錯(cuò)誤而與其它機(jī)器配置不一致時(shí),我們也應(yīng)該能夠從監(jiān)控系統(tǒng)中知曉。
我們使用一種手段解決了我們配置更新痛點(diǎn),但同時(shí)可能因?yàn)槭褂玫氖侄味鴰Ыo我們新的問題。實(shí)際開發(fā)中,我們要對每一步?jīng)Q策多多思考,以使自己不在問題到來時(shí)手足無措。
![]() | ![]() |
更多建議: