Benchmark: conexão sempre aberta vs uma conexão por chamada

Como eu nunca havia visto um benchmark para mostrar as diferenças entre, abrir uma conexão no início do programa e utilizá-la como dependência e, abrir uma nova conexão a cada chamada, resolvi fazê-la.

Embora a maioria das pessoas que converso saberem a resposta correta, não sei se, assim como eu, elas têm a ideia de quão grande é a diferença entre essas abordagens.

Mas antes de ver o resultado, mostrarei como construí o benchmark.

Conexão & Dados

Bem simples, para abrir a conexão sqlite3, criei uma função chamada Open.

package connections  

import (  
    "database/sql"  

    _ "github.com/mattn/go-sqlite3"  
)  

// Open a connection with a sqlite3 database
func Open() (*sql.DB, error) {  
    return sql.Open("sqlite3", "test.db")  
}

Depois, defini uma struct bem simples e o SQL para criar uma tabela no banco de dados.

package connections  

// User definitiontype User struct {  
    ID       int    `json:"id"`  
    Username string `json:"username"`  
    Password string `json:"password"`  
}  

// CreateUserTable creates table on sqlite3 database
func CreateUserTable() error {  
    db, err := Open()  
    if err != nil {  
       return err  
    }  
    defer db.Close()  

    // sql to create user table  
    sql := `CREATE TABLE IF NOT EXISTS users(           
            id INTEGER PRIMARY KEY AUTOINCREMENT,           
            username VARCHAR NOT NULL,           
            password VARCHAR NOT NULL);`

    // execute sql  
    _, err = db.Exec(sql)  

    return err  
}

Por fim, criei um arquivo cmd/main.go para adicionar 3 registros no banco de dados.

package main  

import (  
    "log"  

    "github.com/aprendagolang/connections")  

func main() {  
    db, err := connections.Open()  
    if err != nil {  
       log.Fatal(err)  
    }    defer db.Close()  

    // create table
    err = connections.CreateUserTable()  
    if err != nil {  
       log.Fatal(err)  
    }  
    // insert 3 users  
    users := []connections.User{  
       {Username: "user1", Password: "pass1"},  
       {Username: "user2", Password: "pass2"},  
       {Username: "user3", Password: "pass3"},  
    }  
    // sql for insert user  
    sql := `INSERT INTO users (username, password) VALUES (?, ?)`  

    for _, user := range users {  
       _, err = db.Exec(sql, user.Username, user.Password)  
       if err != nil {  
          log.Fatal(err)  
       }
    }
}

Uma conexão por chamada

Após executar o código acima, implementei uma função bem simples para selecionar todos os registros da tabela users, abrindo uma nova conexão a cada chamada.

package connections  

// SelectAllUsers select all records from users table
func SelectAllUsers() ([]User, error) {  
    db, err := Open()  
    if err != nil {  
       return nil, err  
    }  
    defer db.Close()  

    // select all users  
    rows, err := db.Query("SELECT * FROM users")  

    var users []User  
    for rows.Next() {  
       var user User  
       err = rows.Scan(&user.ID, &user.Username, &user.Password)  
       if err != nil {  
          return nil, err  
       }  
       users = append(users, user)  
    }  
    return users, err  
}

Com a função implementada, criei a seguinte função para benchmark.

package connections  

import "testing"  

func BenchmarkSelectAllUsers(b *testing.B) {  
    for n := 0; n < b.N; n++ {  
       _, err := SelectAllUsers()  
       if err != nil {  
          b.Errorf("Error: %s", err)  
       }    
    }
}

Conexão sempre aberta

Nessa abordagem, criei uma struct de repository bem simples, somente com um atributo do tipo *sql.DB.
Depois, implementei uma função para iniciá-la e um método com basicamente o mesmo código da função SelectAllUsers, exceto pela parte do defer db.Close().

package connections  

import "database/sql"  

// UserRepository defines the structure of a user repository
type UserRepository struct {  
    DB *sql.DB  
}  

// NewUserRepository creates a new instance of UserRepository  
func NewUserRepository(db *sql.DB) *UserRepository {  
    return &UserRepository{DB: db}  
}  

// SelectAllUsers select all records from users table
func (r *UserRepository) SelectAllUsers() ([]User, error) {  
    // select all users  
    rows, err := r.DB.Query("SELECT * FROM users")  

    var users []User  
    for rows.Next() {  
       var user User  
       err = rows.Scan(&user.ID, &user.Username, &user.Password)  
       if err != nil {  
          return nil, err  
       }  
       users = append(users, user)  
    }  
    return users, err  
}

Por fim, implementei a função de benchmark para o método UserRepository.SelectAlUsers().

package connections  

import "testing"  

func BenchmarkUserRepository_SelectAllUsers(b *testing.B) {  
    db, err := Open()  
    if err != nil {  
       b.Errorf("Error: %s", err)  
    }  
    repo := NewUserRepository(db)  
    defer repo.DB.Close()  

    for n := 0; n < b.N; n++ {  
       _, err := repo.SelectAllUsers()  
       if err != nil {  
          b.Errorf("Error: %s", err)  
       }    
    }
}

Repare que ambas implementações são realmente muito parecidas, só mudando a questão da conexão com o banco de dados.

Benchmark

Na imagem, da esquerda para a direita, temos:

  • Nome da função executada
  • Quantidade de loopings executados
  • Nanosegundos por operação
  • Total de bytes alocados por operação
  • Total de alocações em memória por operação

Podemos observar na imagem que, abrir uma conexão por chamada conseguiu executar uma média de 4.101 chamadas, enquanto ter somente uma conexão aberta executou, espantosamente, uma média de 130.810 chamadas.

Além da quantidade de chamadas a mais, ter somente uma chamada aberta ainda “custa” quase a metade de alocações de memória e consome somente 1/3 da quantidade de bytes por operação.

Conclusão

Como dito no início do post, a maioria das pessoas que eu converso sabem qual é a melhor abordagem, mas acredito que elas, assim como eu, não sabiam dessa diferença de mais de 31x na quantidade de chamadas.

Sem dúvida, utilizar a abordagem de abrir a conexão no início do programa traz grandes benefícios em escalabilidade e consumo recursos.

Mas é claro que não podemos esquecer de fechar essas conexões em caso de crash da aplicação. Para entender um pouco melhor sobre, recomendo a leitura do post Como fazer graceful shutdown correto em um server HTTP

Se inscreva na nossa newsletter!

Até a próxima!


Se inscreva na nossa newsletter

* indicates required

Um comentário sobre “Benchmark: conexão sempre aberta vs uma conexão por chamada

Deixe uma resposta