company_banner

«Удаление» объектов в Django



    Рано или поздно перед разработчиками встаёт задача удаления ненужных данных. И чем сложнее сервис, тем больше нюансов необходимо учесть. В данной статье я расскажу, как мы реализовали «удаление» в базе данных с сотней связей.

    Предыстория


    Для контроля работоспособности большинства проектов Mail.ru Group и ВКонтакте используется сервис собственной разработки — Monitoring. Начав свою историю с конца 2012 года, за 6 лет проект вырос в огромную систему, которая обросла большим количеством функциональности. Monitoring регулярно проверяет доступность серверов и корректность ответов на запросы, собирает статистику по используемой памяти, загрузке процессоров и т.д. Когда параметры контролируемого сервера выходят за допустимые значения, ответственные за сервер получают уведомления в системе и по SMS.

    Все проверки и инциденты журналируются для отслеживания динамики характеристик серверов, поэтому объем базы данных достиг порядка сотни миллионов записей. Периодически появляются новые серверы, а старые перестают использоваться. Информацию о неиспользуемых серверах необходимо удалить из системы Monitoring, чтобы: а) не перегружать интерфейс лишней информацией, и б) освободить уникальные идентификаторы.

    Удаление


    Я не зря в заголовке статьи слово «удаление» написал в кавычках. Убрать объект из системы можно несколькими способами:

    • полностью удалив из базы данных;
    • пометив объекты как удалённые и скрыв из интерфейса. В качестве маркера можно использовать Boolean, или DateTime для более точного журналирования.

    Итерация #1


    Изначально использовался первый подход, когда мы просто выполняли object.delete() и объект удалялся со всеми зависимостями. Но со временем нам пришлось отказаться от такого подхода, так как один объект мог иметь зависимости с миллионами других объектов, и каскадное удаление жёстко блокировало таблицы. А так как сервис каждую секунду выполняет по тысяче проверок и журналирует их, то блокировка таблиц приводила к серьёзному замедлению сервиса, что было недопустимо для нас.

    Итерация #2


    Для избежания долгих блокировок мы решили удалять данные порциями. Это позволило бы в промежутки времени между удалениями объектов записывать актуальные данные мониторинга. Список всех объектов, которые будут удалены каскадно, можно получить методом, который применяется в панели администратора при удалении объекта (при подтверждении удаления):

    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

    Ситуация улучшилась: нагрузка распределилась по времени, новые данные стали записываться быстрее. Но мы сразу же натолкнулись на следующий подводный камень. Дело в том, что список удаляемых объектов формируется в самом начале удаления, и если в процессе «порционного» удаления добавляются новые зависимые объекты, то родительский элемент не может быть удалён.

    Мы сразу отказались от идеи при ошибке в рекурсивном удалении снова собирать данные о новых зависимостях или запрещать добавлять зависимые записи при удалении, потому что а) можно уйти в бесконечный цикл или б) придётся найти по всему коду добавления всех зависимых объектов.

    Итерация #3


    Мы задумались о втором типе удаления, когда данные маркируются и скрываются из интерфейса. Изначально этот подход был отвергнут, потому что найти все запросы и добавить фильтр на отсутствие удаленного родительского элемента представлялось задачей, как минимум, на неделю. К тому же была высока вероятность пропустить нужный код, что привело бы к непредсказуемым последствиям.

    Тогда мы решили использовать декораторы для переопределения менеджера запросов. Далее лучше увидеть код, чем писать сотню слов.

    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
    

    Декоратор exclude_objects_for_deleted_hosts(fields) для указанных полей модели fields автоматически для каждого запроса добавляет фильтр exclude, который как раз убирает записи, которые не должны отображаться в интерфейсе.

    Теперь достаточно для всех моделей, на которые каким-либо образом повлияет удаление, добавить декоратор:

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

    Теперь для того, чтобы удалить объект Host, достаточно изменить атрибут is_deleted:

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

    Все запросы будут автоматически исключать записи, ссылающиеся на удаленные объекты:

    # 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')
    

    Получается такой 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')
    );

    Как видно, в запросе добавлены дополнительные join'ы для указанных в декораторе полей и проверки `is_deleted` = TRUE.

    Немного о цифрах


    Логично, что дополнительные join'ы и условия увеличивают время выполнения запроса. Исследование этого вопроса показало, что степень «осложнения» зависит от структуры БД, количества записей и наличия индексов.

    Конкретно в нашем случае за каждый уровень зависимости запрос штрафуется примерно на 30 %. Это максимальный штраф, которой мы получаем на самой большой таблице с миллионами записей, на таблицах поменьше штраф снижается до нескольких процентов. Благо, у нас настроены необходимые индексы, а для большинства критичных запросов необходимые join'ы уже были, поэтому большой разницы в производительности мы не ощутили.

    Уникальные идентификаторы


    Перед тем, как удалить данные, возможно, потребуется освободить идентификаторы, которые планируется использовать в будущем, потому что это может породить ошибку неуникального значения при создании нового объекта. Несмотря на то, что в Django-приложении не будет видно удалённых объектов, они всё равно будут находиться в базе данных. Поэтому для удаляемых объектов к идентификатору мы дописывает uuid.

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

    Эксплуатация


    Для каждой новой модели или зависимости необходимо обновить декоратор, если он нужен. Для упрощения поиска зависимых моделей мы написали «умный» тест:

    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
    

    Тест рекурсивно проверяет все модели на наличие зависимости от удаляемой модели, потом смотрит, был ли для данной модели установлен декоратор на требуемые поля. Если что-то пропущено, тест деликатно подскажет, куда нужно добавить декоратор.

    Эпилог


    Таким образом, при помощи декоратора удалось малой кровью реализовать «удаление» данных, которые имеют большое количество зависимостей. Все запросы автоматически получают обязательный фильтр exclude. Наложение дополнительных условий замедляет процесс получения данных, степень «осложнения» зависит от структуры БД, количества записей и наличия индексов. Предложенный тест подскажет, для каких моделей требуется добавить декораторы, и в будущем будет следить за их консистентностью.
    Mail.ru Group
    1812,00
    Строим Интернет
    Поделиться публикацией

    Похожие публикации

    Комментарии 13

      0
      Момент про замедление работы запросов, когда в базе куча хайд-объектов — не есть хорошо. Не пробовали по какому-то тайм-ауту, выгружать джобой данные в какую-то резервную базу, а из основной делать честный делит в периоды наименьшей активности (скажем 4 утра)?
        +1
        Согласен, что во многих проектах можно сделать честное удаление ночью. Но увы, это не наш случай. В силу специфики проекта мы не имеем периодов низкой активности. Так называемые «чекеры» шлют новые данные ежесекундно.
          0
          А если юзер просит удалить данные в рамках GDPR, а у вас по-умолчанию данные хайдятся. какое решение?
            +1
            Над этим вопросом мы тоже думали, только с другой стороны: «Как посмотреть удаленные объекты?». Для подобных целей можно использовать стандартный менеджер, который будет заранее сохранен декоратором под другим атрибутом модели, например all_objects:

            # добавить строку в декоратор перед переопределением model_class.objects
            # model_class.all_objects = copy.deepcopy(model_class.objects)
            
            Host.objects.values('id')
            # SELECT `Hosts`.`id` FROM `Hosts` WHERE NOT (`Hosts`.`is_deleted` = True)
            Host.all_objects.values('id')
            # SELECT `Hosts`.`id` FROM `Hosts`
            
            # удаление работает должным образом
            Host.all_objects.filter(is_deleted=True).first().delete()
            
              0
              Нет, я к тому, что GDPR предписывает полностью удалять данные с ваших серверов, если юзер этого потребует. У вас сейчас идет хайд данных, а полное удаление возможно, если юзер просит?
                +1
                Да, возможно. В комментарии выше я написал, как это сделать.
                Через менеджер all_objects можно запустить стандартный delete().
        0
        Вместо декораторов можно использовать кастомные QuerySet и/или Manager с последующим линкованием к objects. Однако, сама по себе идея оставлять объекты в базе мне кажется весьма сомнительной. Мы в подобной ситуации просто начали запускать удаление чаще, в моменты минимальной нагрузки и маленькими порциями.
          0
          Нубский вопрос. А почему бы не засунуть запрос во View и обойтись без декораторов?
            0
            … потому что найти все запросы и добавить фильтр на отсутствие удаленного родительского элемента представлялось задачей, как минимум, на неделю. К тому же была высока вероятность пропустить нужный код, что привело бы к непредсказуемым последствиям.
              0
              Решение с вьюхой предполагает, что код вообще не трогается, просто при обращении к базе таблицы подменяются на соответствующие вьюхи, в которых упрятана вся логика отсевом удаленных. Или я чего не понял?
                0
                Присутствие в блоге Django и рядом стоящие слова «View» и «декораторы» заставили меня думать про View в рамках MVC. Теперь понял, что вопрос уровня БД.

                Да, создание представлений таблиц с фильтрами — интересное решение, которое имеет право на существование, но, как всегда, не без подводных камней:

                • когда мы спускаемся на уровень БД, то идем к узконаправленным решениям, что снижает их универсальность;
                • нужно помнить, что представления — это все же не таблицы, поэтому как минимум для миграций нужно прописать названия оригинальных таблиц, а это уже изменения в коде. Если погружаться в решение глубже, то нужно изучить, всегда ли будут использоваться индексы, как и при работе с таблицей, и как будут происходить блокировки при записи или обновлении данных;
                • поиск всех зависимостей и написание представлений — тоже немалая работа. В нашем случае это решилось предложенным тестом, который сам нашел все зависимости. Можно пойти дальше и сделать так, чтобы декорирование необходимых моделей происходило автоматически при запуске проекта, но мы остановились на более явном определении.

                Имхо, данное решение требует дополнительного изучения в силу различий между таблицей и представлением, изучения его универсальности на разных СУБД, и все же требует большего количества времени на внедрение, а в результате будут выполняться все те же фильтры.
                  0
                  Спасибо за ответ.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое