ออกแบบ Schema PostgreSQL ให้เป็นสัญญาที่ชัดเจนระหว่างแอปกับฐานข้อมูล
การออกแบบ schema ใน PostgreSQL ไม่ได้มีไว้เพื่อความสวยงามของชื่อตารางเท่านั้น แต่เป็นการกำหนดกติกาว่าข้อมูลแบบใดควรถูกยอมรับและแบบใดต้องถูกปฏิเสธตั้งแต่ต้นทาง เพื่อให้ระบบ production ปลอดภัยและดูแลง่ายขึ้นในระยะยาว

ออกแบบ Schema PostgreSQL ให้เป็นสัญญาที่ชัดเจนระหว่างแอปกับฐานข้อมูล
Schema design ใน PostgreSQL ไม่ใช่เพียงเรื่องของการตั้งชื่อตารางให้ดูดี แต่คือการกำหนด “สัญญา” ระหว่างแอปพลิเคชันกับฐานข้อมูลอย่างชัดเจน ว่าข้อมูลประเภทใดสามารถเข้ามาได้ และข้อมูลแบบใดต้องถูกปฏิเสธตั้งแต่วันแรก
สำหรับระบบ backend โดยเฉพาะฝั่ง Node.js ที่เริ่มใช้งานจริงใน production ปัญหาที่ทำให้ระบบพังบ่อยครั้งไม่ใช่ query ที่ซับซ้อนที่สุด แต่เป็น schema ที่เปิดช่องให้ข้อมูลผิดรูปแบบไหลเข้ามาแบบเงียบ ๆ เช่น user ไม่มี email, order ไม่มี owner, status สะกดไม่ตรงกัน หรือคอลัมน์เดียวเก็บข้อมูลหลายรูปแบบปะปนกันโดยไม่มีขอบเขตที่ชัดเจน
บทความนี้จะสรุปแนวคิดสำคัญในการออกแบบ schema PostgreSQL ให้แข็งแรง อ่านง่าย และพร้อมเติบโตไปกับระบบ
ตั้งชื่อตารางและคอลัมน์ให้สื่อความหมาย
จุดเริ่มต้นของ schema ที่ดีคือการตั้งชื่อให้สม่ำเสมอและเข้าใจได้ทันที
สำหรับการตั้งชื่อตาราง ควรเลือก convention ให้ชัดเจนตั้งแต่แรกว่าจะใช้แบบพหูพจน์หรือเอกพจน์ เช่น
- แบบพหูพจน์:
users,orders,order_items - แบบเอกพจน์:
user,order,order_item
ทั้งสองแบบใช้ได้เหมือนกัน แต่สิ่งสำคัญคือห้ามสลับไปมาภายในระบบเดียว เพราะจะทำให้ schema ดูไม่นิ่งและเพิ่มภาระในการจดจำโดยไม่จำเป็น
นอกจากนี้ ชื่อตารางควรอ้างอิงถึง business entity มากกว่าชื่อหน้าจอหรือ API route ตัวอย่างเช่น ถ้าข้อมูลนั้นคือคำสั่งซื้อจริง ๆ ก็ควรใช้ชื่อ orders แทน checkout_logs เว้นแต่มันจะเป็น log โดยธรรมชาติจริง ๆ เพราะเมื่อระบบเติบโตขึ้น flow ของ checkout อาจเปลี่ยนได้หลายแบบ แต่แนวคิดเรื่อง order ยังเป็นแกนหลักของธุรกิจอยู่เสมอ
ส่วนการตั้งชื่อคอลัมน์ ควรใช้ snake_case ให้สอดคล้องกับธรรมชาติของ PostgreSQL เช่น
created_atupdated_atuser_idbilling_address_id
ควรหลีกเลี่ยงชื่อกว้างหรือกำกวม เช่น data, info, value เพราะชื่อเหล่านี้ไม่ช่วยให้เข้าใจเจตนาของข้อมูล หากเปิด schema แล้วต้องหยุดคิดว่า “คอลัมน์นี้ไว้เก็บอะไร” แปลว่าชื่อนั้นยังไม่ดีพอ
ออกแบบ Primary Key ให้มั่นคง
ทุกตารางควรมี primary key อย่างชัดเจน สำหรับระบบเว็บทั่วไป ค่าที่นิยมใช้คือ id ซึ่งอาจเป็น bigint generated identity หรือ uuid
การเลือกใช้ขึ้นอยู่กับบริบทของระบบ
- ถ้าระบบมีหลาย service ต้องสร้าง id จากหลายจุด หรือมีการเปิดเผย id ผ่าน public URL จำนวนมาก การใช้
uuidจะช่วยลดโอกาสการเดาค่า id และลดการชนกันข้ามระบบ - ถ้าระบบยังเป็น service เดียว ใช้ database เดียว
bigintมักเรียบง่ายกว่า ทำงานเร็วกว่า และ debug ได้สะดวกกว่า
สิ่งที่ไม่ควรทำคือเอาค่าทางธุรกิจ เช่น email หรือ username มาเป็น primary key เพราะข้อมูลประเภทนี้เปลี่ยนแปลงได้เสมอ ผู้ใช้เปลี่ยน email ได้ บริษัทเปลี่ยนรหัสลูกค้าได้ แต่ primary key ควรเป็นค่าที่นิ่งที่สุดในระบบ
ทางเลือกที่ปลอดภัยกว่าคือใช้ id เป็น primary key แล้วกำหนด email หรือ username เป็น UNIQUE แทน
ใช้ Foreign Key เป็นกฎกลางของระบบ
Foreign key คือ guardrail สำคัญที่ไม่ควรถูกละเลย หาก orders.user_id อ้างถึง users.id ก็ควรประกาศ FOREIGN KEY ไว้ใน database ให้ชัดเจน
หลายทีมมักปล่อยให้แอปฝั่ง Node.js ตรวจสอบความสัมพันธ์เองทุกครั้ง แต่ในความเป็นจริง ข้อมูลไม่ได้เข้าฐานข้อมูลผ่าน API เพียงทางเดียวเสมอไป ยังมีทั้ง
- migration
- script
- admin tool
- import job
- background worker
เมื่อมีหลายช่องทางเข้าระบบ กฎเรื่องความสัมพันธ์ของข้อมูลควรถูกบังคับใช้ที่ database เพื่อให้ทุกทางอยู่ภายใต้กติกาเดียวกัน ลดปัญหาข้อมูลกำพร้าและข้อมูลอ้างอิงผิดตาราง
ใช้ NOT NULL, UNIQUE และ CHECK ให้สะท้อนความจริงของธุรกิจ
Constraint คือหัวใจของ schema ที่ดี เพราะมันทำหน้าที่บอกฐานข้อมูลว่า “ความจริงของระบบ” คืออะไร
NOT NULL
ใช้กับ field ที่ธุรกิจยืนยันว่าต้องมีแน่นอน เช่น
users.emailorders.user_idorder_items.quantity
หากปล่อยให้ nullable ไปก่อนเพราะคิดว่า “เดี๋ยวค่อยจัดการ” สุดท้ายโค้ดทุกจุดจะต้องตามล้างหนี้ด้วย COALESCE หรือเงื่อนไข if else จำนวนมาก
อย่างไรก็ตาม ไม่ควรใส่ NOT NULL ให้ทุกคอลัมน์แบบไม่คิด เพราะบางค่ามีเหตุผลชัดเจนที่ควรเป็น NULL เช่น
delivered_atอาจยังไม่มีค่าจนกว่าจะส่งสินค้าสำเร็จdeleted_atอาจเป็นNULLหากยังไม่ถูกลบในระบบที่ใช้ soft delete
ดังนั้น nullable ไม่ใช่สิ่งผิด ถ้ามันสื่อความหมายที่ชัดเจน
UNIQUE
ใช้ล็อกข้อมูลที่ห้ามซ้ำ เช่น
- slug
- payment transaction id
บางกรณีอาจต้องใช้ composite unique เช่น
user_id + providerสำหรับการล็อกอินผ่านผู้ให้บริการภายนอกorder_id + product_idหากใน order เดียวกันไม่ควรมีสินค้าเดิมซ้ำ
CHECK
CHECK constraint เป็นเครื่องมือที่มีประโยชน์มาก แต่หลายทีมมองข้าม ทั้งที่ช่วยกัน bug ได้ตั้งแต่ระดับฐานข้อมูล เช่น
quantity > 0price_cents >= 0status IN ('draft', 'paid', 'cancelled')
ข้อดีของ CHECK คือช่วยป้องกันสถานะหรือค่าที่หลุดจาก logic ฝั่ง backend ได้ก่อนที่ข้อมูลผิดจะถูกบันทึกลงจริง
เลือก Data Type ให้ตรงกับลักษณะข้อมูล
การเลือกชนิดข้อมูลไม่ควรใช้ text ครอบจักรวาลเพียงเพราะสะดวก เพราะจะทำให้ schema ขาดความชัดเจนและเปิดทางให้ข้อมูลผิดรูปแบบเข้ามาง่ายขึ้น
ตัวอย่างแนวคิดที่ควรพิจารณา ได้แก่
- ข้อมูลเกี่ยวกับเงิน ควรตัดสินใจให้ชัดว่าจะเก็บเป็นจำนวนเต็มหน่วยย่อย เช่น cents หรือใช้
numeric - เวลาที่เกี่ยวกับเหตุการณ์จริงควรใช้
timestamptzเพื่อจัดการ timezone อย่างเหมาะสม booleanควรใช้เมื่อมีเพียงสองสถานะจริง ๆ เท่านั้น
ถ้าธุรกิจมีสถานะมากกว่าสองแบบ เช่น pending, approved, rejected ก็ไม่ควรฝืนยุบเหลือแค่ boolean เพราะจะทำให้ความหมายของข้อมูลพร่าเลือน
Normalize เท่าที่จำเป็น อย่าซับซ้อนเกินเหตุ
Normalization คือการแยกข้อมูลออกเป็นโครงสร้างที่ลดความซ้ำซ้อน เช่น
- แยก
usersออกจากorders - แยก
productsออกจากorder_items
แนวทางนี้เหมาะมากเมื่อข้อมูลถูกใช้ซ้ำในหลายจุด และต้องการอัปเดตจากแหล่งเดียวเพื่อลดความผิดพลาด
แต่ในอีกด้านหนึ่ง การออกแบบเกินความจำเป็นก็สร้างภาระได้เช่นกัน ระบบขนาดเล็กไม่จำเป็นต้องแยกทุก enum เป็นตาราง หรือสร้างโครงสร้างยิบย่อยตั้งแต่วันแรกโดยยังไม่มี use case ชัดเจน
คำถามง่าย ๆ ที่ควรถามก่อนแยกข้อมูลคือ
- ข้อมูลนี้เปลี่ยนบ่อยหรือไม่
- ข้อมูลนี้ถูกใช้ร่วมกันหลายจุดหรือไม่
- ข้อมูลนี้จำเป็นต้อง query แยกต่างหากหรือไม่
หากคำตอบยังไม่ชัด การเริ่มจากโครงสร้างที่เรียบง่ายมักเป็นทางเลือกที่ดีกว่า
ออกแบบ Schema Change ให้ปลอดภัยใน Production
Schema ที่ดีไม่ใช่ schema ที่เดาอนาคตได้ถูกทั้งหมด แต่คือ schema ที่สามารถเปลี่ยนแปลงได้โดยไม่ทำให้ production เจ็บหนัก
แนวทางที่ปลอดภัยมีดังนี้
- เพิ่มคอลัมน์ใหม่แบบ nullable ก่อน
- deploy โค้ดที่เริ่มเขียนค่าให้คอลัมน์นั้น
- backfill ข้อมูลเดิมเป็น batch
- ตรวจสอบว่าข้อมูลครบถ้วน
- ค่อยเพิ่ม
NOT NULLหรือ constraint อื่นในภายหลัง
วิธีนี้ปลอดภัยกว่าการเพิ่ม required column แล้วบังคับ NOT NULL ทันที โดยเฉพาะในตารางขนาดใหญ่ที่มีข้อมูลจำนวนมากอยู่แล้ว
อีกจุดที่ควรระวังคือการ rename column แบบใจร้อน หากแอปหลาย instance ยังรันโค้ดเวอร์ชันเก่าอยู่ การเปลี่ยนชื่อคอลัมน์ทันทีอาจทำให้ production พังได้
วิธีที่ปลอดภัยกว่าคือ
- เพิ่มคอลัมน์ใหม่
- เขียนข้อมูลลงทั้งคอลัมน์เก่าและใหม่ชั่วคราว
- ย้ายฝั่งอ่านข้อมูลไปใช้คอลัมน์ใหม่
- backfill ข้อมูลให้ครบ
- ลบคอลัมน์เก่าทีหลัง
แนวคิดนี้ช่วยให้การเปลี่ยน schema เกิดขึ้นแบบค่อยเป็นค่อยไป และลดความเสี่ยงจาก deployment ที่ไม่พร้อมกันทุก instance
มุมมองที่ควรมีต่อ PostgreSQL ในงานจริง
PostgreSQL ที่ดีไม่ใช่แค่ฐานข้อมูลที่ query ได้เร็ว แต่คือฐานข้อมูลที่ช่วยทีมป้องกันข้อมูลผิดตั้งแต่ต้นทาง
ใน production ความเสียหายจากข้อมูลที่ผิดแบบเงียบ ๆ มักแพงกว่า error ที่ดังออกมาตั้งแต่แรกมาก เพราะเมื่อข้อมูลผิดถูกเขียนลงระบบไปแล้ว ต้นทุนในการแก้ไขจะกระจายไปทั้งในระดับโค้ด รายงาน การเชื่อมต่อบริการอื่น และการตัดสินใจทางธุรกิจ
ดังนั้น schema จึงไม่ใช่รายละเอียดทางเทคนิคเล็ก ๆ แต่เป็นหนึ่งในเครื่องมือสำคัญที่สุดในการรักษาคุณภาพของระบบระยะยาว
สรุป
การออกแบบ schema ใน PostgreSQL ที่ดีควรเริ่มจากชื่อ table และ column ที่อ่านแล้วเข้าใจทันที เลือก primary key ที่นิ่ง ใช้ foreign key เพื่อป้องกันข้อมูลกำพร้า และใช้ NOT NULL, UNIQUE, CHECK เพื่อสะท้อนความจริงของธุรกิจให้ชัดเจน
ขณะเดียวกันก็ควร normalize เท่าที่ช่วยลดปัญหา ไม่ใช่เพื่อเพิ่มความซับซ้อนโดยไม่จำเป็น และทุก schema change ควรถูกมองเหมือนการเปลี่ยนล้อรถขณะรถยังวิ่งอยู่ นั่นคือทำอย่างระมัดระวัง ค่อยเป็นค่อยไป และรองรับช่วง transition เสมอ
เมื่อออกแบบ schema ด้วยแนวคิดนี้ ฐานข้อมูลจะไม่ใช่แค่ที่เก็บข้อมูล แต่จะกลายเป็นแนวป้องกันสำคัญที่ช่วยให้ทีมไม่ใส่งานผิดเข้า production ได้ง่ายเกินไป