原文作者为Malwarebytes公司的首席架构师Marcio Castilho,博客原文地址 —— http://marcio.io/2015/07/handling-1-million-requests-per-minute-with-golang/
前言
目前Malwarebytes公司正在经历显著的发展,自从我一年前加入这家在硅谷的公司, 我的主要责任之一就是给一些快速发展的安全公司和所有需要基础设施的公司提供产品,这些产品每天都会被百万用户使用.我已经为几家不同的公司在反病毒和反恶意软件行业工作了12年,并且我知道这些系统最终因为每天要处理大量数据而变得有多复杂.
有趣的是在过去的9年左右,所有我参与的后端web开发几乎一直都是通过Ruby on Rails 来完成.不要误会,我喜欢Ruby on Rails 并且我相信其是一个了不起的环境,但是在你以Ruby的方式开始思考和设计系统一段时间后,如果你能使用多线程、并行、快速执行和低内存溢出,你会忽略软件架构的高效和简单.我是一名多年经验的C、C++,Delphi 和C#开发,我也才刚开始意识到我们应该如何使用正确的工具去减少工作的复杂.
作为首席架构师,我并不苟同那些拿开发语言和框架来争论的网站.我相信效率,生产力和代码可维护性更多依靠的是你如何简单地去架构你的解决方案.
问题
在我们的匿名遥测和分析系统工作时,我们的目标就是能够处理来自百万终端的庞大POST请求量.Web处理器将会接受一份包含大量有效负载集合的JSON文档,这些文档需要写入到Amazon S3系统中,以便我们的缓存服务系统(map-reduce)稍后处理其数据.
传统做法是, 我们将考虑创建一个worker层架构,利用诸如:
-Sidekiq
- Resque
- DelayedJob
- Elasticbeanstalk Worker Tier
- RabbitMQ
- 等等
并且为web前端和workers层各配置一个集群,这样我们就能提升我们后端需要处理的工作能力.
因为在我们的讨论阶段,我们看到了go语言在处理大型交通系统方面的潜力,所以一开始我们就知道该用Go语言来实现.我使用Go语言开发已经有两年多了,并且已经上线了一些系统,但是没有一个能达到这个负载量.
我们开始创建一些结构体来定义那些通过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{})
}
初级的Go routines实现方式
起初,我们对POST处理器采取的是非常低等的实现,仅仅试着去将处理任务并行化为一个简单的Go routines:
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的方式中,上面的方式是很差的.没有办法控制我创建了多少Go routines,并且因为我每分钟收到了一百万的POST请求,这些代码迅速崩溃宕机.
尝试改进
我们需要找到一种不同的方式.从一开始我们就讨论过我们应该怎样去保持请求处理器的生命周期非常短并且在后台创建进程.当然,在Ruby on Rails中你也得这么做,否则你将阻塞所有当前正在工作的web处理器,无论你是用Puma,Unicorn,Passenger(我们还是不要陷入JRuby的讨论中去了).我们本可以利用常用的解决方案诸如Resque,Sidekiq,SQS等等,实现这的方式有很多.
所以第二个版本迭代就是要创建一个带缓冲的channel以便我们将一些任务排队起来然后上传到S3系统中去.因为我们能控制队列中的最大排队数,并且我们有足够大的RAM内存去队列这些任务,所以我们认为在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
}
//...
}
我们采用以下相似的方式从channel中取出任务并处理:
func StartProcessor() {
for {
select {
case job := <-Queue:
job.payload.UploadToS3() // <-- STILL NOT GOOD
}
}
}
说实话,那些充满了红牛的深夜我也不知道我们在想些什么.这一方式并没有为我们换回什么,我们用缓冲队列来达到这样一个有缺陷的并发性只是简单的延后了最初的问题(指百万请求量到来时系统宕机).我们的同步处理器一次只能上传一个请求负载到S3系统去,并且因为请求进来的速度比单处理器处理上传到S3的能力大得多,我们的缓冲channel迅速地就达到了它的上限并且阻塞了请求处理器去排列更多的任务.
我们只是简单地避开了问题,但慢慢地,系统最终还是会宕掉.在我们发布这个带有缺陷的版本后,延迟率保持在一个恒定的速率不断增加.
更好的改进
我们决定利用一种常用的模式当我们使用Go channel的时候,以便去创建一个两层的channel系统,一个使任务队列起来,另一个控制并行地操作任务队列的worker数量.
这个想法就是将上传到S3的工作以某个可持续的速率并行化,其不会消弱机器处理能力也不会带来与S3的连接错误.所以我们选择了创建一个Job/Worker模式,实现对于那些熟悉Java,C#等语言的人来说的Worker 线程池的Golang方式就是通过channel.
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 请求处理器去创建内嵌有请求payload的Job结构体并传入到JobQueue channel中以便workers去取出.
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 channel中传入的任务.
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)
}
}
}
我们提供最大数量的workers来被实例化并被添加到worker池中.我们在项目中通过一个docker化的Go环境来使用Amazon Elasticbeanstalk方案,并且我们一直尝试遵循12-factor 方法学来配置我们的生产环境.我们从环境变量中读出这些值,那样我们就能控制任务队列中的workers的最大数量.所以我们就能迅速调整这些值而不用重新部署这些集群.
var (
MaxWorker = os.Getenv("MAX_WORKERS")
MaxQueue = os.Getenv("MAX_QUEUE")
)
直接结果
在我们发布这个版本后我们迅速看到我们的延迟率降低到微不足道的数字而我们处理请求的能力却大幅提升
几分钟后我们的弹性负载均衡器(Elastic Load Balancers)充分发挥其作用.我们看到我们的ElasticBeanstalk应用每分钟处理了接近一百万请求.我们通常在早上的几个小时中,交通峰值每分钟会有超过一百万.
我们部署新代码后所需的服务器数量从100台大幅下降到20台.
在我们正确的配置集群和自动伸缩设置项后,我们就能更多地降低服务器数量到只有4台EC2,C4_Large实例(这些都是AWS云的服务器实例). 如果CPU连续5分钟负载超过90%,弹性伸缩设置将生成一个新的实例.
总结
在我的课本里,简单总是获胜,我们本可以通过大量的队列,后台workers,复杂的调度来设计一套复杂的系统,但是相反我们决定利用ElasticBeanstalk 的自动伸缩能力和Golang提供给我们的开箱即用的简单高效的并发方式.
并不是每天都只有四台服务器的集群,当在处理这些每分钟将要写入到Amazon S3 存储容器中达到一百万次的POST请求时这可能还不如我的MacBook Pro的处理能力.
对于工作总有正确的工具提供给你,有时候当你的Ruby on Rails系统需要强大的web处理器时,跳出Ruby的生态系统想一想也许会有多的简单方案可供选择吧.