用Golang處理每分鐘100萬份請求

zhongzhong05發表於2019-03-04

我在幾家不同的公司從事反垃圾郵件,防病毒和反惡意軟體行業工作超過15年,現在我知道這些系統最終會因為我們每天處理的大量資料而變得複雜。

目前,我是smsjunk.com的CEO和KnowBe4的首席架構師,他們都是網路安全行業的公司。

有趣的是,在過去的10年左右,作為一名軟體工程師,我參與過的所有Web後端開發大部分都是在Ruby on Rails中完成的。不要誤會我的意思,我喜歡Ruby on Rails,我相信這是一個了不起的環境,但是過了一段時間,你開始用ruby的方式思考和設計系統,而且如果你忘記了軟體架構的效率和簡單性-可以利用多執行緒,並行化,快速執行和小記憶體開銷。多年來,我是一名C / C ++,Delphi和C#開發人員,而且我剛開始意識到使用正確的工具進行工作可能會有多複雜。

我對網際網路總是爭論的語言和框架戰爭並不太感興趣。我相信效率,生產力和程式碼可維護性主要取決於您構建解決方案的簡單程度。

問題

在處理我們的匿名遙測和分析系統時,我們的目標是能夠處理來自數百萬端點的大量POST請求。Web處理程式將收到一個JSON文件,該文件可能包含需要寫入Amazon S3的多個有效內容的集合,以便我們的map-reduce系統稍後對這些資料進行操作。

傳統上,我們會考慮建立一個工作層架構,利用諸如以下方面的內容:

  • Sidekiq
  • Resque
  • DelayedJob
  • Elasticbeanstalk Worker Tier
  • RabbitMQ
  • ...

並搭建2個不同的叢集,一個用於web前端,一個用於worker,因此我們可以擴大我們可以處理的後臺工作量。

但是從一開始,我們的團隊就知道我們應該在Go中這樣做,因為在討論階段我們看到這可能是一個非常大的交通系統。我一直在使用Go,大約快2年時間了,而且我們這裡開發了一些Go的系統,但是沒有一個系統能夠達到這個數量級。我們首先建立了幾個struct來定義我們通過POST呼叫接收到的Web請求負載,並將其上傳到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{})
}
複製程式碼

Naive的做法-使用Go routine

最初,我們對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 routine的數量。而且,由於我們每分鐘獲得100萬POST請求,所以系統很快崩潰了。

重來

我們需要找到一種不同的方式。從一開始我們就開始討論如何保持請求處理程式的生命週期非常短,並在後臺產生處理。當然,這就是Ruby on Rails必須要做的事情,否則,不管你是使用puma, unicorn還是passenger,你的所有的可用的web worker都將阻塞。

那麼我們就需要利用常見的解決方案來完成這項工作,比如Resque,Sidekiq,SQS等。這個名單還在繼續,因為有很多方法可以實現這一目標。

因此,第二次迭代是建立一個buffer channel,我們可以將一些作業排隊並將它們上傳到S3,由於我們可以控制佇列中的最大物品數量,並且有足夠的RAM來排隊處理記憶體中的作業,因此我們認為只要在通道佇列中緩衝作業就行了。

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
    }
    ...
}
複製程式碼

然後,為了將任務從buffer channel中取出並處理它們,我們正在使用這樣的方式:

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

說實話,我不知道我們在想什麼。這肯定是一個全線飄紅的深夜。這種方法並沒有給我們帶來什麼,我們用一個緩衝的佇列交換了有缺陷的併發,這只是簡單地推遲了這個問題。我們的同步處理器每次只向S3上傳一個有效載荷,由於傳入請求的速率遠遠大於單個處理器上傳到S3的能力,因此我們的buffer channel迅速達到極限,並阻止了處理程式繼續往裡面新增更多的請求資料。

我們只是避免了這個問題,並最終開始倒數計時,直到我們的系統死亡。在我們部署這個有缺陷的版本後,我們的延遲率以不變的速度持續增長。

用Golang處理每分鐘100萬份請求

更好的解決方案

我們決定在Go channel上使用一個通用模式來建立一個雙層channel系統,一個用來處理排隊的job,一個用來控制有多少worker在JobQueue 上併發工作。

這個想法是將上傳到S3的並行化速度提高到一個可持續的速度,不會造成機器癱瘓,也不會引發S3的連線錯誤。 所以我們選擇建立一個Job / Worker模式。對於那些熟悉Java,C#等的人來說,可以將其視為Golang使用channel來實現Worker Thread-Pool的方式。

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請求處理程式以建立具有有效負載的Job struct,並將其傳送到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伺服器初始化期間,我們建立一個Dispatcher並呼叫Run()來建立worker池並開始監聽JobQueue中出現的Job。

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)
		}
	}
}
複製程式碼

請注意,我們例項化了最大數量的worker,並將其儲存到worker池中(就是上面的WorkerPool channel)。由於我們已經將Amazon Elasticbeanstalk用於Docker化的Go專案,並且我們始終嘗試遵循12因子方法來配置生產中的系統,因此我們從環境變數中讀取這些值。這樣我們就可以控制工作佇列的數量和最大規模,所以我們可以快速調整這些值,而不需要重新部署叢集。

var ( 
  MaxWorker = os.Getenv("MAX_WORKERS") 
  MaxQueue  = os.Getenv("MAX_QUEUE") 
)
複製程式碼

在我們部署它之後,我們立即看到我們的所有延遲率都下降到微不足道的數字,我們處理請求的能力急劇上升。

用Golang處理每分鐘100萬份請求

在我們的彈性負載均衡器完全熱身之後的幾分鐘,我們看到我們的ElasticBeanstalk應用程式每分鐘提供近100萬次請求。我們通常在早上的幾個小時裡,我們的流量高達每分鐘100多萬。

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

用Golang處理每分鐘100萬份請求

在我們正確地配置了我們的叢集和自動縮放設定後,我們可以將它降低到只有4x EC2 c4的配置。大型例項和Elastic Auto-Scaling設定為在CPU連續5分鐘超過90%時產生一個新例項。

用Golang處理每分鐘100萬份請求

結論

樸素總是在我的書中獲勝。我們可以設計一個擁有許多佇列,後臺工作人員和複雜部署的複雜系統,但我們決定利用Elasticbeanstalk自動擴充套件的強大功能以及Golang為我們提供開箱即用的併發性效率和簡單方法。

並不是每天都是隻有4臺機器的叢集,這可能遠不及我現在的MacBook Pro,它能夠每分鐘處理100w次的請求。

總是有適合指定需求的工具。有時,當您的Ruby on Rails系統需要一個非常強大的Web處理程式時,請考慮在Ruby生態系統之外尋找更簡單但更強大的替代解決方案。

原文地址:https://medium.com/smsjunk/handling-1-million-requests-per-minute-with-golang-f70ac505fcaa

相關文章