DEV Community

Cover image for Ultra fast backend with Rust
Ivan Yurov
Ivan Yurov

Posted on

Ultra fast backend with Rust

After dealing with Rust internals for a school project, I decided to give it a try in the application land, more specifically in web development. Framework benchmarks looked extremely promising and I had a small backend that could benefit from a little speedup. It was written in Elixir, which I believe is the best language for the web, but nothing can beat the native code, right? The backend I’m about to build will contain a web-server, graphql query processor, and an ORM for PostgreSQL. After a bit of research I chose actix-web, juniper and diesel respectively. Bear in mind, that this is a learn-with-me kind of article, that said it’s not gonna present any best practices, as I have no idea what practices are best yet :)

Getting started

In order to proceed, you will need to install rust toolchain, and then creating an empty Rust project is as easy as:

cargo new rust_backend

Now we need to include actix-rt and actix-web into Cargo.toml and here is our hello world example:

use actix_web::{web, App, HttpServer, Responder};

async fn hello() -> impl Responder {
    "Hello world!"
}

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new().route("/", web::get().to(hello))
    }).bind("127.0.0.1:8080")?.run().await
}

Couple of interesting things happening here. HttpServer constructor accepts a factory — a lambda function that returns a new instance of the App every time it's called, because Actix spawns a new App for every thread. The handler doesn't have any arguments in this case, but normally there will be URL-parameters, payload, etc. Now you can run it:

cargo run

And using Apache Benchmark make sure that it handles massive amount of requests in parallel (with effective rate of ~0.08 ms per request on my machine). A good surprise here is that Cowboy shows very similar levels of performance and parallelism.

GraphQL

Now it's time to start integrating GraphQL. Adding juniper to deps, and here's the simple schema:

use juniper::{GraphQLObject, FieldResult, RootNode};

#[derive(GraphQLObject)]
#[graphql(description = "An artist")]
struct Artist {
  id: String,
  name: String
}

pub struct QueryRoot;

#[juniper::object]
impl QueryRoot {
  fn artists() -> FieldResult<Vec<Artist>> {
    Ok(vec![Artist {
      id: "1".to_string(),
      name: "Ripe".to_string()
    }])
  }
}

pub struct MutationRoot;

#[juniper::object]
impl MutationRoot {
}

pub type Schema = RootNode<'static, QueryRoot, MutationRoot>;

pub fn create() -> Schema {
    Schema::new(QueryRoot {}, MutationRoot {})
}

What we did here is defined an Artist object and a single root query artists that always returns a singleton collection containing one artist Ripe. Before we continue, let's take a break and listen to them! :)

Everything is pretty straight forward so far, if you're already familiar with GraphQL. If not, check this beautiful manual out.

One interesting point is that Juniper defines fields as non_null by default. In our case that means [Artist!]! — non null collection of non null artists. Other GraphQL backends treat all fields and collection elements as optional by default and it's a known source of pain for frontenders using TypeScript. With all my love to monadic types, Maybe that Apollo generates for this case is absolutely useless. In other words, this is a very reasonable default that everyone should adopt.

Now we need to teach the web server to process GraphQL queries. The updated main.rs will look like this:

mod schema;

use actix_web::{App, HttpServer, HttpResponse};
use actix_web::web::{Data, Json, post, get, resource};
use juniper::http::{graphiql, GraphQLRequest};

async fn graphiql() -> HttpResponse {
  HttpResponse::Ok()
    .content_type("text/html; charset=utf-8")
    .body(graphiql::graphiql_source("/api"))
}

async fn api(
  scm: Data<schema::Schema>,
  req: Json<GraphQLRequest>
) -> HttpResponse {
  let res = req.execute(&scm, &());
  let json = serde_json::to_string(&res).unwrap();
  HttpResponse::Ok()
      .content_type("application/json")
      .body(json)
}

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
  let factory = || App::new()
    .data(schema::create())
    .service(resource("/api").route(post().to(api)))
    .service(resource("/graphiql").route(get().to(graphiql)));

  HttpServer::new(factory).bind("127.0.0.1:8080")?.run().await
}

Now we got rid of the dummy handler, and added two new ones related to graphql instead. One is graphiql, a playground for your graphql endpoint, it just renders as a single page app allowing to play with actual graphql backend — run some queries, introspect the schema etc. The second handler api is where the magic happens. We get two arguments there: a schema instance and a request payload json-decoded (automatically, thanks to actix). I want to keep it dead simple for now, so we ignore potential error conditions and blocking nature of graphql executor. So we are doing these simple steps:

  1. Execute graphql request
  2. Encode the result as json
  3. Send the response to client

Now it's a good time to run the app and visit /grphiql route where we can already ask the api for artists:

{
  artists {
    id
    name
  }
}

This should return that one artist we hardcoded earlier.

Hooking up the DB

So far, so good, but now we want to take the records dynamically from the database. From now on, changes are gonna pile up much faster, so bear with me.

First we define schema. Normally it's generated automatically out of migrations, but I want to use (part of) the existing database, so I just create it manually (schema.rs):

table! {
  artists (id) {
    id -> Integer,
    name -> Text,
  }
}

One caveat in case of my existing DB was that id field was defined as BigSerial, which would require defining it as a BigInt, while juniper maintainers dropped the default support of i64 type for some reasons.

Now we modify GraphQl object definitions and move it to their own module (models.rs):

#[derive(Queryable, juniper::GraphQLObject)]
#[graphql(description = "An artist")]
pub struct Artist {
    pub id: i32,
    pub name: String
}

This is very close to what we had before, we just extended it with Queryable derivation. Now we need to create a pool and inject it into the app. This is how updated main function will look like:

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
  let db_url = "postgresql://USER:PASS@localhost:5432/DB_NAME";
  let manager = ConnectionManager::<PgConnection>::new(db_url);
  let pool = Pool::builder().build(manager).expect("Failed to create pool.");

  let factory = move || App::new()
    .data(graphql::create_schema())
    .data(pool.clone())
    .service(resource("/api").route(post().to(api)))
    .service(resource("/graphiql").route(get().to(graphiql)));

  HttpServer::new(factory).bind("127.0.0.1:8080")?.run().await
} 

In order to consume this pool we will pass it into GraphQL executor as a Context. In reality we might want to have more sophisticated Context structure (for example to pass individual dataloaders there), but for now we just assume that the DbPool type implements juniper::Context. This is how it's done:

pub type DbPool = Pool<ConnectionManager<PgConnection>>;
#[juniper::object(Context = DbPool)]
impl QueryRoot { ... }
// And the same for MutationRoot

Checking out a connection from the pool is a blocking operation, just like a request to the DB itself. It is beneficial to offload this work off the main thread, which is done like this in the main graphql handler:

async fn api(
  scm: Data<graphql::Schema>,
  pool: Data<Pool<ConnectionManager<PgConnection>>>,
  req: Json<GraphQLRequest>
) -> Result<HttpResponse, Error> {
  let json = web::block(move || {
    let res = req.execute(&scm, &pool);
    serde_json::to_string(&res)
  }).await?;

  Ok(HttpResponse::Ok()
      .content_type("application/json")
      .body(json))
}

Finally the last step is to replace that hardcoded collection of artists with the actual database request:

fn artists(pool: &DbPool) -> FieldResult<Vec<Artist>> {
    let conn = pool.get().map_err(|_|
      FieldError::new("Could not open connection to the database", Value::null())
    )?;

    artists
      .limit(1000)
      .load::<Artist>(&conn)
      .map_err(|_|
        FieldError::new("Error loading artists", Value::null())
      )
  }

Assessing the performance

In order to assess the performance, we're gonna hit the endpoint using Apache Benchmark with the following (payload.txt):

{"query": "{artists{id name}}"}

and with the following settings:

ab -p payload.txt -T "application/json" -c 100 -n 500 http://127.0.0.1:8080/api

And this is what we get in the result:

Finished 200 requests


Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8080

Document Path:          /api
Document Length:        34245 bytes

Concurrency Level:      50
Time taken for tests:   0.174 seconds
Complete requests:      200
Failed requests:        0
Total transferred:      6871200 bytes
Total body sent:        34000
HTML transferred:       6849000 bytes
Requests per second:    1149.91 [#/sec] (mean)
Time per request:       43.482 [ms] (mean)
Time per request:       0.870 [ms] (mean, across all concurrent requests)
Transfer rate:          38580.30 [Kbytes/sec] received
                        190.90 kb/s sent
                        38771.21 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.5      0       2
Processing:     6   37  10.1     40      57
Waiting:        3   37  10.2     40      57
Total:          6   38   9.7     40      58

Percentage of the requests served within a certain time (ms)
  50%     40
  66%     41
  75%     43
  80%     43
  90%     46
  95%     49
  98%     52
  99%     53
 100%     58 (longest request)

This is pretty impressive! Unfortunately I don't have a similar small app written in Elixir, so I'm going to take my original app which does much more than this little rust exercise, and I will just comment out all extra queries in Absinthe. This app runs on Phoenix, I didn't bother stripping down any plugs and middleware that are not related to GraphQL endpoint. Results are a bit less impressive, but expected:

Finished 200 requests


Server Software:        Cowboy
Server Hostname:        127.0.0.1
Server Port:            4000

Document Path:          /api
Document Length:        36245 bytes

Concurrency Level:      50
Time taken for tests:   1.384 seconds
Complete requests:      200
Failed requests:        0
Total transferred:      7298800 bytes
Total body sent:        34000
HTML transferred:       7249000 bytes
Requests per second:    144.50 [#/sec] (mean)
Time per request:       346.013 [ms] (mean)
Time per request:       6.920 [ms] (mean, across all concurrent requests)
Transfer rate:          5149.90 [Kbytes/sec] received
                        23.99 kb/s sent
                        5173.89 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   0.6      0       2
Processing:    39  310 117.2    307     690
Waiting:       36  310 117.2    307     690
Total:         40  311 117.1    308     690
WARNING: The median and mean for the initial connection time are not within a normal deviation
        These results are probably not that reliable.

Percentage of the requests served within a certain time (ms)
  50%    308
  66%    367
  75%    410
  80%    422
  90%    464
  95%    499
  98%    532
  99%    545
 100%    690 (longest request)

There is a chance that I messed up the test conditions here, otherwise Rust appears to be significantly faster. I might dig into researching how the hello world app shows much smaller performance gap, this could be either Absinthe or Ecto dragging it back. However, in my opinion the development with Elixir is much more pleasant, that this could potentially compensate for the performance disparity.

Todos:

  • Use dataloader when processing relations to avoid N+1 queries
  • Use dotenv for a proper database config

Oldest comments (0)