Transactions & Concurrency
จนถึงตอนนี้ทุก statement ที่คุณเขียนรันเดี่ยว ๆ แต่ในแอปจริง การกระทำทางธุรกิจหนึ่งอย่างมักต้องใช้หลาย statement ที่ต้องสำเร็จพร้อมกันทั้งหมดหรือไม่ก็ไม่สำเร็จเลย การย้ายเงินระหว่างสองบัญชีเป็นตัวอย่างคลาสสิก: row หนึ่งลด อีก row เพิ่ม และคุณรับไม่ได้เลยที่จะทำแค่ครึ่งเดียว
transaction คือเครื่องมือสำหรับสิ่งนี้เอง มันจัดกลุ่ม statement หนึ่งตัวหรือมากกว่าเป็นหน่วยงาน all-or-nothing หน่วยเดียว ไม่ทุก statement มีผล ก็ไม่มีเลย นอกจากนี้ฐานข้อมูลที่ยุ่งมีหลาย transaction รันพร้อมกัน ซึ่งเราเรียกว่า concurrency โมดูลนี้ครอบคลุมทั้งสอง: วิธีกำหนดหน่วยงาน และวิธีที่ PostgreSQL ไม่ให้พวกมันทำลายข้อมูลของกันและกัน
ทุกบทเรียนในโมดูลนี้รันบน PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) ตัวอย่าง concurrency ไม่ได้ถูกเล่า แต่ถูกทำซ้ำ: สองหรือสาม session psql จริงที่เริ่มพร้อมกัน พิมพ์ผลลัพธ์ตามที่ตอบกลับมา
สี่การรับประกัน: ACID
หัวข้อที่มีชื่อว่า “สี่การรับประกัน: ACID”transaction ใน PostgreSQL ให้การรับประกันสี่อย่าง จำด้วยตัวย่อ ACID (docs)
| ตัวอักษร | คุณสมบัติ | สัญญาว่า |
|---|---|---|
| A | Atomicity | ทุก statement commit พร้อมกันหรือ rollback พร้อมกัน ไม่ทำครึ่งเดียว |
| C | Consistency | ฐานข้อมูลเปลี่ยนจากสถานะที่ถูกต้องหนึ่งไปอีกอัน โดยเคารพ constraint |
| I | Isolation | transaction ที่พร้อมกันไม่เห็นงานที่ยังไม่เสร็จของกันและกัน |
| D | Durability | เมื่อ commit แล้ว การเปลี่ยนแปลงรอดจาก crash หรือไฟดับ |
Atomicity คือส่วนที่คุณรู้สึกโดยตรงที่สุดเมื่อพิมพ์ COMMIT หรือ ROLLBACK Isolation คือส่วนที่น่าสนใจเมื่อมี client เชื่อมต่อมากกว่าหนึ่ง และเป็นที่ที่ความลึกส่วนใหญ่ของโมดูลนี้อยู่
ชิ้นส่วนประกอบกันอย่างไร
หัวข้อที่มีชื่อว่า “ชิ้นส่วนประกอบกันอย่างไร”สี่บทถัดไปต่อยอดกัน คุณเริ่ม transaction เลือกว่าจะแยกตัวมากแค่ไหน และใต้ทั้งสองอย่าง PostgreSQL ใช้สองกลไกให้ concurrency ปลอดภัย: เก็บหลาย version ของแต่ละ 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 —
BEGIN,COMMIT,ROLLBACK, สิ่งที่เกิดเมื่อ statement ล้มใน transaction (25P02),SAVEPOINTสำหรับ rollback บางส่วน และ connection รั่วที่เห็นได้ในpg_stat_activity - Isolation levels — Read Committed, Repeatable Read และ Serializable พร้อมทำซ้ำทุก anomaly (ยอดที่เปลี่ยนกลาง transaction, write skew ที่ commit,
40001และ retry loop ที่จัดการมัน) - MVCC — Multi-Version Concurrency Control ที่ผู้เขียนไม่บล็อกผู้อ่านและผู้อ่านไม่บล็อกผู้เขียน เพราะแต่ละ transaction อ่าน snapshot ที่สอดคล้อง คุณดู
xmin,xmaxและctidและ heap page ดิบ และเห็นว่าทำไม transaction ที่ลืมไว้อันเดียวทำให้ table บวม - Locking และ deadlock — row lock แบบชัดเจนด้วย
SELECT ... FOR UPDATE, lost update ที่มันป้องกัน, lock สี่โหมด, deadlock จริงพร้อม server log,NOWAIT,SKIP LOCKEDและ advisory lock
ดูเบื้องต้น
หัวข้อที่มีชื่อว่า “ดูเบื้องต้น”คุณจะพบคำสั่งเหล่านี้อย่างจริงจังในบทถัดไป แต่นี่คือรูปร่างของ transaction เพื่อให้ส่วนที่เหลือของโมดูลอ่านลื่น ทุกบทนิยาม table เล็กนี้ของตัวเอง คุณเริ่มที่ไหนก็ได้
-- sql/setup.sqlCREATE 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);-- sql/first-look.sqlBEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 1;UPDATE accounts SET balance = balance + 100 WHERE id = 2;COMMIT;SELECT id, owner, balance FROM accounts ORDER BY id;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');} catch (e) { await client.query('ROLLBACK'); throw e;} 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, err := conn.Begin(ctx)if err != nil { return err}defer tx.Rollback(ctx)if _, err := tx.Exec(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, 1); err != nil { return err}if _, err := tx.Exec(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", 100, 2); err != nil { return err}return 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?;BEGINUPDATE 1UPDATE 1COMMIT id | owner | balance----+-------+--------- 1 | Ada | 400 2 | Linus | 200(2 rows)การโอนเดียวกันจากแต่ละ driver รันหนึ่งครั้งกับฐานข้อมูล scratch ที่รีเซ็ตเป็น Ada 500 / Linus 100 ก่อน (pg 8.23.0, psycopg 3.3.6, pgx v5.11.0, sqlx 0.9.0):
$ (each snippet run once; the scratch database is reset before every run)[first look] Node.js (pg 8.23.0): Ada 400, Linus 200[first look] Python (psycopg 3.3.6): Ada 400, Linus 200[first look] Go (pgx v5.11.0): Ada 400, Linus 200[first look] Rust (sqlx 0.9.0): Ada 400, Linus 200ใน pgAdmin: Query Tool รันแต่ละ statement เป็น transaction ที่ auto-commit ของมันเองโดยปกติ เพื่อจัดกลุ่ม statement พิมพ์ BEGIN; เองที่ด้านบนและ COMMIT; ที่ท้าย แล้วรันทั้งบล็อกพร้อมกัน
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ถือสองแนวคิดพร้อมกัน transaction คือขอบเขตของ คุณ — คุณตัดสินใจว่างานเริ่มและจบเมื่อไร concurrency control คือหน้าที่ของ PostgreSQL — มันตัดสินว่า transaction ของคุณอยู่ร่วมกับของทุกคนได้อย่างไร โดยใช้ snapshot (ผู้อ่านจึงไม่รอ) และ lock (ผู้เขียนจึงผลัดกัน) ส่วนที่เหลือของโมดูลแสดงว่าสองความรับผิดชอบนี้มาเจอกันที่ไหน และโค้ดของคุณต้องทำอะไรเมื่อ PostgreSQL บอกว่า “ลองใหม่”
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- connection ที่ไม่อยู่ใน transaction แบบชัดเจนรันแต่ละ statement ใน transaction โดยนัยของมันเอง และ commit ทันที เรียกว่า auto-commit
- isolation คือสเปกตรัม ไม่ใช่สวิตช์ level ที่สูงขึ้นป้องกัน anomaly ได้มากขึ้น แต่ทำให้ความขัดแย้งมีโอกาส abort มากขึ้น
- PostgreSQL ไม่เคยขอให้ผู้อ่านรอผู้เขียนสำหรับ query
SELECTธรรมดา นั่นคือประโยชน์หลักของ MVCC - deadlock และ serialization failure เป็นความเป็นไปได้ปกติในระบบ concurrent ไม่ใช่ bug แอปที่เขียนดีคาดหวังและ retry
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “transaction ทำให้โค้ดช้าลง” transaction แทบไม่มีต้นทุนเมื่อสั้น ที่ทำให้เจ็บคืออันที่ ยาว (ดูบทเรียน MVCC)
- “isolation สูงกว่าปลอดภัยกว่าเสมอ” มันเข้มกว่า และ abort มากกว่า ถ้าไม่มี retry loop นั่นคือ outage ไม่ใช่ความปลอดภัย
- “การอ่านและการเขียนบล็อกกัน” ไม่ใช่ใน PostgreSQL: การอ่านธรรมดาใช้ snapshot และไม่ถือ row lock