Papers
Online migrations at scale
Jacqueline Xu · Stripe Engineering Blog, 2017
O problema: mudar o modelo de dados sem tirar o sistema do ar
O artigo parte de uma situação comum: com o crescimento do produto, o modelo de dados deixa de servir e o time precisa redesenhá-lo para ter abstrações mais limpas e funcionalidades mais complexas. Em produção, isso pode significar migrar milhões de objetos ativos e refatorar milhares de linhas de código. A autora conta como a Stripe migrou suas centenas de milhões de objetos Subscriptions. O modelo havia evoluído por etapas. No início, cada Customer tinha no máximo uma assinatura, guardada junto do cliente. Depois, o campo único virou um array de assinaturas ativas dentro do Customer. Com novas funcionalidades, isso se tornou um problema: qualquer mudança em uma assinatura exigia atualizar o registro inteiro do Customer, e consultas sobre assinaturas varriam objetos de clientes. Por isso as assinaturas ativas passaram a ficar em uma tabela própria. O artigo aponta três dificuldades. Escala: se cada objeto levasse um segundo para migrar, copiar cem milhões de objetos em sequência levaria mais de três anos. Disponibilidade: a Stripe faz suas atualizações de infraestrutura online, sem janelas de manutenção, então o serviço de assinaturas não podia ser pausado. Precisão: a tabela é usada em muitos pontos do código, e alterar milhares de linhas de uma vez quase certamente deixaria casos de borda passarem; todo serviço precisava continuar confiando em dados corretos.
Passo 1: escrita dupla e backfill dos dados antigos
O artigo descreve um padrão comum de quatro passos para migrações online grandes, e o primeiro é a escrita dupla: toda nova escrita atualiza o armazenamento antigo e o novo, mantendo os dois sincronizados. Na Stripe, as assinaturas recém-criadas passaram a ser gravadas tanto na tabela Customers quanto na nova tabela Subscriptions. Como a escrita extra pode pesar no banco de produção, a porcentagem de objetos duplicados é aumentada aos poucos, com as métricas operacionais sendo observadas de perto. Nesse ponto, objetos novos existem nas duas tabelas e os antigos só na antiga. A cópia dos existentes começou de forma preguiçosa: sempre que um objeto era atualizado, ele era copiado para a tabela nova. Por fim, um backfill copiou as assinaturas restantes. A parte mais cara era simplesmente encontrar os objetos que ainda precisavam migrar, pois isso exigiria muitas consultas ao banco de produção. A Stripe delegou essa busca a um processo offline: snapshots dos bancos ficam disponíveis no cluster Hadoop, onde MapReduce, gerenciado com a biblioteca Scalding, gerou a lista de IDs de assinaturas a copiar. Uma frota multi-thread fez a cópia em paralelo e, ao terminar, o mesmo job foi executado de novo para confirmar que nenhuma assinatura existente estava faltando na tabela nova.
Passo 2: mudar os caminhos de leitura com experimentos
Com as duas tabelas em sincronia, o segundo passo é passar a ler da tabela nova, mas antes é preciso ter certeza de que os dados são consistentes. A Stripe usou o Scientist, uma biblioteca Ruby do GitHub que permite rodar experimentos comparando o resultado de dois caminhos de código e alertando quando eles divergem em produção. O experimento leu das duas tabelas, Subscriptions e Customers, e comparou os resultados; se não batessem, um erro alertava os engenheiros sobre a inconsistência. O Scientist gera alertas e métricas em tempo real, e se o caminho experimental produzir um erro, o restante da aplicação não é afetado. Só depois de verificar que tudo coincidia a leitura passou a vir da tabela nova. A lógica dessa etapa é transformar a migração em algo observável: em vez de supor que a cópia está correta, o time compara o comportamento real em produção e recebe aviso assim que um dado diverge. Isso dá confiança antes de dar o passo seguinte, que envolve alterar as escritas. O artigo resume esse ganho como transparência e observabilidade: os experimentos do Scientist avisaram assim que um único dado ficou inconsistente em produção, e a cada etapa a confiança na migração aumentava.
Passos 3 e 4: mudar as escritas e remover os dados antigos
O terceiro passo inverte a ordem das escritas. Até então os dados eram gravados no armazenamento antigo e copiados para o novo; agora passam a ser gravados primeiro no novo e depois arquivados no antigo, mantendo os dois consistentes para que cada mudança incremental possa ser observada. O artigo chama a refatoração dos caminhos que alteram assinaturas de talvez a parte mais difícil: a lógica de atualizações, proporcionalizações (prorations) e renovações se espalha por milhares de linhas em vários serviços. A chave foi o processo incremental, isolando cada caminho de código na menor unidade possível, sem simplesmente trocar registros novos por antigos, e usando mais experimentos do Scientist para detectar inconsistências. Para garantir que nenhum código continuasse usando o array antigo, o acesso a essa propriedade do Customer foi alterado para lançar um erro grave. O quarto passo é a limpeza: quando nenhum código depende mais do campo antigo, deixa-se de escrever no armazenamento antigo e a tabela nova vira a fonte de verdade. O array de assinaturas dos objetos Customer foi removido aos poucos, esvaziando-o a cada vez que uma assinatura era carregada e rodando, no fim, um último job Scalding e uma migração para achar o que sobrou. Segundo o artigo, as mudanças nunca passaram de poucas centenas de linhas de código por vez.
Veja as ideias em ação
Pratique na escola de System Design os desenhos que usam este paper.