تحسين استعلامات Django ORM لأداء أعلى

تحسين استعلامات Django ORM لأداء أعلى: دليل عملي بالأمثلة

في تطبيقات Django المتوسطة والكبيرة، غالبًا ما يكون عنق الزجاجة في الأداء هو الاتصال بقاعدة البيانات، وليس منطق الكود نفسه. أي استعلام إضافي غير ضروري، أو عدم استخدام الفهارس بشكل صحيح، يمكن أن يضاعف زمن الاستجابة ويستهلك موارد الخادم.

في هذا الدليل سنركز على تحسين Django ORM، من خلال استراتيجيات عملية لتقليل عدد الاستعلامات وتسريع استجابة قاعدة البيانات: select_related، prefetch_related، الفهارس المخصصة، وتحليل الـ QuerySet بالأمثلة. للمقدمة العامة حول ORM يمكنك الرجوع إلى مقال: ما المقصود بالـ ORM.

لماذا تحسين Django ORM مهم؟

Django ORM يوفر طبقة مريحة للتعامل مع قاعدة البيانات عبر Python بدلًا من SQL الخام. لكنه سيف ذو حدين: استخدامه بدون وعي يمكن أن ينتج عنه:

  • عدد كبير من الاستعلامات الصغيرة (N+1 queries problem)
  • استعلامات بطيئة بسبب غياب الفهارس المناسبة
  • نقل بيانات أكثر من اللازم من قاعدة البيانات
  • عمليات ترتيب وتصفية مكلفة على مستوى التطبيق بدلًا من قاعدة البيانات

النتيجة: صفحات أبطأ، ضغط أعلى على قاعدة البيانات، وتكلفة أعلى للبنية التحتية. لذلك تحسين Django ORM ليس رفاهية، بل ضرورة في أي مشروع جاد.

مشكلة N+1 Queries: الجاني الأول في أداء Django

مشكلة N+1 تحدث عندما تجلب قائمة من العناصر (استعلام واحد)، ثم تقوم بعمل استعلام إضافي لكل عنصر لجلب بيانات مرتبطة. النتيجة: N+1 استعلام بدل استعلام أو اثنين فقط.

مثال على مشكلة N+1 في Django

لنفترض وجود موديلات بسيطة:


class Author(models.Model):
    name = models.CharField(max_length=200)

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

وفي الـ view:


def book_list(request):
    books = Book.objects.all()  # استعلام واحد
    for book in books:
        print(book.author.name)  # استعلام إضافي لكل كتاب

في هذه الحالة، Django ينفذ استعلامًا لجلب الكتب، ثم لكل كتاب استعلام آخر لجلب الـ author. لو عندك 100 كتاب، ستنفذ 101 استعلام.

استخدام select_related لتقليل الاستعلامات مع العلاقات One-To-One و ForeignKey

select_related يستخدم في العلاقات التي تعيد سطرًا واحدًا فقط من الجدول المرتبط:

  • ForeignKey
  • OneToOneField

فكر فيه على أنه تنفيذ JOIN على مستوى قاعدة البيانات.

إعادة كتابة المثال السابق باستخدام select_related


def book_list(request):
    books = Book.objects.select_related('author')  # JOIN مع جدول Author
    for book in books:
        print(book.author.name)  # لا استعلام إضافي

الآن يتم تنفيذ استعلام واحد فقط يجلب الكتب مع المؤلفين دفعة واحدة باستخدام JOIN، ويختفي تمامًا نمط N+1.

متى تستخدم select_related؟

  • عندما تحتاج للوصول الحتمي إلى كائن مرتبط بـ ForeignKey أو OneToOne
  • عندما تعرض قائمة عناصر وكل عنصر يحتاج بيانات من الجدول المرتبط

نصائح عند استخدام select_related

  • يمكنك تمرير أكثر من علاقة:
    
    Book.objects.select_related('author', 'publisher')
        
  • يمكنك التعمق في السلسلة:
    
    Book.objects.select_related('author__profile')
        
  • لا تبالغ: جلب علاقات كثيرة جدًا في استعلام واحد قد يزيد حجم البيانات المنقولة بدون حاجة.

استخدام prefetch_related للعلاقات Many-to-Many و reverse ForeignKey

على عكس select_related، الدالة prefetch_related مناسبة للعلاقات التي يمكن أن تعيد أكثر من صف:

  • ManyToManyField
  • العلاقات العكسية لـ ForeignKey (مثل book_set أو related_name)

Django في هذه الحالة لا يمكنه استخدام JOIN بسيط بدون تكرار الصفوف، لذلك ينفذ عدة استعلامات منفصلة ثم يدمج النتائج في الذاكرة بكفاءة.

مثال استخدام prefetch_related


class Category(models.Model):
    name = models.CharField(max_length=200)

class Book(models.Model):
    title = models.CharField(max_length=200)
    categories = models.ManyToManyField(Category, related_name='books')

المشكلة الكلاسيكية:


def book_list(request):
    books = Book.objects.all()
    for book in books:
        for cat in book.categories.all():  # N+1 على مستوى الفئات
            print(cat.name)

الحل باستخدام prefetch_related:


def book_list(request):
    books = Book.objects.prefetch_related('categories')
    for book in books:
        for cat in book.categories.all():  # لا استعلام إضافي لكل كتاب
            print(cat.name)

Django هنا ينفذ استعلامًا لجلب الكتب، واستعلامًا آخر لجلب جميع العلاقات بين الكتب والفئات، ثم يربطها في الذاكرة، فيختفي نمط N+1.

الجمع بين select_related و prefetch_related

غالبًا تحتاج إلى كليهما في نفس الوقت:


books = (
    Book.objects
    .select_related('author')          # علاقة 1-1 أو 1-N
    .prefetch_related('categories')    # علاقة N-N
)

بهذه الطريقة تحصل على الحد الأدنى من الاستعلامات مع الحد الأقصى من البيانات الجاهزة للاستخدام.

اختيار الحقول المطلوبة فقط باستخدام only و defer

ليس دائمًا تحتاج إلى كل أعمدة الجدول. جلب أعمدة كثيرة، خصوصًا الحقول الكبيرة (TextField، JSONField)، يزيد حجم البيانات المنقولة وزمن الاستجابة.

الاكتفاء بالحقول المطلوبة: only()


books = Book.objects.only('id', 'title')
for book in books:
    print(book.title)

في هذه الحالة، Django ينشئ استعلامًا يجلب فقط id وtitle. إذا حاولت لاحقًا الوصول إلى حقل غير موجود في only، سيقوم ORM بجلبه عند الطلب (استعلام lazy إضافي).

تأجيل بعض الحقول الثقيلة: defer()


books = Book.objects.defer('long_description', 'notes')

هذه الطريقة مفيدة عندما تحتاج معظم الحقول لكن تريد تأجيل الحقول الكبيرة إلى وقت الحاجة. لكن انتبه: الوصول لهذه الحقول لاحقًا سيولد استعلامات إضافية.

استخدام الفهارس (Indexes) لتحسين سرعة الاستعلامات

الفهرسة عنصر أساسي في تحسين Django ORM، لأن معظم بطء الاستعلامات يعود إلى البحث الخطي في جداول كبيرة. الفهارس تسمح لقاعدة البيانات بالوصول للسجلات المطلوبة بسرعة كبيرة بدلًا من فحص كل الصفوف.

لشرح أعمق لمفهوم الفهرسة يمكنك مراجعة: الفهرسة في قواعد البيانات و أهميتها وأيضًا: أهم خوارزميات الفهرسة المستخدمة في قواعد البيانات.

أنواع الفهارس في Django Models

يمكنك تعريف فهارس بعدة طرق:

  1. index=True على حقل معين:
    
    class Book(models.Model):
        isbn = models.CharField(max_length=13, unique=True, db_index=True)
        # أو
        code = models.CharField(max_length=50, db_index=True)
        
  2. فهارس مركبة على أكثر من حقل عبر Meta:
    
    class Book(models.Model):
        title = models.CharField(max_length=200)
        published_at = models.DateField()
    
        class Meta:
            indexes = [
                models.Index(fields=['title', 'published_at']),
            ]
        

متى تحتاج إلى فهرس؟

  • أي حقل تستخدمه كثيرًا في filter()، exclude()، order_by()، distinct()
  • حقول تظهر في شروط JOIN بكثرة (مفاتيح خارجية foreign keys غالبًا تكون مفهرسة تلقائيًا)
  • الحقول المستخدمة في عمليات البحث النصي المحددة (بشرط أن يكون نوع الفهرس مناسبًا)

لكن: الإكثار من الفهارس بدون خطة يبطئ عمليات INSERT وUPDATE لأنه يجب تحديث الفهارس في كل مرة. استخدم الفهرسة بناءً على الاستعلامات الفعلية التي تنفذها في التطبيق.

تحليل وتحسين QuerySet: from SQL إلى ORM واعيًا

Django ORM يسهّل كتابة الاستعلامات، لكن يظل من المهم أن تفهم الاستعلام SQL الفعلي الناتج. هذا يساعدك على تحسينه.

عرض الاستعلام SQL الناتج عن QuerySet

يمكنك طباعة الاستعلام عبر خاصية query:


qs = Book.objects.filter(title__icontains='django')
print(qs.query)  # يظهر SQL الفعلي

في الـ shell أو أثناء التطوير، هذا مفيد لفهم ما إذا كان Django ينتج JOINs غير ضرورية أو شروط معقدة.

استخدام .explain() لتحليل خطة التنفيذ (PostgreSQL, MySQL…)

في الإصدارات الحديثة من Django يمكنك استخدام:


qs = Book.objects.filter(title__icontains='django')
print(qs.explain())

قاعدة البيانات هنا تعرض لك خطة التنفيذ (Execution Plan)، والتي تبين:

  • هل تم استخدام فهرس أم لا؟
  • هل يتم عمل Sequential Scan على كامل الجدول؟
  • تكلفة الاستعلام تقديريًا.

إذا لاحظت أن الاستعلام لا يستخدم فهرس رغم أنك تتوقع ذلك، فغالبًا تحتاج لإنشاء فهرس جديد أو تعديل بنية الاستعلام.

تخفيف التعقيد في filter()

استعلامات معقدة جدًا داخل filter() مع الكثير من Q() والشروط المتداخلة قد تولد SQL ثقيلًا. حاول دائمًا:

  • تقسيم الاستعلامات إلى أجزاء أبسط إن أمكن
  • تجنب التصفية على دوال (مثل Lower(field)) إلا للضرورة لأنها تعيق الاستفادة من الفهارس التقليدية

لشرح أوسع عن filter() يمكنك مراجعة: دليل شامل لفهم واستخدام دالة filter() في Django ORM.

تقليل تكرار العمل: caching و querysets القابلة لإعادة الاستخدام

حتى مع أفضل الاستعلامات، أحيانًا التنفيذ المتكرر لنفس الاستعلام مكلف. هنا يأتي دور:

  • إعادة استخدام QuerySet بدلًا من استدعاء نفس الاستعلام أكثر من مرة في نفس الطلب
  • استخدام التخزين المؤقت (caching) على مستوى الطبقة المناسبة

إعادة استخدام QuerySet

تذكر أن QuerySet في Django كسول (lazy) ويتم تنفيذه عند الحاجة فقط. لكن بمجرد تقييمه، يمكنك إعادة استخدامه:


books_qs = Book.objects.filter(is_published=True).select_related('author')

# التقييم لأول مرة
books_list = list(books_qs)

# إعادة الاستخدام بدون استعلام جديد
for book in books_list:
    ...

for book in books_list:
    ...

المهم: لا تعيد استدعاء Book.objects.filter(...) مرات عديدة بنفس الشروط داخل نفس الطلب.

الاستفادة من order_by مع الفهارس

الترتيب order_by عملية مكلفة إذا لم يكن هناك فهرس يدعمها، لأن قاعدة البيانات قد تضطر لفرز كامل الجدول. عند استخدام order_by() بشكل متكرر، تأكد أن الحقول المستخدمة مفهرسة.

يمكنك مراجعة مقال: تعلم طرق استخدام order_by في إطار عمل Django لفهم مزيد من تفاصيل الترتيب في ORM.

أمثلة عملية لتحسين استعلامات Django ORM

مثال 1: صفحة قائمة المقالات مع الكاتب والفئات

موديلات مبسطة:


class Author(models.Model):
    name = models.CharField(max_length=200)

class Category(models.Model):
    name = models.CharField(max_length=200)

class Article(models.Model):
    title = models.CharField(max_length=200, db_index=True)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    categories = models.ManyToManyField(Category)
    published_at = models.DateTimeField(db_index=True)

الإصدار البطيء:


def article_list(request):
    articles = Article.objects.filter(is_published=True).order_by('-published_at')

    data = []
    for article in articles:
        data.append({
            'title': article.title,
            'author': article.author.name,          # N+1
            'categories': [c.name for c in article.categories.all()],  # N+1
        })

الإصدار المحسن:


def article_list(request):
    articles = (
        Article.objects
        .filter(is_published=True)
        .select_related('author')
        .prefetch_related('categories')
        .only('id', 'title', 'author__name', 'published_at')
        .order_by('-published_at')
    )

    data = []
    for article in articles:
        data.append({
            'title': article.title,
            'author': article.author.name,
            'categories': [c.name for c in article.categories.all()],
        })
  • تقليل الاستعلامات (التخلص من N+1)
  • جلب الحقول الضرورية فقط
  • استخدام فهارس على title و published_at لتسريع الترتيب والبحث

مثال 2: البحث مع الفهرسة والتحليل

الاستعلام:


qs = Article.objects.filter(title__icontains='django').order_by('-published_at')
print(qs.explain())

إذا أظهر explain أن هناك Seq Scan بدون استخدام Index، فكر في:

  • إضافة فهرس مناسب للحقل title
  • أو إذا كنت تستخدم PostgreSQL واستعلامات نصية ثقيلة، استخدم فهارس GIN مع search_vector بدلاً من icontains العادية.

ملخص استراتيجيات تحسين Django ORM

  • استخدم select_related لعلاقات ForeignKey و OneToOne لتجنب N+1
  • استخدم prefetch_related لعلاقات ManyToMany والعلاقات العكسية
  • حدد الحقول التي تحتاجها فقط باستخدام only و defer
  • أنشئ فهارس مخصصة للحقول المستخدمة بكثرة في filter و order_by
  • حلل الاستعلامات باستخدام .query و .explain() لتفهم ما يحدث فعلاً في قاعدة البيانات
  • أعد استخدام QuerySet داخل نفس الطلب لتجنب تكرار الاستعلامات
  • صمّم الموديلات مع وضع الأداء في الاعتبار منذ البداية

بهذه الممارسات يمكنك الانتقال من تطبيق Django بطيء يرسل مئات الاستعلامات في كل صفحة، إلى تطبيق محسّن يستخدم أقل عدد ممكن من الاستعلامات، مستفيدًا من قوة الفهارس وخطة التنفيذ في قاعدة البيانات، مع المحافظة على سهولة ومرونة Django ORM.

لمن يريد تعميق فهمه لـ Django ككل، يمكن الرجوع إلى: دليل شامل حول إطار Django لبناء تطبيقات الويب و دليل استخدام Django ORM. هذه المعرفة التأسيسية ستجعل استراتيجيات تحسين الأداء أكثر وضوحًا وأسهل تطبيقًا في مشاريعك.

حول المحتوى:

استراتيجيات لخفض عدد الاستعلامات وتسريع استجابة قواعد البيانات في Django: استخدام select_related, prefetch_related، الفهارس المخصصة، وتحليل الـ QuerySet بالأمثلة.

هل كان هذا مفيدًا لك؟

أضف تعليقك