SQL & Database — Tư tưởng & Nguyên lý/BEGIN, COMMIT, SAVEPOINT — kiểm soát transaction thủ công
39/51
Bài 39 / 51~18 phútTransactions & consistency — nguyên lýMiễn phí lượt xem

BEGIN, COMMIT, SAVEPOINT — kiểm soát transaction thủ công

Autocommit mặc định, BEGIN/COMMIT/ROLLBACK, SAVEPOINT rollback từng phần, SET TRANSACTION, node-postgres pattern đúng.

TL;DR: Mặc định hầu hết SQL client chạy autocommit — mỗi statement là một transaction độc lập, commit ngay. BEGIN mở explicit transaction: nhiều statement thành một đơn vị, chỉ có hiệu lực khi COMMIT, huỷ toàn bộ khi ROLLBACK. SAVEPOINT cho phép rollback một phần transaction mà giữ nguyên phần trước. SET TRANSACTION điều chỉnh isolation level hoặc chế độ read-only. Mở transaction mà không đóng (long open transaction) là nguồn gốc của nhiều vấn đề production.

psql mặc định chạy ở chế độ autocommit — mỗi câu SQL là một transaction độc lập, commit tức thì. Điều này tiện khi thử nhanh, nhưng là bẫy lớn khi bạn cần nhiều thao tác DML thành công hoặc thất bại cùng nhau. INSERT một user xong, rồi INSERT profile của họ thất bại? Nếu autocommit, user đã tồn tại mà không có profile — data inconsistent, không có lỗi nào báo trước.

BEGIN mở ra một transaction rõ ràng: từ đây mọi statement thuộc về một đơn vị công việc duy nhất, chỉ commit khi bạn nói COMMIT, huỷ toàn bộ khi bạn nói ROLLBACK. SAVEPOINT tinh chỉnh thêm một bước: rollback một phần transaction mà không mất công đoạn trước. Bài này đi qua từng cơ chế, demo thực tế trên TaskFlow, và kết với pattern node-postgres hay bị implement sai nhất trong production.

1. Analogy — Drafts trong Google Docs

Google Docs tự lưu sau mỗi thay đổi — mỗi chữ gõ được lưu ngầm, không cần bấm Save. Đó chính xác là autocommit: mỗi statement một transaction, commit ngay, không có cơ hội xem lại.

Khi cần review kỹ trước khi công bố, bạn mở "Draft mode" — gõ thoải mái, thay đổi bất kỳ, chỉ bấm "Publish" khi đã hài lòng toàn bộ. Nếu không ưng, bạn đóng draft mà không lưu. Đó là BEGIN/COMMIT/ROLLBACK.

Version lịch sử trong Google Docs cho phép bạn đánh dấu một mốc tạm, thử thêm nội dung, rồi quay về mốc đó nếu thấy phần mới sai. Đó là SAVEPOINT.

Google DocsSQL Transaction
Tự lưu sau mỗi ký tựAutocommit — mỗi statement 1 tx
Draft mode — gõ thoải máiBEGIN mở explicit transaction
Bấm PublishCOMMIT — xác nhận toàn bộ thay đổi
Đóng draft không lưuROLLBACK — huỷ toàn bộ thay đổi
Đánh dấu version tạmSAVEPOINT sp1
Quay về version tạmROLLBACK TO sp1
Publish giữ version tạmRELEASE SAVEPOINT sp1 rồi COMMIT
💡 Cách nhớ

BEGIN = mở Draft. COMMIT = Publish. ROLLBACK = đóng không lưu. SAVEPOINT = đánh dấu version tạm để rollback một phần nếu cần. Session khác không thấy bất kỳ thay đổi nào cho đến khi bạn COMMIT.

flowchart TD
    OPEN[BEGIN] --> STMT[Execute statements]
    STMT --> SP{Dung SAVEPOINT?}
    SP -- Yes --> SAVE[SAVEPOINT sp1]
    SAVE --> STMT2[Them statements]
    STMT2 --> ERR{Loi o phan sau?}
    ERR -- Yes --> RBP[ROLLBACK TO sp1\nHuy sau savepoint\nGiu phan truoc]
    RBP --> STMT
    ERR -- No --> DONE{Ket thuc tx?}
    SP -- No --> DONE
    DONE -- COMMIT --> CMT[COMMIT\nflush WAL, fsync\nThay doi visible]
    DONE -- ROLLBACK --> RB[ROLLBACK\nHuy toan bo tu BEGIN]

    style CMT fill:#22c55e,color:#fff
    style RB fill:#ef4444,color:#fff
    style RBP fill:#f59e0b,color:#fff

2. Autocommit — mặc định và demo BEGIN/ROLLBACK

Trong hầu hết database client (psql, MySQL CLI, DBeaver, DataGrip…), mỗi statement tự động được bọc trong một transaction và commit ngay:

-- Autocommit mac dinh: moi statement la 1 tx rieng biet
INSERT INTO users (email, name) VALUES ('[email protected]', 'Alice');
-- Commit ngay lap tuc. Row da ton tai vinh vien.

-- Gio vao explicit transaction
BEGIN;
INSERT INTO users (email, name) VALUES ('[email protected]', 'Bob');
INSERT INTO users (email, name) VALUES ('[email protected]', 'Carol');
ROLLBACK;
-- 0 row insert (bob va carol khong ton tai)
-- alice van con tu cau INSERT truoc BEGIN

-- Kiem tra:
SELECT email FROM users WHERE email IN ('[email protected]', '[email protected]', '[email protected]');
-- Ket qua: chi co [email protected]

Đây là điểm mấu chốt: ROLLBACK huỷ mọi thứ từ sau BEGIN. Statement chạy trước BEGIN (autocommit) không bị ảnh hưởng.

3. BEGIN, COMMIT, ROLLBACK — cú pháp SQL standard

PostgreSQL chấp nhận cả hai dạng mở transaction:

-- Hai cach mo transaction, ket qua giong nhau
BEGIN;
-- hoac:
START TRANSACTION;

Trong transaction, mọi DML nhìn thấy kết quả của nhau ngay lập tức — nhưng chỉ trong session của bạn. Session khác không thấy thay đổi cho đến khi COMMIT:

-- Session A:
BEGIN;
UPDATE tasks SET status = 'done' WHERE id = 42;
-- Session A thay doi status = 'done' ngay lap tuc trong tx cua no.
-- Session B van thay status cu (chua commit).

COMMIT;
-- Bay gio session B moi thay status = 'done'.

Nếu có lỗi xảy ra trong transaction (constraint violation, runtime error…), hầu hết database đặt transaction vào trạng thái lỗi — mọi statement tiếp theo trong cùng transaction sẽ thất bại cho đến khi bạn ROLLBACK.

BEGIN;
INSERT INTO tasks (project_id, title, status)
  VALUES (999, 'T', 'todo');
-- ERROR: foreign key violation (project 999 khong ton tai)
-- Transaction vao trang thai loi

-- Moi lenh tiep theo deu that bai:
SELECT 1;
-- ERROR: current transaction is aborted (PostgreSQL)
-- hoac thong bao tuong duong tuy engine

ROLLBACK;
-- Gio moi session binh thuong tro lai
Ghi chú dialect — trạng thái lỗi trong transaction

PostgreSQL dùng thuật ngữ aborted và báo "current transaction is aborted, commands ignored until end of transaction block". MySQL tự động rollback transaction khi gặp lỗi nghiêm trọng (statement-level rollback cho một số lỗi). SQL Server có cơ chế tương tự với XACT_STATE(). Nguyên lý chung: sau lỗi trong transaction, phải ROLLBACK hoặc ROLLBACK TO SAVEPOINT trước khi tiếp tục.

4. SAVEPOINT — rollback một phần

SAVEPOINT đặt một điểm đánh dấu trong transaction. ROLLBACK TO savepoint_name huỷ mọi thay đổi từ savepoint đó trở về sau mà không huỷ phần trước. Transaction vẫn còn mở — tiếp tục hoặc COMMIT/ROLLBACK toàn bộ sau.

BEGIN;
INSERT INTO tasks (project_id, title, status)
  VALUES (1, 'Task 1', 'todo');  -- T1

SAVEPOINT sp1;

INSERT INTO tasks (project_id, title, status)
  VALUES (1, 'Task 2', 'todo');  -- T2

SAVEPOINT sp2;

INSERT INTO tasks (project_id, title, status)
  VALUES (1, 'Task 3 - du lieu sai', 'invalid_status');  -- T3 loi

ROLLBACK TO sp2;
-- T3 bi huy. T1 va T2 van con trong transaction.

COMMIT;
-- Final: T1 va T2 duoc commit, T3 khong ton tai.

RELEASE SAVEPOINT sp_name xoá savepoint khỏi stack khi không cần nữa — tiết kiệm một chút overhead, không bắt buộc.

Ghi chú dialect — implicit savepoint trong stored procedure

PostgreSQL PL/pgSQL: mỗi BEGIN...EXCEPTION...END block tự động tạo một implicit savepoint — khi exception xảy ra, PG rollback về savepoint đó và chạy EXCEPTION handler, không làm hỏng transaction bên ngoài. MySQL stored procedure và SQL Server T-TRY/CATCH có cơ chế tương tự nhưng cú pháp khác. Nguyên lý chung: stored procedure/function có thể bắt lỗi nội bộ mà không abort transaction ngoài.

-- Vi du PostgreSQL PL/pgSQL (dialect-specific)
CREATE OR REPLACE FUNCTION safe_insert_task(
  p_project_id BIGINT,
  p_title TEXT
) RETURNS BOOLEAN AS $$
BEGIN
  INSERT INTO tasks (project_id, title, status)
    VALUES (p_project_id, p_title, 'todo');
  RETURN TRUE;
EXCEPTION
  WHEN foreign_key_violation THEN
    -- PL/pgSQL tu rollback ve implicit savepoint.
    -- Transaction ngoai van nguyen ven.
    RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

5. SET TRANSACTION — isolation level và READ ONLY

SET TRANSACTION phải đứng ngay sau BEGIN, trước câu DML/query đầu tiên trong transaction:

-- Set isolation level cho transaction hien tai
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- Tat ca query trong tx nay thay cung 1 snapshot tu luc BEGIN
-- Chi tiet ve isolation levels: bai 3 cua module nay
SELECT COUNT(*) FROM tasks WHERE project_id = 1;
COMMIT;

READ ONLY block mọi DML trong transaction — hữu ích cho report query trên replica:

BEGIN;
SET TRANSACTION READ ONLY;
-- Toan bo tx nay chi cho phep SELECT, khong cho phep INSERT/UPDATE/DELETE
SELECT
  p.name AS project,
  COUNT(t.id) AS total_tasks,
  COUNT(t.id) FILTER (WHERE t.status = 'done') AS done_tasks
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id, p.name;
COMMIT;

Dùng READ ONLY trên replica giúp PostgreSQL đảm bảo không có write nào lọt qua, đồng thời cho phép planner tối ưu tốt hơn vì biết chắc không có data mutation.

6. Pitfall — long open transaction giết production

Long open transaction: lock giữ lâu, storage bloat, connection cạn kiệt

BEGIN mà không COMMIT/ROLLBACK — dù chỉ là session CLI bỏ quên — gây ra ba vấn đề lớn:

1. Lock giữ lâu: Transaction giữ SELECT FOR UPDATE sẽ giữ row-level lock trong suốt thời gian open — các session khác cần update cùng row bị block vô thời hạn.

2. Storage bloat (với engine dùng MVCC như PostgreSQL): Transaction mở giữ snapshot từ thời điểm BEGIN. Engine không thể reclaim "dead tuple" (row cũ đã bị UPDATE/DELETE) nếu chúng vẫn cần thiết cho snapshot đó. Table phình to, query chậm dần theo thời gian.

3. Connection pool cạn kiệt: Connection bị "idle in transaction" không trả về pool — các request mới bị reject khi pool đầy.

Monitoring (ví dụ PostgreSQL):

-- Phat hien idle-in-transaction sessions lau hon 1 phut
SELECT pid, usename, state,
       now() - state_change AS idle_duration,
       query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - state_change > interval '1 minute';

Fix cấu hình:

-- PostgreSQL: tu dong ket thuc session idle in transaction qua 5 phut
-- postgresql.conf hoac ALTER SYSTEM:
-- idle_in_transaction_session_timeout = '5min'

-- MySQL: wait_timeout / interactive_timeout
-- SQL Server: KILL <spid> hoac SET LOCK_TIMEOUT

Nguyên tắc production: mỗi explicit transaction phải có timeout ở tầng application. Không bao giờ mở BEGIN trong REPL rồi rời bàn phím.

7. Applied — TaskFlow bulk insert với SAVEPOINT

Scenario: import 1000 task theo batch 100. Nếu một batch có row lỗi, rollback batch đó mà giữ các batch trước:

DO $$
DECLARE
  batch_start INT;
BEGIN
  FOR batch_num IN 1..10 LOOP
    batch_start := (batch_num - 1) * 100 + 1;
    SAVEPOINT batch_checkpoint;
    BEGIN
      INSERT INTO tasks (project_id, title, status)
      SELECT 1, 'Imported task ' || g, 'todo'
      FROM generate_series(batch_start, batch_start + 99) g;
      RELEASE SAVEPOINT batch_checkpoint;
    EXCEPTION WHEN OTHERS THEN
      -- Batch co loi: rollback chi batch nay, giu cac batch truoc
      ROLLBACK TO SAVEPOINT batch_checkpoint;
      RAISE WARNING 'Batch % failed: %', batch_num, SQLERRM;
    END;
  END LOOP;
END;
$$;
-- Ket qua: toi da 900 task (9 batch thanh cong) neu 1 batch loi

Pattern phổ biến trong data migration và ETL: một hàng dữ liệu xấu không phá cả job, công đoạn thành công được giữ lại.

8. Applied — node-postgres pattern đúng

Pattern hay bị implement sai nhất trong production Node.js:

import pg from 'pg';
const pool = new pg.Pool();

async function bulkCreateTasks(projectId, taskTitles) {
  // QUAN TRONG: pool.connect() de co 1 client co dinh cho toan bo tx.
  // KHONG dung pool.query('BEGIN') -- pool co the chon client khac cho moi lenh.
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const results = [];
    for (const title of taskTitles) {
      const res = await client.query(
        `INSERT INTO tasks (project_id, title, status)
         VALUES ($1, $2, 'todo')
         RETURNING id, title`,
        [projectId, title]
      );
      results.push(res.rows[0]);
    }

    await client.query('COMMIT');
    return results;
  } catch (err) {
    // Bat ky loi nao: rollback toan bo, khong co partial insert
    await client.query('ROLLBACK');
    throw err;
  } finally {
    // Luon tra client lai pool, du COMMIT hay ROLLBACK
    client.release();
  }
}

Common bug — tại sao pool.query('BEGIN') sai:

pool.query() lấy client từ pool, chạy xong trả lại ngay. Ba lần gọi liên tiếp có thể dùng ba client khác nhau. BEGIN trên client A, UPDATE trên client B (autocommit — commit ngay), COMMIT trên client C (transaction rỗng). Nếu có lỗi sau UPDATE, không có gì để rollback vì UPDATE đã autocommit ở client B.

9. Deep Dive

📚 Deep Dive — Transaction control
  • SQL Standard — ISO/IEC 9075 "Transaction Management" — định nghĩa chuẩn của BEGIN, COMMIT, ROLLBACK, SAVEPOINT, SET TRANSACTION. Phần lớn RDBMS implement theo chuẩn này với một số dialect extension.
  • PostgreSQL Documentation — SAVEPOINT — cú pháp đầy đủ, RELEASE SAVEPOINT, giới hạn (không thể dùng savepoint ngoài explicit transaction). Ngắn, thực tế, nên đọc trực tiếp khi cần tham chiếu.
  • node-postgres Transactions Guide — pattern chính thức của thư viện pg: pool.connect(), try/catch/finally, client.release(). Áp dụng trực tiếp cho bất kỳ dự án Node.js nào dùng PostgreSQL.
  • DDIA Ch.7 — "Transactions" — phần "Weak Isolation Levels" và "Serializability" xây trên nền BEGIN/COMMIT/SAVEPOINT này. Đọc song song với bài 3 của module này.

10. Tóm tắt

  • Autocommit mặc định: mỗi statement trong SQL client là một transaction độc lập — commit ngay sau khi chạy xong.
  • BEGIN / START TRANSACTION: mở explicit transaction; mọi DML tiếp theo thuộc về một đơn vị, chỉ có hiệu lực khi COMMIT.
  • ROLLBACK: huỷ toàn bộ thay đổi từ BEGIN — không ảnh hưởng statement trước BEGIN.
  • SAVEPOINT: đặt điểm đánh dấu trong transaction; ROLLBACK TO sp huỷ chỉ phần sau sp, giữ nguyên phần trước.
  • Stored procedure / function trên nhiều engine có cơ chế bắt lỗi nội bộ (implicit savepoint) mà không abort transaction bên ngoài — cú pháp khác nhau tùy dialect.
  • SET TRANSACTION READ ONLY: block DML trong transaction, an toàn cho report query trên replica.
  • Long open transaction: giữ lock lâu, gây storage bloat (trên MVCC engine), cạn connection pool. Luôn đặt timeout ở tầng application và xử lý COMMIT/ROLLBACK trong finally block.
  • node-postgres: bắt buộc dùng pool.connect() cho transaction — không dùng pool.query('BEGIN') vì pool có thể chọn client khác cho mỗi lệnh.

11. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao dùng `pool.query('BEGIN')` rồi `pool.query('UPDATE ...')` trong node-postgres không tạo ra một transaction thực sự? Điều gì thực sự xảy ra?

pool.query() lấy client từ pool, chạy xong trả lại ngay. Ba lần gọi liên tiếp có thể nhận ba client khác nhau. BEGIN mở transaction trên client A. UPDATE chạy trên client B — autocommit, commit ngay, không thuộc transaction của A. COMMIT trên client C commit transaction rỗng. Nếu có lỗi, ROLLBACK không làm gì với change đã autocommit ở client B.

Fix: const client = await pool.connect() giữ một client cố định cho toàn bộ transaction. Gọi client.release() trong finally sau COMMIT hoặc ROLLBACK.

Q2
Khi nào nên dùng SAVEPOINT thay vì chỉ dùng BEGIN/COMMIT đơn thuần? Cho ví dụ từ bối cảnh import data.

SAVEPOINT hữu ích khi bạn muốn rollback một phần công việc mà không mất toàn bộ transaction. Phù hợp nhất cho: (1) bulk import theo batch — nếu một batch lỗi, rollback batch đó và giữ các batch đã thành công; (2) thao tác phức tạp có nhiều bước độc lập — lỗi ở bước sau không buộc phải bỏ bước trước; (3) PL/pgSQL function cần thử-và-rollback-nếu-lỗi mà không phá transaction ngoài.

Ví dụ: import 1000 task từ CSV, xử lý theo batch 100. Không dùng SAVEPOINT → một hàng dữ liệu xấu trong batch 8 rollback toàn bộ 700 task đã insert → phải import lại từ đầu. Dùng SAVEPOINT → rollback chỉ batch 8 (100 task), giữ 700 task đã commit → chỉ cần xử lý lại 100 hàng lỗi.

Q3
Phân biệt trạng thái 'idle in transaction' với 'idle' trong database connection. Vì sao 'idle in transaction' nguy hiểm hơn cho production?

idle: connection đang chờ lệnh mới, không có transaction nào đang mở. Không giữ lock. Hoàn toàn vô hại — connection pool thường ở trạng thái này.

idle in transaction: connection đã chạy BEGIN, đang ở giữa transaction, nhưng không có câu query nào đang thực thi. Transaction vẫn đang mở. Nguy hiểm vì: (1) nếu đã SELECT FOR UPDATE, row lock vẫn còn giữ → các session khác cần update row đó bị block vô thời hạn; (2) với engine dùng MVCC (như PostgreSQL), transaction mở giữ snapshot → engine không thể reclaim "dead tuple" → table bloat tích lũy; (3) connection bị giữ → pool cạn kiệt connection → các request mới bị reject.

Giải pháp: đặt timeout idle-in-transaction ở cấu hình engine (PostgreSQL: idle_in_transaction_session_timeout, MySQL: wait_timeout) và luôn xử lý COMMIT/ROLLBACK trong finally block ở tầng application.

Q4
Vì sao stored procedure / function có thể bắt lỗi mà không làm hỏng transaction bên ngoài? Cơ chế nào cho phép điều đó?

Nhiều engine (PostgreSQL PL/pgSQL, MySQL stored procedure với HANDLER, SQL Server T-TRY/CATCH) tự động tạo một implicit savepoint trước khi vào khối xử lý lỗi. Khi exception xảy ra, engine rollback về savepoint đó — hủy chỉ thay đổi trong khối xử lý lỗi, không hủy thay đổi trước đó. Handler chạy trong trạng thái "sạch" từ savepoint.

Transaction bên ngoài function vẫn nguyên vẹn vì chỉ phần trong khối lỗi bị rollback. Không có xử lý lỗi → exception propagate ra ngoài function → transaction bên ngoài vào trạng thái lỗi → mọi câu tiếp theo đều thất bại cho đến khi ROLLBACK. Đây là lý do nên bắt lỗi tại tầng function khi lỗi đó có thể xử lý được.

Q5
Bạn có một report query nặng chạy trên replica PostgreSQL. Vì sao nên bọc trong `BEGIN; SET TRANSACTION READ ONLY; ... COMMIT;` thay vì chạy trực tiếp? Lợi ích cụ thể là gì?

Chạy trực tiếp không có transaction: mỗi câu SELECT là một snapshot riêng. Nếu report gồm nhiều câu query (tổng theo project, tổng theo user, tổng theo status…), mỗi câu nhìn snapshot khác nhau → số liệu có thể inconsistent với nhau nếu có write xảy ra giữa các câu.

Bọc trong READ ONLY transaction: (1) toàn bộ report nhìn cùng một snapshot từ lúc BEGIN → số liệu nhất quán với nhau; (2) READ ONLY block mọi DML — bảo vệ chống lỗi code vô tình chạy UPDATE/DELETE trên replica (replica không cho phép write nhưng lỗi sẽ rõ ràng hơn); (3) trên PostgreSQL replica, READ ONLY transaction cho phép planner biết chắc không có mutation → một số tối ưu bổ sung có thể áp dụng; (4) documenting intent — người đọc code hiểu ngay đây là read-only workload, không phải transaction có DML.

Bài tiếp theo: Isolation levels & 4 anomalies — chỗ DB hay sai nhất

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên