Database Persistence & Transactional Integrity
Transaction ไม่ใช่แค่ "บันทึกข้อมูล" แต่เป็นหน่วยงานเชิงตรรกะที่ต้องเป็นไปตามหลัก All-or-Nothing — ถ้าขั้นตอนใดพัง ทุกอย่างต้องย้อนกลับ
Schema Design: Primary Key & Foreign Key
| Key | หน้าที่ | กฎ |
|---|---|---|
| Primary Key (PK) | ระบุแถวได้ไม่ซ้ำ | ห้ามซ้ำ ห้ามเป็น NULL — 1 ตารางมีได้ 1 ชุด |
| Foreign Key (FK) | ชี้ไปยัง PK ของอีกตาราง | ค่าที่ใส่ต้องมีอยู่จริงในตารางแม่ ไม่งั้นเกิด orphan record |
CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL -- เก็บ hash เท่านั้น ); CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, total REAL NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) );
1:N และ M:N
M:N ต้องมี junction table เสมอ — PK ของมันคือคู่ (order_id, product_id)
CREATE TABLE order_items ( order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL CHECK (quantity > 0), unit_price REAL NOT NULL, -- ราคาตอนซื้อ ไม่ใช่ราคาปัจจุบัน PRIMARY KEY (order_id, product_id), FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (product_id) REFERENCES products(id) );
ทำไมต้องมี Junction Table
- ฐานข้อมูลเชิงสัมพันธ์เก็บ M:N โดยตรงไม่ได้ — ช่องหนึ่งเก็บได้ค่าเดียว
- ถ้าฝืนเก็บเป็น
product_ids = "1,5,9"จะ query, join และตัดสต็อกไม่ได้ - junction table ยังเก็บข้อมูลของความสัมพันธ์ได้ เช่น quantity และราคาตอนซื้อ
SQL Injection
โค้ดอันตราย — เอา input ของผู้ใช้ไปต่อสตริงเป็น SQL ตรง ๆ
const sql = "SELECT * FROM users WHERE email = '" + req.body.email + "'";
ถ้าผู้ใช้กรอก ' OR '1'='1 คำสั่งจะกลายเป็น
SELECT * FROM users WHERE email = '' OR '1'='1'
เงื่อนไขเป็นจริงเสมอ → ได้ข้อมูลผู้ใช้ทั้งตาราง และถ้าเป็นหน้า login ก็ล็อกอินผ่านโดยไม่รู้รหัสผ่าน
Parameterized Query — วิธีแก้ที่ถูกต้อง
// SQLite / node-sqlite3 — ใช้ placeholder เครื่องหมาย ? db.get('SELECT * FROM users WHERE email = ?', [req.body.email], (err, row) => { ... }); // เพิ่มข้อมูลหลายค่า db.run('INSERT INTO orders (user_id, total) VALUES (?, ?)', [userId, total]);
เหตุผลที่ปลอดภัย: ฐานข้อมูล compile โครงสร้างคำสั่งไว้ก่อน แล้วจึงส่งค่าเข้าไปเป็น ข้อมูล
ไม่ใช่ส่วนหนึ่งของคำสั่ง ต่อให้ผู้ใช้กรอก
' OR '1'='1 ก็จะถูกมองว่าเป็นข้อความค้นหาธรรมดา| วิธีป้องกัน | ระดับ |
|---|---|
| Parameterized query / prepared statement | วิธีหลัก ใช้เสมอ |
| Validation ด้วย RegEx (เช่นรูปแบบอีเมล) | ชั้นเสริม |
| Least privilege ของ DB user | จำกัดความเสียหายเมื่อโดนเจาะ |
| ไม่คืนข้อความ error ดิบของฐานข้อมูลให้ client | ลดการรั่วของ schema |
ACID
| ตัวอักษร | ชื่อ | ความหมาย | ตัวอย่างในระบบ e-commerce |
|---|---|---|---|
| A | Atomicity | ทำครบทุกขั้นหรือไม่ทำเลย | ตัดสต็อกแล้วต้องบันทึก order สำเร็จด้วย ไม่งั้นย้อนทั้งหมด |
| C | Consistency | ข้อมูลต้องอยู่ในสถานะที่ถูกต้องตามกฎเสมอ | สต็อกห้ามติดลบ ยอดรวมต้องตรงกับรายการสินค้า |
| I | Isolation | ธุรกรรมพร้อมกันต้องไม่รบกวนกัน | ลูกค้า 2 คนซื้อของชิ้นสุดท้ายพร้อมกัน ต้องมีคนเดียวที่ได้ |
| D | Durability | เมื่อ commit แล้วข้อมูลอยู่ถาวรแม้ไฟดับ | ลูกค้าเห็นหน้า "สั่งซื้อสำเร็จ" แล้ว order ต้องไม่หาย |
Transaction ของ Checkout
4 ขั้นของ checkout ต้องสำเร็จทั้งหมด ไม่งั้นย้อนกลับให้หมด
BEGIN TRANSACTION; UPDATE products SET stock = stock - 2 WHERE id = 101 AND stock >= 2; INSERT INTO orders (user_id, total) VALUES (7, 1998); INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (last_insert_rowid(), 101, 2, 999); COMMIT; -- ถ้ามีคำสั่งใดล้มเหลว ให้ ROLLBACK แทน COMMIT
Rollback & Compensating Action
| Rollback | Compensating Action | |
|---|---|---|
| ใช้กับ | งานที่อยู่ในฐานข้อมูลเดียวกัน | งานที่ออกไปนอกระบบแล้ว เช่นตัดเงินผ่าน payment gateway |
| ทำอย่างไร | สั่ง ROLLBACK ฐานข้อมูลย้อนสถานะให้เอง | ต้องสั่งงาน "ตรงข้าม" เอง เช่น refund หรือคืนสต็อก |
| เหตุผล | ธุรกรรมยังไม่ commit จึงยกเลิกได้ | เงินถูกตัดจริงแล้ว ย้อนเวลาไม่ได้ ต้องชดเชย |
คำถามจากสไลด์: สลับขั้นที่ 2 กับ 3 ได้ไหม —
ได้และมักดีกว่า: บันทึก order สถานะ pending ก่อน แล้วค่อยตัดเงิน
เพราะถ้าตัดเงินสำเร็จแต่บันทึกไม่ได้ ลูกค้าจะเสียเงินฟรีโดยไม่มีหลักฐานในระบบ
Edge cases ของ checkout ที่สไลด์ระบุ
| ขั้นตอน | ทำอะไร | ความล้มเหลวที่ต้องเผื่อ |
|---|---|---|
| Inventory Check | ตรวจว่าของยังมีอยู่ | ของหมดระหว่างที่ผู้ใช้กำลังกรอกฟอร์ม |
| Validation | ตรวจรูปแบบที่อยู่และอีเมล | ผู้ใช้กรอกอีเมลปลอมหรือผิดรูปแบบ |
| Calculations | คำนวณราคารวม + ภาษี ที่ server | ราคาสินค้าเปลี่ยนหลังผู้ใช้ใส่ตะกร้า |
| Persistence | บันทึก order ลงฐานข้อมูล | server crash หรือ database timeout |
PCI Compliance: ในระบบจริงเราไม่ให้เลขบัตรเครดิตแตะฐานข้อมูลของเราเลย
แต่ใช้ token จากผู้ให้บริการอย่าง Stripe
แนวข้อสอบอัตนัย
โจทย์: ระบบ checkout ตัดเงินลูกค้าสำเร็จ แต่ server crash ก่อนบันทึกคำสั่งซื้อ ลูกค้าเสียเงินแต่ไม่มี order ในระบบ จงอธิบายปัญหา สาเหตุ ผลกระทบ และวิธีแก้
- ปัญหา — ระบบละเมิดหลัก Atomicity: มีบางขั้นสำเร็จ บางขั้นล้มเหลว แต่ผลลัพธ์ที่สำเร็จไปแล้วไม่ถูกย้อนกลับ
- สาเหตุทางเทคนิค — ขั้นตอนตัดเงินและบันทึก order ไม่ได้อยู่ใน transaction เดียวกัน (และตัดเงินเป็นระบบภายนอกที่ rollback ตรง ๆ ไม่ได้) อีกทั้งลำดับผิด — ตัดเงินก่อนบันทึกหลักฐาน
- ผลกระทบ — ลูกค้าเสียเงินโดยไม่ได้ของ เกิดข้อร้องเรียนและอาจถึงขั้นข้อพิพาททางกฎหมาย ยอดในระบบบัญชีไม่ตรงกับยอดที่ payment gateway และสต็อกที่ถูกตัดไปแล้วก็ค้างผิด
- วิธีแก้เชิงสถาปัตยกรรม — (1) สลับลำดับ: สร้าง order สถานะ pending ในฐานข้อมูลก่อน แล้วค่อยเรียก payment (2) ครอบขั้นตอนฝั่งฐานข้อมูลด้วย
BEGIN TRANSACTION ... COMMITและROLLBACKเมื่อพลาด (3) สำหรับการตัดเงินที่ย้อนไม่ได้ ให้ใช้ compensating action คือสั่ง refund อัตโนมัติ (4) ใช้ idempotency key กับ request checkout เพื่อกันการกดซ้ำแล้วถูกตัดเงินสองรอบ