Django และ PostgreSQL ในปี 2026: การทำ Indexing, Full-Text Search และคำถามสัมภาษณ์
คู่มือปฏิบัติสำหรับการปรับแต่งประสิทธิภาพ Django PostgreSQL: B-tree, partial index, covering index, การทำ full-text search ด้วย SearchVector และ GIN พร้อมคำถามสัมภาษณ์ปี 2026

การปรับแต่งประสิทธิภาพ Django PostgreSQL คือความแตกต่างระหว่างแอปพลิเคชันที่ขยายรองรับผู้ใช้เกินหลักพันคนแรกได้ กับแอปพลิเคชันที่ทำงานช้าลงจนเกือบหยุดชะงักในทุกหน้ารายการ PostgreSQL มาพร้อมกับเอนจินสำหรับทำ indexing และระบบย่อยสำหรับ full-text search ที่โปรเจกต์ Django ส่วนใหญ่ไม่เคยแตะต้อง ทำให้สูญเสียประสิทธิภาพของคิวรีที่สามารถเรียกคืนกลับมาได้โดยไม่มีต้นทุน คู่มือนี้ครอบคลุมรูปแบบการทำ indexing, เครื่องมือค้นหาใน django.contrib.postgres และคำถามสัมภาษณ์ด้านฐานข้อมูลที่แยกวิศวกร Django ระดับกลางออกจากระดับซีเนียร์ในปี 2026
ทุก index ช่วยเร่งความเร็วในการอ่าน แต่ทำให้การเขียนช้าลงและกินพื้นที่ดิสก์ ควรรัน EXPLAIN (ANALYZE, BUFFERS) บนคิวรีจริง เพิ่ม index แล้วเปรียบเทียบแผนการทำงาน index ที่ query planner ไม่เคยเลือกใช้เลยคือภาระที่เปล่าประโยชน์ในทุกครั้งที่มีการ INSERT และ UPDATE
พื้นฐานการทำ Indexing ของ PostgreSQL ใน Django
โดยค่าเริ่มต้น PostgreSQL จะสร้าง B-tree index ให้เฉพาะกับ primary key และคอลัมน์ที่กำหนด unique=True เท่านั้น คอลัมน์อื่นทุกคอลัมน์ที่ถูกใช้กรองหรือเรียงลำดับจะทำ sequential scan จนกว่าจะมี index Django เปิดให้กำหนด index ผ่าน Meta.indexes ซึ่งเป็นวิธีที่แนะนำมากกว่า db_index=True แบบเดิม เพราะรองรับทั้ง composite, partial และ covering index ได้ในที่เดียว
ลำดับของคอลัมน์ภายใน composite index ไม่ใช่แค่เรื่องความสวยงาม B-tree จะใช้ index กับคิวรีได้ก็ต่อเมื่อเงื่อนไขการกรองใช้คอลัมน์นำหน้า (leading prefix) ของคอลัมน์ที่ทำ index ไว้ ซึ่งเป็นกฎที่อธิบายไว้อย่างละเอียดใน Use The Index, Luke index บน (status, created_at) รองรับคิวรี WHERE status = ? ORDER BY created_at ได้ แต่ index เดียวกันนี้ไม่สามารถเร่งความเร็วคิวรีที่กรองด้วย created_at เพียงอย่างเดียวได้
# 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 คอลัมน์เดียวสำหรับการค้นหาแบบตรงตัวและช่วงค่าบน status
models.Index(fields=["status"]),
# Composite index: รองรับ WHERE status = ? ORDER BY created_at DESC
# คอลัมน์นำหน้า (status) ต้องปรากฏในเงื่อนไขกรองจึงจะถูกใช้งาน
models.Index(fields=["status", "-created_at"]),
]composite index ทำให้คิวรีสำหรับแดชบอร์ดอย่าง "ออเดอร์ที่รอดำเนินการล่าสุด" กลายเป็นการทำ index scan เพียงครั้งเดียว แทนที่จะต้องกรองแล้วเรียงลำดับทั้งตาราง
Partial Index และ Covering Index สำหรับคิวรีเฉพาะเจาะจง
partial index จะครอบคลุมเฉพาะแถวที่ตรงตามเงื่อนไขเท่านั้น จึงมีขนาดเล็กและคงอยู่ในหน่วยความจำได้ เมื่อแถวส่วนใหญ่มีค่าเหมือนกันและคิวรีแตะเฉพาะกลุ่มส่วนน้อยเสมอ partial index จะประหยัดต้นทุนกว่า index แบบเต็มอย่างมาก covering index ก้าวไปอีกขั้น: ด้วยการเพิ่มคอลัมน์ที่ไม่ใช่คีย์ผ่าน include PostgreSQL จะตอบคิวรีจาก index ได้โดยตรงโดยไม่ต้องแตะ heap ของตาราง รูปแบบการเข้าถึงข้อมูลนี้เรียกว่า index-only scan
# models.py
from django.db import models
from django.db.models import Q
class Order(models.Model):
# fields as defined above
class Meta:
indexes = [
# Partial index: ทำ index เฉพาะแถวที่ pending โดยไม่สนใจประวัติที่เสร็จสิ้นแล้ว
models.Index(
fields=["created_at"],
name="pending_orders_idx",
condition=Q(status="pending"),
),
# Covering index: อ่านค่า total จาก index ได้โดยตรง (index-only scan)
models.Index(
fields=["status"],
include=["total"],
name="status_total_covering_idx",
),
]บนตารางออเดอร์ที่มีแถวสถานะ pending เพียง 2% partial index อาจมีขนาดเล็กกว่า index แบบเต็มบนคอลัมน์เดียวกันถึงห้าสิบเท่า และ planner จะเก็บมันไว้ในบัฟเฟอร์แคชให้พร้อมใช้งานเสมอ ประโยค include มีเอกสารอธิบายไว้ใน PostgreSQL index-only scans และเป็นจุดที่ถูกมองข้ามมากที่สุดในแอปพลิเคชัน Django ที่ต้องรวมค่าคอลัมน์ตัวเลขภายใต้เงื่อนไขการกรองด้วยสถานะ
การทำ Full-Text Search ใน Django ด้วย PostgreSQL
การเลือกใช้ title__icontains=query อาจดูสะดวก แต่มันบังคับให้เกิด sequential scan และไม่สามารถจัดอันดับผลลัพธ์ตามความเกี่ยวข้องได้ full-text search ของ Django ห่อหุ้มชนิดข้อมูล tsvector และ tsquery ดั้งเดิมของ PostgreSQL ไว้ผ่าน SearchVector, SearchQuery และ SearchRank search vector จะแปลงข้อความให้เป็น lexeme ตัดคำที่ไม่มีนัยสำคัญ (stop words) ออก และลดรูปคำให้เหลือเพียงรากศัพท์ ดังนั้นการค้นหาคำว่า "running" จึงตรงกับ "run" และ "ran"
# 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")
# Weight A ให้คะแนนการจับคู่ใน title สูงกว่าใน body (weight 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")
)พารามิเตอร์ weight จะกำหนดกลุ่มลำดับความสำคัญตั้งแต่ A (สูงสุด) ไปจนถึง D การจับคู่ใน title จะได้อันดับสูงกว่าการจับคู่ใน body แม้ว่าทั้งสองจะมีคำค้นหาอยู่ก็ตาม ซึ่งตรงกับที่ผู้ใช้คาดหวังว่าการค้นหาควรทำงาน API ทั้งหมดและโมเดลการให้น้ำหนักสี่ระดับมีอธิบายไว้ใน เอกสารอ้างอิง Django full-text search
GIN Index และ SearchVectorField เพื่อการค้นหาที่รวดเร็ว
คิวรีข้างต้นจะคำนวณ search vector ใหม่สำหรับทุกแถวในทุกครั้งที่มีการร้องขอ ซึ่งยอมรับได้กับข้อมูลไม่กี่พันแถว แต่ยอมรับไม่ได้เมื่อมีถึงหนึ่งล้านแถว รูปแบบที่ใช้ในระบบจริงคือการเก็บ vector ไว้ใน SearchVectorField และทำ index ด้วย GIN index ซึ่งเป็นชนิด index ที่ PostgreSQL ใช้กับค่าประเภทประกอบ เช่น เอกสาร full-text และ JSONB
# 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()
# เอกสารค้นหาที่คำนวณล่วงหน้าและจัดเก็บไว้
search_vector = SearchVectorField(null=True)
class Meta:
indexes = [
# GIN เปลี่ยนการค้นหา full-text ให้เป็น index scan แบบลอการิทึม
GinIndex(fields=["search_vector"]),
]ฟิลด์ที่จัดเก็บไว้ต้องซิงค์ให้ตรงกับ title และ body เสมอ การใช้ signal post_save ที่เขียนค่า vector ด้วย .update() จะช่วยเลี่ยงการทริกเกอร์ signal ซ้ำแบบเรียกตัวเอง เพราะ QuerySet.update() ไม่ปล่อย save signal ออกมา
# 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"),
)สำหรับทีมที่ใช้ Django 5.0 ขึ้นไป GeneratedField ที่คำนวณในฝั่งฐานข้อมูลหรือ trigger ของ PostgreSQL จะขจัดคำสั่ง UPDATE ส่วนเกินออกไปทั้งหมด โดยดูแลคอลัมน์นี้ไว้ภายในฐานข้อมูล ไม่ว่าจะใช้กลไกใดในการเติมค่า คิวรีจะกรองฟิลด์ที่ทำ index ไว้ได้โดยตรง และ GIN index จะเปลี่ยนสิ่งที่เคยเป็น full table scan ให้กลายเป็นการค้นหาแบบลอการิทึม
GIN index เร่งความเร็วในการอ่านโดยแลกกับต้นทุนการเขียนที่แท้จริง เพราะการ insert แต่ละครั้งจะแตะข้อมูล index หลายรายการ บนตารางที่มีการเขียนบ่อย ควรตั้งค่าพารามิเตอร์การจัดเก็บ fastupdate และเฝ้าดูขนาดของ pending-list มิฉะนั้นการเขียน full-text จะติดขัดเพราะรอการดูแล index
การเลือกชนิด PostgreSQL Index ที่เหมาะสม
B-tree คือค่าเริ่มต้นที่ถูกต้อง แต่ PostgreSQL เปิดให้ใช้ index หลายชนิดผ่าน django.contrib.postgres.indexes และการเลือกผิดชนิดจะสิ้นเปลืองพื้นที่ดิสก์โดยไม่ช่วยปรับปรุงคิวรีใดเลย การเลือกขึ้นอยู่กับรูปร่างของข้อมูลและ operator ที่คิวรีใช้ ตามที่รวบรวมไว้ใน เอกสารชนิด index ของ PostgreSQL
| ชนิด Index | เหมาะกับ | คลาสใน Django |
|------------|----------|--------------|
| B-tree | การค้นหาแบบเท่ากันและช่วงค่าบนคอลัมน์สเกลาร์ | models.Index |
| GIN | Full-text search, JSONB, การตรวจสอบสมาชิกใน array | GinIndex |
| BRIN | ตารางขนาดใหญ่แบบ append-only ที่เรียงตามคอลัมน์ | BrinIndex |
| Hash | การค้นหาแบบเท่ากันเท่านั้นบนคอลัมน์ขนาดใหญ่ | HashIndex |
BRIN สมควรได้รับความสนใจเป็นพิเศษสำหรับข้อมูลแบบอนุกรมเวลา บนตารางที่มีแถวหลายสิบล้านแถวที่ถูก insert ตามลำดับเวลา BrinIndex บน created_at ใช้พื้นที่เพียงไม่กี่กิโลไบต์ ในขณะที่ B-tree จะต้องใช้พื้นที่หลายร้อยเมกะไบต์ เพราะ BRIN เก็บเพียงค่าต่ำสุดและสูงสุดต่อช่วงบล็อกเท่านั้น ข้อแลกเปลี่ยนคือ BRIN จะช่วยได้ก็ต่อเมื่อลำดับทางกายภาพของแถวสอดคล้องกับคอลัมน์ที่ทำ index ซึ่งเป็นจริงโดยธรรมชาติสำหรับ log แบบ append-only และตารางเหตุการณ์
# 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 = [
# Index ขนาดจิ๋วสำหรับการสแกนช่วงค่าบนตารางแบบ append-only ที่เรียงตามเวลา
BrinIndex(fields=["created_at"]),
]การจับคู่ชนิด index ให้เข้ากับรูปแบบการเข้าถึงข้อมูลเป็นหัวข้อที่ปรากฏซ้ำในการสัมภาษณ์ระดับซีเนียร์ เพราะมันพิสูจน์ว่าผู้สมัครคิดถึงโครงสร้างการจัดเก็บข้อมูล แทนที่จะเติม B-tree ไปทุกที่แบบอัตโนมัติ
พร้อมที่จะพิชิตการสัมภาษณ์ Django แล้วหรือยังครับ?
ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ
คำถามสัมภาษณ์ด้านฐานข้อมูล Django ในปี 2026
คำถามด้านฐานข้อมูลครองพื้นที่ส่วนใหญ่ในการสัมภาษณ์ Django ระดับซีเนียร์ เพราะความคล่องแคล่วในการใช้ ORM ไม่ได้หมายความว่าเข้าใจสิ่งที่ ORM สร้างออกมา คำถามด้านล่างสะท้อนสิ่งที่คณะกรรมการสัมภาษณ์เจาะจริงในปี 2026 และเข้ากันได้ดีกับการปรับแต่งในระดับคิวรีที่กล่าวถึงใน คู่มือการปรับแต่งคิวรีด้วย Django ORM
composite index บน (a, b) จะช่วยคิวรีไม่ได้เมื่อใด? เมื่อคิวรีไม่ได้กรองด้วย a B-tree จะเรียงตามคอลัมน์นำหน้าเป็นอันดับแรก ดังนั้นการกรองด้วย b เพียงอย่างเดียวจึงใช้ index ไม่ได้ นี่คือกฎ leftmost-prefix และผู้สมัครที่อธิบายได้แสดงว่าเข้าใจโครงสร้างของ index มากกว่าการท่องจำสูตรสำเร็จ
select_related กับ prefetch_related ต่างกันอย่างไร? select_related ทำ SQL JOIN และใช้ได้กับความสัมพันธ์แบบ foreign-key และ one-to-one ในคิวรีเดียว ส่วน prefetch_related จะรันคิวรีที่สองแล้วรวมผลลัพธ์ใน Python ซึ่งจำเป็นสำหรับความสัมพันธ์แบบ many-to-many และ reverse foreign-key การเลือกใช้ผิดตัวจะทำให้พลาดการปรับแต่ง หรือทำให้เกิดคิวรีที่สองโดยไม่จำเป็น
icontains ต่างจาก full-text search ในระดับฐานข้อมูลอย่างไร? icontains จะถูกแปลงเป็น ILIKE '%term%' ซึ่งไม่สามารถใช้ B-tree index มาตรฐานได้และต้องสแกนทุกแถว ส่วน full-text search จะจับคู่กับ tsvector ที่คำนวณล่วงหน้าซึ่งมี GIN index รองรับ และจัดอันดับผลลัพธ์ตามความเกี่ยวข้อง ความแตกต่างนี้เป็นสัญญาณที่เชื่อถือได้ว่าผู้สมัครก้าวข้ามชุดข้อมูลขนาดเล็กแบบทดลองไปแล้ว
ทำไม count() จึงช้าบนตารางขนาดใหญ่ และมีทางเลือกอะไรบ้าง? PostgreSQL ไม่มีการเก็บจำนวนแถวของตารางไว้ในแคช ดังนั้น COUNT(*) จึงต้องสแกนทุกแถวที่มองเห็นได้ภายใต้ MVCC สำหรับการนับแบบประมาณ การคิวรี pg_class.reltuples จะคืนค่าประมาณการได้ทันที และสำหรับการแบ่งหน้า การทำ keyset pagination บนคอลัมน์ที่มี index จะเลี่ยงการนับไปได้เลย การฝึกฝนอย่างเป็นระบบกับรูปแบบเหล่านี้มีอยู่ใน โมดูลคำถามสัมภาษณ์ Django caching ที่ตัวนับซึ่งใช้แคชรองรับเป็นหัวข้อที่ปรากฏซ้ำ และครอบคลุมทั้งใน เส้นทางการเรียนรู้ Django
EXPLAIN ANALYZE เผยอะไรที่ EXPLAIN เพียงอย่างเดียวไม่เผย? EXPLAIN จะแสดงต้นทุนที่ planner ประมาณการไว้ ส่วน EXPLAIN ANALYZE จะรันคิวรีจริงและรายงานเวลาและจำนวนแถวที่แท้จริง ช่องว่างที่กว้างระหว่างจำนวนแถวที่ประมาณการกับที่เกิดขึ้นจริงบ่งชี้ว่าสถิติของตารางล้าสมัย ซึ่งแก้ได้ด้วย ANALYZE และเป็นสาเหตุที่พบบ่อยของการที่ planner เพิกเฉยต่อ index ที่ควรถูกใช้อย่างชัดเจน
เริ่มฝึกซ้อมเลย!
ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ
บทสรุป
- กำหนด index ใน
Meta.indexesแทนdb_index=Trueเพื่อให้ composite, partial และ covering index อยู่รวมกันในที่เดียวที่ตรวจสอบได้ - จัดลำดับคอลัมน์ของ composite index ตามกฎ leftmost-prefix: คอลัมน์นำหน้าต้องปรากฏในเงื่อนไขกรองของคิวรีเพื่อให้ index ถูกใช้งาน
- ใช้ partial index เมื่อคิวรีแตะเฉพาะแถวส่วนน้อยเสมอ เช่น ออเดอร์ที่รอดำเนินการในตารางที่เต็มไปด้วยประวัติที่เสร็จสิ้นแล้ว
- เพิ่ม covering index ด้วย
includeเพื่อเปิดใช้ index-only scan และข้ามการเข้าถึง heap บนคิวรีรวมค่าที่เน้นการอ่าน - แทนที่การค้นหาด้วย
icontainsด้วยSearchVector,SearchQueryและSearchRankเพื่อให้ได้การจัดอันดับตามความเกี่ยวข้องและการลดรูปคำ (stemming) โดยไม่มีต้นทุนเพิ่ม - จัดเก็บเอกสารค้นหาไว้ใน
SearchVectorFieldที่มีGinIndexรองรับ และซิงค์ด้วย signal,GeneratedFieldหรือ trigger ของฐานข้อมูล ก่อนที่ full-text search จะขึ้นสู่ระดับการใช้งานจริง - ตรวจสอบการตัดสินใจเรื่อง index ด้วย
EXPLAIN (ANALYZE, BUFFERS)เสมอ index ที่ planner ไม่เคยเลือกใช้คือภาษีในเวลาเขียนที่ไม่ให้ประโยชน์ใดในเวลาอ่าน
แท็ก
แชร์
บทความที่เกี่ยวข้อง

เจาะลึก Serializer ใน Django REST Framework: Validation, Nested และการแก้ปัญหา N+1
คู่มือครบถ้วนสำหรับการใช้งาน DRF serializers: pipeline validation, nested serializers, การจัดการความสัมพันธ์ที่ซับซ้อน และเทคนิคการเพิ่มประสิทธิภาพเพื่อหลีกเลี่ยงปัญหา N+1 queries

Django Async Views และ ASGI ในปี 2026: ประสิทธิภาพและคำถามสัมภาษณ์
เจาะลึก Django async view และ ASGI ในปี 2026: กลไกเบื้องหลัง, เซิร์ฟเวอร์ที่ควรดีพลอย, async ORM กับกับดัก SynchronousOnlyOperation พร้อมคำถามสัมภาษณ์

Django 6.0 ในปี 2026: Composite Primary Key, Background Task และคำถามสัมภาษณ์งาน
คู่มือฉบับสมบูรณ์เกี่ยวกับ Django 6.0 ครอบคลุม composite primary key, framework background task ในตัว, template partial, CSP middleware พร้อมตัวอย่างโค้ดจริงและการเตรียมตัวสัมภาษณ์