Skip to content

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ĩaAi 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 transactionDatabase (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 nhauDatabase, ở 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ệnDatabase (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ọi pool.query() có thể lấy một connection khác trong pool. BEGIN chạy trên connection này, UPDATE chạ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 ROLLBACK khi 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, TRUNCATE tự 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 INSERT vẫ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).

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ỗiChuyệ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 readTrong 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 readTrong 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 updateHai 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 skewHai 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.

#T1T2Kết quả
1BEGINOK
2BEGINOK
3SELECT balance FROM accounts WHERE id = 1100
4SELECT balance FROM accounts WHERE id = 1100
5UPDATE accounts SET balance = 90 WHERE id = 1OK, 1 dòng
6UPDATE accounts SET balance = 80 WHERE id = 1đang chờ lock… → sau đó: OK, 1 dòng
7COMMITOK
8COMMITOK
9Sau cùng: SELECT balance FROM accounts WHERE id = 180
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ỗiMySQL RUMySQL RCMySQL RR (mặc định)MySQL SERIALIZABLEPostgreSQL RC (mặc định)PostgreSQL RRPostgreSQL SERIALIZABLE
Dirty readCóKhôngKhôngKhông (chờ lock)KhôngKhôngKhông
Non-repeatable readCóCóKhôngKhông (chờ lock)CóKhôngKhông
Phantom (với SELECT)CóCóKhông, nhưng UPDATE vẫn thấy dòng mớiKhông (INSERT chờ lock)CóKhôngKhông
Lost updateCó (còn 80)CóCóChặn bằng deadlock 1213CóChặn bằng lỗi 40001Chặn bằng lỗi 40001
Write skewCóCóCóChặn bằng deadlock 1213CóCóChặn bằng lỗi 40001
UPDATE … SET balance = balance - xĐúng (70)ĐúngĐúngĐúngĐúngLỗi 40001, phải retryLỗ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:

  1. Mức mặc định của cả hai database đều để lọt lost update và write skew. Code đọc rồi ghi đè (SELECT số dư, tính trong app, UPDATE số mới) sai trên cả MySQL lẫn PostgreSQL nếu không tự xử lý.
  2. 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.
  3. “Phantom qua lệnh ghi” trên MySQL REPEATABLE READ. SELECT vẫn đếm 2 dòng như lúc đầu, nhưng UPDATE … WHERE balance >= 100 lại sửa 3 dòng, kể cả dòng transaction khác vừa thêm. Sau đó SELECT cũng thấy 3. Tài liệu MySQL ghi rõ snapshot chỉ áp dụng cho SELECT, còn UPDATE và DELETE làm việc trên bản mới nhất đã commit (MySQL: Consistent Nonlocking Reads).
  4. PostgreSQL ở RR và SERIALIZABLE bắt retry cả câu UPDATE nguyên tử. Trên MySQL, balance = balance - 10 luô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ưởngHệ quảDùng ở đâu
Lock (two-phase locking)Đọc thì giữ shared lock, ghi thì giữ exclusive lock, giữ tới khi commitTransaction phải chờ nhau, dễ deadlockMySQL SERIALIZABLE: mọi SELECT trong transaction thành SELECT … FOR SHARE (MySQL: Transaction Isolation Levels)
MVCC / snapshotMỗ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 skewMySQL 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 transactionKhô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ốngGợi ý
Ứng dụng web thông thườngGiữ 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 lockCâ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 đúngPostgreSQL 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 queryRR (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ỗiMySQL (mysql2: err.code)PostgreSQL (pg: err.code)
DeadlockER_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âuER_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ớ

  1. 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.
  2. Mức mặc định (MySQL RR, PostgreSQL RC) đều để lọt lost update và write skew. Xử lý bằng UPDATE có điều kiện, FOR UPDATE, ràng buộc, hoặc SERIALIZABLE.
  3. 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”: UPDATE thấy dòng mà SELECT không thấy.
  4. 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.
  5. 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.
  6. 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ồnDùng cho phần
PostgreSQL, Transaction IsolationBả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 LevelsRR 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 ReadsSnapshot 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 CommitDDL 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 mysql2 khi gọi pool.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';