分享

2023年Web服务器基准测试:NodeJS vs Java vs Rust vs Go

 技术的游戏 2023-05-29 发布于广东

现在是2023年,是时候进行一次新的Web服务器基准测试了!

结果对我来说有些出乎意料!

一个Web服务器必须能够处理大量请求,尽管瓶颈在于IO。这次我决定比较最流行的、速度极快的现代框架的性能。

以下是有关实现细节的许多详细信息。如果您只想了解结果,请直接前往文章底部以节省时间。如果您对测试的执行方式感兴趣,请继续阅读 :)

我们的瓶颈将是一个带有一些数据的Postgres数据库。因此,我们的Web服务器必须能够在不阻塞的情况下尽可能多地处理每秒请求数。在接收到数据后,它应该将答案序列化为JSON并返回有效的HTTP响应。

将测试哪些技术

  • · Spring WebFlux + Kotlin

    • · 传统的JVM

    • · GraalVM原生映像

  • · NodeJS + Express

  • · Rust

    • · Rocket

    • · Actix Web

我的配置

CPU:Intel Core i7–9700K 3.60 GHz(8个核心,无超线程)

RAM:32 GB

操作系统:Windows 11(版本22h2)

Docker:Docker for Desktop(Windows版)版本4.16.3,启用了WSL2支持-由Microsoft提供的默认资源配置

Postgres:使用以下Docker命令启动

docker run -d --name my-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=goods -p 5432:5432 postgres:15.2

数据库连接池大小:最多50个连接。每个Web服务器都将使用此数量以保持相同的条件。

数据库初始化:

CREATE TABLE goods(
    id BIGSERIAL NOT NULL PRIMARY KEY ,
    name VARCHAR(255NOT NULL,
    description TEXT NULL,
    price INT NOT NULL
);

INSERT INTO goods (name, description, price)
VALUES ('Apple''Red fruit'100),
       ('Orange''Orange fruit'150),
       ('Banana''Yellow fruit'200),
       ('Pineapple''Yellow fruit'250),
       ('Melon''Green fruit'300);

我决定不在数据库中存储太多的数据,以避免对数据库性能产生影响。我假设Postgres能够缓存所有的数据,并且大部分时间都将用于网络IO。

基准测试工具集

工具:k6(v0.42.0)

脚本:

import http from 'k6/http';

export default function () {
    http.get('http://localhost:8080/goods');
}

每次运行测试的命令都是相同的:

k6 run --vus 1000 --duration 30s .\load_testing.js

由于我们将有一个简单的端点,它将以 JSON 格式从 DB 返回数据列表,因此我刚刚添加了一个获取测试。 每个框架的所有测试都使用相同的脚本和命令运行。

NodeJS + Express Web 服务器实现

NodeJS version:

node --version
v18.14.0

package.json:

{
  "name": "node-api-postgres",
  "version": "1.0.0",
  "description": "RESTful API with Node.js, Express, and PostgreSQL",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.9.0"
  }
}

index.js:

const express = require('express')
const app = express()
const port = 8080

const { Pool } = require('pg')
const pool = new Pool({
    host: 'localhost',
    port: 5432,
    user: 'postgres',
    password: 'postgres',
    database: 'goods',
    max: 50,
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000,
})

const getGoods = (request, response) => {
    pool.query('SELECT * FROM goods', (error, results) => {
        if (error) {
            throw error
        }
        response.status(200).json(results.rows)
    })
}

app.get('/goods', getGoods)

pool.connect((err, client, done) => {
    console.log(err)

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

Spring WebFlux + R2DBC + Kotlin 实现

Java version:

java --version
openjdk 17.0.5 2022-10-18
OpenJDK Runtime Environment GraalVM CE 22.3.0 (build 17.0.5+8-jvmci-22.3-b08)
OpenJDK 64-Bit Server VM GraalVM CE 22.3.0 (build 17.0.5+8-jvmci-22.3-b08, mixed mode, sharing)

gradle file:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
 id("org.springframework.boot") version "3.0.2"
 id("io.spring.dependency-management") version "1.1.0"
 id("org.graalvm.buildtools.native") version "0.9.18"
 kotlin("jvm") version "1.7.22"
 kotlin("plugin.spring") version "1.7.22"
}

group = "me.alekseinovikov.goods"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17

repositories {
 mavenCentral()
}

dependencies {
 implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
 implementation("org.springframework.boot:spring-boot-starter-webflux")
 implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
 implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
 implementation("org.jetbrains.kotlin:kotlin-reflect")
 implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
 runtimeOnly("org.postgresql:postgresql")
 runtimeOnly("org.postgresql:r2dbc-postgresql")
 testImplementation("org.springframework.boot:spring-boot-starter-test")
 testImplementation("io.projectreactor:reactor-test")
}

tasks.withType<KotlinCompile> {
 kotlinOptions {
  freeCompilerArgs = listOf("-Xjsr305=strict")
  jvmTarget = "17"
 }
}

tasks.withType<Test> {
 useJUnitPlatform()
}

application.properties:

spring.r2dbc.url=r2dbc:postgresql://postgres:postgres@localhost:5432/goods
spring.r2dbc.pool.enabled=true
spring.r2dbc.pool.max-size=50
spring.r2dbc.pool.max-idle-time=30s
spring.r2dbc.pool.max-create-connection-time=30s

Application code:

@SpringBootApplication
class GoodsApplication

fun main(args: Array<String>) {
 runApplication<GoodsApplication>(*args)
}

@Table("goods")
class Good(
    @field:Id
    val id: Int,

    @field:Column("name")
    val name: String,

    @field:Column("description")
    val description: String,

    @field:Column("price")
    val price: Int
) {
}

interface GoodsRepository: R2dbcRepository<Good, Int> {
}

@RestController
class GoodsController(private val goodsRepository: GoodsRepository) {

    @GetMapping("/goods")
    suspend fun getGoods(): Flow<Good> = goodsRepository.findAll().asFlow()

}

为 fat jar 构建:

gradlew clean build

为 GraalVM 本机映像构建:

gradlew clean nativeCompile

Rust + Rocket 实现

cargo.toml:

[package]
name = "rust-goods"
version = "0.1.0"
edition = "2021"

[dependencies]
rocket = { version = "0.5.0-rc.2", features = ["secrets", "tls", "json"] }
serde_json = "1.0"
refinery = { version = "0.8", features = ["tokio-postgres"]}

[dependencies.rocket_db_pools]
version = "0.1.0-rc.2"
features = ["sqlx_postgres"]

Rocket.toml:

[default]
secret_key = "6XrKhVEP3gFMqmfhUzDdSYDthOLU442TjSCnz7sPEYE="
port = 8080

[default.databases.goods]
url = "postgres://postgres:postgres@localhost/goods"
max_connections = 50

main.rs:

#[macro_use]
extern crate rocket;

use rocket::serde::Serialize;
use rocket::serde::json::Json;
use rocket::State;
use rocket_db_pools::{Connection, Database};
use rocket_db_pools::sqlx::{self};
use rocket_db_pools::sqlx::{Error, Postgres, Row};
use rocket_db_pools::sqlx::postgres::PgRow;
use sqlx::FromRow;

#[derive(Serialize, Debug, PartialOrd, PartialEq, Clone)]
#[serde(crate = "rocket::serde")]
pub struct Good {
    pub id: usize,
    pub name: String,
    pub description: String,
    pub price: usize,
}

struct Repository;

impl Repository {
    pub(crate) fn new() -> Repository {
        Repository
    }

    pub(crate) async fn list(&self, mut db: Connection<Goods>) -> Vec<Good> {
        sqlx::query_as::<Postgres, Good>("SELECT id, name, description, price FROM goods")
            .fetch_all(&mut *db)
            .await
            .unwrap()
    }
}

impl<'r> FromRow<'r, PgRow> for Good {
    fn from_row(row: &'r PgRow) -> Result<Self, Error> {
        let id: i64 = row.try_get("id")?;
        let name = row.try_get("name")?;
        let description = row.try_get("description")?;
        let price: i32 = row.try_get("price")?;

        Ok(Good { id: id as usize, name, description, price: price as usize })
    }
}

#[get("/goods")]
async fn list(repository: &State<Repository>,
              db: Connection<Goods>) -> Json<Vec<Good>> {
    Json(repository
        .list(db)
        .await)
}

#[derive(Database)]
#[database("goods")]
struct Goods(sqlx::PgPool);

#[launch]
async fn rocket() -> _ {
    let rocket = rocket::build();

    rocket.attach(Goods::init())
        .manage(Repository::new())
        .mount("/", routes![
            list,
        ])
}

编译:

cargo build --release

Rust + Actix Web 实现

Cargo.toml:

[package]
name = "rust-actix-goods"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4"
derive_more = "0.99.17"
config = "0.13.3"
log = "0.4"
env_logger = "0.10.0"
deadpool-postgres = { version = "0.10.5", features = ["serde"] }
dotenv = "0.15.0"
serde = { version = "1.0.152", features = ["derive"] }
tokio-pg-mapper = "0.2.0"
tokio-pg-mapper-derive = "0.2.0"
tokio-postgres = "0.7.7"

.env:

RUST_LOG=error
SERVER_ADDR=0.0.0.0:8080
PG.USER=postgres
PG.PASSWORD=postgres
PG.HOST=localhost
PG.PORT=5432
PG.DBNAME=goods
PG.POOL.MAX_SIZE=50
PG.SSL_MODE=Disable

main.rs:

mod config {
    use serde::Deserialize;
    #[derive(Debug, Default, Deserialize)]
    pub struct ExampleConfig {
        pub server_addr: String,
        pub pg: deadpool_postgres::Config,
    }
}

mod models {
    use serde::{Deserialize, Serialize};
    use tokio_pg_mapper_derive::PostgresMapper;

    #[derive(Deserialize, PostgresMapper, Serialize)]
    #[pg_mapper(table = "goods")]
    pub struct Good {
        pub id: i64,
        pub name: String,
        pub description: String,
        pub price: i32,
    }
}

mod db {
    use deadpool_postgres::Client;
    use tokio_pg_mapper::FromTokioPostgresRow;

    use crate::models::Good;

    pub async fn select_goods(client: &Client) -> Vec<Good> {
        let _stmt = "SELECT id, name, description, price FROM goods";
        let stmt = client.prepare(&_stmt).await.unwrap();

        client
            .query(
                &stmt,
                &[],
            )
            .await
            .unwrap()
            .iter()
            .map(|row| Good::from_row_ref(row).unwrap())
            .collect::<Vec<Good>>()
    }
}

mod handlers {
    use actix_web::{web, Error, HttpResponse};
    use deadpool_postgres::{Client, Pool};

    use crate::db;

    pub async fn get_goods(
        db_pool: web::Data<Pool>,
    ) -> Result<HttpResponse, Error> {
        let client: Client = db_pool.get().await.unwrap();
        let goods = db::select_goods(&client).await;
        Ok(HttpResponse::Ok().json(goods))
    }
}

use ::config::Config;
use actix_web::{web, App, HttpServer, middleware::Logger};
use dotenv::dotenv;
use handlers::get_goods;
use tokio_postgres::NoTls;

use crate::config::ExampleConfig;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    env_logger::init();

    let config_ = Config::builder()
        .add_source(::config::Environment::default())
        .build()
        .unwrap();

    let config: ExampleConfig = config_.try_deserialize().unwrap();

    let pool = config.pg.create_pool(None, NoTls).unwrap();

    let server = HttpServer::new(move || {
        App::new()
            .wrap(Logger::default())
            .app_data(web::Data::new(pool.clone()))
            .service(web::resource("/goods").route(web::get().to(get_goods)))
    })
        .bind(config.server_addr.clone())?
        .run();
    println!("Server running at http://{}/", config.server_addr);

    server.await
}

编译:

cargo build --release

Go + Echo 实现

go.mod:

module goods-go

go 1.20

require (
 github.com/labstack/echo/v4 v4.10.0
 github.com/lib/pq v1.10.7
)

require (
 github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
 github.com/labstack/gommon v0.4.0 // indirect
 github.com/mattn/go-colorable v0.1.13 // indirect
 github.com/mattn/go-isatty v0.0.16 // indirect
 github.com/valyala/bytebufferpool v1.0.0 // indirect
 github.com/valyala/fasttemplate v1.2.2 // indirect
 golang.org/x/crypto v0.2.0 // indirect
 golang.org/x/net v0.4.0 // indirect
 golang.org/x/sys v0.3.0 // indirect
 golang.org/x/text v0.5.0 // indirect
 golang.org/x/time v0.2.0 // indirect
)

main.go:

package main

import (
 "database/sql"
 "fmt"
 "github.com/labstack/echo/v4"
 _ "github.com/lib/pq"
 "log"
 "net/http"
)

const (
 host     = "localhost"
 port     = 5432
 user     = "postgres"
 password = "postgres"
 dbname   = "goods"
)

var db *sql.DB

type Good struct {
 ID          int    `json:"id"`
 Name        string `json:"name"`
 Description string `json:"description"`
 Price       int    `json:"price"`
}

func getAllGoods(c echo.Context) error {
 rows, err := db.Query("SELECT id, name, description, price FROM goods")
 if err != nil {
  return c.JSON(http.StatusInternalServerError, err)
 }
 defer rows.Close()

 goods := make([]Good, 0)
 for rows.Next() {
  var good Good
  if err := rows.Scan(&good.ID, &good.Name, &good.Description, &good.Price); err != nil {
   log.Fatal(err)
  }

  goods = append(goods, good)
 }

 return c.JSON(http.StatusOK, goods)
}

func main() {
 psqlInfo := fmt.Sprintf("host=%s port=%d user=%s "+
  "password=%s dbname=%s sslmode=disable",
  host, port, user, password, dbname)
 var err error
 db, err = sql.Open("postgres", psqlInfo)
 if err != nil {
  log.Fatal(err)
 }
 db.SetMaxOpenConns(50)

 e := echo.New()
 // Routes
 e.GET("/goods", getAllGoods)

 // Start server
 e.Logger.Fatal(e.Start(":8080"))
}

编译:

go build -ldflags "-s -w"

基准测试

最后,在我们对环境和实现有了一定了解后,我们准备开始进行基准测试。

结果比较:

NameRequests Per SecondRequests TotalMemory Usage
Node Js3233.37773997772105MB
Spring JVM4457.39441134162675MB
Spring Native Image3854.41882116267211MB
Rust Rocket5592.4429516857348MB
Rust Actix5312.35606516031033.5MB
Go Echo13545.85960240725472.1MB

哎呀!当我想到这个基准测试的想法时,我认为Rust会是胜利者。第二名将由JVM和Go获得。但事实的发展有点出乎意料。

如果我在代码实现上犯了任何错误,请写下评论告诉我。我尽力遵循官方文档中的示例。从我的角度来看,我的所有代码都是异步和非阻塞的。我检查了几次。但我是人,如果有更好的方法可以提高特定技术的性能,请告诉我。

Go是最快的。似乎Echo库是其中一个原因。

Rust的速度可疑地慢。我尝试了几次,检查了2个框架,但未能使其更快。

传统JVM相当快(至少比NodeJS快),但仍然消耗大量内存。

GraalVM Native Image在减少内存消耗但保留了JVM的成熟工具集方面很有价值。

NodeJS是最慢的,也许是因为它的单线程事件循环。这里没有什么新鲜的。

结论

我不是说这个特定的用例展示了技术或工具的整体性能。我知道不同的工具有不同的用途。但是,所有这些语言和运行时都用于Web服务器开发,并在云服务器中运行。因此,我决定进行这个基准测试,以了解使用不同技术堆栈开发简单微服务时的速度和资源容忍程度。

对我来说,结果有些令人震惊,因为我预计Rust会获胜。但Go向我展示了这门语言和Echo框架在编写具有大量IO的简单微服务方面非常出色。

遗憾的是,JVM似乎无法达到相同的性能/资源消耗,从而在开发云Web服务方面变得不那么吸引人。但GraalVM Native Image给了它第二次机会。它的速度不及Go或Rust,但减少了对内存的需求。

因此,如果你能雇佣很多Gopher来参与你的下一个项目,你可能能在基础设施上节省一些钱。

如果你喜欢我的文章,点赞,关注,转发!

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多