"Excluindo" objetos no Django



Mais cedo ou mais tarde, os desenvolvedores enfrentam a tarefa de remover dados desnecessários. E quanto mais complicado o serviço, mais nuances você precisa considerar. Neste artigo, descreverei como implementamos a "exclusão" em um banco de dados com centenas de links.

Antecedentes


Para monitorar a operacionalidade da maioria dos projetos, o Mail.ru Group e o VKontakte usam um serviço de seu próprio desenvolvimento - o Monitoring . Começando sua história a partir do final de 2012, ao longo de 6 anos, o projeto se transformou em um enorme sistema, que ganhou muitas funcionalidades. O monitoramento verifica regularmente a disponibilidade dos servidores e a exatidão das respostas às solicitações, coleta estatísticas sobre a memória usada, a utilização da CPU, etc. Quando os parâmetros do servidor monitorado excedem os valores permitidos, os responsáveis ​​pelo servidor recebem notificações no sistema e por SMS.

Todas as verificações e incidentes são registrados para rastrear a dinâmica do desempenho do servidor; portanto, o banco de dados atingiu a ordem de centenas de milhões de registros. Novos servidores aparecem periodicamente e os antigos deixam de ser usados. As informações sobre servidores não utilizados devem ser excluídas do sistema de Monitoramento para: a) não sobrecarregar a interface com informações desnecessárias eb) liberar identificadores exclusivos .

Excluir


Eu, conscientemente, no cabeçalho do artigo, a palavra "excluir" escrevia entre aspas. Existem várias maneiras de remover um objeto do sistema:

  • completamente excluído do banco de dados;
  • marcando objetos como excluídos e ocultando a interface. Como marcador, você pode usar Boolean ou DateTime para um registro mais preciso.

Iteração # 1


Inicialmente, a primeira abordagem foi usada, quando simplesmente executamos object.delete() e o objeto foi excluído com todas as dependências. Porém, com o tempo, tivemos que abandonar essa abordagem, pois um objeto poderia ter dependências com milhões de outros objetos e a exclusão em cascata de tabelas rigidamente bloqueadas. E como o serviço executa milhares de verificações a cada segundo e as registra, o bloqueio das tabelas levou a uma grave desaceleração no serviço, o que era inaceitável para nós.

Iteração 2


Para evitar bloqueios longos, decidimos excluir os dados em lotes. Isso permitiria gravar dados reais de monitoramento nos intervalos entre exclusões de objetos. Uma lista de todos os objetos que serão excluídos em cascata pode ser obtida pelo método usado no painel de administração ao excluir um objeto (ao confirmar a exclusão):

 from django.contrib.admin.util import NestedObjects from django.db import DEFAULT_DB_ALIAS collector = NestedObjects(using=DEFAULT_DB_ALIAS) collector.collect([obj]) objects_to_delete = collector.nested() # Recursive delete objects 

A situação melhorou: a carga foi distribuída ao longo do tempo, novos dados começaram a ser registrados mais rapidamente. Mas imediatamente corremos para a próxima armadilha. O fato é que a lista de objetos a serem excluídos é formada no início da exclusão e, se novos objetos dependentes forem adicionados no processo de exclusão "em porções", o elemento pai não poderá ser excluído.

Abandonamos imediatamente a idéia de um erro na exclusão recursiva para coletar novamente dados sobre novas dependências ou proibimos a adição de registros dependentes ao excluir, porque a) você pode entrar em um loop infinito ou b) precisa encontrar todos os objetos dependentes em todo o código .

Iteração nº 3


Pensamos no segundo tipo de exclusão, quando os dados são marcados e ocultos da interface. Inicialmente, essa abordagem foi rejeitada, porque parecia uma tarefa por pelo menos uma semana encontrar todas as consultas e adicionar um filtro para a ausência de um pai excluído. Além disso, havia uma alta probabilidade de perder o código necessário, o que levaria a consequências imprevisíveis.

Decidimos usar decoradores para substituir o gerenciador de consultas. Além disso, é melhor ver o código do que escrever cem palavras.

 def exclude_objects_for_deleted_hosts(*fields): """ Decorator that adds .exclude({field__}is_deleted=True) for model_class.objects.get_queryset :param fields: fields for exclude condition """ def wrapper(model_class): def apply_filters(qs): for field in filter_fields: qs = qs.exclude(**{ '{}is_deleted'.format('{}__'.format(field) if field else ''): True, }) return qs model_class.all_objects = copy.deepcopy(model_class.objects) filter_fields = set(fields) get_queryset = model_class.objects.get_queryset model_class.objects.get_queryset = lambda: apply_filters(get_queryset()) # save info about model decorator setattr(model_class, DECORATOR_DEL_HOST_ATTRIBUTE, filter_fields) return model_class return wrapper 

O exclude_objects_for_deleted_hosts(fields) para os campos especificados do modelo de campos adiciona automaticamente um filtro de exclude para cada solicitação, o que apenas remove entradas que não devem ser exibidas na interface.

Agora é suficiente para todos os modelos que serão afetados de alguma forma pela exclusão adicionar um decorador:

 @exclude_objects_for_deleted_hosts('host') class Alias(models.Model): host = models.ForeignKey(to=Host, verbose_name='Host', related_name='alias') 

Agora, para remover o objeto Host , basta alterar o atributo is_deleted :

 host.is_deleted = True # after this save the host and all related objects will be inaccessible host.save() 

Todas as consultas excluirão automaticamente os registros que fazem referência a objetos remotos:

 # model decorator @exclude_objects_for_deleted_hosts('checker__monhost', 'alias__host') CheckerToAlias.objects.filter( alias__hostname__in=['cloud.spb.s', 'cloud.msk.s'] ).values('id') 

Acontece a seguinte consulta SQL:

 SELECT monitoring_checkertoalias.id FROM monitoring_checkertoalias INNER JOIN monitoring_checker ON (`monitoring_checkertoalias`.`checker_id` = monitoring_checker.`id`) INNER JOIN Hosts ON (`monitoring_checker`.`monhost_id` = Hosts.`id`) INNER JOIN dcmap_alias ON (`monitoring_checkertoalias`.`alias_id` = dcmap_alias.`id`) INNER JOIN Hosts T5 ON (`dcmap_alias`.`host_id` = T5.`id`) WHERE ( NOT (`Hosts`.`is_deleted` = TRUE) -- ,   monitoring_checker AND NOT (T5.`is_deleted` = TRUE) -- ,   dcmap_alias AND dcmap_alias.name IN ('dir1.server.p', 'dir2.server.p') ); 

Como você pode ver, junções adicionais para os campos especificados no decorador e verificações de `is_deleted` = TRUE adicionadas à solicitação.

Um pouco sobre números


É lógico que junções e condições adicionais aumentem o tempo de execução da consulta. O estudo desta questão mostrou que o grau de “complicação” depende da estrutura do banco de dados, do número de registros e da presença de índices.

Especificamente, no nosso caso, para cada nível de dependência, a solicitação é multada em cerca de 30%. Essa é a penalidade máxima que temos na maior mesa com milhões de registros; em tabelas menores, a penalidade é reduzida para alguns por cento. Felizmente, temos os índices necessários configurados e, para a maioria das consultas críticas, as junções necessárias já estavam lá, portanto, não sentimos uma grande diferença no desempenho.

Identificadores exclusivos


Antes de excluir dados, pode ser necessário liberar identificadores planejados para serem usados ​​no futuro, pois isso pode gerar um erro não exclusivo ao criar um novo objeto. Apesar do fato de que nenhum objeto será visível no aplicativo Django, eles ainda estarão no banco de dados. Portanto, para objetos excluídos, anexamos uuid ao identificador.

 host.hostname = '{}_{}'.format(host.hostname, uuid.uuid4()) host.is_deleted = True host.save() 

Operação


Para cada novo modelo ou dependência, o decorador precisa ser atualizado, se necessário. Para simplificar a busca por modelos dependentes, escrevemos um teste "inteligente":

 def test_deleted_host_decorator_for_models(self): def recursive_host_finder(model, cache, path, filters): # cache for skipping looked models cache.add(model) # process all related models for field in (f for f in model._meta.fields if isinstance(f, ForeignKey)): if field.related_model == Host: filters.add(path + field.name) elif field.related_model not in cache: recursive_host_finder(field.related_model, cache.copy(), path + field.name + '__', filters) # check all models for current_model in apps.get_models(): model_filters = getattr(current_model, DECORATOR_DEL_HOST_ATTRIBUTE, set()) found_filters = set() if current_model == Host: found_filters.add('') else: recursive_host_finder(current_model, set(), '', found_filters) if found_filters or model_filters: try: self.assertSetEqual(model_filters, found_filters) except AssertionError as err: err.args = ( '{}\n !!! Fix decorator "exclude_objects_for_deleted_hosts" ' 'for model {}'.format(err.args[0], current_model), ) raise err 

O teste verifica recursivamente todos os modelos quanto à presença de uma dependência no modelo a ser excluído e, em seguida, verifica se o decorador dos campos obrigatórios foi definido para esse modelo. Se algo estiver faltando, o teste indicará delicadamente onde adicionar o decorador.

Epílogo


Assim, com a ajuda de um decorador, foi possível implementar uma “pequena exclusão” de dados que possui um grande número de dependências. Todas as solicitações recebem automaticamente o filtro de exclude necessário. A imposição de condições adicionais retarda o processo de obtenção de dados, o grau de “complicação” depende da estrutura do banco de dados, do número de registros e da disponibilidade de índices. O teste proposto informará quais modelos você precisa adicionar decoradores e, no futuro, monitorará sua consistência.

Source: https://habr.com/ru/post/pt438280/


All Articles