Ngừng Ghi Đè Dữ Liệu: Dùng Optimistic Locking Tránh Lost Update

Ngừng Ghi Đè Dữ Liệu: Dùng Optimistic Locking Tránh Lost Update

Hướng dẫn dùng Optimistic Locking để chống ghi đè dữ liệu âm thầm trong ứng dụng web khi xử lý bất đồng bộ. Có ví dụ code SQL và TypeScript.

Thử tưởng tượng hai quản trị viên cùng bấm nút cập nhật số lượng kho cho một mặt hàng tại cùng một giây. Nếu không có cơ chế kiểm soát bất đồng bộ, thao tác của người sau sẽ âm thầm đè bẹp dữ liệu của người trước mà không phát ra bất kỳ lỗi nào.

Cơn Ác Mộng Âm Thầm Mang Tên Lost Update

Trong các ứng dụng web dạng stateless (phi trạng thái), một request HTTP không giữ kết nối giao dịch dài hạn với database. Khi người dùng A mở trang chỉnh sửa sản phẩm, ứng dụng sẽ đọc bản ghi có version = 1 từ MySQL hoặc PostgreSQL rồi trả về giao diện. Người dùng A mất 10 giây để xem lại thông tin, chỉnh sửa tiêu đề và bấm Lưu. Trong lúc đó, người dùng B cũng mở đúng sản phẩm này, đổi số lượng tồn kho từ 50 xuống 10, và bấm Lưu trước người dùng A 2 giây.

Khi người dùng B bấm Lưu, dữ liệu dưới database lập tức cập nhật thành công. Nhưng 2 giây sau, request của người dùng A gửi tới. Vì payload của người dùng A chứa thông tin đọc từ 10 giây trước, câu lệnh UPDATE thông thường sẽ được thực thi:

UPDATE products 
SET title = 'Chuột Không Dây Pro', stock = 50 
WHERE id = 42;

Câu lệnh của người dùng A đã ghi đè số lượng tồn kho trở lại 50! Thao tác sửa số lượng của người dùng B hoàn toàn bị xóa sạch (Lost Update) mà không có cảnh báo nào phát ra trong log hệ thống. Trong kỹ thuật phần mềm, đây là sự cố bất đồng bộ điển hình mang tên Lost Update Problem.

Tại Sao Pessimistic Locking Không Phải Lúc Nào Cũng Tốt?

Lập trình viên đang xem lại code trên màn hình đôi

Khi gặp vấn đề race condition, phản xạ đầu tiên của nhiều lập trình viên là dùng Pessimistic Locking (khoá bi quan) với câu lệnh SQL SELECT ... FOR UPDATE. Cách này giữ khoá hàng (row lock) ở cấp độ database cho đến khi transaction kết thúc.

Tuy nhiên, trong kiến trúc Web API hiện đại, việc giữ row lock xuyên qua các request HTTP là một thảm hoạ. Nó khiến database connection pool bị nghẽn do phải chờ độ trễ mạng hoặc thao tác người dùng, làm giảm đáng kể throughput của toàn hệ thống và rất dễ gây ra deadlock. Với web app, chúng ta cần một giải pháp nhẹ nhàng hơn, không chiếm dụng tài nguyên database: Optimistic Locking (khoá quan lạc).

Cơ Chế Hoạt Động Của Optimistic Locking

Optimistic Locking hoạt động dựa trên giả định: phần lớn thời gian các giao dịch sẽ không xung đột với nhau. Thay vì khoá bản ghi ngay từ lúc đọc, chúng ta chỉ kiểm tra xem dữ liệu có bị ai khác sửa đổi trong khoảng thời gian từ lúc đọc đến lúc ghi hay không.

Để cài đặt Optimistic Locking, chúng ta chỉ cần thêm một cột quản lý phiên bản vào bảng database—thường là một cột số nguyên version hoặc cột mốc thời gian updated_at.

Quy trình xử lý diễn ra qua 4 bước đơn giản:

  1. Đọc (Read): Lấy bản ghi cùng số version hiện tại (ví dụ: version = 1).
  2. Thay đổi (Modify): Xử lý logic và chuẩn bị dữ liệu mới trên bộ nhớ ứng dụng.
  3. Ghi có điều kiện (Write with Condition): Chạy câu lệnh UPDATE kiểm tra cả khóa chính lẫn số version đã đọc ở Bước 1, đồng thời tăng version lên 1:
UPDATE products 
SET title = 'Chuột Không Dây Pro', stock = 45, version = version + 1 
WHERE id = 42 AND version = 1;
  1. Kiểm tra số dòng bị ảnh hưởng: Nếu có ai đó đã sửa bản ghi trước đó, version dưới DB đã tăng lên 2. Câu lệnh UPDATE của bạn sẽ không khớp dòng nào cả (affected_rows = 0).

Thực Chiến Cài Đặt Với TypeScript & SQL

Hãy hiện thực hóa ý tưởng này bằng đoạn code thực tế. Trong Node.js/TypeScript, bạn có thể kiểm tra kết quả trả về từ database engine như sau:

interface ProductUpdatePayload {
  id: number;
  title: string;
  stock: number;
  expectedVersion: number;
}

async function updateProduct(payload: ProductUpdatePayload): Promise<boolean> {
  const result = await db.query(
    `UPDATE products 
     SET title = $1, stock = $2, version = version + 1 
     WHERE id = $3 AND version = $4`,
    [payload.title, payload.stock, payload.id, payload.expectedVersion]
  );

  // Nếu rowCount === 0, nghĩa là đã có process khác cập nhật dữ liệu trước đó!
  if (result.rowCount === 0) {
    throw new ConcurrencyConflictError(
      `Sản phẩm ${payload.id} đã bị thay đổi bởi người khác. Vui lòng tải lại trang.`
    );
  }

  return true;
}

Cách làm này cực kỳ sạch sẻ: không giữ khoá lâu, không leak connection, và đảm bảo an toàn bất đồng bộ ngay tại engine của database.

Xử Lý Xung Đột Tại API Layer Và Frontend

Khi xảy ra lỗi Optimistic Lock (rowCount === 0), backend API nên từ chối request và trả về HTTP status code phù hợp: 409 Conflict.

{
  "error": "CONCURRENCY_CONFLICT",
  "message": "Dữ liệu đã bị thay đổi bởi người dùng khác. Vui lòng lấy dữ liệu mới và thử lại.",
  "current_version": 2
}

Tùy theo bài toán nghiệp vụ, phía ứng dụng client có thể xử lý lỗi 409 theo 2 hướng:

  1. Tự động Retry (Background): Nếu thao tác chỉ cập nhật các trường độc lập, client có thể lấy lại state mới nhất, hợp nhất dữ liệu và thử lại request một lần nữa.
  2. Thông báo người dùng (Frontend): Hiển thị dialog so sánh diff: "Đồng nghiệp của bạn vừa cập nhật bản ghi này 5 giây trước. Bạn có muốn ghi đè hay tải lại dữ liệu mới?"

Đúc Kết Thực Chiến

  • Ưu tiên Optimistic Locking cho các ứng dụng web và REST/GraphQL API nơi thao tác của người dùng kéo dài qua nhiều request HTTP.
  • Luôn kiểm tra số dòng bị ảnh hưởng: Việc lệnh UPDATE chạy không báo lỗi cú pháp không có nghĩa là dữ liệu đã được lưu nếu điều kiện WHERE version = x trả về 0 dòng.
  • Dùng HTTP 409 Conflict để thông báo rõ ràng cho client khi xảy ra race condition.

Chỉ với một cột version đơn giản, bạn đã loại bỏ hoàn toàn nguy cơ hỏng dữ liệu âm thầm và giúp hệ thống vận hành cực kỳ vững chắc dưới tải cao.

GENERATED · REVIEWED BY PKN · 2026-08-08

0

Kết nối

04

Phản hồi

Đang tải bình luận…