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

چرا Visitor و Visit باید جدا باشند؟

در اولین پروژه‌ی آماری که سال‌ها پیش نوشتم، همه‌چیز را در یک جدول واحد ذخیره می‌کردم: IP، User-Agent، مسیر، زمان. بعد از چند ماه، گزارش‌گیری از این جدول تقریباً غیرممکن شد. اگر می‌خواستم بدانم یک کاربر خاص در ۳۰ روز گذشته چند بار به سایت آمده، باید تمام ردیف‌های آن IP را می‌گشتم و از روی هیوریستیک تشخیص می‌دادم کدام‌ها یک سشن واحدند. این کار هم کند بود، هم غیرقابل اتکا. درس گرفتم که این دو مفهوم ذاتاً متفاوتند.

یک Visitor یک هویت است. یک Visit یک رویداد است. هویت، پایدار است. رویداد، گذراست. اگر این دو را قاطی کنید، کوئری‌های «کاربر یکتا در این ماه» را نمی‌توانید بدون پیچیدگی بنویسید. جداسازی این دو، شما را قادر می‌سازد تا تحلیل‌هایی مثل retention، frequency و cohort بسازید که بدون آن‌ها سیستم آماری شما فقط یک شمارنده است، نه یک ابزار بینش.

اگر Visitor و Visit در یک جدول باشند، شما دارید داده ذخیره می‌کنید. اگر جدا باشند، دارید دانش ذخیره می‌کنید.

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

طراحی مدل Visitor؛ هویت یکتای بازدیدکننده

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

# analytics/models.py

from django.db import models


class Visitor(models.Model):
    DEVICE_CHOICES = [
        ("desktop", "Desktop"),
        ("mobile", "Mobile"),
        ("tablet", "Tablet"),
        ("bot", "Bot"),
        ("unknown", "Unknown"),
    ]

    fingerprint = models.CharField(
        max_length=64, unique=True, db_index=True,
    )

    ip_address = models.GenericIPAddressField(db_index=True)
    user_agent = models.TextField(blank=True)

    device_type = models.CharField(
        max_length=20, choices=DEVICE_CHOICES,
        default="unknown", db_index=True,
    )
    browser_name = models.CharField(max_length=50, blank=True)
    browser_version = models.CharField(max_length=30, blank=True)
    os_name = models.CharField(max_length=50, blank=True)
    os_version = models.CharField(max_length=30, blank=True)

    first_seen = models.DateTimeField(auto_now_add=True, db_index=True)
    last_seen = models.DateTimeField(auto_now=True, db_index=True)

    class Meta:
        db_table = "analytics_visitor"
        indexes = [
            models.Index(fields=["ip_address", "last_seen"]),
            models.Index(fields=["device_type", "first_seen"]),
        ]

    def __str__(self):
        return f"{self.ip_address} ({self.device_type})"

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

انتخاب اول، GenericIPAddressField به‌جای CharField. این فیلد، IP را در دیتابیس به شکل بومی ذخیره می‌کند و امکان کوئری‌های شبکه‌ای مثل «همه‌ی IPهای در محدوده‌ی x.x.x.0/24» را فراهم می‌سازد. اگر روی MySQL یا PostgreSQL کار می‌کنید، این فیلد به‌طور خودکار ایندکس‌پذیر است.

انتخاب دوم، TextField برای User-Agent. User-Agentهای مدرن معمولاً بین ۱۰۰ تا ۲۵۰ کاراکتر هستند، ولی در موارد نادری از ۵۰۰ کاراکتر هم عبور می‌کنند. اگر از CharField(max_length=255) استفاده کنید، در آینده با خطای truncation مواجه می‌شوید. این اشتباه را در پروژه‌ای دیده‌ام که بعد از یک migration دردناک، مجبور به تبدیل فیلد شدیم.

انتخاب سوم، device_type به‌عنوان فیلد denormalized. می‌شد این مقدار را در لحظه‌ی کوئری از User-Agent استخراج کرد، ولی ذخیره‌ی آن در Visitor دو مزیت دارد: اول، کوئری «چند درصد کاربران موبایل هستند» به یک COUNT ساده تبدیل می‌شود. دوم، اگر بعداً منطق تشخیص دستگاه تغییر کند، داده‌های قدیمی دست‌نخورده می‌مانند. اگر می‌خواهید منطق تشخیص را دقیق‌تر کنید، تشخیص دستگاه کاربر در جنگو راهنمای کامل است.

طراحی مدل Visit؛ هر سشن یک داستان

مدل Visit، مفصل‌ترین مدل این سیستم است، چون تمام اطلاعات رفتاری در آن جمع می‌شود. سه گروه فیلد داریم: ورود، رفتار حین سشن، و خروج.

class Visit(models.Model):
    REFERRER_TYPES = [
        ("direct", "Direct"),
        ("google", "Google"),
        ("bing", "Bing"),
        ("yahoo", "Yahoo"),
        ("social", "Social"),
        ("internal", "Internal"),
        ("other", "Other"),
        ("bot", "Bot"),
    ]

    visitor = models.ForeignKey(
        Visitor, on_delete=models.CASCADE,
        related_name="visits",
    )

    # --- ورود ---
    entry_time = models.DateTimeField(db_index=True)
    entry_url = models.CharField(max_length=2000)
    entry_path = models.CharField(max_length=500, db_index=True)
    entry_title = models.CharField(max_length=500, blank=True)
    referrer_url = models.CharField(max_length=2000, blank=True)
    referrer_domain = models.CharField(
        max_length=255, blank=True, db_index=True,
    )
    referrer_type = models.CharField(
        max_length=20, choices=REFERRER_TYPES,
        default="direct", db_index=True,
    )

    # --- صفحه‌نمایش و اتصال ---
    screen_width = models.PositiveIntegerField(null=True, blank=True)
    screen_height = models.PositiveIntegerField(null=True, blank=True)
    viewport_width = models.PositiveIntegerField(null=True, blank=True)
    viewport_height = models.PositiveIntegerField(null=True, blank=True)
    connection_type = models.CharField(max_length=30, blank=True)

    # --- خروج ---
    exit_time = models.DateTimeField(null=True, blank=True, db_index=True)
    exit_url = models.CharField(max_length=2000, blank=True)
    exit_path = models.CharField(max_length=500, blank=True)

    # --- آمار denormalized ---
    duration_seconds = models.PositiveIntegerField(default=0)
    pages_count = models.PositiveIntegerField(default=0)
    max_scroll = models.PositiveIntegerField(default=0)

    # --- وضعیت ---
    last_activity = models.DateTimeField(
        null=True, blank=True, db_index=True,
    )
    is_active = models.BooleanField(default=True, db_index=True)
    notes = models.TextField(blank=True)

    class Meta:
        db_table = "analytics_visit"
        indexes = [
            models.Index(fields=["entry_time"]),
            models.Index(fields=["visitor", "entry_time"]),
            models.Index(fields=["is_active", "last_activity"]),
            models.Index(fields=["referrer_type", "entry_time"]),
        ]

چند تصمیم فنی در این مدل، مستقیماً از تجربه‌ی پروژه‌های واقعی می‌آید.

تصمیم اول، ذخیره‌ی هر دو entry_url و entry_path. چرا هر دو؟ چون برای کوئری‌های گروهی به entry_path نیاز دارید (مثلاً «پربازدیدترین صفحه‌ی ورودی»)، ولی برای نمایش لینک کامل به کاربر، entry_url لازم است. اگر فقط URL را ذخیره کنید، برای استخراج path باید در هر کوئری SUBSTRING بزنید که کارایی را پایین می‌آورد.

تصمیم دوم، referrer_domain جدا از referrer_url. این denormalization به شما اجازه می‌دهد تا کوئری‌های «کاربران از کدام دامنه آمده‌اند» را در چند میلی‌ثانیه اجرا کنید. اگر می‌خواهید این طبقه‌بندی را دقیق‌تر کنید، طبقه‌بندی Referrer در جنگو الگوهای کاملی ارائه می‌دهد.

تصمیم سوم، فیلدهای آمار denormalized. فیلدهایی مثل duration_seconds و pages_count در نگاه اول اضافه به‌نظر می‌رسند، چون می‌توان آن‌ها را از روی PageView محاسبه کرد. ولی در عمل، این محاسبه برای هر سشن یک کوئری سنگین است. با ذخیره‌ی آن‌ها در Visit، کوئری‌های داشبورد از چند ثانیه به چند میلی‌ثانیه کاهش پیدا می‌کند. الگوی مشابهی در ساخت API JSON برای آمار زنده استفاده می‌شود، چون در آنجا هم تأخیر پاسخ حیاتی است.

fingerprint؛ هشتی که هویت می‌سازد

قلب مدل Visitor، فیلد fingerprint است. این فیلد یک هش SHA-256 از ترکیب IP و User-Agent است. چرا از خود IP به‌عنوان کلید یکتا استفاده نمی‌کنیم؟ سه دلیل:

۱. چند کاربر پشت یک IP. اگر ۱۰ نفر پشت یک NAT سازمانی به سایت شما وصل شوند، همگی یک IP دارند ولی ۱۰ کاربر متفاوتند. با ترکیب IP و User-Agent، تفکیک بهتری می‌شود.

۲. تغییر IP یک کاربر. کاربران موبایل ممکن است در طول روز چندین بار IP عوض کنند. با fingerprint ترکیبی، اگر User-Agent ثابت باشد، تا حدی هویت حفظ می‌شود.

۳. جلوگیری از ذخیره‌ی مستقیم داده‌ی حساس. اگر از هش استفاده کنید، در دیتابیس IP خام ذخیره نمی‌شود (در صورت استفاده از sha256 بدون ذخیره‌ی IP). این موضوع در بحث GDPR اهمیت دارد.

import hashlib


def make_fingerprint(ip: str, ua: str) -> str:
    raw = f"{ip}|{ua}".encode("utf-8", "ignore")
    return hashlib.sha256(raw).hexdigest()[:64]

نکته‌ی ظریف: در همین پروژه، ما IP را هم ذخیره می‌کنیم. اگر فقط به تحلیل رفتاری نیاز دارید و نمی‌خواهید IP را نگه دارید، فیلد ip_address را از مدل حذف کنید و به fingerprint اکتفا کنید. این تصمیم به سیاست حریم خصوصی شما بستگی دارد.

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

ایندکس‌گذاری؛ جایی که سرعت متولد می‌شود

در جدول‌های آماری، ایندکس‌ها تفاوت بین «سایت سریع» و «سایت کند» هستند. سه نوع ایندکس را باید بشناسید:

نوع ایندکسکاربردمثال
single-columnفیلتر سادهentry_time
compositeفیلتر ترکیبی(visitor, entry_time)
partialفیلتر زیرمجموعهWHERE is_active = TRUE

ایندکس entry_time. اکثر کوئری‌های آماری بر اساس بازه‌ی زمانی فیلتر می‌شوند. بدون این ایندکس، کوئری «آمار امروز» تمام جدول را اسکن می‌کند.

ایندکس (visitor, entry_time). برای کوئری‌هایی که می‌خواهید سفر یک بازدیدکننده را ببینید. ترتیب این دو فیلد مهم است: اول visitor (کاردینالیتی بالا)، بعد entry_time. اگر برعکس بگذارید، ایندکس عملاً بی‌فایده می‌شود.

ایندکس (is_active, last_activity). این ایندکس برای صفحه‌ی «کاربران آنلاین» حیاتی است. چون در هر لحظه، تنها درصد کوچکی از Visitها active هستند، یک partial index روی MySQL یا PostgreSQL می‌تواند اندازه‌ی ایندکس را چند برابر کوچک‌تر کند:

CREATE INDEX idx_active_visits
ON analytics_visit (last_activity)
WHERE is_active = TRUE;

در پروژه‌ای، این تغییر ساده اندازه‌ی ایندکس را از ۴۰۰ مگابایت به ۱۲ مگابایت کاهش داد و زمان کوئری آنلاین‌ها را از ۸۰۰ میلی‌ثانیه به ۴۵ میلی‌ثانیه رساند. این نوع بهینه‌سازی در بهینه‌سازی جنگو برای ترافیک بالا یک الگوی تکرارشونده است.

هشدار مهم: هر ایندکس اضافه، هزینه‌ی نوشتن دارد. اگر جدول شما روزانه ۱۰۰ هزار ردیف جدید می‌گیرد، هر ایندکس اضافه، همان تعداد عملیات درج ایندکس را هم به دیتابیس تحمیل می‌کند. پس ایندکس‌ها را بر اساس الگوهای کوئری واقعی انتخاب کنید، نه بر اساس حدس. ابزارهایی مثل pg_stat_user_indexes در PostgreSQL یا SHOW INDEX در MySQL به شما نشان می‌دهند کدام ایندکس‌ها بی‌استفاده مانده‌اند.

denormalization هدفمند در Visit

در دنیای پایگاه داده، denormalization یک انتخاب است، نه یک ضعف. در طراحی مدل‌های آماری، این انتخاب به‌طور مکرر مفید است. سه فیلد denormalized در Visit داریم:

فیلد pages_count. این فیلد تعداد صفحات بازدیدشده در این سشن است. چرا ذخیره می‌کنیم و از COUNT روی PageView استفاده نمی‌کنیم؟ چون کوئری داشبورد که «میانگین تعداد صفحات در هر بازدید» را حساب می‌کند، اگر بخواهد برای هر Visit یک COUNT بزند، با N+1 مواجه می‌شود. با ذخیره‌ی این فیلد، کوئری تبدیل به یک AVG ساده می‌شود.

فیلد duration_seconds. مدت کل سشن. مقدار این فیلد در لحظه‌ی بستن سشن محاسبه و ذخیره می‌شود. اگر سشن به‌دلیل session timeout بسته شود، همان زمان محاسبه می‌شود.

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

نکته‌ی مهم: denormalization همیشه یک تعهد ایجاد می‌کند. اگر جایی خطا رخ دهد و این فیلدها به‌روز نشوند، داده‌های denormalized از منبع اصلی (PageView) فاصله می‌گیرند. برای اطمینان، یک task شبانه بنویسید که این فیلدها را بازمحاسبه و اصلاح کند:

# analytics/tasks.py
from django.db.models import Count, Max, Sum
from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help = "بازمحاسبه فیلدهای denormalized در Visit"

    def handle(self, *args, **options):
        from analytics.models import Visit

        for visit in Visit.objects.filter(is_active=False).iterator(chunk_size=500):
            agg = visit.page_views.aggregate(
                pc=Count("id"),
                ms=Max("scroll_depth"),
            )
            visit.pages_count = agg["pc"] or 0
            visit.max_scroll = agg["ms"] or 0
            visit.save(update_fields=["pages_count", "max_scroll"])

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

انتخاب نوع فیلد زمان؛ درس‌هایی از پروژه‌های واقعی

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

انتخاب اول، DateTimeField(auto_now_add=True). این انتخاب برای فیلد first_seen در Visitor و created_at مناسب است، ولی برای entry_time در Visit مناسب نیست. چرا؟ چون شما به‌عنوان توسعه‌دهنده باید کنترل کامل روی زمان ورود داشته باشید. ممکن است در آینده بخواهید یک ورود را با زمان گذشته ثبت کنید (مثلاً برای import داده‌های تاریخی).

انتخاب دوم، DateTimeField(default=timezone.now). این انتخاب انعطاف بیشتری می‌دهد. زمان پیش‌فرض در لحظه‌ی ساخت، ولی می‌توان آن را override کرد.

انتخاب سوم، DateTimeField(null=True). برای فیلدهای اختیاری مثل exit_time.

نکته‌ی حیاتی: همیشه از timezone.now() استفاده کنید، نه datetime.now(). چرا؟ چون datetime.now() به timezone سیستم وابسته است و در سرورهای با تنظیمات متفاوت، رفتار غیرقابل پیش‌بینی دارد. همچنین تنظیم USE_TZ = True در settings.py را فراموش نکنید.

در یکی از پروژه‌هایم، تیم از datetime.now() استفاده کرده بود. بعد از مهاجرت سرور به یک منطقه‌ی زمانی متفاوت، تمام زمان‌های ثبت‌شده ۳ ساعت و ۳۰ دقیقه جابجا شدند. اصلاح آن داده‌های تاریخی، یک هفته کار برد.

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

روابط و on_delete؛ انتخاب‌های حساس

در مدل Visit، فیلد visitor یک ForeignKey با on_delete=CASCADE است. این انتخاب به‌معنای آن است که اگر Visitor حذف شود، تمام Visitهای مربوطه هم حذف می‌شوند.

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

سه گزینه دارید:

on_deleteرفتارمناسب برای
CASCADEحذف زیرمجموعه‌هاداده‌های شخصی که باید کاملاً پاک شوند
PROTECTجلوگیری از حذفمحافظت از داده‌های تاریخی
SET_NULLمقدار NULLنگه‌داشتن سابقه‌ی آماری

توصیه‌ی عملی من: برای اکثر پروژه‌های آماری، CASCADE انتخاب درست است، ولی با یک شرط: هرگز به‌طور دستی Visitor حذف نکنید. اگر نیاز به پاک‌سازی داده‌های شخصی دارید (مثلاً درخواست GDPR)، این کار را به یک کامند اختصاصی بسپارید که هم Visitor و هم تمام داده‌های مربوطه را در یک تراکنش پاک کند. اگر می‌خواهید الگوی این کامند را ببینید، کامند مدیریتی پاک‌سازی داده‌های قدیمی چارچوب مناسبی ارائه می‌دهد.

پارتیشن‌بندی و مقیاس‌پذیری

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

پارتیشن‌بندی در PostgreSQL. از declarative partitioning استفاده کنید:

CREATE TABLE analytics_visit (
    id BIGSERIAL,
    visitor_id BIGINT NOT NULL,
    entry_time TIMESTAMPTZ NOT NULL,
    -- ...
    PRIMARY KEY (id, entry_time)
) PARTITION BY RANGE (entry_time);

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

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

پارتیشن‌بندی در MySQL. MySQL از RANGE partitioning پشتیبانی می‌کند، ولی محدودیت‌های بیشتری دارد. یک محدودیت مهم: فیلد پارتیشن باید بخشی از کلید اصلی باشد.

اگر روی MySQL کار می‌کنید، گزینه‌ی دیگر جداسازی جدول‌های آماری در یک دیتابیس اختصاصی است. با database router در جنگو، می‌توانید read و write آماری را از دیتابیس اصلی جدا کنید:

class AnalyticsRouter:
    def db_for_read(self, model, **hints):
        if model._meta.app_label == "analytics":
            return "analytics"
        return None

    def db_for_write(self, model, **hints):
        if model._meta.app_label == "analytics":
            return "analytics"
        return None

این جداسازی، فشار آماری را از دیتابیس اصلی برمی‌دارد. الگوی مشابهی در ساخت API JSON برای آمار زنده استفاده می‌شود، چون کوئری‌های آماری سنگین‌اند و نباید روی دیتابیس کاربران اجرا شوند.

الگوهای کوئری پرتکرار و بهینه‌سازی

سه کوئری در این سیستم بسیار پرتکرار است. بیایید هر کدام را بهینه بنویسیم.

کوئری اول، کاربران آنلاین.

from datetime import timedelta
from django.utils import timezone

threshold = timezone.now() - timedelta(seconds=300)
online = Visit.objects.filter(
    is_active=True,
    last_activity__gte=threshold,
).select_related("visitor").count()

نکته‌ی مهم: استفاده از select_related وقتی فقط count می‌خواهید، بی‌فایده است. اینجا فقط برای کوئری‌هایی که نیاز به Visitor دارید، از آن استفاده کنید.

کوئری دوم، بازدیدکنندگان یکتای امروز.

from datetime import datetime, time, timedelta
from django.utils import timezone

today = timezone.localdate()
tz = timezone.get_current_timezone()
start = timezone.make_aware(datetime.combine(today, time.min), tz)
end = start + timedelta(days=1)

unique_visitors = Visit.objects.filter(
    entry_time__gte=start, entry_time__lt=end,
).values("visitor").distinct().count()

نکته‌ی مهم: استفاده از entry_time__lt به‌جای __lte با end. این کار از شمردن دوباره‌ی ورودهای نیمه‌شب جلوگیری می‌کند.

کوئری سوم، پربازدیدترین صفحه‌ی امروز.

from django.db.models import Count

top_page = PageView.objects.filter(
    entered_at__gte=start, entered_at__lt=end,
).values("path").annotate(
    visits=Count("id"),
).order_by("-visits").first()

این کوئری روی جدول PageView اجرا می‌شود که بزرگ‌ترین جدول سیستم است. برای بهینه‌سازی، ایندکس ترکیبی روی (entered_at, path) بسیار مؤثر است.

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

مدل‌های آماری دو نوع تست نیاز دارند: تست صحت داده و تست کارایی.

تست صحت. سناریوهای زیر را پوشش بدهید:

import pytest
from django.utils import timezone
from analytics.models import Visitor, Visit


@pytest.mark.django_db
def test_fingerprint_uniqueness():
    Visitor.objects.create(
        fingerprint="abc123", ip_address="1.2.3.4",
    )
    with pytest.raises(Exception):
        Visitor.objects.create(
            fingerprint="abc123", ip_address="5.6.7.8",
        )


@pytest.mark.django_db
def test_visit_links_to_visitor(visitor_factory):
    visitor = visitor_factory()
    visit = Visit.objects.create(
        visitor=visitor,
        entry_time=timezone.now(),
        entry_url="https://example.com/",
        entry_path="/",
    )
    assert visit.visitor == visitor
    assert visitor.visits.count() == 1

تست کارایی. این نوع تست را کمتر می‌بینم، ولی در پروژه‌های آماری حیاتی است. با django.test.utils.CaptureQueriesContext می‌توانید تعداد کوئری‌ها را اندازه بگیرید:

from django.test.utils import CaptureQueriesContext
from django.db import connection


def test_dashboard_query_count(client, django_assert_num_queries):
    with django_assert_num_queries(5):
        client.get("/panel/stat/")

هدف این است که نمایش داشبورد، بیشتر از ۵ تا ۸ کوئری به دیتابیس نزند. اگر بیشتر شد، یعنی N+1 دارید.

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

مهاجرت‌های امن در جدول‌های بزرگ

وقتی جدول شما چند میلیون ردیف دارد، اجرای migration می‌تواند سایت را برای دقیقه‌ها قفل کند. سه قاعده را رعایت کنید:

قاعده‌ی اول، افزودن فیلد جدید بدون default در سطح دیتابیس. اگر فیلد nullable اضافه می‌کنید، مشکلی نیست. اگر فیلد با default اضافه می‌کنید، در MySQL ممکن است کل جدول را بازنویسی کند. راه‌حل: فیلد را nullable اضافه کنید، مقادیر را به‌تدریج پر کنید، بعد فیلد را NOT NULL کنید.

قاعده‌ی دوم، ساخت ایندکس به‌صورت همزمان (concurrent). در PostgreSQL، از CREATE INDEX CONCURRENTLY استفاده کنید. در Django، با تنظیم atomic = False در migration و اجرای دستی SQL.

class Migration(migrations.Migration):
    atomic = False

    operations = [
        migrations.RunSQL(
            "CREATE INDEX CONCURRENTLY idx_visit_entry ON analytics_visit (entry_time);",
            reverse_sql="DROP INDEX IF EXISTS idx_visit_entry;",
        ),
    ]

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

anti-patternهای شایع در طراحی این مدل‌ها

در بازبینی ده‌ها پروژه‌ی آماری، این اشتباهات تکرارشونده را دیده‌ام:

۱. ذخیره‌ی IP به‌عنوان کلید اصلی هویت. اگر فقط IP را ذخیره کنید، کاربران پشت NAT قاطی می‌شوند. راه‌حل: fingerprint ترکیبی.

۲. ذخیره‌ی User-Agent در CharField(max_length=255). User-Agentهای مدرن می‌توانند از ۵۰۰ کاراکتر عبور کنند. راه‌حل: TextField یا CharField(max_length=2000).

۳. نداشتن فیلد last_activity. بدون این فیلد، نمی‌توانید تشخیص دهید کدام سشن زنده است. راه‌حل: فیلد جدا با ایندکس و آپدیت در middleware. الگوی درستش در نوشتن Middleware سفارشی در جنگو توضیح داده شده است.

۴. استفاده از DateTimeField(auto_now_add=True) برای entry_time. این انتخاب، انعطاف را از شما می‌گیرد. راه‌حل: default=timezone.now.

۵. نداشتن ایندکس روی referrer_type. کوئری «چند درصد کاربران از گوگل آمده‌اند» بدون ایندکس، یک full scan سنگین است.

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

۷. قاطی‌کردن Visitor با User جنگو. این دو مفهوم متفاوتند. کاربر وارد‌شده، ممکن است چند Visitor داشته باشد. راه‌حل: جدا نگه‌داشتن این دو مدل.

۸. نادیده‌گرفتن حقوق حریم خصوصی. ذخیره‌ی IP بدون هش، در حوزه‌های قضایی مختلف دردسر ایجاد می‌کند. راه‌حل: هش یا حذف IP بعد از N روز.

۹. استفاده از Count بدون distinct در گزارش‌های یکتا. این خطا باعث می‌شود «بازدیدکننده‌ی یکتا» را با «تعداد بازدید» قاطی کنید. راه‌حل: values("visitor").distinct().count().

۱۰. نبود جدول تجمیعی. بدون یک جدول DailyStats که هر شب پر شود، کوئری‌های صفحه‌ی داشبورد روی داده‌ی خام اجرا می‌شوند و کند می‌مانند.

پرسش‌های پرتکرار درباره‌ی طراحی Visitor و Visit

آیا باید Visitor و Visit را در دو اپ جداگانه قرار دهم؟ نه. هر دو به یک دامنه تعلق دارند و باید در یک اپ بمانند. جداسازی اپ‌ها بر اساس دامنه است، نه بر اساس مدل.

چطور تعداد کاربران یکتا در یک ماه را حساب کنم؟ با Visit.objects.filter(entry_time__gte=start, entry_time__lt=end).values("visitor").distinct().count(). توجه کنید که distinct روی visitor اعمال می‌شود، نه روی کل ردیف.

آیا باید Visitor حذف شود اگر مدت طولانی غیرفعال بوده؟ بله، ولی با احتیاط. پیشنهاد من: بعد از ۹۰ روز عدم فعالیت، Visitorهای بدون Visit حذف شوند. برای Visitorهایی که Visit دارند، فقط در صورت درخواست GDPR یا نیاز قانونی حذف کنید. الگوی این کار در حذف رکوردهای تکراری و یتیم با batch delete آمده است.

آیا باید فیلد ip_address را ذخیره کنم؟ به سیاست حریم خصوصی شما بستگی دارد. اگر نیازی به تحلیل جغرافیایی ندارید، همان fingerprint کافی است. اگر دارید، IP را ذخیره کنید ولی در سیاست حریم خصوصی شفاف باشید.

چطور می‌توانم سفر یک کاربر را در طول زمان ببینم؟ با کوئری Visit.objects.filter(visitor=X).order_by("entry_time"). برای هر Visit، page_views را با prefetch_related بگیرید.

آیا باید page_views و interactions هم به Visitor وصل شوند؟ بله، برای کوئری‌های مستقیم. ولی فیلد اصلی وابستگی، visit است. اگر می‌خواهید کوئری‌هایتان روی Visitor سریع‌تر باشد، ارتباط مستقیم داشته باشید. الگوی کاملش در ذخیره‌ی PageView و Interaction توضیح داده شده است.

چطور از duplicate شدن Visitor جلوگیری کنم؟ با unique index روی fingerprint و استفاده از get_or_create در middleware. این کار atomic است و race condition را حل می‌کند.

آیا باید Visitor را با کوکی هم شناسایی کنم؟ برای دقت بالاتر، بله. یک کوکی با Secure و HttpOnly که UUID نگه دارد، دقیق‌تر از fingerprint است. ولی این کوکی نیاز به رضایت کاربر (cookie consent) دارد.

چطور داده‌های denormalized را در طول مهاجرت اصلاح کنم؟ با یک کامند اختصاصی که در بچ‌های ۵۰۰ تایی اجرا شود. الگوی بچ‌کردن در حذف رکوردهای یتیم کامل توضیح داده شده است.

آیا باید برای Visitor و Visit جدول تجمیعی جداگانه داشته باشم؟ بله، برای پروژه‌های با ترافیک متوسط و بالا. یک جدول DailyStats با فیلدهای date، unique_visitors، total_visits، total_seconds که هر شب پر شود.

آیا می‌توانم از JSONField برای ذخیره‌ی اطلاعات اضافی استفاده کنم؟ بله، ولی نه برای فیلدهایی که در کوئری‌های فیلتر استفاده می‌شوند. JSON برای داده‌های جانبی مناسب است، نه برای فیلدهای کلیدی. اگر می‌خواهید الگویی برای ذخیره‌ی داده‌های flexible ببینید، ذخیره‌ی Interaction نمونه‌ی خوبی است.

آیا باید select_related و prefetch_related را همیشه استفاده کنم؟ نه. اگر فقط count می‌گیرید، select_related بی‌فایده است. فقط جایی که به Visitor یا PageView نیاز دارید، استفاده کنید.

چطور تعداد کوئری‌های داشبورد را به حداقل برسانم؟ با یک جدول تجمیعی و کوئری‌های annotate و values. اگر می‌خواهید الگوهای کامل را ببینید، ساخت پنل ادمین با کارت‌های quick view راهنمای دقیقی است.

سه پرسشی که قبل از نوشتن مدل باید پاسخ بدهید

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

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

پرسش دوم، حجم داده در یک سال چقدر خواهد بود؟ اگر سایت شما روزانه ۱۰۰۰ بازدیدکننده دارد، سالانه حدود ۳۶۵ هزار Visitor و چند برابر آن Visit خواهید داشت. این حجم با یک دیتابیس معمولی مدیریت‌پذیر است. اگر روزانه ۱۰۰ هزار بازدید دارید، به معماری‌های جدی‌تر مثل پارتیشن‌بندی یا دیتابیس آماری اختصاصی نیاز دارید.

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

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

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

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