3 นาที

ปรับแต่งประสิทธิภาพ Go + Postgres: playbook สำหรับ API

คู่มือปรับแต่งประสิทธิภาพ Go + Postgres สำหรับ API ที่สร้างด้วย AI: ตั้ง pool การเชื่อมต่อ, อ่านแผนคิวรี, ทำดัชนีอย่างชาญฉลาด, แบ่งหน้าอย่างปลอดภัย และจัดรูปแบบ JSON ให้เร็วขึ้น

ปรับแต่งประสิทธิภาพ Go + Postgres: playbook สำหรับ API

หน้าตาของ "ช้า" สำหรับ Go APIs บน Postgres

API ที่สร้างจาก AI มักรู้สึกเร็วในการทดสอบแรก ๆ คุณเรียก endpoint สักสองสามครั้ง ข้อมูลยังน้อย และคำขอเข้ามาทีละเรื่อง พอมีทราฟฟิกจริง: endpoints หลายแบบ โหลดเป็นช่วง ๆ แคชเย็นกว่าเดิม และแถวข้อมูลมากกว่าที่คาดไว้ โค้ดเดิมก็เริ่มรู้สึกช้าบ้างเป็นบางครั้ง ถึงแม้จะไม่มีอะไรพังจริง ๆ

ความช้ามักแสดงออกไม่กี่แบบ: latency พุ่ง (request ส่วนใหญ่โอเค แต่บางอันใช้เวลา 5x ถึง 50x มากกว่าเดิม), เกิด timeout (ส่วนน้อยล้มเหลว), หรือ CPU รันหนัก (CPU ของ Postgres จากงานคิวรี หรือ CPU ของ Go จากการจัดการ JSON, goroutine, logging และ retries).

สถานการณ์ปกติคือ endpoint แบบรายการที่มีฟิลเตอร์ยืดหยุ่นและคืน JSON ขนาดใหญ่ ในฐานข้อมูลทดสอบมันสแกนไม่กี่พันแถวและจบเร็ว แต่ใน production มันสแกนเป็นล้านแถว จัดเรียง แล้วค่อย apply LIMIT API ยัง "ทำงาน" แต่ p95 latency ระเบิดและบางคำขอ timeout ในช่วงพีค

เพื่อแยกความช้าจากฐานข้อมูลกับความช้าจากแอป ให้เก็บโมเดลความคิดให้เรียบง่าย

ถ้าฐานข้อมูลช้า handler ใน Go ส่วนใหญ่รอกระบวนการคิวรี คุณอาจเห็นคำขอหลายรายการค้าง "in flight" ในขณะที่ Go CPU ดูปกติ

ถ้าแอปช้า คิวรีกลับมารวดเร็ว แต่เวลาเสียหลังคิวรี: สร้างออบเจ็กต์ขนาดใหญ่, marshal JSON, รันคิวรีต่อแถวหลายตัว, หรือทำงานมากเกินไปต่อคำขอ Go CPU เพิ่มขึ้น หน่วยความจำเพิ่ม และ latency โตตามขนาด response

"พอใช้" ก่อนปล่อยไม่ต้องสมบูรณ์แบบ สำหรับหลาย ๆ CRUD endpoints ตั้งเป้า p95 ที่นิ่ง (ไม่ใช่แค่อัตราเฉลี่ย), พฤติกรรมที่คาดเดาได้ตอนเกิด burst, และไม่มี timeout ในพีคที่คาดไว้ เป้าหมายชัดเจน: ไม่มีคำขอช้าที่น่าตกใจเมื่อข้อมูลและทราฟฟิกเพิ่ม และมีสัญญาณชัดเจนเมื่อมีบางอย่างเปลี่ยนไป

เริ่มจาก baseline: ตัวเลขไม่กี่ตัวที่สำคัญ

ก่อนจะปรับแต่งอะไร ให้ตัดสินใจว่า "ดี" คืออะไรสำหรับ API ของคุณ ถ้าไม่มี baseline ง่ายที่จะใช้เวลาหลายชั่วโมงเปลี่ยนค่าต่าง ๆ แล้วไม่รู้ว่าได้ปรับปรุงจริงหรือแค่ย้ายคอขวด

ตัวเลขสามตัวมักบอกเรื่องได้มากที่สุด:

  • p95 request latency (ไม่ใช่ค่าเฉลี่ย)
  • อัตราความผิดพลาด (HTTP 5xx, timeout, คำขอที่ถูกยกเลิก)
  • เวลา DB ต่อคำขอ (แต่ละคำขอรอ Postgres นานเท่าไร)

p95 คือเมตริกาของ "วันที่แย่" ถ้า p95 สูงแต่ค่าเฉลี่ยโอเค แสดงว่าชุดคำขอเล็ก ๆ กำลังทำงานหนักเกิน, ถูกบล็อกโดยล็อก หรือทำให้เกิดแผนช้า

ทำให้คิวรีช้าเห็นได้ตั้งแต่ต้น ใน Postgres ให้เปิด slow query logging ด้วย threshold ต่ำสำหรับการทดสอบก่อนปล่อย (เช่น 100–200 ms) และล็อก statement เต็มทั้งก้อนเพื่อคุณจะคัดลอกไปยัง client SQL ได้ เก็บการตั้งค่านี้ไว้ชั่วคราว เพราะการล็อกทุก slow query ใน production จะเสียงดังเร็ว

ถัดมา ทดสอบด้วยคำขอที่เหมือนของจริง ไม่ใช่แค่ route "hello world" หนึ่งตัว ชุดเล็ก ๆ ก็พอถ้ามันสอดคล้องกับสิ่งที่ผู้ใช้จะทำ: การเรียกรายการพร้อมฟิลเตอร์และการเรียง, หน้ารายละเอียดมี join สองสามตัว, create/update ที่มีการตรวจสอบความถูกต้อง, และคิวรีแบบ search ที่ใช้ partial match

ถ้าคุณสร้าง endpoints จากสเปค (เช่น ด้วยเครื่องมือสร้างโค้ดอย่าง Koder.ai) ให้รันคำขอชุดเดียวกันซ้ำ ๆ ด้วยอินพุตคงที่ นั่นทำให้การเปลี่ยนแปลงเช่นดัชนี การปรับ pagination และการเขียนคิวรีวัดผลได้ง่ายขึ้น

สุดท้าย เลือกเป้าหมายที่คุณบอกออกมาเป็นคำพูดได้ ตัวอย่าง: "คำขอส่วนใหญ่อยู่ใต้ 200 ms p95 ที่ 50 concurrent users และ error ต่ำกว่า 0.5%" ตัวเลขจริงขึ้นอยู่กับผลิตภัณฑ์ แต่มีเป้าหมายชัดเจนช่วยหยุดการปรับจูนที่ไร้ที่สิ้นสุด

Connection pooling ที่ทำให้ Postgres มั่นคง

Connection pool เก็บจำนวนการเชื่อมต่อฐานข้อมูลที่เปิดจำกัดและใช้ซ้ำ เมื่อไม่มี pool แต่ละคำขออาจเปิดการเชื่อมต่อใหม่ และ Postgres จะเสียเวลาและหน่วยความจำไปกับการจัดการ session แทนที่จะรันคิวรี

เป้าหมายคือให้ Postgres ทำงานที่มีประโยชน์ ไม่ใช่สลับบริบทระหว่างการเชื่อมต่อจำนวนมาก นี่มักเป็นชัยชนะที่จับต้องได้เป็นครั้งแรก โดยเฉพาะกับ API ที่สร้างจาก AI ซึ่งเงียบ ๆ กลายเป็น endpoints ที่คุยกันเยอะ

การตั้งค่าจุดเริ่มต้นง่าย ๆ

ใน Go ปกติคุณจะปรับ max open connections, max idle connections, และ connection lifetime ค่าเริ่มต้นที่ปลอดภัยสำหรับ API ขนาดเล็กหลายตัวคือหลายเท่าของจำนวนคอร์ CPU (มัก 5 ถึง 20 การเชื่อมต่อทั้งหมด), เก็บ idle ใกล้เคียงกัน, และรีไซเคิลการเชื่อมต่อเป็นระยะ (เช่น ทุก 30–60 นาที)

ถ้าคุณรันหลาย instance ของ API จำไว้ว่าพูลจะทวีคูณ พูล 20 connection ข้าม 10 instance กลายเป็น 200 connection ที่กระหน่ำ Postgres นี่คือสาเหตุที่ทีมมักเจอขีดจำกัด connection โดยไม่คาดคิด

วิธีบอกว่าปัญหาอยู่ที่พูล

ปัญหาพูลให้ความรู้สึกต่างจาก SQL ช้า

ถ้าพูลเล็กเกินไป คำขอจะรอก่อนจะเข้า Postgres Latency พุ่ง แต่ CPU และเวลา query ของ DB อาจดูปกติ

ถ้าพูลใหญ่เกินไป Postgres ดูโอเวอร์โหลด: session จำนวนมากใช้งาน หน่วยความจำกดดัน และ latency ไม่สม่ำเสมอระหว่าง endpoints

วิธีง่าย ๆ ในการแยกคือจับเวลาการเรียก DB เป็นสองส่วน: เวลาที่รอสายการเชื่อมต่อ vs เวลาที่ใช้รันคิวรี ถ้าส่วนใหญ่อยู่ใน "การรอ" พูลคือคอขวด ถ้าส่วนใหญ่อยู่ใน "รันคิวรี" ให้โฟกัสที่ SQL และดัชนี

เช็ครวดเร็วที่ใช้ได้:

  • ล็อกสเตตัสพูล (open, in-use, idle) และมองหาว่า in-use ติดที่ max ไหม
  • เพิ่ม timeout ในการขอ connection เพื่อให้รอพังเร็วในสเตจิง
  • มอนิเตอร์ active connections ใน Postgres และดูใกล้ max_connections แค่ไหน
  • ยืนยันว่าทุกคำขอปิด rows และคืน connection อย่างรวดเร็ว
  • ทดสอบโหลดด้วยจำนวน app instances ที่จะรันจริง

pgx pool vs database/sql

ถ้าใช้ pgxpool คุณได้พูลที่ออกแบบมาสำหรับ Postgres มีสถิติชัดเจนและค่าดีฟอลต์ที่เหมาะกับพฤติกรรมของ Postgres ถ้าใช้ database/sql คุณได้อินเทอร์เฟซมาตรฐานที่ทำงานกับหลายฐานข้อมูล แต่ต้องตั้งค่าพูลและพฤติกรรมของไดรเวอร์ให้ชัดเจน

กฎปฏิบัติ: ถ้าคุณใช้ Postgres เต็มตัวและต้องการควบคุมโดยตรง pgxpool มักง่ายกว่า ถ้าคุณพึ่งไลบรารีที่คาดว่าใช้ database/sql ก็ใช้มัน แต่ตั้งพูลให้ชัดและวัดการรอ

ตัวอย่าง: endpoint ที่แสดง orders อาจวิ่ง 20 ms แต่ภายใต้ 100 concurrent users กระโดดเป็น 2 s ถ้าล็อกแสดงว่า 1.9 s ใช้ไปกับการรอ connection การปรับคิวรีจะไม่ช่วยจนกว่าจะปรับพูลและจำนวน connection ทั้งหมดให้ถูกต้อง

การอ่านแผนคิวรี: อ่าน EXPLAIN อย่างเร็ว

เมื่อ endpoint รู้สึกช้า ให้เช็กว่า Postgres ทำอะไรอยู่จริง ๆ การอ่าน EXPLAIN อย่างเร็วมักชี้จุดแก้ได้ในไม่กี่นาที

รันบน SQL ที่ API ส่งจริง:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;

บรรทัดไม่กี่บรรทัดมีความหมายที่สุด ดูโหนดบนสุด (ที่ Postgres เลือก) และตัวเลขรวมด้านล่าง (ใช้เวลาเท่าไร) แล้วเทียบ estimated vs actual rows ช่องว่างใหญ่ ๆ มักหมายถึง planner ทายผิด

บรรทัดสำคัญหมายถึงอะไรโดยทั่วไป

ถ้าเห็น Index Scan หรือ Index Only Scan แปลว่า Postgres ใช้ดัชนี ซึ่งมักดี Bitmap Heap Scan พอใช้ได้กับการแมตช์ขนาดกลาง Seq Scan หมายถึงอ่านทั้งตาราง ซึ่งใช้ได้เมื่อโต๊ะเล็กหรือเกือบทุกแถวตรงเงื่อนไข

สัญญาณเตือนทั่วไป:

  • Seq Scan บนตารางใหญ่
  • estimated rows กับ actual rows ต่างกันมาก (เช่น ประมาณ 10 แต่จริง 10,000)
  • Sort กินเวลาส่วนใหญ่ (มักคู่กับ ORDER BY)
  • "Filter:" เอาแถวออกมากหลังการสแกน
  • shared read blocks สูงใน BUFFERS (อ่านข้อมูลมาก)

ทำไมแผนผิดพลาด (และการแก้ที่ง่าย)

แผนช้าส่วนใหญ่มาจากรูปแบบไม่กี่อย่าง:

  • ไม่มีดัชนีที่ตรงกับรูปแบบ WHERE + ORDER BY ของคุณ (เช่น (user_id, status, created_at))
  • ชนิดข้อมูลไม่ตรงกัน (เช่น เทียบ UUID กับ parameter แบบ text) ซึ่งอาจขัดขวางการใช้ดัชนี
  • ฟังก์ชันใน WHERE (เช่น WHERE lower(email) = $1) ซึ่งอาจบังคับให้สแกนเว้นแต่จะเพิ่ม expression index ที่ตรงกัน

ถ้าแผนดูแปลกและ estimate ผิดมาก สถิติอาจล้าสมัย รัน ANALYZE (หรือรอ autovacuum) ให้ Postgres เรียนรู้จำนวนแถวและการแจกแจงค่าปัจจุบัน สิ่งนี้สำคัญหลัง import ขนาดใหญ่หรือเมื่อ endpoint ใหม่เริ่มเขียนข้อมูลจำนวนมากอย่างรวดเร็ว

ทำดัชนีให้ตรงกับคิวรีที่คุณเรียกจริง ๆ

สร้าง Go API อย่างรวดเร็ว
อธิบาย endpoints ของคุณแล้วรับบริการ Go + Postgres ที่ปรับจูนได้ตามรูปแบบการใช้งานจริง

ดัชนีช่วยได้เมื่อมันตรงกับวิธีที่ API ของคุณเรียกข้อมูล ถ้าคุณสร้างจากการเดา คุณจะได้เขียนช้าลง พื้นที่เก็บเพิ่ม และได้ประสิทธิภาพไม่คุ้มค่า

คิดง่าย ๆ ว่า: ดัชนีคือทางลัดสำหรับคำถามเฉพาะ ถ้า API ของคุณถามคำถามอื่น Postgres ก็จะไม่ใช้ทางลัดนั้น

สร้างดัชนีรอบ ๆ ฟิลเตอร์ + การเรียง

ถ้า endpoint กรองด้วย account_id และเรียงด้วย created_at DESC ดัชนีเชิงประกอบเดียวมักชนะสองดัชนีแยก มันช่วยให้ Postgres หาแถวที่ถูกต้องและคืนผลในลำดับที่ต้องการด้วยงานน้อยกว่า

กฎง่าย ๆ ที่มักใช้ได้:

  • ทำดัชนีคอลัมน์ที่คุณกรองบ่อย แล้วเพิ่มคอลัมน์ที่คุณเรียง
  • เก็บ composite index ให้เล็ก สองคอลัมน์เป็นเรื่องปกติ สามบางครั้งโอเค มากกว่านั้นมักเป็นสัญญาณไม่ดี
  • ใส่ฟิลเตอร์ที่ละเอียดที่สุดก่อน (อันที่กรองผลมากที่สุด)
  • หลีกเลี่ยงดัชนีแยกที่ถูกคลุมโดย composite index ที่ดีกว่า
  • เลือกดัชนีที่ดีอันเดียว มากกว่าหลายอันที่ "อาจ" มีประโยชน์

ตัวอย่าง: ถ้า API ของคุณมี GET /orders?status=paid และแสดงล่าสุดก่อน ดัชนี (status, created_at DESC) จะเหมาะ หากการคิวรีส่วนใหญ่กรองด้วย customer ด้วย (customer_id, status, created_at) อาจดีกว่า แต่เฉพาะถ้านั่นคือรูปแบบการรันจริงใน production

Partial indexes สำหรับฟิลเตอร์ที่พบบ่อย

ถ้าทราฟฟิกส่วนใหญ่เข้าถึงชิ้นเล็ก ๆ ของแถว ดัชนีแบบ partial อาจถูกกว่าและเร็วกว่า ตัวอย่าง: ถ้าแอปอ่านเฉพาะเรคอร์ดที่ active = true การทำดัชนีเฉพาะ WHERE active = true จะทำให้ดัชนีเล็กกว่าและมีโอกาสอยู่ในหน่วยความจำมากขึ้น

เพื่อยืนยันว่าดัชนีช่วย ให้ทำการเช็กง่าย ๆ:

  • รัน EXPLAIN (หรือ EXPLAIN ANALYZE ในสภาพแวดล้อมที่ปลอดภัย) และมองหาการใช้ index scan ที่ตรงกับคิวรีของคุณ
  • เปรียบเทียบเวลาและแถวที่อ่านก่อนและหลังมีดัชนี
  • มองหา "Rows Removed by Filter" ที่ยังสูง มักหมายความว่าดัชนีไม่ตรงกับฟิลเตอร์ของคุณ

ลบดัชนีที่ไม่ได้ใช้ด้วยความระมัดระวัง ตรวจสอบสถิติการใช้งาน (เช่น ดัชนีถูกสแกนหรือไม่) ลบทีละตัวในช่วงเวลาความเสี่ยงต่ำ และมีแผนย้อนกลับ ดัชนีที่ไม่ได้ใช้ไม่ไร้พิษภัย พวกมันชะลอการ insert และ update ทุกครั้งที่เขียน

รูปแบบการแบ่งหน้าที่ไม่ช้าลงตามเวลา

การแบ่งหน้าเป็นจุดที่ API ที่เร็วเริ่มรู้สึกช้า ถึงแม้ว่าฐานข้อมูลจะยังแข็งแรง ให้มองการแบ่งหน้าเป็นปัญหาการออกแบบคิวรี ไม่ใช่รายละเอียด UI

ทำไม LIMIT/OFFSET ถึงช้าลง

LIMIT/OFFSET ดูเรียบง่าย แต่หน้าลึกมักมีค่าใช้จ่ายมากขึ้น Postgres ยังต้องเดินผ่าน (และมักจะจัดเรียง) แถวที่คุณข้าม หน้า 1 อาจแตะไม่กี่สิบแถว หน้า 500 อาจบังคับให้ดาต้าเบสสแกนและทิ้งหลักหมื่นแถวเพื่อคืน 20 ผลลัพธ์

มันยังสร้างผลลัพธ์ไม่เสถียรเมื่อแถวถูกเพิ่มหรือลบระหว่างคำขอ ผู้ใช้เห็นผลซ้ำหรือข้ามรายการได้ เพราะความหมายของ "แถวที่ 10,000" เปลี่ยนแปลงเมื่อโต๊ะเปลี่ยน

Keyset pagination (cursor) พร้อมตัวอย่าง "last seen"

Keyset pagination ถามคำถามต่างออกไป: "ให้ฉัน 20 แถวถัดไปหลังจากแถวสุดท้ายที่ฉันเห็น" นั่นทำให้ฐานข้อมูลทำงานบนสไลซ์เล็กและคงที่

เวอร์ชันง่ายใช้ id เพิ่มขึ้น:

SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;

API ของคุณคืน next_cursor เท่ากับ id สุดท้ายในหน้า คำขอต่อมาจะใช้ค่านั้นเป็น $1

สำหรับการเรียงตามเวลา ให้ใช้ลำดับที่เสถียรและแก้ปัญหา tie-breaker created_at อย่างเดียวไม่พอถ้าสองแถวมี timestamp เดียวกัน ให้ใช้ compound cursor:

WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

กฎไม่กี่ข้อป้องกันการซ้ำหรือหายไปของรายการ:

  • ใส่ tie-breaker ที่เป็นเอกลักษณ์ใน ORDER BY เสมอ (มักเป็น id)
  • รักษาลำดับการเรียงให้เหมือนกันข้ามคำขอ
  • ทำให้ cursor เป็นแบบ opaque สำหรับ client (เข้ารหัส created_at และ id รวมกัน)
  • ถ้าผู้ใช้สามารถกรอง ให้รวมฟิลเตอร์เดียวกันในทุกหน้า
  • ชอบฟิลด์เรียงที่ไม่เปลี่ยน (เช่น เวลา creation) มากกว่าฟิลด์ที่เปลี่ยนได้ (status, score)

การจัดรูปแบบ JSON: ตอบเร็วขึ้นด้วย payload ที่เล็กลง

ใส่การแบ่งหน้าแบบ cursor ตั้งแต่ต้น
ออกแบบการแบ่งหน้าแบบ keyset และ cursor ที่เสถียรตั้งแต่ต้น เพื่อให้หน้าเชิงลึกยังเร็ว

เหตุผลที่คาดไม่ถึงที่ทำให้ API รู้สึกช้าบ่อยครั้งไม่ใช่ฐานข้อมูล แต่เป็น response JSON ขนาดใหญ่ การสร้าง JSON ใหญ่ใช้เวลามากขึ้น ส่งช้าลง และ client ต้อง parse นานขึ้น ชัยชนะที่เร็วที่สุดมักคือการส่งน้อยลง

เริ่มที่ SELECT ถ้า endpoint ต้องการแค่ id, name, และ status ให้เลือกเฉพาะคอลัมน์เหล่านั้น SELECT * จะหนักขึ้นเงียบ ๆ เมื่อโต๊ะเพิ่ม text ยาว, JSON blob, และคอลัมน์ audit

อีกจุดชะงักบ่อยคือการสร้างตอบแบบ N+1: คุณดึงรายการ 50 รายการ แล้วรันคิวรี 50 ครั้งเพื่อแนบข้อมูลที่เกี่ยวข้อง มันอาจผ่านการทดสอบ แต่ล่มในทราฟฟิกจริง ชอบคิวรีเดียวที่คืนสิ่งที่ต้องการ (join อย่างระมัดระวัง) หรือสองคิวรีที่ batch ตาม IDs

วิธีลด payload โดยไม่ทำลาย client:

  • ใช้ include= flag (หรือ fields= mask) ให้ list responses เบา และ detail responses รับส่วนเสริม
  • จำกัดอาเรย์ซ้อน (เช่น ล่าสุด 10 เหตุการณ์) และให้ endpoint แยกต่างหากสำหรับประวัติเต็ม
  • อย่าคืน raw internal JSON columns ถ้า client ต้องการแค่บางกุญแจ
  • ใช้รหัสสั้นแทนการทำซ้ำ labels ยาว

สร้าง JSON ใน Postgres หรือใน Go?

ทั้งสองแบบอาจเร็ว เลือกตามสิ่งที่คุณต้องการเพิ่มประสิทธิภาพ

ฟังก์ชัน JSON ของ Postgres (jsonb_build_object, json_agg) มีประโยชน์เมื่อคุณต้องการรอบการร้องขอน้อยลงและรูปร่างที่คาดเดาได้จากคิวรีเดียว การปั้นใน Go เหมาะเมื่อคุณต้องการเงื่อนไขเชิงตรรกะ ตัวอย่างซ้ำโครงสร้าง struct หรือต้องการให้ SQL อ่านง่ายขึ้น ถ้า SQL ที่สร้าง JSON ยากอ่าน มันยากที่จะปรับจูน

กฎดี ๆ คือ: ให้ Postgres กรอง เรียง และรวมข้อมูล แล้วให้ Go จัดการการนำเสนอขั้นสุดท้าย

ถ้าคุณสร้าง API อย่างรวดเร็ว (เช่น กับ Koder.ai) การใส่ include flags ตั้งแต่ต้นช่วยหลีกเลี่ยง endpoints ที่บวมเมื่อเวลาผ่านไป และให้วิธีปลอดภัยในการเพิ่มฟิลด์โดยไม่ทำให้ทุก response หนักขึ้น

ขั้นตอนปรับจูนทีละขั้นก่อนมีผู้ใช้จริง

คุณไม่ต้องมีแล็บทดสอบใหญ่เพื่อจับปัญหาส่วนใหญ่ การผ่านแบบสั้น ๆ และทำซ้ำได้จะเผยปัญหาที่ทำให้เกิด outage เมื่อทราฟฟิกมา โดยเฉพาะเมื่อจุดเริ่มต้นคือโค้ดที่สร้างอัตโนมัติและคุณตั้งใจจะปล่อย

ก่อนเปลี่ยนอะไร จด baseline เล็ก ๆ:

  • p95 และ p99 latency สำหรับ endpoints ที่มีโหลดมากที่สุด
  • อัตราความผิดพลาดและ timeout
  • CPU ของฐานข้อมูลและจำนวนการเชื่อมต่อที่ active
  • 5 คิวรีที่ช้าที่สุดตามเวลารวม (ไม่ใช่แค่ทีเดียวที่แย่ที่สุด)

การผ่านการปรับจูน

เริ่มจากเล็ก ๆ เปลี่ยนทีละอย่าง แล้วทดสอบซ้ำหลังแต่ละการเปลี่ยน

  1. รัน load test 10–15 นาทีที่เหมือนการใช้งานจริง กระทบบริการที่ผู้ใช้แรกจะใช้ (login, list pages, search, create) แล้วจัดเรียง route ตาม p95 latency และเวลารวม

  2. ตรวจเช็กแรงกดดัน connection ก่อนปรับ SQL พูลใหญ่เกินไปทำให้ Postgres โอเวอร์โหลด พูลเล็กเกินไปทำให้รอนาน มองหาเวลาการรอรับ connection ที่เพิ่มขึ้นและจำนวนการเชื่อมต่อที่พุ่งช่วง burst ปรับพูลและ idle limits ก่อน แล้วรันโหลดซ้ำ

  3. EXPLAIN คิวรีที่ช้าที่สุดและแก้ปัญหา red flag ที่ใหญ่ที่สุด ผู้ร้ายส่วนใหญ่อาจเป็น full table scans บนตารางใหญ่, sort บนชุดผลขนาดใหญ่, และ joins ที่เพิ่มแถวอย่างรวดเร็ว เลือกคิวรีที่แย่ที่สุดแล้วทำให้มันเรียบง่าย

  4. เพิ่มหรือปรับดัชนีทีละอัน แล้วทดสอบใหม่ ดัชนีช่วยเมื่อมันตรงกับ WHERE และ ORDER BY อย่าเพิ่มทีละหลายตัว ถ้า endpoint ช้าเป็น "list orders by user_id ordered by created_at" ดัชนีประกอบ (user_id, created_at) อาจต่างระหว่างเร็วทันทีกับช้ามาก

  5. เบียดการตอบและการแบ่งหน้าให้เข้มขึ้น แล้วทดสอบอีกครั้ง ถ้า endpoint คืน 50 แถวพร้อม JSON ใหญ่ ฐานข้อมูล เครือข่าย และ client จะจ่ายทั้งหมด คืนเฉพาะฟิลด์ที่ UI ต้องการ และเลือก pagination ที่ไม่ช้าลงตามการเติบโตของตาราง

เก็บ change log ง่าย ๆ: เปลี่ยนอะไร ทำไม และอะไรที่เคลื่อนไหวใน p95 ถ้าการเปลี่ยนไม่มีผลดีให้ย้อนกลับและไปต่อ

ข้อผิดพลาดและกับดักที่พบบ่อย

ปล่อยพร้อมย้อนกลับได้
Deploy Go service ของคุณและเก็บการปรับจูนให้อุ่นใจด้วย snapshot และ rollback

ปัญหาประสิทธิภาพใน Go APIs บน Postgres ส่วนใหญ่เกิดจากการกระทำของตัวเอง ข้อดีคือการเช็กไม่กี่อย่างจับปัญหาได้ก่อนทราฟฟิกจริง

กับดักคลาสสิกคือการมองขนาดพูลเป็นปุ่มเพิ่มความเร็ว การตั้งเป็น "มากที่สุดเท่าที่จะทำได้" มักทำให้ช้าลง Postgres เสียเวลาไปกับการจัดการ session หน่วยความจำ และล็อก และแอปเริ่ม timeout เป็นลูกคลื่น พูลขนาดเล็กและคงที่ที่มีความขนานที่คาดเดาได้มักชนะ

ความผิดพลาดอีกอย่างคือ "ทำดัชนีทุกอย่าง" ดัชนีพิเศษอาจช่วยอ่าน แต่ก็ชะลอการเขียนและอาจเปลี่ยนแผนคิวรีในทางที่ไม่คาดคิด ถ้า API ของคุณ insert/update บ่อย ดัชนีเพิ่มงานทุกครั้งที่เขียน วัดผลก่อนและหลัง แล้วเช็กแผนคิวรีหลังเพิ่มดัชนี

หนี้จาก pagination แทรกซึมเงียบ ๆ OFFSET ดูโอเคในตอนแรก แต่ p95 เพิ่มขึ้นตามเวลาเพราะฐานข้อมูลต้องเดินผ่านแถวเพิ่มขึ้นเรื่อย ๆ

ขนาด payload JSON เป็นภาษีที่ซ่อนอยู่อีกอย่าง การบีบอัดช่วยแถบแบนด์วิดท์แต่ไม่ลดต้นทุนการสร้าง การจัดสรร และการแปลงวัตถุขนาดใหญ่ ตัดฟิลด์ ลดการซ้อนลึก และคืนเฉพาะสิ่งที่หน้าต้องการ

ถ้าคุณดูแค่ค่าเฉลี่ยของ response time คุณจะพลาดจุดที่ผู้ใช้จริงเจ็บปวด p95 (และบางครั้ง p99) คือที่ที่ pool saturation, lock waits, และแผนช้าจะปรากฏก่อน

เช็ครวดเร็วก่อนปล่อย:

  • ดูเวลา pool wait และจำนวนการเชื่อมต่อ Postgres ระหว่าง load test เล็ก ๆ
  • เปรียบเทียบค่าเฉลี่ยกับ p95 สำหรับ endpoint เดียวกัน
  • ตรวจสอบว่า pagination ไม่แย่ลงเมื่อตารางใหญ่ขึ้น 10x
  • ตรวจสอบขนาด response สำหรับ endpoints แบบ list (ขนาดเป็นเรื่องสำคัญ)
  • รัน EXPLAIN อีกครั้งหลังเพิ่มดัชนีหรือเปลี่ยนฟิลเตอร์

เช็คลิสต์ด่วนและขั้นตอนต่อไปก่อนปล่อย

ก่อนผู้ใช้จริงมาถึง คุณต้องมีหลักฐานว่า API ของคุณคงพฤติกรรมได้ภายใต้ความกดดัน เป้าหมายไม่ใช่ตัวเลขสมบูรณ์แบบ แต่จับปัญหาสำคัญที่จะทำให้เกิด timeout, spike, หรือฐานข้อมูลหยุดรับงาน

รันเช็กในสเตจิงที่ใกล้เคียง production (ขนาด DB ใกล้เคียง ดัชนีเหมือนกัน การตั้งพูลเหมือนกัน): วัด p95 ต่อ endpoint สำคัญภายใต้โหลด จับ 5 คิวรีที่ช้าที่สุดตามเวลารวม ดูเวลา pool wait, EXPLAIN (ANALYZE, BUFFERS) สำหรับคิวรีแย่ที่สุดเพื่อยืนยันว่ามันใช้ดัชนีตามที่คาด และตรวจสอบขนาด payload บน route ที่มีโหลดมากที่สุด

แล้วลองสลับ worst-case: ขอหน้าลึก, ใส่ฟิลเตอร์กว้างสุด, และลองกับ cold start (restart API แล้วเรียกคำขอเดิมเป็นครั้งแรก) ถ้าการแบ่งหน้าเชิงลึกช้าลงทุกหน้า ให้เปลี่ยนเป็น cursor-based pagination ก่อนปล่อย

จดค่าเริ่มต้นไว้เพื่อทีมจะตัดสินใจสอดคล้องต่อไป: ขีดจำกัดพูลและ timeout, กฎการแบ่งหน้า (ขนาดหน้า max, อนุญาต offset หรือไม่, รูปแบบ cursor), กฎคิวรี (select เฉพาะคอลัมน์ที่ต้องการ, หลีกเลี่ยง SELECT *, จำกัดฟิลเตอร์ที่แพง), และกฎการล็อก (threshold slow query, เก็บตัวอย่างนานแค่ไหน, วิธีติดป้าย endpoints)

ถ้าคุณสร้างและส่งออกบริการ Go + Postgres ด้วย Koder.ai การผ่านการวางแผนสั้น ๆ ก่อน deploy จะช่วยให้ฟิลเตอร์ การแบ่งหน้า และรูปแบบตอบกลับมีเจตนา เมื่อคุณเริ่มปรับดัชนีและรูปแบบคิวรี snapshots และ rollback จะทำให้ง่ายต่อการย้อนการ "แก้" ที่ช่วย endpoint หนึ่งแต่ทำร้ายอื่น ๆ ถ้าคุณต้องการที่เดียวสำหรับวน workflow นั้น Koder.ai บน koder.ai ออกแบบมาเพื่อสร้างและปรับบริการเหล่านี้ผ่านแชท แล้วส่งออกซอร์สเมื่อคุณพร้อม

คำถามที่พบบ่อย

How do I quickly tell if my Go API is slow because of Postgres or because of my code?

Start by separating DB wait time from app work time.

  • If the database is slow, the handler mostly waits on the query. Go CPU often stays normal while requests pile up “in flight.”
  • If the app is slow, queries return fast but time is spent building objects, doing extra per-row queries, marshaling large JSON, or logging. Go CPU and memory usually rise with response size.

Add simple timing around “wait for connection” and “query execution” to see which side dominates.

What metrics should I track first before tuning anything?

Use a small baseline you can repeat:

  • p95 latency per key endpoint (not average)
  • error rate (5xx, timeouts, cancellations)
  • DB time per request (time spent waiting on Postgres)

Pick a clear target like “p95 under 200 ms at 50 concurrent users, errors under 0.5%.” Then only change one thing at a time and re-test the same request mix.

Should I turn on Postgres slow query logging, and what threshold is practical?

Enable slow query logging with a low threshold in pre-launch testing (for example 100–200 ms) and log the full statement so you can copy it into a SQL client.

Keep it temporary:

  • It gets noisy fast in production.
  • It can add overhead if you log too much.

Once you’ve found the worst offenders, switch to sampling or raise the threshold.

What are good starting connection pool settings for a Go API on Postgres?

A practical default is a small multiple of CPU cores per API instance, often 5–20 max open connections, with similar max idle connections, and recycle connections every 30–60 minutes.

Two common failure modes:

  • Pool too small: requests wait to get a connection even though Postgres query time looks fine.
  • Pool too big: Postgres gets overloaded with many active sessions and latency becomes uneven.

Remember pools multiply across instances (20 connections × 10 instances = 200 connections).

How can I confirm the connection pool is the bottleneck (not SQL)?

Time DB calls in two parts:

  • Time waiting for a connection (pool wait)
  • Time executing the query (Postgres work)

If most time is pool wait, adjust pool sizing, timeouts, and instance counts. If most time is query execution, focus on EXPLAIN and indexes.

Also confirm you always close rows promptly so connections return to the pool.

What should I look for first in EXPLAIN when an endpoint is slow?

Run EXPLAIN (ANALYZE, BUFFERS) on the exact SQL your API sends and look for:

  • Seq Scan on a large table
  • Huge gap between estimated rows vs actual rows
  • Sort dominating time (often with ORDER BY)
  • “Rows Removed by Filter” being very high
  • Lots of shared read blocks in BUFFERS (heavy reads)

Fix the biggest red flag first; don’t tune everything at once.

How do I choose the right index for a list endpoint with filters and sorting?

Indexes should match what the endpoint actually does: filters + sort order.

Good default approach:

  • Build a composite index for your common WHERE + ORDER BY pattern.
  • Keep it small (2 columns often, 3 sometimes).
  • Put the most selective filter first, then the sort column.

Example: if you filter by user_id and sort by newest, an index like (user_id, created_at DESC) is often the difference between stable p95 and spikes.

When is a partial index worth it in Postgres?

Use a partial index when most traffic hits a predictable subset of rows.

Example pattern:

  • Many reads only for active = true
  • Few queries for inactive rows

A partial index like ... WHERE active = true stays smaller, is more likely to fit in memory, and reduces write overhead versus indexing everything.

Confirm with EXPLAIN that Postgres actually uses it for your high-traffic queries.

Why does LIMIT/OFFSET pagination get slower over time, and what should I use instead?

LIMIT/OFFSET gets slower on deep pages because Postgres still has to walk past (and often sort) the skipped rows. Page 500 can be dramatically more expensive than page 1.

Prefer keyset (cursor) pagination:

  • Use a stable sort plus a unique tie-breaker (often id).
  • Keep ORDER BY identical across requests.
  • Encode (created_at, id) or similar into a cursor.

This keeps each page cost roughly constant as tables grow.

My DB queries are fast, but responses are still slow—should I trim JSON payloads?

Usually yes for list endpoints. The fastest response is the one you don’t send.

Practical wins:

  • Select only needed columns (avoid SELECT *).
  • Add include= or fields= so clients opt into heavy fields.
  • Cap nested arrays (e.g., latest 10 items) and fetch the rest via a dedicated endpoint.
  • Avoid N+1 patterns (50 rows + 50 extra queries). Use joins or batch queries.

You’ll often reduce Go CPU, memory pressure, and tail latency just by shrinking payloads.

Related posts