Đặt vấn đề: Thách thức Race Condition trong ứng dụng web có lượng truy cập cao
Trong quá trình phát triển các hệ thống thương mại điện tử, đặt vé sự kiện hoặc các ứng dụng có lưu lượng truy cập đồng thời lớn, tranh chấp dữ liệu (Data Contention) là một trong những bài toán phức tạp nhất. Bài toán phổ biến nhất là tình trạng Race Condition (điều kiện tranh đai) xảy ra khi nhiều yêu cầu (requests) cùng truy cập và chỉnh sửa một tài nguyên dùng chung tại một thời điểm.
Hãy tưởng tượng một kịch bản Flash Sale: Bạn có duy nhất 1 sản phẩm còn lại trong kho (stock = 1). Ngay tại thời điểm 00:00, có 100 người dùng bấm nút Mua ngay cùng một lúc. Hệ thống tiếp nhận 100 HTTP requests xử lý song song thông qua các process PHP-FPM.
Luồng xử lý ngây thơ (Naive Approach)
Thông thường, một lập trình viên chưa có nhiều kinh nghiệm sẽ viết đoạn mã xử lý đơn giản như sau:
$stmt = $pdo->prepare("SELECT stock FROM products WHERE id = :id");
$stmt->execute(['id' => $productId]);
$product = $stmt->fetch();
if ($product['stock'] > 0) {
// Giả định xử lý thanh toán, trừ kho...
$newStock = $product['stock'] - 1;
$updateStmt = $pdo->prepare("UPDATE products SET stock = :stock WHERE id = :id");
$updateStmt->execute(['stock' => $newStock, 'id' => $productId]);
}Khi 100 requests cùng thực thi câu lệnh SELECT tại cùng một millisecond, tất cả đều nhận về stock = 1. Kết quả là điều kiện $product['stock'] > 0 đều đúng cho cả 100 process. Sau đó, 100 câu lệnh UPDATE lần lượt ghi đè vào database, dẫn đến việc bán vượt quá số lượng kho (Overselling) – kho hàng bị âm hoặc 100 đơn hàng được tạo ra nhưng thực tế chỉ có 1 sản phẩm trong kho.
Để giải quyết triệt để vấn đề này, chúng ta cần can thiệp vào cấp độ Database Engine (đặc biệt là MySQL InnoDB) bằng các cơ chế khóa (Locking Mechanisms).
Giải pháp 1: Pessimistic Locking (Khóa Tương quan Âm tính)
Pessimistic Locking (Khóa bi quan) hoạt động dựa trên triết lý: "Luôn có khả năng xảy ra xung đột, vì vậy hãy khóa tài nguyên ngay khi đọc dữ liệu". Trong MySQL, cơ chế này được triển khai bằng mệnh đề FOR UPDATE trong môi trường Database Transaction.
Cơ chế hoạt động của SELECT ... FOR UPDATE
Khi bạn thực thi câu lệnh SELECT ... FOR UPDATE bên trong một transaction, InnoDB Storage Engine sẽ đặt một Exclusive Lock (X-Lock) lên các dòng dữ liệu được chọn. Bất kỳ transaction nào khác muốn đọc (với FOR UPDATE) hoặc sửa đổi các dòng dữ liệu này đều phải chờ cho đến khi transaction ban đầu thực hiện COMMIT hoặc ROLLBACK.
Triển khai trong PHP với PDO
try {
$pdo->beginTransaction();
// Đặt Exclusive Lock cho bản ghi product
$stmt = $pdo->prepare("SELECT stock FROM products WHERE id = :id FOR UPDATE");
$stmt->execute(['id' => $productId]);
$product = $stmt->fetch();
if ($product && $product['stock'] > 0) {
// Cập nhật số lượng kho
$updateStmt = $pdo->prepare("UPDATE products SET stock = stock - 1 WHERE id = :id");
$updateStmt->execute(['id' => $productId]);
$pdo->commit();
echo "Đặt hàng thành công!";
} else {
$pdo->rollBack();
echo "Sản phẩm đã hết hàng!";
}
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}Ưu điểm và Nhược điểm của Pessimistic Locking
- Ưu điểm: Đảm bảo tính nhất quán tuyệt đối của dữ liệu (Data Consistency), ngăn chặn hoàn toàn hiện tượng race condition.
- Nhược điểm: Làm giảm throughput (hiệu năng tổng thể) của hệ thống do các process phải chờ đợi (Lock Waiting). Nếu thời gian xử lý trong transaction kéo dài, có thể dẫn đến hiện tượng
Lock Wait Timeout Exceededhoặc nguy cơ xảy ra Deadlock.
Giải pháp 2: Optimistic Locking (Khóa Lạc quan) với Versioning
Trái ngược với Pessimistic Locking, Optimistic Locking (Khóa lạc quan) dựa trên giả định: "Xung đột hiếm khi xảy ra". Do đó, nó không khóa bản ghi trong quá trình đọc mà sử dụng một cơ chế kiểm tra phiên bản (version column) khi cập nhật.
Cấu trúc bảng Database
Chúng ta thêm cột version kiểu số nguyên vào bảng products để theo dõi số lần thay đổi của bản ghi:
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
stock INT NOT NULL,
version INT DEFAULT 0
) ENGINE=InnoDB;Triển khai thuật toán Retry trong PHP
Khi cập nhật, câu lệnh SQL sẽ kiểm tra xem giá trị version hiện tại trong DB có trùng với giá trị version đã đọc lúc ban đầu hay không. Nếu trùng, tiến hành cập nhật và tăng version lên 1. Nếu không trùng, nghĩa là đã có process khác nhanh chân hơn sửa đổi bản ghi, câu lệnh sẽ trả về số dòng ảnh hưởng (affected rows) bằng 0.
$maxRetries = 3;
$attempt = 0;
$success = false;
while ($attempt < $maxRetries && !$success) {
// Lấy dữ liệu sản phẩm cùng với version hiện tại
$stmt = $pdo->prepare("SELECT stock, version FROM products WHERE id = :id");
$stmt->execute(['id' => $productId]);
$product = $stmt->fetch();
if (!$product || $product['stock'] <= 0) {
echo "Sản phẩm đã hết hàng!";
break;
}
$currentVersion = $product['version'];
// Cập nhật chỉ khi version không thay đổi
$updateStmt = $pdo->prepare("UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = :id AND version = :version");
$updateStmt->execute([
'id' => $productId,
'version' => $currentVersion
]);
if ($updateStmt->rowCount() > 0) {
$success = true;
echo "Đặt hàng thành công!";
} else {
$attempt++;
// Tùy chọn: Tạm dừng ngẫu nhiên vài millisecond trước khi retry để giảm xung đột
usleep(rand(10000, 50000));
}
}Ưu điểm và Nhược điểm của Optimistic Locking
- Ưu điểm: Không gây khóa bản ghi ở bước đọc dữ liệu, giúp tăng đáng kể hiệu năng và throughput của hệ thống đọc ghi hỗn hợp. Phù hợp cho ứng dụng có tỷ lệ đọc cao hơn ghi.
- Nhược điểm: Nếu tỷ lệ tranh chấp (concurrency) cực cao, việc retry liên tục sẽ tiêu tốn tài nguyên CPU của máy chủ ứng dụng.
Giải pháp 3: Atomic UPDATE Statement (Cập nhật Nguyên tử)
Đối với các bài toán đơn giản như trừ số lượng tồn kho hay trừ số dư tài khoản, MySQL cho phép áp dụng kỹ thuật Atomic Update mà không cần triển khai cột version phức tạp hay mở transaction thủ công.
Cách triển khai
$stmt = $pdo->prepare("UPDATE products SET stock = stock - 1 WHERE id = :id AND stock >= 1");
$stmt->execute(['id' => $productId]);
if ($stmt->rowCount() > 0) {
echo "Trừ kho thành công! Tiến hành tạo đơn hàng...";
} else {
echo "Đặt hàng thất bại: Sản phẩm đã hết hàng!";
}Tại sao cách này hoạt động an toàn?
Bản thân câu lệnh UPDATE trong MySQL InnoDB là một thao tác nguyên tử (Atomic Operation). Khi thực thi UPDATE, InnoDB tự động tạo một Row-level Lock ngắn trên bản ghi đó. Điều kiện AND stock >= 1 đảm bảo chỉ có bản ghi đáp ứng điều kiện tồn kho tại đúng thời điểm câu lệnh chạy mới được sửa đổi. Các request đến sau khi kho đã về 0 sẽ trả về rowCount() = 0.
So sánh và Chiến lược Lựa chọn Kiến trúc
- Sử dụng Atomic UPDATE: Khi logic kinh doanh đơn giản (chỉ đơn thuần trừ số lượng kho) và không phụ thuộc vào nhiều bảng dữ liệu khác nhau. Đây là cách nhanh nhất và hiệu quả nhất.
- Sử dụng Optimistic Locking: Khi hệ thống có quy mô lớn, lưu lượng truy cập cao, tỷ lệ tranh chấp ghi ở mức trung bình và muốn tránh việc giữ database lock quá lâu.
- Sử dụng Pessimistic Locking: Khi cần đảm bảo tuyệt đối tính toàn vẹn dữ liệu trong các nghiệp vụ phức tạp liên quan đến tài chính, ngân hàng, hoặc khi giao dịch bắt buộc phải thực thi qua nhiều bước trung gian phụ thuộc lẫn nhau.
Việc làm chủ các kỹ thuật xử lý tranh chấp dữ liệu và tối ưu hóa truy vấn cơ sở dữ liệu là bước đệm quan trọng để phát triển từ một lập trình viên cơ bản trở thành một Senior Backend Engineer. Nếu bạn là người mới bắt đầu và muốn xây dựng nền tảng tư duy lập trình web bài bản, vững chắc từ những dòng code đầu tiên, Tham khảo khóa học "Lập trình PHP & MySQL cơ bản dành cho người mới" tại đây.






