Transaction
Xem thửTình huống mở đầu
Tối 07/09, khách số 4 đặt 5 màn hình giá 3.200.000 đồng một chiếc, tổng 16.000.000 đồng. Kho chỉ còn 4 chiếc. Code đặt hàng của shop chạy ba lệnh liên tiếp: tạo đơn, ghi dòng hàng, trừ kho. Lệnh thứ ba bị bảng chặn vì tồn kho không được âm.
Sáng hôm sau kế toán thấy đơn số 7 đã ở trạng thái paid với 16 triệu đồng, nhưng kho không hề giảm và không ai biết ai sẽ giao 5 chiếc màn hình đó. Hai lệnh đầu đã chạy xong rồi, lệnh thứ ba lỗi thì chẳng ai quay lại dọn.
Hãy đoán trước: sau khi lệnh trừ kho báo lỗi, bảng orders có bao nhiêu đơn và kho màn hình còn bao nhiêu?
Ôn nhanh bài trước: truy vấn đếm đơn ngày 01/02/2026 viết bằng DATE(created_at) = '2026-02-01' đọc 20.000 mục chỉ mục. Viết lại thế nào để chỉ đọc khoảng 205 mục?
Đáp án: dùng khoảng nửa mở created_at >= '2026-02-01' AND created_at < '2026-02-02'. Bài này chuyển sang một câu hỏi khác: làm sao để nhiều lệnh ghi cùng thành công hoặc cùng bị hủy.
Mục tiêu bài học
Sau bài này bạn có thể:
- Gói nhiều lệnh ghi thành một đơn vị bằng
START TRANSACTION,COMMITvàROLLBACK. - Giải thích vì sao một lệnh lỗi giữa chừng không tự hủy các lệnh đã chạy trước nó, và biết lúc nào phải gọi
ROLLBACK. - Dự đoán một phiên khác nhìn thấy gì khi giao dịch chưa
COMMITở mức cô lập mặc định của MySQL. - Chống bán quá số hàng bằng
UPDATE ... WHERE stock >= qtykèmROW_COUNT(), và hiểu khóa dòng làm phiên thứ hai phải chờ.
Bức tranh lớn
Xuyên suốt bài, giao dịch là phong bì niêm phong: mọi lệnh bạn viết là một tờ giấy bỏ vào phong bì. COMMIT là dán tem gửi đi, ai cũng đọc được. ROLLBACK là xé phong bì, như chưa viết gì. Dự án xuyên suốt là luồng đặt hàng của shop: tạo đơn, ghi dòng hàng, trừ kho.
Hình dưới trả lời một câu hỏi: một giao dịch kết thúc theo những nhánh nào?
Đang tải sơ đồ…
Cách đọc hình: đọc từ trên xuống. Ô xanh dương mở phong bì. Ô tím là các lệnh chạy bên trong, thay đổi mới chỉ là bản nháp. Ô cam hỏi mọi bước có thành công không. Nếu có, COMMIT chốt bản nháp thành dữ liệu thật (ô xanh lá). Nếu không, ROLLBACK xé hết (ô đỏ). Màu chỉ là tín hiệu phụ; chữ trong ô mới là nội dung chính.
Chỗ ẩn dụ không còn đúng ở hai điểm. Thứ nhất, phong bì giấy không khóa ai, còn giao dịch giữ khóa trên các dòng nó sửa nên người khác phải chờ. Thứ hai, một lệnh bị lỗi không xé phong bì: nó chỉ hủy chính nó, còn phong bì vẫn mở.
Khái niệm cốt lõi
Bài này có bốn khái niệm.
1. Giao dịch và autocommit
Mặc định MySQL ở chế độ autocommit: mỗi lệnh là một giao dịch riêng và được COMMIT ngay khi chạy xong. START TRANSACTION (hoặc BEGIN) tắt autocommit tạm thời cho tới khi bạn gọi COMMIT hoặc ROLLBACK. Nhờ vậy nhiều lệnh thành một khối: thành công cả hoặc hủy cả (tính nguyên tử, chữ A trong ACID).
2. Lệnh lỗi không đóng giao dịch
Khi một lệnh trong giao dịch bị lỗi (vi phạm ràng buộc, sai kiểu dữ liệu), MySQL chỉ hoàn tác riêng lệnh đó. Các lệnh trước vẫn nằm trong giao dịch đang mở. Ứng dụng phải bắt lỗi rồi tự gọi ROLLBACK hoặc quyết định vẫn COMMIT.
3. Cô lập: phiên khác chỉ thấy dữ liệu đã COMMIT
Bản nháp trong giao dịch chưa COMMIT thì phiên khác không thấy. Ở mức cô lập mặc định của InnoDB là REPEATABLE READ, một giao dịch đọc dữ liệu theo ảnh chụp lấy ở lần đọc đầu tiên, nên đọc lại cùng dòng vẫn ra giá trị cũ dù phiên khác đã COMMIT.
4. Khóa dòng và cập nhật có điều kiện
Lệnh UPDATE khóa các dòng nó sửa cho tới khi giao dịch kết thúc. Phiên khác muốn sửa cùng dòng phải chờ, và nếu chờ quá innodb_lock_wait_timeout giây thì nhận lỗi 1205. Lệnh UPDATE cũng luôn làm việc trên dữ liệu mới nhất, nên UPDATE ... SET stock = stock - 3 WHERE stock >= 3 vừa kiểm tra vừa trừ trong một bước, không có kẽ hở giữa hai việc đó.
Ví dụ đầu tiên
Bài Stored Procedure đã thêm ràng buộc CHECK (stock >= 0) cho bảng products; ví dụ này dùng lại nó. Trước hết chạy ba lệnh đặt hàng mà không có giao dịch, mỗi lệnh gõ riêng như trên một cửa sổ mysql, rồi đếm xem còn gì [Đã chạy: MySQL 8.0.44]:
mysql> INSERT INTO orders (user_id, total, status, created_at)
-> VALUES (4, 16000000, 'paid', '2026-09-07 10:00:00');
mysql> INSERT INTO order_items (order_id, product_id, qty, unit_price)
-> VALUES (LAST_INSERT_ID(), 3, 5, 3200000);
mysql> UPDATE products SET stock = stock - 5 WHERE id = 3;
ERROR 3819 (HY000): Check constraint 'chk_stock' is violated.
mysql> SELECT (SELECT COUNT(*) FROM orders) AS orders,
-> (SELECT COUNT(*) FROM order_items WHERE order_id = 7) AS items,
-> (SELECT stock FROM products WHERE id = 3) AS stock;
+--------+-------+-------+
| orders | items | stock |
+--------+-------+-------+
| 7 | 1 | 4 |
+--------+-------+-------+
Đối chiếu với dự đoán ở đầu bài. Kết quả: lệnh 3 lỗi 3819, nhưng bảng có 7 đơn, đơn 7 có 1 dòng hàng, còn kho vẫn 4. Đây là đơn ma.
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
INSERT INTO orders ... | Tạo đơn 7 và lưu ngay vì autocommit đang bật | Mỗi lệnh đứng riêng là một giao dịch đã chốt |
LAST_INSERT_ID() | Lấy id mà phiên này vừa sinh ra (đơn 7) | Dòng hàng phải trỏ đúng vào đơn vừa tạo |
UPDATE products SET stock = stock - 5 ... | Cố trừ 5 khỏi kho còn 4 và bị CHECK chặn | Đây là lệnh gây lỗi để xem hai lệnh trước ra sao |
ERROR 3819 (HY000) | MySQL báo vi phạm chk_stock | Chỉ lệnh này bị hủy, hai lệnh trước đã lưu rồi |
Bây giờ bọc đúng ba lệnh đó trong một giao dịch. Sau lệnh lỗi, kiểm tra thử bên trong rồi gọi ROLLBACK [Đã chạy: MySQL 8.0.44]:
mysql> START TRANSACTION;
mysql> INSERT INTO orders (user_id, total, status, created_at)
-> VALUES (4, 16000000, 'paid', '2026-09-07 10:00:00');
mysql> INSERT INTO order_items (order_id, product_id, qty, unit_price)
-> VALUES (LAST_INSERT_ID(), 3, 5, 3200000);
mysql> UPDATE products SET stock = stock - 5 WHERE id = 3;
ERROR 3819 (HY000): Check constraint 'chk_stock' is violated.
mysql> SELECT (SELECT COUNT(*) FROM orders) AS orders,
-> (SELECT COUNT(*) FROM order_items WHERE order_id = 7) AS items,
-> (SELECT stock FROM products WHERE id = 3) AS stock;
+--------+-------+-------+
| orders | items | stock |
+--------+-------+-------+
| 7 | 1 | 4 |
+--------+-------+-------+
mysql> ROLLBACK;
mysql> SELECT (SELECT COUNT(*) FROM orders) AS orders,
-> (SELECT COUNT(*) FROM order_items WHERE order_id = 7) AS items,
-> (SELECT stock FROM products WHERE id = 3) AS stock;
+--------+-------+-------+
| orders | items | stock |
+--------+-------+-------+
| 6 | 0 | 4 |
+--------+-------+-------+
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
START TRANSACTION | Mở phong bì: autocommit tạm tắt | Từ đây các lệnh ghi chỉ là bản nháp |
Lần SELECT đầu | Cho thấy 7 đơn, 1 dòng hàng ngay bên trong phong bì | Chính phiên này vẫn thấy bản nháp của mình |
ROLLBACK | Hủy cả đơn 7 và dòng hàng | Lệnh 3 lỗi nên ứng dụng chọn hủy hết |
Lần SELECT sau | 6 đơn, 0 dòng hàng, kho 4 | Dữ liệu về đúng trạng thái trước khi bắt đầu |
Xem từng bước
Animation dưới đây chạy lại hai trường hợp: có phong bì và không có phong bì. Hãy trả lời câu dự đoán trước khi bấm bước đầu tiên.
Đang tải Animation…
Bảng trace bằng chữ [Đã chạy: MySQL 8.0.44, kho màn hình còn 4, khách đặt 5]:
| Cách làm | Đơn sau lệnh lỗi | Dòng hàng của đơn 7 | Kho màn hình |
|---|---|---|---|
| Không có giao dịch | 7 | 1 | 4 |
Có giao dịch và gọi ROLLBACK | 6 | 0 | 4 |
Thêm một điều nữa: phong bì không chỉ cất nháp, nó còn che bản nháp với người khác. Hai phiên cùng nhìn kho màn hình. Phiên A trừ 1 chiếc nhưng chưa chốt, phiên B đọc ngay trong một giao dịch của mình [Đã chạy: MySQL 8.0.44]:
A> START TRANSACTION;
A> UPDATE products SET stock = stock - 1 WHERE id = 3;
A> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 3 |
+-------+
B> START TRANSACTION;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 4 |
+-------+
A> COMMIT;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 4 |
+-------+
B> COMMIT;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 3 |
+-------+
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
A> UPDATE ... stock - 1 rồi SELECT | Phiên A thấy 3, là bản nháp của chính nó | Phiên đang sửa luôn thấy việc của mình |
B> SELECT lần đầu | Phiên B thấy 4 và lấy ảnh chụp tại đây | Bản nháp của A chưa COMMIT nên B không thấy |
A> COMMIT, rồi B> SELECT | B vẫn thấy 4 | REPEATABLE READ giữ nguyên ảnh chụp trong cả giao dịch của B |
B> COMMIT, rồi B> SELECT | B thấy 3 | Giao dịch của B đã kết thúc nên lần đọc mới lấy ảnh chụp mới |
Bạn vừa làm được: biến một chuỗi ba lệnh có thể để lại đơn ma thành một khối tất cả hoặc không gì cả, và dự đoán đúng phiên khác thấy gì khi bản nháp chưa chốt.
Concept Check
Dùng bảng products có CHECK (stock >= 0) và monitor (id 3) còn 4 chiếc.
Câu 1. Trong một giao dịch đang mở, lệnh thứ ba báo lỗi 3819. Hai lệnh đã chạy trước đó đang ở trạng thái nào?
- A. MySQL đã tự hoàn tác chúng cùng với lệnh bị lỗi.
- B. Chúng vẫn nằm trong giao dịch đang mở, chờ bạn gọi
COMMIThoặcROLLBACK. - C. Chúng đã được lưu vĩnh viễn vì mọi lệnh ghi đều tự
COMMIT. - D. Chúng bị khóa và không ai dùng được cho tới khi khởi động lại MySQL.
Câu 2. Phiên A đã UPDATE kho màn hình nhưng chưa COMMIT. Phiên B ở mức cô lập mặc định chạy SELECT stock. Phiên B thấy gì?
- A. Giá trị mới của A, vì dữ liệu đã được ghi vào bảng.
- B. Lỗi 1205, vì dòng đang bị khóa nên không đọc được.
- C. Giá trị cũ đã
COMMIT, vì thay đổi của A chưa được chốt. - D.
NULL, vì dòng đang ở trạng thái chưa xác định.
Câu 3. Vì sao UPDATE products SET stock = stock - 3 WHERE id = 3 AND stock >= 3 an toàn hơn đọc stock ra ứng dụng, tính 4 - 3 rồi ghi SET stock = 1?
- A. Việc kiểm tra và việc trừ diễn ra trong một lệnh trên dữ liệu mới nhất của dòng đã khóa.
- B. Vì lệnh này ngắn hơn nên MySQL chạy nhanh hơn và không kịp xung đột.
- C. Vì lệnh này tắt cơ chế khóa dòng của InnoDB trong lúc chạy.
- D. Vì MySQL tự gọi
ROLLBACKnếu hai phiên cùng sửa một dòng.
Đáp án và giải thích:
- Câu 1: B. MySQL chỉ hoàn tác riêng lệnh lỗi. A và C hiểu sai: giao dịch đang mở nên chưa có gì được lưu. D bịa ra một cơ chế không có.
- Câu 2: C. Phiên B chỉ thấy dữ liệu đã
COMMIT. A là đọc bẩn, mức mặc định không cho phép. B và D bịa ra: đọc thường không bị khóa chặn. - Câu 3: A. Dòng bị khóa và
stock >= 3được đánh giá trên giá trị mới nhất. B, C, D đều mô tả cơ chế không tồn tại.
Làm cùng mình
Tối 11/11, màn hình còn 4 chiếc. Hai khách cùng muốn mua 3 chiếc, và chỉ một người mua được. Việc của bạn là viết lệnh trừ kho sao cho người đến sau tự biết mình thua, thay vì làm kho âm hay bán quá.
Bước 1. Vẽ ra giấy
Kho 4. Khách A trừ 3 thì kho còn mấy? Khi đó khách B cũng trừ 3 thì điều gì nên xảy ra? Ghi dự đoán số dòng bị thay đổi bởi lệnh trừ của A và của B.
Bước 2. Điền chỗ trống
Mỗi khách chạy cùng một khối này:
START TRANSACTION;
UPDATE products SET stock = stock - 3
WHERE id = 3 AND stock >= 3;
-- TODO 1: hiện số dòng vừa bị thay đổi bằng hàm ROW_COUNT(), đặt tên cột là sold
-- TODO 2: nếu sold bằng 0 thì khách này thua, chạy ROLLBACK thay vì COMMIT
COMMIT;
Gợi ý: ROW_COUNT() phải gọi ngay sau lệnh UPDATE, trước mọi lệnh khác.
Chạy xong bạn sẽ thấy [Đã chạy] một bảng sold cho từng khách và kho cuối cùng. So với dự đoán ở Bước 1. Đáp án ở mục Lời giải và đáp án.
Bước 3. Vẽ lại ra giấy
Ghi một câu: vì sao khách B vẫn thua dù trước đó B đã đọc thấy kho còn 4?
Thử phá nó đi
Ba lỗi người mới thường gặp [Đã chạy: MySQL 8.0.44].
Lỗi 1. Đọc rồi ghi lại giá trị tự tính. Cả hai khách đọc kho là 4, mỗi người tự tính 4 - 3 = 1 rồi ghi SET stock = 1:
A> START TRANSACTION;
A> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 4 |
+-------+
B> START TRANSACTION;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 4 |
+-------+
A> UPDATE products SET stock = 1 WHERE id = 3;
A> COMMIT;
B> UPDATE products SET stock = 1 WHERE id = 3;
B> COMMIT;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 1 |
+-------+
Cả hai lệnh đều thành công, kho là 1, nhưng thực tế đã bán 6 chiếc từ kho 4. Phiên B ghi đè kết quả của phiên A vì giá trị 1 được tính từ số liệu cũ. Cách sửa là để MySQL tính trên dữ liệu mới nhất: SET stock = stock - 3 WHERE ... AND stock >= 3, đúng như phần Làm cùng mình.
Lỗi 2. Quên COMMIT hoặc ROLLBACK, nên giao dịch giữ khóa mãi. Phiên A sửa dòng màn hình rồi bỏ đó; phiên B (đặt thời gian chờ 2 giây cho nhanh, mặc định là 50 giây) muốn sửa cùng dòng:
A> START TRANSACTION;
A> UPDATE products SET stock = stock - 1 WHERE id = 3;
B> SET SESSION innodb_lock_wait_timeout = 2;
B> UPDATE products SET stock = stock - 1 WHERE id = 3;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
A> COMMIT;
B> UPDATE products SET stock = stock - 1 WHERE id = 3;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 2 |
+-------+
Hình dưới cho thấy ai chờ ai:
Đang tải sơ đồ…
Cách đọc hình: phiên A (xanh dương) sửa dòng nên InnoDB khóa dòng đó (ô tím). Phiên B (xanh dương) cũng muốn sửa dòng ấy nên phải chờ (ô cyan). Hết thời gian chờ thì B nhận lỗi 1205 (ô đỏ). Sau khi A gọi COMMIT, B chạy lại lệnh và thành công: kho từ 4 xuống 3 (lệnh của A) rồi xuống 2 (lệnh của B).
Trong ứng dụng thật, giao dịch phải ngắn và luôn có đường thoát: bắt lỗi rồi gọi ROLLBACK. Đừng để giao dịch mở trong lúc chờ người dùng bấm nút hoặc chờ một cuộc gọi mạng.
Lỗi 3. Tin rằng ROLLBACK hủy được mọi thứ. Lệnh DDL như CREATE TABLE tự COMMIT ngầm giao dịch đang mở trước khi chạy:
mysql> START TRANSACTION;
mysql> UPDATE products SET stock = stock - 1 WHERE id = 1;
mysql> CREATE TABLE note (id INT);
mysql> ROLLBACK;
mysql> SELECT stock FROM products WHERE id = 1;
+-------+
| stock |
+-------+
| 9 |
+-------+
Kho bàn phím là 9 dù đã ROLLBACK: lệnh UPDATE trước CREATE TABLE đã bị chốt. Hãy tách lệnh DDL ra khỏi giao dịch. Một lỗi liên quan: bảng dùng engine không hỗ trợ giao dịch (như MyISAM) thì ROLLBACK không hủy được gì, xem phần Đào sâu.
Tự tay làm
Dùng chung bảng shop với ràng buộc CHECK (stock >= 0). Mỗi bài nêu đầu vào, kết quả mong đợi và tiêu chí đạt.
Cơ bản
Bài 1. Đặt 2 bàn phím trong một giao dịch. Đầu vào: khách 4 mua 2 bàn phím (id 1, giá 450.000), kho 10. Viết giao dịch tạo đơn 900.000, ghi dòng hàng và trừ kho, rồi COMMIT. Tiêu chí đạt: có 7 đơn, đơn 7 có 1 dòng hàng, kho bàn phím còn 8. Gợi ý: LAST_INSERT_ID() cho mã đơn vừa tạo.
Vận dụng
Bài 2. Hủy một phần với savepoint. Đầu vào: giao dịch trừ 1 bàn phím (id 1), đặt SAVEPOINT, rồi trừ 1 chuột (id 2). Hãy hủy riêng lệnh trừ chuột nhưng vẫn lưu lệnh trừ bàn phím. Tiêu chí đạt: bàn phím còn 9, chuột vẫn 25. Gợi ý: ROLLBACK TO SAVEPOINT tên.
Thử thách (tùy chọn)
Bài 3. Đọc lại hai lần ở mức READ COMMITTED. Đầu vào: hai phiên; phiên B đặt READ COMMITTED, mở giao dịch và đọc kho màn hình, phiên A trừ 1 chiếc (tự COMMIT), phiên B đọc lại. Tiêu chí đạt: nói được B thấy gì ở hai lần đọc và so được với mức mặc định ở phần Ví dụ đầu tiên. Gợi ý: SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED.
Tự kiểm tra
Trả lời bằng lời của bạn, không nhìn lại bài. Mỗi câu ghi mục tiêu được kiểm tra.
- (Mục tiêu 1, nhớ lại)
START TRANSACTIONthay đổi điều gì so với chế độ autocommit? - (Mục tiêu 2, dự đoán) Trong giao dịch, lệnh thứ ba lỗi và ứng dụng không làm gì thêm rồi đóng kết nối. Hai lệnh đầu còn không?
- (Mục tiêu 3, giải thích cho bạn chưa học) Vì sao phiên B vẫn thấy kho cũ sau khi phiên A đã
COMMIT? - (Mục tiêu 4, tìm lỗi) Ứng dụng đọc
stockbằngSELECT, tính ở code rồi ghiUPDATE ... SET stock = giá_trị_đã_tính. Lỗi nằm ở đâu và sửa thế nào? - (Mục tiêu 4, dự đoán trạng thái) Phiên A sửa một dòng và chưa kết thúc. Phiên B sửa cùng dòng với
innodb_lock_wait_timeoutmặc định. B chờ bao lâu rồi nhận lỗi gì?
Điều cần nhớ
- Giao dịch gom nhiều lệnh thành một khối:
COMMITchốt tất cả,ROLLBACKhủy tất cả. - Lệnh lỗi chỉ tự hủy chính nó. Ứng dụng phải bắt lỗi và gọi
ROLLBACK. - Phiên khác chỉ thấy dữ liệu đã
COMMIT; ởREPEATABLE READmột giao dịch giữ nguyên ảnh chụp lần đọc đầu. - Trừ kho bằng một lệnh có điều kiện (
stock = stock - n WHERE stock >= n) rồi kiểmROW_COUNT(), đừng đọc rồi ghi giá trị tự tính. - Giữ giao dịch ngắn, và nhớ DDL tự
COMMITngầm.
Cheat sheet:
| Muốn | Viết | Lưu ý |
|---|---|---|
| Mở giao dịch | START TRANSACTION; hoặc BEGIN; | Tắt autocommit đến khi kết thúc |
| Chốt | COMMIT; | Phiên khác bắt đầu thấy thay đổi |
| Hủy hết | ROLLBACK; | Chỉ hủy được phần chưa COMMIT |
| Hủy một phần | SAVEPOINT tên; rồi ROLLBACK TO SAVEPOINT tên; | Giữ phần trước điểm đánh dấu |
| Trừ kho an toàn | UPDATE ... SET stock = stock - n WHERE id = x AND stock >= n; | Kiểm tra bằng ROW_COUNT() |
| Xem mức cô lập | SELECT @@transaction_isolation; | Mặc định REPEATABLE-READ |
Hình tóm tắt của bài là sơ đồ ở mục Bức tranh lớn.
Câu hỏi để dành cho ngày mai: giờ bạn đã giữ được dữ liệu đúng khi ghi. Nhưng các bảng orders, order_items và products nối với nhau bằng những cột nào, và ai quyết định một thông tin nên nằm ở bảng nào chứ không nằm ở bảng khác?
Đào sâu
Phần này dành cho bạn tò mò.
Các biến điều khiển. Ba biến quyết định hành vi mặc định mà bài đã dùng [Đã chạy: MySQL 8.0.44]:
mysql> SELECT @@autocommit AS autocommit, @@transaction_isolation AS isolation, @@innodb_lock_wait_timeout AS lock_wait_s;
+------------+-----------------+-------------+
| autocommit | isolation | lock_wait_s |
+------------+-----------------+-------------+
| 1 | REPEATABLE-READ | 50 |
+------------+-----------------+-------------+
autocommit là 1 (bật), mức cô lập là REPEATABLE-READ và thời gian chờ khóa là 50 giây.
Engine không có giao dịch. Bảng dùng engine MyISAM không hỗ trợ giao dịch, nên ROLLBACK không hoàn tác được gì [Đã chạy: MySQL 8.0.44]:
mysql> CREATE TABLE logs_myisam (id INT) ENGINE=MyISAM;
mysql> START TRANSACTION;
mysql> INSERT INTO logs_myisam VALUES (1);
mysql> ROLLBACK;
mysql> SELECT COUNT(*) AS rows_after_rollback FROM logs_myisam;
+---------------------+
| rows_after_rollback |
+---------------------+
| 1 |
+---------------------+
Bảng của bài này đều là InnoDB, engine mặc định của MySQL 8.0.
PostgreSQL khác MySQL ở lệnh lỗi. Trong PostgreSQL, một lệnh lỗi làm cả giao dịch rơi vào trạng thái hủy: mọi lệnh sau đó bị từ chối với thông báo current transaction is aborted, commands ignored until end of transaction block, và COMMIT thực chất chạy như ROLLBACK. Cùng hai lệnh trừ kho trong một giao dịch, lệnh thứ hai lỗi, trên PostgreSQL 16.15 [Đã chạy; hai kho cuối cùng đều giữ nguyên]:
+----+-------+
| id | stock |
+----+-------+
| 1 | 10 |
| 3 | 4 |
+----+-------+
(2 rows)
Mức cô lập mặc định của PostgreSQL là READ COMMITTED, khác REPEATABLE READ của InnoDB. Ngoài ra SET TRANSACTION ISOLATION LEVEL phải đứng ngay sau BEGIN; gọi trước BEGIN chỉ nhận cảnh báo SET TRANSACTION can only be used in transaction blocks và không có tác dụng.
Deadlock. Khi hai giao dịch giữ khóa chéo nhau (A giữ dòng 1 chờ dòng 2, B giữ dòng 2 chờ dòng 1), InnoDB phát hiện và hủy một giao dịch với lỗi 1213 [Suy luận: bài không chạy ca deadlock vì cần hai phiên chờ nhau cùng lúc]. Cách giảm: luôn sửa các dòng theo cùng một thứ tự, giữ giao dịch ngắn, và cho ứng dụng thử lại khi gặp 1213.
Khóa chủ động. SELECT ... FOR UPDATE đọc và khóa dòng ngay lúc đọc, dùng khi bạn phải đọc rồi mới quyết định ghi. Với kho hàng, cách UPDATE có điều kiện trong bài gọn hơn và không cần bước đọc.
Giới hạn của bài. Bài này chưa nói đến cách MVCC lưu phiên bản dữ liệu, khóa khoảng (gap lock), SERIALIZABLE, giao dịch phân tán và cấu hình độ bền khi ghi nhật ký.
Đọc thêm. MySQL 8.0 Reference Manual, InnoDB Locking and Transaction Model: https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-transaction-model.html
Bài tiếp theo
Bài sau mở chương Thiết kế CSDL và bảo mật, bắt đầu bằng mô hình ER: vẽ ra các thực thể và quan hệ giữa chúng trước khi tạo bảng. Mini-dự án nối tiếp: vẽ lại sơ đồ của shop gồm khách, đơn, dòng hàng và sản phẩm.
Lời giải và đáp án
Làm cùng mình, Bước 2. Hai chỗ trống:
SELECT ROW_COUNT() AS sold;
ROLLBACK;
Chạy thật với hai khách (khách B mở giao dịch và đọc kho trước khi khách A chốt) [Đã chạy: MySQL 8.0.44]:
A> START TRANSACTION;
A> UPDATE products SET stock = stock - 3
WHERE id = 3 AND stock >= 3;
A> SELECT ROW_COUNT() AS sold;
+------+
| sold |
+------+
| 1 |
+------+
B> START TRANSACTION;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 4 |
+-------+
A> COMMIT;
B> UPDATE products SET stock = stock - 3
WHERE id = 3 AND stock >= 3;
B> SELECT ROW_COUNT() AS sold;
+------+
| sold |
+------+
| 0 |
+------+
B> ROLLBACK;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 1 |
+-------+
Khách A trừ được (sold bằng 1) và COMMIT. Khách B đã đọc thấy 4 nhưng lệnh UPDATE làm việc trên dữ liệu mới nhất: lúc đó kho chỉ còn 1 nên điều kiện stock >= 3 sai, không dòng nào đổi (sold bằng 0) và B gọi ROLLBACK. Kho cuối là 1, không ai bán quá.
Bài 1.
mysql> START TRANSACTION;
mysql> INSERT INTO orders (user_id, total, status, created_at)
-> VALUES (4, 900000, 'paid', '2026-09-07 11:00:00');
mysql> INSERT INTO order_items (order_id, product_id, qty, unit_price)
-> VALUES (LAST_INSERT_ID(), 1, 2, 450000);
mysql> UPDATE products SET stock = stock - 2 WHERE id = 1 AND stock >= 2;
mysql> COMMIT;
mysql> SELECT (SELECT COUNT(*) FROM orders) AS orders,
-> (SELECT COUNT(*) FROM order_items WHERE order_id = 7) AS items,
-> (SELECT stock FROM products WHERE id = 1) AS stock;
+--------+-------+-------+
| orders | items | stock |
+--------+-------+-------+
| 7 | 1 | 8 |
+--------+-------+-------+
Bài 2.
mysql> START TRANSACTION;
mysql> UPDATE products SET stock = stock - 1 WHERE id = 1;
mysql> SAVEPOINT after_keyboard;
mysql> UPDATE products SET stock = stock - 1 WHERE id = 2;
mysql> ROLLBACK TO SAVEPOINT after_keyboard;
mysql> COMMIT;
mysql> SELECT id, stock FROM products WHERE id IN (1, 2) ORDER BY id;
+----+-------+
| id | stock |
+----+-------+
| 1 | 9 |
| 2 | 25 |
+----+-------+
Lệnh trừ bàn phím trước SAVEPOINT được giữ (10 xuống 9), lệnh trừ chuột sau SAVEPOINT bị hủy (vẫn 25).
Bài 3.
B> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
B> START TRANSACTION;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 4 |
+-------+
A> UPDATE products SET stock = stock - 1 WHERE id = 3;
B> SELECT stock FROM products WHERE id = 3;
+-------+
| stock |
+-------+
| 3 |
+-------+
B> COMMIT;
Ở READ COMMITTED, phiên B thấy 4 ở lần đọc đầu và 3 ở lần đọc sau trong cùng một giao dịch, vì mỗi lần đọc lấy dữ liệu đã COMMIT mới nhất. Ở mức mặc định REPEATABLE READ (phần Ví dụ đầu tiên) B vẫn thấy 4 ở lần đọc sau.
Tự kiểm tra.
- Mỗi lệnh không còn tự
COMMIT: các thay đổi chỉ chốt khi gọiCOMMITvà hủy khi gọiROLLBACK. - Không: đóng kết nối khi giao dịch chưa
COMMITkhiến MySQL hoàn tác toàn bộ giao dịch [Suy luận: hành vi chuẩn của InnoDB khi ngắt kết nối, bài không chạy lại ca này]. - Vì ở
REPEATABLE READgiao dịch của B dùng ảnh chụp lấy ở lần đọc đầu; ảnh đó không đổi cho đến khi B kết thúc giao dịch. - Giá trị tính từ số liệu cũ ghi đè kết quả của phiên khác. Sửa bằng
UPDATE ... SET stock = stock - n WHERE ... AND stock >= nrồi kiểmROW_COUNT(). - Chờ 50 giây rồi nhận lỗi 1205
Lock wait timeout exceeded(bài đặt 2 giây để chạy nhanh).