- PVSM.RU - https://www.pvsm.ru -
Рано или поздно перед разработчиками встаёт задача удаления ненужных данных. И чем сложнее сервис, тем больше нюансов необходимо учесть. В данной статье я расскажу, как мы реализовали «удаление» в базе данных с сотней связей.
Для контроля работоспособности большинства проектов Mail.ru Group и ВКонтакте используется сервис собственной разработки — Monitoring. Начав свою историю с конца 2012 года, за 6 лет проект вырос в огромную систему, которая обросла большим количеством функциональности. Monitoring регулярно проверяет доступность серверов и корректность ответов на запросы, собирает статистику по используемой памяти, загрузке процессоров и т.д. Когда параметры контролируемого сервера выходят за допустимые значения, ответственные за сервер получают уведомления в системе и по SMS.
Все проверки и инциденты журналируются для отслеживания динамики характеристик серверов, поэтому объем базы данных достиг порядка сотни миллионов записей. Периодически появляются новые серверы, а старые перестают использоваться. Информацию о неиспользуемых серверах необходимо удалить из системы Monitoring, чтобы: а) не перегружать интерфейс лишней информацией, и б) освободить уникальные идентификаторы.
Я не зря в заголовке статьи слово «удаление» написал в кавычках. Убрать объект из системы можно несколькими способами:
Изначально использовался первый подход, когда мы просто выполняли object.delete()
и объект удалялся со всеми зависимостями. Но со временем нам пришлось отказаться от такого подхода, так как один объект мог иметь зависимости с миллионами других объектов, и каскадное удаление жёстко блокировало таблицы. А так как сервис каждую секунду выполняет по тысяче проверок и журналирует их, то блокировка таблиц приводила к серьёзному замедлению сервиса, что было недопустимо для нас.
Для избежания долгих блокировок мы решили удалять данные порциями. Это позволило бы в промежутки времени между удалениями объектов записывать актуальные данные мониторинга. Список всех объектов, которые будут удалены каскадно, можно получить методом, который применяется в панели администратора при удалении объекта (при подтверждении удаления):
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
Ситуация улучшилась: нагрузка распределилась по времени, новые данные стали записываться быстрее. Но мы сразу же натолкнулись на следующий подводный камень. Дело в том, что список удаляемых объектов формируется в самом начале удаления, и если в процессе «порционного» удаления добавляются новые зависимые объекты, то родительский элемент не может быть удалён.
Мы сразу отказались от идеи при ошибке в рекурсивном удалении снова собирать данные о новых зависимостях или запрещать добавлять зависимые записи при удалении, потому что а) можно уйти в бесконечный цикл или б) придётся найти по всему коду добавления всех зависимых объектов.
Мы задумались о втором типе удаления, когда данные маркируются и скрываются из интерфейса. Изначально этот подход был отвергнут, потому что найти все запросы и добавить фильтр на отсутствие удаленного родительского элемента представлялось задачей, как минимум, на неделю. К тому же была высока вероятность пропустить нужный код, что привело бы к непредсказуемым последствиям.
Тогда мы решили использовать декораторы для переопределения менеджера запросов. Далее лучше увидеть код, чем писать сотню слов.
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
. Наложение дополнительных условий замедляет процесс получения данных, степень «осложнения» зависит от структуры БД, количества записей и наличия индексов. Предложенный тест подскажет, для каких моделей требуется добавить декораторы, и в будущем будет следить за их консистентностью.
Автор: Михаил Юлдашев
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/307705
Ссылки в тексте:
[1] Источник: https://habr.com/ru/post/438280/?utm_source=habrahabr&utm_medium=rss&utm_campaign=438280
Нажмите здесь для печати.