🧱 Transaction: khi nào cần và AI hay bỏ sót gì

By Better Wise

Từ đọc truy vấn sang ghi dữ liệu

Module trước bạn học cách đọc: index là gì, EXPLAIN nói gì, và vì sao N+1 làm một trang chậm đi. Bài này đổi hướng sang chỗ nguy hiểm hơn: ghi dữ liệu. Một truy vấn chậm chỉ làm người dùng chờ thêm vài giây; một lần ghi sai làm dữ liệu sai vĩnh viễn, và thường chỉ lộ ra khi đã có người phàn nàn.

Sau bài này, bạn sẽ:

  • Nhận ra một nhóm thao tác ghi bắt buộc phải đi cùng nhau.
  • Chỉ ra được bốn chỗ AI sinh code hay bỏ sót transaction.
  • Đọc một hàm ghi và nói ngay "chỗ này thiếu transaction".
  • Biết khi nào không cần transaction để khỏi bọc thừa.

Khi một nhóm lệnh ghi chỉ có nghĩa khi đi cùng nhau

Chuyển 100k từ tài khoản A sang B là hai lần ghi: trừ A, cộng B. Nếu lệnh trừ A chạy xong rồi tiến trình chết, tiền biến mất khỏi hệ thống. Transaction gom nhiều câu lệnh thành một đơn vị: COMMIT ghi tất cả, ROLLBACK hủy tất cả.

Tính _____ của transaction nghĩa là: hoặc mọi thay đổi cùng được ghi, hoặc không thay đổi nào được ghi.

Câu hỏi thực dụng không phải "có cần transaction không", mà là: hai lần ghi này có được phép lệch nhau không? Nếu không, cần transaction.

Note: Đo trước khi đoán, kể cả khi ghi. Nhưng thứ tự ưu tiên ở đây khác với đọc: đúng trước, nhanh sau. Một transaction thiếu làm hỏng dữ liệu, còn một transaction hơi dài chỉ làm chậm.

Dấu hiệu cần transaction, và dấu hiệu không cần

flowchart TD
    A["🧾<br/>Một nghiệp vụ ghi"] --> B{"Có từ 2 lần ghi<br/>trở lên?"}
    B -- "Không" --> C["Một câu lệnh<br/>DB tự bọc"]
    B -- "Có" --> D{"Được phép lệch nhau<br/>khi bước sau lỗi?"}
    D -- "Không" --> E["🔒<br/>Bọc trong transaction"]
    D -- "Có" --> F["Ghi độc lập"]

Bốn dấu hiệu cần bọc:

  • Từ hai lần ghi trở lên cho cùng một nghiệp vụ (tạo order, trừ kho, ghi payment).
  • Đọc rồi ghi dựa trên giá trị vừa đọc (kiểm tra tồn kho rồi trừ).
  • Ghi vào nhiều bảng có khóa ngoại ràng buộc lẫn nhau.
  • Cần hủy toàn bộ khi bước cuối thất bại.

Ba trường hợp không cần: một câu INSERT/UPDATE/DELETE đơn lẻ (DB tự bọc nó trong transaction ngầm), thao tác chỉ đọc, và hai lần ghi độc lập như tăng counter view với ghi log.

Bốn vết cắt AI hay bỏ sót

Vết cắt 1: hàm ghi nhiều bước, không có transaction

Đây là dạng phổ biến nhất. Code trông rất gọn nên dễ được duyệt cho qua:

// AI sinh: ba lần ghi, không có BEGIN/COMMIT
async function taoDonHang(userId, items, total) {
  const { rows } = await db.query(
    'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id', [userId, total]);
  for (const it of items) {
    await db.query('UPDATE products SET stock = stock - $1 WHERE id = $2', [it.qty, it.id]);
  }
  await db.query('INSERT INTO payments (order_id, amount) VALUES ($1, $2)', [rows[0].id, total]);
  return rows[0];
}

Nếu lệnh ghi payment lỗi, bạn có order đã tạo và kho đã trừ nhưng không có payment. Không có gì sai ở từng câu lệnh, chỉ sai ở chỗ chúng không đi cùng nhau. Bọc lại:

const client = await pool.connect();
try {
  await client.query('BEGIN');
  // ba lệnh ghi như trên, đổi db.query thành client.query
  await client.query('COMMIT');
} catch (err) {
  await client.query('ROLLBACK');
  throw err;
} finally {
  client.release();
}

Điểm quan trọng: mọi lệnh trong transaction phải dùng cùng một client, không phải db.query hay pool.query.

Vết cắt 2: catch nuốt lỗi, không rollback

try {
  await client.query('BEGIN');
  // ... ghi
} catch (e) {
  console.log(e); // rồi thôi
}

Không ROLLBACK thì transaction treo, khóa chưa nhả, và connection trả về pool trong trạng thái dở dang. Luôn ROLLBACK rồi throw lại để tầng trên biết là đã thất bại.

Vết cắt 3: BEGIN trên connection này, INSERT trên connection khác

await pool.query('BEGIN');        // có thể lấy connection số 3
await pool.query('UPDATE ...');   // có thể lấy connection số 7
await pool.query('COMMIT');

Mỗi lệnh qua pool có thể rơi vào một _____ khác nhau, nên ba lệnh trên không nằm trong cùng một transaction. Đây là lỗi rất khó thấy khi đọc code, vì cú pháp SQL hoàn toàn đúng.

Vết cắt 4: kiểm tra rồi mới ghi, thiếu khóa dòng

BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
UPDATE products SET stock = stock - 1 WHERE id = 42 AND stock > 0;
COMMIT;

Thêm _____ để khóa dòng vừa đọc, nếu không hai request đồng thời cùng thấy stock = 1 và cùng trừ, kho thành -1. Cách khác gọn hơn: một câu UPDATE ... WHERE stock > 0 và kiểm tra số dòng bị ảnh hưởng.

Vết cắt 5: gửi email, gọi API bên ngoài khi transaction còn mở

Commit có thể thất bại sau khi email đã gửi, và transaction giữ khóa lâu hơn cần thiết. Đặt các side effect đó _____.

Front: Transaction là gì? Back: Cách gom nhiều câu lệnh ghi thành một đơn vị: tất cả COMMIT hoặc tất cả ROLLBACK.

Front: Hai lệnh SQL kết thúc một transaction? Back: COMMIT (ghi tất cả) và ROLLBACK (hủy tất cả).

Front: Khi nào chắc chắn cần transaction? Back: Khi có từ hai lần ghi trở lên mà chúng không được phép lệch nhau.

Front: Một câu INSERT đơn lẻ có cần BEGIN/COMMIT? Back: Không, database tự bọc nó trong một transaction ngầm.

Front: Lỗi phổ biến nhất của AI ở hàm ghi nhiều bước là gì? Back: Không bọc transaction, để lại dữ liệu nửa vời khi bước sau lỗi.

Front: catch mà không ROLLBACK thì hậu quả? Back: Transaction treo, khóa chưa nhả, connection trả về pool ở trạng thái dở dang.

Front: Vì sao BEGIN và INSERT qua pool có thể không cùng transaction? Back: Mỗi lệnh có thể lấy connection khác nhau; phải giữ một client cho cả transaction.

Front: Kiểm tra rồi ghi (check-then-act) cần gì? Back: Transaction cộng khóa dòng (SELECT ... FOR UPDATE) hoặc một câu UPDATE có điều kiện.

Front: Vì sao không gửi email trong lúc transaction mở? Back: Commit có thể thất bại sau khi email đã gửi; đặt sau commit hoặc dùng outbox.

Front: Transaction quá dài gây hại gì? Back: Giữ khóa lâu, chặn ghi khác, tăng nguy cơ deadlock.

Front: ROLLBACK làm gì? Back: Hủy mọi thay đổi chưa commit trong transaction đó.

Front: Chữ A trong ACID là gì? Back: Atomicity: tất cả hoặc không có gì.

Front: Hai lần ghi độc lập có cần chung transaction? Back: Không, nếu lệch nhau không gây sai dữ liệu thì để riêng sẽ ít khóa hơn.

Bốn thói quen khi đọc code ghi do AI sinh

  1. Đếm số lần ghi trong một hàm. Từ hai trở lên là hỏi ngay: "transaction ở đâu?". Đếm nhanh hơn đọc hiểu nghiệp vụ.
  2. Tìm catch trước khi tìm COMMIT. Nếu catch không có ROLLBACK và không throw lại, dừng lại sửa chỗ đó.
  3. Kiểm tra biến được dùng để chạy lệnh. Nếu là pool.query hoặc db.query bên trong một transaction, gần như chắc chắn sai.
  4. Giữ transaction ngắn. Không gọi mạng, không chờ người dùng, không render template bên trong transaction.

Một lưu ý về mức isolation: mặc định của phần lớn hệ quản trị là đủ cho các trường hợp trong bài này. Khi nào cần mức chặt hơn là chủ đề riêng, đừng tự nâng lên rồi đoán.

Điều đáng nhớ trước khi đụng vào migration

  • Transaction là để giữ đúng, không phải để làm nhanh.
  • Từ hai lần ghi trở lên mà không được phép lệch nhau: bọcchung một transaction.
  • Client dùng để chạy lệnh phải lấy từ transaction, COMMIT chỉ đặt sau khi mọi bước xong, và catch phải ROLLBACK rồi throw lại.
  • Nếu chỉ ghi một lần, hoặc nhiều lần mà lệch nhau không gây sai dữ liệu, đừng thêm transaction: ít khóa hơn, ít deadlock hơn.

Nhắc lại nhanh bằng ba câu điền khuyết:

  • Chữ A trong ACID là _____, nghĩa là tất cả hoặc không có gì.
  • Khi một bước giữa chừng thất bại, phải gọi _____ để không để lại nửa dữ liệu.
  • Bên trong transaction, mọi lệnh phải chạy trên client lấy từ _____, không phải từ pool.

Bài tiếp theo là Migration an toàn khi AI sinh schema: cùng một tư duy "đo trước khi đoán", nhưng áp lên thay đổi cấu trúc bảng, nơi một lệnh ALTER sai có thể khóa bảng hoặc làm mất dữ liệu đang có.

Front: Transaction là gì? Back: Một nhóm thao tác ghi được xử lý như một đơn vị: hoặc tất cả thành công, hoặc không có gì thay đổi.

Front: COMMIT làm gì? Back: Chốt toàn bộ thay đổi của transaction xuống database.

Front: Khi nào một nhóm thao tác ghi cần transaction? Back: Khi các lần ghi phải cùng đúng hoặc cùng sai, ví dụ trừ tiền và cộng tiền trong một lần chuyển khoản.

Front: Lỗi phổ biến nhất của code AI khi ghi nhiều bước là gì? Back: Không bọc transaction, nên bước đầu đã ghi còn bước sau thất bại thì dữ liệu lệch.

Front: Vì sao lấy client từ pool bên trong transaction là sai? Back: Lệnh chạy trên connection khác, không nằm trong transaction và có thể bị treo vì connection đó đang chờ.

Front: Trong catch của transaction phải làm gì? Back: ROLLBACK rồi throw lại lỗi, để không nuốt lỗi và không để transaction mở.

Front: Vì sao không gửi email trong lúc transaction còn mở? Back: COMMIT có thể thất bại sau khi email đã gửi; hãy gửi sau commit hoặc dùng outbox.

Front: Transaction quá dài gây hại gì? Back: Giữ khóa lâu, chặn các ghi khác và tăng nguy cơ deadlock.

Front: ROLLBACK làm gì? Back: Hủy mọi thay đổi chưa commit trong transaction đó.

Front: Chữ A trong ACID là gì? Back: Atomicity: tất cả hoặc không có gì.

Front: Hai lần ghi độc lập có cần chung transaction? Back: Không, nếu lệch nhau không gây sai dữ liệu thì để riêng sẽ ít khóa hơn.

Front: Thói quen đọc code AI: đếm gì trước? Back: Đếm số lần ghi trong một hàm; từ hai trở lên là phải hỏi transaction ở đâu.

Front: Vì sao không giữ transaction trong lúc chờ người dùng hay gọi API mạng? Back: Thời gian chờ đó giữ khóa, làm nghẽn các thao tác ghi khác.