Afinação de desempenho com go-mssqldb

Este artigo fornece orientações sobre como otimizar o desempenho das aplicações Go que utilizam o go-mssqldb driver com o SQL Server.

Comece pelas mudanças de maior impacto

A maioria das aplicações não precisa de ajustes específicos do controlador no primeiro dia. Comece com estes passos antes de alterar o tamanho do pacote, adicionar declarações preparadas por todo o lado ou ajustar as opções em bloco:

  1. Defina um tamanho limitado do pool de ligação que corresponda aos limites do seu servidor.
  2. Adicione timeouts de contexto para que chamadas SQL bloqueadas não mantenham ligações ocupadas e façam a aplicação parecer congelada quando está sob carga.
  3. Corrija consultas lentas, índices em falta e idas e volta desnecessárias no SQL Server.
  4. Faz um benchmark da carga de trabalho antes e depois de cada alteração.

Para muitos serviços, as definições do agrupamento de ligações e a estrutura da consulta importam mais do que o tamanho do pacote ou a preparação de instruções.

Configuração do agrupamento de ligações

O database/sql agrupamento de ligações é o fator com maior impacto no desempenho. Pools insuficientemente dimensionados fazem com que as goroutines fiquem bloqueadas à espera de conexões, enquanto pools sobredimensionados desperdiçam recursos do servidor.

db.SetMaxOpenConns(25)     // Match your workload concurrency
db.SetMaxIdleConns(10)     // Keep warm connections ready
db.SetConnMaxLifetime(5 * time.Minute)  // Recycle connections periodically
db.SetConnMaxIdleTime(1 * time.Minute)  // Close stale idle connections

Monitorize db.Stats().WaitCount e db.Stats().WaitDuration para detetar contenção no conjunto. Para mais detalhes, veja Agrupamento de ligações.

Aumentar o tamanho do pacote

O tamanho padrão do pacote TDS é de 4.096 bytes. Para cargas de trabalho que transferem grandes conjuntos de resultados ou dados em massa, aumentar o tamanho do pacote reduz o número de viagens de ida e volta na rede.

sqlserver://<user>:<password>@<server>?database=AdventureWorks2025&packet+size=16384

Intervalo válido: 512 a 32.767. Valores de 8.192 ou 16.384 são comuns em cenários de alto rendimento.

Mantém o tamanho padrão do pacote, a menos que os dados de benchmark mostrem que a transferência de rede domina a carga de trabalho. Para tráfego ao estilo OLTP, com consultas e linhas pequenas, pacotes maiores frequentemente acrescentam complexidade sem um ganho significativo.

Utilizar declarações preparadas

Instruções preparadas evitam análises repetidas de consultas e compilação de planos no servidor. Use db.PrepareContext quando executa a mesma consulta muitas vezes com parâmetros diferentes:

stmt, err := db.PrepareContext(ctx,
    "SELECT TOP (1) FirstName + ' ' + LastName AS Name FROM Sales.vSalesPerson WHERE CountryRegionName = @p1")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

for _, loc := range locations {
    var name string
    stmt.QueryRowContext(ctx, loc).Scan(&name)
}

Tip

Feche as instruções preparadas usando defer stmt.Close() para evitar consumir os handles de instruções preparadas do lado do servidor.

Não prepares todas as declarações por defeito. Ajudam mais quando a mesma afirmação se repete repetidamente num caminho quente. Para perguntas pontuais, QueryContext ou ExecContext geralmente é mais direto e rápido o suficiente.

Use cópias em massa para inserções grandes

As instruções INSERT individuais são lentas para grandes volumes de dados. A cópia em massa transmite os dados diretamente para o servidor, contornando o processador de consultas:

stmt, err := txn.Prepare(mssql.CopyIn("MyTable", mssql.BulkOptions{
    Tablock:      true,
    RowsPerBatch: 5000,
}, "Col1", "Col2"))

Para mais detalhes, ver Operações em bloco.

Use varchar quando apropriado

Por predefinição, o código envia os parâmetros string como nvarchar (Unicode). Se as suas colunas usarem varchar, o servidor pode realizar conversão implícita e saltar índices. Use mssql.VarChar para enviar varchar parâmetros.

db.QueryContext(ctx, "SELECT * FROM Production.Product WHERE ProductNumber = @p1",
    mssql.VarChar("FR-R92B-58"))

Utilize tempos limite de contexto

Defina prazos contextuais para consultas individuais para evitar que chamadas SQL bloqueadas fixem as ligações e atrasem os chamadores.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT * FROM LargeTable")

Encerre os recursos rapidamente

Abre *sql.Rows, *sql.Tx, e *sql.Conn os objetos fixam uma ligação a partir do pool. Feche-os sempre o mais rápido possível.

rows, err := db.QueryContext(ctx, query)
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    // process
}
return rows.Err()

Reduzir viagens de ida e volta

  • Agrupe consultas numa única chamada sempre que possível: "SELECT ...; SELECT ...;".
  • Use cláusulas OUTPUT em vez de chamadas SELECT SCOPE_IDENTITY() separadas.
  • Use parâmetros com valores de tabela para enviar múltiplas linhas numa única chamada em vez de fazer looping sobre inserções individuais.

Performance checklist (Lista de verificação do desempenho)

Area Recommendation
Piscina Define MaxOpenConns com base nos limites do servidor e na concorrência da carga de trabalho.
Piscina Definir MaxIdleConns para pelo menos metade de MaxOpenConns.
Network Altere packet size apenas após realizar testes de desempenho de grandes transferências ou carregamentos em massa.
Queries Use declarações preparadas apenas para declarações que se repetem frequentemente.
Queries Utilize mssql.VarChar para colunas varchar para evitar conversões implícitas.
Cargas grandes Use cópia em massa (mssql.CopyIn) para inserções em lote.
Resources Feche os objetos Rows, Tx e Conn de imediato.
Timeouts Defina prazos de contexto para todas as consultas e declarações.
Leitura intensa Utilize ApplicationIntent=ReadOnly para réplicas de leitura.
Benchmarks Use testing.B para medir antes e depois da otimização.
Monitoring Ative o Query Store e utilize relatórios SSMS ou o Query Performance Insight.
Monitoring Exportar db.Stats() para Prometheus ou OpenTelemetry.

Operações de teste de desempenho da base de dados

Use Go's testing.B para medir o desempenho das operações da base de dados e validar alterações de otimização:

func BenchmarkInsertSingle(b *testing.B) {
    db := setupDB(b)
    ctx := context.Background()

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _, err := db.ExecContext(ctx,
            "INSERT INTO BenchTable (Name) VALUES (@p1)",
            sql.Named("p1", fmt.Sprintf("bench-%d", i)))
        if err != nil {
            b.Fatal(err)
        }
    }
}

func BenchmarkInsertBulk(b *testing.B) {
    db := setupDB(b)
    ctx := context.Background()

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        txn, err := db.BeginTx(ctx, nil)
        if err != nil {
            b.Fatal(err)
        }
        stmt, err := txn.Prepare(mssql.CopyIn("BenchTable",
            mssql.BulkOptions{}, "Name"))
        if err != nil {
            b.Fatal(err)
        }
        for j := 0; j < 1000; j++ {
            if _, err := stmt.Exec(fmt.Sprintf("bench-%d-%d", i, j)); err != nil {
                b.Fatal(err)
            }
        }
        if _, err := stmt.Exec(); err != nil {
            b.Fatal(err)
        }
        stmt.Close()
        if err := txn.Commit(); err != nil {
            b.Fatal(err)
        }
    }
}

Execute testes de desempenho com:

go test -bench=BenchmarkInsert -benchmem -count=5

Tip

Use -count=5 ou mais para obter resultados estatisticamente significativos. Usa o benchstat para comparar resultados de benchmarks antes e depois de uma alteração.

Usar encaminhamento apenas de leitura

Se o seu ambiente SQL Server tiver um grupo de disponibilidade com secundários legíveis, encaminhe as consultas só de leitura para a réplica secundária, definindo ApplicationIntent=ReadOnly na cadeia de ligação:

sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025&ApplicationIntent=ReadOnly

Crie instâncias separadas *sql.DB para cargas de leitura e escrita:

writeDB, err := sql.Open("sqlserver",
    "sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025")
if err != nil {
    log.Fatal(err)
}

readDB, err := sql.Open("sqlserver",
    "sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025&ApplicationIntent=ReadOnly")
if err != nil {
    log.Fatal(err)
}

// Use readDB for reports, dashboards, and analytics.
// Use writeDB for inserts, updates, and deletes.

Análise do plano de consulta

Use SET SHOWPLAN_XML para recuperar o plano de execução de uma consulta sem o executar. Este método ajuda-o a identificar digitalizações de tabelas, índices em falta e operações dispendiosas:

func getQueryPlan(ctx context.Context, db *sql.DB, query string) (string, error) {
    // Use a dedicated connection so SHOWPLAN mode doesn't affect other queries.
    conn, err := db.Conn(ctx)
    if err != nil {
        return "", err
    }
    defer conn.Close()

    // Enable SHOWPLAN_XML mode.
    _, err = conn.ExecContext(ctx, "SET SHOWPLAN_XML ON")
    if err != nil {
        return "", err
    }

    var planXML string
    err = conn.QueryRowContext(ctx, query).Scan(&planXML)
    if err != nil {
        return "", err
    }

    // Disable SHOWPLAN_XML mode.
    _, _ = conn.ExecContext(ctx, "SET SHOWPLAN_XML OFF")

    return planXML, nil
}

Warning

SET SHOWPLAN_XML ON afeta toda a conexão. Use sempre db.Conn(ctx) para isolar o modo SHOWPLAN numa ligação dedicada.

Utilize o Query Store para encontrar consultas lentas

Os benchmarks do Go e o timing do lado do cliente dizem-te quanto tempo demora uma consulta do ponto de vista da tua aplicação, mas esse número combina latência de rede, tempo de execução do servidor e processamento do cliente. A Query Store captura planos de execução e estatísticas de tempo de execução no servidor, para que possa ver exatamente como o SQL Server executou cada consulta, com que frequência foi executada e como o seu desempenho mudou ao longo do tempo.

O Query Store é especialmente útil para identificar deteção de parâmetros, regressões de plano e consultas que consomem mais recursos do servidor. Ative-o na tua base de dados se ainda não estiver ativado:

ALTER DATABASE [AdventureWorks2025] SET QUERY_STORE = ON;

Uma vez ativado, pode rever os dados de desempenho de várias formas:

  • SQL Server Management Studio (SSMS): Expanda a sua base de dados no Object Explorer, abra a pasta Query Store e utilize os relatórios incorporados como Consultas que Consomem Recursos Maiores, Consultas Regressadas e Consumo Geral de Recursos.
  • Portal Azure: Para Base de Dados SQL do Azure, abra a lâmina Query Performance Insight para ver as consultas que mais consomem recursos sem instalar qualquer ferramenta.
  • Transact-SQL (T-SQL): Consulte diretamente as vistas de catálogo sys.query_store_runtime_stats e sys.query_store_plan da sua aplicação Go, se precisar de acesso programático.

Tip

A Query Store mantém dados ao longo dos reinícios dos servidores, para que possa analisar tendências de desempenho ao longo de dias ou semanas. Use o relatório de Consultas Regressadas para identificar rapidamente consultas que ficaram mais lentas após uma alteração de esquema ou código.

Utilizar o Painel de Desempenho

O Performance Dashboard é um relatório SSMS integrado que oferece uma visão geral em tempo real do estado do SQL Server. Clique com o botão direito do rato na instância do servidor no SSMS Object Explorer e selecione Relatórios>Relatórios padrão>Painel de desempenho.

O dashboard mostra:

  • Esperas e gargalos atuais.
  • Consultas recentes e caras.
  • Tendências de uso de CPU, I/O e memória.
  • Pedidos de utilizadores ativos e sessões bloqueadas.

O Performance Dashboard é útil durante o desenvolvimento e testes de carga para detetar rapidamente problemas sem escrever quaisquer consultas de diagnóstico.

Monitorizar métricas no servidor em Go

Se precisar de expor dados de desempenho do SQL Server a um sistema de monitorização como o Prometheus ou OpenTelemetry a partir da sua aplicação Go, consulte diretamente as vistas de gestão dinâmica (DMVs):

Consultas mais caras

Recupere as 10 principais consultas ordenadas pelo tempo médio decorrido:

rows, err := db.QueryContext(ctx, `
    SELECT TOP 10
        qs.total_elapsed_time / qs.execution_count AS avg_elapsed_us,
        qs.execution_count,
        qs.total_logical_reads / qs.execution_count AS avg_reads,
        SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
            ((CASE qs.statement_end_offset
                WHEN -1 THEN DATALENGTH(st.text)
                ELSE qs.statement_end_offset
            END - qs.statement_start_offset)/2)+1) AS query_text
    FROM sys.dm_exec_query_stats AS qs
    CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
    ORDER BY avg_elapsed_us DESC`)

Pedidos ativos atuais

Liste todos os pedidos atualmente em execução no servidor, excluindo a sua própria sessão:

rows, err := db.QueryContext(ctx, `
    SELECT
        r.session_id,
        r.status,
        r.wait_type,
        r.cpu_time,
        r.logical_reads,
        t.text AS query_text
    FROM sys.dm_exec_requests AS r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
    WHERE r.session_id != @@SPID`)

Padrões eficientes em memória para grandes resultados

Linhas de fluxo sem acumulação

Processe as linhas uma de cada vez em vez de carregar todo o conjunto de resultados numa fatia:

rows, err := db.QueryContext(ctx, "SELECT Id, Data FROM BigTable")
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    var id int
    var data string
    if err := rows.Scan(&id, &data); err != nil {
        return err
    }
    // Process immediately, don't append to a slice.
    process(id, data)
}
return rows.Err()

Processamento em lote com paginação por conjunto de chaves

Divida grandes varreduras de tabelas em blocos geríveis para limitar o uso de memória e evitar manter uma ligação durante longos períodos:

func processBatched(ctx context.Context, db *sql.DB, batchSize int) error {
    var lastID int
    for {
        rows, err := db.QueryContext(ctx, `
            SELECT TOP(@batch) Id, Data FROM BigTable
            WHERE Id > @lastID ORDER BY Id`,
            sql.Named("batch", batchSize),
            sql.Named("lastID", lastID))
        if err != nil {
            return err
        }

        var count int
        for rows.Next() {
            var id int
            var data string
            if err := rows.Scan(&id, &data); err != nil {
                return err
            }
            process(id, data)
            lastID = id
            count++
        }
        if err := rows.Err(); err != nil {
            return err
        }
        rows.Close()

        if count < batchSize {
            break // No more rows.
        }
    }
    return nil
}