0

[Series Phỏng Vấn Backend] #2: Đừng Để Tài Liệu Thiết Kế Đánh Lừa Bạn - Kẽ Hở TOCTOU & Sự Sụp Đổ Của "All-or-Nothing"

Chào mừng anh em quay lại với series Giải Mã Phỏng Vấn Backend. Lần trước chúng ta đã bàn về Dependency Injection. Hôm nay, chúng ta sẽ chuyển sang một chiến trường khốc liệt hơn: Concurrency (Xử lý đồng thời) và Data Integrity (Tính toàn vẹn dữ liệu).

Trọng tâm của bài viết hôm nay là một câu hỏi phỏng vấn thực tế (và cũng là tình huống rất hay gặp khi làm việc với các tài liệu thiết kế tồi).

1. Bối Cảnh Câu Hỏi: Bản Thiết Kế Đầy "Cạm Bẫy"

Nhà tuyển dụng đưa ra một logic code xử lý cập nhật trạng thái đơn hàng (trong Laravel) như sau:

// Bước 1: Lấy danh sách ID hiện có trong DB (Kiểm tra tồn tại)
$missing = array_diff($stockIds, $repo->getExistingInsideIds($stockIds)); 
if (!empty($missing)) {
    return errorResponse('Sai ID, từ chối toàn bộ!'); // Sai 1 ID -> từ chối hết
}

// Bước 2: Update trạng thái (Chỉ chạy khi qua được bài kiểm tra ở trên)
$repo->updateOnlineSaleByInsideIds($statusByStockId);

Kèm theo đó là lời phê chuẩn từ tài liệu thiết kế (System Design): "Chỗ này logic đơn giản, update theo cơ chế All-or-nothing, không cần dùng DB::transaction đâu cho nặng máy."

Nhà tuyển dụng hỏi:

Theo em, tài liệu thiết kế nói vậy có đúng không?

  1. Logic tách làm 2 thao tác (SELECT rồi UPDATE) có tạo ra kẽ hở (Race Condition) nào không khi có nhiều request chạy đồng thời?
  2. Giả sử quá trình UPDATE bị đứt gánh giữa đường (đứt cáp, sập DB), hệ thống sẽ rơi vào trạng thái gì? Nó có phá vỡ lời hứa "All-or-nothing" không?

Nếu bạn trả lời là "Code trông có vẻ ổn, cứ thế mà chạy", cuộc phỏng vấn của bạn có thể kết thúc ở đây. Dưới góc độ của một Software Engineer, bạn phải bóc tách vấn đề thành 2 lỗ hổng nghiêm trọng sau:

2. Giải Mã Lỗ Hổng #1: Ảo Giác Tuần Tự Và Kẽ Hở TOCTOU

Nhiều anh em dev có thói quen suy nghĩ theo đường thẳng: Nhìn thử xem có không \rightarrow Có thì mới sửa.Nhưng hệ thống Web Backend là môi trường đa luồng (Multi-thread/Multi-request). Giữa thời điểm câu lệnh SELECT chạy xong và câu UPDATE rục rịch chạy, một "bóng ma" khác có thể can thiệp vào Database. Trong khoa học máy tính, lỗi này có tên là TOCTOU (Time-Of-Check to Time-Of-Use) - Lỗ hổng giữa lúc kiểm tra và lúc sử dụng.

Hãy xem kịch bản đẫm máu sau:

  • [T0] Request A (Của bạn): Gửi SELECT kiểm tra ID 1, 2, 3. Kết quả trả về: Cả 3 đều tồn tại. (Hoàn thành kiểm tra).
  • [T1] Request B (Của Cronjob hoặc User khác): Vô tình gửi lệnh DELETE ID số 3, hoặc đổi trạng thái khiến nó không còn hợp lệ nữa. Lệnh này chạy xong ngay lập tức.
  • [T2] Request A (Của bạn): Chạy tiếp bước 2, ung dung gửi lệnh UPDATE cho tập ID (1, 2, 3).

Hậu quả: Câu UPDATE của bạn không văng lỗi (Code không crash), nhưng DB chỉ tìm thấy và cập nhật ID 1 và 2. ID 3 đã bốc hơi từ kiếp nào. Bạn tưởng mình đã update thành công 3 ID, nhưng thực tế chỉ có 2. Dữ liệu bắt đầu sai lệch.

3. Giải Mã Lỗ Hổng #2: Sự Sụp Đổ Của Trạng Thái "All-or-Nothing"

Hãy nói về câu khẳng định "Không cần Transaction" của tài liệu thiết kế. Giả sử logic của bạn cần chạy 2 câu lệnh UPDATE liên tiếp (Câu 1: Update trạng thái A, Câu 2: Update trạng thái B).

Kịch bản đứt gãy:

  1. Hệ thống chạy câu UPDATE thứ nhất thành công. Database lập tức ghi nhận.
  2. Hệ thống chuẩn bị chạy câu UPDATE thứ hai thì gặp sự cố: DB server quá tải (timeout), hoặc code PHP văng Exception.

Lúc này, hệ thống rơi vào trạng thái Inconsistent State (Dữ liệu lửng lơ, không nhất quán). Một nửa dữ liệu đã bị đổi, một nửa thì chưa.

Nó có phá vỡ lời hứa "All-or-nothing" không? Chắc chắn là có, phá vỡ hoàn toàn. "All-or-nothing" (Tất cả thành công, hoặc không có gì xảy ra) chính là tính chất Atomicity (Tính nguyên tử) - chữ A trong nguyên lý ACID của Database. Việc không dùng Transaction chính là nhát dao trực tiếp giết chết tính nguyên tử này.

4. Tuyệt Kỹ Phản Biện: Giải Pháp Tối Thượng Với DB::transaction & lockForUpdate

Để đập tan tài liệu thiết kế tồi, bạn hãy đưa ra đoạn code này cho nhà tuyển dụng xem:

use Illuminate\Support\Facades\DB;
use Exception;

DB::transaction(function () use ($stockIds, $statusByStockId, $repo) {
    // 1. SELECT kèm theo khóa dòng (Pessimistic Locking)
    // Các dòng được lấy ra sẽ bị KHÓA CỨNG. Request B muốn xóa/sửa phải đứng đợi!
    $existing = $repo->getExistingInsideIdsWithLock($stockIds); 
    
    // 2. Kiểm tra tính vẹn toàn
    $missing = array_diff($stockIds, $existing);
    if (!empty($missing)) {
        throw new Exception('Sai ID, từ chối toàn bộ!'); // Văng lỗi để Rollback
    }

    // 3. UPDATE an toàn tuyệt đối
    // Lúc này không một ai xen vào giữa được nữa.
    $repo->updateOnlineSaleByInsideIds($statusByStockId);
});

Tại sao cách này là "Vô đối"?

  1. Giải quyết bài toán TOCTOU: Nhờ dùng lockForUpdate() (Bên trong hàm getExistingInsideIdsWithLock), từ lúc bạn kiểm tra cho đến lúc bạn sửa xong, dữ liệu bị khóa chặt (Pessimistic Locking).
  2. Bảo vệ All-or-nothing: Nhờ DB::transaction(), mọi thao tác DB đều ghi vào bản nháp. Nếu Exception văng ra ở bước 2, hệ thống tự động ROLLBACK, database nguyên vẹn. Chỉ khi trót lọt đến dòng cuối cùng, COMMIT mới được gọi.

Kết Luận

Performance (hiệu năng) là quan trọng, nhưng Data Integrity (tính toàn vẹn dữ liệu) là sinh mạng của hệ thống. Đừng bao giờ thỏa hiệp với một tài liệu thiết kế xúi bạn bỏ qua Transaction trong các tác vụ liên hoàn. Việc dám đứng lên chỉ ra lỗ hổng và đề xuất giải pháp Locking chính là ranh giới giữa một thợ gõ code và một Software Engineer thực thụ.

*** Anh em đã bao giờ bị dính quả bug Race Condition nào khóc ròng trong dự án chưa? Cùng chia sẻ dưới comment nhé! Nhớ Upvote để mình có động lực ra tiếp kỳ #3 của series!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí