现在是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(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来参与你的下一个项目,你可能能在基础设施上节省一些钱。
如果你喜欢我的文章,点赞,关注,转发!