Como lidamos com centenas de pedidos num segundo

A aplicação UnderLX comunica com o nosso servidor através de uma API REST. Recorrendo a esta API, a aplicação obtém o “mapa de rede” - informação relativamente estática sobre a topologia da rede de Metro, todas as suas linhas e estações, bem como os respectivos átrios, saídas, horários de operação, correspondências, valências e outras características. É também por esta API que a aplicação obtém e submete informação mais dinâmica, como o estado das linhas, o histórico de perturbações e os registos de viagem.

A gráfica dos mapas de rede

O mapa de rede é guardado no armazenamento da aplicação, para que a maior parte da informação sobre a rede possa ser consultada mesmo quando se está offline, e tem uma versão associada, para que a aplicação saiba quando deve actualizar a sua cache do mapa de rede. Esta versão pode ser vista na secção “Acerca do UnderLX” da aplicação, onde também é possível forçar a actualização manual do mapa de rede.

Quanto às actualizações automáticas do mapa de rede, a aplicação verifica a existência de uma versão nova aproximadamente uma vez por dia (o Android encarrega-se de fazer o melhor agendamento para poupar bateria), actualizando se a versão actual for diferente da da cópia local. A forma como estas verificações são agendadas, leva a que a quantidade de pedidos ao servidor relacionados com o mapa de rede seja relativamente uniforme ao longo do dia.

Sabendo isto, quem segue o projeto mais atentamente pode estar a perguntar-se onde entram as centenas de pedidos. Afinal, a aplicação não tem muito mais de 4 milhares de utilizadores activos, pelo que ainda que cada actualização do mapa de rede envolva 20 pedidos ao servidor, isso não ultrapassa um pedido por segundo.

As “centenas de pedidos” surgiram a partir do momento em que introduzimos uma funcionalidade que permite uma actualização mais imediata do mapa de rede. Mas se este é “relativamente estático”, porque haveríamos de querer que ele fosse actualizado mais rapidamente? Acontece que muitas vezes só são anunciadas alterações ao horário de funcionamento da rede de Metro, ou de certas linhas e estações, com poucos dias de antecedência. É o caso, por exemplo, das passagens de ano, em que apenas um subconjunto das estações permanece aberto. Mais: muitas vezes, esses anúncios são alterados praticamente um dia antes de entrarem em efeito (por exemplo, devido a erros tipográficos nos materiais que os acompanham). Há ainda o caso dos encerramentos inesperados de estações, como foi o caso da Encarnação em Junho de 2018.

Se deixássemos as actualizações automáticas do mapa de rede progredirem ao seu ritmo habitual, isso significaria que muitos utilizadores ainda não teriam o mapa actualizado na noite da passagem de ano, por exemplo. Por isso, a aplicação passou a suportar comandos enviados através do Firebase Cloud Messaging (FCM), que também usamos, por exemplo, para enviar as notificações de perturbações. Um destes comandos ordena a aplicação a actualizar imediatamente o mapa de rede. No entanto, depois de implementada e disponibilizada numa actualização da aplicação, usar esta funcionalidade revelou-se mais difícil do que o previsto.

Picos inesperados

Surpreendentemente, da primeira vez que tentámos provocar uma actualização imediata do mapa de rede, o servidor do UnderLX ficou indisponível durante quase um minuto, arrastando consigo o Perturbações.pt, que é servido pelo mesmo programa. O FCM entrega as mensagens muito rapidamente aos dispositivos que estejam online, e tínhamos subestimado a carga que um par de milhares de dispositivos provocavam no servidor ao realizar pedidos para obter o mapa de rede. Efectivamente, estávamos a receber centenas de pedidos num segundo… e passados mais alguns segundos, já estávamos de volta aos padrões habituais de tráfego.

Os pedidos eram tantos que mais de metade acabou por não ser bem sucedido, pelo que muitas instalações continuaram com as versões antigas do mapa de rede. O tratamento de erros da aplicação faz com que a actualização seja reagendada para mais tarde, o que é um mal menor, mas acaba por falhar o nosso objectivo de fazer as actualizações chegar mais rapidamente ao lado do cliente. Apesar de termos uma carga média bastante baixa, surgiu esta necessidade de lidar com picos - que no nosso caso, até eram perfeitamente previsíveis, pois eram provocados por nós, numa espécie de auto-DDOS.

Uma análise cuidada dos acontecimentos permitiu-nos concluir que esta incapacidade de lidar com tantos pedidos resultava de um efeito em cadeia com origem na leitura da base de dados:

  • Para responder aos pedidos com a informação que constitui o mapa de rede, o servidor faz uma série de consultas relativamente simples à base de dados (PostgreSQL), que acabam por ser em número exagerado quando existem muitas actualizações a decorrer em simultâneo;

  • A base de dados leva algum tempo a realizar tantas consultas, o que impede o servidor de responder particularmente rapidamente aos pedidos;

  • Depois de obter a informação necessária da base de dados, o servidor tem ainda que codificar a resposta, o que habitualmente é muito rápido, mas nem tanto quando a base de dados está a usar muito tempo de CPU e o servidor deseja codificar centenas de respostas em simultâneo;

  • O reverse proxy (Nginx) que temos à frente do servidor acaba por se fartar de esperar pela resposta dele e responde com um 504 Gateway Timeout, ou a própria aplicação do lado do cliente faz timeout.

A situação abriu-nos os olhos para a necessidade de fazer algumas optimizações no nosso código.

Em que a base de dados nos receita comprimidos para a memória

Um ponto que já tínhamos identificado anteriormente era a enorme quantidade de consultas repetidas à base de dados que o servidor realizava por cada pedido. Da forma como temos as estruturas de dados organizadas, quando queremos obter informação acerca p.ex. do átrio de uma estação, o código que trata de ler o átrio da base de dados, também “faz o favor” de ler a estação a que o átrio pertence. Por sua vez, código que obtém a informação sobre uma estação também obtém a informação sobre a sua rede. Apesar de parecer um desperdício de CPU e memória, ao carregar assim a hierarquia, o código acaba por se tornar mais simples de ler e escrever. A maioria das vezes, a informação que é carregada desta forma acaba por ser necessária de qualquer maneira, pelo que não é um desperdício tão grande quanto parece.

O verdadeiro problema assenta na repetição destas consultas quando se carregam vários objectos de uma vez. Por exemplo, ao carregar todas as estações de uma linha em memória, o servidor acabava por ler dezenas de vezes a informação da mesma rede (até porque por enquanto só suportamos uma) a partir da base de dados. A solução passou por implementar uma cache dentro do servidor, para que cada objecto seja carregado apenas uma vez. A cache utiliza as chaves primárias das entidades como identificador do objecto. A cache tem em conta o contexto da transação em que se insere e é invalidada automaticamente quando os objectos são alterados nessa transação. Este é o género de funcionalidade que os ORMs tipicamente disponibilizam, mas nós não estamos a usar um ORM - o que tem desvantagens, como se pode ver neste caso, mas também tem muitas vantagens, incluindo podermos usar um esquema de base de dados exactamente de acordo com as nossas necessidades sem termos de “lutar contra o ORM” através de um processo interminável de configuração do mesmo.

A introdução desta cache interna permitiu um aumento da velocidade de certas operações superior a 500%, o que habitualmente é pouco perceptível - a nível individual, as operações já eram suficientemente rápidas. Mas em termos de escala, com centenas de pedidos em simultâneo, as operações “suficientemente rápidas” eram um grande peso.

Curiosamente, esta primeira alteração não resolveu completamente o problema. Com milhares de dispositivos a querer actualizar o mapa de rede em simultâneo, continuávamos a receber mais pedidos simultâneos do que o servidor conseguia tratar. Agora, o bottleneck parecia ter passado mais para a própria interpretação do pedido e codificação da resposta.

Reverse proxies: inventados por algum motivo

Voltámo-nos para o componente que temos tido presente desde o início e que ainda não nos tínhamos lembrado de aproveitar. Apesar de a linguagem na qual desenvolvemos o servidor, Go, ter na sua standard library um servidor HTTP perfeitamente viável e relativamente eficiente que tem inclusive suporte para TLS, nós temos preferido sempre colocar o Nginx “à frente” do nosso servidor, para tratar do TLS da compressão gzip e servir ficheiros estáticos, como as imagens do site.

A solução completa passou por compreender que o mapa de rede é construído pelo servidor de forma dinâmica baseado nos conteúdos da base de dados, mas o mapa de rede não muda de cliente para cliente, nem está constantemente a mudar. O mapa de rede muda, muitas vezes, menos do que duas vezes por mês. No fundo, para responder a estes pedidos, o servidor acaba por realizar imensas operações a nível interno, mas que resultam sempre exactamente no mesmo conjunto de bytes de resposta… como se o mapa de rede fosse um conjunto de ficheiros estáticos. Ainda assim, queremos que o servidor passe a servir uma versão actualizada dele assim que fazemos alterações na base de dados.

Ora, responder a muitos pedidos em simultâneo usando poucos recursos é a especialidade do Nginx, pelo que usámos as suas capacidades de cache para reduzir a quantidade de pedidos a que o nosso servidor “lento” tem de responder. Para começar, o nosso servidor teve que passar a incluir nas respostas aos pedidos um header, Cache-Control, para indicar ao Nginx se deve fazer cache da resposta, e em que condições é que essa cache fica inválida. Na prática, usamos apenas duas hipóteses para o valor deste header:

  • no-cache, no-store, must-revalidate - quando não queremos que o Nginx coloque a resposta em cache, isto é, mantenha o comportamento que tinha originalmente;
  • s-maxage=10 - quando queremos que o Nginx guarde a resposta na cache por 10 segundos, ou seja, para pedidos iguais feitos até 10 segundos depois do primeiro, o Nginx irá ler a resposta da cache em vez de contactar o nosso servidor.

A primeira opção é usada, por exemplo, para respostas a pedidos autenticados que variam consoante as credenciais do cliente, ou pedidos de submissão de informações que poderão ser bem sucedidos da primeira vez, mas que já deverão resultar numa mensagem de erro se forem repetidos imediatamente a seguir. A segunda opção é usada para tudo o que seja informação do mapa de rede, ou semelhante. O intervalo de 10 segundos é muito curto, mas é mais do que suficiente para lidar com as centenas de pedidos por segundo que raramente se verificam, ao mesmo tempo que tem uma invalidação quase imediata, o que é útil quando alteramos o mapa de rede e queremos que este seja actualizado imediatamente pelas instalações da aplicação.

A configuração do Nginx é relativamente simples, mas tem um pormenor interessante, específico da nossa API REST.

proxy_cache_path /var/tmp/nginx-cache levels=1:2 keys_zone=disturbances_api_cache:10m max_size=1g inactive=60m use_temp_path=off;

server {
    server_name api.perturbacoes.tny.im;
    # ...
    location / {
        proxy_cache disturbances_api_cache;
        proxy_cache_lock on;
        proxy_cache_use_stale updating;
        # proxy_cache_key must include the requested content type ($http_accept)!
        proxy_cache_key $http_accept$scheme$proxy_host$uri$is_args$args;
        add_header X-Cache-Status $upstream_cache_status;

        # ...
        proxy_pass upstream;
    }
}

A nossa API REST “fala” várias “línguas”, consoante o conteúdo do header Accept do pedido. A aplicação usa MessagePack, mas quem tentar explorar a API com um browser vai receber JSON pretty-printed (bem formatado), por exemplo. Assim, é importante que o conteúdo deste header faça parte da chave da cache do Nginx, caso contrário, a cache poderá servir MessagePack a quem pediu JSON, e vice-versa.

Com meia dúzia de linhas na configuração do Nginx, o nosso servidor (como um todo) passou a poder responder a milhares de pedidos do mapa de rede por segundo. Se no futuro tal for necessário, esta estratégia pode ser usada para mais tipos de pedidos, por exemplo, os pedidos pelo estado das linhas podem ter uma invalidação de um segundo, o que permite uma actualização imediata em termos práticos, mas limita a um por segundo o ritmo de pedidos do tipo a que o nosso servidor (em Go) tem de responder.

Aproveitamos para informar que apesar de ainda não estar devidamente documentada, grande parte da nossa API REST não exige autenticação e está disponível para utilização pelo público em geral. De especial interesse para o público em geral, e já em utilização por alguns projectos de terceiros, temos o estado das linhas e histórico de perturbações, incluindo perturbações comunicadas pela comunidade UnderLX. Pode tentar perceber como a utilizar através do código da aplicação que comunica com ela, através do código do servidor, ou perdendo a vergonha e fazendo perguntas no nosso servidor de Discord.