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