ข้ามไปยังเนื้อหา

พื้นฐาน Transaction

transaction ทำให้หลาย statement เป็นหน่วยเดียวที่แบ่งแยกไม่ได้ คุณเปิดด้วย BEGIN รัน statement ที่ต้องการจัดกลุ่ม แล้วปิดด้วย COMMIT เพื่อเก็บงานไว้ หรือ ROLLBACK เพื่อทิ้ง ไม่มีอะไรใน transaction ที่ connection อื่นมองเห็นจนกว่าคุณจะ commit (docs)

ทุกอย่างด้านล่างรันบน PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) ตัวอย่าง session เดียวคือ fence sql ที่รันจากบนลงล่างในฐานข้อมูลเปล่าหนึ่งตัว ตัวอย่างสอง session เป็นการรันจริงของสองโปรเซส psql ส่วน snippet ของ driver รันอย่างละหนึ่งครั้ง (pg 8.23.0, psycopg 3.3.6, pgx v5.11.0, sqlx 0.9.0)

ทุกตัวอย่างใช้ table accounts เล็ก ๆ ตัวเดียว CHECK constraint สำคัญ เพราะเป็นวิธีที่เราจะทำให้ statement ล้มเหลวโดยตั้งใจ

-- sql/setup.sql
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 ที่ต้องเกิดพร้อมกัน ถ้าเซิร์ฟเวอร์ crash ระหว่างสองอัน atomicity รับประกันว่าไม่มีการเปลี่ยนแปลงใดรอด เงินจึงไม่ถูกสร้างหรือหายไป

-- sql/transfer.sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada';
UPDATE accounts SET balance = balance + 100 WHERE owner = 'Linus';
COMMIT;

หลัง commit ยอดคงเหลือสะท้อนทั้งสอง update ไปพร้อมกัน

-- sql/after-transfer.sql
SELECT * FROM accounts ORDER BY id;
id | owner | balance
----+-------+---------
1 | Ada | 400
2 | Linus | 200
(2 rows)

ถ้าอะไรดูผิดก่อน commit ROLLBACK ทิ้งทุกการเปลี่ยนแปลงตั้งแต่ BEGIN เหมือน transaction ไม่เคยรัน สังเกตว่าใน transaction คุณเห็นการเปลี่ยนแปลงที่ยังไม่ commit ของตัวเอง (Ada แสดง 300) หลัง rollback มันหายไป

-- sql/rollback.sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada';
SELECT owner, balance FROM accounts ORDER BY id;
-- on reflection, cancel everything:
ROLLBACK;
SELECT owner, balance FROM accounts ORDER BY id;
BEGIN
UPDATE 1
owner | balance
-------+---------
Ada | 300
Linus | 200
(2 rows)
ROLLBACK
owner | balance
-------+---------
Ada | 400
Linus | 200
(2 rows)

driver ทั้งสี่ แต่ละ snippet รันหนึ่งครั้งกับฐานข้อมูล scratch ที่รีเซ็ตเป็น Ada 500 / Linus 100 ก่อนทุกครั้ง กลุ่มสุดท้ายคือตัวอย่าง savepoint จากด้านล่าง จึงพิมพ์ไว้ที่นี่ด้วย:

$ (each snippet run once; the scratch database is reset before every run)
[transfer] Node.js (pg 8.23.0): Ada 400, Linus 200
[transfer] Python (psycopg 3.3.6): Ada 400, Linus 200
[transfer] Go (pgx v5.11.0): Ada 400, Linus 200
[transfer] Rust (sqlx 0.9.0): Ada 400, Linus 200
[rollback] Node.js (pg 8.23.0): Ada 500, Linus 100
[rollback] Python (psycopg 3.3.6): Ada 500, Linus 100
[rollback] Go (pgx v5.11.0): Ada 500, Linus 100
[rollback] Rust (sqlx 0.9.0): Ada 500, Linus 100
[savepoint] Node.js (pg 8.23.0): rolling back to the savepoint: 23514 | Ada 400, Linus 200
[savepoint] Python (psycopg 3.3.6): rolling back to the savepoint: 23514 | Ada 400, Linus 200
[savepoint] Go (pgx v5.11.0): rolling back to the savepoint: 23514 | Ada 400, Linus 200
[savepoint] Rust (sqlx 0.9.0): rolling back to the savepoint: 23514 | Ada 400, Linus 200

ทุก transaction เดินทางเส้นเดียวกัน: เริ่ม รัน statement และจบในสองทางใดทางหนึ่งเท่านั้น เมื่อคุณตัดสินใจแล้วไม่มีสถานะกึ่งกลาง

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
transaction เริ่ม รัน แล้ว commit หรือ rollback

โดยปกติ connection อยู่ในโหมด auto-commit: แต่ละ statement เดี่ยวคือ transaction ของตัวเอง และถูก commit ทันทีที่เสร็จ ที่นี่เราย้าย 500 จาก Linus (ที่มีแค่ 200) ไป Ada ด้วยสอง statement แยกกัน การเพิ่มเงินสำเร็จและถูก commit ทันที แล้วการหักเงินล้มเหลวที่ CHECK:

-- @expect-error 23514
-- move 500 from Linus (who has 200) to Ada, WITHOUT BEGIN: each statement is its own transaction
UPDATE accounts SET balance = balance + 500 WHERE owner = 'Ada';
UPDATE accounts SET balance = balance - 500 WHERE owner = 'Linus';
UPDATE 1
ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (2, Linus, -300).
-- sql/half-done.sql
SELECT owner, balance, sum(balance) OVER () AS total FROM accounts ORDER BY id;
owner | balance | total
-------+---------+-------
Ada | 900 | 1100
Linus | 200 | 1100
(2 rows)

Ada ได้ 500 ที่ไม่มีใครเสีย: ยอดรวมจาก 600 กลายเป็น 1100 นี่คือสถานะทำครึ่งเดียวที่ transaction มีไว้ป้องกัน ดึง 500 กลับด้วยมือ:

-- sql/repair.sql
UPDATE accounts SET balance = balance - 500 WHERE owner = 'Ada';

ทีนี้สอง statement เดิมภายใน BEGIN … COMMIT การหักเงินล้มเหลว และ PostgreSQL ใส่ transaction เข้าสถานะ aborted: ทุกคำสั่งหลังจากนั้นถูกปฏิเสธด้วย SQLSTATE 25P02 จนกว่าคุณจะจบ transaction แม้แต่ COMMIT ก็ช่วยไม่ได้ และมันตอบด้วย command tag ROLLBACK ไม่ใช่ COMMIT:

-- @expect-error 23514
BEGIN;
UPDATE accounts SET balance = balance + 500 WHERE owner = 'Ada';
UPDATE accounts SET balance = balance - 500 WHERE owner = 'Linus';
SELECT 1;
COMMIT;
BEGIN
UPDATE 1
ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (2, Linus, -300).
ERROR: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
-- sql/atomic-check.sql
SELECT owner, balance, sum(balance) OVER () AS total FROM accounts ORDER BY id;
owner | balance | total
-------+---------+-------
Ada | 400 | 600
Linus | 200 | 600
(2 rows)

ไม่มีอะไรเปลี่ยน: Ada 400, Linus 200 ยอดรวม 600 การเพิ่มเงินที่รันก่อนความล้มเหลวถูกทิ้งไปพร้อมกับทุกอย่าง

บางครั้งคุณอยากยกเลิกแค่บางส่วนของ transaction โดยไม่ทิ้งส่วนที่เหลือ SAVEPOINT คือจุดหมายที่มีชื่อภายใน transaction ROLLBACK TO ย้อนกลับไปที่จุดนั้น เก็บทุกอย่างก่อนหน้าและทิ้งทุกอย่างหลังจากนั้น มันยังเป็นวิธีเดียวที่จะกู้จาก error แล้วไปต่อได้: ROLLBACK TO พา transaction ออกจากสถานะ aborted (docs)

statement ที่ล้มเหลวต้องล้มเหลวจริง อันนี้ทำให้ยอดของ Ada ติดลบและชน CHECK (UPDATE ที่ไม่ตรงกับ row ใดเลย เช่น WHERE owner = 'Nobody' ไม่ raise error เลย มันแค่รายงาน UPDATE 0 จึงไม่มีอะไรให้ rollback ไป)

-- @expect-error 23514
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE owner = 'Ada';
SAVEPOINT after_debit;
UPDATE accounts SET balance = balance - 1000 WHERE owner = 'Ada'; -- violates the CHECK
-- the transaction is now aborted; undo just the failed part:
ROLLBACK TO after_debit;
UPDATE accounts SET balance = balance + 100 WHERE owner = 'Linus';
COMMIT;
BEGIN
UPDATE 1
SAVEPOINT
ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (1, Ada, -700).
ROLLBACK
UPDATE 1
COMMIT

การหักเงินและการเพิ่มเงินสุดท้ายถูกเก็บไว้ เฉพาะ statement ที่ล้มเหลวระหว่าง savepoint กับ ROLLBACK TO ที่ถูกทิ้ง เริ่มจาก Ada 400 / Linus 200 ผลคือ Ada 300 / Linus 300 และยอดรวมยัง 600:

-- sql/savepoint-check.sql
SELECT owner, balance, sum(balance) OVER () AS total FROM accounts ORDER BY id;
owner | balance | total
-------+---------+-------
Ada | 300 | 600
Linus | 300 | 600
(2 rows)

rollback ทิ้งการเปลี่ยนแปลง row และใน PostgreSQL ทิ้งการเปลี่ยน schema ด้วย: DDL เป็น transactional แต่ไม่คืนค่า sequence ดังนั้น identity id มีช่องว่างหลัง rollback (เหตุผลเดียวกับบทเรียน upsert):

-- sql/side-effects.sql
CREATE TABLE ledger (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, note text);
BEGIN;
INSERT INTO ledger (note) VALUES ('rolled back') RETURNING id;
ROLLBACK;
INSERT INTO ledger (note) VALUES ('kept') RETURNING id;
BEGIN;
CREATE TABLE scratch (x int);
INSERT INTO scratch VALUES (1);
ROLLBACK;
SELECT to_regclass('scratch') AS scratch_exists;
CREATE TABLE
BEGIN
id
----
1
(1 row)
INSERT 0 1
ROLLBACK
id
----
2
(1 row)
INSERT 0 1
BEGIN
CREATE TABLE
INSERT 0 1
ROLLBACK
scratch_exists
----------------
(1 row)

insert ที่ถูก rollback ใช้ id 1 ไปแล้ว row ถัดไปจึงได้ 2 และ CREATE TABLE ใน transaction ที่ถูก rollback ไม่ทิ้ง table ไว้ (to_regclass คืน NULL)

ทุก transaction ที่เขียนข้อมูลได้ transaction id pg_current_xact_id() คืนค่านั้น ซึ่งเท่าเดิมตลอดหนึ่ง transaction แต่ใหม่ทุกครั้งสำหรับแต่ละ statement ที่ auto-commit (ตัวเลขของคุณจะต่างไป รูปแบบจะไม่ต่าง):

-- sql/xid.sql
BEGIN;
SELECT pg_current_xact_id() = pg_current_xact_id() AS same_xid_in_tx;
COMMIT;
SELECT pg_current_xact_id() AS first_autocommit;
SELECT pg_current_xact_id() AS second_autocommit;
BEGIN
same_xid_in_tx
----------------
t
(1 row)
COMMIT
first_autocommit
------------------
324811
(1 row)
second_autocommit
-------------------
324812
(1 row)

transaction ที่เปิดอยู่ถือ lock และ snapshot ของมัน session ที่รัน BEGIN แล้วเงียบไปจะปรากฏใน pg_stat_activity เป็น idle in transaction และใครที่ต้องใช้ row ที่มันแตะต้องรอ session A update Ada แล้วไม่ทำอะไรเลย 3 วินาที session B ที่วินาทีแรกดู pg_stat_activity และพยายาม update row เดียวกัน:

-- session A: script
BEGIN;
UPDATE accounts SET balance = balance WHERE owner = 'Ada';
\! sleep 3
ROLLBACK;
-- session A: psql output
BEGIN
UPDATE 1
ROLLBACK
-- session B: script
\! sleep 1
SELECT state, wait_event_type, query
FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid() AND backend_type = 'client backend';
\timing on
UPDATE accounts SET balance = balance WHERE owner = 'Ada';
-- session B: psql output
state | wait_event_type | query
---------------------+-----------------+------------------------------------------------------------
idle in transaction | Client | UPDATE accounts SET balance = balance WHERE owner = 'Ada';
(1 row)
Timing is on.
UPDATE 1
Time: 1997.304 ms (00:01.997)

B รอประมาณสองวินาที — จนกว่า A จบ — เพื่อการเปลี่ยนแปลงที่ A ไม่ได้ต้องการด้วยซ้ำ เซิร์ฟเวอร์จบ session แบบนี้ให้ได้ด้วย idle_in_transaction_session_timeout connection ถูกปิดด้วย FATAL และ client ต้องเชื่อมต่อใหม่:

-- session A: script
SET idle_in_transaction_session_timeout = '1s';
BEGIN;
SELECT 1 AS still_alive;
\! sleep 2
SELECT 2 AS after_the_nap;
-- session A: psql output
SET
BEGIN
still_alive
-------------
1
(1 row)
FATAL: terminating connection due to idle-in-transaction timeout
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
connection to server was lost

ผู้ช่วย AI ถูกขอให้เขียนการโอนแบบ atomic ด้วย node-postgres และเขียนเส้นทางปกติ พร้อมคอมเมนต์ที่บอกตรงกับที่เราต้องการเป๊ะ:

🐛 หา bug ในโค้ดนี้

// AI comment: "transfer money atomically inside a transaction"
async function transfer(pool, from, to, amount) {
const client = await pool.connect();
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE owner = $2', [amount, from]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE owner = $2', [amount, to]);
await client.query('COMMIT');
client.release();
}
เฉลย

มัน atomic เฉพาะตอนที่ไม่มีอะไรผิดพลาด เมื่อ UPDATE ที่สอง (หรือแรก ถ้า Ada เงินไม่พอ) throw การควบคุมออกจากฟังก์ชันก่อน COMMIT และก่อน client.release() ไม่มี ROLLBACK ดังนั้น connection ค้างอยู่ใน transaction ที่ aborted และไม่ถูกคืนเข้า pool รันสามครั้งด้วยจำนวนที่ Ada จ่ายไม่ไหว (pg 8.23.0, pool 5 ตัว, amount = 1000):

$ node transfer.js buggy # 3 calls of transfer(pool, "Ada", "Linus", 1000), pool of 5
call failed: 23514 new row for relation "accounts" violates check constraint "accounts_balance_check"
call failed: 23514 new row for relation "accounts" violates check constraint "accounts_balance_check"
call failed: 23514 new row for relation "accounts" violates check constraint "accounts_balance_check"
after 3 failed calls: [{"state":"idle in transaction (aborted)","n":3}]
pool: total 3 idle 0 waiting 0
$ node transfer.js fixed
call failed: 23514 new row for relation "accounts" violates check constraint "accounts_balance_check"
call failed: 23514 new row for relation "accounts" violates check constraint "accounts_balance_check"
call failed: 23514 new row for relation "accounts" violates check constraint "accounts_balance_check"
after 3 failed calls: []
pool: total 1 idle 1 waiting 0

สาม backend ค้างเป็น idle in transaction (aborted) และ pool เสียไปสามจากห้า client หลังความล้มเหลวแบบนี้ห้าครั้ง ทุก request จะรอ connection ตลอดไป คุณจะหาเจอด้วย query แบบนี้:

SELECT state, count(*) FROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid() AND state <> 'idle' GROUP BY state;

วิธีแก้คือรูปแบบ try / catch / finally จากแท็บ transfer: ROLLBACK บนเส้นทาง error และ release() ใน finally เพื่อให้ connection ถูกคืนเสมอ การเรียกที่ล้มเหลวสามครั้งเดิมไม่ทิ้งอะไรเปิดอยู่ (การรันที่สองข้างบน):

async function transfer(pool, from, to, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE owner = $2', [amount, from]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE owner = $2', [amount, to]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}

transaction คือกระดาษทดส่วนตัว ทุกสิ่งที่คุณทำลงบนกระดาษของคุณก่อน คนอื่นมองไม่เห็น COMMIT เผยแพร่ทั้งแผ่นในพริบตาเดียว ROLLBACK ฉีกทิ้ง นอก BEGIN PostgreSQL ห่อแต่ละ statement ด้วยกระดาษแผ่นเล็กของมันเอง error ในกระดาษไม่ได้ทำให้ statement เดียวล้ม — มันทำให้กระดาษเสีย และมีแต่ ROLLBACK (หรือ ROLLBACK TO savepoint) เท่านั้นที่ให้เขียนต่อได้

  • ทำ transaction ให้สั้น transaction ที่เปิดค้างถือ lock (B รอสองวินาทีข้างบน) และตามที่บทเรียน MVCC แสดง มันยับยั้งการเก็บกวาด row version เก่า
  • error ใน transaction พาไปสถานะ aborted (25P02 สำหรับทุกคำสั่งหลังจากนั้น) COMMIT บน transaction ที่ aborted กลายเป็น rollback เงียบ ๆ
  • UPDATE ที่ไม่ตรงกับอะไรเลยไม่ใช่ error ตรวจจำนวน row (UPDATE 0) ถ้าต้องรู้
  • ในโค้ดแอป จับคู่ BEGIN กับ ROLLBACK บนเส้นทาง error เสมอ และปล่อย connection ใน finally ตัวอย่าง driver ข้างบนแสดงรูปแบบ bug ข้างบนแสดงสิ่งที่เกิดขึ้นถ้าไม่มี
  • ตั้ง idle_in_transaction_session_timeout บน production เป็นตาข่ายรองรับโค้ดที่ลืม
  • sequence และ nextval ไม่ถูก rollback อย่าพึ่งพา id ที่ไม่มีช่องว่าง
ตัวเลือกBenefitCost
ห่อ statement ที่เกี่ยวข้องด้วย BEGIN...COMMITAtomicity — ทั้งหมดสำเร็จหรือไม่มีอะไรเกิดขึ้น (ยอดรวม 600 ข้างบน)ต้องจัดการเส้นทาง error (ROLLBACK) เอง และ transaction ที่เปิดค้างถือ lock นานขึ้น
ปล่อยให้แต่ละ statement auto-commitโค้ดง่ายกว่า ไม่ต้องจัดการ transactionerror กลางทางทิ้งงานครึ่งหนึ่งที่ commit ไปแล้ว (ยอดรวม 1100 ข้างบน)
SAVEPOINT รอบ statement เสี่ยงกู้จากความล้มเหลวหนึ่งครั้งโดยไม่เสียส่วนที่เหลือแต่ละ savepoint เริ่ม subtransaction ใช้ในที่ที่คาดว่าจะเกิด error ไม่ใช่รอบทุก statement
  • “หลาย statement atomic โดยอัตโนมัติ” ถ้าไม่มี BEGIN แต่ละ statement commit เอง ความล้มเหลวระหว่างกลางทิ้งข้อมูลทำครึ่งเดียว (Ada +500 โดยไม่มีการหักเงินคู่กัน)
  • “COMMIT commit เสมอ” บน transaction ที่ aborted มัน rollback และบอกด้วย tag ROLLBACK
  • “error ล้มแค่ statement นั้น” ใน transaction มันล้มส่วนที่เหลือด้วย จนกว่าจะ ROLLBACK หรือ ROLLBACK TO
  • “ROLLBACK ย้อนทุกอย่าง” มันไม่คืนค่า sequence (id 1 ถูกเผาไปแล้ว) และไม่ใช่ตัวแทนของการปิด connection ให้ถูกต้อง
  • “ลืมปิด transaction ไม่เป็นอันตราย” มันบล็อกผู้เขียนคนอื่นและยับยั้ง vacuum เป็นหนึ่งในเหตุการณ์ production ที่พบบ่อยที่สุด
ROLLBACK ทำอะไรกับ statement ที่รันตั้งแต่ BEGIN?
SAVEPOINT มีไว้เพื่ออะไร?
statement เดี่ยวถูก commit เองโดยอัตโนมัติเมื่อไร?
ใน transaction มี statement ล้มเหลวด้วย CHECK violation แล้วคุณส่ง COMMIT จะเกิดอะไรขึ้น?
ฟังก์ชัน Node.js รัน BEGIN, สอง UPDATE, COMMIT และ client.release() โดยไม่มี try/catch แล้ว UPDATE ตัวหนึ่ง throw อะไรถูกทิ้งไว้?