DEV Community

Cover image for Como testar a performance de queries em banco de dados usando K6.
Marlo Henrique
Marlo Henrique

Posted on

Como testar a performance de queries em banco de dados usando K6.

Garantir que uma API responda de forma rápida para os percentis p(99) e p(95) envolve inúmeros fatores, desde a distribuição de conteúdo em múltiplas zonas para reduzir latência até a arquitetura da aplicação consumida. O consumo de APIs terceiras ou de outros componentes da arquitetura, como bancos de dados, pode representar gargalos mascarados no tempo de resposta da sua aplicação.

Neste artigo, veremos como testar a performance de queries no banco de dados utilizando o plugin xk6-sql do K6.

Pré-requisitos📑

Conhecendo o modulo xk6-sql🧠

O xk6-sql é uma extensão oficial do K6 que permite executar consultas SQL diretamente em testes de performance. Com ele, é possível conectar-se a diferentes bancos de dados relacionais, como MySQL, PostgreSQL, SQL Server, SQLite entre outros, realizando operações como criação de tabelas, inserção de dados e consultas durante os ciclos de teste de performance.

Para utilizá o xk6-sql, é necessário importar o módulo k6/x/sql junto com o driver correspondente ao banco de dados escolhido, por exemplo: xk6-sql-driver-postgres. Após a importação do módulo, você pode abrir uma conexão com o banco de dados, executar comandos SQL na fase de configuração (setup), manipular dados das entidades na fase de execução e fechar a conexão na fase de desmontagem (teardown).

Aplicação utilizada🛝

O Padrinho, uma aplicação de discovery de vagas, que possui rotas públicas onde os usuários acessam detalhes de cada oportunidade diretamente pelo frontend. Mesmo com cache ativado nessas rotas, a consulta de detalhes envolve junções entre varias entidades e representa cerca de 60% a 65% de todas as requisições processadas pela aplicação.

Além de garantir a consistência das informações retornadas, também precisávamos lidar com a disponibilidade da nossa única instância de PostgreSQL no Supabase, especialmente, durante períodos de maior tráfego, como nos acessos de first visit, quando uma vaga é consultada pela primeira vez e ainda não existe no cache.

Configurando nosso script👷🏻‍♀️

Na fase de inicialização, vamos definir os principais módulos a serem utilizados no nosso script, com destaque para os módulos sql e o driver do Postgres. Além dos imports de módulos, também vamos adicionar uma importação de arquivo local, onde será definido o script SQL que será utilizado:

import sql from "k6/x/sql";
import postgres from "k6/x/sql/driver/postgres";
import { SharedArray } from "k6/data";
import { check} from "k6";
import { QUERY_DETALHE_VAGA } from "./query.js";
Enter fullscreen mode Exit fullscreen mode

Um ponto importante, na versão 1.2.1 do K6 foi introduzida a feature Automatic extension resolution. Com ela, não existe mais a necessidade de criação de um binário customizável para os módulos xk6 ou módulos que não façam parte do core do K6, bastando que a flag K6_ENABLE_COMMUNITY_EXTENSIONS esteja configurada como true, como podemos ver no log abaixo:

INFO[0000] Automatic extension resolution is enabled. The current k6 binary doesn't satisfy all dependencies, it's required to provision a custom binary.  deps="k6/x/faker*"
INFO[0000] A new k6 binary has been provisioned with version(s): k6:v1.4.2 k6/x/faker:v0.4.4
Enter fullscreen mode Exit fullscreen mode

Inicialmente na fase de configuração, vamos definir um volume de iterações e o tempo limite em que nossas iterações precisam ser realizadas. Não utilizaremos nenhum executor do K6:

export const options = {
    vus: 10,
    iterations: 100,
    duration: "5s",
};
Enter fullscreen mode Exit fullscreen mode

Estamos definindo que um total de 100 iterações serão realizadas com o banco de dados, em um tempo maximo de 5 segundos. Serão utilizadas 10 VUs para a execução.

Alem das configurações de duração, quantidade de VUs e duração, vamos adicionar um limite(thresold) no percentil de p(90), definindo um baseline deperformance da query:

export const options = {
    vus: 5,
    iterations: 100,
    duration: "5s",
    thresholds: {
        iteration_duration: ["p(90)<300"],
    },
};
Enter fullscreen mode Exit fullscreen mode

Em bancos de dados com constantes evoluções, nosso threshold é um aliado na identificação da degradação da performance do banco, a medida que novas tabelas, relacionamentos e ausência de indices em consultas.

É preciso realizar duas etapas importantes, a primeira delas é a abertura de conexão com nosso banco de dados, por meio de uma string de conexão. Para isso, vamos precisar das seguintes variaveis de ambiente:

const host = __ENV.DB_HOST;
const port = __ENV.DB_PORT;
const dbname = __ENV.DB_NAME;
const user = __ENV.DB_USER;
const password = __ENV.DB_PASSWORD;
const ssl = __ENV.DB_SSL || "disable"; 
Enter fullscreen mode Exit fullscreen mode

Importante que os valores não sejam definidos diretamente no código, evitando exposição de dados.

Em seguida, podemos montar nossa string de conexão e realizar a abertura de conexão com o banco de dados:

export function setup() {

    if (!host || !port || !dbname || !user || !password) {
        throw new Error("Variáveis DB_HOST, DB_PORT, DB_NAME, DB_USER e DB_PASSWORD são obrigatórias.");
    }

    const connectionString = `postgres://${user}:${password}@${host}:${port}/${dbname}?sslmode=${ssl}`;

    const db = sql.open(postgres, connectionString);

    return db;
}
Enter fullscreen mode Exit fullscreen mode

Na segunda etapa, será definido as massas que serão utilizadas para consulta na nossa base de dados, utilizaremos alguns IDs de consultas, definidos em um arquivo .json, sendo necessario sua leitura em uma estrutura de SharedArray:

const nanoIds = new SharedArray("nano_ids", () => {
  return JSON.parse(open("./nanoids.json"));
});
Enter fullscreen mode Exit fullscreen mode

Com as massas definidas e a conexão com o banco de dados aberta, podemos definir nossa consulta na fase de execução. O propósito é distribuir os IDs carregados no sharedArray entre todas as VUs planejadas durante o tempo de execução, realizar uma consulta ao banco, e confirmar que foram retornados dados da consulta. Como não definiremos um atraso nas configurações, as VUs vão realizar quantas requisições conseguirem no intervalo de tempo configurado.

export default function (db) {
  const nanoId = nanoIds[Math.floor(Math.random() * nanoIds.length)];

  const result = db.query(QUERY_DETALHE_VAGA, nanoId);

  check(result, {
    "consulta executada": (rows) => rows.length >= 0,
  });

}
Enter fullscreen mode Exit fullscreen mode

Pós execução, na fase de desmontagem, finalizaremos fechando nossa conexão com o banco de dados:

export function teardown(db) {
  db.close();
}
Enter fullscreen mode Exit fullscreen mode

Resultados do script🧑‍🔬

No resultado de saída pós-execução, teremos os principais indicadores das iterações, incluindo avg, min, med, max e os percentis p(90) e p(95).

  █ THRESHOLDS

    iteration_duration
    ✗ 'p(90)<300' p(90)=494.15ms


  █ TOTAL RESULTS

    checks_total.......: 100     21.726862/s
    checks_succeeded...: 100.00% 100 out of 100
    checks_failed......: 0.00%   0 out of 100

    ✓ consulta executada

    EXECUTION
    iteration_duration...: avg=458.63ms min=324.57ms med=408.59ms max=1.06s p(90)=494.15ms p(95)=1.06s
    iterations...........: 100 21.726862/s
    vus..................: 10  min=10      max=10
    vus_max..............: 10  min=10      max=10

    NETWORK
    data_received........: 0 B 0 B/s
    data_sent............: 0 B 0 B/s
Enter fullscreen mode Exit fullscreen mode

Dois pontos importantes dos resultados obtidos com script contruido acima: o primeiro é que não atingimos o limite estabelecido, nosso percentil p(90) ficou em 494ms. O segundo, o drop de conexões com o banco de dados. O tempo mínimo de resposta obtido da nossa query foi de 324ms, ou seja, o menor valor obtido das 100 iterações também esteve bem acima do nosso baseline.

Otimizando nossa consulta👩‍💻

Agora, com as ferramentas de IA Generativa como nossas aliadas durante todo o ciclo de desenvolvimento, podemos fornecer nossas queries ao banco, a modelagem lógica e receber sugestões de melhorias de performance. Foi o que realizamos, fornecemos nossa consulta ao Claude e recebemos algumas sugestões, entre elas a criação dos seguintes índices no banco:

-- Filtro principal por identificador público (ex: slug, uuid, hash)
CREATE UNIQUE INDEX idx_main_public_id
  ON main_entity (public_id);

-- Índices para JOINs por chave estrangeira
CREATE INDEX idx_main_fk_company
  ON main_entity (company_id);

CREATE INDEX idx_main_fk_details
  ON main_entity (details_id);

-- JOIN crítico por campo textual (ex: URL, username, email)
CREATE INDEX idx_related_text_key
  ON related_entity (text_key);
Enter fullscreen mode Exit fullscreen mode

Foram preservadas os nomes reais de entidades do banco de dados.

Reexecutando nosso script após a criação dos índices no banco de dados e mantendo a mesma query, obtivemos os seguintes resultados

  █ THRESHOLDS

    iteration_duration
    ✗ 'p(90)<300' p(90)=411.45ms


  █ TOTAL RESULTS

    checks_total.......: 100     5.006751/s
    checks_succeeded...: 100.00% 100 out of 100
    checks_failed......: 0.00%   0 out of 100

    ✓ consulta executada

    EXECUTION
    iteration_duration...: avg=399.1ms min=279ms med=408.2ms max=1.16s p(90)=411.45ms p(95)=416.13ms
    iterations...........: 100 5.006751/s
    vus..................: 10   min=2      max=2
    vus_max..............: 10   min=2      max=2

    NETWORK
    data_received........: 0 B 0 B/s
    data_sent............: 0 B 0 B/s
Enter fullscreen mode Exit fullscreen mode

Como podemos observar no nosso segundo resultado, a simples criação de índices melhorou nosso percentil p(90), com uma redução de aproximadamente 90ms, e nosso valor mínimo obtido esteve abaixo dos 300ms do baseline.

Nosso experimento foi conduzido em execução única utilizando um banco de dados free da ferramenta Supabase.

Conclusão❤️

A performance de queries no banco de dados é um fator crítico que impacta diretamente a experiência do usuário e a escalabilidade de aplicações.

Como demonstrado neste artigo, com o uso do xk6-sql foi possivel identificar gargalos de performance em consultas SQL de forma isolada, permitindo estabelecer baselines claros e monitorar a degradação ao longo do tempo.

Integrar testes de performance de banco de dados conduzindo experimentos no seu cilco de desenvolvimento, não apenas previne problemas em produção, mas também fornece métricas objetivas para priorizar otimizações e validar melhorias antes que impactem os usuários finais.

Gostou do conteúdo e quer saber mais sobre testes de performance com K6? Confira meu curso na Udemy!

Top comments (0)