0

Race Condition trong Queue và tuyệt chiêu "Atomic Locks" để bảo vệ dữ liệu

Chào anh em, nếu ở bài trước chúng ta đã cùng nhau set up xong Laravel Horizon để scale hệ thống lên hàng chục, hàng trăm worker chạy song song mượt mà, thì hôm nay, chúng ta sẽ phải đối mặt với "mặt tối" của sức mạnh đó.

Khi bạn có quá nhiều worker chạy cùng một lúc (Concurrency), tốc độ xử lý tăng lên, nhưng nguy cơ dữ liệu đâm sầm vào nhau cũng tăng theo cấp số nhân. Hôm nay, chúng ta sẽ mổ xẻ một cơn ác mộng mang tên: Race Condition và cách dùng Atomic Locks để phong ấn nó.

1. Race Condition là gì mà đáng sợ vậy?

Hãy tưởng tượng một kịch bản kinh điển ở các sàn thương mại điện tử: Bạn phát hành một Voucher giảm 500k, giới hạn chỉ 1 người dùng được sử dụng 1 lần.

Một user tinh ranh (hoặc do lỗi UI) đã bấm nút "Áp dụng & Thanh toán" 3 lần liên tục trong vòng 10 mili-giây. API của bạn nhận được 3 request và đẩy thẳng 3 Jobs vào Queue.

Nhờ sức mạnh của Horizon cấu hình ở bài trước, 3 workers rảnh rỗi ngay lập tức chộp lấy 3 Jobs này và xử lý cùng một thời điểm.

Luồng xử lý tấu hài diễn ra như sau:

  • Worker A đọc DB: "Voucher này chưa dùng. OK, cho qua!"
  • Worker B đọc DB (chỉ chậm hơn 2 mili-giây, lúc A chưa kịp lưu): "Voucher này chưa dùng. OK, cho qua!"
  • Worker C cũng đọc DB: "Voucher ngon, quất thôi!"
  • Worker A trừ 500k, update trạng thái Voucher = used.
  • Worker B trừ tiếp 500k, update trạng thái Voucher = used.
  • Worker C trừ nốt 500k...

BÙM! User được giảm giá 1.5 triệu chỉ với 1 voucher. Công ty lỗ nặng, và sáng hôm sau bạn nhận được trát sa thải.

Đó chính là Race Condition (Điều kiện tranh cảnh) - khi kết quả của một tiến trình phụ thuộc vào thứ tự thực thi không thể kiểm soát của các luồng (threads/workers) chạy song song.

2. Tại sao IF - ELSE bình thường trở nên vô dụng?

Nhiều anh em newbie thường viết code xử lý Job thế này:

public function handle()
{
    $voucher = Voucher::find($this->voucherId);

    // Chặn ngay từ cửa?
    if ($voucher->is_used) {
        Log::info('Voucher xài rồi anh êi!');
        return; 
    }

    // Xử lý logic phức tạp, tính toán tiền nong (mất khoảng 1 giây)
    $this->applyDiscount();

    // Cập nhật trạng thái
    $voucher->is_used = true;
    $voucher->save();
}

Vấn đề nằm ở khoảng thời gian giữa lệnh if ($voucher->is_used) và lệnh $voucher->save(). Khoảng thời gian này có thể là vài trăm mili-giây, đủ lâu để hàng tá worker khác lọt qua khe cửa hẹp của lệnh IF trước khi trạng thái được cập nhật.

3. Vị cứu tinh: Atomic Locks (Khóa nguyên tử)

Để giải quyết bài toán trên, chúng ta cần một cơ chế gọi là Mutual Exclusion (Loại trừ tương hỗ). Nghĩa là: Khi tao đang xử lý cái Voucher này, tất cả tụi mày phải đứng xếp hàng chờ tao làm xong!

Trong Laravel, cơ chế này được tích hợp sẵn vô cùng thanh lịch dưới cái tên Atomic Locks, và nó hoạt động tuyệt vời nhất khi kết hợp với Redis (hoặc Memcached).

Ý tưởng cốt lõi:

  1. Worker A vào xử lý, tạo ra một cái ổ khóa có tên process_voucher_123.
  2. Worker B lao vào, thấy ổ khóa process_voucher_123 đang đóng, nó sẽ bị chặn lại hoặc từ bỏ.
  3. Worker A làm xong nhiệm vụ, mở khóa.

Triển khai thực chiến bằng Code Chúng ta sử dụng Facade Cache::lock() của Laravel:

use Illuminate\Support\Facades\Cache;

public function handle()
{
    // Tạo một cái khóa dựa trên ID của voucher, khóa có hiệu lực trong 5 giây
    $lock = Cache::lock('process_voucher_' . $this->voucherId, 5);

    // Thử lấy khóa. Nếu lấy được (khóa chưa ai giữ), tiến hành xử lý
    if ($lock->get()) {
        try {
            $voucher = Voucher::find($this->voucherId);
            
            if ($voucher->is_used) {
                return;
            }

            $this->applyDiscount();
            $voucher->is_used = true;
            $voucher->save();

        } finally {
            // LUÔN LUÔN NHỚ MỞ KHÓA sau khi xong việc, dù có lỗi xảy ra
            $lock->release(); 
        }
    } else {
        // Không lấy được khóa (có worker khác đang làm rồi)
        // Cách 1: Bỏ qua luôn
        Log::warning('Đang có tiến trình khác xử lý voucher này!');
        
        // Cách 2: Quăng Job lại vào Queue để thử lại sau vài giây
        // $this->release(2); 
    }
}

Lưu ý cực mạnh: Luôn để hàm $lock->release(); trong khối finally. Nếu code của bạn throw ra Exception ở giữa chừng mà không mở khóa, chiếc khóa đó sẽ biến thành "Deadlock", chặn đứng mọi process sau này cho đến khi nó hết hạn (sau 5 giây như cấu hình).

4. "Tuyệt chiêu" xịn sò: WithoutOverlapping Middleware

Nếu bạn thấy việc viết try...catch...finally ở trên hơi "cồng kềnh" và làm bẩn logic chính của bạn, thì Laravel đã cung cấp sẵn một Job Middleware cực kì ảo diệu: WithoutOverlapping.

Bạn chỉ cần thêm một method middleware vào trong Job class của bạn. Mọi logic khóa/mở khóa sẽ được Laravel lo từ A-Z ở tầng background:

namespace App\Jobs;

use Illuminate\Queue\Middleware\WithoutOverlapping;

class ProcessVoucher implements ShouldQueue
{
    use Queueable;

    public $voucherId;

    public function __construct($voucherId)
    {
        $this->voucherId = $voucherId;
    }

    /**
     * Xác định middleware đi qua trước khi chạy Job.
     */
    public function middleware()
    {
        // Khóa dựa trên ID voucher. Nếu bị trùng, tự động thả lại vào Queue (release).
        return [(new WithoutOverlapping($this->voucherId))->releaseAfter(5)];
    }

    public function handle()
    {
        // Lúc này bạn hoàn toàn yên tâm code logic ở đây 
        // mà không cần quan tâm đến IF-ELSE lock nữa!
        $voucher = Voucher::find($this->voucherId);
        if ($voucher->is_used) return;
        
        $this->applyDiscount();
        $voucher->is_used = true;
        $voucher->save();
    }
}

Đẹp, gọn, và chuẩn kĩ sư hệ thống! Middleware này sẽ tự động chặn các Job có cùng $voucherId chạy song song, ép chúng phải chạy tuần tự từng cái một.

5. Tổng kết

Qua bài viết này, hi vọng anh em đã hiểu rõ sự nguy hiểm của Race Condition khi thiết kế các hệ thống chạy ngầm (Background Jobs) và cách dùng Atomic Locks để phòng chống thất thoát dữ liệu.

Tuy nhiên, khóa dữ liệu mới chỉ giải quyết được bài toán chạy song song.

Sẽ ra sao nếu hệ thống của bạn (hoặc đối tác) gửi nhầm 2 request giống hệt nhau cách nhau tận 1 phút? Lúc này Atomic Locks vô tác dụng vì Job 1 đã chạy xong và mở khóa từ đời nào rồi. Làm sao để API của bạn đủ thông minh để nhận ra: "Ủa cái giao dịch này nãy tao trừ tiền rồi mà, tao sẽ không trừ nữa đâu!"?

Đó là lúc chúng ta cần một tư duy thiết kế hệ thống ở đẳng cấp cao hơn. Mời anh em đón đọc tiếp:

Bài 3: Hệ thống Idempotency - Bí quyết thiết kế API "miễn nhiễm" với việc bị gọi trùng lặp nhiều lần.

Cảm ơn anh em đã đồng hành. Đừng quên Upvote để tiếp thêm động lực cho tác giả lên bài đều đặn nhé!


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í