Lost Update — Khi 2 request đồng thời cùng ghi đè nhau

User có 100.000 VNĐ trong ví. Mạng lag, họ bấm "Rút tiền" hai lần liên tiếp. Hệ thống xử lý xong, số dư về 0 — đúng. Nhưng họ nhận được 200.000 VNĐ.

Không có lỗi nào được log. Không có exception. Code nhìn vào hoàn toàn đúng logic.

Đây là Lost Update — và nó xảy ra khi concurrency tăng lên mà code vẫn đang nghĩ mình là single-threaded.

Bẫy Read-Modify-Write

Pattern này có trong gần như mọi codebase:

// Request A và Request B cùng chạy, cách nhau vài mili giây
const wallet = await db.query(
  "SELECT balance FROM wallets WHERE user_id = 1"
);
// Cả hai đọc được balance = 100.000

if (wallet.balance >= 100000) {
  const newBalance = wallet.balance - 100000; // = 0
  await db.query(
    `UPDATE wallets SET balance = ${newBalance} WHERE user_id = 1`
  );
}

Timeline thực tế:

Request A: READ  balance = 100.000
Request B: READ  balance = 100.000   ← đọc trước khi A ghi xong
Request A: WRITE balance = 0
Request B: WRITE balance = 0         ← ghi đè, không hề biết A đã xong

Cả hai request đều thấy số dư hợp lệ, đều tính ra kết quả đúng, đều ghi thành công. Chỉ có điều B đã xoá sạch công sức của A.

Kết quả: user rút được 200.000 VNĐ, tài khoản về 0, không có gì báo lỗi.

Giải pháp 1: Atomic Update

Nguyên tắc đơn giản: đừng mang dữ liệu lên RAM để tính toán rồi ghi đè. Giao phép tính đó cho database.

UPDATE wallets
SET balance = balance - 100000
WHERE user_id = 1
  AND balance >= 100000;

Câu lệnh này không đọc giá trị lên rồi tính — nó thực thi phép trừ trực tiếp trên storage engine trong một thao tác duy nhất. Khi hai request chạy đồng thời, database row-level lock đảm bảo chúng chạy tuần tự trên cùng một dòng.

Timeline sau fix:

Request A: UPDATE balance = balance - 100000 WHERE ... AND balance >= 100000
           → LOCK row, thực thi, balance = 0, RELEASE
Request B: UPDATE balance = balance - 100000 WHERE ... AND balance >= 100000
           → Chờ A nhả lock
           → Chạy, điều kiện balance >= 100000 sai (balance = 0)
           → Affected rows = 0

Ở application code, kiểm tra affected rows:

const result = await db.query(
  "UPDATE wallets SET balance = balance - 100000 WHERE user_id = 1 AND balance >= 100000"
);

if (result.affectedRows === 0) {
  throw new Error("Insufficient balance");
}

Không cần transaction bọc ngoài. Atomic update đã là một giao dịch hoàn chỉnh.

Dùng khi: tăng giảm số đơn giản — balance, like, view count, inventory, rate limit counter.

Giải pháp 2: Optimistic Locking

Atomic update không dùng được khi logic phức tạp hơn một phép toán — ví dụ: đọc JSON config, sửa một vài trường theo business logic, rồi ghi lại. Lúc này cần Optimistic Locking.

Ý tưởng: thêm cột version vào bảng. Mỗi lần update thành công thì tăng version. Khi ghi xuống, kiểm tra version có còn khớp với lúc đọc không.

Bước 1: thêm cột version.

ALTER TABLE settings ADD COLUMN version INT NOT NULL DEFAULT 1;

Bước 2: đọc kèm version.

SELECT content, version FROM settings WHERE id = 1;
-- Giả sử lấy được version = 5

Bước 3: xử lý logic ở application, rồi ghi xuống với điều kiện version phải khớp.

UPDATE settings
SET content = '{"theme": "dark", "lang": "vi"}',
    version = version + 1
WHERE id = 1
  AND version = 5;

Nếu trong thời gian xử lý có request khác đã ghi trước (version thành 6), điều kiện AND version = 5 không còn đúng. Affected rows = 0.

Timeline:

Request A: READ  content, version = 5
Request B: READ  content, version = 5
Request A: WRITE ... WHERE version = 5  → OK, version tăng thành 6
Request B: WRITE ... WHERE version = 5  → Affected rows = 0, thất bại

Xử lý thất bại — Retry loop:

const MAX_RETRIES = 3;

async function updateSettings(id, transform) {
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    const row = await db.query(
      "SELECT content, version FROM settings WHERE id = ?", [id]
    );

    const newContent = transform(JSON.parse(row.content));

    const result = await db.query(
      `UPDATE settings
       SET content = ?, version = version + 1
       WHERE id = ? AND version = ?`,
      [JSON.stringify(newContent), id, row.version]
    );

    if (result.affectedRows === 1) return; // thành công

    // version conflict — thử lại với data mới nhất
  }

  throw new Error("Too many concurrent updates, please try again");
}

Retry loop là phần hay bị bỏ qua nhất khi implement Optimistic Locking. Không có nó, user chỉ thấy lỗi khi đáng ra có thể tự phục hồi.

Dùng khi: sửa thông tin user, update JSON config, bài viết trên CMS, bất kỳ bảng nào nhiều admin cùng thao tác.

Giải pháp 3: Pessimistic Locking

Đôi khi bạn không muốn retry — muốn request sau chờ request trước xong hẳn rồi mới bắt đầu đọc, để chắc chắn nhìn thấy data mới nhất ngay từ đầu. Đây là Pessimistic Locking.

BEGIN;

SELECT balance FROM wallets
WHERE user_id = 1
FOR UPDATE;           -- lock dòng này ngay khi đọc

-- xử lý logic phức tạp ở đây ...

UPDATE wallets SET balance = ? WHERE user_id = 1;

COMMIT;

SELECT ... FOR UPDATE lock dòng ngay lúc đọc. Request B đến sau sẽ block ở câu SELECT, chờ đến khi A COMMIT hoặc ROLLBACK xong mới được tiếp tục — và B sẽ đọc được data đã cập nhật của A.

Pessimistic Locking đúng khi:

  • Conflict rate cao (nhiều request tranh nhau cùng một row)
  • Business logic phức tạp, không thể retry dễ dàng (ví dụ: giao dịch ngân hàng có side effect)

Nhưng nó tạo ra lock contention — throughput giảm khi traffic tăng. Cần cân nhắc kỹ trước khi dùng trên bảng hot.

Bảng tổng kết

Atomic Update Optimistic Locking Pessimistic Locking
Cơ chế Phép tính chạy trong DB Cột version phát hiện conflict Lock dòng ngay khi đọc
Ưu điểm Nhanh, code ngắn Không block, throughput cao Đảm bảo không conflict
Nhược điểm Chỉ dùng được phép toán đơn giản Cần xử lý retry Lock contention, giảm throughput
Dùng khi Balance, like, inventory Config JSON, CMS, multi-admin Giao dịch tài chính phức tạp

Ba cách trên giải quyết cùng một vấn đề từ ba góc độ khác nhau. Atomic update là default cho bài toán đếm số. Optimistic Locking là lựa chọn tốt cho phần lớn bài toán CMS và config. Pessimistic Locking là vũ khí cuối khi conflict rate thực sự cao và không thể chấp nhận retry.

Lỗi Lost Update không gây exception — nó chỉ để lại số tiền sai, config bị mất, hay inventory âm thầm lệch. Đó là lý do nó tồn tại lâu trong production trước khi bị phát hiện.