Masalah query N+1 terjadi ketika Django ORM mengeksekusi satu query SQL untuk mengambil kumpulan data induk, kemudian mengeksekusi query tambahan untuk setiap baris data guna mengambil relasi terkait. Karakteristik lazy loading pada Django ORM membuat masalah ini kerap lolos dari pengujian unit biasa, karena pengujian fungsional umumnya hanya memeriksa validitas payload JSON tanpa memvalidasi jumlah query SQL yang ditembakkan ke database.
Akar Masalah: Lazy Loading pada Django REST Framework (DRF)
Pada DRF, serializer mengakses relasi objek secara otomatis saat melakukan serialisasi data. Pertimbangkan model relasional berikut:
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
class Tag(models.Model):
name = models.CharField(max_length=50)
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
tags = models.ManyToManyField(Tag, related_name="books")Ketika serializer DRF mengakses relasi author dan tags tanpa pemuatan awal (eager loading), Django mengeksekusi query baru setiap kali loop serializer berjalan:
from rest_framework import serializers
class BookSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(source="author.name", read_only=True)
tag_list = serializers.SlugRelatedField(
many=True, read_only=True, slug_field="name", source="tags"
)
class Meta:
model = Book
fields = ["id", "title", "author_name", "tag_list"]Jika endpoint mengembalikan 50 buku, kode di atas mengeksekusi 1 query untuk Book, 50 query untuk Author, dan 50 query untuk Tag, menghasilkan total 101 query (1 + 2N). Solusinya adalah menerapkan select_related untuk relasi foreign key (menggunakan SQL JOIN) dan prefetch_related untuk relasi many-to-many (menggunakan SQL IN terpisah):
from rest_framework.viewsets import ReadOnlyModelViewSet
class BookViewSet(ReadOnlyModelViewSet):
serializer_class = BookSerializer
def get_queryset(self):
return Book.objects.select_related("author").prefetch_related("tags")Dengan optimasi ini, jumlah query berkurang menjadi tepat 2 query, berapapun jumlah baris data yang dikembalikan.
Mendeteksi Regresi: Django TestCase vs pytest-django
Untuk memastikan optimasi query tidak rusak akibat perubahan kode di masa mendatang, jumlah query harus diuji secara terprogram.
1. Menggunakan Django TestCase Standar
Django menyediakan method bawaan assertNumQueries pada kelas TestCase:
from django.test import TestCase
from django.urls import reverse
from .models import Author, Book, Tag
class BookAPITest(TestCase):
@classmethod
def setUpTestData(cls):
author = Author.objects.create(name="Martin Fowler")
tag = Tag.objects.create(name="Architecture")
for i in range(10):
book = Book.objects.create(title=f"Book {i}", author=author)
book.tags.add(tag)
def test_book_list_query_count(self):
url = reverse("book-list")
# 1 query untuk books + author, 1 query untuk prefetch tags
with self.assertNumQueries(2):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)2. Menggunakan pytest-django
Jika menggunakan pytest, gunakan fixture django_assert_num_queries dari paket pytest-django:
import pytest
from django.urls import reverse
@pytest.mark.django_db
def test_book_list_query_budget(client, django_assert_num_queries, create_books):
create_books(count=15)
url = reverse("book-list")
with django_assert_num_queries(2):
response = client.get(url)
assert response.status_code == 200Context Manager Khusus: Debugging dan Query Budget
Kendala utama assertNumQueries standar adalah kegagalan hanya memunculkan perbedaan angka (misal: Expected 2, got 12) tanpa menampilkan query mana yang bocor. Manfaatkan CaptureQueriesContext untuk mencetak trace query SQL ketika limit terlampaui:
from contextlib import contextmanager
from django.db import connection
from django.test.utils import CaptureQueriesContext
@contextmanager
def assert_max_queries(max_queries: int):
with CaptureQueriesContext(connection) as ctx:
yield ctx
executed = len(ctx)
if executed > max_queries:
queries_log = "\n".join(
f"[{idx + 1}] {q['sql']} ({q['time']}s)"
for idx, q in enumerate(ctx.captured_queries)
)
raise AssertionError(
f"Query budget exceeded! Allowed: {max_queries}, Executed: {executed}\n"
f"Executed Queries:\n{queries_log}"
)Menangani Query Non-Deterministik
Tes query sering kali menjadi flaky karena query non-deterministik dari middleware otentikasi, pengecekan permission, atau caching internal Django. Berikut langkah isolasinya:
- Otentikasi & Session: Gunakan
force_authenticate(DRF) atau lakukan pemanggilan awal (warm-up request) sebelum blok penangkapan query untuk memuat user dan session ke dalam cache memori koneksi. - Content Types & Permissions: Django memuat permission tabel via
ContentTypesaat pertama kali diakses. Pastikan objek user telah di-preload sebelum context assertion dijalankan.
from rest_framework.test import APIClient
def test_deterministic_authenticated_endpoint(django_assert_num_queries, user):
client = APIClient()
# force_authenticate memotong evaluasi session/token SQL di tengah assertion
client.force_authenticate(user=user)
with django_assert_num_queries(2):
response = client.get("/api/books/")
assert response.status_code == 200Otomatisasi di CI Pipeline
Jalankan pemeriksaan regresi query di pipeline CI (seperti GitHub Actions) pada setiap pull request untuk memblokir kode yang melanggar batas query.
name: Django Regression Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: test_db
POSTGRES_USER: user
POSTGRES_PASSWORD: password
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Run Query Budget Tests
env:
DATABASE_URL: postgres://user:password@localhost:5432/test_db
run: |
pytest -m "django_db" tests/test_performance.py --tb=shortCatatan: Jalankan regression test database terhadap engine database yang identik dengan production (misal: PostgreSQL). Evaluasi optimasi query pada SQLite lokal dapat berbeda perilakunya terhadap engine produksi karena penanganan nested joins dan query planning yang berbeda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!