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.
FOREIGN KEY mặc định TẮT trong SQLiteFOREIGN 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.
bank_accounts (bấm nút "1️⃣" để tạo)
| Cột | Ràng buộc |
|---|---|
| account_id | INTEGER PRIMARY KEY |
| owner_name | NOT NULL |
| UNIQUE | |
| balance | NOT NULL, CHECK (balance >= 0) |
| branch_id | REFERENCES branches(branch_id) |
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
BEGIN;
UPDATE orders SET status = 'shipped'
WHERE order_id = 1;
-- CHƯA COMMIT — dòng order_id=1
-- đang bị khoá (row lock)
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ả
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!
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.
READ UNCOMMITTED →
READ COMMITTED → REPEATABLE READ → SERIALIZABLE. 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
COMMIT — COMMIT 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;?