Go (fiber) vs Rust (axum) JWT&DB

от автора

На medium.com есть ряд статей со сравнением простых web-сервисов, написанных на разных языках. Одна из них Go vs Rust: Performance comparison for JWT verify and MySQL query и судя по ней, Go на 42% быстрее чем Rust. Я решил перепроверить и заодно поменять Gin на Fiber, Axis на Axum и MySQL на PostgreSQL.

Web-сервис будет принимать запрос с аутентификацией по токену JWT, искать в БД пользователя с данным email из JWT и возвращать его в виде json. Так как подобная аутентификация используется повсеместно, то тест актуальный.

Сперва готовим тестовую БД. Это будет PostgreSQL и разворачивать ее будем в Docker через compose. В папке, в которой будет наша БД, создаем файл init.sql. В нем мы создаем новую базу и в ней таблицу users:

CREATE DATABASE testbench;  \connect testbench;  CREATE TABLE users(     email VARCHAR(255) NOT NULL PRIMARY KEY,     first VARCHAR(255),     last VARCHAR(255),     country VARCHAR(255),     city VARCHAR(255),     age int );

Далее там же создаем папку db и файл docker-compose.yaml следующего содержания:

services:   postgres:     image: postgres:alpine     environment:       - POSTGRES_PASSWORD=123456     volumes:       - ./db:/var/lib/postgresql/data       # скрипт ниже выполнится при первом создании базы       - ./init.sql:/docker-entrypoint-initdb.d/init.sql     ports:       - "5432:5432"

Создаем и запускаем контейнер:

$ docker-compose up

Далее нам нужно наполнить базу 100 000 записями. Для этого нужен генератор данных, в качестве которого используем Synth. Создаем файл с настройками генератора (обратите внимание, что email у нас первичный уникальный ключ):

{     "type": "array",     "length": {         "type": "number",         "constant": 1     },     "content": {         "type": "object",         "email": {             "type": "unique",             "content": {                 "type": "string",                 "faker": {                     "generator": "free_email"                 }             }         },         "first": {             "type": "string",             "faker": {                 "generator": "first_name"             }         },         "last": {             "type": "string",             "faker": {                 "generator": "last_name"             }         },         "city": {             "type": "string",             "faker": {                 "generator": "city_name"             }         },         "country": {             "type": "string",             "faker": {                 "generator": "country_name"             }         },         "age": {             "type": "number",             "subtype": "i32",             "range": {                 "low": 18,                 "high": 55,                 "step": 1             }         }     } }

Запускаем генератор:

$ synth generate ./ --to postgres://postgres:123456@localhost:5432/testbench --size 100000

БД готова, пишем сами веб-сервисы.

Сначала на Go:
package main  import ( "bufio" "database/sql" "log" "os" "strings" "time"  "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v5" _ "github.com/lib/pq" )  type MyCustomClaims struct { Email string `json:"email"` jwt.RegisteredClaims }  type User struct { Email  string First  string Last   string City   string Country string Age    int }  var jwtSecret = "mysuperPUPERsecret100500security"  func getToken(c *fiber.Ctx) string { hdr := c.Get("Authorization") if hdr == "" { return "" }  token := strings.Split(hdr, "Bearer ")[1] return token }  func main() { app := fiber.New() db, err := sql.Open("postgres", "user=postgres password=123456 dbname=testbench sslmode=disable")  if err != nil { return }  defer db.Close()  db.SetMaxOpenConns(10) db.SetMaxIdleConns(10)  app.Get("/", func(c *fiber.Ctx) error { tokenString := getToken(c) if tokenString == "" { return c.SendStatus(fiber.StatusUnauthorized) } token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(jwtSecret), nil })  if err != nil { log.Println(err) return c.SendStatus(fiber.StatusUnauthorized) }  claims := token.Claims.(*MyCustomClaims)  query := "SELECT * FROM users WHERE email=$1" row := db.QueryRow(query, claims.Email)  var user User = User{} err2 := row.Scan(&user.Email, &user.First, &user.Last, &user.Country, &user.City, &user.Age) if err2 == sql.ErrNoRows { return c.SendStatus(fiber.StatusNotFound) } if err2 != nil { log.Println(err2) return c.SendStatus(fiber.StatusInternalServerError) }  return c.JSON(user) })  //вспомогательная ручка app.Get("/randomtoken", func(c *fiber.Ctx) error { file, err := os.Create("tokens.txt") if err != nil { log.Println(err) return c.SendStatus(fiber.StatusInternalServerError) }  writer := bufio.NewWriter(file)  rows, err := db.Query("SELECT * FROM USERS OFFSET floor(random() * 100000) LIMIT 10") if err != nil { return c.SendStatus(fiber.StatusInternalServerError) }  for rows.Next() { var user User err = rows.Scan(&user.Email, &user.First, &user.Last, &user.Country, &user.City, &user.Age) if err != nil { log.Println(err) return c.SendStatus(fiber.StatusInternalServerError) }  claims := MyCustomClaims{ user.Email, jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), }, }  token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) ss, err := token.SignedString([]byte(jwtSecret))  if err != nil { log.Println(err) return c.SendStatus(fiber.StatusInternalServerError) }  _, err = writer.WriteString(ss + "\n") if err != nil { file.Close() log.Println(err) return c.SendStatus(fiber.StatusInternalServerError) }  }  writer.Flush() file.Close()  return c.SendFile(file.Name()) })  log.Fatal(app.Listen(":3000")) } 

Теперь на Rust
use axum::{     extract::State,     http::{header::AUTHORIZATION, HeaderMap, StatusCode},     response::IntoResponse,     routing::get,     Json, Router, }; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; use sqlx::{postgres::PgPoolOptions, Pool, Postgres};  #[derive(Debug, Serialize, Deserialize)] struct Claims {     email: String,     exp: usize, }  #[derive(Debug, Deserialize, Serialize, sqlx::FromRow)] pub struct User {     pub email: String,     pub first: Option<String>,     pub last: Option<String>,     pub city: Option<String>,     pub country: Option<String>,     pub age: Option<i32>, }  type ConnectionPool = Pool<Postgres>;  async fn root(headers: HeaderMap, State(pool): State<ConnectionPool>) -> impl IntoResponse {     let jwt_secret = "mysuperPUPERsecret100500security";     let validation = Validation::new(Algorithm::HS256);      let auth_header = headers.get(AUTHORIZATION).expect("no authorization header");     let mut auth_hdr: &str = auth_header.to_str().unwrap();     auth_hdr = &auth_hdr.strip_prefix("Bearer ").unwrap();      let token = match decode::<Claims>(         &auth_hdr,         &DecodingKey::from_secret(jwt_secret.as_ref()),         &validation,     ) {         Ok(c) => c,         Err(e) => {             println!("Application error: {e}");             return (StatusCode::INTERNAL_SERVER_ERROR, "invalid token").into_response();         }     };      let email = token.claims.email;     let query_result: Result<User, sqlx::Error> =         sqlx::query_as(r#"SELECT *  FROM USERS WHERE email=$1"#)             .bind(email)             .fetch_one(&pool)             .await;      match query_result {         Ok(user) => {             return (StatusCode::ACCEPTED, Json(user)).into_response();         }         Err(sqlx::Error::RowNotFound) => {             return (StatusCode::NOT_FOUND, "user not found").into_response();         }         Err(_e) => {             println!("error: {}", _e.to_string());             return (StatusCode::INTERNAL_SERVER_ERROR, "error").into_response();         }     }; }  #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {     let database_url = String::from("postgres://postgres:123456@localhost/testbench");     let pool = PgPoolOptions::new()         .max_connections(10)         .connect(&database_url)         .await         .expect("can't connect to database");      println!("DB connect success");      let app = Router::new()         .route("/", get(root))         .with_state(pool);      let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")         .await         .unwrap();     axum::serve(listener, app).await?;     Ok(()) }  // NOTE ---- // Rust code has been built in release mode for all performance tests 

Для Go собираем исполняемый файл командой: go build, для Rust: cargo build —release

Далее собственно тестирование:

ПК: Windows 11 22H2, Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz, 32 Гб.

Go 1.21.4

Rust 1.74.0

Делать будем 100К запросов при 10,50 и 100 одновременных подключениях.

Используем Cassowary. Получаем 10 «случайных» токенов запустив веб-сервис на Go: http://127.0.0.1:3000/randomtoken

Выбираем любой и запускаем тесты (подставив свой токен):

$ cassowary.exe run -u http://127.0.0.1:3000 -c 10 -n 100000 -H "Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFsZWphbmRyaW5fZG9sb3JlbXF1ZUBnbWFpbC5jb20iLCJleHAiOjE3MDEzMDYyMTl9.5mT3KVV9Q69yd5gx-z97LVr6tgNA1yVJxpeJEXSq6U0"

Делаем по 3 запуска и берем максимальный результат.

Результаты Go:

Go 10

Go 10
Go 50

Go 50
Go 100

Go 100

Результаты Rust:

Rust 10

Rust 10
Rust 50

Rust 50
Rust 100

Rust 100

Разница между 10,50 и 100 одновременными подключениями в Go небольшая, а в Rust вообще нет. Это связанно с тем, что количество соединений в пуле БД ограничено 10-тью. Так было в изначальном сравнении и я не стал это менять (все равно соединений в пуле всегда в разы меньше чем запросов).

Итог

Go быстрее Rust на 35% в данном сценарии.

В Rust я пробовал менять sqlx на tokio-postgres, убирал расшифровку JWT, результат тот же.

Ссылка на Github для желающих проверить на своих серверах.

UPD

Причина низкой производительности Rust оказалась в библиотеке sqlx. Замена sqlx на diesel сравняла Go и Rust. (в Go тоже поменял pq на pgx). А вот памяти Go использовал в 3 раза больше чем Rust.

Go 50

Go 50
Rust 50

Rust 50

Вывод

В обоих языках постоянно появляются новые, более производительные библиотеки. Например на данный момент для Rust предпочтительнее ntex, для Go можно взять gnet (правда это только замена пакету net, а не полноценный фреймворк). На обоих языках можно сделать отличную оптимизацию, писать unsafe код. На Go легче писать, ошибки компилятора понятнее. Rust более гибкий, но часто не понятные ошибки компиляции и многословное описания ограничений generics, зато быстрее и использует меньше памяти.

Бонус

Сделал еще сравнение CPU bounds задачи: вычисление соли для получения хэша md5 с четырьмя нулями в конце. Код для Go взял из статьи на Хабре (однопоток) и немного оптимизировал, сделал по аналогии в Rust. Rust оказался быстрее на 65% чем Go

Go
package main  import ( "crypto/md5" "fmt" "time" )  func getHashSync(in string) (string, int) { var salt int for { if h, ok := getHashIf(in, salt); ok { return h, salt } salt++ } }  func getHashIf(in string, salt int) (string, bool) { if x := md5.Sum([]byte(fmt.Sprintf("%d.%s", salt, in))); isHashAcceptable(x) { return fmt.Sprintf("%x", x), true } return "", false }  func isHashAcceptable(x [16]byte) bool { return x[15] == 0 && x[14] == 0 && x[13] == 0 && x[12] == 0 }  const phrase = "Это проврека"  func main() { fmt.Println("Start...") defer func(t time.Time) { fmt.Printf("done with %v\n", time.Since(t)) }(time.Now())  var hash, salt = getHashSync(phrase) fmt.Printf("%s salt=%d\n", hash, salt) } 

Rust
use md5; use std::time::Instant;  fn get_hash_sync(input: &str) -> (String, u32) {     let mut salt: u32 = 0;     loop {         if let Some(hash) = get_hash_if(input, salt) {             return (hash, salt);         }         salt += 1;     } }  fn get_hash_if(input: &str, salt: u32) -> Option<String> {     let digest: md5::Digest = md5::compute(format!("{}.{}", salt, input).as_bytes());      if is_hash_acceptable(digest) {         return Some(format!("{:x}", digest));     }     None }  fn is_hash_acceptable(bytes: md5::Digest) -> bool {     bytes[15] == 0 && bytes[14] == 0 && bytes[13] == 0 && bytes[12] == 0 }  const PHRASE: &str = "Это проврека";  fn main() {     println!("Start...");     let start_time = Instant::now();      let (hash, salt) = get_hash_sync(PHRASE);     println!("{} salt={}", hash, salt);      let elapsed: std::time::Duration = start_time.elapsed();     let minutes = elapsed.as_secs() / 60;     let seconds = elapsed.as_secs() % 60;     println!("done with {:02}m{:02}s", minutes, seconds);      let mut input = String::new();     let _ = std::io::stdin().read_line(&mut input); } 


ссылка на оригинал статьи https://habr.com/ru/articles/777072/