使用Go語言每分鐘處理1百萬請求

astaxie發表於2016-10-10

使用 Go 語言每分鐘處理 1 百萬請求(譯)


Malwarebytes 我們經歷了顯著的增長,自從我一年前加入了矽谷的公司,一個主要的職責成了設計架構和開發一些系統來支援一個快速增長的資訊保安公司和所有需要的設施來支援一個每天百萬使用者使用的產品。我在反病毒和反惡意軟體行業的不同公司工作了 12 年,從而我知道由於我們每天處理大量的資料,這些系統是多麼複雜。

有趣的是,在過去的大約 9 年間,我參與的所有的 web 後端的開發通常是通過 Ruby on Rails 技術實現的。不要錯怪我。我喜歡 Ruby on Rails,並且我相信它是個令人驚訝的環境。但是一段時間後,你會開始以 ruby 的方式開始思考和設計系統,你會忘記,如果你可以利用多執行緒、並行、快速執行和小記憶體開銷,軟體架構本來應該是多麼高效和簡單。很多年期間,我是一個 c/c++、Delphi 和 c# 開發者,我剛開始意識到使用正確的工具可以把複雜的事情變得簡單些。

作為首席架構師,我不會很關心在網際網路上的語言和框架戰爭。我相信效率、生產力。程式碼可維護性主要依賴於你如何把解決方案設計得很簡單。

問題

當工作在我們的匿名遙測和分析系統中,我們的目標是可以處理來自於百萬級別的終端的大量的 POST 請求。web 處理服務可以接收包含了很多 payload 的集合的 JSON 資料,這些資料需要寫入 Amazon S3 中。接下來,map-reduce 系統可以操作這些資料。

按照習慣,我們會調研服務層級架構,涉及的軟體如下:

  • Sidekiq
  • Resque
  • DelayedJob
  • Elasticbeanstalk Worker Tier
  • RabbitMQ
  • and so on…

搭建了 2 個不同的叢集,一個提供 web 前端,另外一個提供後端處理,這樣我們可以橫向擴充套件後端服務的數量。

但是,從剛開始,在 討論階段我們的團隊就知道我們應該使用 Go,因為我們看到這會潛在性地成為一個非常龐大( large traffic)的系統。我已經使用了 Go 語言大約 2 年時間,我們開發了幾個系統,但是很少會達到這樣的負載(amount of load)。

我們開始建立一些結構,定義從 POST 呼叫得到的 web 請求負載,還有一個上傳到 S3 budket 的函式。

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 處理實現,僅僅嘗試把發到簡單 go routine 的 job 並行化:

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 routine 的數量。由於我們收到了每分鐘 1 百萬的 POST 請求,這段程式碼很快就崩潰了。

再次嘗試

我們需要找一個不同的方式。自開始我們就討論過, 我們需要保持請求處理程式的生命週期很短,並且程式在後臺產生。當然,這是你在 Ruby on Rails 的世界裡必須要做的事情,否則你會阻塞在所有可用的工作 web 處理器上,不管你是使用 puma、unicore 還是 passenger(我們不要討論 JRuby 這個話題)。然後我們需要利用常用的處理方案來做這些,比如 Resque、 Sidekiq、 SQS 等。這個列表會繼續保留,因為有很多的方案可以實現這些。

所以,第二次迭代,我們建立了一個緩衝 channel,我們可以把 job 排隊,然後把它們上傳到 S3。因為我們可以控制我們佇列中的 item 最大值,我們有大量的記憶體來排列 job,我們認為只要把 job 在 channel 裡面緩衝就可以了。

var Queue chan Payload

func 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
    }
    ...
}

接下來,我們再從佇列中取 job,然後處理它們。我們使用類似於下面的程式碼:

func StartProcessor() {
    for {
        select {
        case job := <-Queue:
            job.payload.UploadToS3()  // <-- STILL NOT GOOD
        }
    }
}

說實話,我不知道我們在想什麼。這肯定是一個滿是 Red-Bulls 的夜晚。這個方法不會帶來什麼改善,我們用了一個 有缺陷的緩衝佇列併發,僅僅是把問題推遲了。我們的同步處理器同時僅僅會上傳一個資料到 S3,因為來到的請求遠遠大於單核處理器上傳到 S3 的能力,我們的帶緩衝 channel 很快達到了它的極限,然後阻塞了請求處理邏輯的 queue 更多 item 的能力。

我們僅僅避免了問題,同時開始了我們的系統掛掉的倒數計時。當部署了這個有缺陷的版本後,我們的延時保持在每分鐘以常量增長。

此處輸入圖片的描述

最好的解決方案

我們討論過在使用用 Go channel 時利用一種常用的模式,來建立一個二級 channel 系統,一個來 queue job,另外一個來控制使用多少個 worker 來併發操作 JobQueue。

想法是,以一個恆定速率並行上傳到 S3,既不會導致機器崩潰也不好產生 S3 的連線錯誤。這樣我們選擇了建立一個 Job/Worker 模式。對於那些熟悉 Java、C# 等語言的開發者,可以把這種模式想象成利用 channel 以 golang 的方式來實現了一個 worker 執行緒池,作為一種替代。

var (
    MaxWorker = os.Getenv("MAX_WORKERS")
    MaxQueue  = os.Getenv("MAX_QUEUE")
)

// Job represents the job to be run
type 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 job
type 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 it
func (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 請求 handler,用 payload 建立一個 Job 例項,然後發到 JobQueue channel,以便於 worker 來獲取。

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 server 初始化時,我們建立一個 Dispatcher,然後呼叫 Run() 函式建立一個 worker 池子,然後開始監聽 JobQueue 中的 job。

dispatcher := NewDispatcher(MaxWorker)
dispatcher.Run()

下面是 dispatcher 的實現程式碼:

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)
        }
    }
}

注意到,我們提供了初始化並加入到池子的 worker 的最大數量。因為這個工程我們利用了 Amazon Elasticbeanstalk 帶有的 docker 化的 Go 環境,所以我們常常會遵守12-factor方法論來配置我們的生成環境中的系統,我們從環境變了讀取這些值。這種方式,我們控制 worker 的數量和 JobQueue 的大小,所以我們可以很快的改變這些值,而不需要重新部署叢集。

var (
    MaxWorker = os.Getenv("MAX_WORKERS")
    MaxQueue  = os.Getenv("MAX_QUEUE")
)

直接結果

我們部署了之後,立馬看到了延時降到微乎其微的數值,並未我們處理請求的能力提升很大。

此處輸入圖片的描述

Elastic Load Balancers 完全啟動後,我們看到 ElasticBeanstalk 應用服務於每分鐘 1 百萬請求。通常情況下在上午時間有幾個小時,流量峰值超過每分鐘一百萬次。

我們一旦部署了新的程式碼,伺服器的數量從 100 臺大幅 下降到大約 20 臺。

此處輸入圖片的描述

我們合理配置了我們的叢集和自動均衡配置之後,我們可以把伺服器的數量降至 4x EC2 c4.Large 例項,並且 Elastic Auto-Scaling 設定為如果 CPU 達到 5 分鐘的 90% 利用率,我們就會產生新的例項。

此處輸入圖片的描述

總結

在我的書中,簡單總是獲勝。我們可以使用多佇列、後臺 worker、複雜的部署設計一個複雜的系統,但是我們決定利用 Elasticbeanstalk 的 auto-scaling 的能力和 Go 語言開箱即用的特性簡化併發。

我們僅僅用了 4 臺機器,這並不是什麼新鮮事了。可能它們還不如我的 MacBook 能力強大,但是卻處理了每分鐘 1 百萬的寫入到 S3 的請求。

處理問題有正確的工具。當你的 Ruby on Rails 系統需要更強大的 web handler 時,可以考慮下 ruby 生態系統之外的技術,或許可以得到更簡單但更強大的替代方案。

文章原文

更多原創文章乾貨分享,請關注公眾號
  • 使用Go語言每分鐘處理1百萬請求
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章