Skip to content
Go

Como Otimizei um Motor de Busca em Go para ser 5x Mais Rápido: Além do Óbvio

Published: Duration: 7:40
0:00 0:00

Transcrição

Apresentadora

Juliana Santos E aí, pessoal, bem-vindos de volta ao Allur! Eu sou a Juliana Santos e hoje o papo é para quem gosta de escovar bit e entender o que acontece por baixo do capô. Sabe aquele projeto que você começa do jeito mais óbvio, ele funciona, mas aí quando o volume de dados cresce, tudo começa a se arrastar? Pois é. Desenvolver um motor de busca eficiente é quase um rito de passagem para quem quer dominar arquitetura de sistemas e performance.

Apresentadora

Juliana Santos E para me ajudar a desbravar esse universo de performance em Go, eu trouxe um convidado que entende muito de sistemas distribuídos e otimização. Ele é engenheiro de software sênior e um entusiasta da comunidade Go aqui no Brasil. Seja muito bem-vindo ao Allur, Rafael Costa!

Convidado

Rafael Costa Opa, valeu pelo convite, Juliana! É um prazer estar aqui no Allur. Cara, esse tópico é sensacional porque mexe com aquela dor que todo desenvolvedor sente quando vê um benchmark e percebe que o código "bonitinho" dele é, na verdade, um gargalo. Vamos trocar essa ideia!

Apresentadora

Juliana Santos Com certeza, Rafael! E olha, para começar, eu queria falar sobre o "pecado original" de quem começa um motor de busca. A gente geralmente faz aquele `for range` maroto, usa um `strings.Contains` com um `strings.ToLower` e pronto, tá buscando. No começo é lindo, né? Mas por que isso escala tão mal?

Convidado

Rafael Costa Pois é, Juliana, é a famosa "v1 ingênua". O problema é puramente matemático: é uma operação O(n). Se você tem mil documentos, é rápido. Se você tem um milhão, você vai fazer um milhão de verificações de string para cada busca. E o pior não é nem só o loop, mas o que você faz dentro dele. Toda vez que você chama `strings.ToLower` dentro de um loop de busca, você está alocando uma nova string na memória, sujando o heap e dando um trabalho danado pro Garbage Collector (o GC) do Go. No final das contas, sua CPU gasta 10% buscando e 90% limpando lixo ou comparando bytes que ela já viu antes.

Apresentadora

Juliana Santos Massa! E aí que entra o famoso `pprof` do Go, né? Eu imagino que ele seja o melhor amigo do desenvolvedor nessa hora para mostrar que o problema não é a lógica, mas a gestão de recursos.

Convidado

Rafael Costa Exatamente! O `pprof` é o raio-X do Go. Quando você roda um profile de memória e CPU nessa v1, você vê aquelas "chamas" gigantescas em funções de alocação de memória. Foi aí que a gente percebeu: "Cara, precisamos de um Índice Invertido". Em vez de ler o livro todo para achar uma palavra, a gente vai lá no final do livro, no índice remissivo, e vê em quais páginas a palavra está.

Apresentadora

Juliana Santos Explica melhor esse conceito do Índice Invertido para a gente, Rafael. Como que o Go lida com isso de forma eficiente? Tipo, a gente usa mapas, slices...?

Convidado

Rafael Costa Isso! A estrutura clássica em Go é um `mapuint32`. A chave é o termo (a palavra "normalizada", sem acento, tudo minúsculo) e o valor é um slice de IDs dos documentos onde aquela palavra aparece. A mágica acontece porque o acesso ao mapa em Go é O(1). Então, se você busca "Go", você não olha um milhão de documentos; você faz um único "lookup" no mapa e ele te cospe na hora: "Ó, tá nos documentos 5, 12 e 42". O ganho de velocidade só aqui já é absurdo.

Apresentadora

Juliana Santos Nossa, imagino! Mas aí, lendo sobre como chegar nesse nível de 5x mais rápido, eu vi que não parou por aí. Teve uma parada de "fugir das strings" usando Hashing. Como que funciona isso? Trocar texto por número ajuda tanto assim?

Convidado

Rafael Costa Ajuda demais, Juliana! Tipo assim, pensa comigo: comparar se a string "desenvolvimento" é igual a "desenvolvedor" exige que o processador percorra byte por byte. Agora, comparar se o número 456 é igual a 789 é uma única instrução de CPU. Então, o que a gente faz? No processo de indexação, a gente transforma cada token (cada palavra) em um hash numérico, tipo um `uint32` ou `uint64`. A busca deixa de ser textual e passa a ser numérica. É bizarro o quanto isso alivia a CPU.

Apresentadora

Juliana Santos Que massa! E eu vi que você mencionou Bitsets também. Eu confesso que Bitsets me lembra faculdade, aquela coisa de baixo nível. Onde isso entra numa busca complexa, tipo quando eu quero procurar "Go" AND "Performance"?

Convidado

Rafael Costa Cara, Bitsets são o "pulo do gato" para operações booleanas. Imagina que para o termo "Go" você tem um vetor de bits onde o bit na posição 10 é '1' porque o termo existe no documento 10. Para o termo "Performance", você tem outro vetor. Se você quer saber quais documentos têm os dois termos, você faz uma operação `AND` binária entre os dois vetores. O processador faz isso de forma extremamente otimizada, processando 64 documentos de uma vez só em um único ciclo de clock. É muito mais rápido do que ficar iterando em dois slices e comparando IDs manualmente.

Apresentadora

Juliana Santos Caramba, 64 documentos por ciclo de clock? É surreal. Agora, a gente sabe que o Go é famoso pela concorrência, pelas Goroutines. Mas você comentou que sair disparando goroutine para todo lado nem sempre resolve e pode até atrapalhar por causa de concorrência em cima de Mutexes. Como vocês resolveram isso?

Convidado

Rafael Costa Essa é uma armadilha clássica, Juliana. Se você tem um mapa gigante e 100 goroutines tentando ler e escrever nele ao mesmo tempo, você vai ter um gargalo de contenção de lock. A solução que usamos foi o "Sharding". Em vez de um mapa único, a gente divide o índice em, sei lá, 16 ou 32 pedaços (shards). Cada pedaço tem seu próprio lock ou até nem precisa de lock se for só leitura. Aí sim, a gente dispara buscas paralelas em cada segmento e depois junta tudo. É o jeito inteligente de usar o poder do Go sem dar tiro no pé.

Apresentadora

Juliana Santos Entendi, então é tipo dividir para conquistar, né? E para fechar essa parte técnica, eu queria falar de memória. Você mencionou o `sync.Pool` e a pré-alocação de slices. Isso é para evitar que o Garbage Collector fique louco, certo?

Convidado

Rafael Costa Exato! O GC é seu amigo, mas ele cobra caro se você for bagunceiro. Se a cada busca você cria um novo slice de resultados, o GC tem que limpar isso depois. Com o `sync.Pool`, a gente "aluga" um objeto, usa ele na busca e depois devolve pro pool para ser reusado pela próxima requisição. E a pré-alocação com `make(T, 0, capacity)` evita que o Go tenha que ficar redimensionando o slice e copiando dados enquanto você popula os resultados. No final, essas pequenas economias somadas são o que entrega os 5x de performance extra.

Apresentadora

Juliana Santos Sensacional, Rafael! É muito legal ver que a performance de verdade não vem de "mágica", mas de entender como a máquina funciona e como o Go nos dá as ferramentas para conversar com ela. O resultado final então foi uma busca que, mesmo com muito mais dados, continua respondendo em milissegundos?

Convidado

Rafael Costa Exatamente. A curva de latência ficou flat, sabe? Ela não sobe mais na vertical quando os dados aumentam. Foi um aprendizado massa: performance é sobre ser eficiente com o que você tem.

Apresentadora

Juliana Santos Legal demais! Rafael, papo incrível, muito obrigada por compartilhar essa experiência com a gente aqui no Allur.

Convidado

Rafael Costa Eu que agradeço, Juliana! Quem quiser trocar mais ideia sobre Go e performance, é só me dar um alô nas redes. Valeu, pessoal!

Apresentadora

Juliana Santos E é isso, pessoal! O grande aprendizado de hoje é: não aceite o óbvio. Às vezes, a solução para o seu gargalo não está em um servidor maior, mas em uma estrutura de dados mais inteligente e num uso mais consciente da memória.

Tags

Go Golang software engineering backend performance memory management profiling