Giao diện
Transaction & isolation
Transaction là một nhóm câu lệnh mà database coi như một việc duy nhất: hoặc mọi câu đều có hiệu lực, hoặc không câu nào có hiệu lực. Chuyển tiền là ví dụ kinh điển. Trừ tiền tài khoản A và cộng tiền tài khoản B phải cùng thành công; không được trừ xong A rồi sập trước khi kịp cộng B.
Phần khó của transaction là isolation: nhiều transaction chạy cùng lúc thì chúng thấy dữ liệu của nhau tới đâu. Trang này nói phần chung cho mọi database, kèm kết quả chạy thật trên MySQL và PostgreSQL. Cách InnoDB cài đặt bên trong (undo log, read view, gap lock) nằm ở MySQL chương 6 và chương 7.
Mọi kết quả trong trang được chạy thật ngày 2026-10-16 trên MySQL 8.0.46 và PostgreSQL 17, bằng một script Node.js điều khiển hai connection xen kẽ nhau theo đúng thứ tự từng bước.
ACID
| Chữ | Nghĩa | Ai lo |
|---|---|---|
| Atomicity (nguyên tử) | Tất cả hoặc không gì cả. Lỗi giữa chừng thì database hoàn tác mọi thay đổi của transaction | Database (nhờ undo log) |
| Consistency (nhất quán) | Dữ liệu luôn thoả các quy tắc: số dư không âm, khoá ngoại hợp lệ | Phần lớn là app; database chỉ giữ được những quy tắc khai báo thành ràng buộc (CHECK, UNIQUE, FOREIGN KEY) |
| Isolation (cô lập) | Các transaction chạy song song không làm hỏng dữ liệu của nhau | Database, ở mức độ do isolation level quyết định |
| Durability (bền vững) | Đã commit thì không mất, kể cả khi mất điện | Database (nhờ redo log, fsync), cộng với cấu hình (MySQL chương 8) |
Chữ I là chữ dễ hiểu sai nhất. Isolation level mặc định của cả MySQL lẫn PostgreSQL không đảm bảo các transaction chạy song song cho kết quả giống như khi chạy lần lượt. Phần còn lại của trang nói về chuyện đó.
Transaction trong code Node.js
Mỗi câu lệnh chạy riêng lẻ đã là một transaction (chế độ autocommit). Muốn gộp nhiều câu thì mở transaction trên một connection cố định:
js
// mysql2/promise
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
await conn.execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId])
await conn.execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId])
await conn.commit()
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}Ba lỗi hay gặp:
Dùng
pool.query()cho từng câu. Mỗi lần gọipool.query()có thể lấy một connection khác trong pool.BEGINchạy trên connection này,UPDATEchạy trên connection kia, nên thực ra không có transaction nào cả. Lúc ít tải, pool hay đưa lại đúng connection vừa trả, nên lỗi này có thể không lộ ra khi test.Quên
ROLLBACKkhi lỗi rồi trả connection về pool. Transaction vẫn đang mở trên connection đó, và request tiếp theo lấy phải connection này sẽ chạy bên trong transaction cũ.DDL trên MySQL tự commit. Trên MySQL, các câu như
CREATE TABLE,ALTER TABLE,TRUNCATEtự commit transaction đang mở trước khi chạy (MySQL: Statements That Cause an Implicit Commit). Chạy thửBEGIN; INSERT …; CREATE TABLE …; ROLLBACK;:- MySQL: dòng vừa
INSERTvẫn còn và bảng mới vẫn tồn tại. - PostgreSQL: rollback được cả DDL, nên không còn gì.
Vì vậy migration trên MySQL không rollback được giữa chừng (Migration trong team).
- MySQL: dòng vừa
Giữ transaction ngắn: không gọi API bên ngoài và không chờ người dùng thao tác khi đang mở transaction (MySQL chương 6, Bẫy của ORM).
Các lỗi khi transaction chạy song song
Mỗi ví dụ dưới đây dùng bảng accounts (tài khoản 1 và 2, mỗi tài khoản 100) hoặc bảng doctors (Alice và Bob đang trực).
| Lỗi | Chuyện gì xảy ra |
|---|---|
| Dirty read | Đọc được dữ liệu transaction khác chưa commit, và sau đó nó rollback. Đọc ra một con số chưa từng tồn tại |
| Non-repeatable read | Trong cùng transaction, đọc một dòng hai lần ra hai giá trị khác nhau, vì giữa hai lần có transaction khác sửa và commit |
| Phantom read | Trong cùng transaction, chạy cùng một điều kiện hai lần ra hai tập dòng khác nhau, vì có transaction khác thêm hoặc xoá dòng thoả điều kiện |
| Lost update | Hai transaction cùng đọc một giá trị, tính trong app rồi ghi đè; một lần cập nhật biến mất (Chống trùng & race condition) |
| Write skew | Hai transaction cùng kiểm tra một điều kiện trên nhiều dòng, rồi mỗi bên sửa một dòng khác nhau. Mỗi bên đều đúng nếu chạy một mình, nhưng cùng nhau thì phá quy tắc. Ví dụ: quy tắc “luôn còn ít nhất một bác sĩ trực”, Alice và Bob cùng thấy còn 2 người trực nên cùng xin nghỉ, và không còn ai trực |
Thử: hai transaction xen kẽ, kết quả chạy thật
Số dư 100. T1 đọc rồi định trừ 10 (ghi 90), T2 đọc rồi định trừ 20 (ghi 80). Đúng ra phải còn 70.
| # | T1 | T2 | Kết quả |
|---|---|---|---|
| 1 | BEGIN | OK | |
| 2 | BEGIN | OK | |
| 3 | SELECT balance FROM accounts WHERE id = 1 | 100 | |
| 4 | SELECT balance FROM accounts WHERE id = 1 | 100 | |
| 5 | UPDATE accounts SET balance = 90 WHERE id = 1 | OK, 1 dòng | |
| 6 | UPDATE accounts SET balance = 80 WHERE id = 1 | đang chờ lock… → sau đó: OK, 1 dòng | |
| 7 | COMMIT | OK | |
| 8 | COMMIT | OK | |
| 9 | Sau cùng: SELECT balance FROM accounts WHERE id = 1 | 80 | |
Còn 80 thay vì 70: lệnh ghi của T2 đè lên T1, một lần trừ tiền biến mất (lost update).
Mỗi bước được gửi theo đúng thứ tự trong bảng. Bước nào chờ quá 1,2 giây thì ghi “đang chờ lock…”, bước kế tiếp vẫn được gửi, và kết quả thật của bước bị chờ được ghi sau dấu “→”. Bảng mẫu: accounts (id 1 và 2, mỗi tài khoản 100), doctors (Alice và Bob đang trực).
Kết quả chạy thật
| Lỗi | MySQL RU | MySQL RC | MySQL RR (mặc định) | MySQL SERIALIZABLE | PostgreSQL RC (mặc định) | PostgreSQL RR | PostgreSQL SERIALIZABLE |
|---|---|---|---|---|---|---|---|
| Dirty read | Có | Không | Không | Không (chờ lock) | Không | Không | Không |
| Non-repeatable read | Có | Có | Không | Không (chờ lock) | Có | Không | Không |
Phantom (với SELECT) | Có | Có | Không, nhưng UPDATE vẫn thấy dòng mới | Không (INSERT chờ lock) | Có | Không | Không |
| Lost update | Có (còn 80) | Có | Có | Chặn bằng deadlock 1213 | Có | Chặn bằng lỗi 40001 | Chặn bằng lỗi 40001 |
| Write skew | Có | Có | Có | Chặn bằng deadlock 1213 | Có | Có | Chặn bằng lỗi 40001 |
UPDATE … SET balance = balance - x | Đúng (70) | Đúng | Đúng | Đúng | Đúng | Lỗi 40001, phải retry | Lỗi 40001, phải retry |
RU, RC, RR là READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ. PostgreSQL READ UNCOMMITTED chạy giống hệt READ COMMITTED, nên bảng gộp làm một cột (PostgreSQL: Transaction Isolation).
Bốn điều rút ra từ bảng:
- Mức mặc định của cả hai database đều để lọt lost update và write skew. Code đọc rồi ghi đè (
SELECTsố dư, tính trong app,UPDATEsố mới) sai trên cả MySQL lẫn PostgreSQL nếu không tự xử lý. - Cùng tên REPEATABLE READ nhưng MySQL và PostgreSQL làm khác nhau.
- PostgreSQL phát hiện hai transaction cùng sửa một dòng và huỷ transaction đến sau với lỗi
could not serialize access due to concurrent update. - MySQL không huỷ gì, để lệnh ghi sau đè lệnh ghi trước.
- PostgreSQL phát hiện hai transaction cùng sửa một dòng và huỷ transaction đến sau với lỗi
- “Phantom qua lệnh ghi” trên MySQL REPEATABLE READ.
SELECTvẫn đếm 2 dòng như lúc đầu, nhưngUPDATE … WHERE balance >= 100lại sửa 3 dòng, kể cả dòng transaction khác vừa thêm. Sau đóSELECTcũng thấy 3. Tài liệu MySQL ghi rõ snapshot chỉ áp dụng choSELECT, cònUPDATEvàDELETElàm việc trên bản mới nhất đã commit (MySQL: Consistent Nonlocking Reads). - PostgreSQL ở RR và SERIALIZABLE bắt retry cả câu
UPDATEnguyên tử. Trên MySQL,balance = balance - 10luôn đúng ở mọi mức. Trên PostgreSQL ở hai mức này, transaction thứ hai nhận lỗi 40001 và phải chạy lại từ đầu.
Chuẩn SQL nói gì, và vì sao chưa đủ
Chuẩn SQL định nghĩa bốn isolation level chỉ qua ba lỗi: dirty read, non-repeatable read, phantom. Mức càng cao thì càng ít lỗi được phép. Chuẩn không nhắc tới lost update và write skew, trong khi đó lại là hai lỗi hay gây sai dữ liệu nhất trong ứng dụng thật. Bài báo A Critique of ANSI SQL Isolation Levels (Berenson và cộng sự, 1995) đã chỉ ra điểm này, và cũng là bài định nghĩa snapshot isolation.
Tên mức chỉ là điểm bắt đầu; muốn biết một mức thực sự chặn được lỗi nào thì phải xem từng database cài đặt nó thế nào, như bảng kết quả ở trên.
Database cài isolation bằng cách nào
| Cách | Ý tưởng | Hệ quả | Dùng ở đâu |
|---|---|---|---|
| Lock (two-phase locking) | Đọc thì giữ shared lock, ghi thì giữ exclusive lock, giữ tới khi commit | Transaction phải chờ nhau, dễ deadlock | MySQL SERIALIZABLE: mọi SELECT trong transaction thành SELECT … FOR SHARE (MySQL: Transaction Isolation Levels) |
| MVCC / snapshot | Mỗi dòng có nhiều phiên bản; mỗi transaction đọc một bản chụp, nên đọc không chặn ghi và ghi không chặn đọc | Đọc nhanh, nhưng vẫn để lọt write skew | MySQL RC và RR, mọi mức của PostgreSQL (MySQL chương 6) |
| SSI (serializable snapshot isolation) | Vẫn đọc trên snapshot, nhưng database theo dõi ai đọc gì, ai ghi gì; thấy nguy cơ sai thì huỷ một transaction | Không phải chờ lock, nhưng app phải retry khi bị huỷ | PostgreSQL SERIALIZABLE |
Trong kết quả chạy thật có thể thấy rõ khác biệt của hai mức SERIALIZABLE. Ở MySQL, write skew bị chặn vì các transaction chờ lock của nhau rồi deadlock. Ở PostgreSQL, cả hai transaction chạy không phải chờ gì, và tới lúc COMMIT thì transaction thứ hai nhận lỗi could not serialize access due to read/write dependencies among transactions.
Chọn isolation level nào
| Tình huống | Gợi ý |
|---|---|
| Ứng dụng web thông thường | Giữ mức mặc định (MySQL RR, PostgreSQL RC). Xử lý từng chỗ có race condition bằng UPDATE có điều kiện, SELECT … FOR UPDATE, UNIQUE (Chống trùng & race condition) |
| MySQL bị nhiều deadlock do gap lock | Cân nhắc chuyển sang RC: ít gap lock hơn, nhưng sẽ có non-repeatable read và phantom, nên phải kiểm tra lại các đoạn code dựa vào RR (MySQL chương 7) |
| Nhiều quy tắc phức tạp trên nhiều dòng, khó tự lock cho đúng | PostgreSQL SERIALIZABLE kèm cơ chế retry chung cho mọi transaction |
| Báo cáo cần số liệu khớp nhau giữa nhiều câu query | RR (PostgreSQL: BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY), để mọi câu đọc cùng một snapshot |
| Không bao giờ | READ UNCOMMITTED |
Đổi isolation level cho một transaction:
sql
-- MySQL: áp dụng cho transaction kế tiếp của session
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- PostgreSQL
BEGIN ISOLATION LEVEL SERIALIZABLE;Retry khi transaction bị huỷ
Deadlock và lỗi serialization không phải bug mà là cách database báo “hãy chạy lại”. Tài liệu PostgreSQL ghi rõ: app dùng REPEATABLE READ hay SERIALIZABLE phải chuẩn bị retry cả transaction khi gặp lỗi 40001 (PostgreSQL: Transaction Isolation).
| Lỗi | MySQL (mysql2: err.code) | PostgreSQL (pg: err.code) |
|---|---|---|
| Deadlock | ER_LOCK_DEADLOCK (1213) | 40P01 |
| Không serialize được | (không có; MySQL dùng lock nên báo deadlock) | 40001 |
| Chờ lock quá lâu | ER_LOCK_WAIT_TIMEOUT (1205) | 55P03 (khi đặt lock_timeout) |
js
const RETRYABLE = new Set(['ER_LOCK_DEADLOCK', '40001', '40P01'])
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// fn nhận connection và chỉ làm việc với database; chạy lại được nhiều lần
export async function withTransaction(pool, fn, { retries = 3 } = {}) {
for (let attempt = 1; ; attempt++) {
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
const result = await fn(conn)
await conn.commit()
return result
} catch (err) {
await conn.rollback().catch(() => {})
if (!RETRYABLE.has(err.code) || attempt > retries) throw err
await sleep(20 * 2 ** attempt + Math.random() * 20) // backoff có jitter
} finally {
conn.release()
}
}
}- Chạy lại cả transaction từ đầu, kể cả các câu
SELECT. Không chỉ chạy lại câu bị lỗi, vì dữ liệu đã đọc trước đó có thể đã cũ. - Không đặt việc có tác dụng ra bên ngoài trong
fn. Gửi email hay gọi API thanh toán bên trong mà bị retry thì sẽ gửi hai lần. Làm những việc đó sau khi commit, hoặc qua outbox (Hàng đợi bằng database). - Lỗi chờ lock quá lâu (1205) trên MySQL chỉ huỷ câu lệnh, không huỷ cả transaction (trừ khi bật
innodb_rollback_on_timeout). Rollback rồi retry cả transaction là cách an toàn nhất.
Transaction trải qua nhiều database hay nhiều service
Mọi thứ ở trên chỉ đúng trong một database. Khi một việc phải ghi vào hai database, hoặc gọi sang service khác, thì không còn transaction chung. Các cách xử lý (two-phase commit, saga, outbox) và vì sao microservice thường tránh two-phase commit nằm ở Commit phân tán.
Ghi nhớ
- Transaction là tất cả hoặc không gì cả. Trong Node.js phải chạy mọi câu trên cùng một connection;
pool.query()từng câu là không có transaction. - Mức mặc định (MySQL RR, PostgreSQL RC) đều để lọt lost update và write skew. Xử lý bằng
UPDATEcó điều kiện,FOR UPDATE, ràng buộc, hoặc SERIALIZABLE. - Cùng tên REPEATABLE READ: PostgreSQL huỷ transaction ghi đè (lỗi 40001), MySQL để ghi đè. MySQL RR còn có “phantom qua lệnh ghi”:
UPDATEthấy dòng màSELECTkhông thấy. - SERIALIZABLE của MySQL dùng lock nên chờ và deadlock; của PostgreSQL dùng SSI nên không chờ mà huỷ lúc commit.
- Deadlock và lỗi 40001 là tín hiệu “chạy lại”: retry cả transaction có backoff, và để các việc có tác dụng bên ngoài ra sau commit.
- Trên MySQL, DDL tự commit transaction đang mở; PostgreSQL rollback được cả DDL.
Nguồn tham khảo
Đã chạy thử ngày 2026-10-16 trên MySQL 8.0.46 và PostgreSQL 17 (Docker), bằng script Node.js dùng mysql2 3.24 và pg 8.23. Script mở hai connection, gửi từng bước theo đúng thứ tự trong mô phỏng; bước nào chờ quá 1,2 giây thì ghi nhận “đang chờ lock” rồi gửi bước kế tiếp. Có 6 kịch bản × 4 isolation level × 2 database, tổng cộng 48 lần chạy. Toàn bộ kết quả nằm trong docs/.vitepress/theme/data/isolation-results.js và là dữ liệu của mô phỏng. Phép thử DDL trong transaction chạy riêng bằng client dòng lệnh.
Ở lần chạy đầu, kịch bản lost update đặt COMMIT của T1 ngay sau UPDATE của T1. Với MySQL SERIALIZABLE, UPDATE đó phải chờ lock, nên script đứng chờ T1 mà không gửi được bước của T2, và kết quả ra timeout thay vì deadlock. Kịch bản đã được sửa để hai UPDATE chạy trước hai COMMIT; thứ tự này hợp lệ ở mọi isolation level.
Tài liệu chính thức đã đối chiếu (ngày 2026-10-16):
| Nguồn | Dùng cho phần |
|---|---|
| PostgreSQL, Transaction Isolation | Bảng lỗi theo từng mức; RU chạy như RC; RR không có phantom; lỗi “concurrent update” ở RR; SERIALIZABLE dùng SSI; retry khi gặp 40001; RC là mặc định |
| MySQL 8.4, Transaction Isolation Levels | RR là mặc định, đọc trên snapshot của lần đọc đầu; RC tạo snapshot mới mỗi lần đọc, gần như không dùng gap lock; RU cho dirty read; SERIALIZABLE đổi SELECT thành FOR SHARE |
| MySQL 8.4, Consistent Nonlocking Reads | Snapshot chỉ áp dụng cho SELECT; UPDATE và DELETE thấy dòng transaction khác vừa commit, và sau đó các dòng này hiện ra với chính transaction |
| MySQL 8.4, Statements That Cause an Implicit Commit | DDL tự commit transaction đang mở; CREATE TEMPORARY TABLE thì không |
| PostgreSQL, Error Codes (đọc ngày 2026-10-15) | 40001 serialization_failure, 40P01 deadlock_detected, 55P03 lock_not_available |
Chưa đối chiếu, từ kiến thức chung:
- Berenson, Bernstein, Gray, Melton, O’Neil, O’Neil, A Critique of ANSI SQL Isolation Levels (SIGMOD 1995): chuẩn SQL định nghĩa isolation qua ba lỗi, và định nghĩa snapshot isolation.
- Lỗi lock wait timeout 1205 của MySQL chỉ huỷ câu lệnh, không huỷ cả transaction, và tuỳ chọn
innodb_rollback_on_timeout. - Hành vi chọn connection của pool trong
mysql2khi gọipool.query()liên tiếp.
Code đã dùng để chạy thử
Script nằm trong thư mục lab/transaction/ của repo. Tự chạy lại bằng npm run lab:up rồi npm run lab -- transaction (cách chạy); lệnh này ghi lại docs/.vitepress/theme/data/isolation-results.js. Lần chạy lại ngày 2026-10-19 sinh ra dữ liệu giống hệt lần chạy ngày 2026-10-16, ở cả 48 tổ hợp.
isolation-lab.mjs: hai transaction xen kẽ, 6 kịch bản × 4 isolation level × 2 database
js
// Trang database/transaction: chạy các kịch bản lỗi đồng thời với hai transaction xen kẽ,
// trên MySQL và PostgreSQL, ở cả bốn isolation level. Kết quả ghi vào
// docs/.vitepress/theme/data/isolation-results.js (dữ liệu của mô phỏng IsolationLab).
//
// Mỗi bước gửi trên một connection. Bước nào chưa xong sau WAIT ms thì ghi "đang chờ lock…" và gửi bước kế tiếp;
// kết quả thật của bước đó được ghi sau dấu "→". Lưu ý khi viết kịch bản: không đặt bước kế tiếp trên chính
// connection đang chờ lock, vì script sẽ phải đứng chờ connection đó (ví dụ: hai UPDATE trước, hai COMMIT sau).
import mysql from 'mysql2/promise'
import pg from 'pg'
import fs from 'node:fs'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'
const WAIT = 1200
const LEVELS = { ru: 'READ UNCOMMITTED', rc: 'READ COMMITTED', rr: 'REPEATABLE READ', ser: 'SERIALIZABLE' }
const OUT_FILE = new URL('../../docs/.vitepress/theme/data/isolation-results.js', import.meta.url)
const SETUP = [
'DROP TABLE IF EXISTS accounts',
'DROP TABLE IF EXISTS doctors',
'CREATE TABLE accounts (id INT PRIMARY KEY, balance INT NOT NULL)',
'INSERT INTO accounts VALUES (1, 100), (2, 100)',
'CREATE TABLE doctors (name VARCHAR(20) PRIMARY KEY, on_call BOOLEAN NOT NULL)',
"INSERT INTO doctors VALUES ('Alice', TRUE), ('Bob', TRUE)"
]
// [ai chạy, câu lệnh]; 0 là connection thứ ba đọc kết quả cuối sau khi hai transaction xong
export const SCEN = {
dirty: [
[1, 'BEGIN'], [2, 'BEGIN'],
[2, 'UPDATE accounts SET balance = 50 WHERE id = 1'],
[1, 'SELECT balance FROM accounts WHERE id = 1'],
[2, 'ROLLBACK'], [1, 'COMMIT']
],
nonrepeatable: [
[1, 'BEGIN'],
[1, 'SELECT balance FROM accounts WHERE id = 1'],
[2, 'UPDATE accounts SET balance = 50 WHERE id = 1'],
[1, 'SELECT balance FROM accounts WHERE id = 1'],
[1, 'COMMIT']
],
phantom: [
[1, 'BEGIN'],
[1, 'SELECT COUNT(*) AS n FROM accounts WHERE balance >= 100'],
[2, 'INSERT INTO accounts VALUES (3, 200)'],
[1, 'SELECT COUNT(*) AS n FROM accounts WHERE balance >= 100'],
[1, 'UPDATE accounts SET balance = balance + 1 WHERE balance >= 100'],
[1, 'SELECT COUNT(*) AS n FROM accounts WHERE balance >= 100'],
[1, 'COMMIT']
],
lostupdate: [
[1, 'BEGIN'], [2, 'BEGIN'],
[1, 'SELECT balance FROM accounts WHERE id = 1'],
[2, 'SELECT balance FROM accounts WHERE id = 1'],
[1, 'UPDATE accounts SET balance = 90 WHERE id = 1'],
[2, 'UPDATE accounts SET balance = 80 WHERE id = 1'],
[1, 'COMMIT'],
[2, 'COMMIT'],
[0, 'SELECT balance FROM accounts WHERE id = 1']
],
atomic: [
[1, 'BEGIN'], [2, 'BEGIN'],
[1, 'UPDATE accounts SET balance = balance - 10 WHERE id = 1'],
[2, 'UPDATE accounts SET balance = balance - 20 WHERE id = 1'],
[1, 'COMMIT'],
[2, 'COMMIT'],
[0, 'SELECT balance FROM accounts WHERE id = 1']
],
writeskew: [
[1, 'BEGIN'], [2, 'BEGIN'],
[1, 'SELECT COUNT(*) AS n FROM doctors WHERE on_call'],
[2, 'SELECT COUNT(*) AS n FROM doctors WHERE on_call'],
[1, "UPDATE doctors SET on_call = FALSE WHERE name = 'Alice'"],
[2, "UPDATE doctors SET on_call = FALSE WHERE name = 'Bob'"],
[1, 'COMMIT'],
[2, 'COMMIT'],
[0, 'SELECT COUNT(*) AS n FROM doctors WHERE on_call']
]
}
function fmtResult(engine, r) {
if (engine === 'mysql') {
const [rows] = r
if (Array.isArray(rows)) return rows.length ? rows.map((x) => Object.values(x).join(',')).join(' | ') : '(0 dòng)'
return rows.affectedRows !== undefined ? `OK, ${rows.affectedRows} dòng` : 'OK'
}
if (r.command === 'SELECT') return r.rows.length ? r.rows.map((x) => Object.values(x).join(',')).join(' | ') : '(0 dòng)'
if (['UPDATE', 'INSERT', 'DELETE'].includes(r.command)) return `OK, ${r.rowCount} dòng`
return r.command ?? 'OK' // COMMIT của transaction đã lỗi trả về ROLLBACK
}
const fmtErr = (e) => `LỖI ${e.code ?? ''}${e.errno ? ' (' + e.errno + ')' : ''}: ${e.message.split('\n')[0]}`
// Gọn lại cho dễ đọc trong mô phỏng: BEGIN, COMMIT thành công đều ghi là OK
const tidy = (s) => s.replace(/^OK, 0 dòng$/, 'OK').replace(/^(BEGIN|COMMIT)$/, 'OK')
async function connect(engine) {
if (engine === 'mysql') {
const c = await mysql.createConnection({ ...MYSQL, database: 'txn' })
await c.query('SET SESSION innodb_lock_wait_timeout = 5')
return { q: (s) => c.query(s), end: () => c.end(), setLevel: (l) => c.query(`SET SESSION TRANSACTION ISOLATION LEVEL ${l}`) }
}
const c = new pg.Client({ ...PG, database: 'txn' })
await c.connect()
await c.query('SET lock_timeout = 5000')
return { q: (s) => c.query(s), end: () => c.end(), setLevel: (l) => c.query(`SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL ${l}`) }
}
async function run(engine, steps, level) {
const admin = await connect(engine)
for (const s of SETUP) await admin.q(s)
const conns = { 1: await connect(engine), 2: await connect(engine), 0: admin }
await conns[1].setLevel(level)
await conns[2].setLevel(level)
const log = []
const pending = {}
for (const [who, sql] of steps) {
if (pending[who]) {
const r = await pending[who].promise
log[pending[who].idx].out += ` → sau đó: ${r}`
delete pending[who]
}
const idx = log.push({ who, sql, out: '' }) - 1
const p = conns[who].q(sql).then((r) => fmtResult(engine, r), (e) => fmtErr(e))
const res = await Promise.race([p, new Promise((ok) => setTimeout(() => ok(null), WAIT))])
if (res === null) {
log[idx].out = 'đang chờ lock…'
pending[who] = { promise: p, idx }
} else log[idx].out = res
for (const w of Object.keys(pending)) {
if (Number(w) === who) continue
const r = await Promise.race([pending[w].promise, new Promise((ok) => setTimeout(() => ok(null), 300))])
if (r !== null) { log[pending[w].idx].out += ` → sau đó: ${r}`; delete pending[w] }
}
}
for (const w of Object.keys(pending)) { const r = await pending[w].promise; log[pending[w].idx].out += ` → sau đó: ${r}` }
for (const c of Object.values(conns)) await c.end()
return log.map((x) => tidy(x.out))
}
ensureDb('mysql', 'txn')
ensureDb('pg', 'txn')
const STEPS = Object.fromEntries(Object.entries(SCEN).map(([k, v]) => [k, v]))
const OUT = { mysql: {}, pg: {} }
for (const engine of ['mysql', 'pg']) {
for (const [scen, steps] of Object.entries(SCEN)) {
OUT[engine][scen] = {}
for (const [k, level] of Object.entries(LEVELS)) {
OUT[engine][scen][k] = await run(engine, steps, level)
process.stdout.write('.')
}
}
}
const today = new Date().toISOString().slice(0, 10)
const js = `// Kết quả chạy thật (MySQL 8.0, PostgreSQL 17, ${today}) bằng lab/transaction/isolation-lab.mjs: script Node.js (mysql2, pg) điều khiển hai connection xen kẽ.
// Mỗi bước chờ tối đa 1,2 giây; quá thời gian thì ghi "đang chờ lock…" và chạy bước kế tiếp.
// Bước của T0 là một connection thứ ba đọc kết quả cuối cùng sau khi hai transaction kết thúc.
// File này được sinh tự động: sửa kịch bản trong lab/transaction/isolation-lab.mjs rồi chạy npm run transaction.
export const STEPS = ${JSON.stringify(STEPS)}
export const OUT = ${JSON.stringify(OUT)}
`
fs.writeFileSync(OUT_FILE, js)
console.log(`\nĐã ghi ${OUT_FILE.pathname}`)ddl.sql: DDL nằm giữa transaction
sql
-- DDL nằm giữa transaction: MySQL tự commit (dòng INSERT còn, bảng mới còn), PostgreSQL rollback được cả hai.
DROP TABLE IF EXISTS ddl_t;
DROP TABLE IF EXISTS ddl_new;
CREATE TABLE ddl_t (id INT PRIMARY KEY);
BEGIN;
INSERT INTO ddl_t VALUES (1);
CREATE TABLE ddl_new (id INT);
ROLLBACK;
SELECT COUNT(*) AS rows_after_rollback FROM ddl_t;
SELECT COUNT(*) AS new_table_exists FROM information_schema.tables WHERE table_name = 'ddl_new';