Benchmark: Unicorn vs Puma vs Node.js vs Go

Publicado por Geison Biazus e Pedro Vitti no dia dev

Recentemente, precisávamos desenvolver uma nova feature, onde processaríamos muitas requisições por segundo. Calculamos que com os 2000 clientes que temos hoje, receberíamos cerca de 50 requisições por segundo, porém com a projeção que temos de atingir 5 mil clientes até o final do ano, precisávamos de um servidor que processase todas essas requisições e já estivesse preparado para escalar em breve. Fizemos então um benchmark com alguns servidores web, escritos em diversas linguagens.

Aqui no time de produto da Resultados Digitais, tomamos nossas decisões baseadas em dados ao invés de intuições ou achismos. É o que chamamos de Data-driven. Exploração de dados, análises gráficas, benchmarks, e outras ferramentas de estudo são imprescindíveis antes de fazer uma escolha ou considerar uma solução. Essa é uma característica bastante evidente, não só no time de produto, mas em toda a Resultados Digitais e é um dos itens relacionados no nosso Culture Code.

Temos uma preocupação recorrente com o desempenho da nossa aplicação, seja durante o desenvolvimento de uma nova funcionalidade ou fazendo a manutenção das existentes. A escalabilidade é sempre importante. Precisamos garantir que o nosso produto suporte a quantidade de acessos que temos hoje e continue suportando mesmo que tenhamos 10 vezes mais acessos.

Os testes foram feitos com Ruby (Unicorn e Puma), Node.js e Go e utilizamos a biblioteca de código aberto wrk. Vamos aos resultados.

Ruby

Começamos implementando um servidor em Ruby utilizando Sinatra, com um processamento simples, que recebe uma requisição e retorna uma mensagem de sucesso.

main.rb
1
2
3
4
5
require 'sinatra'

get '/' do
  "Ruby Server: Success"
end
config.ru
1
2
3
4
require 'bundler/setup'
require File.join(File.expand_path(File.dirname(__FILE__)), 'main')

run Sinatra::Application

Unicorn

No primeiro teste usamos o Unicorn, que é o servidor que utilizamos no RD Station atualmente. Configuramos o server para utilizar quatro workers, ou seja, quatro processos, que conseguem responder até quatro requisições simultâneas.

unicorn.rb
1
2
3
worker_processes 4
timeout 10
preload_app true

Executamos o benchmark com o wrk por 60 segundos usando 12 threads e mantendo 1000 conexões HTTP abertas. O resultado foi o seguinte:

1
2
3
4
5
6
7
8
9
10
11
12
$ unicorn -c unicorn.rb
$ wrk -t12 -c1000 -d60s http://127.0.0.1:8080

Running 1m test @ http://127.0.0.1:8080
  12 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   108.06ms  179.85ms   1.98s    95.33%
    Req/Sec   137.87    186.49     1.49k    89.74%
  31852 requests in 1.00m, 8.05MB read
  Socket errors: connect 614, read 753, write 0, timeout 508
Requests/sec:    530.02
Transfer/sec:    137.16KB

O Unicorn processou 31.852 requisições nesse tempo mantendo uma média de 530 requisições por segundo. Porém ocorreram erros em cerca de 1800 requisições.

Puma

O segundo benchmark foi usando o server Puma. Ele trabalha com workers da mesma forma que o Unicorn, porém, além de workers, ele utiliza threads para processar as requisições. Em nossos testes executamos 4 workers com 16 threads, assim o Puma consegue responder 64 requisições simultâneas. O resultado foi o seguinte:

1
2
3
4
5
6
7
8
9
10
11
$ puma -t 16 -w 4
$ wrk -t12 -c1000 -d60s http://127.0.0.1:9292

Running 1m test @ http://127.0.0.1:9292
  12 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    11.69ms   11.73ms 205.80ms   82.18%
    Req/Sec     1.08k     0.90k    3.35k    71.50%
  323094 requests in 1.00m, 59.47MB read
Requests/sec:   5376.79
Transfer/sec:      0.99MB

O Puma se saiu muito melhor que o Unicorn. Durante 60 segundos, ele processou 323.094 requisições, que deu uma média de 5.376 requisições por segundo. Além disso nenhum erro de conexão ocorreu durante o teste.

Node.js

Single-core

Utilizamos o seguinte código para realizar o benchmark com Node.js:

app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var http = require("http");

var app = function(request,response) {
    response.writeHeader(200);

    response.write("Node Server: You requested " + request.url);

    response.end();
};

http.createServer(app).listen(8081, function() {
  console.log('Server listening on port 8081');
});

module.exports = app

O Node.js não implementa threads, porém processa as requisições de forma assíncrona. O resultado do benchmark foi o seguinte:

1
2
3
4
5
6
7
8
9
10
11
12
$ nodejs app.js
$ wrk -t12 -c1000 -d60s http://127.0.0.1:8081

Running 1m test @ http://127.0.0.1:8081
  12 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   175.10ms   41.06ms   1.16s    93.88%
    Req/Sec   470.57    277.98     2.30k    63.12%
  336415 requests in 1.00m, 47.20MB read
  Socket errors: connect 0, read 0, write 0, timeout 32
Requests/sec:   5603.51
Transfer/sec:    805.14KB

O resultado foi semelhante ao Ruby utilizando Puma. Durante 60 segundos, o servidor processou 336.415 requisições, com uma média de 5.603 requisições por segundo. Com essa implementação, não estamos utilizando ao máximo o poder do Node.js, pois ele processa todas as requisições utilizando apenas um núcleo do processador.

Multicore

Nesse benchmark, criamos o seguinte arquivo para garantir que o Node.js utilizasse todos os núcleos disponíveis.

cluster.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var cluster = require('cluster');
var cpus = require('os').cpus();

if (cluster.isMaster) {
  cpus.forEach(function() {
    cluster.fork();
  });

  cluster.on('listening', function(worker) {
    console.log('Cluster %d conected', worker.process.pid);
  });

  cluster.on('disconnect', function(worker) {
    console.log('Cluster %d disconnected', worker.process.pid);
  });

  cluster.on('exit', function(worker) {
    console.log('Cluster %d falled off', worker.process.pid);
  });
} else {
  require('./app');
}

Executando o benchmark com a nova implementação tivemos os seguintes resultados:

1
2
3
4
5
6
7
8
9
10
11
$ node clusters.js
$ wrk -t12 -c1000 -d60s http://127.0.0.1:8081

Running 1m test @ http://127.0.0.1:8081
  12 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    43.55ms   13.39ms 549.80ms   95.15%
    Req/Sec     1.93k   229.59     2.35k    81.01%
  1382946 requests in 1.00m, 194.01MB read
Requests/sec:  23010.65
Transfer/sec:      3.23MB

Dessa vez, o servidor processou 1.382.946 requisições durante os 60 segundos, dando uma média de 23.010 requisições por segundo.

Go

Fizemos o último benchmark em Go. O servidor foi implementado da seguinte forma:

server.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "GO Server: You requested is %s", r.URL.Path)
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}

O resultado dos testes foi o seguinte:

1
2
3
4
5
6
7
8
9
10
11
$ go run server.go
$ wrk -t12 -c1000 -d60s http://127.0.0.1:8080

Running 1m test @ http://127.0.0.1:8080
  12 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    21.32ms   18.35ms 825.18ms   98.47%
    Req/Sec     4.02k     0.94k   15.62k    84.65%
  2846596 requests in 1.00m, 396.35MB read
Requests/sec:  47392.59
Transfer/sec:      6.60MB

O servidor escrito em Go conseguiu processar 2.846.596 requisições em 60 segundos, com uma média de 47.392 requisições por segundo. O resultado foi bem satisfatório, pois em um segundo processou mais requisições que o Unicorn em um minuto.

Resultados

O gráfico abaixo mostra a comparação entre os servidores em relação à quantidade de requisições por segundo que atendem.

Comparação requests/sec

A escolha do servidor (ou qualquer outra ferramenta) deve ser feita levando em consideração as necessidades específicas de cada caso, e não baseada somente em um fator, como o número de requisições processadas por segundo, como nesse caso. O Unicorn, mesmo atendendo um número baixo de conexões, em comparação com os outros, conseguiu suportar uma aplicação web com muitos acessos sem problemas. Ele também é uma ótima opção caso a aplicação trabalhe com dependências externas que não lidem muito bem com threads. O Puma, por sua vez, é capaz de processar uma quantidade bem maior de requisições apenas por utilizar threads.

Existem alguns tipos específicos de aplicação que necessitam responder um número muito alto de requisições. Para esses tipos os servidores em Node.js ou Go são uma boa opção.

Decidimos por utilizar o Puma para o desenvolvimento da nossa nova feature. Nossa aplicação principal, o RDStation é desenvolvido em Ruby, e dessa forma teríamos mais familiaridade com a linguagem e poderíamos aproveitar algumas dependências. Nesse caso, o ônus de “aprender” uma nova linguagem, na qual a equipe possui menos conhecimento, prática e experiência, para desenvolver e dar manutenção, superou a vantagem do número de requisições suportadas nos outros servidores que avaliamos.

Caso haja a necessidade de atender um número muito maior de requisições em algum momento no futuro, podemos reaproveitar esses dados em um novo benchmark, e quem sabe partirmos para um servidor mais “rápido” como Node.js ou Go.

O código fonte dos servidores implementados e utilizados no benchmark se encontram no GitHub.

Geison Biazus

Geison Biazus

Full Stack Developer

Pedro Vitti

Pedro Vitti

Full Stack Developer

Comentários