สารบัญ
Series: Database 101 — เข้าใจห้องสมุดของเมืองดิจิทัล (ภาษาคน)
Part 0 — WHY: ทำไมต้องมีห้องสมุด
Part 1 — ประวัติ: 4 ยุคของ Database
- EP.02 — ยุค 1960s-70s: Hierarchical → Relational Revolution
- EP.03 — ยุค 1980s-90s: ACID + Enterprise Backbone
- EP.04 — ยุค 2000s-2010s: NoSQL Movement + Big Data
- EP.05 — ยุค 2020s: Cloud-Native + Serverless 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.06 เราจัดของในห้องสมุดให้เข้าที่ (schema) EP.07 เราติดป้าย index ให้บรรณารักษ์เปิดเจอเร็ว ทั้งสองตอนเราคุยกันบนสมมติฐานเงียบๆ อันหนึ่ง — มีคนอ่าน/เขียนทีละคน ในโลกจริงมันไม่ใช่แบบนั้นครับ ลูกค้าที่กดเข้ามาพร้อมกันมีหลักหมื่น หลักแสน บางครั้งหลักล้าน database ตัวเดียวต้องตอบทุกคนพร้อมกันโดยไม่ให้ข้อมูลพังเป็นปะ
EP.08 คือเรื่องของ “หลังบ้าน” ที่ทำให้ทุกคนใช้ database พร้อมกันได้โดยไม่ทะเลาะกันเอง — Transaction กับ Concurrency Control ฟังดูเป็นเรื่องเทคนิค แต่จริงๆ มันคือเรื่องที่ผู้บริหารต้องเข้าใจมากที่สุดเรื่องหนึ่ง เพราะ bug ที่เกิดจากตรงนี้เป็น bug ที่ debug ยากที่สุดในวงการ มันไม่เจอใน dev มันไม่เจอตอน test มันโผล่มาวันที่ระบบดังที่สุด แล้วทำให้บริษัทเสียเงินจริง เสียลูกค้าจริง
มาเริ่มที่เคสที่ทำเอาผู้จัดการลานบินคนหนึ่งต้องนั่งกุมขมับเช้าวันถัดมา
ฉากเปิด: flash sale ของสายการบิน — ที่นั่งสุดท้าย ลูกค้า 100 คน
ลองนึก scenario สมมติแบบนี้ครับ — สายการบินเปิด flash sale ตั๋วเที่ยวบินไปเชียงใหม่ ราคาโปรโมชั่น เหลือที่นั่งสุดท้ายอีก 1 ที่ ระบบประกาศเวลาเริ่ม sale เป๊ะ 20:00 น. คนตั้งโทรศัพท์รอกดพร้อมกันทั่วประเทศ ถึงเวลา ลูกค้า 100 คนกดปุ่ม “จอง” พร้อมกันภายในเสี้ยววินาทีเดียว
ฝั่งบริษัทดูหน้าจอ ระบบขึ้น “จองสำเร็จ” ให้ลูกค้า 5 คน ทีม operations หายใจโล่ง — โอเค ระบบกัน race condition ได้ปกติ จองได้แค่ 5 คนแรกที่กดทัน ทีมไปนอนได้
เช้าวันถัดมา — call center โทรศัพท์ระเบิด ลูกค้า 5 คนถือ booking confirmation มาทุกคน หมายเลขที่นั่งบนหน้าตั๋ว — เลขเดียวกันทั้ง 5 ใบ 5 คนได้ที่นั่ง 12A ทั้งหมด ผู้จัดการลานบินเปิด log ของ database เห็นแต่ “transaction success” ทั้ง 5 รายการ ไม่มี error แม้แต่บรรทัดเดียว
คำถามคือ ระบบที่ “ทำงานปกติ” ในมุม database ทำไมถึงขายตั๋วใบเดียวกันได้ 5 ครั้ง? ใครผิดล่ะ?
คำตอบไม่ได้อยู่ที่โค้ดผิด ไม่ได้อยู่ที่ database พัง ไม่ได้อยู่ที่ server ล่ม คำตอบอยู่ที่การตั้งค่าตัวเดียว ที่ developer ส่วนใหญ่ไม่เคยแตะ และไม่รู้ด้วยซ้ำว่ามันมีอยู่ ก่อนจะไปเฉลย ผมขอชวนเดินผ่านพื้นฐานก่อน เริ่มจากของที่เราพอรู้กันแล้วใน EP.03
Transaction คืออะไร — เจาะลึกจาก EP.03
EP.03 เราคุยกันเรื่อง ACID สั้นๆ — กฎ 4 ข้อที่ทำให้ transaction ไม่หายระหว่างทาง
- A — Atomicity — ทั้ง transaction ทำสำเร็จทั้งก้อน หรือไม่ทำเลย ไม่มีครึ่งๆ กลางๆ
- C — Consistency — หลัง transaction จบ ข้อมูลต้องถูกต้องตามกฎทุกข้อ
- I — Isolation — transaction ที่รันพร้อมกัน ต้องไม่กวนกัน
- D — Durability — เขียนสำเร็จแล้ว ต่อให้ไฟดับก็ไม่หาย
EP.03 จบที่ตัว I พูดสั้นมาก แค่ “ต้องไม่กวนกัน” EP.08 ทั้งตอนนี้คือการเจาะตัว I ให้ลึก เพราะของจริงมัน “ไม่กวนกัน” ได้หลายระดับ และระดับที่เลือกผิด = เคส flash sale ที่เพิ่งเล่านั่นแหละ
Transaction ในภาษา SQL หน้าตาแบบนี้:
BEGIN; UPDATE seats SET status = 'booked' WHERE seat_id = '12A'; INSERT INTO bookings (user_id, seat_id) VALUES (101, '12A');COMMIT;BEGIN คือเปิด transaction ระหว่างนี้ database จะถือคำสั่งทั้งหมดไว้ในมือก่อน ยังไม่บันทึกลงจริง COMMIT คือ “เอาเลย บันทึกทั้งก้อน” ถ้าระหว่างทางมีอะไรพัง สั่ง ROLLBACK ทั้งก้อนหายเรียบ เหมือนไม่เคยทำอะไรเลย
มี 2 concept เสริมที่ควรรู้คร่าวๆ
- Savepoint — จุดบันทึกย่อยภายใน transaction ใหญ่ เหมือน save game ในเกม ถ้าเดินไปแล้วเจอบอสเก่ง โหลดกลับมาที่ savepoint ล่าสุดได้ ไม่ต้องเริ่มต้นทั้งเกม
- Nested Transaction — transaction ซ้อน transaction database ส่วนใหญ่จัดการด้วย savepoint อยู่ดี แต่ syntax ดูเป็นชั้น
ฟังเหมือนสะอาดสะอ้านนะครับ แต่ปัญหาเริ่มตอนมีคน “เปิด transaction พร้อมกัน” ถ้า user 100 คน BEGIN พร้อมกัน แล้วทุกคนจะ UPDATE ที่นั่ง 12A พร้อมกัน ใครได้ ใครไม่ได้? database ตัดสินใจยังไง?
คำตอบมีกลไกพื้นฐานชื่อว่า Locking
Locking — กลไกพื้นฐานของ Concurrency Control
วิธีคิดของ database ตรงนี้คล้ายกับห้องสมุดมากครับ หนังสือบางเล่มหลายคนเปิดอ่านพร้อมกันได้ แค่ดูตัวอักษรไม่ได้ทำลายของ แต่ถ้าใครจะ “เขียนเพิ่ม” ลงในหนังสือเล่มนั้น คนอื่นต้องรอ ไม่งั้นข้อความจะปนกัน
database จัดการเรื่องนี้ผ่าน Lock 2 ประเภทหลัก
Shared Lock (read lock) — หลายคนอ่านพร้อมกันได้
Analogy: หนังสือเล่มหนึ่งวางอยู่บนโต๊ะกลางห้องสมุด ใครเดินผ่านก็เปิดอ่านได้พร้อมกัน 5 คน 10 คน ไม่กระทบกัน นี่คือ shared lock — “ผมขอจับหนังสือเล่มนี้ไว้นะ ตอนผมอ่าน ใครจะอ่านด้วยก็ได้ แต่ห้ามใครเอามาแก้”
ใน SQL หน้าตา:
SELECT * FROM seats WHERE seat_id = '12A' FOR SHARE;หลายคนถือ FOR SHARE lock บน row เดียวกันได้พร้อมกัน ทุกคนอ่าน “12A ว่างอยู่” ได้พร้อมกัน แต่ถ้าใครจะ เขียน ต้องรอให้ทุกคนปล่อย shared lock ก่อน
Exclusive Lock (write lock) — เขียนคนเดียว อ่านก็ไม่ได้
Analogy: บรรณารักษ์ยกหนังสือเข้าห้องส่วนตัว ปิดประตู เขียนแก้เนื้อหา ระหว่างนี้ใครจะเปิดอ่าน ทำไม่ได้ ใครจะแก้ด้วย ทำไม่ได้ ต้องรอบรรณารักษ์เสร็จ เปิดประตูออกมาก่อน
ใน SQL:
SELECT * FROM seats WHERE seat_id = '12A' FOR UPDATE;FOR UPDATE บอก database ว่า “ผมจะแก้ row นี้ block ใครก็ตามที่จะมาอ่านหรือแก้จนกว่าผม COMMIT” ครั้งละ transaction เดียวเท่านั้นที่ถือ exclusive lock ได้
Lock granularity — ล็อกที่ระดับไหน
จุดสำคัญที่ผู้บริหารควรเข้าใจคือ ล็อกที่ระดับไหน ก็เปลี่ยนพฤติกรรมของระบบทั้งหมด
- Row-level lock — ล็อกแค่ “แถวนี้แถวเดียว” user คนอื่นยังเล่นกับแถวอื่นในตารางเดียวกันได้ concurrency สูง แต่ overhead เยอะ เพราะ database ต้องจำว่าใครล็อกอะไรไว้บ้างเป็นพันเป็นหมื่น
- Page-level lock — ล็อก “หน้า” (database เก็บข้อมูลเป็นหน้า หน้าหนึ่งมี row หลายแถว) กลางๆ ระหว่าง row กับ table
- Table-level lock — ล็อกทั้งตาราง simple แต่ block ทุกคน ใครจะอ่านจะเขียนต้องรอหมด
Postgres + InnoDB (engine ของ MySQL) ใช้ row-level lock เป็นค่าตั้งต้น เหมาะกับเว็บที่มี user เยอะ SQLite ใช้ database-level lock ทั้ง file เหมาะกับเว็บคนน้อย
มุมผู้บริหาร: ตอนทีม IT บอกว่า “ระบบเราช้ามากตอน peak” หนึ่งในคำถามแรกที่ควรถามคือ “เราล็อกที่ระดับไหน” ถ้าตอบว่า table-level แปลว่า user 1000 คนต้องรอกันเข้าคิว ถ้าตอบว่า row-level แล้วยังช้า ต้องไปดูเรื่อง isolation level (เดี๋ยวถึง)
Deadlock — 2 transaction ติดกัน ไม่มีใครยอมปล่อย
ถ้ามีแค่ “หนึ่งล็อก หนึ่งคน” ทุกอย่างเรียบร้อย ปัญหาเริ่มเมื่อมี 2 transaction ที่ต้องการ 2 ทรัพยากร แต่ลำดับสลับกัน
ลองนึกภาพแบบนี้ครับ — Transaction T1 ล็อก row A ไว้แล้วกำลังจะขอ row B ในเวลาเดียวกัน Transaction T2 ล็อก row B ไว้แล้วกำลังจะขอ row A T1 รอให้ T2 ปล่อย B T2 รอให้ T1 ปล่อย A ทั้งคู่รอกันชั่วนิรันดร์ ไม่มีใครยอมปล่อยก่อน
Analogy แบบบ้านๆ: 2 คนเดินสวนกันในซอยแคบ คนนึงคิดว่า “เดี๋ยวเขาหลีกฉัน” อีกคนคิดเหมือนกัน ไม่มีใครหลีก ติดตรงนั้นทั้งวัน
นี่คือ Deadlock ครับ database ตัวจริงไม่ปล่อยให้ระบบค้างอยู่อย่างนั้นครับ มี mechanism ชื่อ deadlock detection ที่ scan ดูว่ามีใครรอกันเป็นวงกลมไหม พอเจอ database จะ kill 1 transaction ทิ้ง (ส่วนใหญ่เลือกตัวที่ทำงานน้อยที่สุด เสียหายน้อยที่สุด) แล้วส่ง error กลับให้ application
จากนั้นเป็นหน้าที่ของ application ต้อง retry transaction ที่ถูก kill ถ้านักพัฒนาไม่ได้เขียน retry logic ไว้ user คนนั้นจะเห็นข้อความ error แปลกๆ บนหน้าจอ
วิธีป้องกัน deadlock ในเชิงออกแบบ: ทุก transaction ควรขอ lock ตาม order เดียวกันเสมอ เช่น “ขอตามลำดับ alphabetical ของชื่อตาราง” หรือ “ขอตาม ID จากน้อยไปมาก” ถ้าทุกคนทำตามกฎเดียวกัน วงกลมรอกันจะไม่เกิดขึ้นเลย
มุมผู้บริหาร: ถ้า log ของระบบเริ่มขึ้น “deadlock detected” บ่อยๆ ที่ peak hour นั่นคือสัญญาณว่าทีมยังออกแบบลำดับ lock ไม่ดี ไม่ใช่ “database พัง” เป็น “code ขอของผิดลำดับ”
Isolation Levels 4 ระดับ — ตรงนี้แหละที่เคส flash sale พัง
มาถึงหัวใจของตอนแล้วครับ Locking อย่างเดียวไม่พอ เพราะ “เข้มแค่ไหน” ก็ส่งผลต่อความเร็วและความถูกต้องของระบบ database เลยให้คุณเลือก Isolation Level กำหนดว่า transaction ของคุณจะ “เข้มงวด” แค่ไหนตอนต้องอยู่ร่วมกับ transaction อื่น
ก่อนเข้า 4 level ต้องรู้จัก 3 ปัญหา ที่เกิดถ้า isolation หลวมเกินไปก่อน
3 ปัญหาคลาสสิกของ concurrency
1. Dirty Read — อ่านข้อมูลที่อีก transaction “เพิ่ง update แต่ยังไม่ commit”
ลองนึกภาพ — T1 update ยอดเงินบัญชีเป็น 1,000,000 บาท (ยังไม่ commit) T2 อ่านยอดเงิน เห็น 1,000,000 บาท คำนวณดอกเบี้ยตามนั้น แล้ว T1 rollback — ยอดเงินกลับเป็น 100 T2 ที่คำนวณดอกเบี้ยจาก “1,000,000 บาทที่ไม่เคยมีอยู่จริง” คำนวณผิดไปแล้ว
2. Non-repeatable Read — อ่าน row เดียวกัน 2 ครั้งใน transaction เดียวกัน ได้ค่าต่างกัน
T1 เริ่ม transaction อ่านราคาสินค้า = 100 บาท ระหว่าง T1 ทำงานอยู่ T2 update ราคาเป็น 150 บาท แล้ว commit T1 อ่านราคาอีกครั้ง เห็น 150 ในมุมของ T1 ราคาเปลี่ยนกลางคัน ทั้งที่ T1 ยังไม่จบ transaction ของตัวเอง
3. Phantom Read — query เดียวกัน 2 ครั้ง ได้ จำนวน row ต่างกัน
T1 query “นับ order ทั้งหมดของลูกค้า A” ได้ 5 รายการ ระหว่าง T1 ทำงาน T2 insert order ใหม่ของลูกค้า A เพิ่ม commit T1 นับซ้ำ ได้ 6 รายการ มี row “ผี” โผล่มา
ทั้ง 3 ปัญหานี้ฟังดูเล็กน้อย แต่ในระบบที่เกี่ยวกับเงิน สต๊อก ที่นั่ง เป็น bug ระดับฉิบหายเลย database เลยให้เราเลือก isolation level ที่กันแต่ละแบบได้
4 Isolation Levels
มาตรฐาน SQL กำหนดไว้ 4 ระดับ เรียงจากหลวมที่สุดไปเข้มที่สุด
Level 1: Read Uncommitted — เห็น dirty data ได้ ห้ามใช้ใน production แทบทุกเคส
- ไม่กันอะไรเลย เร็วที่สุด อันตรายที่สุด
- ใช้เฉพาะงาน analytics ที่ไม่สนความถูกต้อง 100% (เช่น dashboard ดูแนวโน้มคร่าวๆ)
- Postgres ไม่รองรับด้วยซ้ำ กำหนดค่านี้ไป Postgres treat เป็น Read Committed อัตโนมัติ
Level 2: Read Committed — กัน dirty read
- ค่า default ของ Postgres และ MySQL InnoDB
- อ่านได้เฉพาะข้อมูลที่ commit แล้ว
- ยังเจอ non-repeatable read ได้ และ phantom read ได้
- เคส flash sale ของสายการบินที่เปิดเรื่อง เกิดที่ระดับนี้นี่แหละ
Level 3: Repeatable Read
- กัน dirty read + non-repeatable read
- row ที่เคยอ่านในรอบแรก จะเห็นค่าเดิมตลอด transaction
- ยังเจอ phantom read ได้ (row ใหม่ insert เข้ามายังโผล่ได้)
- MySQL InnoDB เวอร์ชันใหม่กัน phantom read เพิ่มได้ด้วย gap lock เป็น implementation detail
Level 4: Serializable
- กันทุกอย่าง เหมือน transaction รันทีละตัวต่อแถว
- ช้าที่สุด deadlock เสี่ยงสุด
- ใช้เฉพาะงานที่ความถูกต้อง > ความเร็ว เช่น ระบบโอนเงิน ระบบตัดสต๊อกที่นั่ง
ตารางเปรียบเทียบ — ระดับไหนกันอะไร
| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | เกิดได้ | เกิดได้ | เกิดได้ |
| Read Committed | กันได้ | เกิดได้ | เกิดได้ |
| Repeatable Read | กันได้ | กันได้ | เกิดได้* |
| Serializable | กันได้ | กันได้ | กันได้ |
*MySQL InnoDB กัน phantom ได้ในบางกรณีผ่าน gap lock implementation ของแต่ละ database ต่างกัน
กลับไปที่เคส flash sale: ระบบจองตั๋วใช้ Postgres (default = Read Committed) transaction หน้าตาประมาณนี้
BEGIN; -- T1 อ่านจำนวนที่นั่งคงเหลือ SELECT seats_left FROM flights WHERE flight_id = 'TG101'; -- ได้ค่า 1 -- application ตัดสินใจ "OK ยังเหลือ จองได้" UPDATE flights SET seats_left = seats_left - 1 WHERE flight_id = 'TG101'; INSERT INTO bookings ...;COMMIT;ปัญหาคือ 5 transaction รันพร้อมกัน ทุก transaction อ่าน seats_left = 1 ในเวลาเดียวกัน (เพราะยังไม่มีใคร commit) ทุก transaction คิดว่า “ยังเหลือ จองได้” ทุก transaction UPDATE แล้ว INSERT ทุก transaction commit สำเร็จ ที่นั่ง 1 ที่ ขายไป 5 ใบ
นี่ไม่ใช่ bug ของ database นะครับ เป็นพฤติกรรม “ตามสเปก” ของ Read Committed database ทำตามที่ระบบสั่งเป๊ะๆ ทีม dev ไม่รู้ว่าต้องสั่งให้เข้มกว่านี้
MVCC — วิธีของ Postgres ที่ไม่ใช้ lock เป็นหลัก
ตรงนี้น่าสนใจครับ Postgres ทำเรื่องที่ revolutionary อยู่อันหนึ่ง แทนที่จะเอา lock มาบังคับให้ transaction รอกัน Postgres เก็บ หลายเวอร์ชันของข้อมูลเดียวกันพร้อมกัน กลไกนี้เรียกว่า MVCC — Multi-Version Concurrency Control
วิธีคิด: row หนึ่งใน Postgres อาจมีอยู่ 5 version ในเวลาเดียวกัน แต่ละ transaction “เห็น” version ที่ commit ก่อน transaction ตัวเองเริ่ม
Analogy: ลองนึกถึงห้องสมุดที่ทุกครั้งบรรณารักษ์แก้ข้อมูลในหนังสือ เขาไม่ได้ลบของเก่าทับ เขาทำสำเนาเล่มใหม่วางทับ พร้อมจดวันที่กำกับไว้ ใครเดินเข้ามาตอน 09:00 เห็นเล่มเวอร์ชัน 09:00 ใครเข้ามาตอน 10:00 เห็นเวอร์ชัน 10:00 ทั้งสองคนเห็น “ความจริง” ของตัวเอง โดยไม่ต้องรอกัน
ผลที่ตามมาคือ reader ไม่ block writer และ writer ไม่ block reader ใน database ที่ใช้ lock เป็นหลัก ถ้าใครเขียน คนอ่านต้องรอ ใน Postgres เขียนไป อ่านไปพร้อมกันได้ เพราะคนอ่านอ่านเวอร์ชันเก่าที่ commit แล้ว ส่วนคนเขียนสร้างเวอร์ชันใหม่ขึ้นมา
นี่เป็นเหตุผลใหญ่หนึ่งที่ทำให้ Postgres ครองตลาดในยุคใหม่ workload สมัยนี้คือ “อ่านเยอะมาก เขียนพอประมาณ” MVCC ทำให้ Postgres รับโหลดอ่านได้แทบไม่จำกัดโดยไม่กระทบคนเขียน
ข้อแลกเปลี่ยนคือ Postgres ต้องมี process เบื้องหลังชื่อ VACUUM มาเก็บกวาด version เก่าๆ ที่ไม่มีใครใช้แล้วเป็นระยะ ถ้า VACUUM ไม่ทำงานดี database จะอ้วนเรื่อยๆ จน disk เต็ม (เป็นเหตุผลที่ DBA ของ Postgres ต้องคอย monitor ตรงนี้ตลอด เดี๋ยวคุยใน EP.13)
มุมผู้บริหาร: ถ้าทีมเลือก Postgres + MVCC งานอ่าน scale ได้ดี แต่ต้องลงทุนกับ DBA ที่เข้าใจ VACUUM ถ้าเลือก database ที่ใช้ lock เป็นหลัก (เช่น SQL Server แบบ default mode) งานอ่านอาจ block ตอน peak ได้ ต้องระวัง
Optimistic vs Pessimistic Locking — สมมติฐานเรื่อง conflict
อีกแกนความคิดที่สำคัญในการออกแบบระบบที่มี concurrency — คุณ “สมมติ” ว่า conflict จะเกิดบ่อยแค่ไหน
Pessimistic Locking — สมมติว่าจะ conflict, lock ก่อน
วิธีคิด: “ผมเชื่อว่ามีคนอื่นจะมาแย่งของชิ้นนี้แน่ๆ ผมล็อกไว้ก่อน ใครมาทีหลังต้องรอ”
ใน SQL ทำผ่าน SELECT ... FOR UPDATE ที่เราคุยไปตอน Locking ทันที transaction ขอ lock คนอื่นที่จะแก้ row เดียวกันต้องเข้าคิวรอ
เหมาะกับ: high contention ระบบที่มีคนแย่งทรัพยากรเดียวกันบ่อย เช่น
- ระบบจองที่นั่ง / ตั๋ว / โต๊ะร้านอาหาร
- ระบบโอนเงิน
- ระบบตัดสต๊อกในร้านค้าที่ของขายดี
ข้อแลกเปลี่ยน ช้าตอนคนเยอะ เพราะคนต้องเข้าคิว และ deadlock เสี่ยงขึ้น
Optimistic Locking — สมมติว่าจะไม่ conflict, ตรวจตอน commit
วิธีคิด: “ผมเชื่อว่าคนอื่นไม่น่าจะมาแย่งหรอก ไม่ต้องล็อก ทำไปก่อน ตอน commit ค่อยตรวจว่ามีคนชิงแก้ก่อนผมไหม ถ้าใช่ ทิ้ง แล้ว retry ใหม่”
วิธีทำคลาสสิคคือ ใส่ column version ในตาราง ทุกครั้งที่แก้ เพิ่ม version + 1
-- อ่านSELECT id, data, version FROM items WHERE id = 1;-- ได้ data = 'A', version = 5
-- ตอน update — เช็คว่า version ยังเป็น 5 อยู่ไหมUPDATE items SET data = 'B', version = 6WHERE id = 1 AND version = 5;ถ้ามีคนแก้ไปก่อนแล้ว version ใน database จะเป็น 6 หรือ 7 ไม่ใช่ 5 อีกต่อไป WHERE version = 5 จะไม่ match update ไม่เกิดขึ้น (affected rows = 0) application อ่านค่ากลับมาเห็นว่า fail แล้ว retry เอง
หลายระบบใช้ updated_at timestamp แทน version number แนวคิดเดียวกัน
เหมาะกับ: low contention ระบบที่คนแก้ของชิ้นเดียวกันพร้อมกันน้อย เช่น
- social network (post/comment ส่วนใหญ่ไม่มีคนแก้พร้อมกัน)
- CMS (บทความเดียวกันน้อยมากที่ 2 คนแก้พร้อม)
- profile ของ user เอง
ข้อดี เร็วมากตอน traffic ปกติ เพราะไม่ต้องล็อกอะไรเลย
สรุปความต่างเป็นภาษาคน:
- Pessimistic = “กันไว้ก่อน” ปลอดภัย แต่ช้า
- Optimistic = “ลุยก่อน ผิดค่อยแก้” เร็ว แต่ต้องเตรียม retry logic
ระบบใหญ่จริงๆ ใช้ทั้งสองแบบในระบบเดียวกัน — pessimistic ตรงจุดที่แย่งกันแน่ (สต๊อก ที่นั่ง) optimistic ตรงจุดที่เหลือ (profile, post)
Eventual Consistency — concept ของ NoSQL ที่ trade-off
ทั้ง EP.08 ที่ผ่านมาคุยกันบนสมมติฐานว่า database ตัวเดียว หรือ cluster ที่ sync กันเรียลไทม์ แต่ในโลก distributed database ที่กระจายไป 10 region ทั่วโลก (DynamoDB, Cassandra) สมมติฐานนี้พังทันที
CAP theorem ที่คุยใน EP.04 บอกว่า เครือข่ายระหว่าง region แตกได้เสมอ ตอนแตก คุณต้องเลือกระหว่าง consistency กับ availability database พวก AP (เช่น DynamoDB, Cassandra) เลือก availability ขอแค่ระบบไม่ล่ม ผลคือข้อมูลที่เขียนเข้า region A อาจใช้เวลา 100ms, 5 วินาที, หรือแม้แต่หลายนาที กว่าจะ “ไหล” ไปปรากฏที่ region B
นี่คือ Eventual Consistency — “ในที่สุดทุก replica จะตรงกัน แต่ไม่ใช่ตอนนี้”
Analogy: ลองนึกถึงข่าวซุบซิบในออฟฟิศใหญ่ คุณเล่าเรื่องให้เพื่อนคนหนึ่งฟัง อีกครึ่งชั่วโมงต่อมา ทั้งบริษัทรู้กันหมด แต่ระหว่างทางมีคนรู้ก่อน คนรู้หลัง ในที่สุดทุกคนรู้ แต่ไม่ใช่พร้อมกันเป๊ะ
คำถามสำคัญ — เมื่อไหร่ business OK กับ eventual?
OK กับ eventual:
- Like / view counter — ช้าไป 5 วินาทีไม่มีใครตาย
- Feed ของ social media — โพสต์ใหม่โผล่ช้าหน่อยรับได้
- Recommendation engine — “คนที่ซื้อหนังสือเล่มนี้…” ช้าไม่เป็นไร
ไม่ OK กับ eventual:
- ระบบโอนเงินธนาคาร — ยอดเงินต้องตรง 100% ทุกวินาที
- ระบบตัดสต๊อก — ขายเกินจำนวนจริงไม่ได้
- ระบบจองตั๋ว / ที่นั่ง — เคส flash sale ที่เปิดเรื่อง
มุมผู้บริหาร: ทุกครั้งที่ทีม IT บอกว่า “เราใช้ NoSQL เพราะ scale ดี” คำถามที่ผู้บริหารควรถามคือ “data ของเรารับ eventual consistency ได้ไหม” ถ้าตอบไม่ได้ แปลว่าทีมยังไม่ได้คิดเรื่องนี้
เคส flash sale — root cause + ทางแก้
กลับมาที่ผู้จัดการลานบินกับตั๋ว 5 ใบที่หมายเลขที่นั่งเดียวกันครับ ตอนนี้พอเรามีเครื่องมือพื้นฐานครบ เห็นภาพชัดขึ้นแล้ว
Root cause: isolation level เป็น Read Committed (default ของ Postgres) + transaction ใช้ SELECT ธรรมดาไม่ใช่ SELECT FOR UPDATE 5 transaction อ่านค่า “ที่นั่งเหลือ 1” พร้อมกัน ทุกตัวคิดว่ายังจองได้ ทุกตัว update + insert + commit สำเร็จ
ทางแก้มี 3 ทางหลัก เลือกตาม trade-off
ทางแก้ที่ 1: ยก isolation level เป็น Serializable + retry
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;BEGIN; -- เหมือนเดิมCOMMIT;ผลที่ได้: Postgres จะ detect ตอน commit ว่ามี conflict ไหม transaction ที่มาทีหลังจะถูก reject ด้วย error “could not serialize access” application ต้อง catch error นั้นแล้ว retry
ข้อดี: code เดิมแทบไม่ต้องแก้ ข้อเสีย: retry เยอะตอน peak ระบบช้าลง
ทางแก้ที่ 2: Pessimistic — SELECT FOR UPDATE
BEGIN; SELECT seats_left FROM flights WHERE flight_id = 'TG101' FOR UPDATE; -- ล็อก row ไว้ -- transaction อื่นที่จะแตะ row นี้ต้องรอ UPDATE flights SET seats_left = seats_left - 1 ...; INSERT INTO bookings ...;COMMIT;ผลที่ได้: transaction แรกล็อก row transaction ที่ 2-5 ต้องเข้าคิวรอ พอคนแรก commit seats_left เหลือ 0 คนที่ 2-5 อ่านเห็น 0 application return “ที่นั่งเต็มแล้ว”
ข้อดี: ง่าย เข้าใจง่าย ใช้ได้ทันที ข้อเสีย: ถ้าคนเข้าพร้อมกันเป็นพัน คิวยาว ระบบช้า
ทางแก้ที่ 3: Optimistic — version column
เพิ่ม version ใน table flights ตอน update เช็ค version
UPDATE flights SET seats_left = seats_left - 1, version = version + 1WHERE flight_id = 'TG101' AND version = ?;ถ้า affected rows = 0 มีคนแก้ไปก่อนแล้ว application return “ลองใหม่”
ข้อดี: เร็วมากตอน traffic ปกติ ข้อเสีย: ตอน flash sale คนแย่งกันแน่ fail rate สูง user experience แย่
ในความเป็นจริง ระบบจองตั๋วระดับ enterprise มักใช้ทางที่ 2 (pessimistic) เพราะตอน flash sale = high contention optimistic จะ fail ทุกคนยกเว้นคนแรก UX แย่กว่า “เข้าคิวรอ”
มุมผู้บริหาร: “isolation level ผิด” คือ bug ที่ debug ยากที่สุดในวงการ
ผมขอเขียนตรงนี้ตรงๆ ครับ เพราะมันสำคัญมาก ในบรรดา bug ทั้งหมดที่ระบบ database จะมี bug ที่เกิดจาก isolation level ผิด หรือ lock strategy ผิด เป็น bug ที่ debug ยากที่สุด เหตุผลมี 3 ข้อ
1. มันไม่เจอใน dev — ตอน developer test ในเครื่องตัวเอง คนเดียว transaction เดียว ทุกอย่างทำงานถูก Unit test ผ่านหมด integration test ผ่าน QA ผ่าน ขึ้น production โผล่
2. มันเจอเฉพาะตอน scale — ปัญหาเริ่มเมื่อมี transaction ขนานกันถึง threshold หนึ่ง ระบบที่ user 100 คนใช้ทุกวันอยู่ดีๆ วันที่มี campaign ดึงคนมา 10,000 คนในชั่วโมงเดียว bug โผล่ หลัง campaign จบ กลับมาปกติ หาสาเหตุไม่เจอ
3. log ไม่เห็น — ทุก transaction รายงาน “success” ไม่มี error message ไม่มี exception database ทำงาน “ถูกต้องตามสเปก” ทุกอย่าง แต่ข้อมูลใน table เริ่มไม่ตรงกับความเป็นจริง
signal ที่ business team จะมาบ่นกับ IT:
- “ตัวเลข dashboard ไม่ตรงกับยอดจริงนานๆ ครั้ง”
- “stock ในระบบเห็นไม่ตรงกัน คนละหน้าจอเลขต่างกัน”
- “ลูกค้าโทรมาบอกว่าจองสำเร็จแล้วแต่หน้าจอบอกว่ายังไม่จอง”
- “ยอดเงินในบัญชีหายไปนิดหน่อย แล้วกลับมา”
ถ้าเริ่มได้ยินคำพูดแบบนี้ โอกาสสูงที่ root cause คือ concurrency / isolation ไม่ใช่ “ลูกค้าจำผิด” ไม่ใช่ “ระบบสะดุด”
คำถามที่ผู้บริหารควรถามทีม dev / DBA ก่อน bug แบบนี้จะเกิด:
- “ระบบเราใช้ isolation level อะไรเป็น default?”
- “ตรง critical transaction (โอนเงิน ตัดสต๊อก จองที่นั่ง) เราใช้ isolation level เดียวกับที่อื่น หรือยกขึ้นเฉพาะที่?”
- “เรามี SELECT FOR UPDATE ตรงไหนบ้าง? ถ้าไม่มีเลย เรามี optimistic version ตรงไหนแทน?”
- “เราเคย load test ระบบที่ระดับ traffic peak จริงไหม? หรือ test แค่ baseline?”
- “ถ้า deadlock เกิดที่ production application retry อัตโนมัติ หรือ user เห็น error?”
ถ้าทีม ตอบไม่ได้ หรือ ตอบว่า “ไม่เคยคิด” นั่นคือ red flag ครับ ไม่ใช่เรื่อง blame เป็นเรื่องที่ทีมส่วนใหญ่ไม่ได้รับการสอนเรื่องนี้ในมหาวิทยาลัย และไม่มีในตำราเขียนเว็บทั่วไป แต่ระบบที่จะเอาไปขายของจริง ตัดเงินจริง ต้องมีคำตอบ
ปิดบท + tease Part 3
EP.08 จบที่นี่ครับ มาถึงตรงนี้ Part 2 ของซีรีส์เราครบแล้ว — เรารู้แล้วว่า database “ภายใน” ทำงานยังไง
- EP.06 — Schema จัดของให้เข้าที่
- EP.07 — Index ติดป้ายให้บรรณารักษ์เปิดเจอเร็ว
- EP.08 — Transaction + Concurrency ทำให้ user หลายคนใช้พร้อมกันได้โดยไม่ทะเลาะกัน
สามตอนนี้เป็น “หลังบ้าน” ที่ database ทุกตัวต้องมี ไม่ว่าจะเลือกตัวไหนก็ตาม ความรู้ตรงนี้เปลี่ยนคุณจาก “ผู้ใช้ database” เป็น “คนที่เข้าใจว่า database ทำอะไรอยู่”
ตอนนี้เรารู้ว่า database ทำงานยังไง แต่คำถามต่อไปที่ใหญ่กว่าคือ เราจะเลือก database แบบไหนสำหรับงานของเรา? blog ส่วนตัวกับระบบธนาคาร ใช้คนละตัวแน่นอน สตาร์ทอัพ 5 คนกับ enterprise พันคน สถาปัตยกรรมต่างกันสิ้นเชิง
Part 3 ของซีรีส์ — “เลือก Storage ตามขนาด” จะแยกเล่าตาม use case 4 ขนาด
- EP.09 — มุมเว็บส่วนตัว / blog : Database-less Architecture ที่เปลี่ยน blog ของผมเอง
- EP.10 — มุม Personal Data : SQLite + Local-first ที่กลับมาเป็นเทรนด์
- EP.11 — มุม Startup : Serverless DB Stack (Neon, Supabase, PlanetScale)
- EP.12 — มุม Enterprise : Polyglot Persistence ใช้ database หลายตัวพร้อมกัน
EP.09 จะเปิดด้วยเรื่องที่ผมทำกับ blog ตัวเอง — ย้ายจาก WordPress + MySQL บน Cloudways มาเป็น static site บน Cloudflare ไม่มี database เลย ฟังดูขัดกับทุกอย่างที่คุยใน Part 2 แต่จริงๆ มันคือคำตอบที่ถูกต้องสำหรับเว็บประเภทหนึ่งในยุคนี้