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

1 : N — ผู้ใช้ 1 คนมีได้หลายคำสั่งซื้อ users PK id 1 N orders PK id · FK user_id FK อยู่ฝั่ง "หลาย" เสมอ M : N — 1 คำสั่งซื้อมีหลายสินค้า และ 1 สินค้าอยู่ในหลายคำสั่งซื้อ orders PK id order_items FK order_id · FK product_id quantity · unit_price products PK id 1 N N 1 ตารางกลาง (junction table) แปลง M:N ให้กลายเป็น 1:N สองชุด เก็บ unit_price ไว้ในตารางกลางด้วย เพื่อให้ประวัติคำสั่งซื้อไม่เปลี่ยนตามราคาปัจจุบัน

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

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
AAtomicityทำครบทุกขั้นหรือไม่ทำเลยตัดสต็อกแล้วต้องบันทึก order สำเร็จด้วย ไม่งั้นย้อนทั้งหมด
CConsistencyข้อมูลต้องอยู่ในสถานะที่ถูกต้องตามกฎเสมอสต็อกห้ามติดลบ ยอดรวมต้องตรงกับรายการสินค้า
IIsolationธุรกรรมพร้อมกันต้องไม่รบกวนกันลูกค้า 2 คนซื้อของชิ้นสุดท้ายพร้อมกัน ต้องมีคนเดียวที่ได้
DDurabilityเมื่อ commit แล้วข้อมูลอยู่ถาวรแม้ไฟดับลูกค้าเห็นหน้า "สั่งซื้อสำเร็จ" แล้ว order ต้องไม่หาย

Transaction ของ Checkout

BEGIN TRANSACTION 1. ตรวจสต็อก ของยังพอไหม 2. ตัดเงิน payment 3. บันทึก order INSERT 4. ล้างตะกร้า clear cart ทุกขั้นสำเร็จ → COMMIT — ข้อมูลถูกบันทึกถาวร ขั้นที่ 3 ล้มเหลว (server crash / DB timeout) 1. ตรวจสต็อก 2. ตัดเงินแล้ว! 3. FAIL ROLLBACK / คืนเงิน (compensating action) ถ้าไม่มี rollback = ลูกค้าจ่ายเงินแล้วแต่ไม่มีคำสั่งซื้อในระบบ

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

RollbackCompensating 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 ในระบบ จงอธิบายปัญหา สาเหตุ ผลกระทบ และวิธีแก้

  1. ปัญหา — ระบบละเมิดหลัก Atomicity: มีบางขั้นสำเร็จ บางขั้นล้มเหลว แต่ผลลัพธ์ที่สำเร็จไปแล้วไม่ถูกย้อนกลับ
  2. สาเหตุทางเทคนิค — ขั้นตอนตัดเงินและบันทึก order ไม่ได้อยู่ใน transaction เดียวกัน (และตัดเงินเป็นระบบภายนอกที่ rollback ตรง ๆ ไม่ได้) อีกทั้งลำดับผิด — ตัดเงินก่อนบันทึกหลักฐาน
  3. ผลกระทบ — ลูกค้าเสียเงินโดยไม่ได้ของ เกิดข้อร้องเรียนและอาจถึงขั้นข้อพิพาททางกฎหมาย ยอดในระบบบัญชีไม่ตรงกับยอดที่ payment gateway และสต็อกที่ถูกตัดไปแล้วก็ค้างผิด
  4. วิธีแก้เชิงสถาปัตยกรรม — (1) สลับลำดับ: สร้าง order สถานะ pending ในฐานข้อมูลก่อน แล้วค่อยเรียก payment (2) ครอบขั้นตอนฝั่งฐานข้อมูลด้วย BEGIN TRANSACTION ... COMMIT และ ROLLBACK เมื่อพลาด (3) สำหรับการตัดเงินที่ย้อนไม่ได้ ให้ใช้ compensating action คือสั่ง refund อัตโนมัติ (4) ใช้ idempotency key กับ request checkout เพื่อกันการกดซ้ำแล้วถูกตัดเงินสองรอบ