اگر امروز یک delete() ساده روی جدولی با چند میلیون ردیف اجرا کنید، احتمالاً سایت شما برای چند دقیقه قفل می‌شود، replication lag در دیتابیس‌های توزیع‌شده به چند ساعت می‌رسد و در بدترین حالت، تراکنش شما به‌دلیل پر شدن redo log شکست می‌خورد؛ چون پاک‌سازی داده، یک عملیات ظریف مهندسی است، نه یک دستور ساده.

چرا پاک‌سازی داده، یک عملیات مهندسی است؟

در یکی از پروژه‌های آماری که چند سال پیش روی آن کار می‌کردم، یک تیم تصمیم گرفت داده‌های قدیمی‌تر از ۹۰ روز را پاک کند. کد آن‌ها یک خط بود: Interaction.objects.filter(occurred_at__lt=cutoff).delete(). نتیجه: دیتابیس اصلی به مدت ۴۵ دقیقه قفل شد، سایت تجارت الکترونیک برای همان مدت از دسترس خارج شد و هزینه‌ی مستقیم این قطعی، چند برابر هزینه‌ی نگهداری سرور برای یک سال بود.

این تجربه نشان می‌دهد که پاک‌سازی داده، یک عملیات ظریف مهندسی است. سه دلیل اصلی:

دلیل اول، قفل طولانی جدول. یک DELETE بدون محدودیت، تراکنش طولانی ایجاد می‌کند که همه‌ی نوشتن‌ها روی جدول را قفل می‌کند.

دلیل دوم، انفجار redo log. در MySQL، هر ردیف حذف‌شده در redo log ثبت می‌شود. اگر redo log پر شود، تراکنش شکست می‌خورد و تمام تلاش هدر می‌رود.

دلیل سوم، replication lag. در دیتابیس‌های توزیع‌شده، replicaها باید همان حجم DELETE را replay کنند. اگر این کار زمان‌بر باشد، replicaها از master عقب می‌افتند و کوئری‌های read شما با داده‌ی قدیمی جواب می‌دهند.

پاک‌سازی داده، یک عملیات مهندسی است که در آن، سرعت و امنیت در تضاد مستقیم با هم قرار دارند. انتخاب درست، تعادل بین این دو است.

این اهمیت، در ساختار پروژه‌های آماری مثل اپ جنگو برای ردیابی بازدیدکننده به‌طور مستقیم دیده می‌شود. اگر لایه‌ی پاک‌سازی داده به‌درستی طراحی نشود، تمام داده‌های شما در معرض از دست رفتن قرار می‌گیرند. برای درک عمیق‌تر این موضوع، پیشنهاد می‌کنم ابتدا طراحی مدل Visitor و Visit در جنگو را مطالعه کنید، چون ساختار جدول‌ها، مستقیماً روی استراتژی پاک‌سازی اثر می‌گذارد.

روش ساده‌ای که همه‌جا دیده می‌شود و شکست می‌خورد

روش ساده‌ی پاک‌سازی، در اکثر پروژه‌ها به این شکل است:

from datetime import timedelta
from django.utils import timezone
from django.core.management.base import BaseCommand
from analytics.models import Interaction


class Command(BaseCommand):
    def handle(self, *args, **options):
        cutoff = timezone.now() - timedelta(days=90)
        count, _ = Interaction.objects.filter(
            occurred_at__lt=cutoff,
        ).delete()
        self.stdout.write(f"Deleted {count} rows")

این کد، پنج مشکل جدی دارد.

مشکل اول، یک DELETE بزرگ. اگر جدول شما میلیون‌ها ردیف داشته باشد، این یک دستور، تمام آن‌ها را در یک تراکنش حذف می‌کند.

مشکل دوم، عدم اعمال CASCADE به‌صورت کنترل‌شده. اگر جدول شما روابط CASCADE داشته باشد، Django برای هر ردیف، کوئری‌های اضافی می‌زند. این کار می‌تواند ده‌ها برابر کندتر از خود DELETE باشد.

مشکل سوم، عدم dry-run. شما هیچ راهی ندارید که قبل از اجرای واقعی، بدانید چند ردیف قرار است حذف شود.

مشکل چهارم، عدم بازیابی. اگر کد شما باگ داشت و داده‌ی اشتباهی حذف شد، راهی برای بازگردانی نیست.

مشکل پنجم، عدم مانیتورینگ. اگر کامند ۳۰ دقیقه طول بکشد، نمی‌دانید الان چند درصد کار انجام شده.

در یکی از پروژه‌ها، بعد از اجرای این کامند ساده، متوجه شدیم که occurred_at در بعضی ردیف‌ها به‌اشتباه مقدار صفر داشته. نتیجه: تمام رکوردهای آن‌ها حذف شد، حتی رکوردهای جدید. بازیابی این داده، سه روز زمان برد.

برای رفع این مشکلات، به یک استراتژی چندلایه نیاز دارید. اگر با الگوی ذخیره‌ی PageView و Interaction کار کرده باشید، می‌دانید که این نوع توجه به جزئیات، در پروژه‌های بزرگ ضروری است.

استراتژی batch delete؛ قلب پاک‌سازی امن

استراتژی batch delete، تمام مشکلات روش ساده را حل می‌کند. ایده‌ی اصلی ساده است: به‌جای یک DELETE بزرگ، چند DELETE کوچک در تراکنش‌های جداگانه اجرا کنید.

from datetime import timedelta
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from analytics.models import Interaction


DEFAULT_BATCH_SIZE = 1000
MAX_BATCHES = 10000


class Command(BaseCommand):
    help = "پاک‌سازی Interactionهای قدیمی به‌صورت batch"

    def add_arguments(self, parser):
        parser.add_argument("--days", type=int, default=90)
        parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
        parser.add_argument("--dry-run", action="store_true")
        parser.add_argument("--max-batches", type=int, default=MAX_BATCHES)

    def handle(self, *args, **options):
        days = options["days"]
        batch_size = options["batch_size"]
        dry_run = options["dry_run"]
        max_batches = options["max_batches"]

        if days < 1:
            raise CommandError("--days باید حداقل 1 باشد.")
        if batch_size < 100:
            raise CommandError("--batch-size حداقل 100.")
        if batch_size > 10000:
            raise CommandError("--batch-size حداکثر 10000.")

        cutoff = timezone.now() - timedelta(days=days)
        self.stdout.write(
            f"پاک‌سازی رکوردهای قدیمی‌تر از {cutoff:%Y-%m-%d %H:%M:%S}"
        )

        qs = Interaction.objects.filter(occurred_at__lt=cutoff)
        total = qs.count()
        self.stdout.write(f"تعداد رکوردهای قدیمی: {total}")

        if dry_run:
            self.stdout.write(self.style.WARNING("dry-run فعال است."))
            return

        if total == 0:
            self.stdout.write(self.style.SUCCESS("چیزی برای حذف نیست."))
            return

        deleted = self._delete_in_batches(qs, batch_size, max_batches)
        self.stdout.write(self.style.SUCCESS(
            f"تعداد رکورد حذف‌شده: {deleted}"
        ))

    def _delete_in_batches(self, qs, batch_size, max_batches):
        total_deleted = 0
        batch_count = 0

        while batch_count < max_batches:
            ids = list(
                qs.values_list("pk", flat=True)[:batch_size]
            )
            if not ids:
                break

            with transaction.atomic():
                Interaction.objects.filter(pk__in=ids).delete()

            total_deleted += len(ids)
            batch_count += 1

            self.stdout.write(
                f"  batch {batch_count}: {total_deleted} ردیف حذف شد"
            )

        return total_deleted

این کامند، هفت بهبود نسبت به روش ساده دارد.

بهبود اول، batch_size قابل تنظیم. با --batch-size می‌توانید اندازه‌ی هر batch را تنظیم کنید. batch کوچک‌تر، قفل کوتاه‌تر، ولی تعداد تراکنش بیشتر.

بهبود دوم، dry-run. با --dry-run می‌توانید قبل از اجرا، تعداد ردیف‌ها را ببینید.

بهبود سوم، max-batches. محدودیت تعداد batch، از اجرای بی‌نهایت جلوگیری می‌کند. اگر باگ داشتید، کامند به‌جای ساعت‌ها، در یک زمان محدود متوقف می‌شود.

بهبود چهارم، نمایش پیشرفت. هر batch، تعداد ردیف‌های حذف‌شده را نمایش می‌دهد. این کار، در کامندهای طولانی بسیار مفید است.

بهبود پنجم، تراکنش جداگانه. هر batch در یک تراکنش جداگانه اجرا می‌شود. اگر batch شماره‌ی ۱۰۰ شکست خورد، ۹۹ batch قبلی حفظ می‌شوند.

بهبود ششم، اعتبارسنجی ورودی. اگر ورودی نامعتبر باشد، کامند بلافاصله با خطا خارج می‌شود.

بهبود هفتم، استفاده از pk__in. به‌جای استفاده از qs.delete() مستقیم، اول یک لیست از pk‌ها استخراج می‌کنیم و بعد آن‌ها را با pk__in حذف می‌کنیم. این کار، از CASCADE غیرقابل پیش‌بینی جلوگیری می‌کند.

نکته‌ی مهم: batch_size بهینه، به دیتابیس و بار سرور بستگی دارد. برای MySQL روی سرور معمولی، batch_size بین ۵۰۰ تا ۲۰۰۰ مناسب است. برای PostgreSQL، بین ۱۰۰۰ تا ۵۰۰۰. اگر با الگوی بهینه‌سازی جنگو برای ترافیک بالا کار کرده باشید، می‌دانید که این تنظیمات باید با کالیبراسیون مشخص شوند.

تراکنش‌ها؛ چرا یک DELETE بزرگ ممنوع است

تراکنش‌ها در پاک‌سازی داده، هم دوست هستند و هم دشمن. دوست، چون اتمیسیتی را فراهم می‌کنند. دشمن، چون هرچه تراکنش بزرگ‌تر، قفل طولانی‌تر.

سه نکته‌ی کلیدی در مورد تراکنش‌ها:

نکته‌ی اول، تراکنش کوتاه. هر batch باید در یک تراکنش کوتاه اجرا شود. تراکنش طولانی، قفل طولانی، مصرف redo log بیشتر و lag بیشتر در replication.

نکته‌ی دوم، تراکنش مستقل. هر batch باید تراکنش مستقل خودش را داشته باشد. اگر batch 100 شکست خورد، batch‌های 1 تا 99 باید حفظ شوند.

نکته‌ی سوم، عدم استفاده از atomic طولانی. اگر تمام کامند را در یک with transaction.atomic() قرار دهید، این کار تراکنش را به یک تراکنش طولانی تبدیل می‌کند.

رویکردقفلredo logreplication lag
یک DELETE بزرگچند دقیقهانفجاریچند ساعت
batch 1000چند ثانیهمدیریت‌پذیرچند ثانیه
batch 100زیر ثانیهخیلی کمناچیز
batch 1ناچیزناچیزناچیز ولی کند

نکته‌ی ظریف: batch خیلی کوچک هم مشکل‌ساز است، چون تعداد تراکنش‌ها بالا می‌رود و overhead تراکنش، بر صرفه‌جویی batch غلبه می‌کند. batch بهینه، ترکیبی از این دو حد است.

در پاک‌سازی داده، تعادل بین قفل کوتاه و تعداد تراکنش کم، یک هنر است. batch بهینه، همان نقطه‌ی تعادل است.

اگر با الگوی حذف رکوردهای تکراری و یتیم با batch delete کار کرده باشید، می‌دانید که این اصل، در همه‌ی عملیات‌های پاک‌سازی مشترک است.

on_delete و CASCADE؛ تله‌ای که دیر خودش را نشان می‌دهد

یکی از بزرگ‌ترین تله‌ها در پاک‌سازی داده، روابط CASCADE است. اگر جدول شما روابط CASCADE داشته باشد، Django به‌طور خودکار زیرمجموعه‌ها را هم حذف می‌کند. این رفتار، در ظاهر مطلوب است، ولی در عمل سه مشکل ایجاد می‌کند.

مشکل اول، کوئری‌های اضافی. Django برای هر ردیف اصلی، یک کوئری جداگانه برای حذف زیرمجموعه‌ها می‌زند. اگر هزار ردیف اصلی داشته باشید، این می‌تواند هزاران کوئری اضافی باشد.

مشکل دوم، تراکنش بزرگ. CASCADE در یک تراکنش اجرا می‌شود. اگر زیرمجموعه‌ها زیاد باشند، تراکنش طولانی می‌شود.

مشکل سوم، عدم پیش‌بینی حجم. اگر ندانید چند زیرمجموعه وجود دارد، نمی‌توانید زمان اجرا را تخمین بزنید.

# روش بد: تکیه بر CASCADE
Interaction.objects.filter(occurred_at__lt=cutoff).delete()

# روش بهتر: کنترل دستی
interaction_ids = list(
    Interaction.objects
    .filter(occurred_at__lt=cutoff)
    .values_list("pk", flat=True)[:1000]
)

with transaction.atomic():
    # ابتدا زیرمجموعه‌ها را حذف کن
    RelatedModel.objects.filter(interaction_id__in=interaction_ids).delete()
    # سپس رکوردهای اصلی را حذف کن
    Interaction.objects.filter(pk__in=interaction_ids).delete()

نکته‌ی مهم: اگر CASCADE دارید، همیشه قبل از حذف اصلی، زیرمجموعه‌ها را به‌صورت دستی حذف کنید. اگر با الگوی طراحی مدل Visitor و Visit در جنگو کار کرده باشید، می‌دانید که این نوع کنترل دستی، بخشی از طراحی مقاوم است.

dry-run؛ قبل از هر چیز، اندازه را اندازه بگیرید

dry-run یکی از مهم‌ترین ویژگی‌های یک کامند پاک‌سازی است. قبل از اجرای واقعی، باید بدانید:

سؤال اول، چند ردیف قرار است حذف شود؟ این عدد، به شما می‌گوید که اجرا چقدر طول می‌کشد.

سؤال دوم، کدام زیرمجموعه‌ها تحت تأثیر قرار می‌گیرند؟ اگر CASCADE دارید، باید حجم زیرمجموعه‌ها را هم بدانید.

سؤال سوم، بازه‌ی زمانی درست است؟ گاهی یک خطای ساده در محاسبه‌ی cutoff، باعث حذف داده‌های اشتباه می‌شود.

# dry-run پیشرفته
def handle_dry_run(self, qs, cutoff):
    from django.db.models import Count

    total = qs.count()
    oldest = qs.order_by("occurred_at").values_list("occurred_at", flat=True).first()
    newest = qs.order_by("-occurred_at").values_list("occurred_at", flat=True).first()

    self.stdout.write(f"بازه‌ی زمانی: {oldest} تا {newest}")
    self.stdout.write(f"تعداد رکورد: {total}")

    # نمونه‌گیری
    sample = list(qs.values("pk", "event_type")[:10])
    self.stdout.write(f"نمونه: {sample}")

این dry-run پیشرفته، سه مزیت دارد.

مزیت اول، نمایش بازه. می‌بینید که واقعاً کدام رکوردها حذف می‌شوند.

مزیت دوم، تعداد دقیق. قبل از اجرا، تخمین زمان می‌زنید.

مزیت سوم، نمونه. با دیدن نمونه، می‌توانید از صحت معیار حذف مطمئن شوید.

آرشیو قبل از حذف؛ بکاپی که همیشه نجات‌بخش است

در پاک‌سازی داده، همیشه قبل از حذف، آرشیو کنید. این کار، سه مزیت کلیدی دارد.

مزیت اول، بازیابی آسان. اگر بعداً فهمیدید که داده‌ی ارزشمندی حذف شده، می‌توانید آن را برگردانید.

مزیت دوم، تحلیل تاریخی. حتی اگر دیگر به داده‌ی خام نیاز نداشته باشید، ممکن است بعداً برای تحلیل‌های تاریخی به آن نیاز پیدا کنید.

مزیت سوم، compliance. در بعضی حوزه‌های قضایی، نگهداری داده برای مدت مشخصی الزامی است.

import gzip
import json
from datetime import datetime


def archive_batch(qs, batch_size, output_file):
    with gzip.open(output_file, "at", encoding="utf-8") as f:
        for row in qs.values().iterator(chunk_size=batch_size):
            f.write(json.dumps(row, default=str) + "\n")

نکته‌ی مهم: آرشیو در فرمت JSON فشرده با gzip، حجم داده را تا ۹۰٪ کاهش می‌دهد. برای داده‌های خیلی بزرگ، فرمت‌های پارکت (Parquet) یا ORC انتخاب‌های بهتری هستند.

و در کامند، قبل از حذف هر batch، ابتدا آرشیو کنید:

def _delete_in_batches_with_archive(self, qs, batch_size, archive_path):
    total_deleted = 0
    batch_count = 0

    with gzip.open(archive_path, "at", encoding="utf-8") as archive:
        while True:
            ids = list(qs.values_list("pk", flat=True)[:batch_size])
            if not ids:
                break

            # آرشیو
            for row in Interaction.objects.filter(pk__in=ids).values().iterator():
                archive.write(json.dumps(row, default=str) + "\n")

            # حذف
            with transaction.atomic():
                Interaction.objects.filter(pk__in=ids).delete()

            total_deleted += len(ids)
            batch_count += 1

            self.stdout.write(f"  batch {batch_count}: {total_deleted} ردیف")

    return total_deleted

اگر با الگوی طبقه‌بندی Referrer و تشخیص ورود از گوگل کار کرده باشید، می‌دانید که این نوع آرشیو، در تحلیل‌های تاریخی بسیار مفید است.

اجرای زمان‌بندی شده؛ cron، systemd و Celery

پاک‌سازی داده باید به‌صورت دوره‌ای و خودکار اجرا شود. سه رویکرد اصلی وجود دارد.

رویکرد اول، cron. ساده‌ترین رویکرد، ولی مدیریت خطا و لاگ‌گیری چالش‌برانگیز است.

# crontab
0 3 * * * cd /path/to/project && /path/to/venv/bin/python manage.py cleanup_analytics --days 90 >> /var/log/cleanup_analytics.log 2>&1

رویکرد دوم، systemd timer. مدرن‌تر از cron، با لاگ‌گیری متمرکز و مدیریت خطای بهتر.

# /etc/systemd/system/cleanup-analytics.service
[Unit]
Description=Cleanup old analytics data

[Service]
Type=oneshot
User=www-data
WorkingDirectory=/path/to/project
ExecStart=/path/to/venv/bin/python manage.py cleanup_analytics --days 90

# /etc/systemd/system/cleanup-analytics.timer
[Unit]
Description=Run cleanup daily at 3 AM

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

رویکرد سوم، Celery Beat. اگر از Celery استفاده می‌کنید، می‌توانید پاک‌سازی را به‌عنوان یک task تعریف کنید.

# analytics/tasks.py

from celery import shared_task
from django.core.management import call_command


@shared_task(bind=True, max_retries=3)
def cleanup_analytics_task(self, days=90):
    try:
        call_command("cleanup_analytics", days=days)
    except Exception as exc:
        self.retry(exc=exc, countdown=300)


# celery.py

app.conf.beat_schedule = {
    "cleanup-analytics": {
        "task": "analytics.tasks.cleanup_analytics_task",
        "schedule": crontab(hour=3, minute=0),
    },
}
رویکردسادگیلاگ‌گیریمدیریت خطامناسب برای
cronبالاپایینپایینپروژه‌های کوچک
systemd timerمتوسطبالامتوسطپروژه‌های متوسط
Celery Beatپایینبالابالاپروژه‌های بزرگ

توصیه‌ی من: برای پروژه‌های جدی، از Celery Beat یا systemd timer استفاده کنید. این دو، مدیریت خطا و لاگ‌گیری متمرکز را فراهم می‌کنند.

مانیتورینگ و هشدار؛ چشمی که همیشه باز است

پاک‌سازی داده، یک عملیات پس‌زمینه است. اگر شکست بخورد، شما تا زمانی که مشکل دیگری رخ دهد، متوجه نمی‌شوید. سه لایه‌ی مانیتورینگ را در نظر بگیرید.

لایه‌ی اول، لاگ ساختاریافته. هر اجرای کامند، باید یک لاگ ساختاریافته ثبت کند.

import logging
import json


logger = logging.getLogger("analytics.cleanup")


def log_cleanup_result(start_time, deleted_count, errors, dry_run=False):
    duration = (timezone.now() - start_time).total_seconds()

    logger.info(
        "cleanup_completed",
        extra={
            "deleted_count": deleted_count,
            "duration_seconds": duration,
            "errors": errors,
            "dry_run": dry_run,
        },
    )

لایه‌ی دوم، هشدار برای خطا. اگر کامند با خطا مواجه شد، باید هشدار دریافت کنید. با Sentry یا یک سرویس مشابه، این کار ساده است.

لایه‌ی سوم، هشدار برای اجرای ناقص. اگر کامند در بازه‌ی زمانی مشخصی اجرا نشد، هشدار دریافت کنید.

def check_last_run():
    from django.core.cache import cache

    last_run = cache.get("analytics:last_cleanup")
    if not last_run:
        return False

    from datetime import timedelta
    if timezone.now() - last_run > timedelta(hours=25):
        return False
    return True

نکته‌ی مهم: هشدارها باید به کسی برسند که بتواند اقدام کند. هشدار به یک کانال Slack یا یک ایمیل عمومی، معمولاً نادیده گرفته می‌شود. باید به یک فرد یا تیم مشخص ارسال شود.

بازیابی در صورت خطا؛ طرحی که باید از قبل داشته باشید

حتی با بهترین طراحی، ممکن است خطا رخ دهد. در این حالت، باید طرح بازیابی داشته باشید. سه سطح بازیابی را در نظر بگیرید.

سطح اول، بازیابی batch. اگر batch شماره‌ی N شکست خورد، می‌توانید کامند را با --skip-batches N ادامه دهید.

def add_arguments(self, parser):
    parser.add_argument("--skip-batches", type=int, default=0)


def _delete_in_batches(self, qs, batch_size, max_batches, skip_batches):
    batch_count = 0
    total_deleted = 0

    while batch_count < max_batches:
        ids = list(qs.values_list("pk", flat=True)[:batch_size])
        if not ids:
            break

        batch_count += 1
        if batch_count <= skip_batches:
            continue

        with transaction.atomic():
            Interaction.objects.filter(pk__in=ids).delete()

        total_deleted += len(ids)
        self.stdout.write(f"  batch {batch_count}: {total_deleted}")

    return total_deleted

سطح دوم، بازیابی از آرشیو. اگر آرشیو دارید، می‌توانید داده را بازگردانید.

import gzip
import json


def restore_from_archive(archive_path):
    restored = 0
    with gzip.open(archive_path, "rt", encoding="utf-8") as f:
        for line in f:
            row = json.loads(line)
            Interaction.objects.update_or_create(
                id=row["id"],
                defaults=row,
            )
            restored += 1
    return restored

سطح سوم، بازیابی از backup دیتابیس. اگر کامند شما فاجعه‌ی بزرگی ایجاد کرد، آخرین راه، بازگردانی از backup دیتابیس است. این کار، هم زمان‌بر است و هم ممکن است باعث از دست رفتن داده‌های جدید شود.

در پاک‌سازی داده، طرح بازیابی مهم‌تر از خود عملیات است. اگر طرح بازیابی ندارید، شما آماده‌ی اجرای عملیات نیستید.

کارایی؛ عددهایی که در مستندات نیست

کارایی پاک‌سازی، به چند عامل بستگی دارد. این عددها، بر اساس تجربه‌ی واقعی از پروژه‌های مختلف است.

حجم رکوردbatch_sizeزمان تخمینیتوضیح
۱۰ هزار۱۰۰۰۲-۵ ثانیهیک‌بار اجرا
۱۰۰ هزار۱۰۰۰۳۰-۶۰ ثانیهیک‌بار اجرا
۱ میلیون۱۰۰۰۵-۱۵ دقیقهنیاز به مانیتورینگ
۱۰ میلیون۱۰۰۰۱-۲ ساعتنیاز به زمان‌بندی
۱۰۰ میلیون۱۰۰۰۱۰-۲۰ ساعتنیاز به پارتیشن‌بندی

نکته‌ی مهم: زمان اجرا، به سرعت دیتابیس، تعداد ایندکس‌ها و روابط CASCADE بستگی دارد. اگر جدول شما ایندکس‌های زیادی داشته باشد، زمان حذف بیشتر می‌شود، چون دیتابیس باید ایندکس‌ها را هم به‌روز کند.

اگر با الگوی بهینه‌سازی جنگو برای ترافیک بالا کار کرده باشید، می‌دانید که این نوع محاسبات، برای تخمین زمان اجرا ضروری است.

طراحی schema که پاک‌سازی را ساده می‌کند

طراحی schema، مستقیماً روی پیچیدگی پاک‌سازی اثر می‌گذارد. سه نکته‌ی کلیدی:

نکته‌ی اول، فیلد زمان ایندکس‌شده. اگر فیلد occurred_at یا created_at ایندکس نداشته باشد، کوئری حذف به‌سرعت یک full scan می‌شود.

نکته‌ی دوم، عدم روابط CASCADE پیچیده. اگر جدول شما روابط CASCADE دارد، حذف پیچیده‌تر می‌شود. تا حد امکان، از SET_NULL یا PROTECT استفاده کنید.

نکته‌ی سوم، پارتیشن‌بندی. اگر جدول شما میلیون‌ها ردیف دارد، پارتیشن‌بندی ماهانه بر اساس occurred_at، حذف داده‌های قدیمی را به یک عملیات تقریباً لحظه‌ای تبدیل می‌کند.

# در PostgreSQL
CREATE TABLE analytics_interaction (
    id BIGSERIAL,
    occurred_at TIMESTAMPTZ NOT NULL,
    event_type VARCHAR(15) NOT NULL,
    -- ...
    PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE analytics_interaction_2026_01
    PARTITION OF analytics_interaction
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

با پارتیشن‌بندی، حذف یک ماه داده به یک دستور DROP TABLE تبدیل می‌شود که تقریباً لحظه‌ای است.

تست کامند مدیریتی

کامندهای مدیریتی، اغلب تست نمی‌شوند. این یک اشتباه است. سه سطح تست را در نظر بگیرید.

سطح اول، تست dry-run. بررسی کنید که dry-run هیچ رکوردی را حذف نمی‌کند.

import pytest
from datetime import timedelta
from django.core.management import call_command
from django.utils import timezone
from analytics.models import Interaction


@pytest.mark.django_db
def test_dry_run_does_not_delete():
    # ساخت رکوردهای قدیمی
    old_time = timezone.now() - timedelta(days=100)
    Interaction.objects.create(
        event_type="click", occurred_at=old_time,
    )

    call_command("cleanup_analytics", days=90, dry_run=True)

    assert Interaction.objects.count() == 1

سطح دوم، تست حذف صحیح. بررسی کنید که فقط رکوردهای قدیمی حذف می‌شوند.

@pytest.mark.django_db
def test_cleanup_deletes_only_old_records():
    old_time = timezone.now() - timedelta(days=100)
    new_time = timezone.now() - timedelta(days=10)

    Interaction.objects.create(event_type="click", occurred_at=old_time)
    Interaction.objects.create(event_type="scroll", occurred_at=new_time)

    call_command("cleanup_analytics", days=90)

    assert Interaction.objects.count() == 1
    assert Interaction.objects.first().event_type == "scroll"

سطح سوم، تست batch. بررسی کنید که کامند در batch‌های کوچک اجرا می‌شود.

@pytest.mark.django_db
def test_cleanup_uses_batches():
    from django.test.utils import CaptureQueriesContext
    from django.db import connection

    old_time = timezone.now() - timedelta(days=100)
    for i in range(50):
        Interaction.objects.create(
            event_type="click", occurred_at=old_time,
        )

    with CaptureQueriesContext(connection) as ctx:
        call_command("cleanup_analytics", days=90, batch_size=10)

    # بیش از ۱۰ کوئری نباید بزند
    assert len(ctx) <= 20

این سه سطح تست، به شما اجازه می‌دهند که در طول زمان، تغییرات را با اطمینان اعمال کنید.

anti-patternهای رایج در پاک‌سازی داده

در بازبینی پروژه‌های مختلف، این اشتباهات را زیاد دیده‌ام:

۱. DELETE بدون batch. یک delete() ساده، قفل طولانی و ریسک شکست بالا دارد.

۲. عدم dry-run. بدون این ویژگی، ممکن است داده‌ی اشتباهی حذف شود.

۳. عدم آرشیو قبل از حذف. اگر داده‌ی ارزشمندی از بین برود، راهی برای بازیابی نیست.

۴. عدم اعتبارسنجی ورودی. اگر days منفی باشد، ممکن است داده‌های جدید هم حذف شوند.

۵. atomic طولانی. اگر تمام کامند در یک تراکنش باشد، تمام مزایای batch از بین می‌رود.

۶. عدم مدیریت CASCADE. اگر CASCADE داشته باشید، Django کوئری‌های اضافی می‌زند که کندی ایجاد می‌کند.

۷. عدم ایندکس روی فیلد زمان. اگر occurred_at ایندکس نداشته باشد، کوئری حذف به full scan تبدیل می‌شود.

۸. عدم مانیتورینگ. بدون مانیتورینگ، خطاها در سکوت اتفاق می‌افتند.

۹. عدم طرح بازیابی. اگر کامند فاجعه ایجاد کرد، راهی برای بازگشت نیست.

۱۰. اجرا در ساعات پرترافیک. پاک‌سازی باید در ساعات کم‌ترافیک اجرا شود، مثلاً ۳ بامداد.

۱۱. عدم rate limiting روی کامند. اگر کامند خیلی سریع اجرا شود، ممکن است دیتابیس را overload کند.

۱۲. عدم هماهنگی با replicaها. در پروژه‌های بزرگ، باید هماهنگی با replicaها داشته باشید تا lag ایجاد نشود.

اگر با الگوی حذف رکوردهای تکراری و یتیم با batch delete کار کرده باشید، می‌دانید که این الگوها، در همه‌ی عملیات‌های پاک‌سازی مشترک است.

پرسش‌های پرتکرار درباره‌ی پاک‌سازی داده در جنگو

batch_size بهینه چقدر است؟ بسته به دیتابیس و بار سرور متفاوت است. برای MySQL، بین ۵۰۰ تا ۲۰۰۰. برای PostgreSQL، بین ۱۰۰۰ تا ۵۰۰۰. بهترین راه، کالیبراسیون تجربی روی محیط خودتان است.

آیا باید حذف نرم (soft delete) استفاده کنم؟ برای داده‌ی آماری، معمولاً نه. حذف سخت، فضای دیتابیس را آزاد می‌کند. ولی برای داده‌های کسب‌وکار (مثل سفارش‌ها)، حذف نرم گزینه‌ی بهتری است.

چطور بفهمم کامند چقدر طول می‌کشد؟ با dry-run و شمارش رکوردها. زمان تقریبی = (تعداد رکورد / batch_size) × زمان هر batch. برای batch ۱۰۰۰ رکورد، هر batch حدود ۲۰ تا ۵۰ میلی‌ثانیه طول می‌کشد.

آیا باید کامند را در ساعات پرترافیک اجرا کنم؟ نه، همیشه در ساعات کم‌ترافیک، مثلاً ۳ تا ۵ بامداد.

چطور با CASCADE مقابله کنم؟ قبل از حذف رکورد اصلی، زیرمجموعه‌ها را به‌صورت دستی حذف کنید. این کار، کوئری‌های اضافی Django را حذف می‌کند.

آیا باید آرشیو را در همان دیتابیس ذخیره کنم؟ نه، آرشیو باید در یک storage خارجی (S3، فایل، یا دیتابیس جداگانه) ذخیره شود.

چطور با replication lag مقابله کنم؟ با batch_size کوچک‌تر و اجرای کامند در ساعات کم‌ترافیک. همچنین می‌توانید با throttling، فاصله بین batchها را بیشتر کنید.

آیا باید کامند را در transaction.atomic قرار دهم؟ در کل کامند، نه. در هر batch، بله. تراکنش طولانی، مزایای batch را از بین می‌برد.

چطور از حذف داده‌ی اشتباه جلوگیری کنم؟ با dry-run، نمایش بازه‌ی زمانی، نمونه‌گیری و اعتبارسنجی ورودی.

آیا باید کامند را در یک اپ جداگانه قرار دهم؟ نه، کامندهای یک اپ باید در همان اپ باشند. اگر کامند شما به چند اپ مربوط است، بهتر است در اپ اصلی (core یا مشابه) قرار بگیرد.

چطور با دیتابیس‌های توزیع‌شده مقابله کنم؟ با هماهنگی master و replicaها. ابتدا روی replica یک dry-run اجرا کنید، سپس روی master اجرا کنید.

آیا باید backup دیتابیس قبل از کامند بگیرم؟ برای اولین اجرا، بله. برای اجراهای بعدی، اگر کامند به‌درستی تست شده باشد، معمولاً نه.

چطور با کامندهای طولانی (چند ساعت) مقابله کنم؟ با نمایش پیشرفت دوره‌ای، ذخیره‌ی state در Redis یا دیتابیس، و امکان ادامه از نقطه‌ی توقف.

آیا باید کامند را با Celery اجرا کنم یا مستقیم؟ برای پروژه‌های بزرگ، Celery به‌خاطر مدیریت خطا و monitoring بهتر. برای پروژه‌های کوچک، cron کافی است.

چطور با کامندهای موازی مقابله کنم؟ اگر چند کامند همزمان اجرا شوند، ممکن است با هم تداخل کنند. از یک lock در Redis یا دیتابیس استفاده کنید.

def acquire_lock(lock_name: str, timeout: int = 3600) -> bool:
    from django.core.cache import cache
    return cache.add(f"lock:{lock_name}", 1, timeout=timeout)

نگاهی از منظر مهندس داده در مقیاس میلیونی

در مقیاس میلیون‌ها ردیف در روز، پاک‌سازی داده تبدیل به یک مسئله‌ی مهندسی داده می‌شود. سه مفهوم بنیادین را باید بازتعریف کنید.

مفهوم اول، پارتیشن‌بندی. به‌جای حذف ردیف به ردیف، از پارتیشن‌بندی استفاده کنید. حذف یک پارتیشن کامل، به یک DROP TABLE تبدیل می‌شود که تقریباً لحظه‌ای است. مفهوم Partition در پایگاه داده در ویکی‌پدیا توضیح داده شده است. برای جدول‌های آماری، پارتیشن‌بندی ماهانه یک الگوی استاندارد است.

مفهوم دوم، tiered storage. در معماری‌های مدرن، داده‌های قدیمی از دیتابیس اصلی به یک storage سرد (مثل S3 با Athena یا ClickHouse) منتقل می‌شوند. این انتقال، به‌جای حذف، مهاجرت است. داده‌های تاریخی همچنان قابل کوئری هستند، ولی هزینه‌ی نگهداری پایین‌تری دارند.

مفهوم سوم، lifecycle policy. در سیستم‌های بزرگ، به‌جای یک کامند که همه‌چیز را پاک می‌کند، یک lifecycle policy تعریف می‌شود: داده‌های ۰ تا ۳۰ روز در دیتابیس اصلی، ۳۰ تا ۹۰ روز در یک دیتابیس آرشیو، و بعد از ۹۰ روز در S3. این policy، به‌صورت خودکار و پیوسته اعمال می‌شود.

نکته‌ی آخر: در مقیاس بالا، پاک‌سازی نباید یک عملیات دوره‌ای باشد. باید یک فرایند پیوسته باشد که در پس‌زمینه اجرا می‌شود. اگر با الگوی بهینه‌سازی جنگو برای ترافیک بالا کار کرده باشید، می‌دانید که این نوع فرایندهای پیوسته، بخشی از معماری مدرن هستند.

پرسشی که در پایان باید پاسخ دهید

قبل از اینکه استراتژی پاک‌سازی داده خود را نهایی کنید، یک پرسش را از خودتان بپرسید: «اگر فردا تعداد رکوردهای جدول Interaction سه برابر شود، آیا استراتژی پاک‌سازی من همچنان جواب می‌دهد؟» اگر پاسخ شما «نه» است، یعنی به پارتیشن‌بندی یا lifecycle policy نیاز دارید. اگر پاسخ شما «بله» است، یعنی استراتژی شما به‌درستی طراحی شده است.

پاک‌سازی داده، در نهایت یک تصمیم مهندسی است که به پایداری سیستم و قابلیت نگهداری داده‌ی شما گره خورده است. اگر این تجربه را در پروژه‌ی خودتان داشته‌اید — مثلاً جایی که یک DELETE ساده سایت شما را از کار انداخت یا جایی که طرح بازیابی نجات‌تان داد — برایم جالب است بدانید. مخصوصاً اگر راه‌حل خاصی برای یک سناریوی خاص پیدا کرده‌اید، چون همان راه‌حل‌ها می‌توانند به خواننده‌ی بعدی کمک کنند. تجربه‌ی خودتان را در دیدگاه‌ها بنویسید.