現在是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伺服器都将使用此數量以保持相同的條件。
資料庫初始化:
```sql
CREATE TABLE goods(
id BIGSERIAL NOT NULL PRIMARY KEY ,
name VARCHAR(255) NOT 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"
```
基準測試
最後,在我們對環境和實作有了一定了解後,我們準備開始進行基準測試。
結果比較:
Name| Requests Per Second| Requests Total| Memory Usage
- | - | - | -
Node Js| 3233.377739| 97772| 105MB
Spring JVM| 4457.39441| 134162| 675MB
Spring Native Image| 3854.41882| 116267| 211MB
Rust Rocket| 5592.44295| 168573| 48MB
Rust Actix| 5312.356065| 160310| 33.5MB
Go Echo| 13545.859602| 407254| 72.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來參與你的下一個項目,你可能能在基礎設施上節省一些錢。
如果你喜歡我的文章,點贊,關注,轉發!