Query ที่ปรับแต่งมาอย่างดีบนฐานข้อมูลที่ดูแลไม่ดี ก็ยังเป็น query ที่ช้าอยู่ดี Connection ไม่พอ ตารางบวม disk โตไม่หยุด และ backup ที่ไม่เคยทดสอบ ก่อให้เกิด incident บน production มากกว่า index ที่ขาดไปตัวใดตัวหนึ่งเสียอีก บทความนี้รวบรวมนิสัยด้าน operation ที่ช่วยให้ relational database (ในตัวอย่างใช้ PostgreSQL) แข็งแรงไปอีกหลายปี
การจัดการ connection
ทำไม connection ถึงแพง
แต่ละ connection ของ PostgreSQL คือ process แยกของระบบปฏิบัติการที่มี memory ของตัวเอง Connection หลายร้อยตัวที่ส่วนใหญ่อยู่ในสถานะ idle จะกิน RAM เพิ่ม context switching และทำให้การจัดการ lock แพงขึ้น ฐานข้อมูลที่รับได้ 10,000 query ต่อวินาทีผ่าน 50 connection อาจเริ่มหอบเมื่อมี 2,000 connection ทำงานปริมาณเท่าเดิม
ทำ pool ในแอปพลิเคชัน
ทุก service ควรใช้ connection pool ที่กำหนดขีดจำกัดไว้ชัดเจน:
db.SetMaxOpenConns(20) // hard ceiling per instance
db.SetMaxIdleConns(10) // keep some warm
db.SetConnMaxLifetime(30 * time.Minute) // recycle to rebalance after failovers
db.SetConnMaxIdleTime(5 * time.Minute)ลองคูณดู: 30 pod × 20 connection = 600 connection ซึ่งมักมากเกินกว่าที่ฐานข้อมูลจะรับได้อย่างมีประสิทธิภาพ Autoscaling ยิ่งทำให้แย่ลง เพราะ pod ที่เพิ่มขึ้นหมายถึง connection ที่เพิ่มขึ้นในจังหวะที่โหลดพีกพอดี
กำหนดขนาด pool
จุดเริ่มต้นที่ดีคือจำไว้ว่าฐานข้อมูลทำงานได้ดีที่สุดเมื่อมี connection ที่ active เพียงไม่กี่ตัวต่อ CPU core การเพิ่ม concurrency เกินกว่านั้นจะเพิ่มการแย่งทรัพยากรโดยไม่ได้ throughput เพิ่ม ให้เริ่มจากค่าน้อย ๆ วัดเวลาที่รอ connection เทียบกับ latency ของ query และเพิ่มขนาดเฉพาะเมื่อ request ต้องต่อคิวรอ connection ในขณะที่ฐานข้อมูลยังมี capacity ว่างอยู่
ทำ pool ไว้หน้าฐานข้อมูล
เมื่อมีหลาย service หรือมี serverless function ให้เพิ่ม connection pooler อย่าง PgBouncer คั่นระหว่างแอปกับฐานข้อมูล ในโหมด transaction pooling client connection นับพันจะใช้ server connection ชุดเล็ก ๆ ร่วมกัน แต่ต้องรู้ด้วยว่าโหมด transaction ทำให้อะไรใช้ไม่ได้บ้าง ได้แก่ ฟีเจอร์ระดับ session อย่าง SET ที่ไม่มี LOCAL, advisory lock ที่ถือข้าม transaction และ (ขึ้นอยู่กับเวอร์ชันและการตั้งค่า driver) server-side prepared statement
Vacuum, bloat และ statistics
ทำไมต้องมี vacuum
PostgreSQL ใช้ MVCC โดย UPDATE จะเขียนเวอร์ชันใหม่ของแถว ส่วน DELETE แค่ทำเครื่องหมายไว้ เวอร์ชันเก่า ("dead tuple") จะยังคงอยู่จนกว่า VACUUM จะเรียกคืนพื้นที่ให้นำกลับมาใช้ใหม่ได้ ถ้าไม่มี vacuum ตารางและ index จะบวม และ query ต้องอ่าน page มากขึ้นเพื่อหาข้อมูลที่ยังใช้งานอยู่ชุดเดิม
Autovacuum ทำงานนี้ให้อัตโนมัติ แต่ค่าเริ่มต้นถูกตั้งไว้สำหรับตารางขนาดปานกลาง บนตาราง 200 ล้านแถว threshold เริ่มต้น (ประมาณ 20% ของแถวที่เปลี่ยนแปลง) หมายความว่าต้องรอให้มี dead row ถึง 40 ล้านแถวก่อนจะเริ่มเก็บกวาด
ให้ปรับแต่งเป็นรายตารางสำหรับตารางใหญ่ที่มีงานหนาแน่น:
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_limit = 2000
);สิ่งที่ต้องเฝ้าดู
SELECT relname, n_live_tup, n_dead_tup,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;- Dead tuple เพิ่มเร็วกว่าที่ถูกเก็บกวาด แปลว่า autovacuum ต้องตั้งค่าให้ดุขึ้นหรือเพิ่มจำนวน worker
- Transaction ที่รันนาน จะขวางการเก็บกวาด เพราะ vacuum ลบเวอร์ชันของแถวที่ transaction ที่ยังเปิดอยู่อาจต้องใช้ไม่ได้
BEGINที่ถูกลืมไว้ตัวเดียวใน session ที่ idle อาจทำให้ทั้งฐานข้อมูลบวมได้ ให้ตั้งค่าidle_in_transaction_session_timeout - Transaction ID wraparound คือขีดจำกัดตายตัวของ PostgreSQL ปกติ autovacuum จะป้องกันไว้ แต่ควร monitor
age(datfrozenxid)และตั้ง alert ไว้ตั้งแต่เนิ่น ๆ ก่อนจะเข้าใกล้ขีดจำกัด
Statistics ที่สดใหม่
พี่น้องของ vacuum อย่าง ANALYZE มีหน้าที่รีเฟรช statistics ที่ query planner ใช้ หลัง bulk load หรือการลบข้อมูลจำนวนมาก ให้รัน ANALYZE เองแทนที่จะรอ autovacuum
การทำ partition ตารางขนาดใหญ่
เมื่อตารางโตถึงหลายร้อยล้านแถว โดยเฉพาะข้อมูลแบบ time-series อย่าง event, log หรือธุรกรรม declarative partitioning จะแบ่งตารางเป็นตารางทางกายภาพย่อย ๆ ที่อยู่เบื้องหลังตารางเชิงตรรกะตัวเดียว:
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY,
occurred_at TIMESTAMPTZ NOT NULL,
tenant_id BIGINT NOT NULL,
payload JSONB
) PARTITION BY RANGE (occurred_at);
CREATE TABLE events_2026_09 PARTITION OF events
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');สิ่งที่ partitioning ให้คุณ
- Partition pruning Query ที่มี
WHERE occurred_at >= '2026-09-15'จะ scan เฉพาะ partition ที่เกี่ยวข้อง - การจัดการ retention ที่ถูก การลบข้อมูลเก่ากลายเป็น
DROP TABLE events_2025_09ซึ่งเสร็จทันที แทนที่จะเป็นDELETEก้อนใหญ่ที่สร้าง dead tuple หลายกิกะไบต์ - หน่วยการ maintenance ที่เล็กลง Vacuum และ reindex ทำงานทีละ partition
สิ่งที่ partitioning ไม่ได้ให้
- ไม่ได้ช่วยให้ query ที่ไม่กรองด้วย partition key เร็วขึ้น query เหล่านั้นจะ scan ทุก partition และอาจช้ากว่าเดิมด้วยซ้ำ
- Unique constraint ต้องรวม partition key ไว้ด้วย
- Partition ที่มากเกินไป (หลักพัน) จะเพิ่ม overhead ในการวาง plan ให้เลือกความละเอียด เช่น รายเดือนหรือรายสัปดาห์ ที่ทำให้แต่ละ partition ยังมีขนาดใหญ่พอสมควร
ให้สร้าง partition ล่วงหน้าแบบอัตโนมัติด้วยเครื่องมืออย่าง pg_partman หรือ scheduled job เพื่อไม่ให้การ insert ล้มเหลวเพราะไม่มี partition รองรับ
การวางแผน capacity
ติดตามแนวโน้มก่อนที่มันจะกลายเป็น incident:
- Disk: อัตราการโตต่อสัปดาห์ และจำนวนวันที่คาดว่าจะเต็ม ให้ alert ที่ 70–80% ไม่ใช่ 95%
- Memory: buffer cache hit ratio ของตารางที่ใช้บ่อย ถ้าอัตราส่วนลดลง มักแปลว่า working set โตเกินขนาด RAM แล้ว
- CPU และ I/O: การใช้งานต่อเนื่องในช่วงพีก ไม่ใช่ค่าเฉลี่ย
- Connections: จำนวน connection ที่ active สูงสุดเทียบกับขีดจำกัด
- Replication lag: replica ที่ให้บริการข้อมูลเก่าจะก่อให้เกิดบั๊กที่สังเกตยาก
ขยายฝั่งการอ่านด้วย read replica สำหรับงานรายงานและ endpoint ที่อ่านเป็นหลัก และส่งงานเขียนกับเส้นทางที่ต้องอ่านหลังเขียนทันทีไปยัง primary ก่อนจะทำ sharding ซึ่งเพิ่มความซับซ้อนอย่างมาก ให้ตรวจดูก่อนว่า partitioning การ archive ข้อมูลที่ไม่ค่อยถูกใช้ index ที่ดีขึ้น หรือ instance ที่ใหญ่ขึ้น จะแก้ปัญหาได้หรือไม่
เปลี่ยน schema โดยไม่มี downtime
การดำเนินการ ALTER TABLE บางอย่างจะถือ lock ที่ block traffic ทั้งหมด รูปแบบที่ปลอดภัยกว่ามีดังนี้
- การเพิ่มคอลัมน์ ที่ไม่มีค่า default หรือมีค่า default เป็นค่าคงที่ใน PostgreSQL เวอร์ชันใหม่ ๆ ทำได้เร็ว แต่การเพิ่มคอลัมน์ที่มี default แบบ volatile จะเขียนตารางใหม่ทั้งตาราง
- การเพิ่ม NOT NULL constraint: ให้เพิ่มแบบ
NOT VALIDก่อน แล้วค่อยVALIDATE CONSTRAINTแยกอีกครั้ง เพื่อให้การตรวจสอบทั้งหมดทำงานได้โดยไม่ต้องถือ lock ที่ block งานอื่น - การเปลี่ยนชื่อหรือลบคอลัมน์: ใช้แนวทาง expand and contract คือเพิ่มคอลัมน์ใหม่ เขียนลงทั้งสองคอลัมน์ backfill เป็น batch เปลี่ยนการอ่านไปที่คอลัมน์ใหม่ แล้วค่อยลบคอลัมน์เก่าใน release ถัดไป
- ตั้งค่า
lock_timeoutสำหรับ migration เพื่อให้ migration ที่ต้องรอหลัง query ยาว ๆ ล้มเหลวอย่างรวดเร็ว แทนที่จะทำให้ traffic ทั้งหมดต้องต่อคิวรออยู่ข้างหลัง
Backup ที่ไว้ใจได้
Backup ที่ไม่เคย restore เลยเป็นแค่ความหวัง ไม่ใช่ backup
ชั้นของการป้องกัน
- Continuous WAL archiving + base backup ทำให้ทำ point-in-time recovery (PITR) ได้ คุณสามารถ restore กลับไปยังช่วงเวลาใดก็ได้ เช่น หนึ่งวินาทีก่อนที่ใครบางคนจะรัน
DELETEโดยไม่มีWHERE - Logical dump (
pg_dump) พกพาไปใช้ที่อื่นได้ และสะดวกสำหรับตารางเดี่ยวหรือการ migrate แต่ช้าสำหรับฐานข้อมูลขนาดใหญ่และใช้แทน PITR ไม่ได้ - Replica ไม่ใช่ backup มันจะ replicate ความผิดพลาดของคุณไปทันที
กำหนดเป้าหมาย
- RPO (Recovery Point Objective): คุณยอมให้ข้อมูลหายได้มากแค่ไหน? WAL archiving ช่วยลดตัวเลขนี้ลงเหลือระดับวินาที
- RTO (Recovery Time Objective): คุณยอมให้ระบบล่มได้นานแค่ไหน? ตัวเลขนี้ขึ้นกับขนาดฐานข้อมูลและความเร็วในการ restore ดังนั้นต้องวัดจริง
ทดสอบ restore อย่างสม่ำเสมอ
ทำ scheduled job แบบอัตโนมัติที่:
- Restore backup ล่าสุดลงใน instance ที่แยกออกมา
- รัน sanity check เช่น จำนวนแถวและ timestamp ล่าสุด
- บันทึกระยะเวลาที่ใช้ restore
- แจ้ง alert เมื่อล้มเหลว
เก็บ backup ไว้ใน account หรือ region แยกต่างหากที่จำกัดสิทธิ์การลบ เพื่อไม่ให้ incident ที่เจาะเข้า production ได้ทำลาย backup ไปด้วย
Checklist ตรวจสุขภาพประจำเดือน
- ทบทวน query อันดับต้น ๆ ตามเวลารวมแล้ว
- ตรวจสอบ index ที่ไม่ได้ใช้และ index ที่ซ้ำกันแล้ว
- ตรวจแนวโน้ม bloat และ dead tuple แล้ว
- อายุของ transaction ID ที่เก่าที่สุดยังอยู่ในระยะปลอดภัย
- อัปเดตการคาดการณ์การโตของ disk แล้ว
- ทบทวนจำนวน connection สูงสุดเทียบกับขีดจำกัดแล้ว
- ทดสอบ restore ผ่าน พร้อมบันทึกระยะเวลาแล้ว
สรุป
ทำ connection pool และจำกัดจำนวน connection ดูแล vacuum และ statistics ให้พร้อมใช้งาน ทำ partition ตารางขนาดใหญ่ที่อิงตามเวลา เฝ้าดูแนวโน้ม capacity เปลี่ยน schema ทีละขั้น และทดสอบ restore อย่างสม่ำเสมอ ไม่มีอะไรในนี้ที่หวือหวา แต่ทั้งหมดนี้คือสิ่งที่ทำให้ฐานข้อมูลเชื่อถือได้ไปอีกหลายปี
