Anti-patterns de Performance

Adriano Croco
6 min readJan 30, 2023

--

Olá!

Hoje eu gostaria de catalogar alguns erros comuns que são ofensores em se tratando de performance de sistemas de uma maneira geral. Apesar de alguns parecerem óbvios, acredito que o óbvio também precisa ser dito.

Um detalhe que eu gostaria de comentar: Todos os códigos foram gerados via ChatGPT. Durante o texto eu comento porque isso é importante.

Problema do N+1

Vamos começar com um exemplo: Qual é o problema desse código?

const users = await User.findAll();

for (const user of users) {
const posts = await user.getPosts();
console.log(`User ${user.name} has ${posts.length} posts`);
}

Após consultar os usuários do banco (método findAll), é necessário percorrer cada usuário para obter os posts do mesmo (user.getPosts). Isso gera o fatídico problema do N+1. Ocorre quando se efetua uma query para uma determinada entidade, porém, para montar a entidade completa é necessário queries adicionais para cada registro retornado pela primeira consulta.

Uma forma simples de resolver esse problema é consultar todos os dados em uma única ida ao banco de dados:

const users = await User.findAll({
include: [{
model: Post,
as: 'posts'
}]
});

for (const user of users) {
console.log(`User ${user.name} has ${user.posts.length} posts`);
}

Essa técnica também pode ser chamada de Eager Loading.

De uma maneira geral, um bom modelo mental para performance se resume na seguinte ordem de velocidade de acesso aos recursos computacionais:

CPU < Memória < Disco < Rede.

Aqui nesse link você encontra um joguinho que você pode tentar responder as perguntas para internalizar a diferença entre uma operação que leva nanosegundos e uma que leva algumas centenas de milissegundos, por exemplo.

A partir desses comentários e sabendo que o Eager Loading é mais rápido que o problema original N+1, a explicação para tal operação ser mais rápida é simplesmente porque operações feitas via rede sempre perdem em velocidade para memória e disco, logo, vale a pena agrupar as consultas em uma pesquisa só que usa somente uma ida-e-volta pela rede. Com isso transforma-se vários operações lentas em uma só operação lenta (o que é menos pior).

Requisições pequenas demais

Vamos a um outro exemplo:

const axios = require('axios');

async function getData() {
for (let i = 0; i < 1000; i++) {
const response = await axios.get("https://api.example.com/getUserData/" + i);
console.log(response.data);
}
}

getData();

Nesse caso, é um exemplo de uma função que faz 1000 consultas pequenas para um back-end para obter dados de um usuário. Apesar de ser uma estrutura relativamente comum, convenhamos que fazer uma chamada dessa em algum tipo de sistema web teria um desempenho horroroso, justamente devido ao excesso de consultas a rede.

Uma forma de melhorar esse código seria algo como isso:

const axios = require('axios');

async function getData() {
let data = [];
for (let i = 0; i < 1000; i++) {
const response = await axios.post("https://api.example.com/getUsersListData",
{
userIds: [1,2,3,4,5,6,7,8,9,10],
from: i*10,
to: (i+1)*10
});
data = [...data, ...response.data];
}
console.log(data);
}

getData();

Apesar do código em si fazer mais sentido do ponto de vista de performance que o anterior, afinal, ao invés de 1000 requisições, estamos fazendo chamadas por chunks, o que já melhora bastante a performance de uma maneira geral, o código tem um problema: Legibilidade.

Os parâmetros from e to com cálculos de índices são confusos de entender. Pedi para o ChatGPT fazer melhor e a IA me sugeriu isso aqui:

const axios = require('axios');

async function getData() {
let data = [];
const response = await axios.post("https://api.example.com/getUsersListData", {
userIds: [1,2,3,4,5,6,7,8,9,10],
from: 0,
to: 1000
});
data = response.data;
console.log(data);
}

getData();

Ainda está ruim. A consulta é por uma lista de IDs ou por um range? Para evitar essa ambiguidade uma solução melhor seria:

const axios = require('axios');

async function getData() {
let data = [];
const response = await axios.post("https://api.example.com/getUsersListData", {
limit: 1000
});
data = response.data;
console.log(data);
}

getData();

Ao meu ver, essa solução é um pouco mais semântica que as versões anteriores, dado que está mais parecendo uma paginação (que é comum de ver por aí). Por isso, fique tranquilo: Enquanto inteligências artificiais continuarem gerando códigos assim, seu trabalho como pessoa desenvolvedora ainda não está ameaçado.

Pensando que o problema original era fazer 1000 chamadas pequenas (que se tornam um problema dado o volume), se o payload de retorno da API nesse caso não for tão grande, vale mais a pena fazer uma única chamada que obtém os 1000 registros de uma vez. Portanto, ao invés de tentar pegar o total de uma vez, a ideia chave é quebrar em partes. O tamanho ideal de cada parte depende do problema a ser resolvido (e muitas vezes requer experimentação para encontrar).

O quebrar em partes é um conceito fundamental da ciência da computação chamado Divide and Conquer.

Algoritmo ineficiente

Por fim, vamos a um exemplo de um problema simples, que, dado a escolha de um algoritmo ineficiente para a tarefa em questão pode se tornar um problema rapidamente:

function findWordsWithPrefix(words, prefix) {
let result = [];
for (let i = 0; i < words.length; i++) {
if (words[i].startsWith(prefix)) {
result.push(words[i]);
}
}
return result;
}

O código em questão encontra um determinado prefixo (uma string qualquer) em uma lista de palavras. É um algoritmo usado para fazer buscas com autocompletar em listas de contatos no seu celular, por exemplo. A complexidade assintótica desse algoritmo é O(n*m), no qual n é o tamanho da lista de palavras e m é o tamanho do prefixo.

O grande problema de algoritmos ineficientes é que eles geralmente só se tornam um problema com um volume maior de dados. Para exemplos com 100 ou 10.000 registros você não notará a diferença. Porém, caso o uso seja para uma ordem de grandeza maior (100 mil ou 1 milhão, por exemplo), os problemas começam a aparecer.

Aqui, a minha dica é: Execute um benchmark durante o desenvolvimento para saber se o seu algoritmo caseiro é eficiente. Caso você não queira fazer isso, pesquisa sobre algoritmos já previamente documentados e use-os.

Para esse problema de busca de prefixos, uma estrutura de dados que é perfeita para isso é uma Trie (nada mais é que uma árvore otimizada para buscas dessa natureza). A representação gráfica é algo parecido com isso:

Representação gráfica de uma Trie

Em código, a solução seria:

class TrieNode {
constructor() {
// Um mapa que armazena os filhos de um nó
this.children = {};
// Uma flag para indicar se um nó é o fim de uma palavra
this.endOfWord = false;
}
}

class Trie {
constructor() {
// A raiz da trie
this.root = new TrieNode();
}

// Insere uma palavra na trie
insert(word) {
let node = this.root;
// Percorre a trie e cria nós para cada caractere na palavra
for (let char of word) {
// Se o nó filho não existe, cria um novo nó
if (!node.children[char]) node.children[char] = new TrieNode();
// Move-se para o nó filho
node = node.children[char];
}
// Marca o nó como o fim de uma palavra
node.endOfWord = true;
}

// Encontra todas as palavras na trie que começam com um prefixo dado
findWordsWithPrefix(prefix) {
let node = this.root;
// Percorre a trie até o nó que representa o fim do prefixo
for (let char of prefix) {
// Se o prefixo não estiver na trie, retorna um array vazio
if (!node.children[char]) return [];
node = node.children[char];
}

let words = [];
// Usa busca em profundidade para encontrar todas as palavras que começam com o prefixo
this.depthfirstsearch(node, prefix, words);
return words;
}

// Usa busca em profundidade para encontrar todas as palavras que começam com o prefixo
depthfirstsearch(node, word, words) {
if (node.endOfWord) words.push(word);
for (let char in node.children) {
this.dfs(node.children[char], word + char, words);
}
}
}

E aqui é a deixa para eu comentar sobre trade-offs.

Apesar de ser o código ideal para esse tipo de problema, a implementação acaba sendo complexa para a maioria das pessoas desenvolvedoras. Esse é um padrão que eu vi acontecer muitas vezes na minha carreira: Código performático geralmente é um pouco complexo (ou feio, até). Infelizmente, não dá para fugir muito de ter que lidar com complexidade de uma maneira geral.

A título de curiosidade, a complexidade assintótica desse código é O(n) para inserção, porque precisamos percorrer a trie para cada caractere na palavra e criar um novo nó se necessário. Para a busca, é O(m + k), onde m é o comprimento do prefixo dado e k é o número de palavras na trie que começam com o prefixo. Isso porque precisamos percorrer a trie até o nó que representa o fim do prefixo (m), e depois usamos busca em profundidade para encontrar todas as palavras que começam com o prefixo (k).

O grande ganho ao usar esse algoritmo é que delimitamos a busca apenas por palavras que contenham o prefixo e não precisamos percorrer a lista com todas as palavras. Para um volume grande, isso faz muita diferença.

E você, o que achou?

Espero que o texto tenha te ajudado de alguma forma.

Até!

Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.

--

--

Adriano Croco
Adriano Croco

No responses yet