2202 คำ
11 นาที
Database 101 EP.06 — Schema, Normalization, Keys: ออกแบบห้องสมุดที่ไม่รก
สารบัญ
ฉากเปิด: ระบบสั่งของออนไลน์ที่ออกแบบผิด Table / Row / Column — โครงสร้างพื้นฐาน Primary Key — บัตรประชาชนของแถว Natural key — ใช้ของที่มีอยู่แล้ว Surrogate key — สร้างเลขขึ้นมาเอง Foreign Key — ลิงก์ระหว่างตาราง Referential Integrity — กฎเหล็กของ FK MongoDB กับการไม่มี Foreign Key Normalization 1NF / 2NF / 3NF — refactor ตารางรกผ่านตัวอย่างเดียว เริ่มต้น: ตารางรกที่ยัดทุกอย่างไว้ที่เดียว 1NF — ทุก cell มีค่าเดียว ห้ามมีกลุ่มซ้ำ 2NF — ทุก non-key column ต้อง depend on PK ทั้งตัว 3NF — non-key column ห้าม depend on non-key column หลัง 3NF — เราได้อะไร Denormalization — เมื่อไหร่ควร “ผิดหลักโดยตั้งใจ” เมื่อไหร่ denormalize ตัวอย่างในวงการ Schema Migration — เปลี่ยน schema ยังไงไม่ให้ระบบล่ม Migration tool — เครื่องมือพื้นฐาน Backward-compatible change — หลักทอง Blue-green deployment สำหรับ schema breaking change มุมผู้บริหาร: สัญญาณว่าทีมไม่ normalize ปิดบท + ก้าวไป EP.07

Series: Database 101 — เข้าใจห้องสมุดของเมืองดิจิทัล (ภาษาคน)

Part 0 — WHY: ทำไมต้องมีห้องสมุด

Part 1 — ประวัติ: 4 ยุคของ Database

Part 2 — How: ภายใน Database ทำงานยังไง

  • EP.06 — Schema, Normalization, Keys ← คุณอยู่ตรงนี้
  • EP.07 — Index + Query Optimization (เร็วๆ นี้)
  • EP.08 — Transaction + Concurrency Control (เร็วๆ นี้)

Part 3 — เลือก Storage ตามขนาด

  • EP.09 — มุมเว็บส่วนตัว: Database-less Architecture (เร็วๆ นี้)
  • EP.10 — มุม Personal Data: SQLite + Local-first (เร็วๆ นี้)
  • EP.11 — มุม Startup: Serverless DB Stack (เร็วๆ นี้)
  • EP.12 — มุม Enterprise: Polyglot Persistence (เร็วๆ นี้)

Part 4 — Operations

  • EP.13 — DBA Role + Privileged Access (เร็วๆ นี้)
  • EP.14 — Database Security + Encryption (เร็วๆ นี้)

Part 5 — Future

  • EP.15 — Vector Database + AI Era (เร็วๆ นี้)
  • EP.16 — Wrap: Decision Tree + 5 Trends (เร็วๆ นี้)

EP.04 เราจบที่ภาพใหญ่ของวงการ — relational ครองโลก NoSQL ระเบิดออกมา 4 ตระกูล CAP บังคับให้ทุกคนเลือกข้าง นั่นคือมุมประวัติศาสตร์ครับ ทีนี้ Part 2 เราจะมุดเข้าไปข้างใน เปิดฝา database ดูว่ามันทำงานยังไง เริ่มจากรากของรากเลย — schema หรือโครงสร้างที่บอกว่า “ข้อมูลแต่ละชิ้นไปอยู่ตรงไหน”

ฟังดูแห้งใช่มั้ย เปลี่ยนมุมหน่อย — ถ้า schema ของบริษัทคุณออกแบบผิดวันแรก 5 ปีต่อมา dev ทุกคนจะใช้เวลาครึ่งวันต่อสัปดาห์ “ตามแก้ข้อมูลที่ไม่ตรงกัน” 10 ปีต่อมา ทุกครั้งที่จะเพิ่ม feature ใหม่ ต้อง refactor ฐาน ใช้เงินหลายล้าน มีคำพูดในวงการที่ว่า schema ที่ดี = save 10 ปีของ refactoring ผมเชื่อคำนี้เต็มใจ

EP.06 ผมอยากชวนมองว่าออกแบบ schema = ออกแบบชั้นวางของห้องสมุด ห้องสมุดที่ออกแบบดี บรรณารักษ์หาหนังสือเจอใน 10 วินาที หนังสือไม่ซ้ำกันเอง เปลี่ยนหัวข้อหมวดได้โดยไม่ต้องย้ายทั้งห้องสมุด ห้องสมุดที่ออกแบบรก เดินวน 30 นาทีหาหนังสือไม่เจอ เจอ 3 เล่มเดียวกันคนละชั้น ข้อมูลไม่ตรงกัน

แต่ก่อนจะไปทฤษฎี มาดูฉากความเสียหายจริงก่อนครับ

ฉากเปิด: ระบบสั่งของออนไลน์ที่ออกแบบผิด#

ลองนึก scenario ที่เกิดได้จริงในร้านค้าออนไลน์เล็กๆ ทั่วประเทศไทย

ร้านขายเสื้อผ้าออนไลน์ร้านหนึ่ง เปิดมา 3 ปี มีลูกค้าประจำ วันหนึ่ง คุณ “พิมพ์” ลูกค้าประจำ ย้ายบ้าน เธอเข้าไปแก้ที่อยู่ใน profile ในระบบเรียบร้อยแล้ว สั่งของล็อตใหม่ ของไปส่งที่บ้านใหม่ถูกต้อง ทุกอย่างดูปกติ

ผ่านไป 2 สัปดาห์ เธอได้รับเสื้อโค้ตที่สั่งเมื่อเดือนก่อน (ก่อนย้ายบ้าน) ของไปส่งที่ บ้านเก่า โทรไปร้าน ร้านยืนยันว่าระบบยังขึ้นที่อยู่เก่าใน order นั้น คุณพิมพ์งงมาก — “ก็แก้ที่อยู่ไปแล้วนี่!”

นี่ไม่ใช่ bug ของ application ครับ นี่คือ schema ของ database ออกแบบผิดตั้งแต่วันแรก

วิธีที่ผิด — ตอนสร้าง order ระบบ copy ที่อยู่จาก profile ของลูกค้ามาเก็บไว้ในตาราง orders ตรงๆ เป็น text 1 ช่อง order นั้นเลย “แช่แข็ง” ที่อยู่ ณ ตอนกดสั่งไว้ตลอดกาล แก้ที่อยู่ใน profile ทีหลัง order เก่าไม่รู้เรื่อง

วิธีที่ถูก — ตาราง orders เก็บแค่ link ไปที่ ID ของที่อยู่ในตาราง addresses แยก แก้ที่อยู่ ทุก order ที่ link มาเห็นที่อยู่ใหม่หมด

ฟังดูเหมือนเรื่องเทคนิคเล็กๆ แต่ผลกระทบมหาศาลครับ — ลูกค้าได้ของผิดที่อยู่ = เสียเงินค่า re-shipping + เสียเครดิตร้าน + ลูกค้าหายไปจริงๆ และ root cause ไม่ได้อยู่ที่ programmer ผิด อยู่ที่คนออกแบบ schema ไม่ได้คิดเรื่อง normalization

ทีนี้ก่อนจะไปเรื่อง normalize เรามารู้จัก building blocks ของ schema ก่อน

Table / Row / Column — โครงสร้างพื้นฐาน#

ถ้าใครเคยเปิด Excel หรือ Google Sheets ก็เข้าใจ database 70% แล้วครับ relational database ออกแบบมาจากแนวคิดเดียวกับ spreadsheet — ตารางที่มีแถวและคอลัมน์ ต่างกันที่ database จัดการข้อมูลล้านล้านแถวได้ + บังคับโครงสร้างได้ + ให้คนหลายคนใช้พร้อมกันได้ + มี ACID

ภาษาที่วงการใช้กันมีหลายคำสำหรับเรื่องเดียวกัน แล้วแต่ใครพูด

  • Table = ตาราง = sheet ใน Excel (เช่น ตาราง customers หรือตาราง orders)
  • Row = แถว = ข้อมูลของ “หนึ่งหน่วย” (ลูกค้า 1 คน, order 1 ใบ). นักวิชาการเรียกว่า tuple
  • Column = คอลัมน์ = “คุณสมบัติ” หนึ่งอย่างของหน่วย (ชื่อ, อีเมล, เบอร์โทร). นักวิชาการเรียกว่า attribute
  • Schema = โครงสร้างทั้งหมดของ database — มีตารางอะไรบ้าง, แต่ละตารางมีคอลัมน์อะไร, ชนิดข้อมูลแต่ละช่องคืออะไร, ลิงก์กันยังไง

ตัวอย่างตาราง customers ในร้านค้าออนไลน์

idnameemailphonecreated_at
1พิมพ์[email protected]081-xxx2024-03-15 10:30:00
2ก้อง[email protected]082-yyy2024-04-01 14:22:00

แต่ละช่อง (cell) มี ชนิดข้อมูล (data type) ที่บังคับไว้ตั้งแต่ตอนออกแบบ schema ตัวอย่างที่เจอบ่อย

  • integer (int) — เลขจำนวนเต็ม. เหมาะกับ ID, จำนวน, อายุ
  • varchar(n) — ข้อความยาวไม่เกิน n ตัวอักษร. เหมาะกับชื่อ, อีเมล, เบอร์โทร
  • text — ข้อความยาวเท่าไหร่ก็ได้. เหมาะกับเนื้อหาบทความ, comment ยาวๆ
  • date / datetime / timestamp — วันที่/เวลา. database จัดการ timezone, การคำนวณ “วันก่อนหน้า 30 วัน” ให้
  • boolean — true/false. เหมาะกับ flag เช่น “is_active”, “is_verified”
  • decimal(10,2) — เลขทศนิยมที่แม่นยำ. เหมาะกับเงิน — ห้ามใช้ float กับเงินเด็ดขาด (float ปัดเศษได้ — ผลคือ 0.1 + 0.2 = 0.30000000000000004 บัญชีพัง)
  • jsonb (ใน Postgres) — เก็บ JSON object ได้ทั้งก้อน + query ภายในได้

ทำไมต้องสนใจเรื่อง data type? มี 3 เหตุผล

  1. Storage — เก็บ “อายุ 25” เป็น int ใช้ 4 byte เก็บเป็น text “25” ใช้ 2 byte ของตัวเลข + overhead ของ text อีกหลาย byte ตารางที่มีล้านแถว ต่างกันเป็น GB เลย
  2. Performance — query “หา customer อายุ > 30” ใน column ที่เป็น int เร็วกว่า column ที่เป็น text หลายสิบเท่า database optimize เลขได้ดีกว่า optimize ข้อความ
  3. Validation — column ที่เป็น int รับเลขเท่านั้น ใส่ “abc” เข้าไปไม่ได้ column ที่เป็น email (ใน Postgres ที่มี extension) บังคับรูปแบบอีเมลที่ถูก schema เป็นแนวป้องกันด่านแรกของข้อมูลขยะ

ทีนี้พอมีตารางแล้ว คำถามต่อมาคือ ตารางหนึ่งมีลูกค้า 10 ล้านคน เราจะแยกแถวไหนเป็นใครได้ยังไง? คำตอบคือ ทุกแถวต้องมี บัตรประชาชน

Primary Key — บัตรประชาชนของแถว#

Primary Key (PK) คือคอลัมน์ที่ทำหน้าที่ “ระบุตัวตน” ของแต่ละแถวในตาราง กฎมีแค่ 2 ข้อ — ห้ามซ้ำ (unique) + ห้ามว่าง (not null)

เปรียบกับเลขบัตรประชาชนของคนไทยตรงตัวเลยครับ คน 70 ล้านคนในประเทศไทย ทุกคนต้องมีเลขบัตร ห้ามซ้ำกับใคร ห้ามว่าง ราชการอยากค้นหาประวัติของคุณ ใส่เลขบัตร 13 หลัก เจอแน่นอน

ในวงการ database primary key มี 2 รูปแบบหลักที่ต้องเลือกตอนออกแบบ

Natural key — ใช้ของที่มีอยู่แล้ว#

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

  • ตาราง customers ใช้ email เป็น PK (เพราะอีเมลแต่ละคนไม่ซ้ำกัน)
  • ตาราง employees ใช้ national_id เป็น PK
  • ตาราง products ใช้ sku_code เป็น PK

ข้อดี: ไม่ต้องสร้างคอลัมน์ใหม่ คนอ่าน data เห็นแล้วเข้าใจทันที

ข้อเสีย: สิ่งที่คิดว่า “ไม่มีวันเปลี่ยน” เปลี่ยนได้เสมอครับ

  • ลูกค้าเปลี่ยนอีเมล → ทุกที่ที่อ้าง email ต้องตามแก้พร้อมกัน
  • คนเปลี่ยนชื่อนามสกุล (แต่งงาน) → ถ้าใช้ full_name เป็น key ตายเลย
  • รัฐบาลเปลี่ยนรูปแบบเลขบัตร — เคยเจอ pattern ของวงการที่ระบบทั้งระบบล่ม
  • email = PII (Personally Identifiable Information) — ใช้เป็น key แล้วเอาไป log, share API, debug เสี่ยงเรื่อง privacy

Surrogate key — สร้างเลขขึ้นมาเอง#

แนวคิด: สร้างคอลัมน์ id แยกต่างหาก ให้ database สร้างเลขให้อัตโนมัติ (auto-increment 1, 2, 3, … หรือใช้ UUID ที่เป็นเลขสุ่ม 36 ตัวอักษร)

ข้อดี:

  • ไม่มีความหมายในชีวิตจริง — ไม่มีวันเปลี่ยน
  • ไม่ใช่ PII — เอาไปแชร์ใน log/API ได้ปลอดภัย
  • เป็นเลขสั้น — JOIN เร็ว, index ขนาดเล็ก
  • application logic ไม่ต้องสนใจว่า business rule เปลี่ยนหรือไม่

ข้อเสีย: ต้องเสียคอลัมน์เพิ่มมา 1 ช่อง (น้อยมาก) คนอ่าน data ดิบไม่เข้าใจว่า “id 4521 คือใคร” ต้อง JOIN ตารางอื่น

ในวงการปี 2026 surrogate key คือ default choice 95% ของระบบใหม่ใช้ surrogate key (id = bigint auto-increment หรือ UUID) แล้วเก็บ email/national_id/sku เป็นคอลัมน์ปกติที่บังคับ unique constraint ได้ทั้งความยืดหยุ่น + ความปลอดภัย + ความเร็ว

ทีนี้พอมีหลายตาราง คำถามต่อมาคือ ตาราง orders จะรู้ได้ยังไงว่า order ใบนี้เป็นของลูกค้าคนไหน? คำตอบคือ Foreign Key

Foreign Key — ลิงก์ระหว่างตาราง#

Foreign Key (FK) คือคอลัมน์ที่ “ชี้” ไปที่ Primary Key ของอีกตาราง มันคือการเชื่อมตารางสองตารางเข้าด้วยกัน

ตัวอย่าง — ตาราง orders ที่มีคอลัมน์ customer_id

idcustomer_idtotalcreated_at
10111,2002024-05-10 09:00:00
10218502024-05-12 14:30:00
10322,1002024-05-15 11:15:00

customer_id = 1 ใน orders ชี้ไปที่ id = 1 ในตาราง customers (คุณพิมพ์) ระบบรู้ว่า order 101 และ 102 เป็นของพิมพ์ order 103 เป็นของก้อง

ลองนึกถึงห้องสมุดอีกครั้ง บัตรยืมหนังสือไม่ได้เขียนชื่อสมาชิกเต็มๆ ลงไป มันเขียนแค่ “เลขสมาชิก 12345” ถ้าจะรู้ว่าเลข 12345 คือใคร ไปเปิดสมุดทะเบียนสมาชิก นี่คือ FK ในชีวิตจริง

Referential Integrity — กฎเหล็กของ FK#

พอมี FK แล้ว database จะบังคับกฎที่เรียกว่า referential integrity แปลว่า “ความถูกต้องของการอ้างอิง” กฎมี 2 ข้อ

  1. สร้าง order ที่ชี้ไปที่ customer_id ที่ไม่มี = ไม่ได้ — database จะปฏิเสธทันที ไม่มีลูกค้า id 9999 ใส่ลงไปไม่ได้
  2. ลบ customer ที่ยังมี order ค้างอยู่ = ไม่ได้ (โดย default) — เพราะลบไปแล้ว order จะ “ลอย” ไม่รู้ว่าเป็นของใคร

ข้อ 2 นี่แหละครับที่มี option ให้เลือก เรียกว่า ON DELETE behavior มี 2 แบบหลัก

  • ON DELETE RESTRICT (default) — ลบ customer ไม่ได้ ถ้ายังมี order ค้างอยู่ ต้องไปลบ order ก่อน หรือย้าย order ไป customer อื่น ปลอดภัยที่สุด
  • ON DELETE CASCADE — ลบ customer = ลบ order ทั้งหมดของ customer คนนั้นทิ้งอัตโนมัติ ใช้กับ relation ที่ “พ่วงกัน” จริงๆ เช่น ลบ user = ลบ session/token ทั้งหมด

สัญญาณเตือน: อย่าใช้ CASCADE กับข้อมูล financial/audit เด็ดขาด ลบลูกค้าครั้งเดียว order 5 ปีย้อนหลังหายหมด สรรพากรเรียกตรวจ เจ๊ง ใช้ “soft delete” แทน (เพิ่มคอลัมน์ deleted_at แล้ว filter ออกตอน query record ยังอยู่ใน database)

MongoDB กับการไม่มี Foreign Key#

ตรงนี้สำคัญสำหรับผู้บริหารที่ทีม IT จะเสนอใช้ NoSQL ครับ — document database อย่าง MongoDB ไม่มี foreign key แบบ native แปลว่า database ไม่บังคับ referential integrity ให้

ตัวอย่าง — MongoDB เก็บ order ที่อ้าง customer_id “abc123” ได้สบาย แม้ customer “abc123” ไม่มีอยู่จริง ลบ customer ทิ้งไป order ที่ชี้มาเป็นซากค้างไว้ query แล้วเจอ “user not found” ทุกครั้ง

วิธีแก้ของ MongoDB — enforce ใน application layer แปลว่า programmer ต้องเขียนโค้ดเช็คเองทุกที่ ก่อน insert order เช็คว่า customer มีอยู่ไหม ก่อนลบ customer เช็คว่ามี order ค้างไหม ปัญหาคือ programmer คนเดียวลืมเช็คที่จุดเดียว = ข้อมูลเสียหายโดยไม่มีใครรู้

นี่คือเหตุผลที่ EP.04 ผมเตือนเรื่องเลือก MongoDB ผิด ถ้าข้อมูลของคุณมี relation จริงๆ (order ↔ customer ↔ product) relational ที่บังคับ FK ให้อัตโนมัติ ปลอดภัยกว่า application layer ที่ programmer ต้องเขียนเช็คเองพันจุดเยอะ

ทีนี้พอเข้าใจ table + key + FK แล้ว เราจะมุดเข้าไปเรื่องที่หนักที่สุดของ EP.06 กัน — normalization

Normalization 1NF / 2NF / 3NF — refactor ตารางรกผ่านตัวอย่างเดียว#

normalization เป็นเรื่องที่ดูแห้ง แต่จริงๆ มันคือ ศิลปะของการแยกของให้ไม่ซ้ำ ผมจะเล่าผ่านตัวอย่างเดียวที่ค่อยๆ refactor ทีละขั้น แทนที่จะอธิบายทฤษฎี ผู้อ่านจะเห็นภาพมากกว่า

เริ่มต้น: ตารางรกที่ยัดทุกอย่างไว้ที่เดียว#

นี่คือตาราง orders ของร้านค้าออนไลน์ที่ออกแบบโดยมือใหม่ ยัดทุกอย่างไว้ใน 1 ตาราง

order_idcustomer_namecustomer_phonescustomer_addressproduct_nameproduct_priceorder_datetotal
1พิมพ์081-xxx, 082-yyy123 ถ.พหลโยธิน กรุงเทพเสื้อยืด3502024-05-01350
2พิมพ์081-xxx, 082-yyy123 ถ.พหลโยธิน กรุงเทพกางเกง8002024-05-03800
3พิมพ์081-xxx, 082-yyy123 ถ.พหลโยธิน กรุงเทพรองเท้า1,2002024-05-051,200
4ก้อง089-zzz456 ถ.สุขุมวิท กรุงเทพเสื้อยืด3502024-05-06350

ปัญหาที่เห็นด้วยตาเปล่า

  1. ชื่อ “พิมพ์” + ที่อยู่ + เบอร์โทรเดียวกัน ซ้ำ 3 ครั้ง (มี 3 order)
  2. ถ้าพิมพ์มี 100 order ชื่อกับที่อยู่ซ้ำ 100 รอบ เปลือง storage
  3. ถ้าพิมพ์ย้ายบ้าน ต้อง UPDATE 100 row พร้อมกัน ลืม update บางแถว = data inconsistency
  4. คอลัมน์ customer_phones มี 2 เบอร์อยู่ใน cell เดียว query “หาลูกค้าที่ใช้เบอร์ 082-yyy” ต้องไปค้นใน text ช้ามาก
  5. เปลี่ยนราคา “เสื้อยืด” จาก 350 เป็น 400 ถ้าอัพเดต product_price ทุกแถว → order เก่าราคาเปลี่ยนตาม → บัญชีย้อนหลังพัง

นี่คือตารางที่ยังไม่ผ่าน normalization สักขั้นเดียวครับ มาแก้ทีละขั้นกัน

1NF — ทุก cell มีค่าเดียว ห้ามมีกลุ่มซ้ำ#

กฎของ 1NF (First Normal Form): ทุก cell มีค่าเดียว ไม่มี repeating group

ตัวอย่างที่ผิด — customer_phones = "081-xxx, 082-yyy" cell เดียวมี 2 ค่า

วิธีแก้ — สร้างตารางแยก customer_phones ที่ link กลับมาที่ customer

ตาราง customers (หลัง 1NF)

idnameaddress
1พิมพ์123 ถ.พหลโยธิน กรุงเทพ
2ก้อง456 ถ.สุขุมวิท กรุงเทพ

ตาราง customer_phones (ใหม่)

idcustomer_idphone
11081-xxx
21082-yyy
32089-zzz

ตอนนี้ลูกค้า 1 คนมี phone กี่เบอร์ก็ได้ แค่เพิ่ม row ในตาราง customer_phones query “หาลูกค้าที่ใช้เบอร์ 082-yyy” เร็วเพราะค้นใน column ที่เป็น phone โดยตรง

2NF — ทุก non-key column ต้อง depend on PK ทั้งตัว#

กฎของ 2NF (Second Normal Form): ผ่าน 1NF แล้ว + ทุก column ที่ไม่ใช่ key ต้องขึ้นอยู่กับ primary key ทั้งตัว ไม่ใช่แค่บางส่วน กฎนี้มีผลเฉพาะตารางที่ใช้ composite key (PK ที่ประกอบด้วยหลายคอลัมน์)

ตัวอย่างที่ผิด — สมมติเรามีตาราง order_items ที่ใช้ composite key (order_id + product_id)

order_idproduct_idquantityproduct_nameproduct_price
11001เสื้อยืด350
12002กางเกง800
21003เสื้อยืด350

สังเกตว่า product_name และ product_price ขึ้นอยู่กับ product_id เท่านั้น ไม่เกี่ยวกับ order_id แปลว่า “เสื้อยืด” ราคา 350 ถูกเก็บซ้ำหลายแถว เพราะมีอยู่ใน order หลายใบ

วิธีแก้ — แยก products ออกเป็นตารางใหม่

ตาราง products (ใหม่)

idnameprice
100เสื้อยืด350
200กางเกง800

ตาราง order_items (หลัง 2NF)

order_idproduct_idquantity
11001
12002
21003

เปลี่ยนราคาเสื้อยืด — แก้ที่ตาราง products แถวเดียว ทุก order ที่อ้างถึงเห็นราคาใหม่หมด (หรือถ้าอยากเก็บราคา ณ วันสั่ง ก็ copy price มาเก็บใน order_items ตอน insert เป็น design choice)

3NF — non-key column ห้าม depend on non-key column#

กฎของ 3NF (Third Normal Form): ผ่าน 2NF แล้ว + ทุก non-key column ต้องขึ้นกับ PK โดยตรง ไม่ใช่ขึ้นกับ column อื่นที่ไม่ใช่ key

ตัวอย่างที่ผิด — ตาราง customers มีคอลัมน์ city + zipcode

idnameaddresscityzipcode
1พิมพ์123 ถ.พหลโยธินกรุงเทพ10400
2ก้อง456 ถ.สุขุมวิทกรุงเทพ10110
3นัท789 ถ.นิมมานเชียงใหม่50200

ปัญหาคือ zipcode ไม่ได้ depend on customer.id จริงๆ มัน depend on city (จริงๆ คือ depend on แขวง/ตำบล) ทุกคนในกรุงเทพใจกลางเมืองใช้รหัส 10400 หมด

วิธีแก้ — แยกตาราง cities ออกมา

ตาราง cities (ใหม่)

idnamezipcode
1กรุงเทพ-พหล10400
2กรุงเทพ-สุขุมวิท10110
3เชียงใหม่-นิมมาน50200

ตาราง customers (หลัง 3NF)

idnameaddresscity_id
1พิมพ์123 ถ.พหลโยธิน1
2ก้อง456 ถ.สุขุมวิท2
3นัท789 ถ.นิมมาน3

เปลี่ยน zipcode (ไปรษณีย์เปลี่ยนรหัสเขต) — แก้ที่ตาราง cities แถวเดียว ทุกคนในเขตเห็นรหัสใหม่อัตโนมัติ

หลัง 3NF — เราได้อะไร#

จากตาราง 1 ตารางรกๆ เรา refactor ออกเป็น 5 ตารางที่ link กันด้วย key

  • customers — ลูกค้า (id, name, address, city_id)
  • customer_phones — เบอร์โทร (customer_id, phone) รองรับหลายเบอร์
  • cities — เมือง/เขต (id, name, zipcode)
  • products — สินค้า (id, name, price)
  • orders + order_items — order และรายการสินค้าใน order

ผลที่ได้

  • เปลี่ยนข้อมูลที่จุดเดียว ทุกที่อัพเดตอัตโนมัติ
  • ไม่มีข้อมูลซ้ำ storage ลดลง 60-80% สำหรับ table ที่ใหญ่
  • ไม่มี data inconsistency แบบ “ที่อยู่เก่า ที่อยู่ใหม่” ที่เปิดบทไว้
  • query เฉพาะส่วนได้ตรง เช่น “นับจำนวนลูกค้าในกรุงเทพ” ไป query ตาราง customers + cities โดยไม่ต้องดึง order

นี่คือของขวัญที่ normalization ให้ครับ แต่ทุกของขวัญมีต้นทุน ทีนี้มาดูว่าเมื่อไหร่ “ผิดหลักโดยตั้งใจ” ถึงจะถูก

Denormalization — เมื่อไหร่ควร “ผิดหลักโดยตั้งใจ”#

ฟังเรื่อง normalization มา 3 ขั้นแล้ว ผู้บริหารบางคนอาจคิดว่า “งั้น normalize ให้สุดทุกตารางสิ ถูกหลักดี” ความจริงคือ ในระบบจริง บางที denormalization คือคำตอบที่ถูก

denormalization = จงใจเก็บข้อมูลซ้ำในหลายที่ เพื่อแลกความเร็วในการอ่าน ฟังเหมือนผิดหลัก แต่มันเป็น engineering trade-off

เมื่อไหร่ denormalize#

1. read-heavy workload — ระบบที่อ่านมากกว่าเขียน 100 เท่า เช่น dashboard ผู้บริหารที่โหลดวันละพันครั้ง ถ้า normalize เต็มที่ ทุก query ต้อง JOIN 5 ตาราง ใช้เวลา 3 วินาที ถ้า denormalize เก็บผลรวมไว้ในตารางเดียว query 50 ms

2. JOIN cost > storage cost — ใน relational, JOIN หลายตารางที่มีล้านแถวกินทรัพยากร CPU มหาศาล storage ราคาถูกลงเรื่อยๆ (Postgres hosting 100 GB ราคาเดือนละไม่กี่ร้อยบาท) บางครั้งเก็บข้อมูลซ้ำสองที่ + อ่านเร็วขึ้น 50 เท่า — คุ้ม

3. eventual consistency รับได้ — ระบบที่ “ข้อมูลช้าไป 5 นาทีไม่ตาย” เช่น analytics, recommendation, reporting ระบบ banking ที่ “ยอดเงินต้องตรง 100% ทุกวินาที” — ห้าม denormalize

ตัวอย่างในวงการ#

  • Data warehouse — ระบบเก็บข้อมูลเพื่อทำรายงาน ใช้ denormalized schema (star schema, snowflake schema) ที่ JOIN น้อย query เร็ว ตัวอย่าง Snowflake, BigQuery, Redshift
  • OLAP cube — pre-compute ผลลัพธ์ของ dimension ที่ใช้บ่อยไว้ล่วงหน้า ตอบ “ยอดขายแยกตามจังหวัด/เดือน/หมวด” ใน 100 ms แม้มีข้อมูล 10 ปี
  • Materialized view — view ที่ pre-compute ผลลัพธ์ + เก็บไว้เป็นตารางจริง ใช้บ่อยใน dashboard
  • Caching layer — Redis/Memcached เก็บผลลัพธ์ของ query ที่ใช้บ่อย เป็น denormalization รูปแบบหนึ่ง

Pattern ที่ใช้บ่อย — มี 2 schema คู่ขนาน normalized (OLTP) สำหรับ transaction และ denormalized (OLAP) สำหรับ analytics ETL pipeline copy ข้อมูลจาก OLTP ไป OLAP ทุกชั่วโมง/ทุกคืน ระบบ banking ที่ตัดเงินยังคงใช้ normalized schema 100% ส่วน dashboard ของผู้บริหารใช้ denormalized

เรื่อง read-heavy + analytics + polyglot persistence แบบนี้จะเจาะลึกใน EP.12 — มุม Enterprise: Polyglot Persistence ตอนนี้รู้แค่ว่า “denormalize เป็น tool ไม่ใช่ความผิดพลาด” ก็พอ

ทีนี้พอเข้าใจ schema กับ normalization แล้ว คำถามถัดมาที่หลีกเลี่ยงไม่ได้คือ ระบบ production ที่มีลูกค้าจริงใช้อยู่ เราจะเปลี่ยน schema ยังไงโดยไม่ให้ระบบล่ม?

Schema Migration — เปลี่ยน schema ยังไงไม่ให้ระบบล่ม#

ในวันแรกที่ออกแบบ schema เราคิดทุกอย่างไว้ครบ ผ่านไป 6 เดือน business ขออะไรใหม่ ต้องเพิ่มคอลัมน์ ต้องแยกตาราง ต้องเปลี่ยน data type ปัญหาคือระบบกำลังมีลูกค้าใช้งานอยู่ตลอด 24 ชั่วโมง แก้ schema ผิดวิธี ระบบล่มกลางดึก ลูกค้าโทรด่า

วงการมีทั้งเครื่องมือและ pattern สำหรับเรื่องนี้ เรียกว่า schema migration

Migration tool — เครื่องมือพื้นฐาน#

แทนที่จะแก้ schema ตรงๆ ใน production database เราเขียน “migration script” เป็นไฟล์ version-controlled (เก็บใน Git เหมือนโค้ด) แต่ละ script เป็นการเปลี่ยนแปลง schema 1 ครั้ง เช่น “เพิ่มคอลัมน์ phone ในตาราง customers”, “สร้างตาราง addresses ใหม่”, “เปลี่ยน data type ของ price จาก float เป็น decimal”

ตัวอย่างเครื่องมือที่ใช้กันในวงการ

  • Flyway (Java ecosystem) — เขียน migration เป็น SQL file ตรงๆ
  • Liquibase (Java) — เขียน migration เป็น XML/YAML ที่แปลงเป็น SQL หลายภาษา
  • Prisma Migrate (Node.js/TypeScript) — generate migration จาก schema definition
  • Alembic (Python, ใช้กับ SQLAlchemy) — เขียน migration เป็น Python script
  • Rails ActiveRecord migrations (Ruby) — ต้นแบบของวงการ

ทุกตัวทำเรื่องเดียวกัน track ว่า migration ไหนถูกรันไปแล้วใน database ไหน รัน migration ใหม่อัตโนมัติตอน deploy rollback ได้ถ้าผิดพลาด

Backward-compatible change — หลักทอง#

หัวใจของ migration ที่ไม่ทำระบบล่มคือ ทุกการเปลี่ยน schema ต้องเป็น backward-compatible กับโค้ดเวอร์ชันก่อนหน้า

ตัวอย่างการเปลี่ยน type ของ column — สมมติอยากเปลี่ยน customers.email จาก varchar(100) เป็น varchar(255) (รองรับอีเมลยาวขึ้น)

วิธีผิด — รัน ALTER TABLE customers ALTER COLUMN email TYPE varchar(255) กลางวันที่มีคนใช้ระบบ database lock table ทั้งตาราง 5 นาที ทุก request ระหว่างนั้น timeout ลูกค้าโทรเข้ามาด่า

วิธีถูก — แบ่งเป็น 4 ขั้น

  1. Add column ใหม่ — เพิ่ม email_v2 varchar(255) แบบ nullable โค้ดเก่ายังใช้ email เดิมได้ รวดเร็ว ไม่ lock
  2. Migrate data — รัน background job copy ข้อมูลจาก email ไป email_v2 ทีละก้อน 1,000 row ครั้ง ใช้เวลานาน แต่ไม่กระทบ production
  3. Switch over — deploy โค้ดใหม่ที่อ่าน/เขียน email_v2 โค้ดเก่ายัง deploy อยู่ระหว่างนี้ (rolling deploy)
  4. Drop old column — ผ่านไป 1 อาทิตย์ มั่นใจว่าทุกอย่างใช้ email_v2 แล้ว → ลบคอลัมน์ email เก่าทิ้ง

ฟังดูซับซ้อน แต่ผลคือ zero downtime ลูกค้าใช้ระบบไม่หยุดสักวินาทีตลอด migration

Blue-green deployment สำหรับ schema breaking change#

บางการเปลี่ยน schema ทำ backward-compatible ไม่ได้จริงๆ เช่น แยกตารางใหญ่เป็น 5 ตาราง วิธีจัดการคือ blue-green deployment

แนวคิด — มี database 2 ชุด

  • Blue = production ปัจจุบัน (schema เก่า)
  • Green = สำเนา (schema ใหม่)

flow

  1. setup green database พร้อม schema ใหม่
  2. setup data sync — ทุก write บน blue → sync ไป green ทันที (ผ่าน CDC: Change Data Capture)
  3. รอจนข้อมูล green ตามทัน blue
  4. switch traffic จาก blue ไป green ใน 1 วินาที
  5. blue กลายเป็น standby — ถ้า green มีปัญหา switch กลับได้

cost สูงกว่า (ต้องรัน 2 databases พร้อมกันช่วงเปลี่ยน) แต่ปลอดภัยที่สุด ใช้กับ migration ที่เสี่ยงสูง หากผิดพลาดเสียหายมหาศาล (เช่น ระบบ banking ระบบ healthcare)

มุมผู้บริหาร: สัญญาณว่าทีมไม่ normalize#

ผมเขียนส่วนนี้สำหรับเจ้าของกิจการ / ผู้บริหารโดยตรงครับ ถ้าคุณไม่ใช่เทคนิค ก็ดู signal เหล่านี้ ถ้ามี 2-3 ข้อตรงกัน แปลว่าฐานของระบบมีปัญหาเรื่อง schema

1. duplicate customer record หลายเท่า — เปิด CRM ค้นชื่อลูกค้าคนเดียวกัน เจอ 3-5 entry แต่ละ entry มีข้อมูลส่วนหนึ่งที่ไม่ตรงกัน (อีเมล เบอร์ ที่อยู่) ทีมขาย confused ว่าจะติดต่อ entry ไหนดี root cause: ไม่มี unique constraint บนคอลัมน์ที่ควร unique (อีเมล เบอร์โทร) + ไม่มี business logic ป้องกันสร้างซ้ำ

2. report ใช้เวลา > 10 วินาทีต่อหน้า — เปิด dashboard บริหารต้องรอจนคิดว่าเว็บค้าง root cause: schema ที่บังคับให้ทุก report ต้อง JOIN 8-10 ตาราง + ไม่มี materialized view + ไม่มี denormalized analytics layer

3. dev ใช้เวลา 50% ในการ “fix data inconsistency” — ทีม dev มีงานประจำคือ “ตามแก้ข้อมูลที่ไม่ตรงกัน” order มี customer_id ที่ไม่มีใน customers, product_id หาย, ราคาไม่ตรงระหว่างหน้า list กับหน้า detail root cause: ไม่มี foreign key constraint + application logic ที่ไม่ atomic

4. ตัวเลขใน report ไม่ตรงระหว่างทีม — ฝ่าย finance รายงานยอดขาย 10 ล้าน ฝ่าย operation รายงาน 11.5 ล้าน ฝ่าย marketing รายงาน 9.8 ล้าน ทุกฝ่ายอ้าง “ผมดึงจาก database จริง” root cause: ไม่มี single source of truth แต่ละทีมมีตารางของตัวเองที่ sync ข้อมูลจาก source กลางในเวลาต่างกัน

cost ที่จับต้องไม่ได้แต่หนัก — เมื่อตัวเลขใน report ไม่ตรง business team หยุดเชื่อ data การตัดสินใจกลับไปใช้ “feeling” ของผู้บริหารแทนข้อมูลจริง นี่คือ root cause ของบริษัทที่ “มี data warehouse แต่ไม่ data-driven”

ปิดบท + ก้าวไป EP.07#

EP.06 เราคุยกันตั้งแต่ — ทำไม schema ออกแบบผิดทำให้ลูกค้าได้ของผิดที่อยู่, building blocks ของ table/row/column, primary key vs surrogate key, foreign key + referential integrity, normalization 3 ขั้นที่ refactor ตารางรกให้สะอาด, denormalization ที่จงใจผิดหลักเพื่อความเร็ว, schema migration ที่ไม่ทำระบบล่ม, และ signal ที่ผู้บริหารดูได้ว่าทีมมีปัญหา schema

หัวใจของ EP.06 อยู่ตรงนี้ครับ — schema ที่ดี = save 10 ปีของ refactoring ลงทุน 1 สัปดาห์ออกแบบ schema ตอนเริ่มโครงการ save เงิน save เวลา save เครดิตของทีมไปทั้งสิบปี Normalization ไม่ใช่กฎตายตัว เป็น tool ที่ใช้แล้วต้องรู้เมื่อไหร่ “ผิดหลักโดยตั้งใจ”

แต่ schema สวยอย่างเดียวไม่พอครับ ลองนึกภาพห้องสมุดที่ออกแบบชั้นวางมาดี แต่ไม่มี catalog ค้นหา บรรณารักษ์เดินวนหา “หนังสือชื่อโน้น” 30 นาที database ก็เหมือนกันครับ ตารางมี 10 ล้านแถว query ที่ดูง่ายๆ “หา customer ที่ชื่อพิมพ์” ถ้าไม่มี index database ต้อง scan ทั้ง 10 ล้านแถว ใช้เวลา 30 วินาที มี index 5 milli-second

ทำไม query เดียวกันบน schema เดียวกัน เร็วต่างกันเป็น 1,000 เท่า? คำตอบอยู่ที่ index และทำไม index ที่ใช้ผิดถึงทำให้ระบบช้าลง — EP.07 ครับ

→ EP.07 — Index + Query Optimization (เร็วๆ นี้)