如何用Golang處理每分鐘100萬個請求

janrs_com發表於2023-04-11

轉載請註明來源:janrs.com/9yaq


在我設計一個分析系統中,我們公司的目標是能夠處理來自數百萬個端點的大量POST請求。web 網路處理程式將收到一個JSON文件,其中可能包含許多有效載荷的集合,需要寫入Amazon S3,以便我們的地圖還原系統隨後對這些資料進行操作。

傳統上,我們會研究建立一個工人層架構,利用諸如以下東西:

  • Sidekiq
  • Resque
  • DelayedJob
  • Elasticbeanstalk Worker Tier
  • RabbitMQ
  • 還有等等其他的技術手段…

並設定 2 個不同的叢集,一個用於 Web 前端,另一個用於 worker 處理程式,這樣我們就可以擴大我們可以處理的後臺工作量。

但從一開始,我們的團隊就知道我們應該在 Go 中這樣做,因為在討論階段我們看到這可能是一個非常大的流量系統。 我使用 Go 已有大約 2 年左右的時間,我們公司在處理業務時開發了一些系統,但沒有一個能承受如此大的負載。以下是最佳化的過程。

我們首先建立一些結構體來定義我們將透過 POST 呼叫接收的 Web 請求負載,以及一種將其上傳到我們的 S3 儲存桶的方法。程式碼如下:

type PayloadCollection struct {
    WindowsVersion  string    `json:"version"`
    Token           string    `json:"token"`
    Payloads        []Payload `json:"data"`
}

type Payload struct {
    // ...負載欄位
}

func (p *Payload) UploadToS3() error {
    // storageFolder 方法確保在我們在鍵名中獲得相同時間戳時不會發生名稱衝突
    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
    }

    // 我們釋出到 S3 儲存桶的所有內容都應標記為“私有”
    var acl = s3.Private
    var contentType = "application/octet-stream"

    return bucket.PutReader(storage_path, b, int64(b.Len()), contentType, acl, s3.Options{})
}

最初我們採用了一個非常簡單的 POST 處理程式實現,只是試圖將job 處理程式並行化到一個簡單的 goroutine 中:

func payloadHandler(w http.ResponseWriter, r *http.Request) {

    if r.Method != "POST" {
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }

    // 將body讀入字串進行json解碼
    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
    }

    // 分別檢查每個有效負載和佇列專案以釋出到 S3
    for _, payload := range content.Payloads {
        go payload.UploadToS3()   // <----- 這是不建議的做法。這裡是最開始的做法。
    }

    w.WriteHeader(http.StatusOK)
}

對於中等負載,這可能適用於大多數公司的流量,但很快證明這在大規模情況下效果不佳。 我們期望有很多請求,但沒有達到我們將第一個版本部署到生產環境時開始看到的數量級。 我們完全低估了流量。

上面的方法在幾個不同的方面是不好的。 無法控制我們生成了多少個 go routines。 由於我們每分鐘收到 100 萬個 POST 請求,因此這段程式碼很快崩潰了。

我們需要找到一種不同的方式。 從一開始我們就開始討論我們需要如何保持請求處理程式的生命週期非常短,並在後臺進行生成處理。 當然,這是你在使用 Ruby on Rails 時必須做的,否則你將阻止所有可用的 worker web 處理器,無論你使用的是 puma、unicorn 還是 passenger(請不要進入 JRuby 討論)。 然後我們需要利用常見的解決方案來做到這一點,例如 Resque、Sidekiq、SQS 等等,有很多方法可以實現這一點。

所以第二次迭代是建立一個緩衝通道,我們可以建立一些佇列,然後把 job push到佇列並將它們上傳到 S3,並且由於我們可以控制job 佇列中的最大數數量並且我們有足夠的記憶體來處理佇列中的 job。在這個方案中,我們認為只需要在通道佇列中緩衝需要處理的 job 就可以了。

程式碼如下:

var Queue chan Payload

func init() {
    Queue = make(chan Payload, MAX_QUEUE)
}

func payloadHandler(w http.ResponseWriter, r *http.Request) {
    ...
    // 分別檢查每個有效負載和佇列專案以釋出到 S3
    for _, payload := range content.Payloads {
        Queue <- payload // <----- 這是建議的做法。
    }
    ...
}

然後為了實際出列作業並處理它們,我們使用了類似的東西:

func StartProcessor() {
    for {
        select {
        case job := <-Queue:
            job.payload.UploadToS3()  // <-- 這裡雖然最佳化了,但還不是最好的。
        }
    }
}

在上面的程式碼中,我們用一個緩衝佇列來交換有缺陷的併發性,而緩衝佇列只是推遲了問題。 我們的同步處理器一次只將一個有效負載上傳到 S3,並且由於傳入請求的速率遠遠大於單個處理器上傳到 S3 的能力,我們的 job 緩衝通道很快達到了極限並阻止了請求處理程式的能力,佇列很快就阻塞滿了。

我們只是在避免這個問題,並開始倒數計時,直到我們的系統最終死亡。 在我們部署這個有缺陷的版本後,我們的延遲率在幾分鐘內以恆定的速度持續增加。以下是延遲率增長圖:

我們決定在使用 Go 通道時使用一種通用模式,以建立一個 2 層通道系統,一個用於 Job 佇列,另一個用於控制同時在 Job 佇列上操作的 Worker 的數量。

這個想法是將上傳到 S3 的資料並行化到某種程度上可持續的速度,這種速度既不會削弱機器也不會開始從 S3 生成連線錯誤。 所以我們選擇建立 Job/Worker 模式。 對於那些熟悉 Java、C# 等的人來說,可以將其視為 Golang 使用通道實現 Worker 執行緒池的方式。

程式碼如下:

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

// Job 表示要執行的作業
type Job struct {
    Payload Payload
}

// 我們可以在 Job 佇列上傳送工作請求的緩衝通道。
var JobQueue chan Job

// Worker 代表執行作業的 Worker。
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 方法為 Worker 啟動迴圈監聽。監聽退出訊號以防我們需要停止它。
func (w Worker) Start() {
    go func() {
        for {
            // 將當前 woker 註冊到工作佇列中。
            w.WorkerPool <- w.JobChannel

            select {
            case job := <-w.JobChannel:
                // 接收 work 請求。
                if err := job.Payload.UploadToS3(); err != nil {
                    log.Errorf("Error uploading to S3: %s", err.Error())
                }

            case <-w.quit:
                // 接收一個退出的訊號。
                return
            }
        }
    }()
}

// 將退出訊號傳遞給 Worker 程式以停止處理清理。
func (w Worker) Stop() {
    go func() {
        w.quit <- true
    }()
}

我們已經修改了我們的 Web 請求處理程式,以建立一個帶有有效負載的 Job 結構例項,並將其傳送到 JobQueue 通道以供 Worker 提取。

func payloadHandler(w http.ResponseWriter, r *http.Request) {

    if r.Method != "POST" {
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }

    // 將body讀入字串進行json解碼
    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
    }

    // 分別檢查每個有效負載和佇列專案以釋出到 S3
    for _, payload := range content.Payloads {

        // 建立一個有效負載的job
        work := Job{Payload: payload}

        // 將 work push 到佇列。
        JobQueue <- work
    }

    w.WriteHeader(http.StatusOK)
}

在我們的 Web 伺服器初始化期間,我們建立一個 Dispatcher 排程器並呼叫 Run() 來建立 Woker 工作池並開始偵聽將出現在 Job 佇列中的 Job。

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

下面是我們的排程程式實現的程式碼:

type Dispatcher struct {
    // 透過排程器註冊一個 Worker 通道池
    WorkerPool chan chan Job
}

func NewDispatcher(maxWorkers int) *Dispatcher {
    pool := make(chan chan Job, maxWorkers)
    return &Dispatcher{WorkerPool: pool}
}

func (d *Dispatcher) Run() {
    // 啟動指定數量的 Worker
    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:
            // 接收一個 job 請求
            go func(job Job) {
                // 嘗試獲取可用的 worker job 通道
                // 這將阻塞 worker 直到空閒
                jobChannel := <-d.WorkerPool

                // 排程一個 job 到 worker job 通道
                jobChannel <- job
            }(job)
        }
    }
}

請注意,我們提供了要例項化並新增到我們的 Worker 池中的最大worker 數量。 由於我們在這個專案中使用了 Amazon Elasticbeanstalk 和 dockerized Go 環境,因此我們從環境變數中讀取這些值。 這樣我們就可以控制 Job 佇列的數量和最大大小,因此我們可以快速調整這些值而無需重新部署叢集。

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

在我們部署它之後,我們立即看到我們所有的延遲率都下降到極低的延遲,並且我們處理請求的能力急劇上升。以下是流量截圖:

在我們的彈性負載均衡器完全預熱幾分鐘後,我們看到我們的 ElasticBeanstalk 應用程式每分鐘處理近 100 萬個請求。 我們通常在早上有幾個小時的流量會飆升至每分鐘超過一百萬。

一旦我們部署了新程式碼,伺服器數量就從 100 臺伺服器大幅下降到大約 20 臺伺服器。以下是伺服器數量變化截圖:

在正確配置叢集和自動縮放設定後,我們能夠將其進一步降低到僅 4x EC2 c4.Large 例項,並且如果 CPU 使用率超過 90% 持續 5 天,Elastic Auto-Scaling 將生成一個新例項 分鐘值。以下是截圖:

可以看出利用 Elasticbeanstalk 自動縮放的強大功能以及 Golang 提供的開箱即用的高效和簡單的併發方法,就可以構建出一個高效能的處理程式。


轉載請註明來源:janrs.com/9yaq

本作品採用《CC 協議》,轉載必須註明作者和本文連結
Go / istio / k8s / 雲原生 - janrs.com

相關文章