Bài MVCC hé lộ rằng PostgreSQL giữ nhiều phiên bản hàng cho các giao dịch khác nhau. Giao dịch (transaction) là khái niệm trung tâm của cơ sở dữ liệu, và chữ đầu trong ACID — Atomicity (tính nguyên tử) — là lời hứa quan trọng nhất: một nhóm thao tác hoặc thành công trọn vẹn, hoặc không có gì xảy ra. Bài này đo lời hứa đó bằng một phép chuyển tiền cố tình hỏng giữa chừng, và vấp đúng cái lỗi kinh điển mà mọi lập trình viên đều mắc một lần.
Nguyên tử: tất cả hoặc không gì
Một giao dịch gộp nhiều lệnh thành một đơn vị không thể chia cắt, mở bằng BEGIN và đóng bằng COMMIT. Atomicity đảm bảo: hoặc mọi lệnh trong đó thành công và có hiệu lực cùng lúc (COMMIT), hoặc không lệnh nào có hiệu lực (ROLLBACK — hoàn tác sạch). Không bao giờ có trạng thái "nửa chừng".
Ví dụ kinh điển là chuyển tiền: chuyển 500 từ tài khoản An sang Bình là hai bước — trừ 500 khỏi An, cộng 500 vào Bình. Hai bước này phải cùng thành công hoặc cùng thất bại. Nếu chỉ bước một chạy (trừ An) rồi hệ thống lỗi trước bước hai (cộng Bình), 500 đồng bốc hơi — tài khoản An mất tiền mà Bình chẳng nhận được. Giao dịch tồn tại để chuyện đó không bao giờ xảy ra: bọc cả hai bước trong BEGIN...COMMIT, nếu bước nào hỏng thì cả cụm bị hoàn tác.
Đo: 500 đồng bốc hơi, rồi được cứu
Tôi tạo hai tài khoản, An và Bình, mỗi người 1000 — tổng 2000. Rồi chuyển 500 từ An sang Bình bằng hai UPDATE, cố tình cho lỗi (chia cho 0) xảy ra giữa hai bước, và đo tổng số dư ở ba cách chạy:
| Cách chạy | An | Bình | Tổng | Kết quả |
|---|---|---|---|---|
Autocommit (không BEGIN) |
500 | 1000 | 1500 | mất 500 |
BEGIN, lỗi → ROLLBACK |
1000 | 1000 | 2000 | nguyên vẹn |
BEGIN, đủ → COMMIT |
500 | 1500 | 2000 | chuyển xong |
Dòng đầu là thảm họa: chạy hai UPDATE không bọc giao dịch. Lệnh trừ An chạy và tự commit ngay; rồi lệnh cộng Bình gặp lỗi và không chạy. Kết quả: An còn 500, Bình vẫn 1000, tổng còn 1500 — 500 đồng biến mất khỏi hệ thống. Dòng hai là cùng phép chuyển với cùng lỗi, nhưng bọc trong BEGIN...COMMIT: khi lỗi xảy ra, PostgreSQL hoàn tác cả giao dịch (lệnh COMMIT cuối in ra ROLLBACK), nên An và Bình đều về nguyên, tổng giữ 2000. Dòng ba cho thấy khi không có lỗi, COMMIT áp dụng cả hai bước: An 500, Bình 1500, tổng vẫn 2000. Tính nguyên tử đo được rõ ràng bằng một con số bất biến: tổng tiền.
Một lần tôi đo hớ: quên BEGIN
Cái bẫy nằm ở dòng đầu, và tôi bước vào nó một cách tự nhiên nhất. Tôi viết hai câu UPDATE — trừ An, cộng Bình — và chạy chúng, đinh ninh rằng "hai lệnh liên tiếp thì đi cùng nhau". Tôi không gõ BEGIN.
Nhưng PostgreSQL (như hầu hết cơ sở dữ liệu) mặc định ở chế độ autocommit: mỗi câu lệnh đơn lẻ tự nó là một giao dịch, tự commit ngay khi xong. Nên câu UPDATE trừ An chạy xong là đã vĩnh viễn — commit rồi. Khi câu UPDATE cộng Bình gặp lỗi và không chạy, không có gì hoàn tác câu đầu cả. Tổng tiền tụt từ 2000 xuống 1500.
Con số đó — tổng tiền tự dưng hụt đi — là một con số bất khả: tiền không thể bốc hơi trong một phép chuyển nội bộ. Theo kỷ luật đo lường, một con số vi phạm định luật bảo toàn như vậy là dấu hiệu rõ ràng có gì sai trong cách tôi làm, không phải cơ sở dữ liệu tính nhầm. Và cái sai là tôi quên bọc giao dịch. Bọc lại trong BEGIN...COMMIT, lỗi giữa chừng kích hoạt ROLLBACK cả cụm, tổng giữ nguyên 2000 — đúng như Atomicity hứa.
Cái tôi đo hớ có hai lớp. Thứ nhất: quên rằng mặc định là autocommit — nhiều lệnh không tự động thành một đơn vị, phải chủ động bọc BEGIN. Thứ hai, một chi tiết tôi cũng hiểu sai lúc đầu: tôi tưởng một lệnh lỗi trong giao dịch chỉ đơn giản bị bỏ qua, các lệnh khác vẫn chạy. Thực tế, một lỗi làm cả giao dịch vào trạng thái "aborted"; mọi lệnh sau đó đều bị từ chối tới khi ROLLBACK, và COMMIT lúc này tự động biến thành ROLLBACK. Bài học đo lường: khi một con số vi phạm bất biến bạn tin chắc (tổng tiền phải cố định), đừng nghi công cụ trước — nghi cách bạn nhóm các thao tác lại với nhau.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là quy tắc vàng: mọi chuỗi thao tác phải-cùng-đúng bắt buộc nằm trong một giao dịch. Chuyển tiền, tạo đơn hàng kèm trừ kho, cập nhật nhiều bảng liên quan — bất cứ khi nào "làm một nửa" là sai, phải bọc BEGIN...COMMIT. Đây là lỗi phổ biến bậc nhất trong code thật: hai câu UPDATE/INSERT chạy tách rời, và một sự cố (lỗi mạng, crash, ngoại lệ) xảy ra đúng giữa chúng để lại dữ liệu mâu thuẫn. Trong hầu hết thư viện, điều này nghĩa là dùng đúng khối transaction { ... } chứ không phát từng câu lệnh riêng lẻ.
Hệ quả thứ hai là hiểu ba chữ còn lại của ACID, mà giao dịch cũng bảo vệ. Consistency: giao dịch đưa cơ sở dữ liệu từ một trạng thái hợp lệ sang một trạng thái hợp lệ khác — mọi ràng buộc (khóa ngoại, CHECK, UNIQUE) được giữ đúng, và nếu một ràng buộc bị vi phạm giữa chừng, cả giao dịch bị hoàn tác. Isolation: các giao dịch chạy đồng thời không thấy trạng thái nửa chừng của nhau (bài sau đo kỹ). Durability: một khi COMMIT trả về thành công, dữ liệu được ghi bền vững, sống sót qua cả mất điện (nhờ WAL, một bài sau). Bốn chữ này là lý do ta tin cơ sở dữ liệu với tiền và dữ liệu quan trọng.
Hệ quả thứ ba là bài học đo lường. Con số mang theo: giao dịch (BEGIN...COMMIT) đảm bảo nguyên tử — chuyển 500 mà lỗi giữa chừng: không bọc giao dịch làm tổng tụt còn 1500 (mất 500), bọc BEGIN...COMMIT thì ROLLBACK giữ tổng 2000; và một lỗi trong giao dịch hoàn tác CẢ cụm, không phải chỉ bỏ lệnh lỗi. Tính nguyên tử là thứ đo được bằng một bất biến (tổng tiền); khi bất biến đó vỡ, thủ phạm gần như luôn là thiếu một giao dịch, không phải cơ sở dữ liệu sai.
Thử ba mươi giây
Trong psql, tạo một bảng nhỏ và thử tự tay: BEGIN; rồi vài câu UPDATE, rồi ROLLBACK; — kiểm lại và thấy không thay đổi nào có hiệu lực, dù các lệnh đã "chạy". Đổi ROLLBACK thành COMMIT và thấy chúng có hiệu lực. Rồi thử cái bẫy: trong một BEGIN, cố ý chạy một lệnh lỗi (ví dụ SELECT 1/0;), rồi thử chạy một UPDATE bình thường ngay sau — bạn sẽ thấy nó bị từ chối với "current transaction is aborted", chứng tỏ cả giao dịch đã hỏng và phải ROLLBACK. Đó chính là tính nguyên tử "tất cả hoặc không gì" mà bài này đo, và là lý do luôn bọc các thao tác quan trọng trong một giao dịch.