اگر تا امروز یک DELETE FROM table WHERE id NOT IN (SELECT ...) ساده روی جدولی با چند میلیون ردیف اجرا کرده‌اید، احتمالاً سایت شما برای چند دقیقه قفل شده و ترافیک کاربران روی خطا افتاده است؛ چون حذف رکوردهای یتیم و تکراری، یکی از ظریف‌ترین عملیات‌های دیتابیس است که به یک استراتژی چندلایه نیاز دارد.

رکورد یتیم و تکراری دقیقاً چیست؟

در یک دیتابیس رابطه‌ای، رکورد یتیم (Orphan Record) به رکوردی گفته می‌شود که یک رابطه‌ی خارجی آن به یک رکورد ناموجود اشاره می‌کند. مثلاً یک PageView که visit_id آن به یک سشن حذف‌شده اشاره می‌کند، یا یک Interaction که visitor_id آن به یک کاربر پاک‌شده اشاره دارد.

رکورد تکراری (Duplicate Record) به رکوردی گفته می‌شود که یک رکورد دیگر با ترکیب مشابه در دیتابیس وجود دارد. مثلاً دو ردیف Interaction با همان event_id که نشان می‌دهد ارسال beacon دو بار انجام شده.

هر دو نوع این رکوردها، بدون توجه به علت ایجادشان، سه مشکل جدی ایجاد می‌کنند:

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

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

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

رکوردهای یتیم و تکراری، مانند رسوب در لوله‌های آب‌رسانی هستند: نامرئی‌اند تا زمانی که لوله را ببندند.

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

چرا این داده‌ها جمع می‌شوند؟

رکوردهای یتیم و تکراری، به دلایل مختلفی جمع می‌شوند. شناخت این دلایل، به شما کمک می‌کند که هم جلوی ایجاد آن‌ها را بگیرید و هم استراتژی حذف مؤثرتری داشته باشید.

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

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

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

دلیل چهارم، مهاجرت‌های ناقص. اگر یک migration نیمه‌کاره متوقف شود، ممکن است رکوردهایی با null یا مقادیر پیش‌فرض ساخته شوند.

دلیل پنجم، تغییرات در schema. اگر یک فیلد جدید به مدل اضافه شود و مقدار پیش‌فرض آن با منطق برنامه سازگار نباشد، ممکن است رکوردهای ناسازگار ایجاد شود.

# نمونه‌ای از ایجاد رکورد یتیم
visit = Visit.objects.get(pk=123)
# اگر کاربر این Visit را حذف کند ولی PageViewها باقی بمانند
visit.delete()  # اگر CASCADE نباشد، PageViewها یتیم می‌شوند

اگر با الگوی ذخیره‌ی PageView و Interaction کار کرده باشید، می‌دانید که ساختار روابط پیچیده، احتمال ایجاد رکورد یتیم را بالا می‌برد.

تأثیر پنهان رکوردهای یتیم بر سایت

رکوردهای یتیم، در نگاه اول بی‌ضرر به‌نظر می‌رسند، ولی تأثیرات پنهانشان به‌تدریج خودشان را نشان می‌دهند. چهار تأثیر اصلی:

تأثیر اول، کوئری‌های کند. اگر کوئری‌های شما join داشته باشند، رکوردهای یتیم باعث می‌شوند join‌ها نتیجه‌ی ناقص بدهند یا کندتر اجرا شوند.

تأثیر دوم، گزارش‌های اشتباه. اگر روی جدول Interaction یک گزارش count() بگیرید، رکوردهای یتیم هم شمرده می‌شوند و عدد نهایی اشتباه است.

تأثیر سوم، مصرف فضا. در مقیاس میلیونی، رکوردهای یتیم می‌توانند چند گیگابایت فضا اشغال کنند.

تأثیر چهارم، پیچیدگی دیباگ. اگر خطایی در منطق برنامه رخ دهد، دیباگ کردن با حضور رکوردهای یتیم بسیار سخت‌تر است.

نوع مشکلتعداد رکوردتأثیر تقریبی
کوئری کند۱۰ هزار یتیم۱۰-۲۰٪ کندی
گزارش اشتباه۱۰۰ هزار یتیم۵-۱۵٪ خطا در آمار
مصرف فضا۱ میلیون یتیم۵۰۰ مگابایت تا ۲ گیگابایت
کندی کلی سایت۱۰ میلیون یتیم۳۰-۵۰٪ کاهش کارایی

در یکی از پروژه‌ها، بعد از یک سال بدون پاک‌سازی، جدول PageView به ۱۸ میلیون ردیف رسیده بود که حدود ۲۲٪ آن یتیم بودند. با پاک‌سازی، اندازه‌ی جدول ۳۰٪ کاهش پیدا کرد و کوئری‌های تحلیلی ۲.۵ برابر سریع‌تر شدند.

تشخیص ناهنجاری؛ قبل از هر حذفی

قبل از هر عملیات حذف، باید بدانید دقیقاً چه چیزی را حذف می‌کنید. سه سطح تشخیص را در نظر بگیرید.

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

from analytics.models import PageView, Visit


orphan_pageviews = PageView.objects.filter(
    visit__isnull=True,
).count()

# یا با استفاده از raw SQL
from django.db import connection


with connection.cursor() as cursor:
    cursor.execute("""
        SELECT COUNT(*)
        FROM analytics_page_view pv
        LEFT JOIN analytics_visit v ON pv.visit_id = v.id
        WHERE pv.visit_id IS NOT NULL AND v.id IS NULL
    """)
    result = cursor.fetchone()

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

from django.db.models import Count


duplicates = (
    Interaction.objects
    .values("event_id")
    .annotate(count=Count("id"))
    .filter(count__gt=1)
    .count()
)

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

from django.db.models import Q


inconsistent = Visit.objects.filter(
    Q(exit_time__isnull=False) & Q(entry_time__gt=F("exit_time"))
)

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

استراتژی چک‌پوینت؛ جایگزین امن DELETE بزرگ

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

# analytics/management/commands/cleanup_orphans.py

import time
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone

from analytics.models import PageView, Interaction, Visit


DEFAULT_BATCH_SIZE = 500
DEFAULT_SLEEP_MS = 100


class Command(BaseCommand):
    help = "حذف رکوردهای یتیم به‌صورت batch"

    def add_arguments(self, parser):
        parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
        parser.add_argument("--sleep-ms", type=int, default=DEFAULT_SLEEP_MS)
        parser.add_argument("--max-batches", type=int, default=1000)
        parser.add_argument("--dry-run", action="store_true")

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

        if batch_size < 50:
            raise CommandError("--batch-size حداقل 50.")
        if batch_size > 5000:
            raise CommandError("--batch-size حداکثر 5000.")

        started = time.time()

        orphan_pageviews = self._count_orphans_pageviews()
        orphan_interactions = self._count_orphans_interactions()

        self.stdout.write(f"PageView یتیم: {orphan_pageviews}")
        self.stdout.write(f"Interaction یتیم: {orphan_interactions}")

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

        deleted_pv = self._delete_orphans(
            model=PageView,
            batch_size=batch_size,
            sleep_ms=sleep_ms,
            max_batches=max_batches,
            label="PageView",
        )
        deleted_int = self._delete_orphans(
            model=Interaction,
            batch_size=batch_size,
            sleep_ms=sleep_ms,
            max_batches=max_batches,
            label="Interaction",
        )

        elapsed = time.time() - started
        self.stdout.write(self.style.SUCCESS(
            f"حذف شد: {deleted_pv} PageView، {deleted_int} Interaction "
            f"در {elapsed:.2f} ثانیه"
        ))

    def _count_orphans_pageviews(self):
        return PageView.objects.filter(visit__isnull=True).count()

    def _count_orphans_interactions(self):
        return Interaction.objects.filter(visit__isnull=True).count()

    def _delete_orphans(
        self, model, batch_size, sleep_ms, max_batches, label,
    ):
        total_deleted = 0
        batch_count = 0

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

            try:
                with transaction.atomic():
                    model.objects.filter(pk__in=ids).delete()
                total_deleted += len(ids)
                batch_count += 1

                self.stdout.write(
                    f"  {label} batch {batch_count}: {total_deleted}"
                )

            except Exception as exc:
                self.stderr.write(f"  خطا در batch {batch_count}: {exc}")
                break

            if sleep_ms > 0:
                time.sleep(sleep_ms / 1000.0)

        return total_deleted

این کامند، چند نکته‌ی کلیدی را رعایت می‌کند.

نکته‌ی اول، batch_size قابل تنظیم. مقدار پیش‌فرض ۵۰۰ رکورد، که تعادل خوبی بین سرعت و قفل ایجاد می‌کند.

نکته‌ی دوم، sleep بین batchها. با --sleep-ms، فاصله‌ی زمانی بین batchها کنترل می‌شود. این کار، به دیتابیس فرصت نفس کشیدن می‌دهد.

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

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

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

نکته‌ی مهم: مقدار بهینه‌ی sleep-ms به بار سرور بستگی دارد. در ساعات کم‌ترافیک، می‌توانید sleep را صفر کنید. در ساعات پرترافیک، ۱۰۰ تا ۵۰۰ میلی‌ثانیه. اگر با الگوی بهینه‌سازی جنگو برای ترافیک بالا کار کرده باشید، می‌دانید که این تنظیمات باید با کالیبراسیون مشخص شوند.

پیاده‌سازی batch delete در جنگو

در پیاده‌سازی batch delete، چند تکنیک ظریف وجود دارد که کارایی و امنیت را بالا می‌برد.

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

# روش بد
PageView.objects.filter(visit__isnull=True)[:500].delete()
# این خطا می‌دهد چون Django روی slice شده نمی‌تواند delete بزند

# روش بهتر
ids = list(
    PageView.objects
    .filter(visit__isnull=True)
    .values_list("pk", flat=True)[:500]
)
PageView.objects.filter(pk__in=ids).delete()

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

with transaction.atomic():
    # ابتدا Interaction
    Interaction.objects.filter(visit_id__in=visit_ids).delete()
    # سپس PageView
    PageView.objects.filter(visit_id__in=visit_ids).delete()
    # در نهایت Visit
    Visit.objects.filter(pk__in=visit_ids).delete()

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

# یتیم‌هایی که بیش از ۹۰ روز قدمت دارند
from datetime import timedelta
from django.utils import timezone

cutoff = timezone.now() - timedelta(days=90)

ids = list(
    PageView.objects
    .filter(visit__isnull=True, entered_at__lt=cutoff)
    .values_list("pk", flat=True)[:500]
)
PageView.objects.filter(pk__in=ids).delete()

تکنیک چهارم، استفاده از select_for_update. در محیط‌های پرترافیک، ممکن است بین خواندن pkها و حذف آن‌ها، رکوردی توسط درخواست دیگری تغییر کند. برای جلوگیری از این مسئله، از select_for_update استفاده کنید.

with transaction.atomic():
    ids = list(
        PageView.objects
        .filter(visit__isnull=True)
        .select_for_update(skip_locked=True)
        .values_list("pk", flat=True)[:500]
    )
    PageView.objects.filter(pk__in=ids).delete()

نکته‌ی مهم: skip_locked=True به دیتابیس می‌گوید که رکوردهای قفل‌شده را نادیده بگیرد. این کار، از توقف کامند به‌خاطر قفل‌های دیگر جلوگیری می‌کند.

batch delete در جنگو، یک دستور نیست؛ یک استراتژی است که از ترکیب تکنیک‌های مختلف ساخته می‌شود.

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

حذف رکوردهای تکراری؛ چالش‌های خاص

حذف رکوردهای تکراری، چالش‌های خاص خودش را دارد، چون نمی‌توانید صرفاً با delete() عمل کنید. باید تصمیم بگیرید کدام رکورد نگه داشته شود.

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

from django.db.models import Min


# شناسایی رکوردهایی که باید نگه داشته شوند
keep_ids = (
    Interaction.objects
    .values("event_id")
    .annotate(min_id=Min("id"))
    .values_list("min_id", flat=True)
)

# حذف بقیه
Interaction.objects.exclude(id__in=keep_ids).filter(
    event_id__isnull=False,
).exclude(event_id="").delete()

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

from django.db.models import Max


keep_ids = (
    Interaction.objects
    .values("event_id")
    .annotate(max_id=Max("id"))
    .values_list("max_id", flat=True)
)

Interaction.objects.exclude(id__in=keep_ids).filter(
    event_id__isnull=False,
).exclude(event_id="").delete()

رویکرد سوم، ادغام داده‌ها. اگر رکوردهای تکراری داده‌های متفاوتی دارند، می‌توانید آن‌ها را در یک رکورد ادغام کنید.

from django.db.models import Count


# گروه‌های تکراری
groups = (
    Interaction.objects
    .values("event_id")
    .annotate(count=Count("id"))
    .filter(count__gt=1)
)

for group in groups:
    event_id = group["event_id"]
    records = list(
        Interaction.objects.filter(event_id=event_id).order_by("id")
    )
    if len(records) < 2:
        continue

    keep = records[0]
    # ادغام metadata
    merged_metadata = {}
    for record in records:
        if record.metadata:
            merged_metadata.update(record.metadata)
    keep.metadata = merged_metadata
    keep.save()

    # حذف بقیه
    delete_ids = [r.pk for r in records[1:]]
    Interaction.objects.filter(pk__in=delete_ids).delete()

نکته‌ی مهم: قبل از حذف رکوردهای تکراری، همیشه یک unique constraint روی فیلدهای کلیدی تعریف کنید تا از ایجاد مجدد جلوگیری شود.

class Interaction(models.Model):
    event_id = models.CharField(max_length=36, blank=True, db_index=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["event_id"],
                name="unique_interaction_event",
                condition=models.Q(event_id__gt=""),
            ),
        ]

اگر با الگوی ذخیره‌ی PageView و Interaction کار کرده باشید، می‌دانید که این نوع unique constraint، بخشی از طراحی idempotent است.

اجرای idempotent؛ اگر کامند وسط کار قطع شد

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

راه‌حل، اجرای idempotent است: کامند باید بتواند چندین بار اجرا شود، بدون اینکه تأثیر اضافه داشته باشد.

import json
import os


CHECKPOINT_FILE = "/var/lib/analytics/cleanup_checkpoint.json"


def save_checkpoint(state: dict):
    tmp = CHECKPOINT_FILE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(state, f)
    os.replace(tmp, CHECKPOINT_FILE)


def load_checkpoint() -> dict:
    if not os.path.exists(CHECKPOINT_FILE):
        return {}
    try:
        with open(CHECKPOINT_FILE) as f:
            return json.load(f)
    except Exception:
        return {}

و در کامند:

class Command(BaseCommand):
    def handle(self, *args, **options):
        state = load_checkpoint()
        last_id = state.get("last_pageview_id", 0)

        while True:
            ids = list(
                PageView.objects
                .filter(visit__isnull=True, pk__gt=last_id)
                .values_list("pk", flat=True)[:500]
            )
            if not ids:
                break

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

            last_id = ids[-1]
            save_checkpoint({"last_pageview_id": last_id})
            self.stdout.write(f"  checkpoint: {last_id}")

این ساختار، سه مزیت کلیدی دارد.

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

مزیت دوم، idempotency. اجرای دوباره‌ی کامند، تأثیر اضافه ندارد، چون رکوردهای حذف‌شده دوباره پیدا نمی‌شوند.

مزیت سوم، سبک بودن. ذخیره‌ی checkpoint در یک فایل JSON، overhead کمی دارد.

نکته‌ی مهم: در محیط‌های توزیع‌شده، به‌جای فایل، از یک key-value store (مثل Redis) استفاده کنید. اگر با الگوی ساخت API JSON برای آمار زنده کار کرده باشید، می‌دانید که Redis، در این نوع سناریوها ابزار مناسبی است.

پارتیشن‌بندی؛ راه‌حل نهایی مقیاس

در مقیاس میلیون‌ها ردیف، حتی batch delete هم می‌تواند زمان‌بر باشد. راه‌حل نهایی، پارتیشن‌بندی است: تقسیم جدول به چند زیرجدول کوچک‌تر بر اساس یک کلید منطقی (مثلاً تاریخ).

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

# در PostgreSQL
CREATE TABLE analytics_page_view (
    id BIGSERIAL,
    entered_at TIMESTAMPTZ NOT NULL,
    visit_id BIGINT,
    -- ...
    PRIMARY KEY (id, entered_at)
) PARTITION BY RANGE (entered_at);

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

نکته‌ی مهم: در PostgreSQL، کلید اصلی جدول پارتیشن‌شده باید شامل فیلد پارتیشن باشد. یعنی PRIMARY KEY (id, entered_at). اگر این نکته را رعایت نکنید، پارتیشن‌بندی شکست می‌خورد.

و در جنگو، برای کار با جدول پارتیشن‌شده، معمولاً از یک migration دستی یا کتابخانه‌هایی مثل django-postgres-extra استفاده می‌شود. اگر با الگوی بهینه‌سازی جنگو برای ترافیک بالا کار کرده باشید، می‌دانید که این نوع معماری، در پروژه‌های بزرگ استاندارد است.

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

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

import gzip
import json


def archive_and_delete(model, ids, archive_path):
    with gzip.open(archive_path, "at", encoding="utf-8") as f:
        for row in model.objects.filter(pk__in=ids).values().iterator():
            f.write(json.dumps(row, default=str) + "\n")

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

نکته‌ی مهم: آرشیو باید در یک storage خارجی (S3، فایل، یا دیتابیس جداگانه) ذخیره شود، نه در همان دیتابیس اصلی. این کار، هم فضای دیتابیس را آزاد می‌کند، هم از فاجعه‌ی پاک شدن آرشیو در اثر خطای انسانی جلوگیری می‌کند.

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

مانیتورینگ و هشدار

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

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

import logging


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


def log_cleanup(start_time, deleted_count, errors):
    duration = (timezone.now() - start_time).total_seconds()
    logger.info(
        "orphan_cleanup_completed",
        extra={
            "deleted_count": deleted_count,
            "duration_seconds": duration,
            "errors": errors,
        },
    )

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

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

امنیت و کنترل دسترسی به کامند

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

لایه‌ی اول، محدودیت اجرا به کاربران مشخص. کامند باید فقط توسط کاربران با نقش admin اجرا شود. این کار با بررسی os.getuid() یا مشابه در ابتدای کامند انجام می‌شود.

import os


ALLOWED_USERS = {"www-data", "deploy"}


def check_user():
    import getpass
    current_user = getpass.getuser()
    if current_user not in ALLOWED_USERS:
        raise CommandError(f"کاربر {current_user} مجاز نیست.")

لایه‌ی دوم، لاگ‌گیری از اجرا. هر اجرا باید در یک log مخصوص ثبت شود، شامل نام کاربر، زمان و پارامترها.

لایه‌ی سوم، محدودیت سخت‌گیرانه. کامند باید به‌طور پیش‌فرض dry-run باشد. برای اجرای واقعی، باید --confirm مشخص شود.

parser.add_argument(
    "--confirm", action="store_true",
    help="برای اجرای واقعی، این گزینه لازم است.",
)


if not options["confirm"] and not dry_run:
    raise CommandError("برای اجرای واقعی، --confirm را اضافه کنید.")

این لایه، از اجرای تصادفی کامند در محیط production جلوگیری می‌کند.

تست استراتژی پاک‌سازی

سه سطح تست را در نظر بگیرید.

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

import pytest
from django.core.management import call_command
from analytics.models import PageView, Visit, Visitor


@pytest.mark.django_db
def test_dry_run_does_not_delete():
    visitor = Visitor.objects.create(fingerprint="test", ip_address="1.2.3.4")
    # ساخت PageView یتیم
    PageView.objects.create(
        visit=None,
        visitor=visitor,
        path="/",
        url="https://example.com/",
        entered_at=timezone.now(),
    )

    call_command("cleanup_orphans", dry_run=True)

    assert PageView.objects.count() == 1

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

@pytest.mark.django_db
def test_cleanup_deletes_only_orphans():
    visitor = Visitor.objects.create(fingerprint="test", ip_address="1.2.3.4")
    visit = Visit.objects.create(
        visitor=visitor,
        entry_time=timezone.now(),
        entry_url="https://example.com/",
        entry_path="/",
        is_active=True,
    )

    PageView.objects.create(
        visit=None, visitor=visitor,
        path="/orphan", url="https://example.com/orphan",
        entered_at=timezone.now(),
    )
    PageView.objects.create(
        visit=visit, visitor=visitor,
        path="/", url="https://example.com/",
        entered_at=timezone.now(),
    )

    call_command("cleanup_orphans")

    assert PageView.objects.count() == 1
    assert PageView.objects.first().path == "/"

سطح سوم، تست checkpoint. بررسی کنید که کامند می‌تواند از نقطه‌ی توقف ادامه دهد.

@pytest.mark.django_db
def test_checkpoint_resume(tmp_path, monkeypatch):
    # مسیر checkpoint را به tmp تغییر دهیم
    monkeypatch.setattr(
        "analytics.management.commands.cleanup_orphans.CHECKPOINT_FILE",
        str(tmp_path / "checkpoint.json"),
    )
    # ... تست

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

anti-patternهای رایج در حذف رکوردهای یتیم

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

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

۲. عدم تشخیص قبل از حذف. بدون شمارش دقیق، نمی‌دانید که اجرا چقدر طول می‌کشد.

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

۴. تکیه بر CASCADE. CASCADE در حجم بالا، کوئری‌های اضافی می‌زند و تراکنش را بزرگ می‌کند.

۵. اجرا در ساعات پرترافیک. پاک‌سازی باید در ساعات کم‌ترافیک اجرا شود.

۶. عدم sleep بین batchها. بدون sleep، دیتابیس overload می‌شود.

۷. عدم checkpoint. اگر کامند قطع شود، باید از صفر شروع کنید.

۸. عدم unique constraint. بدون constraint، رکوردهای تکراری دوباره ساخته می‌شوند.

۹. عدم مدیریت خطا. اگر یک batch شکست خورد، کل کامند باید متوقف شود.

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

۱۱. عدم کنترل دسترسی. هر کسی نباید بتواند کامند را اجرا کند.

۱۲. عدم تست. بدون تست، نمی‌دانید که کامند در شرایط مختلف چطور رفتار می‌کند.

اگر با الگوی حذف رکوردهای تکراری و یتیم با batch delete کار کرده باشید، این anti-patternها برایتان آشناست.

پرسش‌های پرتکرار درباره‌ی پاک‌سازی رکوردهای یتیم

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

آیا باید بین batchها sleep کنم؟ در ساعات پرترافیک، بله. در ساعات کم‌ترافیک، معمولاً نیازی نیست.

چطور رکوردهای یتیم را تشخیص دهم؟ با filter(visit__isnull=True) یا با raw SQL و LEFT JOIN.

چطور از ایجاد مجدد رکوردهای یتیم جلوگیری کنم؟ با FK constraint و on_delete=CASCADE یا SET_NULL. همچنین با مکانیزم‌های تأیید در سطح اپلیکیشن.

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

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

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

آیا باید در transaction.atomic قرار دهم؟ هر batch، بله. کل کامند، نه.

چطور با رکوردهای تکراری مقابله کنم؟ با unique constraint و مکانیزم idempotency در سطح اپلیکیشن.

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

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

چطور با قطع شدن کامند مقابله کنم؟ با checkpoint در فایل یا Redis و ادامه از نقطه‌ی توقف.

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

آیا باید کامند را در یک اپ جداگانه قرار دهم؟ نه، کامندهای یک اپ باید در همان اپ باشند.

چطور با پارتیشن‌بندی مقابله کنم؟ در PostgreSQL، از declarative partitioning. در MySQL، از RANGE partitioning. با پارتیشن‌بندی، حذف یک ماه داده به یک DROP TABLE تبدیل می‌شود.

آیا باید لاگ کامند را در جایی غیر از فایل ذخیره کنم؟ برای پروژه‌های جدی، لاگ‌ها باید در یک سیستم متمرکز (Sentry، ELK، Loki) ذخیره شوند.

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

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

مفهوم اول، referential integrity. به‌جای تکیه بر FK constraint در سطح دیتابیس، باید در سطح اپلیکیشن هم referential integrity را تضمین کنید. این کار با transaction‌های دقیق و idempotency انجام می‌شود. مفهوم Referential Integrity در ویکی‌پدیا توضیح داده شده است.

مفهوم دوم، event sourcing. به‌جای ذخیره‌ی state نهایی، می‌توانید رویدادها را ذخیره کنید و state را از آن‌ها بازسازی کنید. این معماری، مشکل رکوردهای یتیم را به‌کلی حل می‌کند، چون همه‌چیز در یک event stream پیوسته است.

مفهوم سوم، tiered storage. رکوردهای یتیم را می‌توانید به یک storage سرد منتقل کنید، به‌جای حذف. این کار، از فضای دیتابیس اصلی می‌کاهد، ولی داده را برای بازیابی حفظ می‌کند.

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

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

قبل از اینکه استراتژی پاک‌سازی رکوردهای یتیم خود را نهایی کنید، یک پرسش را از خودتان بپرسید: «اگر امروز دیتابیس سایت‌ام را بازرسی کنم، چند درصد از رکوردها یتیم یا تکراری هستند؟» اگر پاسخ شما «نمی‌دانم» است، یعنی سیستم شما به یک لایه‌ی تشخیص نیاز دارد. اگر پاسخ شما «کمتر از ۱٪» است، یعنی سیستم شما به‌درستی طراحی شده است.

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