2017年8月31日
我从事反垃圾邮件、反病毒和反恶意软件行业已经超过15年了,任职过几家不同的公司,现在我知道,由于我们每天处理海量的数据,这些系统最终会变得非常复杂。
目前我是 smsjunk.com 的 CEO 以及 KnowBe4 的首席架构,这两家公司都活跃在网络安全行业。
有趣的是,在过去10年左右的软件工程师生涯中,我参与的所有 web 后端开发几乎都是用 Ruby on Rails 。别误会我的意思,我喜欢 Ruby on Rails,我相信它是一个很棒的环境,过了一段时间你就会开始用 Ruby 的方式思考和设计系统,却忘记了没有想过如果利用多线程、并行化、快速执行和小内存开销,你的架构会有多么高效和简单。多年来,我是 C/C++、Delphi 和 C 开发人员,我开始意识到,用正确的工具来完成工作,事情不会那么复杂。
我对互联网语言和框架不是很热衷。我相信效率、生产力和代码可维护性很大程度上取决于构建解决方案的简单程度。
问题
在开发我们的匿名遥测和分析系统时,我们的目标是能够处理来自数百万个端点的大量 POST 请求。web 处理程序将接收一个 JSON 文档,该文档可能包含许多有效负载的集合(payloads),可能需要把这些集合(payloads)写入 Amazon S3,以便我们的 map-reduce 系统稍后对这些数据进行操作。
传统上,我们会考虑创建工作层体系结构,利用以下内容:
- Sidekiq
- Resque
- DelayedJob
- Elasticbeanstalk Worker Tier
- RabbitMQ
- and so on…
然后设置两个不同的集群,一个用于 web 前端,另一个用于 workers ,这样我们就可以根据后台工作量扩容。
从一开始,我们的团队就知道我们应该用 Go,因为在讨论阶段我们就猜测这可能是一个非常大流量的系统。我已经使用 Go 大约2年并开发了一些系统,但是没有一个能有这么大的负载(load)。
我们首先创建一些结构来定义通过 POST 调用接收的 web 请求有效负载(payload),并创建一个方法将其上传到 S3 bucket 中。
//gistfile1.go
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 并行处理请求:
//gistfile1.go
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。由于我们每分钟收到 100 万个 POST 请求,所以这段代码很快就崩溃了。
再次尝试
我们需要一个不同的解决方案。从一开始,我们就开始讨论如何使请求处理程序的生存期尽可能的简洁,并在后台进行处理工作。当然,这是你在 Ruby on Rails 世界里必须做的,否则你将阻塞所有可用的 worker web 处理器,像 puma、unicorn、passenger(暂时不讨论 JRuby 了)。那么我们就需要利用常见的解决方案来实现这一点,比如 Resque、Sidekiq、SQS 等等。实现这一点的方法有很多种。
所以第二次迭代是创建一个缓冲通道,这样我们就可以将一些工作任务放到队列里,并将它们上传到 S3,由于我们可以控制队列的长度,并且内存中有足够的内存,所以我们认为只需把工作任务缓存到通道队列中就 ok 了。
//second_attemp.go
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
}
...
}
然后,我们需要从队列里提取工作任务并进行处理,我们使用了类似的方法:
//medium-secondattempt-2.go
func StartProcessor() {
for {
select {
case job := <-Queue:
job.payload.UploadToS3() // <-- STILL NOT GOOD
}
}
}
老实说,我不知道当时我们在想什么。这一定是熬夜喝红牛的结果。这种方法没有给我们带来任何好处,我们用一个缓冲队列来交换有缺陷的并发性,这个队列只是推迟了问题的发生。我们的同步处理器一次只上载一个有效负载到 S3,由于接收请求的速率远远大于单个处理器上载到 S3 的能力,我们的缓冲通道很快就达到了极限,并阻止了请求处理程序对更多工作任务进行排队的能力。
我们只是在回避这个问题,并开始倒计时到我们的系统最终死亡。在我们部署了这个有缺陷的版本之后,系统的延迟率在几分钟内以恒定的速率不断增加。
更好的解决方案
我们决定使用 Go channels 的一个通用模式,以便创建一个两层通道系统,一个用于存放任务队列,另一个用于控制处理任务队列的并发量。
我们的想法是以一个可持续的速度将 S3 的上传并行化,这样既不会使机器瘫痪,也不会导致 S3 产生连接错误。因此,我们选择创建一个 Job/Worker 模式。对于那些熟悉 Java、C# 等的人来说,可以将此看作是利用 Golang 的通道实现了一个工作线程池。
//medium_better_solution.go
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
结构体的实例,并将其发送到 JobQueue
通道中,以供 workers 拾取。
//medium_better_solution_2.go
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()
来创建 workers 池,并开始侦听 JobQueue
是否有任务要处理。
dispatcher := NewDispatcher(MaxWorker)
dispatcher.Run()
下面是我们的 dispatcher
实现的代码:
//medium_dispatcher.go
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 并添加到 workers 池。由于我们在这个项目中使用了Amazon Elasticbeanstalk 和 dockinized Go 环境,并且我们严格按照 12-factor 方法配置我们的生产环境,因此我们从环境变量中读取这些值。这样我们就可以控制 workers 数量和任务队列的长度,这样我们就可以快速调整这些值,而不需要重新部署集群。
var (
MaxWorker = os.Getenv("MAX_WORKERS")
MaxQueue = os.Getenv("MAX_QUEUE")
)
在我们部署完之后,我们立即看到系统的延迟率下降到可以忽略的量级,同时我们处理请求的能力大幅上升。
在 Elastic Load Balancers 完全预热几分钟后,我们看到 Elasticbeanstalk app 每分钟处理近 100 万个请求服务。通常在上午的几个小时里,流量会每分钟超过 100 万。
当我们部署了新的代码,我们就将服务器的数量就从 100 台下降到 20 台左右。
通过合理配置集群和 auto-scaling 设置,我们可以只配置 4 台 EC2 c4.Large 实例,然后在 CPU 持续 5 分钟超过 90% 时用 Elastic Auto-Scaling 来生成新实例。
结论
对我来说简单是最重要的。我们本可以设计一个包含许多队列、后台 workers 和复杂部署的复杂系统,但我们最终决定利用 Elasticbeanstalk auto-scaling 的强大功能以及 Golang 语言提供的应对并发的简单方法。用仅仅 4 台机器(可能还不如我的 MacBook Pro)每分钟处理 100 万次 POST 请求对 Amazon S3 进行写操作。
每项任务都有对应的正确工具。有时候,当您的 Ruby on Rails 系统需要一个非常强大的 web 处理程序时,可以考虑一下 Ruby 生态系统之外的更简单、更强大的替代解决方案。
原文链接: medium.com/smsjunk/han…