Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение7 мин
Количество просмотров10K

На 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);
}

Теги:
Хабы:
Всего голосов 17: ↑12 и ↓5+11
Комментарии68

Публикации

Истории

Работа

Go разработчик
134 вакансии
Rust разработчик
8 вакансий

Ближайшие события

AdIndex City Conference 2024
Дата26 июня
Время09:30
Место
Москва
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область