# Django และ PostgreSQL ในปี 2026: การทำ Indexing, Full-Text Search และคำถามสัมภาษณ์ > คู่มือปฏิบัติสำหรับการปรับแต่งประสิทธิภาพ Django PostgreSQL: B-tree, partial index, covering index, การทำ full-text search ด้วย SearchVector และ GIN พร้อมคำถามสัมภาษณ์ปี 2026 - Published: 2026-06-27 - Updated: 2026-07-06 - Author: SharpSkill - Tags: django, postgresql, database optimization, full text search, python - Reading time: 9 min --- การปรับแต่งประสิทธิภาพ Django PostgreSQL คือความแตกต่างระหว่างแอปพลิเคชันที่ขยายรองรับผู้ใช้เกินหลักพันคนแรกได้ กับแอปพลิเคชันที่ทำงานช้าลงจนเกือบหยุดชะงักในทุกหน้ารายการ PostgreSQL มาพร้อมกับเอนจินสำหรับทำ indexing และระบบย่อยสำหรับ full-text search ที่โปรเจกต์ Django ส่วนใหญ่ไม่เคยแตะต้อง ทำให้สูญเสียประสิทธิภาพของคิวรีที่สามารถเรียกคืนกลับมาได้โดยไม่มีต้นทุน คู่มือนี้ครอบคลุมรูปแบบการทำ indexing, เครื่องมือค้นหาใน `django.contrib.postgres` และคำถามสัมภาษณ์ด้านฐานข้อมูลที่แยกวิศวกร Django ระดับกลางออกจากระดับซีเนียร์ในปี 2026 > **วัดผลก่อนสร้าง Index** > > ทุก 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](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys) index บน `(status, created_at)` รองรับคิวรี `WHERE status = ? ORDER BY created_at` ได้ แต่ index เดียวกันนี้ไม่สามารถเร่งความเร็วคิวรีที่กรองด้วย `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 คอลัมน์เดียวสำหรับการค้นหาแบบตรงตัวและช่วงค่าบน 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 ```python # 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](https://www.postgresql.org/docs/current/indexes-index-only-scans.html) และเป็นจุดที่ถูกมองข้ามมากที่สุดในแอปพลิเคชัน 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" ```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") # 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](https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/search/) ## GIN Index และ SearchVectorField เพื่อการค้นหาที่รวดเร็ว คิวรีข้างต้นจะคำนวณ search vector ใหม่สำหรับทุกแถวในทุกครั้งที่มีการร้องขอ ซึ่งยอมรับได้กับข้อมูลไม่กี่พันแถว แต่ยอมรับไม่ได้เมื่อมีถึงหนึ่งล้านแถว รูปแบบที่ใช้ในระบบจริงคือการเก็บ vector ไว้ใน `SearchVectorField` และทำ index ด้วย GIN index ซึ่งเป็นชนิด index ที่ PostgreSQL ใช้กับค่าประเภทประกอบ เช่น เอกสาร full-text และ 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() # เอกสารค้นหาที่คำนวณล่วงหน้าและจัดเก็บไว้ 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 ออกมา ```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"), ) ``` สำหรับทีมที่ใช้ Django 5.0 ขึ้นไป `GeneratedField` ที่คำนวณในฝั่งฐานข้อมูลหรือ trigger ของ PostgreSQL จะขจัดคำสั่ง UPDATE ส่วนเกินออกไปทั้งหมด โดยดูแลคอลัมน์นี้ไว้ภายในฐานข้อมูล ไม่ว่าจะใช้กลไกใดในการเติมค่า คิวรีจะกรองฟิลด์ที่ทำ index ไว้ได้โดยตรง และ GIN index จะเปลี่ยนสิ่งที่เคยเป็น full table scan ให้กลายเป็นการค้นหาแบบลอการิทึม > **GIN Index มีต้นทุนสูงในการเขียน** > > GIN index เร่งความเร็วในการอ่านโดยแลกกับต้นทุนการเขียนที่แท้จริง เพราะการ insert แต่ละครั้งจะแตะข้อมูล index หลายรายการ บนตารางที่มีการเขียนบ่อย ควรตั้งค่าพารามิเตอร์การจัดเก็บ `fastupdate` และเฝ้าดูขนาดของ pending-list มิฉะนั้นการเขียน full-text จะติดขัดเพราะรอการดูแล index ## การเลือกชนิด PostgreSQL Index ที่เหมาะสม B-tree คือค่าเริ่มต้นที่ถูกต้อง แต่ PostgreSQL เปิดให้ใช้ index หลายชนิดผ่าน `django.contrib.postgres.indexes` และการเลือกผิดชนิดจะสิ้นเปลืองพื้นที่ดิสก์โดยไม่ช่วยปรับปรุงคิวรีใดเลย การเลือกขึ้นอยู่กับรูปร่างของข้อมูลและ operator ที่คิวรีใช้ ตามที่รวบรวมไว้ใน [เอกสารชนิด index ของ PostgreSQL](https://www.postgresql.org/docs/current/indexes-types.html) | ชนิด 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 และตารางเหตุการณ์ ```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 = [ # Index ขนาดจิ๋วสำหรับการสแกนช่วงค่าบนตารางแบบ append-only ที่เรียงตามเวลา BrinIndex(fields=["created_at"]), ] ``` การจับคู่ชนิด index ให้เข้ากับรูปแบบการเข้าถึงข้อมูลเป็นหัวข้อที่ปรากฏซ้ำในการสัมภาษณ์ระดับซีเนียร์ เพราะมันพิสูจน์ว่าผู้สมัครคิดถึงโครงสร้างการจัดเก็บข้อมูล แทนที่จะเติม B-tree ไปทุกที่แบบอัตโนมัติ ## คำถามสัมภาษณ์ด้านฐานข้อมูล Django ในปี 2026 คำถามด้านฐานข้อมูลครองพื้นที่ส่วนใหญ่ในการสัมภาษณ์ Django ระดับซีเนียร์ เพราะความคล่องแคล่วในการใช้ ORM ไม่ได้หมายความว่าเข้าใจสิ่งที่ ORM สร้างออกมา คำถามด้านล่างสะท้อนสิ่งที่คณะกรรมการสัมภาษณ์เจาะจริงในปี 2026 และเข้ากันได้ดีกับการปรับแต่งในระดับคิวรีที่กล่าวถึงใน [คู่มือการปรับแต่งคิวรีด้วย Django ORM](/blog/django/django-orm-optimizing-queries) **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](/technologies/django/interview-questions/django-caching) ที่ตัวนับซึ่งใช้แคชรองรับเป็นหัวข้อที่ปรากฏซ้ำ และครอบคลุมทั้งใน [เส้นทางการเรียนรู้ Django](/technologies/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 ไม่เคยเลือกใช้คือภาษีในเวลาเขียนที่ไม่ให้ประโยชน์ใดในเวลาอ่าน --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/django/django-postgresql-indexing-full-text-search-2026