Quản lý Transaction trong SQL: Đảm bảo Tính Nhất quán và Toàn vẹn Dữ liệu

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Ngày Tôi Chứng Kiến Tiền Chuyển Khoản “Bốc Hơi”

Vài năm trước, tôi đang xây dựng module thanh toán cho một sàn thương mại điện tử nhỏ. Logic trông có vẻ đơn giản: trừ số dư tài khoản người mua, rồi cộng vào tài khoản người bán. Hai câu lệnh UPDATE là xong. Vậy mà một buổi chiều, server bị giật giữa chừng đúng lúc đang chạy hai câu truy vấn đó. Số dư người mua đã bị trừ. Người bán thì chẳng nhận được xu nào. Tiền cứ thế… biến mất.

Cái lỗi đó dạy tôi về transaction trong cơ sở dữ liệu nhiều hơn bất kỳ cuốn sách giáo khoa nào. Nếu bạn đang viết SQL tác động lên nhiều hàng — đặc biệt là dữ liệu tài chính, tồn kho, hay thông tin người dùng — transaction không phải thứ tùy chọn. Đó là yêu cầu bắt buộc.

Nguyên Nhân Gốc Rễ: Tại Sao Dữ Liệu Bị Hỏng Khi Không Có Transaction

Vấn đề không phải do SQL viết sai. Cơ sở dữ liệu thực thi từng câu lệnh một, và thực tế không chờ đợi lịch sự giữa các câu lệnh đó.

Rất nhiều thứ có thể xảy ra giữa hai câu lệnh liên quan:

  • Application server bị crash giữa chừng
  • Network timeout làm mất kết nối
  • Một người dùng khác đọc phải dữ liệu đang ở trạng thái dở
  • Câu lệnh thứ 2 vi phạm ràng buộc trong khi câu lệnh thứ 1 đã được commit

Không có transaction, mỗi câu lệnh SQL sẽ tự động commit ngay lập tức. Một khi đã xong là vĩnh viễn. Không có cách “hoàn tác” nếu câu lệnh tiếp theo thất bại.

Tôi đã làm việc với MySQL, PostgreSQLMongoDB qua nhiều dự án khác nhau. Mỗi loại có điểm mạnh riêng. Nhưng riêng về đảm bảo transaction, sự tuân thủ ACID nghiêm ngặt của PostgreSQL đã cứu tôi nhiều lần không đếm xuể. MySQL (InnoDB) cũng ổn — chỉ cần chủ động quản lý ranh giới transaction rõ ràng. MongoDB bổ sung multi-document transaction sau này, hoạt động được, nhưng cảm giác như thứ được thêm vào sau hơn là một nguyên tắc thiết kế cốt lõi.

ACID Thực Sự Có Nghĩa Gì (Không Rườm Rà Như Sách Giáo Khoa)

ACID định nghĩa “đáng tin cậy” thực sự có nghĩa gì với một transaction. Bỏ qua lý thuyết — đây là từng thuộc tính gắn với một lỗi thực tế bạn có thể đã gặp:

Atomicity — Tất cả hoặc Không có gì

Hoặc mọi câu lệnh trong transaction đều thành công, hoặc không có gì được thực thi cả. Nếu bước 3 trong 5 bước thất bại, bước 1 và 2 tự động bị rollback. Lỗi thanh toán của tôi chính là lỗi atomicity — hai câu UPDATE không được gói trong một đơn vị atomic duy nhất.

Consistency — Các Quy tắc Luôn Được Đảm bảo

Cơ sở dữ liệu chuyển từ trạng thái hợp lệ này sang trạng thái hợp lệ khác. Các ràng buộc, khóa ngoại và quy tắc nghiệp vụ được kiểm tra trên toàn bộ transaction — không chỉ từng câu lệnh riêng lẻ.

Isolation — Các Transaction Không Nhìn Thấy Công Việc Của Nhau

Các transaction chạy đồng thời hoạt động như thể chúng chạy tuần tự. Một transaction không thể đọc các thay đổi chưa được commit của transaction khác. (Mức độ đảm bảo cụ thể phụ thuộc vào isolation level — sẽ nói thêm ngay sau.)

Durability — Dữ Liệu Đã Commit Tồn Tại Qua Sự Cố

Một khi đã COMMIT, dữ liệu được ghi xuống đĩa. Dù server chết đúng một mili giây sau đó, dữ liệu vẫn ở đó khi khởi động lại.

Cú Pháp Transaction Cơ Bản

Các lệnh cốt lõi giống nhau trên cả MySQL lẫn PostgreSQL:

-- Bắt đầu một transaction
BEGIN;

-- Các câu lệnh SQL của bạn
UPDATE accounts SET balance = balance - 500 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 500 WHERE user_id = 2;

-- Nếu mọi thứ ổn
COMMIT;

-- Hoặc nếu có lỗi xảy ra
ROLLBACK;

Trong code ứng dụng, bạn quản lý việc này theo cách thủ công. Đây là ví dụ Python dùng psycopg2 với PostgreSQL:

import psycopg2

conn = psycopg2.connect("dbname=mydb user=postgres")
conn.autocommit = False  # Tắt autocommit — transaction phải quản lý thủ công

try:
    cur = conn.cursor()

    # Trừ tiền từ người gửi
    cur.execute(
        "UPDATE accounts SET balance = balance - %s WHERE user_id = %s AND balance >= %s",
        (500, 1, 500)
    )
    if cur.rowcount == 0:
        raise ValueError("Số dư không đủ hoặc không tìm thấy người dùng")

    # Cộng tiền cho người nhận
    cur.execute(
        "UPDATE accounts SET balance = balance + %s WHERE user_id = %s",
        (500, 2)
    )
    if cur.rowcount == 0:
        raise ValueError("Không tìm thấy người nhận")

    conn.commit()
    print("Chuyển khoản thành công")

except Exception as e:
    conn.rollback()
    print(f"Chuyển khoản thất bại, đã rollback: {e}")

finally:
    conn.close()

Chú ý dòng conn.autocommit = False — đây là điều quan trọng. Nếu không có nó, psycopg2 sẽ bọc mỗi câu lệnh trong một transaction ngầm riêng, làm mất đi toàn bộ ý nghĩa.

SAVEPOINT — Rollback Một Phần Khi Cần Thiết

Đôi khi bạn muốn rollback một phần của transaction mà không mất tất cả. SAVEPOINT cho phép bạn đặt các điểm kiểm tra có tên trong một transaction:

BEGIN;

INSERT INTO orders (user_id, total) VALUES (1, 1500);

SAVEPOINT after_order;

INSERT INTO order_items (order_id, product_id, qty) VALUES (101, 5, 2);

-- Ôi, sản phẩm 5 đã hết hàng
ROLLBACK TO SAVEPOINT after_order;

-- Thử sản phẩm thay thế
INSERT INTO order_items (order_id, product_id, qty) VALUES (101, 7, 2);

COMMIT;

SAVEPOINT hữu ích nhất trong xử lý hàng loạt. Khi tôi đang insert 500 hàng và gặp một bản ghi lỗi ở hàng 347, tôi muốn bỏ qua hàng đó — không phải hủy toàn bộ batch. Rollback về savepoint đặt trước hàng lỗi, ghi log lại, rồi tiếp tục.

Isolation Level: Chọn Mức Đánh Đổi Phù Hợp

Isolation level là nơi hầu hết lập trình viên bị bất ngờ. Có bốn cấp độ chuẩn, mỗi cấp đánh đổi giữa độ chính xác của dữ liệu và hiệu năng đồng thời:

READ UNCOMMITTED

Transaction có thể đọc các thay đổi chưa commit từ phiên khác — gọi là “dirty read”. Đây là lý do nguy hiểm: Thread A cập nhật số dư tài khoản từ $500 xuống $0 nhưng chưa commit. Thread B đọc được $0 và chặn một lệnh rút tiền. Thread A sau đó rollback. Thread B vừa chặn một lệnh rút tiền hợp lệ dựa trên dữ liệu chưa bao giờ thực sự tồn tại. Cấp độ nhanh nhất, nhưng tôi chưa bao giờ dùng ngoài việc benchmark.

READ COMMITTED (mặc định của PostgreSQL)

Chỉ đọc dữ liệu đã được commit. Dirty read được ngăn chặn. Nhưng bạn vẫn có thể gặp “non-repeatable read” — đọc cùng một hàng hai lần trong một transaction có thể trả về kết quả khác nhau nếu transaction khác đã commit giữa hai lần đọc của bạn.

REPEATABLE READ (mặc định của MySQL InnoDB)

Các hàng bạn đã đọc bị khóa cho đến khi transaction kết thúc. Non-repeatable read được ngăn chặn. Với hầu hết các thao tác tài chính, đây là mức mặc định phù hợp.

SERIALIZABLE

Cô lập hoàn toàn — các transaction hoạt động như thể chúng chạy tuần tự từng cái một. Ngăn chặn mọi bất thường kể cả phantom read. Dùng cho các nghiệp vụ đối soát tài chính quan trọng hoặc thao tác audit. Thông lượng giảm rõ rệt khi tải cao, vì vậy đừng áp dụng toàn cục.

-- Đặt isolation level cho transaction hiện tại (PostgreSQL)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- Hoặc đặt cho phiên làm việc (MySQL)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

Những Lỗi Phổ Biến Khi Dùng Transaction

Transaction Chạy Quá Lâu

Một transaction đang mở sẽ giữ khóa. Trên một instance PostgreSQL bận xử lý 500 request/phút, một transaction bị để mở chỉ 30 giây thôi cũng có thể tạo ra hàng đợi khóa với 50+ kết nối bị chặn. Write-ahead log cứ thế tăng trưởng không ngừng. Các truy vấn khác chồng chất chờ đợi, kéo theo hiệu suất toàn bộ hệ thống đi xuống.

Hãy giữ transaction ngắn — mở muộn, commit sớm. Chuyển các tác vụ tính toán chậm ra ngoài ranh giới transaction:

# Sai: Gọi API bên trong transaction
conn.autocommit = False
cur.execute("UPDATE orders SET status = 'processing' WHERE id = %s", (order_id,))
response = requests.post(payment_gateway_url, data=payload)  # Có thể mất 3+ giây!
cur.execute("UPDATE orders SET status = 'paid' WHERE id = %s", (order_id,))
conn.commit()

# Đúng: Giữ transaction chỉ bao quanh các thao tác DB
response = requests.post(payment_gateway_url, data=payload)  # Ngoài transaction

conn.autocommit = False
if response.status_code == 200:
    cur.execute("UPDATE orders SET status = 'paid' WHERE id = %s", (order_id,))
    conn.commit()
else:
    cur.execute("UPDATE orders SET status = 'failed' WHERE id = %s", (order_id,))
    conn.commit()

Quên Xử Lý Lỗi Trong Code Ứng Dụng

Nếu code ném exception mà bạn không bao giờ gọi ROLLBACK, transaction sẽ ở trạng thái mở cho đến khi kết nối bị đóng. Trong connection pool, kết nối đó được tái sử dụng cho request tiếp theo — ở trạng thái lỗi. Luôn bọc logic transaction trong try/except/finally, hoặc dùng context manager:

# Dùng context manager trong Python (psycopg2)
with psycopg2.connect(dsn) as conn:
    with conn.cursor() as cur:
        cur.execute("UPDATE ...")
        cur.execute("INSERT ...")
    # conn.__exit__ gọi commit() khi thành công, rollback() khi có exception

Nguyên Tắc Thiết Kế Transaction Thực Sự Hiệu Quả Trên Môi Trường Production

Sau khi đưa vào production nhiều hệ thống có yêu cầu transaction, tôi đúc kết lại thành năm nguyên tắc:

  1. Một thao tác nghiệp vụ = một transaction. Hành động “chuyển tiền” là một transaction, không phải hai commit riêng biệt.
  2. Ngắn gọn và tập trung. Gom 50 lệnh insert vào một transaction — nhưng đừng trộn các thao tác không liên quan chỉ vì tiện.
  3. Chọn isolation level phù hợp với rủi ro. Hầu hết CRUD dùng READ COMMITTED là ổn. Các phép tổng hợp hay tính toán tài chính cần REPEATABLE READ hoặc cao hơn.
  4. Luôn xử lý rollback tường minh trong code. Đừng trông chờ vào việc dọn dẹp kết nối để làm thay.
  5. Kiểm thử các trường hợp lỗi. Mô phỏng crash giữa chừng transaction trong môi trường phát triển. Tắt tiến trình sau câu lệnh thứ 2 trong số 3 câu. Nếu bạn chưa bao giờ kiểm chứng điều gì xảy ra, bạn thực sự chưa biết transaction của mình có đúng không.

Lỗi thanh toán tôi gặp hồi đầu sự nghiệp chỉ tốn vài giờ sửa dữ liệu thủ công và một email chăm sóc khách hàng căng thẳng. Nó có thể đã tệ hơn rất nhiều. Transaction là tính năng SQL mà bạn không nên đưa lên production khi thiếu — không phải tối ưu hóa, mà là đảm bảo tính đúng đắn.

Share: