這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
原文記錄: http://www.codedata.cn/hackne...
我在反廣告、殺病毒、檢木馬等行業的不同軟體公司裡已經工作 15 年以上了,非常瞭解這類系統軟體因每天處理海量資料而導致的複雜性。
目前我作為 smsjunk.com 的 CEO 和 KnowBe4 的主架構師,在這兩個網路安全領域的公司裡工作。
有趣的是,在過去的 10 年裡,作為軟體工程師,我接觸到的 web 後端代碼大多是用 Ruby on Rails 開發的。請不要誤會,我很喜歡 Ruby on Railds 架構,而且我認為它是一套令人稱讚的架構,不過時間一長,你就會習慣於使用 ruby 語言的方式思考和設計系統,會忘記利用多線程,並行化,快速執行和小的記憶體消耗,軟體架構本可以如此高效且簡單。很多年來,我也是一個 C/C++,Delphi 以及 C# 的使用者,而且我開始認識到使用正確的工具能讓事情變得更簡單。
我對互連網上沒完沒了的語言架構之間的論戰並不感冒。因為我相信解決方案的效能及代碼可維護性主要倚仗於你的架構能做到多簡單。
實際問題
在實現某個遙測分析系統時,我們遇到一個實際問題,要處理來自數百萬終端的 POST 請求。其中的 web 請求處理過程會接收到一個 JSON 文檔,它包含一個由許多荷載資料群組成的集合,我們要把它寫到 Amazon S3 儲存中,之後我們的 map-reduce 系統就可以對這些資料進行處理。
一般我們會利用如下的組件去建立一個有後台工作層的架構,如:
- Sidekiq
- Resque
- DelayedJob
- Elasticbeanstalk Worker Tier
- RabbitMQ
- 等等
並且建立兩個不同的服務叢集,一個用作 web 前端接收資料,另一個執行具體的工作,這樣我們就能動態調整幕後處理工作的能力了。
不過從項目伊始,我們的團隊就認為應該用 Go 語言來實現這項工作,因為在討論過程中我們發現這可能是一個流量巨大的系統。我已經使用 Go 語言快兩年了,而且我們已經在工作中用它開發了一些系統,只是還沒遇到過負載如此大的系統。
我們從定義一些 web 的 POST 請求載荷資料結構開始,還有一個用於上傳到 S3 儲存的方法。
type PayloadCollection struct { WindowsVersion string `json:"version"` Token string `json:"token"` Payloads []Payload `json:"data"`}type Payload struct { // [redacted]}func (p *Payload) UploadToS3() error { // the storageFolder method ensures that there are no name collision in // case we get same timestamp in the key name storage_path := fmt.Sprintf("%v/%v", p.storageFolder, time.Now().UnixNano()) bucket := S3Bucket b := new(bytes.Buffer) encodeErr := json.NewEncoder(b).Encode(payload) if encodeErr != nil { return encodeErr } // Everything we post to the S3 bucket should be marked 'private' var acl = s3.Private var contentType = "application/octet-stream" return bucket.PutReader(storage_path, b, int64(b.Len()), contentType, acl, s3.Options{})}
Go routines 的傻瓜式用法
起初我們實現了一個非常簡單的 POST 處理介面,嘗試用一個簡單的 goroutine 並行工作處理過程:
func payloadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { w.WriteHeader(http.StatusMethodNotAllowed) return } // Read the body into a string for json decoding var content = &PayloadCollection{} err := json.NewDecoder(io.LimitReader(r.Body, MaxLength)).Decode(&content) if err != nil { w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.WriteHeader(http.StatusBadRequest) return } // Go through each payload and queue items individually to be posted to S3 for _, payload := range content.Payloads { go payload.UploadToS3() // <----- DON'T DO THIS } w.WriteHeader(http.StatusOK)}
在普通負載的情況下,這段代碼對於大多數人已經夠用了,不過很快就被證明了不適合大流量的情形。當我們把第一個版本的代碼部署到生產環境後,才發現實際情況遠遠超出我們的預期,系統流量比之前預計的大許多,我們低估了資料負載量。
上面的處理方式從幾個方面來看都有問題。我們無法辦法控制建立的 go routines 的數量。而且我們每分鐘收到一百萬次的 POST 請求,代碼必然很快就崩潰。
再次嘗試
我們需要尋找別的出路。從一開始,我們就在討論怎樣保證請求處理時間較短,然後在後台進行工作處理。當然,在 Ruby on Rails 裡必須這樣做,否則你會阻塞掉所有的 web 處理進程,無論你是否使用了 puma,unicorn,passenger(我們這裡就不討論 JRuby 了)。然後我們可能會使用常見的解決方案,比如 Resque,Sidkiq,SQS,等等。有許多方法可以完成這個任務。
所以第二次迭代採用了緩衝通道( buffered channel ),我們可以將一些工作先放入隊列,再將它們上傳至 S3,由於我們能夠控制隊列的大小,而且有充足的記憶體可用,所以我們以為將任務緩衝到 channel 隊列中就可以了。
var Queue chan Payloadfunc init() { Queue = make(chan Payload, MAX_QUEUE)}func payloadHandler(w http.ResponseWriter, r *http.Request) { ... // Go through each payload and queue items individually to be posted to S3 for _, payload := range content.Payloads { Queue <- payload } ...}
然後將任務從隊列中取出再進行處理,我們使用了類似下面的代碼:
func StartProcessor() { for { select { case job := <-Queue: job.payload.UploadToS3() // <-- 仍然不好使! } }}
老實說,我都不知道當時我們在想些什麼。這一定是喝紅牛熬夜導致的結果。這個方案沒給我們帶來任何好處,我們只是將一個有問題的並發過程替換為了一個緩衝隊列,它只是將問題推後了而已。我們的同步處理過程每次只將一份載荷資料上傳到 S3,由於接受到請求的速率遠大於單常式上傳到 S3 的能力,我們的緩衝隊列很快就滿了,導致請求處理過程阻塞,無法將更多的資料送入隊列。
我們傻乎乎地忽略了問題,最終開始了系統的死亡倒計時。在部署了這個問題版本之後幾分鐘裡,系統的延遲以固定的速率不斷增加。
更好的解決方案
我們決定使用 Go 通道的一種常用模式構建一個兩層的通道系統,一個通道用作任務隊列,另一個來控制處理任務時的並發量。
這個辦法是想以一種可持續的速率、並發地上傳資料至 S3 儲存,這樣既不會把機器跑掛掉也不會產生 S3 的串連錯誤。因此我們選擇使用了一種 Job/Worker 模式。如果你熟悉 Java,C# 等語言,可以認為這是使用通道以 Go 語言的方式實現了一個背景工作執行緒池。
var ( MaxWorker = os.Getenv("MAX_WORKERS") MaxQueue = os.Getenv("MAX_QUEUE"))// Job represents the job to be runtype Job struct { Payload Payload}// A buffered channel that we can send work requests on.var JobQueue chan Job// Worker represents the worker that executes the jobtype Worker struct { WorkerPool chan chan Job JobChannel chan Job quit chan bool}func NewWorker(workerPool chan chan Job) Worker { return Worker{ WorkerPool: workerPool, JobChannel: make(chan Job), quit: make(chan bool)}}// Start method starts the run loop for the worker, listening for a quit channel in// case we need to stop itfunc (w Worker) Start() { go func() { for { // register the current worker into the worker queue. w.WorkerPool <- w.JobChannel select { case job := <-w.JobChannel: // we have received a work request. if err := job.Payload.UploadToS3(); err != nil { log.Errorf("Error uploading to S3: %s", err.Error()) } case <-w.quit: // we have received a signal to stop return } } }()}// Stop signals the worker to stop listening for work requests.func (w Worker) Stop() { go func() { w.quit <- true }()}
我們修改了 web 請求處理過程,使用資料載荷建立了一個 Job 執行個體,然後將其送入 JobQueue 通道中供工作常式使用。
func payloadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { w.WriteHeader(http.StatusMethodNotAllowed) return } // Read the body into a string for json decoding var content = &PayloadCollection{} err := json.NewDecoder(io.LimitReader(r.Body, MaxLength)).Decode(&content) if err != nil { w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.WriteHeader(http.StatusBadRequest) return } // Go through each payload and queue items individually to be posted to S3 for _, payload := range content.Payloads { // let's create a job with the payload work := Job{Payload: payload} // Push the work onto the queue. JobQueue <- work } w.WriteHeader(http.StatusOK)}
在 web 服務初始化的過程中,我們建立了一個 Dispatcher 執行個體,調用 Run() 方法建立了工作常式池,並且通過監聽 JobQueue 擷取工作任務。
dispatcher := NewDispatcher(MaxWorker) dispatcher.Run()
下面的代碼是任務指派器的具體實現:
type Dispatcher struct { // A pool of workers channels that are registered with the dispatcher WorkerPool chan chan Job}func NewDispatcher(maxWorkers int) *Dispatcher { pool := make(chan chan Job, maxWorkers) return &Dispatcher{WorkerPool: pool}}func (d *Dispatcher) Run() { // starting n number of workers for i := 0; i < d.maxWorkers; i++ { worker := NewWorker(d.pool) worker.Start() } go d.dispatch()}func (d *Dispatcher) dispatch() { for { select { case job := <-JobQueue: // a job request has been received go func(job Job) { // try to obtain a worker job channel that is available. // this will block until a worker is idle jobChannel := <-d.WorkerPool // dispatch the job to the worker job channel jobChannel <- job }(job) } }}
注意我們提供了一個最大數量的參數,用於控制工作池中初始的常式數量。因為這個項目使用了 Amazon Elasticbeanstalk 以及 docker 中的 Go 環境,所以我們努力遵循 12-factor 的方法,從環境變數中讀取配置值,便於在生產環境中進行系統配置。通過這種方式,我們可以控制工作常式的數量和工作隊列的長度,無需對叢集進行重新部署,我們就能快速調整參數值。
var ( MaxWorker = os.Getenv("MAX_WORKERS") MaxQueue = os.Getenv("MAX_QUEUE") )
在部署這份代碼後,我們發現系統的延遲立刻大幅下降,而我們處理請求的能力得到了巨大的提升。
在我們的 Elastic Load Balancers 全部預熱完成幾分鐘後,可以看到我們的 ElasticBeanstalk 應用每分鐘可以處理近一百萬的請求,常常會在流量早高峰的時候突破每分鐘一百萬。
我們剛把新代碼部署上去,伺服器數量就從 100 台伺服器大幅下降到大約 20 台伺服器。
在我們調整叢集配置和自動縮放配置後,我們能將伺服器的使用數量降低到四個 EC2 c4.Large 執行個體,再將 Elastic Auto-Scaling 設定為 CPU 使用率持續五分鐘超 90% 的時候,增加一個執行個體。
結論
在我的認知中,「簡單化」才是常勝秘訣。我們本可能設計一個更複雜的系統,擁有許多隊列和後台工作常式,部署也更複雜。但是我們最終利用了 Elasticbeanstalk 的自動縮放能力和 Go 語言為我們帶來的高效簡單的並發解決方案。
並不是每天都能發生這樣的事情:一個只有四台機器叢集處理著每分鐘一百萬的 POST 請求,把資料寫入 Amazon S3 儲存中,而且這些機器可能比我現在的 MacBook Pro 效能還差。
每件工作總會有更合適的工具。當你的 Ruby on Rails 系統需要強大的請求處理能力時,不妨嘗試一下 ruby 生態圈外那些更加簡單有效解決方案。
翻譯自:Handling 1 Million Requests per Minute with Golang