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

6h-4-wzp5wlydal3wkp2df8aq5a.jpeg

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

Предыстория


Для контроля работоспособности большинства проектов 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

        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. Наложение дополнительных условий замедляет процесс получения данных, степень «осложнения» зависит от структуры БД, количества записей и наличия индексов. Предложенный тест подскажет, для каких моделей требуется добавить декораторы, и в будущем будет следить за их консистентностью.

© Habrahabr.ru