Skip to content

Transactions: ACID Properties & Control

Trong thế giới thực, nhiều thao tác liên quan đến nhau phải thành công CẢ GÓI hoặc KHÔNG GÌ CẢ. Transaction là cơ chế đảm bảo điều này.


1️⃣ ACID: Bốn trụ cột của Transaction

ACID là viết tắt của 4 đặc tính mà một Transaction database PHẢI đảm bảo.

Ví dụ thực tế: Chuyển tiền ngân hàng

Bạn chuyển 1.000.000 VNĐ từ tài khoản A sang tài khoản B. Nghiệp vụ này có 2 bước:

  1. Trừ 1.000.000 từ tài khoản A.
  2. Cộng 1.000.000 vào tài khoản B.

Chuyện gì xảy ra nếu server crash sau bước 1 và trước bước 2?

Không có ACID: Tiền biến mất! A mất 1 triệu, B không nhận được gì. Khách hàng kiện.

Có ACID: Database tự động rollback bước 1, tiền quay về A. Không ai mất gì.

A - Atomicity (Tính nguyên tử)

"All or Nothing" - Tất cả hoặc Không gì cả.

Một transaction hoặc hoàn thành 100% mọi thao tác, hoặc không thay đổi gì cả.

sql
BEGIN;
    UPDATE accounts SET balance = balance - 1000000 WHERE id = 'A';
    UPDATE accounts SET balance = balance + 1000000 WHERE id = 'B';
COMMIT;
-- Nếu một trong hai UPDATE fail -> ROLLBACK toàn bộ

C - Consistency (Tính nhất quán)

"Database luôn đi từ trạng thái hợp lệ này sang trạng thái hợp lệ khác."

Sau khi transaction hoàn thành, tất cả constraints (CHECK, FK, UNIQUE) vẫn được thỏa mãn.

sql
-- Ví dụ: Tài khoản không được âm
ALTER TABLE accounts ADD CONSTRAINT check_positive_balance CHECK (balance >= 0);

BEGIN;
    UPDATE accounts SET balance = balance - 1000000 WHERE id = 'A'; -- A có 500k
    -- LỖI: check_positive_balance bị vi phạm -> Toàn bộ transaction ROLLBACK
COMMIT;

I - Isolation (Tính độc lập)

"Transaction của bạn không thấy dữ liệu dở dang của transaction khác."

Các transaction chạy song song phải cách ly với nhau. A đang chuyển tiền cho B, C không được thấy trạng thái giữa chừng (A đã trừ, B chưa cộng).

Timeline:
T1: BEGIN; UPDATE A; [CRASH ĐIỂM NÀY]; UPDATE B; COMMIT;
T2:                 SELECT balance FROM A; <- T2 thấy GÌ?

Với Isolation: T2 thấy balance CŨ của A (trước khi T1 bắt đầu).
Không Isolation: T2 thấy balance sau khi trừ nhưng chưa commit -> Dirty Read!

D - Durability (Tính bền vững)

"Đã COMMIT là đã ghi vào đĩa. Mất điện cũng không mất dữ liệu."

Sau khi COMMIT thành công, dữ liệu phải được persist vào đĩa cứng. Dù có restart server, dữ liệu vẫn còn đó.

Cơ chế: WAL (Write-Ahead Log)
1. Trước khi thay đổi data, ghi LOG vào đĩa trước.
2. Nếu server crash, database đọc LOG và "replay" lại các thay đổi đã COMMIT.

2️⃣ Transaction Control: BEGIN, COMMIT, ROLLBACK

Cú pháp cơ bản

sql
-- Bắt đầu transaction
BEGIN;  -- PostgreSQL, SQLite
-- hoặc START TRANSACTION; -- MySQL

-- Các câu lệnh SQL
UPDATE accounts SET balance = balance - 1000000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 1000000 WHERE id = 'B';

-- Kết thúc: Lưu tất cả thay đổi
COMMIT;

-- HOẶC: Hủy tất cả thay đổi
ROLLBACK;

Ví dụ thực tế với Python

python
import psycopg2

conn = psycopg2.connect(database="bank")
try:
    with conn.cursor() as cur:
        # Autocommit = False mặc định -> mọi thao tác trong transaction
        cur.execute("UPDATE accounts SET balance = balance - 1000000 WHERE id = 'A'")
        cur.execute("UPDATE accounts SET balance = balance + 1000000 WHERE id = 'B'")
        
        # Kiểm tra thêm business logic nếu cần
        cur.execute("SELECT balance FROM accounts WHERE id = 'A'")
        if cur.fetchone()[0] < 0:
            raise ValueError("Số dư không đủ!")
        
        conn.commit()  # Thành công -> COMMIT
        print("Chuyển tiền thành công!")
        
except Exception as e:
    conn.rollback()  # Lỗi -> ROLLBACK
    print(f"Lỗi: {e}")
    
finally:
    conn.close()

3️⃣ Savepoints: Rollback một phần Transaction

Đôi khi bạn không muốn rollback toàn bộ transaction, chỉ muốn undo một phần nhỏ.

Use Case: Import dữ liệu với Error Tolerance

Bạn import 1000 sản phẩm. Nếu 1 sản phẩm lỗi, bạn muốn bỏ qua nó và tiếp tục, không muốn rollback hết 999 sản phẩm kia.

sql
BEGIN;

-- Import product 1
SAVEPOINT before_product_1;
INSERT INTO products (sku, name, price) VALUES ('SKU-001', 'Product 1', 100000);
-- Nếu lỗi -> ROLLBACK TO SAVEPOINT before_product_1;

-- Import product 2
SAVEPOINT before_product_2;
INSERT INTO products (sku, name, price) VALUES ('SKU-002', 'Product 2', -50000);
-- Lỗi! Price âm vi phạm CHECK constraint!
ROLLBACK TO SAVEPOINT before_product_2; -- Chỉ undo product 2

-- Import product 3
SAVEPOINT before_product_3;
INSERT INTO products (sku, name, price) VALUES ('SKU-003', 'Product 3', 150000);
-- Thành công

COMMIT;  -- Product 1 và 3 được lưu, Product 2 bị bỏ qua

Python Code với Savepoints

python
def import_products(conn, products):
    """Import products với error tolerance."""
    success_count = 0
    error_count = 0
    
    with conn.cursor() as cur:
        for i, product in enumerate(products):
            savepoint_name = f"sp_product_{i}"
            try:
                cur.execute(f"SAVEPOINT {savepoint_name}")
                cur.execute("""
                    INSERT INTO products (sku, name, price) 
                    VALUES (%s, %s, %s)
                """, (product['sku'], product['name'], product['price']))
                success_count += 1
                
            except Exception as e:
                cur.execute(f"ROLLBACK TO SAVEPOINT {savepoint_name}")
                error_count += 1
                print(f"Lỗi import {product['sku']}: {e}")
        
        conn.commit()  # Commit tất cả những gì thành công
    
    return success_count, error_count

💡 HPN Pro Tip: Savepoint và Nested Transactions

Một số ORM (như Django) dùng Savepoints để mô phỏng "Nested Transactions". Khi bạn gọi transaction.atomic() bên trong một transaction khác, Django tạo Savepoint thay vì BEGIN mới.


4️⃣ Error Handling: Server Crash giữa Transaction?

Câu hỏi kinh điển: Điều gì xảy ra nếu server tắt đột ngột SAU UPDATE accounts SET balance = balance - 1000000 nhưng TRƯỚC COMMIT?

Câu trả lời: Depends on WAL (Write-Ahead Log)

Crash Scenario Timeline:
[BEGIN] -> [UPDATE A] -> [UPDATE B] -> 💥 CRASH 💥 -> [COMMIT never reached]

Khi server restart:
1. Database đọc WAL (Write-Ahead Log).
2. Thấy transaction chưa COMMIT.
3. Tự động ROLLBACK transaction đó.
4. Dữ liệu quay về trạng thái TRƯỚC khi BEGIN.

Cơ chế WAL (Write-Ahead Log)

Giải thích:

  1. Trước khi thay đổi data thực sự, database ghi log vào WAL.
  2. WAL được fsync (ghi xuống đĩa ngay lập tức).
  3. Nếu crash trước COMMIT: Log không có record COMMIT -> Rollback.
  4. Nếu crash sau COMMIT nhưng trước ghi Data Files: Log có COMMIT -> Database replay.

Các trạng thái crash recovery

Thời điểm CrashDữ liệu LogHành động Recovery
Trước BEGINKhông có logKhông cần làm gì
Giữa BEGINCOMMITLog có changes, không có COMMITUNDO (Rollback)
Sau COMMITLog có changes, COMMITREDO (Replay)

⚠️ Quan trọng: fsync và Data Loss

Một số setup "performance tuning" tắt fsync (synchronous_commit=off trong PostgreSQL) để tăng tốc độ. Điều này có nghĩa: Dù đã COMMIT, nếu crash trong vài ms sau, có thể mất dữ liệu!

Chỉ tắt fsync cho dữ liệu không quan trọng (logs, analytics). KHÔNG BAO GIỜ cho transactions tài chính.


5️⃣ Autocommit Mode

Mặc định, nhiều database client chạy ở chế độ Autocommit = ON. Điều này có nghĩa:

  • Mỗi câu lệnh SQL tự động wrap trong một transaction riêng.
  • Mỗi câu lệnh tự động COMMIT ngay sau khi hoàn thành.
sql
-- Autocommit ON (mặc định)
UPDATE accounts SET balance = 0 WHERE id = 'A';  -- Tự động COMMIT
UPDATE accounts SET balance = 0 WHERE id = 'B';  -- Tự động COMMIT riêng

-- Nếu câu 2 fail, câu 1 VẪN ĐÃ COMMIT!

Best Practice: Luôn dùng explicit transaction (BEGIN...COMMIT) cho các thao tác liên quan đến nhau.


Tổng kết

Thuật ngữMô tả
AtomicityAll or Nothing - Toàn bộ hoặc Không gì
ConsistencyDatabase luôn ở trạng thái hợp lệ
IsolationTransaction không thấy dữ liệu dở dang của nhau
DurabilityCOMMIT = persist vĩnh viễn
SAVEPOINTĐiểm checkpoint để rollback một phần
WALCơ chế đảm bảo Durability và Crash Recovery

💡 HPN Pro Tip: Đừng giữ Transaction quá lâu

Transaction mở càng lâu, lock càng nhiều row, performance càng tệ. Nguyên tắc: Transaction nên kết thúc trong vài trăm ms, KHÔNG BAO GIỜ chờ user input trong transaction.