กลับไปยังบทความ

Backend

การปรับแต่ง SQL Query ให้เร็วขึ้น: อ่าน Query Plan และแก้ต้นเหตุที่พบบ่อย

Query ที่ช้าแทบไม่เคยเป็นปริศนา ถ้าคุณอ่าน execution plan ของมันเป็น คู่มือนี้อธิบาย EXPLAIN ANALYZE แล้วแก้ปัญหาที่เป็นต้นเหตุของ query ช้าส่วนใหญ่ ได้แก่ N+1, filter ที่ไม่ sargable, การประมาณจำนวนแถวผิดพลาด, pagination ที่ลึกเกินไป และการดึงข้อมูลมากเกินจำเป็น

24 กันยายน 25696 นาทีในการอ่าน

SQL Query Optimization: Reading Plans and Fixing the Usual Suspects

เมื่อ endpoint ไหนเริ่มช้า ฐานข้อมูลมักเป็นผู้ต้องสงสัยอันดับแรก และส่วนใหญ่ก็สงสัยถูกเสียด้วย การเพิ่มฮาร์ดแวร์หรือ cache ทุกอย่างแทบไม่เคยเป็นก้าวแรกที่ถูกต้อง query ที่ช้าส่วนใหญ่มีต้นเหตุร่วมกันเพียงไม่กี่อย่าง และฐานข้อมูลจะบอกคุณได้ชัดเจนว่าเป็นอย่างไหน ถ้าคุณถามมัน ตัวอย่างด้านล่างใช้ PostgreSQL แต่แนวคิดนำไปใช้กับ MySQL, SQL Server และตัวอื่น ๆ ได้เช่นกัน

ขั้นที่ 1: หา query ที่สำคัญจริง ๆ

อย่าปรับแต่งแบบสุ่ม ให้หา query ที่กินเวลา รวม มากที่สุด ซึ่งก็คือความถี่ × ระยะเวลา:

sql
-- requires the pg_stat_statements extension
SELECT query,
       calls,
       round(total_exec_time::numeric, 0) AS total_ms,
       round(mean_exec_time::numeric, 1)  AS mean_ms,
       rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;

Query ที่ใช้เวลา 5ms แต่ถูกเรียก 2 ล้านครั้งต่อชั่วโมง มักสำคัญกว่ารายงานที่ใช้เวลา 3 วินาทีแต่รันแค่วันละครั้ง

ขั้นที่ 2: อ่าน plan

EXPLAIN แสดง plan ที่ optimizer ตั้งใจจะใช้ ส่วน EXPLAIN ANALYZE จะรัน query จริงแล้วรายงานสิ่งที่เกิดขึ้น:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.total
FROM orders o
WHERE o.customer_id = 4821
  AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 20;
code
Limit  (cost=0.43..58.21 rows=20) (actual time=0.041..0.118 rows=20 loops=1)
  ->  Index Scan Backward using orders_customer_created_idx on orders o
        Index Cond: (customer_id = 4821)
        Filter: (status = 'paid')
        Rows Removed by Filter: 3
        Buffers: shared hit=24
Planning Time: 0.2 ms
Execution Time: 0.15 ms

สิ่งที่ควรมองหา:

  • ประเภทของ node Seq Scan บนตารางขนาดใหญ่ในจุดที่คุณคาดว่าจะใช้ index, Nested Loop ที่ฝั่ง outer มีขนาดใหญ่มาก หรือ Sort ที่ล้นลง disk (external merge)
  • จำนวนแถวที่ประมาณไว้ เทียบกับจำนวนจริง ถ้า planner คาดไว้ 10 แถวแต่ได้จริง 500,000 แถว มีแนวโน้มว่ามันเลือกกลยุทธ์การ join ผิด การประมาณที่คลาดเคลื่อนเป็นต้นเหตุของ plan แย่ ๆ จำนวนมาก
  • Rows Removed by Filter ถ้าตัวเลขสูง แปลว่า index ช่วยกรองได้น้อยเกินไป และฐานข้อมูลต้องทิ้งงานส่วนใหญ่ที่ทำไป
  • Buffers shared read หมายถึง page ถูกอ่านมาจาก disk ส่วน shared hit หมายถึงมาจาก cache ถ้า query ที่ถูกเรียกบ่อยมี read สูง แสดงว่ามีปัญหาด้าน I/O
  • loops เวลาจริงของ node ด้านในเป็นค่า ต่อหนึ่ง loop ต้องคูณด้วยจำนวน loops จึงจะได้ต้นทุนจริง

ผู้ต้องสงสัยที่ 1: N+1 query

ปัญหาที่เกี่ยวกับ ORM ที่พบบ่อยที่สุด คุณโหลดรายการมาชุดหนึ่ง แล้ว query อีกครั้งต่อหนึ่งรายการ:

python
orders = Order.objects.filter(status="paid")[:100]   # 1 query
for o in orders:
    print(o.customer.name)                           # +100 queries

แต่ละ query เร็วเมื่อดูแยกกัน แต่การไปกลับบนเครือข่าย 101 รอบจะสะสมเป็นเวลามากอย่างรวดเร็ว แก้ได้ด้วยการดึงข้อมูลที่เกี่ยวข้องทีเดียวเป็นชุด:

python
orders = (Order.objects
          .filter(status="paid")
          .select_related("customer")[:100])         # 1 query with a JOIN

หรือใน SQL ใช้การ lookup ด้วย IN เพียงครั้งเดียวแทนการวน loop:

sql
SELECT id, name FROM customers WHERE id = ANY($1::bigint[]);

วิธีตรวจจับ: log จำนวน query ต่อ request ในสภาพแวดล้อม development และให้ test fail เมื่อ endpoint ไหนใช้เกินงบที่กำหนด

ผู้ต้องสงสัยที่ 2: predicate ที่ไม่ sargable

Predicate จะ sargable ("search argument-able") เมื่อฐานข้อมูลใช้ index ในการหาผลลัพธ์ได้ การครอบคอลัมน์ที่มี index ด้วยฟังก์ชันหรือนิพจน์มักจะทำให้ใช้ index ไม่ได้:

sql
-- Can't use an index on created_at
WHERE DATE(created_at) = '2026-09-01'
WHERE created_at + INTERVAL '1 day' > now()
WHERE LOWER(email) = '[email protected]'
WHERE amount_cents / 100 > 50

ให้เขียนใหม่ให้คอลัมน์เปล่า ๆ อยู่โดดเดี่ยวฝั่งใดฝั่งหนึ่ง:

sql
WHERE created_at >= '2026-09-01' AND created_at < '2026-09-02'
WHERE created_at > now() - INTERVAL '1 day'
WHERE amount_cents > 5000

ถ้าจำเป็นต้องใช้ฟังก์ชันจริง ๆ ให้สร้าง index บนนิพจน์นั้น:

sql
CREATE INDEX users_email_lower_idx ON users (LOWER(email));

ปัญหาด้าน sargability อื่น ๆ:

  • Wildcard นำหน้า (LIKE '%smith') ใช้ B-tree index ไม่ได้ ลองพิจารณา trigram index หรือ full-text search
  • การแปลงชนิดข้อมูลโดยปริยาย (implicit type cast) เช่น การเทียบคอลัมน์ varchar กับพารามิเตอร์ที่เป็นจำนวนเต็ม อาจทำให้ index ถูกปิดไปแบบเงียบ ๆ
  • OR ข้ามคอลัมน์ต่างกัน มักจะถอยกลับไปเป็นการ scan การใช้ UNION ALL รวมสอง query ที่มี index อาจเร็วกว่ามาก

ผู้ต้องสงสัยที่ 3: statistics ที่เก่าหรือชวนให้เข้าใจผิด

Planner เลือก plan จาก statistics ของข้อมูลคุณ เมื่อ statistics ผิด มันก็ตัดสินใจผิด

  • รีเฟรช statistics หลัง bulk load ด้วย ANALYZE table_name; Autovacuum จะทำให้เองในที่สุด แต่ "ในที่สุด" อาจมาถึงหลังจาก job ตอนกลางคืนของคุณรันช้าไปเรียบร้อยแล้ว
  • คอลัมน์ที่สัมพันธ์กัน Planner สันนิษฐานว่าคอลัมน์เป็นอิสระต่อกัน ถ้า city = 'Bangkok' และ country = 'TH' มาคู่กันเสมอ มันจะประมาณจำนวนแถวต่ำเกินไป Extended statistics แก้ปัญหานี้ได้:
sql
CREATE STATISTICS addr_stats (dependencies) ON city, country FROM addresses;
ANALYZE addresses;
  • คอลัมน์ที่ข้อมูลเบ้ เพิ่ม statistics target ให้คอลัมน์ที่มีการกระจายตัวไม่สม่ำเสมอ: ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;

ผู้ต้องสงสัยที่ 4: OFFSET pagination ที่ลึกเกินไป

sql
SELECT * FROM events ORDER BY id LIMIT 20 OFFSET 200000;

เพื่อจะคืนหน้าที่ 10,001 ฐานข้อมูลยังต้องอ่านแล้วทิ้งไป 200,000 แถว ยิ่งหน้าลึกเท่าไร query ก็ยิ่งช้าลง

ให้ใช้ keyset (cursor) pagination แทน แล้วอ่านต่อจากแถวสุดท้ายที่เห็น:

sql
SELECT * FROM events
WHERE id > $last_seen_id
ORDER BY id
LIMIT 20;

เมื่อมี index บนคีย์ที่ใช้เรียงลำดับ ทุกหน้าจะมีต้นทุนเท่ากัน สำหรับการเรียงหลายคอลัมน์ ให้เปรียบเทียบเป็น row tuple: WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC

ผู้ต้องสงสัยที่ 5: ดึงข้อมูลมากเกินจำเป็น

  • SELECT * ดึงคอลัมน์ขนาดใหญ่ (JSON blob, เนื้อหาข้อความ) ที่ endpoint ไม่ได้ใช้ และยังทำให้ใช้ index-only scan ไม่ได้ ให้ระบุเฉพาะคอลัมน์ที่ต้องการ
  • นับทุกอย่าง การใช้ SELECT COUNT(*) บนตารางใหญ่เพื่อแสดงป้าย "1,234,567 รายการ" นั้นแพง ลองใช้ค่าประมาณ หรือจำกัดเพดานการนับ ("1,000+")
  • คำนวณในแอปทั้งที่ DB aggregate ให้ได้ การโหลด 100,000 แถวมารวมผลในโค้ดแอปพลิเคชันเปลืองทั้งเครือข่ายและ memory ให้ใช้ SUM/GROUP BY ในฐานข้อมูล
  • ไม่ใส่ LIMIT ใน query ที่ต้องการแค่ผลลัพธ์แรกที่เจอ ให้ใช้ EXISTS แทน COUNT(*) > 0

ผู้ต้องสงสัยที่ 6: transaction ที่ยาวนานและการรอ lock

บางครั้ง query เร็วอยู่แล้ว แต่ต้อง รอ ให้ตรวจดู session ที่ถูก block:

sql
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event IS NOT NULL AND state <> 'idle';

ทำ transaction ให้สั้น อย่าเปิด transaction ค้างไว้ระหว่างเรียกเครือข่ายไปยัง service อื่นเด็ดขาด และทำ bulk update เป็น batch เพื่อไม่ให้ lock แถวนับล้านพร้อมกัน

ขั้นตอนการปรับแต่งเป็นกิจวัตร

  1. จัดอันดับ query ตามเวลารวมด้วย pg_stat_statements
  2. รัน EXPLAIN (ANALYZE, BUFFERS) กับตัวปัญหาอันดับหนึ่ง โดยใช้พารามิเตอร์ที่สมจริง
  3. เทียบจำนวนแถวที่ประมาณไว้กับจำนวนจริง ถ้าห่างกันมาก ให้แก้ statistics ก่อน
  4. ตรวจเรื่อง sargability และ index ที่ขาดหายหรือไม่ตรงกับ query
  5. เปลี่ยนทีละอย่าง วัดผลใหม่ และเก็บการเปลี่ยนแปลงไว้เฉพาะเมื่อตัวเลขดีขึ้น
  6. เพิ่มการตรวจจับ regression เช่น งบจำนวน query หรือ alert สำหรับ slow query

Query ที่ช้าส่วนใหญ่มักกลายเป็นหนึ่งในปัญหาไม่กี่ข้อนี้ และ execution plan ก็มักจะบอกได้ว่าเป็นข้อไหน

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ