Benchmark: ORM vs SQL puro

Finalmente tive tempo para sanar, com dados, uma das minhas e, imagino que de várias outras pessoas, maiores dúvidas quando se trata de Go e banco de dados. Qual a diferença, ao nível de consumo de recurso e performance, entre utilizar GORM vs escrever SQL na unha.

Para ficar mais fácil a leitura, separei o post em tópicos. Iniciarei explicando como fiz o setup, as funções comuns e realizei a execução dos benchmarks. Depois, separo o código do benchmark, assim como o resultado, em ações de CRUD.

Setup

Primeiramente, criei os packages entities, orm e std. Dentro do package entities, criei uma struct para ser utilizada em todos os benchmarks.

package entities

type Category struct {
    ID          int64  `gorm:"column:id;primaryKey"`
    Name        string `gorm:"column:name"`
    Description string `gorm:"column:description"`
}

Depois, em cada um dos packages de banco, implementei uma função chamada setup, com a finalidade de, iniciar uma nova conexão com o banco de dados e criar a tabela categories.

No caso do package orm, a função ficou da seguinte forma.

package orm

import (
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"

    _ "github.com/lib/pq"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func setup() *gorm.DB {
    db, err := gorm.Open(postgres.New(postgres.Config{
        DriverName: "postgres",
        DSN:        "host=localhost port=5432 user=postgres password=1234 dbname=benchmark sslmode=disable",
    }), &gorm.Config{})
    if err != nil {
        panic(err)
    }

    err = db.AutoMigrate(&entities.Category{})
    if err != nil {
        panic(err)
    }

    return db
}

Já no package std, ficou assim:

package std

import (
    "database/sql"

    _ "github.com/lib/pq"
)

func setup() *sql.DB {
    db, err := sql.Open("postgres", "host=localhost port=5432 user=postgres password=1234 dbname=benchmark sslmode=disable")
    if err != nil {
        panic(err)
    }

    _, err = db.Exec("CREATE TABLE IF NOT EXISTS categories (id SERIAL, name TEXT, description TEXT, PRIMARY KEY (id))")
    if err != nil {
        panic(err)
    }

    return db
}

Embora possa parecer estranho criar a tabela toda as vezes que a função setup for chamada, fiz isso pois, a execução dos benchmarks foram realizadas individualmente e limpas. Em outra palavras, entre uma execução e outra, sempre fiz um drop da tabela.

Mas pode ficar tranquilo, utilizei a função b.ResetTimer()para que a criação da tabela não impactasse no resultado dos benchmarks.

Funções Comuns

Tirando o benchmark de insert, para todos os outros é preciso que existam dados na tabela.

Como uma nova tabela sempre é criada, precisei implementar uma função para alimentar a tabela categories antes da execução do benchmark.

Afim de re-utilizar o máximo de código possível, além de isolar totalmente os packages, implementei uma função de insert em lote em cada um dos package.

package orm

import (
    "fmt"
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
    "gorm.io/gorm"
)

func InsertBenchORM(db *gorm.DB, size int) []entities.Category {
    categories := make([]entities.Category, size)
    for i := 0; i < size; i++ {
        category := entities.Category{
            Name:        fmt.Sprintf("Category %d", i),
            Description: fmt.Sprintf("Description %d", i),
        }

        cat, err := InsertORM(db, category)
        if err != nil {
            panic(err)
        }

        categories[i] = cat
    }

    return categories
}
package std

import (
    "database/sql"
    "fmt"
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
)

func InsertBenchStd(db *sql.DB, size int) []entities.Category {
    categories := make([]entities.Category, size)
    for i := 0; i < size; i++ {
        category := entities.Category{
            Name:        fmt.Sprintf("Category %d", i),
            Description: fmt.Sprintf("Description %d", i),
        }

        cat, err := InsertStd(db, category)
        if err != nil {
            panic(err)
        }

        categories[i] = cat
    }

    return categories
}

Reparem que, exceto pelo tipo do parâmetro db e o nome da função de insert, o restante das funções são praticamente idênticas.

Execução

Como dito anteriormente, antes da execução de cada um dos benchmarks, a tabela categories era removida, para que todos os benchmarks fossem executados em uma tabela recém criada.

Outro ponto importante, é que todos os benchmarks foram executados individualmente, ou seja, passei o nome completo do benchmark a ser realizado na flag -bench.

Por fim, todas as execuções foram realizadas com as flags -cpu 8 -benchmem -benchtime 5s -count 5.

CREATE

Antes de ver os resultados, vamos analisar a diferença na implementação das funções de insert.

package orm

import (
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
    "gorm.io/gorm"
)

func InsertORM(db *gorm.DB, data entities.Category) (entities.Category, error) {
    err := db.Create(&data).Error

    return data, err
}

Quando comparamos com a função abaixo, logo vemos que precisamos escrever muito mais código para obter o mesmo resultado, ou seja, ter um novo registro no banco de dados.

No entanto, será que compensa escrever esse monte de código a mais?

package std

import (
    "database/sql"
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
)

func InsertStd(db *sql.DB, data entities.Category) (entities.Category, error) {
	err := db.QueryRow("INSERT INTO categories (name, description) VALUES ($1, $2) RETURNING id", data.Name, data.Description).Scan(&data.ID)
	if err != nil {
		return data, err
	}

	return data, nil
}

Como a função de benchmark tem a mesma estrutura para ambos os casos, abaixo mostro uma função genérica a marcação FUNCAO_DE_INSERT no lugar da chamada da função de insert.

import (
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
    "testing"
)

// BnechmarkInsertORM para ORM
// BenchmarkInsertStd para SQL
func BenchmarkInsert(b *testing.B) {
    db := setup()

    category := entities.Category{
       Name:        "Category 1",
       Description: "Description 1",
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
       // InsertORM para ORM
       // InsertStd para SQL
       _, err := FUNCAO_DE_INSERT(db, category)

       b.StopTimer()
       if err != nil {
          b.Error(err)
       }
       b.StartTimer()
    }
}

Após executar os benchmarks, obtive os seguintes resultados.

Embora a quantidade de operações executadas pela função sem ORM tenha sido somente 15% maior – o que pode ser uma limitação da engine do banco -, a quantidade de bytes por operação foi 85% menor, além de precisar realizar 61 alocações a menos.

READ

No caso da leitura, implementei um benchmark para obter 1 registro (get) e outro para buscar todos os registros da tabela (list).

get

package orm

import (
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
    "gorm.io/gorm"
)

func GetORM(db *gorm.DB, id int64) (*entities.Category, error) {
    var category entities.Category
    err := db.First(&category, id).Error
    if err != nil {
       return nil, err
    }

    return &category, nil
}

Assim como no caso do insert, é necessário escrever um pouco mais de código para que a função sem ORM consiga alcançar o mesmo resultado.

package std

import (
    "database/sql"
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
)

func GetStd(db *sql.DB, id int64) (*entities.Category, error) {
    var category entities.Category
    err := db.QueryRow("SELECT id, name, description FROM categories WHERE id = $1", id).
       Scan(&category.ID, &category.Name, &category.Description)

    if err != nil {
       return nil, err
    }

    return &category, nil
}

Para o benchmark, escrevi a seguinte função:

import "testing"

// BenchmarkGetORM para ORM
// BenchmarkGetStd para SQL
func BenchmarkGet(b *testing.B) {
    db := setup()

    // InsertBenchORM para ORM
    // InsertBenchStd para SQL
    categories := INSERT_EM_LOTE(db, b.N)

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
		// GetORM para ORM
		// GetStd para SQL
       _, err := FUNCAO_DE_GET(db, categories[i].ID)

       b.StopTimer()
       if err != nil {
          b.Fatal(err)
       }       b.StartTimer()
    }
}

Os seguintes resultados foram obtidos com a execução do benchmark.

Muito semelhante ao que vimos no caso do insert, a função sem ORM conseguiu realizar cerca de 15% mais operações – que realmente me faz acreditar ser uma limitação da engine -, consumindo 76% menos bytes e realizando 39 alocações a menos por operação.

list

package orm

import (
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
    "gorm.io/gorm"
)

func ListORM(db *gorm.DB) ([]entities.Category, error) {
    var categories []entities.Category
    err := db.Find(&categories).Error

    return categories, err
}

Sem sombra de dúvidas, escrever uma função para retornar uma lista de registro do banco de dados, é onde está a maior diferença na quantidade de código necessário para alcançar o mesmo resultado.

Me pergunto se isso irá se refletir no resultado do benchmark…

package std

import (
    "database/sql"
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
)

func ListStd(db *sql.DB) ([]entities.Category, error) {
    var categories []entities.Category
    rows, err := db.Query("SELECT id, name, description FROM categories")
    if err != nil {
       return nil, err
    }
    defer rows.Close()

    for rows.Next() {
       var category entities.Category
       err := rows.Scan(&category.ID, &category.Name, &category.Description)
       if err != nil {
          return nil, err
       }
       categories = append(categories, category)
    }

    err = rows.Err()
    if err != nil {
       return nil, err
    }

    return categories, nil
}

Para o benchmark, escrevi uma função bem simples.

import "testing"

// BenchmarkListORM para ORM
// BenchmarkListStd para SQL
func BenchmarkListStd(b *testing.B) {
    db := setup()

    // InsertBenchORM para ORM
    // InsertBenchStd para SQL
    _ = INSERT_EM_LOTE(db, b.N)

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
	    // ListORM para ORM
		// ListStd para SQL
       _, err := FUNCAO_DE_LIST(db)

       b.StopTimer()
       if err != nil {
          b.Fatal(err)
       }
       b.StartTimer()
    }
}

Com o benchmark executado, os seguintes resultados foram obtidos.

Embora eu não saiba explicar o motivo da diferença entre a quantidade operações no primeiro teste vs os outros quatro (vou perguntar ao time do Go), a execução sem ORM consumiu cerca de 16% menos bytes e realizou uma média de 39.808 alocações de memória a menos por operação.

UPDATE

Continuando a linha dos outros tópicos, vamos começar comparando as funções de update com e sem ORM.

package orm

import (
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
    "gorm.io/gorm"
)

func UpdateORM(db *gorm.DB, data entities.Category) error {
    return db.Save(&data).Error
}

Embora a diferença em número de linhas não seja tão grande, a quantidade de código ainda é um pouco maior.

package std

import (
    "database/sql"
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
)

func UpdateStd(db *sql.DB, data entities.Category) error {
    _, err := db.Exec("UPDATE categories SET name = $1, description = $2 WHERE id = $3", data.Name, data.Description, data.ID)

    return err
}

Para o benchmark, além do reset do timer logo após a inserção em lote, parei o timer logo na sequência. Isso por que o início do looping contém parte do setup também.

A fim de buscar maior precisão no benchmark, controlei o timer ao redor da FUNCAO_DE_UPDATE.

import (
    "fmt"
    "testing"
)

// BenchmarkUpdateORM para ORM
// BenchmarkUpdateStd para SQL
func BenchmarkUpdate(b *testing.B) {
    db := setup()

    // InsertBenchORM para ORM
    // InsertBenchStd para SQL
    categories := INSERT_EM_LOTE(db, b.N)

    b.ResetTimer()
	b.StopTimer()
	for i := 0; i < b.N; i++ {
	    category := categories[i]
	    category.Name = fmt.Sprintf("Update Category %d", i)

	    b.StartTimer()
		// UpdateORM para ORM
		// UpdateStd para SQL
	    _ = FUNCAO_DE_UPDATE(db, category)
	    b.StopTimer()
	}
}

Após execução, o seguinte resultado foi obtido.

Seguindo a linha dos outros benchmarks realizados até aqui, a função sem ORM conseguiu executar um pouco mais operações. Já no quesito consumo de recursos, a função sem ORM consumiu 93% menos bytes com 75 alocações de memória a menos por operação.

DELETE

Por fim, mas não menos importante, vamos comparar as funções para exclusão de registros.

package orm

import (
    "github.com/aprendagolang/benchmark-orm-vs-std/entities"
    "gorm.io/gorm"
)

func DeleteORM(db *gorm.DB, id int64) error {
    return db.Delete(&entities.Category{ID: id}).Error
}

De todas as funções que escrevemos até agora, essa é que menos dá diferença na quantidade de código.

package std

import "database/sql"

func DeleteStd(db *sql.DB, id int64) error {
    _, err := db.Exec("DELETE FROM categories WHERE ID = $1", id)
    return err
}

Para o benchmark, fiz a seguinte função.

import "testing"

// BenchmarkDeleteORM para ORM
// BenchmarkDeleteStd para SQL
func BenchmarkDeleteStd(b *testing.B) {
    db := setup()

    // InsertBenchORM para ORM
    // InsertBenchStd para SQL
    categories := INSERT_EM_LOTE(db, b.N)

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
		// DeleteORM para ORM
		// DeleteStd para SQL
       err := FUNCAO_DE_DELETE(db, categories[i].ID)

       b.StopTimer()
       if err != nil {
          b.Error(err)
       }       b.StartTimer()
    }
}

A execução nos deu o seguinte resultado.

Nesse último benchmark, a função sem ORM executou cerca de 12% mais operações, consumindo 94% menos bytes e 57 alocações de memória a menos por operação.

Conclusão

Embora a quantidade de operações realizadas a mais pelas funções sem ORM não tenha sido muito expressiva – talvez uma questão da engine estar rodando na minha máquina -, o consumo de recursos dessas funções foi extremamente menor.

Sei que algumas pessoas defendem o uso de ORM pelo ganho de produtividade. No entanto, utilizando o Copilot como fiz para escrever essas funções sem ORM, a diferença foi praticamente nenhuma. Por outro lado, o consumo de recursos foi gigante.

Em um próximo post, farei o benchmark utilizando consultas mais complexas, com JOINS e sub queries.

Se você gostou desse post, assine nossa newsletter para não perder nenhum post.

Ah! E se você quer aprender um pouco mais sobre testes e benchmark, confira nossa curso!

Até a próxima!


Se inscreva na nossa newsletter

* indicates required

Um comentário sobre “Benchmark: ORM vs SQL puro

Deixe uma resposta