Django và PostgreSQL năm 2026: Đánh chỉ mục, tìm kiếm toàn văn và câu hỏi phỏng vấn

Hướng dẫn thực hành tối ưu Django với PostgreSQL: chỉ mục B-tree, một phần và bao phủ, tìm kiếm toàn văn với SearchVector và GIN, cùng các câu hỏi phỏng vấn 2026.

Tối ưu Django với PostgreSQL bằng đánh chỉ mục và tìm kiếm toàn văn

Việc tối ưu Django với PostgreSQL chính là ranh giới giữa một ứng dụng có thể mở rộng vượt qua nghìn người dùng đầu tiên và một ứng dụng chậm rề ở mỗi trang danh sách. PostgreSQL đi kèm sẵn một bộ máy đánh chỉ mục và một hệ thống con tìm kiếm toàn văn mà phần lớn các dự án Django bỏ ngỏ, lãng phí hiệu năng truy vấn vốn không tốn kém gì để lấy lại. Bài viết này trình bày các mẫu đánh chỉ mục, các công cụ tìm kiếm trong django.contrib.postgres, cùng những câu hỏi phỏng vấn về cơ sở dữ liệu giúp phân biệt kỹ sư Django tầm trung với kỹ sư cấp cao trong năm 2026.

Đo lường trước khi đánh chỉ mục

Mỗi chỉ mục đều tăng tốc thao tác đọc nhưng làm chậm thao tác ghi và tiêu tốn dung lượng đĩa. Hãy chạy EXPLAIN (ANALYZE, BUFFERS) trên truy vấn thực tế, thêm chỉ mục, rồi so sánh kế hoạch thực thi. Một chỉ mục mà bộ lập kế hoạch truy vấn không bao giờ chọn chỉ là gánh nặng thuần túy trên mỗi lệnh INSERT và UPDATE.

Nền tảng đánh chỉ mục PostgreSQL trong Django

Theo mặc định, PostgreSQL chỉ tạo chỉ mục B-tree cho khóa chính và các cột có unique=True. Mọi cột khác dùng để lọc hoặc sắp xếp đều phải quét tuần tự cho đến khi có chỉ mục. Django khai báo chỉ mục thông qua Meta.indexes, cách này được ưu tiên hơn db_index=True cũ vì nó hỗ trợ chỉ mục tổ hợp, chỉ mục một phần và chỉ mục bao phủ ở cùng một nơi.

Thứ tự các cột bên trong chỉ mục tổ hợp không phải chuyện hình thức. Một B-tree chỉ có thể dùng chỉ mục cho một truy vấn khi bộ lọc sử dụng phần tiền tố đứng đầu của các cột được đánh chỉ mục, một quy tắc được giải thích cặn kẽ tại Use The Index, Luke. Chỉ mục trên (status, created_at) phục vụ tốt cho WHERE status = ? ORDER BY created_at, nhưng cũng chính chỉ mục đó không thể tăng tốc một truy vấn chỉ lọc theo created_at.

python
# models.py
from django.db import models

class Order(models.Model):
    customer_email = models.EmailField()
    status = models.CharField(max_length=20)
    total = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            # B-tree một cột cho tra cứu chính xác và theo khoảng trên status
            models.Index(fields=["status"]),
            # Chỉ mục tổ hợp: phục vụ WHERE status = ? ORDER BY created_at DESC
            # Cột đứng đầu (status) phải xuất hiện trong bộ lọc thì mới được dùng
            models.Index(fields=["status", "-created_at"]),
        ]

Chỉ mục tổ hợp biến một truy vấn dashboard như "các đơn hàng đang chờ mới nhất" thành một lần quét chỉ mục duy nhất thay vì lọc rồi sắp xếp trên toàn bộ bảng.

Chỉ mục một phần và chỉ mục bao phủ cho truy vấn có mục tiêu

Một chỉ mục một phần chỉ bao gồm những hàng khớp với một điều kiện, nhờ vậy nó luôn nhỏ gọn và nằm trong bộ nhớ. Khi phần lớn các hàng cùng chung một giá trị và truy vấn chỉ chạm tới nhóm thiểu số, chỉ mục một phần rẻ hơn hẳn so với chỉ mục đầy đủ. Chỉ mục bao phủ còn tiến xa hơn: bằng cách thêm các cột không phải khóa qua include, PostgreSQL trả lời truy vấn trực tiếp từ chỉ mục mà không cần chạm vào phần heap của bảng, một kiểu truy cập gọi là quét chỉ mục thuần (index-only scan).

python
# models.py
from django.db import models
from django.db.models import Q

class Order(models.Model):
    # các trường như đã định nghĩa ở trên

    class Meta:
        indexes = [
            # Chỉ mục một phần: chỉ đánh chỉ mục các hàng đang chờ, bỏ qua lịch sử đã hoàn tất
            models.Index(
                fields=["created_at"],
                name="pending_orders_idx",
                condition=Q(status="pending"),
            ),
            # Chỉ mục bao phủ: total được đọc thẳng từ chỉ mục (quét chỉ mục thuần)
            models.Index(
                fields=["status"],
                include=["total"],
                name="status_total_covering_idx",
            ),
        ]

Trên một bảng đơn hàng có 2% số hàng ở trạng thái đang chờ, chỉ mục một phần có thể nhỏ hơn năm mươi lần so với chỉ mục đầy đủ trên cùng cột đó, và bộ lập kế hoạch giữ nó luôn nóng trong bộ đệm buffer. Mệnh đề include được ghi trong tài liệu PostgreSQL index-only scans và là lợi ích bị bỏ sót nhiều nhất trong các ứng dụng Django có tổng hợp một cột số phía sau bộ lọc trạng thái.

Tìm kiếm toàn văn trong Django với PostgreSQL

Việc dùng title__icontains=query có vẻ tiện lợi, nhưng nó buộc phải quét tuần tự và không thể xếp hạng kết quả theo độ liên quan. Tìm kiếm toàn văn của Django bao bọc các kiểu tsvectortsquery gốc của PostgreSQL thông qua SearchVector, SearchQuerySearchRank. Một vector tìm kiếm chuẩn hóa văn bản thành các lexeme, loại bỏ các từ dừng và rút gọn từ về gốc của chúng, nhờ vậy tìm kiếm "running" khớp với "run" và "ran".

python
# views.py
from django.contrib.postgres.search import (
    SearchVector, SearchQuery, SearchRank,
)
from .models import Article

def search_articles(query_text):
    query = SearchQuery(query_text, config="english")
    # Trọng số A xếp hạng khớp ở title cao hơn khớp ở body (trọng số B)
    vector = (
        SearchVector("title", weight="A")
        + SearchVector("body", weight="B")
    )
    return (
        Article.objects.annotate(rank=SearchRank(vector, query))
        .filter(rank__gte=0.1)
        .order_by("-rank")
    )

Tham số weight gán các nhóm ưu tiên từ A (cao nhất) đến D. Một kết quả khớp ở title được xếp trên kết quả khớp ở body ngay cả khi cả hai đều chứa từ khóa tìm kiếm, điều này đúng với kỳ vọng của người dùng về cách hoạt động của tìm kiếm. Toàn bộ API và mô hình trọng số bốn cấp được trình bày trong tài liệu tham khảo tìm kiếm toàn văn của Django.

Chỉ mục GIN và SearchVectorField để tìm kiếm nhanh

Truy vấn ở trên tính lại vector tìm kiếm cho mọi hàng ở mỗi lần yêu cầu, điều này ổn với vài nghìn hàng nhưng không thể chấp nhận với một triệu hàng. Mẫu dùng cho môi trường production lưu vector trong một SearchVectorField và đánh chỉ mục nó bằng chỉ mục GIN, loại chỉ mục mà PostgreSQL dùng cho các giá trị tổ hợp như tài liệu toàn văn và JSONB.

python
# models.py
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.db import models

class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    # Tài liệu tìm kiếm đã tính sẵn và được lưu trữ
    search_vector = SearchVectorField(null=True)

    class Meta:
        indexes = [
            # GIN biến tra cứu toàn văn thành quét chỉ mục theo logarit
            GinIndex(fields=["search_vector"]),
        ]

Trường được lưu cần đồng bộ với titlebody. Một signal post_save ghi vector bằng .update() tránh kích hoạt signal một cách đệ quy, vì QuerySet.update() không phát ra các signal lưu.

python
# signals.py
from django.contrib.postgres.search import SearchVector
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Article

@receiver(post_save, sender=Article)
def sync_search_vector(sender, instance, **kwargs):
    Article.objects.filter(pk=instance.pk).update(
        search_vector=SearchVector("title", weight="A")
        + SearchVector("body", weight="B"),
    )

Với những nhóm dùng Django 5.0 trở lên, một GeneratedField được tính toán trong cơ sở dữ liệu hoặc một trigger PostgreSQL loại bỏ hoàn toàn lệnh UPDATE bổ sung bằng cách duy trì cột ngay bên trong cơ sở dữ liệu. Bất kể dùng cơ chế nào để giữ cột luôn có dữ liệu, truy vấn sau đó lọc trực tiếp trên trường đã được đánh chỉ mục, và chỉ mục GIN biến thứ từng là quét toàn bảng thành một lần tra cứu theo logarit.

Chỉ mục GIN tốn kém khi ghi

Chỉ mục GIN tăng tốc thao tác đọc với một cái giá ghi thực sự, vì mỗi lần chèn phải chạm tới nhiều mục chỉ mục. Trên các bảng có lượng ghi cao, hãy đặt tham số lưu trữ fastupdate và theo dõi kích thước danh sách chờ (pending list), nếu không các thao tác ghi toàn văn sẽ bị nghẽn phía sau việc bảo trì chỉ mục.

Chọn đúng loại chỉ mục PostgreSQL

B-tree là lựa chọn mặc định đúng đắn, nhưng PostgreSQL cung cấp nhiều loại chỉ mục qua django.contrib.postgres.indexes, và chọn nhầm loại sẽ lãng phí đĩa mà không cải thiện được bất kỳ truy vấn nào. Lựa chọn phụ thuộc vào hình dạng của dữ liệu và toán tử mà truy vấn sử dụng, như được liệt kê trong tài liệu về các loại chỉ mục của PostgreSQL.

| Loại chỉ mục | Phù hợp nhất cho | Lớp trong Django | |------------|----------|--------------| | B-tree | Bằng và khoảng trên các cột vô hướng | models.Index | | GIN | Tìm kiếm toàn văn, JSONB, kiểm tra chứa trong mảng | GinIndex | | BRIN | Bảng khổng lồ chỉ thêm mới, sắp theo một cột | BrinIndex | | Hash | Chỉ tra cứu theo bằng trên các cột lớn | HashIndex |

BRIN đáng được quan tâm đặc biệt với dữ liệu chuỗi thời gian. Trên một bảng có hàng chục triệu hàng được chèn theo thứ tự thời gian, một BrinIndex trên created_at chỉ chiếm vài kilobyte trong khi B-tree cần tới hàng trăm megabyte, vì BRIN chỉ lưu giá trị nhỏ nhất và lớn nhất cho mỗi dải khối. Đánh đổi ở đây là BRIN chỉ hữu ích khi thứ tự vật lý của hàng bám theo cột được đánh chỉ mục, điều tự nhiên đúng với nhật ký chỉ thêm mới và các bảng sự kiện.

python
# models.py
from django.contrib.postgres.indexes import BrinIndex
from django.db import models

class Event(models.Model):
    payload = models.JSONField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            # Chỉ mục rất nhỏ cho quét theo khoảng trên bảng chỉ thêm mới, sắp theo thời gian
            BrinIndex(fields=["created_at"]),
        ]

Việc khớp loại chỉ mục với kiểu truy cập là chủ đề thường xuất hiện trong các buổi phỏng vấn cấp cao, vì nó chứng tỏ ứng viên suy luận về cách bố trí lưu trữ thay vì phản xạ thêm B-tree khắp mọi nơi.

Sẵn sàng chinh phục phỏng vấn Django?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Câu hỏi phỏng vấn về cơ sở dữ liệu Django trong năm 2026

Các câu hỏi về cơ sở dữ liệu chiếm ưu thế trong những buổi phỏng vấn Django cấp cao vì sự thành thạo ORM không đồng nghĩa với việc hiểu ORM sinh ra những gì. Các câu hỏi dưới đây phản ánh điều mà các hội đồng tuyển dụng thực sự đào sâu trong năm 2026, và chúng đi liền một cách tự nhiên với việc tinh chỉnh ở cấp truy vấn được trình bày trong hướng dẫn tối ưu truy vấn Django ORM.

Khi nào một chỉ mục tổ hợp trên (a, b) không giúp được cho truy vấn? Khi truy vấn không lọc theo a. Một B-tree được sắp xếp theo cột đứng đầu trước tiên, nên bộ lọc chỉ theo b không thể dùng được chỉ mục. Đây là quy tắc tiền tố trái nhất, và những ứng viên giải thích được điều này cho thấy họ hiểu cấu trúc chỉ mục thay vì học thuộc công thức.

Sự khác biệt giữa select_relatedprefetch_related là gì? select_related thực hiện một phép JOIN trong SQL và hoạt động cho các quan hệ khóa ngoại và một-một trong một truy vấn duy nhất. prefetch_related chạy một truy vấn thứ hai rồi ghép kết quả trong Python, điều bắt buộc với các quan hệ nhiều-nhiều và khóa ngoại ngược. Chọn nhầm một trong hai hoặc là bỏ lỡ tối ưu, hoặc là gây ra một truy vấn thứ hai không cần thiết.

icontains khác với tìm kiếm toàn văn ở cấp cơ sở dữ liệu như thế nào? icontains biên dịch thành ILIKE '%term%', thứ không thể dùng chỉ mục B-tree tiêu chuẩn và phải quét mọi hàng. Tìm kiếm toàn văn đối chiếu với một tsvector đã tính sẵn được hỗ trợ bởi chỉ mục GIN và xếp hạng kết quả theo độ liên quan. Sự phân biệt này là một dấu hiệu đáng tin cậy cho thấy ứng viên đã đi xa hơn những tập dữ liệu đồ chơi.

Vì sao count() có thể chậm trên một bảng lớn, và có những giải pháp thay thế nào? PostgreSQL không lưu sẵn số lượng hàng cho một bảng, nên COUNT(*) phải quét mọi hàng còn hiển thị theo cơ chế MVCC. Để đếm gần đúng, truy vấn pg_class.reltuples trả về một ước lượng ngay lập tức, còn với phân trang, phân trang theo keyset trên một cột đã đánh chỉ mục giúp tránh việc đếm hoàn toàn. Có thể luyện tập một cách có hệ thống về những mẫu này trong module phỏng vấn về caching trong Django, nơi các bộ đếm dựa trên cache là chủ đề thường gặp, và trong toàn bộ lộ trình học Django.

EXPLAIN ANALYZE cho thấy điều gì mà một mình EXPLAIN không cho thấy? EXPLAIN in ra chi phí ước tính của bộ lập kế hoạch; EXPLAIN ANALYZE thực sự thực thi truy vấn và báo cáo thời gian cùng số hàng thực tế. Khoảng cách lớn giữa số hàng ước tính và thực tế chỉ ra rằng thống kê bảng đã cũ, có thể khắc phục bằng ANALYZE, và đây là nguyên nhân gốc thường gặp khiến bộ lập kế hoạch bỏ qua một chỉ mục lẽ ra hiển nhiên phải được áp dụng.

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Kết luận

  • Khai báo chỉ mục trong Meta.indexes thay vì db_index=True để chỉ mục tổ hợp, một phần và bao phủ nằm cùng một nơi có thể kiểm tra được.
  • Sắp thứ tự các cột trong chỉ mục tổ hợp theo quy tắc tiền tố trái nhất: cột đứng đầu phải xuất hiện trong bộ lọc của truy vấn thì chỉ mục mới được áp dụng.
  • Dùng chỉ mục một phần khi truy vấn chỉ chạm tới nhóm thiểu số các hàng, chẳng hạn các đơn hàng đang chờ trong một bảng chủ yếu là lịch sử đã hoàn tất.
  • Thêm chỉ mục bao phủ với include để bật quét chỉ mục thuần và bỏ qua việc truy cập heap trên các truy vấn tổng hợp nặng về đọc.
  • Thay tìm kiếm bằng icontains bằng SearchVector, SearchQuerySearchRank để có sẵn tính năng xếp hạng theo độ liên quan và rút gọn về gốc từ.
  • Lưu tài liệu tìm kiếm trong một SearchVectorField được hỗ trợ bởi GinIndex, giữ đồng bộ bằng signal, GeneratedField hoặc trigger cơ sở dữ liệu, trước khi tìm kiếm toàn văn đạt quy mô production.
  • Luôn kiểm chứng các quyết định về chỉ mục bằng EXPLAIN (ANALYZE, BUFFERS); một chỉ mục mà bộ lập kế hoạch không bao giờ chọn là một khoản thuế lúc ghi mà không mang lại lợi ích lúc đọc.

Thẻ

#django
#postgresql
#database optimization
#full text search
#python

Chia sẻ

Bài viết liên quan