Transactions & Concurrency
จนถึงตอนนี้ทุกคำสั่งที่คุณเขียนต่างก็ทำงานของตัวเองแยกกัน แต่ในแอปพลิเคชันจริง การกระทำทางธุรกิจหนึ่งอย่างมักต้องใช้หลายคำสั่งที่ต้องสำเร็จไปด้วยกันทั้งหมดหรือไม่สำเร็จเลย การโอนเงินระหว่างสองบัญชีคือตัวอย่างคลาสสิก คือ row หนึ่งลดลง อีก row หนึ่งเพิ่มขึ้น และคุณไม่มีทางยอมให้เกิดการทำสำเร็จเพียงครึ่งเดียวได้เลย
transaction คือเครื่องมือสำหรับสิ่งนี้โดยเฉพาะ โดยจัดกลุ่มคำสั่งตั้งแต่หนึ่งคำสั่งขึ้นไปให้เป็นหน่วยงานเดียวแบบ all-or-nothing ถ้าไม่ทุกคำสั่งมีผล ก็ไม่มีคำสั่งไหนมีผลเลย นอกจากนี้ ฐานข้อมูลที่มีงานหนาแน่นยังมี transaction หลายตัวทำงานพร้อมกันในเวลาเดียวกัน ซึ่งนี่คือสิ่งที่เราหมายถึงคำว่า concurrency โมดูลนี้พูดถึงทั้งสองเรื่อง คือวิธีนิยามหน่วยงาน และวิธีที่ PostgreSQL ป้องกันไม่ให้ transaction เหล่านั้นทำให้ข้อมูลของกันและกันเสียหาย
การรับประกันสี่ข้อ: ACID
หัวข้อที่มีชื่อว่า “การรับประกันสี่ข้อ: ACID”transaction ใน PostgreSQL ให้การรับประกันแก่คุณสี่ข้อ จำง่าย ๆ ด้วยตัวย่อ ACID
| ตัวอักษร | คุณสมบัติ | สิ่งที่รับประกัน |
|---|---|---|
| A | Atomicity | ทุกคำสั่ง commit ไปด้วยกันหรือ roll back ไปด้วยกัน ไม่มีทางทำเสร็จครึ่งเดียว |
| C | Consistency | ฐานข้อมูลเคลื่อนจากสถานะที่ถูกต้องหนึ่งไปยังอีกสถานะที่ถูกต้อง โดยเคารพ constraint |
| I | Isolation | transaction ที่ทำงานพร้อมกันจะไม่เห็นงานที่ยังไม่เสร็จของกันและกัน |
| D | Durability | เมื่อ commit แล้ว การเปลี่ยนแปลงจะอยู่รอดผ่านการ crash หรือไฟดับ |
Atomicity คือส่วนที่คุณรู้สึกได้ตรง ๆ ที่สุดเมื่อพิมพ์ COMMIT หรือ ROLLBACK ส่วน Isolation คือส่วนที่เริ่มน่าสนใจเมื่อมี client มากกว่าหนึ่งตัวเชื่อมต่ออยู่ และเป็นจุดที่เนื้อหาเชิงลึกส่วนใหญ่ของโมดูลนี้อยู่
ชิ้นส่วนต่าง ๆ ประกอบกันอย่างไร
หัวข้อที่มีชื่อว่า “ชิ้นส่วนต่าง ๆ ประกอบกันอย่างไร”สี่บทเรียนถัดไปต่อยอดกันเป็นชั้น คุณเริ่ม transaction คุณเลือกว่า transaction นั้นควร isolate มากแค่ไหน และเบื้องล่างของทั้งสองสิ่งนั้น PostgreSQL ใช้กลไกสองอย่างเพื่อให้ concurrency ปลอดภัย คือการเก็บ row เดียวกันไว้หลายเวอร์ชัน และการจับ lock เมื่อจำเป็น
flowchart TD A[Transaction: BEGIN to COMMIT] --> B[Isolation level: how much it sees] B --> C[MVCC: each transaction reads a snapshot] B --> D[Locking: serialize conflicting writes] C --> E[Safe concurrency for many clients] D --> E
โมดูลนี้ครอบคลุมอะไรบ้าง
หัวข้อที่มีชื่อว่า “โมดูลนี้ครอบคลุมอะไรบ้าง”- Transaction basics —
BEGIN,COMMIT,ROLLBACK, atomicity และSAVEPOINTสำหรับการ roll back บางส่วน สาธิตด้วยการโอนเงินระหว่างบัญชี - Isolation levels — Read Committed, Repeatable Read และ Serializable พร้อม read anomaly ที่แต่ละระดับป้องกันได้
- MVCC — Multi-Version Concurrency Control ที่ writer ไม่ block reader และ reader ไม่ block writer เพราะแต่ละ transaction อ่านจาก snapshot ที่สอดคล้องกัน
- Locking and deadlocks — row lock แบบชัดเจนด้วย
SELECT ... FOR UPDATE, เหตุที่สอง transaction เกิด deadlock ได้ และวิธีที่ PostgreSQL ตัดสินผลเสมอ
ดูภาพรวมก่อน
หัวข้อที่มีชื่อว่า “ดูภาพรวมก่อน”คุณจะได้รู้จักคำสั่งเหล่านี้อย่างเป็นเรื่องเป็นราวในบทเรียนถัดไป แต่นี่คือรูปร่างของ transaction เพื่อให้เนื้อหาที่เหลือของโมดูลอ่านได้อย่างเป็นธรรมชาติ
BEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 1;UPDATE accounts SET balance = balance + 100 WHERE id = 2;COMMIT;const client = await pool.connect();try { await client.query('BEGIN'); await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, 1]); await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, 2]); await client.query('COMMIT');} finally { client.release();}with conn.transaction(): cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (100, 1)) cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (100, 2))tx, _ := conn.Begin(ctx)tx.Exec(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, 1)tx.Exec(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", 100, 2)tx.Commit(ctx)let mut tx = pool.begin().await?;sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2") .bind(100_i64).bind(1_i64).execute(&mut *tx).await?;sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2") .bind(100_i64).bind(2_i64).execute(&mut *tx).await?;tx.commit().await?;ใน pgAdmin: โดยค่าเริ่มต้น Query Tool จะรันแต่ละคำสั่งใน transaction แบบ auto-commit ของตัวเอง หากต้องการจัดกลุ่มคำสั่ง ให้พิมพ์ BEGIN; ด้วยตัวเองที่ด้านบนและ COMMIT; ที่ท้าย แล้วรันทั้งบล็อกพร้อมกันทีเดียว
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- connection ที่ไม่ได้อยู่ภายใน transaction แบบชัดเจนจะรันแต่ละคำสั่งใน transaction โดยปริยายของตัวเอง และ commit ทันที สิ่งนี้เรียกว่า auto-commit
- isolation คือสเปกตรัม ไม่ใช่สวิตช์ ระดับที่สูงขึ้นป้องกัน anomaly ได้มากขึ้น แต่ทำให้ conflict มีโอกาส abort มากขึ้นด้วย
- PostgreSQL ไม่เคยขอให้ reader รอ writer สำหรับ query
SELECTธรรมดา นั่นคือประโยชน์เด่นของ MVCC - deadlock เป็นความเป็นไปได้ปกติในระบบ concurrent ใด ๆ ไม่ใช่บั๊ก แอปพลิเคชันที่เขียนดีจะเผื่อไว้แล้ว retry