Давным-давно я начал задумываться о производительности технологических стеков, которые я использовал для Backend-разработки. Я хотел сравнить Spring Boot, Ruby on Rails и Express / Node.js при выполнении простых операций REST с MongoDB.

Я написал простой REST API, который потреблял и создавал JSON с использованием этих фреймворков, и измерил количество запросов, которые они способны обработать.

Настраивать

Я использовал следующие версии, которые были доступны на тот момент:

  • MongoDB v4 работает на Ubuntu 16
  • Spring Boot 2 работает на JVM 11
  • Рельсы 5 + Ruby 2.6 + Mongoid
  • Node.js 11 + Экспресс + Мангуст

Я использовал конфигурацию по умолчанию для всех компонентов, потому что я хотел протестировать начальное состояние без какой-либо производственной настройки.

Исходные коды доступны на GitHub:
https://github.com/Lameaux/rest-benchmark

Я использовал wrk - инструмент тестирования HTTP из: https://github.com/wg/wrk

В их документации есть базовый пример, который я использовал:

wrk -t12 -c400 -d30s https://127.0.0.1:8080/index.html

Сценарий 0 - Манекен читает

Давайте измерим производительность фиктивной конечной точки GET, которая просто возвращает метку времени в виде JSON.

Ruby on Rails

class TimestampController < ApplicationController
  def timestamp
    render json: { timestamp: Time.now.to_i }
  end
end

Полученные результаты:

wrk -t12 -c400 -d30s https://127.0.0.1:3000/timestamp
Running 30s test @ https://127.0.0.1:3000/timestamp
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     9.64ms    3.09ms  34.62ms   78.32%
    Req/Sec   260.29    158.28   565.00     47.17%
  15557 requests in 30.06s, 4.42MB read
Requests/sec:    517.54
Transfer/sec:    150.61KB

Expess и Node.js

const express = require('express');
const app = express();
const port = 3001;

app.get('/timestamp',
    (_req, res) => res.json(
        {
            timestamp: new Date().getTime()
        }
    )
);

app.listen(port, () => console.log(`Listening on port ${port}`));

Полученные результаты:

wrk -t12 -c400 -d30s https://127.0.0.1:3001/timestamp
Running 30s test @ https://127.0.0.1:3001/timestamp
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    35.50ms    2.06ms  86.90ms   80.10%
    Req/Sec     0.93k   111.91     1.21k    83.39%
  333933 requests in 30.06s, 76.11MB read
Requests/sec:  11110.03
Transfer/sec:      2.53MB

Spring Boot

@RestController
public class TimestampRestController {

    @GetMapping("/timestamp")
    public TimestampResponse timestamp() {
        return new TimestampResponse(System.currentTimeMillis());
    }

    class TimestampResponse {
        private final long timestamp;

        TimestampResponse(long timestamp) {
            this.timestamp = timestamp;
        }

        public long getTimestamp() {
            return timestamp;
        }
    }
}

Полученные результаты:

wrk -t12 -c400 -d30s https://127.0.0.1:8080/timestamp
Running 30s test @ https://127.0.0.1:8080/timestamp
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    21.70ms   17.44ms 274.30ms   65.75%
    Req/Sec     1.63k   480.17     3.94k    71.97%
  582651 requests in 30.10s, 92.34MB read
Requests/sec:  19356.66
Transfer/sec:      3.07MB

Резюме

Перед каждым тестом я прогревал приложения, отправляя запросы на конечные точки в течение 5 минут.

Rails показал худший результат с ~ 500 запросов в секунду.
Express смог обработать 11 КБ запросов / сек.
Spring показал 19 КБ запросов / Сек.

Я действительно не понимал, почему Rails такой медленный. Может быть, причина в дефолтной конфигурации Puma?

Puma starting in single mode...
* Version 3.12.1 (ruby 2.6.1-p33), codename: Llamas in Pajamas
* Min threads: 5, max threads: 5
* Environment: production

По умолчанию Rails запускает Puma в одиночном режиме с 5 потоками.

# Puma can serve each request in a thread from an internal thread pool.
# The `threads` method setting takes two numbers: a minimum and maximum.
# Any libraries that use thread pools should be configured to match
# the maximum value specified for Puma. Default is set to 5 threads for minimum
# and maximum; this matches the default thread size of Active Record.
#
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count

Я попытался запустить больше обсуждений Puma, переопределив RAILS_MAX_THREADS:

RAILS_MAX_THREADS=20 rails s -e production
...
* Min threads: 20, max threads: 20
...

Но никакого эффекта это не дало.

Я дал Rails еще один шанс. На этот раз я начал больше рабочих.

Я обновил config / puma.rb, разрешив предварительную загрузку приложений и запустив 4 воркера Puma:

# Specifies the number of `workers` to boot in clustered mode.
# Workers are forked webserver processes. If using threads and workers together
# the concurrency of the application would be max `threads` * `workers`.
# Workers do not work on JRuby or Windows (both of which do not support
# processes).
#
workers ENV.fetch("WEB_CONCURRENCY") { 4 }

# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write
# process behavior so workers use less memory.
#
preload_app!

Вывод из rails s:

[7678] Puma starting in cluster mode...
[7678] * Version 3.12.1 (ruby 2.6.1-p33), codename: Llamas in Pajamas
[7678] * Min threads: 5, max threads: 5
[7678] * Environment: production
[7678] * Process workers: 4
[7678] * Preloading application

Окончательный результат для Rails…

wrk -t12 -c400 -d30s https://127.0.0.1:3000/timestamp

Running 30s test @ https://127.0.0.1:3000/timestamp
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     9.61ms   11.85ms 131.84ms   84.99%
    Req/Sec     1.04k     0.94k    2.31k    47.67%
  62364 requests in 30.08s, 17.72MB read
Requests/sec:   2072.93
Transfer/sec:    603.26KB

2 КБ запросов в секунду, что в 4 раза лучше, чем для одного экземпляра Puma.

Я продолжал использовать Rails в этой настройке, хотя это было несправедливо, потому что вначале я обещал использовать конфигурацию по умолчанию.

А пока предположим, что Rails чертовски медленный, и перейдем к следующим сценариям.

Сценарий 1 - Запись: заполнение MongoDB новыми документами

В этом сценарии мы собираемся заполнить MongoDB документами.

Ruby on Rails

# Gemfile
gem 'mongoid', '~> 6.1.0'
# models/document.rb
class Document
  include Mongoid::Document
  field :title, type: String
end
# routes.rb
resources :documents
# controllers/documents_controller.rb
class DocumentsController < ApplicationController
  def create
    document = Document.new(title: "Document #{SecureRandom.uuid}")
    document.save!

    render json: document.to_json
  end
end

Полученные результаты:

wrk -t12 -c400 -d30s -s post.lua https://localhost:3000/documents
Running 30s test @ https://localhost:3000/documents
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    22.81ms   12.88ms 124.29ms   73.70%
    Req/Sec   293.23    202.42   630.00     39.44%
  26320 requests in 30.09s, 9.36MB read
Requests/sec:    874.57
Transfer/sec:    318.57KB
> db.documents.count()
26343

Express / Node.js

const mongoose = require('mongoose');
const url = 'mongodb://localhost:27017/nodejs';

mongoose.connect(url, { useNewUrlParser: true });
const db = mongoose.connection;
db.on('error', console.error.bind(console, 'MongoDB connection error:'));

const Schema = mongoose.Schema;

const DocumentSchema = new Schema({ title: String });

const DocumentModel = mongoose.model('Document', DocumentSchema );

app.post('/documents', (req, res) => {

    const document = new DocumentModel({ 
        title: `Document ${new Date().getTime()}` 
    });
    document.save().catch(error => { console.log(error) });

    res.json(document);
});

Полученные результаты:

wrk -t12 -c400 -d30s -s post.lua https://localhost:3001/documents
Running 30s test @ https://localhost:3001/documents
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   299.59ms   32.46ms 638.44ms   89.11%
    Req/Sec   119.39     91.18   590.00     63.08%
  39406 requests in 30.07s, 10.48MB read
Requests/sec:   1310.60
Transfer/sec:    357.09KB
> db.documents.count()
39802

Spring Boot

// build.gradle
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
// Document.java
public class Document {
    @Id
    public String id;
    public String title;
    public Document() {}
    public Document(String title) {
        this.title = title;
    }
}
// DocumentRepository.java
public interface DocumentRepository extends MongoRepository<Document, String> {
}
// DocumentRestController.java
@PostMapping("/documents")
public Document create() {
    Document doc = new Document("Document " + UUID.randomUUID().toString());
    repository.save(doc);
    return doc;
}
// application.properties
spring.data.mongodb.database=springboot

Полученные результаты:

wrk -t12 -c400 -d30s -s post.lua https://localhost:8080/documents
Running 30s test @ https://localhost:8080/documents
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    61.34ms   27.44ms 332.76ms   73.69%
    Req/Sec   540.65     98.04     1.39k    76.57%
  193356 requests in 30.09s, 42.07MB read
Requests/sec:   6426.18
Transfer/sec:      1.40MB
> db.documents.count()
181712

Сценарий 2 - Чтение: получение документа из MongoDB

В этом сценарии мы собираемся прочитать документ из MongoDB.

Ruby on Rails

# controllers/documents_controller.rb
def index
  document = Document.first
  render json: document.to_json
end

Полученные результаты:

wrk -t12 -c400 -d30s https://localhost:3000/documents
Running 30s test @ https://localhost:3000/documents
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    22.24ms   12.34ms 113.50ms   73.97%
    Req/Sec   451.12    409.20     0.97k    50.17%
  26981 requests in 30.10s, 9.60MB read
Requests/sec:    896.37
Transfer/sec:    326.51KB

Express / Node.js

app.get('/documents', (req, res) => {
    DocumentModel
        .findOne({})
        .exec(function (err, document) {
            if (err) console.log(err);
            res.json(document);
        });
});

Полученные результаты:

wrk -t12 -c400 -d30s https://localhost:3001/documents
Running 30s test @ https://localhost:3001/documents
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   156.10ms   37.30ms 558.64ms   87.29%
    Req/Sec   218.31    100.88   333.00     52.83%
  76140 requests in 30.06s, 20.84MB read
Requests/sec:   2533.12
Transfer/sec:    709.97KB

Spring Boot

@GetMapping("/documents")
public Document index() {
    Pageable pageableRequest = PageRequest.of(0, 1);
    Page<Document> page = repository.findAll(pageableRequest);
    List<Document> document = page.getContent();
    return document.get(0);
}

Полученные результаты:

wrk -t12 -c400 -d30s https://localhost:8080/documents
Running 30s test @ https://localhost:8080/documents
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    68.51ms   35.07ms 441.45ms   72.05%
    Req/Sec   485.78     92.96     1.50k    74.96%
  173931 requests in 30.10s, 37.85MB read
Requests/sec:   5777.83
Transfer/sec:      1.26MB

Заключение

Вот результаты:

Итак, Java по-прежнему на высоте! Spring Boot в два раза быстрее других фреймворков. Я просто не понимаю, почему чтение медленнее, чем запись. Какого черта?

Вы можете использовать Express / Node.js для Backend-разработки, если Javascript - ваш любимый язык.

Ruby - хороший язык. Особенно классные RSpec. С Ruby on Rails разработка идет очень быстро. С другой стороны, кажется, что производительности Rails может быть недостаточно, если вы собираетесь создать высоконагруженный REST API.