เมื่อ endpoint ไหนเริ่มช้า ฐานข้อมูลมักเป็นผู้ต้องสงสัยอันดับแรก และส่วนใหญ่ก็สงสัยถูกเสียด้วย การเพิ่มฮาร์ดแวร์หรือ cache ทุกอย่างแทบไม่เคยเป็นก้าวแรกที่ถูกต้อง query ที่ช้าส่วนใหญ่มีต้นเหตุร่วมกันเพียงไม่กี่อย่าง และฐานข้อมูลจะบอกคุณได้ชัดเจนว่าเป็นอย่างไหน ถ้าคุณถามมัน ตัวอย่างด้านล่างใช้ PostgreSQL แต่แนวคิดนำไปใช้กับ MySQL, SQL Server และตัวอื่น ๆ ได้เช่นกัน
ขั้นที่ 1: หา query ที่สำคัญจริง ๆ
อย่าปรับแต่งแบบสุ่ม ให้หา query ที่กินเวลา รวม มากที่สุด ซึ่งก็คือความถี่ × ระยะเวลา:
-- 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 จริงแล้วรายงานสิ่งที่เกิดขึ้น:
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;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 อีกครั้งต่อหนึ่งรายการ:
orders = Order.objects.filter(status="paid")[:100] # 1 query
for o in orders:
print(o.customer.name) # +100 queriesแต่ละ query เร็วเมื่อดูแยกกัน แต่การไปกลับบนเครือข่าย 101 รอบจะสะสมเป็นเวลามากอย่างรวดเร็ว แก้ได้ด้วยการดึงข้อมูลที่เกี่ยวข้องทีเดียวเป็นชุด:
orders = (Order.objects
.filter(status="paid")
.select_related("customer")[:100]) # 1 query with a JOINหรือใน SQL ใช้การ lookup ด้วย IN เพียงครั้งเดียวแทนการวน loop:
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 ไม่ได้:
-- 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ให้เขียนใหม่ให้คอลัมน์เปล่า ๆ อยู่โดดเดี่ยวฝั่งใดฝั่งหนึ่ง:
WHERE created_at >= '2026-09-01' AND created_at < '2026-09-02'
WHERE created_at > now() - INTERVAL '1 day'
WHERE amount_cents > 5000ถ้าจำเป็นต้องใช้ฟังก์ชันจริง ๆ ให้สร้าง index บนนิพจน์นั้น:
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 แก้ปัญหานี้ได้:
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 ที่ลึกเกินไป
SELECT * FROM events ORDER BY id LIMIT 20 OFFSET 200000;เพื่อจะคืนหน้าที่ 10,001 ฐานข้อมูลยังต้องอ่านแล้วทิ้งไป 200,000 แถว ยิ่งหน้าลึกเท่าไร query ก็ยิ่งช้าลง
ให้ใช้ keyset (cursor) pagination แทน แล้วอ่านต่อจากแถวสุดท้ายที่เห็น:
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:
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 แถวนับล้านพร้อมกัน
ขั้นตอนการปรับแต่งเป็นกิจวัตร
- จัดอันดับ query ตามเวลารวมด้วย
pg_stat_statements - รัน
EXPLAIN (ANALYZE, BUFFERS)กับตัวปัญหาอันดับหนึ่ง โดยใช้พารามิเตอร์ที่สมจริง - เทียบจำนวนแถวที่ประมาณไว้กับจำนวนจริง ถ้าห่างกันมาก ให้แก้ statistics ก่อน
- ตรวจเรื่อง sargability และ index ที่ขาดหายหรือไม่ตรงกับ query
- เปลี่ยนทีละอย่าง วัดผลใหม่ และเก็บการเปลี่ยนแปลงไว้เฉพาะเมื่อตัวเลขดีขึ้น
- เพิ่มการตรวจจับ regression เช่น งบจำนวน query หรือ alert สำหรับ slow query
Query ที่ช้าส่วนใหญ่มักกลายเป็นหนึ่งในปัญหาไม่กี่ข้อนี้ และ execution plan ก็มักจะบอกได้ว่าเป็นข้อไหน
