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

Transcript

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