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

On medium.com There are a number of articles comparing simple web services written in different languages. One of them Go vs Rust: Performance comparison for JWT verify and MySQL query and judging by it, Go is 42% faster than Rust. I decided to double-check and at the same time change Gin to Fiber, Axis to Axum and MySQL to PostgreSQL.

The web service will accept a request with authentication using a JWT token, search the database for a user with this email from JWT and return it in the form of json. Since such authentication is used everywhere, the test is relevant.

First, we prepare a test database. This will be PostgreSQL and we will deploy it in Docker via compose. In the folder where our database will be, create a file init.sql. In it we create a new database and a users table in it:

CREATE DATABASE testbench;

\connect testbench;

CREATE TABLE users(
    email VARCHAR(255) NOT NULL PRIMARY KEY,
    first VARCHAR(255),
    last VARCHAR(255),
    county VARCHAR(255),
    city VARCHAR(255),
    age int
);

Next, create the db folder and the docker-compose.yaml file with the following content:

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"

Create and launch the container:

$ docker-compose up

Next, we need to fill the database with 100,000 records. To do this we need a data generator, for which we use Synth. We create a file with the generator settings (note that email is our primary unique key):

{
    "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"
            }
        },
        "county": {
            "type": "string",
            "faker": {
                "generator": "country_name"
            }
        },
        "age": {
            "type": "number",
            "subtype": "i32",
            "range": {
                "low": 18,
                "high": 55,
                "step": 1
            }
        }
    }
}

Let’s start the generator:

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

The database is ready, we write the web services ourselves.

First in 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
	County 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.County, &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.County, &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"))
}
Now on 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 county: 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) => {
            eprintln!("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();
        }
    };
}

async fn alltoken() -> StatusCode {
    StatusCode::CREATED
}

#[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))
        .route("/alltoken", get(alltoken))
        .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

For Go, we build the executable file with the command: go build, for Rust: cargo build –release

Next is the testing itself.

PC: Windows 11 22H2, Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz, 32 GB.

Go 1.21.4

Rust 1.74.0

We will make 100K requests with 10, 50 and 100 simultaneous connections.

We use Cassowary. We get 10 “random” tokens by launching a web service in Go: http://127.0.0.1:3000/randomtoken
Select any one and run the tests (substituting your token):

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

We do 3 runs and take the maximum result.

Go results:

Go 10

Go 10

Go 50

Go 50

Go 100

Go 100

Rust results:

Rust 10

Rust 10

Rust 50

Rust 50

Rust 100

Rust 100

The difference between 10.50 and 100 simultaneous connections is small in Go, but not at all in Rust. This is due to the fact that the number of connections in the database pool is limited to 10. This was the case in the initial comparison and I did not change it (all the same, there are always many fewer connections in the pool than requests).

Bottom line

Go is 35% faster than Rust in this scenario.

In Rust, I tried changing sqlx to tokio-postgres, removing JWT decryption, the result was the same.

Link to Github for those who want to check on their servers.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *