Como caímos –e fugimos!– da otimização-prematura ao criar um serviço de CEP

Postado 27 de março de 2017

Gráfico de linhas amarelas e verdes compara a performance do tempo de resposta dos serviços.

Na Youse, utilizávamos um serviço de CEP externo que sofria com alguns problemas. Primeiro porque o tempo de resposta médio era ao redor de 3300 ms com picos acima dos 28 s, algo brutalmente elevado para uma operação simples como a de busca de um endereço a partir de um CEP. Não bastasse isso, o tal serviço frequentemente ficava indisponível, nos impedindo de vender os produtos Auto e Residencial, que precisam do CEP validado e do endereço completo, extraído do CEP, para realizar sua precificação.

Frente a esses problemas, optamos por criar um serviço nosso, a partir da importação da base completa de CEP dos Correios – o e-DNE. Encontramos no site dos Correios uma base de exemplo, então fizemos o download e começamos a analisar sua estrutura e a pensar sobre como montar esse serviço.

De modo resumido, a base de CEP dos Correios é distribuída em arquivos CSV, que na realidade são um dump de uma base relacional que possui algumas tabelas com os CEPs propriamente, organizados por tipo (localidade, logradouro, grande recebedor, etc.), com cada tipo em sua tabela, e algumas tabelas de dados relacionados (países, bairros, etc.).

Para nós, a única funcionalidade que desejávamos ter no serviço era, a partir de um determinado CEP, extrair o endereço completo (tipo, logradouro, bairro, cidade e estado).

Para fazer isso na estrutura de dados dos Correios, contudo, o custo de cada request nos pareceu ser mais alto do que o desejado, pois, dado um CEP, não sabíamos em qual tabela ele estaria. Assim, teríamos que ir de tabela em tabela procurando até que, uma vez encontrado, os dados adicionais das tabelas relacionadas seriam carregados e só então a requisição seria respondida por completo.

Começamos a pensar, então, em uma estrutura de chave-valor, onde teríamos todos os CEPs como chaves e os endereços completos correspondentes como valores. Pensamos que seria a forma mais eficiente, sobretudo se optarmos por armazená-la num banco em memória, possivelmente Redis.

Contudo, a ideia não era realizar a importação uma única vez e pronto, pois todos os meses os Correios disponibilizam uma atualização do e-DNE. Essas atualizações seguem a mesma estrutura da base inicial, porém com marcações para determinar se cada dado listado deve ser inserido, atualizado ou removido. E como estamos falando de mais de 1 milhão de registros, não nos parecia uma opção viável parsear todas as linhas de todos os CSVs, mapeá-los e relacioná-los em memória, para então fazer a inserção no Redis.

Pensamos, então, que uma maneira de solucionar esse problema seria manter uma base relacional, mimetizando (ou quase) a estrutura dos arquivos disponibilizados pelos Correios, pois assim seria fácil fazer a importação inicial e também a atualização periódica. E, a partir desta base, poderíamos fazer registro a registro o mapeamento adequado, inserindo cada entrada de CEP na base final de consulta, no Redis, otimizada para o cenário que recebe um CEP e retorna um endereço completo.

Tudo parecia muito bem: havíamos discutido e pensado bastante sobre o problema que tínhamos em mãos, estudado algumas questões sobre a origem dos dados e tínhamos certeza de que estávamos frente a frente com um plano perfeito para fazer o melhor serviço de CEP ever-done-on-earth.

Porém, conversando um pouco mais com pessoas de fora do time que tinha o problema em mãos e também com a equipe de DevOps, todo o plano começou a nos parecer um pouco exagerado. Precisaríamos de uma nova instância do EC2, um novo RDS e um novo ElastiCache para construir um único endpoint, de consulta de CEP, com toda uma lógica complexa de dados duplicados em 2 camadas de consulta/persistência.

Começamos a achar que poderia ser interessante reduzir o número de partes móveis, simplificando a solução tanto em termos de código quanto de infraestrutura, e vimos que talvez o Redis estivesse fora de lugar, que poderíamos buscar outras soluções de otimização no próprio PostgreSQL.

Seguimos pensando e nos demos conta de que a mesma estrutura planejada para o Redis, de dados chave-valor (cep-endereço), poderia ser realizada utilizando campos JSONB no próprio PostgreSQL. Consideramos ainda que poderíamos ter algo similar utilizando as Structured Views, também no PostgreSQL que já era nosso banco relacional “default”.

Pensávamos que certamente teríamos uma performance pior no PostgreSQL do que se todos os dados estivessem em memória num Redis da vida, mas talvez fosse o suficiente. Consideramos, então, que valeria a pena ao menos experimentar essa solução e avançar para o Redis apenas se o tempo de resposta não fosse suficiente utilizando o PostgreSQL.

Estávamos, então, convencidos de que tínhamos um bom plano, já bastante simples, visto que estávamos evitando o over-engineering do plano inicial com Redis, e que era hora de colocar a mão-na-massa!

Mas enquanto trabalhávamos na primeira estrutura relacional, mimetizando as estruturas recebidas dos Correios, em uma nova conversa entre membros do time um colega muito sagaz pensou: se já temos mesmo que fazer a estrutura assim, por que não fazer um teste criando um endpoint para consultar o CEP a partir dessa estrutura mesmo, com múltiplas tabelas, sem qualquer otimização extra, e mensurar o tempo de resposta para vermos como performa? Com isso vamos poder decidir se é necessário ou não otimizar a partir de uma experiência real e não baseados em “achismos”!

Apesar de tão óbvia, a ideia nos pareceu brilhante.

E assim o fizemos:

  class AddressFinder
    def self.by_zip_code(zip_code)
      find_address_provider(zip_code)&.to_address
    end

    def self.find_address_provider(zip_code)
      Locality.find_by(zip_code: zip_code) ||
        Location.find_by(zip_code: zip_code) ||
        MajorReceiver.find_by(zip_code: zip_code) ||
        OperationalUnit.find_by(zip_code: zip_code) ||
        CommunityPostalBox.find_by(zip_code: zip_code)
    end
    private_class_method :find_address_provider
  end

 

Em cascata, buscamos tabela a tabela, desde Localidade até Caixa Postal Comunitária, retornando na primeira que tenha um registro para o CEP buscado.

O resultado? Em produção, o tempo de resposta médio é de 56 ms, com teto abaixo dos 300 ms. Podemos melhorar isso? Sem dúvida! Temos ao menos 2 formas já pensadas e planejadas de como fazê-lo. Precisamos de uma performance melhor hoje? De modo algum… Na experiência do usuário, o SLA abaixo de 300 ms é suficiente para que, ao digitar o CEP em um campo, o endereço completo já esteja preenchido antes que o usuário avance para o próximo campo. Ou seja, é super rápido.

O que aprendemos com isso? Over-engineering não é algo que se faz intencionalmente. Quando você se dá por si, já está planejando soluções mirabolantes, pois acha que tem um problema antes de constatar se ele de fato existe. E mesmo quando você se esquiva do risco de uma solução mirabolante, pode estar caminhando para outra sem nem mesmo se dar conta. Talvez a 2ª solução seja mais simples, porém nem sempre será necessária.

Neste caso que contamos, não foi necessária nenhuma otimização – e segue não sendo até hoje, com o serviço suportando mais que o dobro do tráfego suportado pelo anterior. Não teríamos corrido o risco de implementar nenhuma dessas coisas desnecessárias se tivéssemos, desde o minuto zero, deixado os problemas surgirem antes de querer resolvê-los.

Por Rodrigo Brancher