Na Slash, movimentamos bilhões de dólares em gastos anualmente. Tendo crescido mais de 1000% nos últimos 12 meses, estamos constantemente resolvendo novos problemas em escala cada vez maior. À medida que a quantidade de dados crescia e nosso sistema se tornava mais complexo, as suposições que fazíamos sobre as relações entre os dados frequentemente se revelavam incorretas. Encontramos problemas de várias origens: preenchimentos incorretos, bugs no código de produção e pequenos erros durante as migrações de dados. Com o tempo, ficou cada vez mais difícil verificar a exatidão dos nossos dados, o que nos levou a criar o que hoje chamamos de Health Checks — um sistema projetado para verificar continuamente a exatidão dos dados em produção.
As verificações de integridade são uma ferramenta que nos ajuda a verificar invariantes em nossa base de código. Quando criamos sistemas, sempre os projetamos com um conjunto de invariantes implícitas e explícitas, que são regras/suposições que esperamos que sejam sempre verdadeiras, para que possamos construir sistemas mais complexos com base nelas.
Um exemplo trivial de uma invariante: noCartãotabela em nosso banco de dados, onde cada linha representa um cartão de crédito, há umestadocoluna em que um dos valores pode ser “fechado” e ummotivo do encerramentocoluna que é NULLABLE. Uma invariante é que oestadocoluna é “fechada” **se e somente se** omotivo do encerramentoO campo NÃO É NULO.
O exemplo acima não é necessariamente uma condição para a qual escreveríamos uma verificação de integridade, mas é um exemplo de uma invariante que garantimos que seja verdadeira ao trabalhar com essa área do código-fonte.
Nosso projeto inicial de avaliação de saúde
Nossa primeira iteração de verificações de saúde ficou mais ou menos assim:
Inicialmente, projetamos verificações de integridade para serem definidas por consultas SQL. Executávamos uma consulta SQL em uma tabela para encontrar quaisquer “erros potenciais”. Mas, como executar consultas SQL em tabelas grandes é caro, executávamos essas verificações em uma réplica de leitura eventualmente consistente (no nosso caso, era o Snowflake). Para cada resultado retornado da consulta, executávamos uma verificação em nosso banco de dados de produção principal para garantir que não fosse um falso positivo. Esse projeto tinha alguns problemas:
- Tivemos que manter duas consultas. A primeira consulta seria executada na réplica de leitura para toda a tabela. Os mantenedores também teriam que pensar explicitamente e lidar com a paginação da consulta. A segunda consulta seria executada na produção para cada resultado individual que a primeira consulta retornaria.
- As consultas em toda a tabela se tornariam cada vez mais complexas e ilegíveis/difíceis de manter, pois estaríamos tentando codificar várias lógicas de negócios em uma única consulta SQL.
Segunda iteração das verificações de saúde
Decidimos:
- As verificações de integridade devem sempre ser executadas em nosso banco de dados de produção primário. Caso contrário, podemos não detectar todos os problemas que realmente ocorrem.
- Realizar “varreduras completas da tabela” em uma consulta SQL é totalmente desaconselhável, mas realizar uma “varredura completa da tabela” controlada, iterando sobre cada linha e realizando uma verificação de integridade, é aceitável — desde que a carga seja constante, previsível e não apresente picos, tudo tende a ficar bem.
- Realizar uma iteração ao longo do tempo nas tabelas em produção (e abstrair isso para o desenvolvedor) simplifica muito nossa experiência de desenvolvedor:
- Não precisamos mais manter duas consultas SQL complexas com paginação. Precisamos apenas definir a lógica da aplicação para verificar a integridade de um único item.
- Cada verificação de integridade é definida em toda uma tabela (no futuro, podemos optar por estender as verificações de integridade para que possam iterar especificamente sobre uma definição de índice).
Uma grande lição que aprendemos como equipe ao longo do tempo é que sistemas previsíveis que exercem carga constante são ideais. O maior culpado pelos problemas de produção tem sido, normalmente, mudanças repentinas e imprevisíveis, como um grande pico na carga do banco de dados ou uma mudança repentina no planejador de consultas interno do banco de dados.
O aspecto contraintuitivo de colocar carga constante nos sistemas, especialmente no que diz respeito a bancos de dados e filas, é que isso pode parecer um desperdício. Eu costumava acreditar que era melhor não colocar carga nos sistemas por padrão e só trabalhar algumas vezes por dia, quando necessário. No entanto, isso pode levar a picos de carga de trabalho que, às vezes, degradariam nosso sistema de forma inesperada. Na verdade, descobrimos que geralmente é melhor ter uma pequena carga constante sendo executada no banco de dados por um longo período, mesmo que essa carga constante contribua com algo como 1 a 5% do uso total da CPU 24 horas por dia, 7 dias por semana. Quando a carga é previsível, é fácil monitorar as taxas de uso em toda a linha. Essa previsibilidade nos permite escalar horizontalmente com confiança e planejar com antecedência possíveis problemas de desempenho futuros.
Há uma leitura perspicaz sobre isso pela equipe da AWS: Confiabilidade, trabalho constante e uma boa xícara de café
A segunda versão das verificações de saúde agora se parece com isto:
Com esse design, fica muito fácil adicionar novas verificações de integridade a uma única entidade, pois cada verificação é definida como uma função única assíncrona. Poderíamos ter essas verificações de integridade iterando sobre tabelas inteiras várias vezes ao dia sem precisar fazer uma compensação entre a frequência, já que a carga é constante. Para a verificação de integridade acima, cada entidade realiza uma pesquisa rápida na tabela `LimitRule`. Essas verificações são executadas continuamente, colocando uma carga mínima, mas constante, no banco de dados em qualquer momento.
O funcionamento interno deste sistema pode ser simplificado para o seguinte design:
- Executamos nossas verificações no Temporal para garantir a execução final. Usamos o produto “agendas” do Temporal para garantir que nosso cron seja executado a cada minuto.
- A cada minuto, analisamos todas as verificações que devem ser agendadas IMEDIATAMENTE e as executamos em uma fila de tarefas. Controlamos nossa fila de tarefas de forma que nunca haja mais do que um determinado RPS sendo enviado para nosso banco de dados.
- Cada atividade executada realiza algum tipo de trabalho “constante”. Não há O(N) ou outra complexidade de tempo que cresça em relação ao número total de entidades no banco de dados. Isso garante que as atividades sejam executadas rapidamente, sejam fáceis de prever e não bloqueiem a própria fila de tarefas.
O que vem a seguir
Atualmente, as verificações de integridade constituem uma base importante em nosso pipeline de testes. Elas são o alicerce dos nossos testes contínuos de verificação da produção. À medida que crescemos, foram necessários cada vez mais testes gerais para manter nosso produto estável e funcional. Foram necessárias várias tentativas para acertarmos. Nos últimos três anos, queríamos implementar alguma forma de teste de registro/verificação de dados na produção. No entanto, foi somente há um ano e meio que finalmente conseguimos criar algo. No geral, algumas lições importantes que aprendemos são:
- Envie algo, qualquer coisa, e repita isso ao longo do tempo. Não teríamos chegado à segunda iteração de nossas verificações de saúde sem ter enviado a primeira.
- Mantenha a simplicidade — queremos facilitar ao máximo a criação e a manutenção de verificações de integridade pelos engenheiros. Nunca é agradável trabalhar com lógicas de negócios complexas incorporadas em várias consultas SQL.
- Executamos nossas verificações diretamente na produção — essa é nossa fonte de verdade, e se estivermos executando as coisas em relação a outra coisa, isso será inerentemente mais complexo e mais suscetível a falsos positivos/negativos.
Hoje, as verificações de integridade são o primeiro passo que demos para testar e verificar melhor nossos sistemas, além das formas mais tradicionais, como testes e observabilidade. À medida que continuamos a crescer, vamos refletir e descobrir novas maneiras de manter nosso produto estável.