Mọi bài trước giả định dữ liệu luôn đúng và mỗi câu lệnh chạy độc lập. Thực tế: nhiều thao tác phải xảy ra cùng lúc hoặc không xảy ra gì cả (chuyển khoản ngân hàng: trừ tiền người này, cộng tiền người kia — không thể chỉ làm 1 nửa), và với dữ liệu thật, nhiều người/tiến trình cùng đọc/ghi đồng thời. Đây là lý do có transaction (giao dịch) và chuẩn ACID (Atomicity, Consistency, Isolation, Durability). Bài này minh hoạ 3/4 chữ cái đó thật trong trình duyệt (Atomicity, Consistency qua ràng buộc, 1 phần Durability) — riêng chữ I (Isolation) với nhiều kết nối tranh chấp thật thì SQLite-WASM đơn tiến trình không thể demo nổi, bắt buộc cần lab Docker PostgreSQL ở mục 5.

1. BEGIN/COMMIT/ROLLBACK: Đơn Vị Nguyên Tử Của Thay Đổi

Mặc định, SQLite chạy ở chế độ autocommit: mỗi câu lệnh riêng lẻ tự động là 1 transaction hoàn chỉnh, commit ngay khi chạy xong. BEGIN tắt chế độ đó, gom nhiều câu lệnh vào 1 đơn vị nguyên tử — hoặc tất cả cùng có hiệu lực (COMMIT), hoặc không câu nào có hiệu lực (ROLLBACK). Tình huống kinh doanh kinh điển: chuyển khoản ngân hàng — trừ tiền tài khoản A, cộng tiền tài khoản B, cả 2 thao tác PHẢI cùng thành công hoặc cùng thất bại, không được phép chỉ trừ mà không cộng (tiền "bốc hơi"):

BEGIN;
UPDATE bank_accounts SET balance = balance - 300 WHERE account_id = 1;
UPDATE bank_accounts SET balance = balance + 300 WHERE account_id = 2;
-- Chưa COMMIT — số dư đã đổi trong bộ nhớ (1000→700, 500→800) nhưng
-- vẫn có thể huỷ toàn bộ bằng ROLLBACK, quay về đúng 1000/500 ban đầu.

Đo thật trong sân chơi bên dưới: sau BEGIN + 2 UPDATE, số dư đã đổi thành 700/800 — nhưng bấm ROLLBACK ngay lập tức đưa về đúng 1000/500 như chưa có gì xảy ra. Đây chính là Atomicity (tính nguyên tử) và một phần Durability (bền vững — 1 khi đã COMMIT thật, thay đổi tồn tại vĩnh viễn kể cả khi mất điện/crash ngay sau đó, nhờ file nhật ký rollback journal/WAL).

SAVEPOINT tạo 1 "điểm lưu" bên trong transaction lớn hơn, cho phép huỷ 1 phần mà không huỷ toàn bộ — hữu ích khi 1 giao dịch phức tạp gồm nhiều bước, và bạn chỉ muốn huỷ bước cuối nếu nó thất bại:

BEGIN;
UPDATE bank_accounts SET balance = balance - 100 WHERE account_id = 1; -- giữ lại
SAVEPOINT sp1;
UPDATE bank_accounts SET balance = balance + 100 WHERE account_id = 2; -- sắp huỷ riêng

ROLLBACK TO sp1;  -- chỉ huỷ phần SAU savepoint (TK2 không được cộng)
COMMIT;           -- vẫn giữ phần TRƯỚC savepoint (TK1 vẫn bị trừ 100)
-- Kết quả thật: TK1 = 900 (bị trừ, giữ), TK2 = 500 (không đổi, đã huỷ riêng)

2. Atomicity Thật Sự Nghĩa Là Gì? (Không Phải "Lỗi 1 Câu Thì Tự Rollback Cả Giao Dịch")

Đây là hiểu lầm phổ biến nhất về transaction: nhiều người nghĩ nếu 1 câu lệnh trong BEGIN...COMMIT vi phạm ràng buộc, SQLite sẽ tự động huỷ toàn bộ giao dịch. Sai — đã kiểm chứng thật: hành vi mặc định chỉ huỷ riêng câu lệnh gây lỗi, các câu lệnh trước đó đã thành công vẫn tồn tại và COMMIT vẫn chạy được bình thường:

BEGIN;
INSERT INTO bank_accounts (account_id, owner_name, email, balance, branch_id)
  VALUES (3, 'Lê Văn Cường', '[email protected]', 200, 1); -- ① thành công
INSERT INTO bank_accounts (account_id, owner_name, email, balance, branch_id)
  VALUES (4, 'Phạm Thị Dung', '[email protected]', 300, 1);  -- ② LỖI: email trùng TK1!
-- → Lỗi: UNIQUE constraint failed: bank_accounts.email

COMMIT;  -- vẫn CHẠY ĐƯỢC bình thường, không báo lỗi gì thêm!
-- Kết quả thật: TK3 (Lê Văn Cường) ĐƯỢC TẠO, TK4 (Phạm Thị Dung) KHÔNG tồn tại

Muốn có atomicity thật sự (tất cả-hoặc-không-gì cho cả khối lệnh), ứng dụng phải tự bắt lỗi và gọi ROLLBACK tường minh — không thể trông chờ engine tự làm việc đó. Đã kiểm chứng bằng kịch bản rút quá số dư (vi phạm CHECK balance >= 0):

BEGIN;
UPDATE bank_accounts SET balance = balance - 100 WHERE account_id = 1;    -- ① hợp lệ
UPDATE bank_accounts SET balance = balance - 9999 WHERE account_id = 2;   -- ② LỖI: âm số dư!
-- → Lỗi: CHECK constraint failed: balance >= 0

ROLLBACK;  -- cách ĐÚNG để đảm bảo atomic thật: huỷ TOÀN BỘ, kể cả câu ① đã thành công
-- Kết quả thật: cả 2 tài khoản KHÔNG đổi gì — atomic đúng nghĩa

Pattern chuẩn trong code ứng dụng thật (giả sử ngôn ngữ có try/catch): mở BEGIN, chạy các câu lệnh, nếu bất kỳ câu nào ném lỗi thì bắt lỗi và gọi ROLLBACK ngay, chỉ gọi COMMIT khi mọi câu lệnh đều chạy trót lọt.

3. Ràng Buộc Toàn Vẹn: NOT NULL, UNIQUE, CHECK, FOREIGN KEY

4 loại ràng buộc trên chính là Consistency (tính nhất quán) trong ACID — đảm bảo dữ liệu luôn thoả các quy tắc kinh doanh, bất kể transaction có bị lỗi giữa chừng hay không:

Ràng buộc Bảo vệ điều gì Ví dụ thực tế đã kiểm chứng
NOT NULL Cột bắt buộc phải có giá trị owner_name để trống → lỗi NOT NULL constraint failed: bank_accounts.owner_name
UNIQUE Không 2 dòng nào trùng giá trị cột này 2 tài khoản cùng email → lỗi UNIQUE constraint failed: bank_accounts.email
CHECK Giá trị phải thoả 1 biểu thức logic tuỳ ý CHECK (balance >= 0) — rút quá số dư → lỗi CHECK constraint failed: balance >= 0
FOREIGN KEY Giá trị phải tồn tại thật ở bảng được tham chiếu Tạo tài khoản với branch_id không tồn tại → xem mục dưới

3 tình huống CHECK thực tế khác ngoài số dư ngân hàng, cùng 1 công cụ giải nhiều bài toán: CHECK (stock_quantity >= 0) chặn bán vượt tồn kho, CHECK (unit_price > 0) chặn nhập giá 0 hoặc âm do lỗi nhập liệu, CHECK (quantity > 0) trên order_items chặn dòng chi tiết đơn hàng số lượng 0 vô nghĩa.

⚠️ Cạm bẫy: FOREIGN KEY mặc định TẮT trong SQLite
Khác PostgreSQL (luôn enforce FK), SQLite mặc định KHÔNG kiểm tra ràng buộc FOREIGN KEY — kiểm chứng thật: PRAGMA foreign_keys; trả về 0 ngay cả khi bảng đã khai báo REFERENCES. Nghĩa là tạo 1 tài khoản với branch_id = 99999 (chi nhánh không tồn tại) vẫn thành công bình thường — 1 "tài khoản ma" trỏ tới chi nhánh ma! Phải bật tường minh mỗi kết nối mới: PRAGMA foreign_keys = ON; — sau đó cùng thao tác đó mới bị chặn đúng với lỗi FOREIGN KEY constraint failed. Lý do lịch sử: FK được thêm vào SQLite sau, mặc định tắt để không phá vỡ database cũ đã có dữ liệu vi phạm ẩn từ trước.

Sân Chơi Tương Tác: Demo Rollback Đầy Đủ Khi Vi Phạm

Bấm lần lượt 1️⃣ → 7️⃣c để tái tạo đúng mọi con số ở trên: chuyển khoản + rollback, savepoint bán phần, vi phạm UNIQUE/CHECK, và bật/tắt FOREIGN KEY.

🗄️ SQL Workbench (TechMart + bank_accounts/branches)

bank_accounts (bấm nút "1️⃣" để tạo)

Cột Ràng buộc
account_id INTEGER PRIMARY KEY
owner_name NOT NULL
email UNIQUE
balance NOT NULL, CHECK (balance >= 0)
branch_id REFERENCES branches(branch_id)
Bấm "1️⃣" bên dưới để bắt đầu...
Đang tải SQLite-WASM engine...
sql-transaction-acid.js

4. Thực Hành Với Docker 🐳: Lock Contention & Isolation Level Thật

Chữ I (Isolation) trong ACID chỉ thật sự "sống động" khi có từ 2 kết nối trở lên tranh chấp cùng lúc — điều SQLite-WASM (1 tiến trình, 1 kết nối đơn) không thể demo nổi. Dùng container techmart-pg đã dựng ở Bài 2, mở 2 terminal riêng biệt, mỗi terminal 1 phiên psql độc lập:

-- Cả 2 terminal đều chạy:
psql -h localhost -U postgres -d techmart

4.1 Lock Contention: Terminal 2 Phải Đợi Terminal 1

🖥️ Terminal 1
BEGIN;
UPDATE orders SET status = 'shipped'
WHERE order_id = 1;
-- CHƯA COMMIT — dòng order_id=1
-- đang bị khoá (row lock)
🖥️ Terminal 2
BEGIN;
UPDATE orders SET status = 'cancelled'
WHERE order_id = 1;
-- TREO ở đây! Phải đợi Terminal 1
-- COMMIT hoặc ROLLBACK mới chạy tiếp

Quay lại Terminal 1, gõ COMMIT; (hoặc ROLLBACK;) — Terminal 2 sẽ ngay lập tức chạy tiếp (nếu Terminal 1 rollback, Terminal 2 áp dụng đúng giá trị nó định ghi; nếu Terminal 1 commit, tuỳ isolation level mà Terminal 2 có thể ghi đè lên hoặc nhận lỗi serialization — xem mục 4.3). Đây là bằng chứng trực quan nhất về lock: 2 transaction không thể cùng sửa 1 dòng đồng thời, dù chạy trên 2 kết nối hoàn toàn độc lập.

4.2 Non-Repeatable Read: Cùng 1 Câu SELECT, 2 Lần Khác Kết Quả

🖥️ Terminal 1 (READ COMMITTED — mặc định)
BEGIN;
SELECT total_amount FROM orders
WHERE order_id = 2; -- ví dụ: 249.99

-- (đợi Terminal 2 commit ở giữa đây)

SELECT total_amount FROM orders
WHERE order_id = 2; -- KHÁC lần trước!
🖥️ Terminal 2
UPDATE orders SET total_amount = 199.99
WHERE order_id = 2;
COMMIT;
-- Chạy xong NGAY giữa 2 lần SELECT
-- của Terminal 1

Ở mức READ COMMITTED (mặc định của PostgreSQL), 2 lần SELECT y hệt nhau trong cùng 1 transaction của Terminal 1 có thể trả về 2 giá trị khác nhau — vì Terminal 1 chỉ tránh đọc dữ liệu chưa commit (không có dirty read), nhưng vẫn nhìn thấy dữ liệu Terminal 2 vừa commit xong ở lần đọc thứ 2. Đây gọi là non-repeatable read. Đổi BEGIN; của Terminal 1 thành BEGIN ISOLATION LEVEL REPEATABLE READ; rồi lặp lại đúng kịch bản — lần này cả 2 lần SELECT của Terminal 1 trả về cùng 1 giá trị (giá trị tại thời điểm BEGIN), vì REPEATABLE READ giữ nguyên 1 "snapshot" dữ liệu suốt cả transaction, bất kể transaction khác commit gì ở giữa.

ℹ️ Vì sao không demo được "dirty read" thật trong PostgreSQL?
Chuẩn SQL định nghĩa 4 mức isolation, từ yếu nhất: READ UNCOMMITTEDREAD COMMITTEDREPEATABLE READSERIALIZABLE. Dirty read (đọc được dữ liệu chưa commit của transaction khác) chỉ có thể xảy ra ở mức yếu nhất READ UNCOMMITTED — nhưng PostgreSQL không thực sự cài đặt mức này: khai báo BEGIN ISOLATION LEVEL READ UNCOMMITTED; vẫn được chấp nhận về cú pháp, nhưng PostgreSQL âm thầm dùng hành vi READ COMMITTED thay thế — dirty read không bao giờ xảy ra trong PostgreSQL ở bất kỳ isolation level nào, nhờ kiến trúc MVCC (Multi-Version Concurrency Control) của nó luôn giữ phiên bản dữ liệu đã commit ổn định cho mỗi transaction đọc. Đây là ưu điểm an toàn của PostgreSQL so với 1 số engine khác từng cho phép dirty read thật ở mức thấp nhất.

4.3 SERIALIZABLE: An Toàn Nhất, Nhưng Cần Code Tự Thử Lại

Mức mạnh nhất, SERIALIZABLE, đảm bảo kết quả cuối cùng giống hệt như thể mọi transaction chạy tuần tự, không hề chồng chéo — dù thực tế chúng chạy đồng thời. Cái giá phải trả: nếu PostgreSQL phát hiện 2 transaction SERIALIZABLE xung đột theo cách không thể sắp xếp tuần tự an toàn, nó sẽ chủ động huỷ 1 trong 2 bằng lỗi:

ERROR:  could not serialize access due to concurrent update
HINT:  The transaction might succeed if retried.

Đây không phải lỗi hệ thống — là hành vi ĐÚNG THIẾT KẾ, buộc code ứng dụng phải có logic tự động thử lại (retry) giao dịch khi gặp lỗi này. Đánh đổi rõ ràng: an toàn dữ liệu tuyệt đối hơn, nhưng ứng dụng phức tạp hơn (bắt buộc phải viết retry logic), và throughput thấp hơn khi tranh chấp nhiều. Đây là lý do READ COMMITTED vẫn là mặc định thực dụng cho phần lớn ứng dụng, còn SERIALIZABLE chỉ dùng cho nghiệp vụ cực kỳ nhạy cảm (giao dịch tài chính, đặt vé/đặt chỗ giới hạn số lượng).

Trắc nghiệm ôn tập

Câu 1: Sau BEGIN + 2 UPDATE chuyển khoản (chưa COMMIT), số dư đã đổi thành 700/800 khi SELECT. Bấm ROLLBACK thì điều gì xảy ra?

Trắc nghiệm ôn tập

Câu 2: Trong BEGIN, insert TK3 thành công, insert TK4 lỗi (email trùng), rồi COMMITCOMMIT chạy được bình thường. Kết quả cuối cùng có TK3 không?

Trắc nghiệm ôn tập

Câu 3: Muốn đảm bảo atomicity thật sự (tất cả-hoặc-không-gì) cho cả khối lệnh, ứng dụng cần làm gì?

Trắc nghiệm ôn tập

Câu 4: Tạo tài khoản với branch_id = 99999 (chi nhánh không tồn tại) — khi PRAGMA foreign_keys còn ở giá trị mặc định, điều gì xảy ra?

Trắc nghiệm ôn tập

Câu 5: Vì sao lock contention (Terminal 2 phải đợi Terminal 1) không thể demo được bằng SQLite-WASM trong trình duyệt?

Trắc nghiệm ôn tập

Câu 6: Vì sao không thể demo "dirty read" thật trong PostgreSQL, dù khai báo BEGIN ISOLATION LEVEL READ UNCOMMITTED;?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 12: Trigger, View & Virtual Table Bài 10: SQLite Internals — B-Tree & File Format Quay lại Lộ trình Series SQL