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!
Parabéns!