Como implantar refatoração perigosa para produzir com um milhão de usuários?


O filme "Avião", 1980.

Foi assim que me senti quando joguei outra refatoração no prod. Mesmo se você cobrir todo o código com métricas e logs, teste a funcionalidade em todos os ambientes - isso não economizará 100% dos fakaps após a implantação.

First Fakap


De alguma forma, refatoramos nosso processamento de integração com o Planilhas Google. Para os usuários, esse é um recurso muito valioso, porque eles usam muitas ferramentas ao mesmo tempo que precisam ser vinculadas - envie contatos para uma tabela, envie respostas a perguntas, exporte usuários etc.

O código de integração não foi refatorado da primeira versão e ficou cada vez mais difícil de manter. Isso começou a afetar nossos usuários - foram revelados erros antigos que tínhamos medo de editar devido à complexidade do código. É hora de fazer algo sobre isso. Nenhuma mudança lógica era suposta - basta escrever testes, mover classes e combinar nomes. Obviamente, testamos a funcionalidade no ambiente de desenvolvimento e fomos implantados.

Após 20 minutos, os usuários escreveram que a integração não funcionou. A funcionalidade de envio de dados para o Google Sheet caiu - e, para depuração, enviamos dados em diferentes formatos para vendas e ambientes locais. Ao refatorar, atingimos o formato de venda.

Consertamos a integração, mas, mesmo assim, o sedimento da alegre noite de sexta-feira (e você pensou!) Permaneceu. Em retrospecto (conhecendo a equipe para concluir o sprint), começamos a pensar em como evitar essas situações no futuro - precisamos melhorar a prática de testes manuais, testes automáticos, trabalhar com métricas e alarmes e, além disso, tivemos a ideia de usar sinalizadores de recursos para testar a refatoração de fato, isso será discutido.

Implementação


O esquema é simples: se o usuário tiver o sinalizador ativado, vá para o código com a nova versão, se não, para o código com a versão antiga:

if ($user->hasFeature(UserFeatures::FEATURE_1)) {
  // new version
} else {
  // old version
}

Com essa abordagem, temos a oportunidade de testar a refatoração do prod primeiro em nós mesmos e depois despejá-la nos usuários.

Quase desde o início do projeto, tivemos uma implementação primitiva do recurso flags. No banco de dados para duas entidades básicas, usuário e conta, foram adicionados campos de recursos, que eram uma máscara de bits . No código, registramos novas constantes para os recursos, que adicionamos à máscara se um recurso específico estiver disponível para o usuário.

public const ALLOW_FEATURE_1 = 0b0000001;
public const ALLOW_FEATURE_2 = 0b0000010;
public const ALLOW_FEATURE_3 = 0b0000100;

O uso no código ficou assim:

If ($user->hasFeature(UserFeatures::ALLOW_FEATURE_1)) {
  // feature 1 logic
}

Ao refatorar, geralmente abrimos o sinalizador para a equipe para testes, depois para vários usuários que usam ativamente o recurso e, finalmente, abertos a todos, mas às vezes esquemas mais complexos aparecem, mais sobre eles abaixo.

Refatoração de local sobrecarregado


Um de nossos sistemas aceita webhooks do Facebook e os processa através da fila. O processamento da fila parou de lidar e os usuários começaram a receber determinadas mensagens com um atraso, o que poderia afetar criticamente a experiência dos assinantes de bots. Começamos a refatorar esse local transferindo o processamento para um esquema de filas mais complexo. O local é crítico - é perigoso derramar nova lógica em todos os servidores, por isso fechamos a nova lógica sob a bandeira e pudemos testá-la no produto. Mas o que acontece quando abrimos essa bandeira? Como se comportará nossa infraestrutura? Desta vez, implantamos a abertura da bandeira nos servidores e seguimos as métricas.

Todo o processamento de dados críticos foi dividido em clusters. Cada cluster tem um ID. Decidimos simplificar o teste dessa refatoração complexa, abrindo o recurso de sinalizador apenas em determinados servidores; a verificação no código é assim:

If ($user->hasFeature(UserFeatures::CGT_REFACTORING) ||
    \in_array($cluster, Configurator::get('cgt_refactoring_cluster_ids'))) {
  // new version
} else {
  // old version
}

Primeiro, servimos refatoração e abrimos as bandeiras para a equipe. Em seguida, encontramos vários usuários que usavam ativamente o recurso cgt, abrimos sinalizadores para eles e procuramos ver se tudo funcionava para eles. E, finalmente, eles começaram a abrir sinalizadores nos servidores e seguir as métricas.

O sinalizador cgt_refactoring_cluster_ids pode ser alterado através do painel de administração. Inicialmente, atribuímos o valor cgt_refactoring_cluster_ids a uma matriz vazia, depois adicionamos um cluster por vez - [1], observamos as métricas por um tempo e adicionamos outro cluster - [1, 2] até testar todo o sistema.

Implementação do configurador


Falarei um pouco sobre o que é o Configurator e como ele é implementado. Foi escrito para poder alterar a lógica sem implantação, por exemplo, como no caso acima, quando precisamos reverter drasticamente a lógica. Também o usamos para configurações dinâmicas, por exemplo, quando você precisa testar diferentes tempos de armazenamento em cache, pode executá-lo para testes rápidos. Para o desenvolvedor, isso parece uma lista de campos com valores de administrador que podem ser alterados. Armazenamos tudo isso em um banco de dados, armazenamos em cache em Redis e em estática para nossos trabalhadores.

Refatorando locais desatualizados


No próximo trimestre, refatoramos a lógica de registro, preparando-a para a transição para a possibilidade de registro através de vários serviços. Em nossas condições, é impossível agrupar a lógica de registro para que um determinado usuário esteja vinculado a uma certa lógica, e não criamos nada melhor do que testar a lógica, lançando uma porcentagem de todas as solicitações de registro. Isso é fácil de fazer de maneira semelhante com sinalizadores:

If (Configurator::get('auth_refactoring_percentage') > \random_int(0, 99)) {
  // new version
} else {
  // old version
}

Dessa forma, definimos o valor de auth_refactoring_percentage no painel do administrador de 0 a 100. É claro que “manchamos” toda a lógica de autorização com métricas para entender que não reduzimos a conversão no final.

Métricas


Para dizer como seguimos as métricas no processo de abertura de sinalizadores, consideraremos outro caso com mais detalhes. ManyChat aceita ganchos do Facebook quando um assinante envia uma mensagem para o Facebook Messenger. Devemos processar cada mensagem de acordo com a lógica de negócios. Para o recurso cgt, precisamos determinar se o assinante iniciou a conversa através de um comentário no Facebook para enviar a ele uma mensagem relevante em resposta. No código, parece determinar o contexto do assinante atual; se podemos determinar o widgetId, determinamos a mensagem de resposta a partir dele.

Mais sobre o recurso
Facebook api. — . Widget, :

—> —> —> Facebook:



:
—> —>



“ , !” , . , “ !” id , — , id.

Anteriormente, definimos o contexto de três maneiras: era algo assim:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //      
  if (null !== $user->gt_widget_id_context) {
    $watcher->logTick('cgt_match_processor_matched_via_context');

    return $user->gt_widget_id_context;
  }

  //      
  if (null !== $user->name) {
    $widgetId = $this->cgtMatchByThread($user);
    if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_thread');

      return $widgetId;
    }

    $widgetId = $this->cgtMatchByConversation($user);
    if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_conversation');

      return $widgetId;
    }
  }

  return null;
}

O serviço inspetor envia análises no momento da partida, respectivamente, tínhamos métricas para os três casos: o


número de vezes que o contexto foi encontrado por diferentes métodos de vinculação no tempo.Em

seguida, encontramos outro método de correspondência que deve substituir todas as opções antigas. Para testar isso, temos outra métrica:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //    
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_echo_message');
  }

  //    
  // ...
}

Nesse estágio, queremos garantir que o número de novas ocorrências seja igual à soma das ocorrências antigas, então escreva a métrica sem retornar $ widgetId: O


número de contextos encontrados pelo novo método cobre completamente a soma das ligações dos métodos antigos.

Mas isso não garante a lógica de correspondência correta em todos os casos. O próximo passo é o teste gradual através da abertura de sinalizadores:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //    
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
    $watcher->logTick('cgt_match_processor_matched_by_echo_message');
  
    //    ,   
    If ($this->allowMatchingByEcho($user)) {
      return $widgetId;
    }
  }

  // ...
}

function allowMatchingByEcho(User $user): bool
{
  //    
  If ($user->hasFeature(UserFeatures::ALLOW_CGT_MATCHING_BY_ECHO)) {
    return true;
  }
  //     
  If (\in_array($this->clusterId, Configurator::get('cgt_matching_by_echo_cluster_ids'))) {
    return true;
  }

  return false;
}

Em seguida, o processo de teste começou: a princípio, testamos a nova funcionalidade por conta própria em todos os ambientes e em usuários aleatórios que costumam usar a correspondência abrindo a bandeira UserFeatures :: ALLOW_CGT_MATCHING_BY_ECHO. Nesta fase, capturamos alguns casos em que a partida funcionou incorretamente e os reparamos. Então eles começaram a implantar nos servidores: em média, implantamos um servidor em um dia durante a semana. Antes do teste, alertamos o suporte para que eles analisem atentamente os tickets relacionados à funcionalidade e nos escrevam sobre quaisquer esquisitices. Graças ao suporte e aos usuários, vários casos de canto foram corrigidos. E, finalmente, o último passo é abrir tudo sem condições:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
    $watcher->logTick('cgt_match_processor_matched_by_echo_message');
  
    return $widgetId;
  }

  return null;
}

Implementação do novo recurso de sinalizador


A implementação do recurso de sinalizador descrita no início do artigo nos serviu por cerca de 3 anos, mas com o crescimento das equipes ficou desconfortável - tivemos que implantar ao criar cada sinalizador e não esquecemos de limpar o valor dos sinalizadores (reutilizamos valores constantes para diferentes recursos). Recentemente, o componente foi reescrito e agora podemos gerenciar sinalizadores de forma flexível através do painel de administração. Os sinalizadores foram desatados da máscara de bits e armazenados em uma tabela separada - isso facilita a criação de novos sinalizadores. Cada entrada também tem uma descrição e proprietário, o gerenciamento de sinalização se tornou mais transparente.

Contras de tais abordagens


Essa abordagem tem muito menos - há duas versões do código e elas precisam ser suportadas ao mesmo tempo. Ao testar, você deve considerar que existem dois ramos da lógica e precisa verificar todos eles, e isso é muito doloroso. Durante o desenvolvimento, houve situações em que introduzimos uma correção em uma lógica, mas esquecemos de outra e, em algum momento, ela disparou. Portanto, aplicamos essa abordagem apenas em locais críticos e tentamos nos livrar da versão antiga do código o mais rápido possível. Tentamos fazer o restante da refatoração em pequenas iterações.

Total


O processo atual se parece com isso - primeiro fechamos a lógica sob a condição dos sinalizadores, depois implantamos e começamos a abrir gradualmente os sinalizadores. Ao expandir sinalizadores, monitoramos de perto os erros e as métricas, assim que algo der errado - reverta imediatamente o sinalizador e lide com o problema. A vantagem é que abrir / fechar a bandeira é muito rápido - é apenas uma alteração no valor no painel de administração. Depois de algum tempo, cortamos a versão antiga do código, que deve ser o tempo mínimo para evitar alterações nas duas versões do código. É importante alertar os colegas sobre essa refatoração. Fazemos uma revisão através do github e usamos proprietários de código durante essa refatoração, para que as alterações não entrem no código sem o conhecimento do autor da refatoração.

Mais recentemente, implantei uma nova versão da API do Facebook Graph. Em um segundo, fazemos mais de 3000 solicitações à API e qualquer erro é caro para nós. Portanto, implantei a alteração com o mínimo impacto - ela detectou um bug desagradável, testou a nova versão e, por fim, mudou completamente para ela sem preocupações.

All Articles