Transaction basics
transaction เปลี่ยนหลายคำสั่งให้กลายเป็นหน่วยเดียวที่แบ่งแยกไม่ได้ คุณเปิดด้วย BEGIN รันคำสั่งที่ต้องการจัดกลุ่ม แล้วปิดด้วย COMMIT เพื่อเก็บงานไว้ หรือ ROLLBACK เพื่อทิ้งงานนั้น สิ่งที่อยู่ภายใน transaction จะไม่ปรากฏให้ connection อื่นเห็นจนกว่าคุณจะ commit
เราจะใช้ table accounts เล็ก ๆ ในทุกตัวอย่างของบทเรียนนี้
CREATE TABLE accounts ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, owner text NOT NULL, balance numeric NOT NULL CHECK (balance >= 0));
INSERT INTO accounts (owner, balance) VALUES ('Ada', 500), ('Linus', 100);การโอนเงินแบบคลาสสิก
หัวข้อที่มีชื่อว่า “การโอนเงินแบบคลาสสิก”การย้ายเงิน 100 จาก Ada ไปยัง Linus คือการ update สองครั้งที่ต้องเกิดขึ้นพร้อมกัน ถ้า server crash ระหว่างสองคำสั่งนั้น atomicity รับประกันว่าจะไม่มีการเปลี่ยนแปลงใดอยู่รอด เงินจึงไม่ถูกสร้างขึ้นหรือหายไป
BEGIN;UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada';UPDATE accounts SET balance = balance + 100 WHERE owner = 'Linus';COMMIT;const client = await pool.connect();try { await client.query('BEGIN'); await client.query('UPDATE accounts SET balance = balance - $1 WHERE owner = $2', [100, 'Ada']); await client.query('UPDATE accounts SET balance = balance + $1 WHERE owner = $2', [100, 'Linus']); await client.query('COMMIT');} catch (e) { await client.query('ROLLBACK'); throw e;} finally { client.release();}with conn.transaction(): cur.execute("UPDATE accounts SET balance = balance - %s WHERE owner = %s", (100, "Ada")) cur.execute("UPDATE accounts SET balance = balance + %s WHERE owner = %s", (100, "Linus"))# the block commits on success and rolls back on any exceptiontx, err := conn.Begin(ctx)if err != nil { return err}if _, err := tx.Exec(ctx, "UPDATE accounts SET balance = balance - $1 WHERE owner = $2", 100, "Ada"); err != nil { tx.Rollback(ctx) return err}if _, err := tx.Exec(ctx, "UPDATE accounts SET balance = balance + $1 WHERE owner = $2", 100, "Linus"); err != nil { tx.Rollback(ctx) return err}err = tx.Commit(ctx)let mut tx = pool.begin().await?;sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE owner = $2") .bind(100_i64).bind("Ada").execute(&mut *tx).await?;sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE owner = $2") .bind(100_i64).bind("Linus").execute(&mut *tx).await?;tx.commit().await?;หลังจาก commit ยอดเงินคงเหลือสะท้อนการ update ทั้งสองครั้งไปด้วยกัน
id | owner | balance----+-------+--------- 1 | Ada | 400 2 | Linus | 200(2 rows)เลือกที่จะละทิ้งงาน
หัวข้อที่มีชื่อว่า “เลือกที่จะละทิ้งงาน”หากมีอะไรดูผิดปกติก่อนที่คุณจะ commit ROLLBACK จะทิ้งทุกการเปลี่ยนแปลงที่ทำไปตั้งแต่ BEGIN ราวกับว่า transaction นั้นไม่เคยรันเลย ในที่นี้ Ada มีเงินไม่พอ เราจึงทิ้งทั้งหมด
BEGIN;UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada';-- on reflection, cancel everything:ROLLBACK;await client.query('BEGIN');await client.query('UPDATE accounts SET balance = balance - $1 WHERE owner = $2', [100, 'Ada']);await client.query('ROLLBACK');conn.rollback() # discards everything since the transaction begantx, _ := conn.Begin(ctx)tx.Exec(ctx, "UPDATE accounts SET balance = balance - $1 WHERE owner = $2", 100, "Ada")tx.Rollback(ctx)let mut tx = pool.begin().await?;sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE owner = $2") .bind(100_i64).bind("Ada").execute(&mut *tx).await?;tx.rollback().await?;วงจรชีวิตของ transaction
หัวข้อที่มีชื่อว่า “วงจรชีวิตของ transaction”ทุก transaction เดินตามเส้นทางเดียวกัน คือเริ่มต้น รันคำสั่งข้างใน แล้วจบลงทางใดทางหนึ่งในสองทางนี้เท่านั้น ไม่มีสถานะระหว่างกลางเมื่อคุณตัดสินใจแล้ว
flowchart TD
A[BEGIN] --> B[Run statements]
B --> C{Everything correct?}
C -->|Yes| D[COMMIT: changes become permanent]
C -->|No| E[ROLLBACK: changes discarded]
D --> F[Transaction ends]
E --> F การ roll back บางส่วนด้วย SAVEPOINT
หัวข้อที่มีชื่อว่า “การ roll back บางส่วนด้วย SAVEPOINT”บางครั้งคุณต้องการยกเลิกเพียงบางส่วนของ transaction โดยไม่ละทิ้งส่วนที่เหลือ SAVEPOINT คือเครื่องหมายที่มีชื่อภายใน transaction ROLLBACK TO จะย้อนกลับไปยังเครื่องหมายนั้น โดยเก็บทุกอย่างก่อนหน้าเครื่องหมายไว้ และทิ้งทุกอย่างหลังจากนั้น
BEGIN;UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada';SAVEPOINT after_debit;UPDATE accounts SET balance = balance + 100 WHERE owner = 'Nobody';-- that owner does not exist; undo just the second update:ROLLBACK TO after_debit;UPDATE accounts SET balance = balance + 100 WHERE owner = 'Linus';COMMIT;await client.query('BEGIN');await client.query("UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada'");await client.query('SAVEPOINT after_debit');await client.query("UPDATE accounts SET balance = balance + 100 WHERE owner = 'Nobody'");await client.query('ROLLBACK TO after_debit');await client.query("UPDATE accounts SET balance = balance + 100 WHERE owner = 'Linus'");await client.query('COMMIT');with conn.transaction(): cur.execute("UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada'") try: with conn.transaction(): # a nested block acts as a savepoint cur.execute("UPDATE accounts SET balance = balance + 100 WHERE owner = 'Nobody'") raise ValueError("undo just this part") except ValueError: pass cur.execute("UPDATE accounts SET balance = balance + 100 WHERE owner = 'Linus'")tx, _ := conn.Begin(ctx)tx.Exec(ctx, "UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada'")tx.Exec(ctx, "SAVEPOINT after_debit")tx.Exec(ctx, "UPDATE accounts SET balance = balance + 100 WHERE owner = 'Nobody'")tx.Exec(ctx, "ROLLBACK TO after_debit")tx.Exec(ctx, "UPDATE accounts SET balance = balance + 100 WHERE owner = 'Linus'")tx.Commit(ctx)let mut tx = pool.begin().await?;sqlx::query("UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada'") .execute(&mut *tx).await?;sqlx::query("SAVEPOINT after_debit").execute(&mut *tx).await?;sqlx::query("UPDATE accounts SET balance = balance + 100 WHERE owner = 'Nobody'") .execute(&mut *tx).await?;sqlx::query("ROLLBACK TO after_debit").execute(&mut *tx).await?;sqlx::query("UPDATE accounts SET balance = balance + 100 WHERE owner = 'Linus'") .execute(&mut *tx).await?;tx.commit().await?;การหักเงินและการเพิ่มเงินครั้งสุดท้ายถูกเก็บไว้ มีเพียงงานระหว่าง savepoint กับ ROLLBACK TO เท่านั้นที่ถูกทิ้งไป
owner | balance-------+--------- Ada | 400 Linus | 200(2 rows)เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- ทำให้ transaction สั้น transaction ที่เปิดค้างไว้จะถือทรัพยากรไว้ และอย่างที่บทเรียน MVCC แสดงไว้ ยังอาจหน่วงการเก็บกวาด row เวอร์ชันเก่าด้วย
- โดยค่าเริ่มต้น connection อยู่ในโหมด auto-commit คือแต่ละคำสั่งเดี่ยว ๆ เป็น transaction ที่ commit ของตัวเอง คุณออกจากโหมดนั้นได้ด้วยการสั่ง
BEGINเท่านั้น - error ภายใน transaction จะผลัก transaction เข้าสู่สถานะ aborted แล้ว PostgreSQL จะปฏิเสธคำสั่งต่อไปจนกว่าคุณจะ
ROLLBACK(หรือROLLBACK TOไปยัง savepoint) - ในโค้ดแอปพลิเคชัน ให้จับคู่
BEGINกับROLLBACKบนเส้นทาง error เสมอ ตัวอย่าง driver ด้านบนแสดงรูปแบบ try-or-finally ที่รับประกันว่า connection จะไม่ปล่อย transaction ที่เปิดค้างหลุดออกไป
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
ห่อคำสั่งที่เกี่ยวข้องกันด้วย BEGIN...COMMIT | atomicity รับประกันว่างานทั้งหมดสำเร็จหรือไม่มีอะไรเกิดขึ้นเลย | ต้องเขียนโค้ดจัดการ error path (ROLLBACK) เอง และ transaction ที่เปิดค้างจะถือ lock ไว้นานขึ้น |
| ปล่อยให้แต่ละคำสั่งรันแบบ auto-commit | เขียนโค้ดง่ายกว่า ไม่ต้องจัดการ transaction เอง | ถ้า crash หรือ error เกิดขึ้นกลางทาง ข้อมูลอาจไม่สอดคล้องกัน เพราะแต่ละคำสั่ง commit แยกกัน |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- ลืม
COMMITหรือROLLBACKแล้วปล่อย transaction ค้างไว้ — transaction ที่เปิดค้างยังถือ lock และหน่วงการเก็บกวาดของ VACUUM อยู่ ให้จับคู่BEGINกับ path จบเสมอ ไม่ว่าจะสำเร็จหรือ error - คิดว่าคำสั่งหลายคำสั่งเป็น atomic โดยอัตโนมัติ — ถ้าไม่ได้ห่อด้วย
BEGINแต่ละคำสั่งจะ commit แยกกันทันที การ crash ระหว่างคำสั่งจะทิ้งข้อมูลไว้ครึ่ง ๆ กลาง ๆ - ไม่จัดการ error ระหว่าง transaction — เมื่อคำสั่งหนึ่ง fail transaction จะเข้าสู่สถานะ aborted ทันที คำสั่งถัดไปทั้งหมดจะถูกปฏิเสธเงียบ ๆ จนกว่าคุณจะ
ROLLBACK
💡 ตัวอย่างจากของจริง
ระบบธนาคารและ payment gateway — การโอนเงินหักบัญชีหนึ่งแล้วเพิ่มอีกบัญชีหนึ่งต้องห่อด้วย transaction เดียวเสมอ เพื่อไม่ให้ crash ระหว่างสองคำสั่งทำให้เงินหายหรือถูกสร้างขึ้นมาลอย ๆ นี่คือเหตุผลคลาสสิกที่ ACID transaction ถูกออกแบบมา
Stripe และระบบ e-commerce — เมื่อสร้างคำสั่งซื้อ ระบบต้องหักสต็อกสินค้าและบันทึก order พร้อมกันในหนึ่ง transaction ถ้าขั้นตอนใดล้มเหลว ทั้งสอง operation ต้อง roll back กลับไปพร้อมกัน ไม่ปล่อยให้สต็อกถูกหักแต่ order ไม่ถูกสร้าง