+3

Đừng để Redis "bốc khói": Tuyệt chiêu chống Spike Enqueue với Bulk Dispatch và Master-Worker Pattern

Chào anh em cộng đồng Viblo!

Hãy tưởng tượng một kịch bản thế này: Hệ thống của bạn chuẩn bị chạy chương trình Sale lúc 00:00 đêm. Sếp yêu cầu đúng 00:00 phải gửi Push Notification (hoặc Email) cho 1 triệu user đang xài app để giục họ vào mua hàng.

Đến 00:00, Cronjob chạy. Và bùm... Hệ thống sập toàn tập. Khách hàng không vào được app, API báo 502 Bad Gateway, Redis CPU vọt lên 100%.

Bạn vội vã check log và ngỡ ngàng nhận ra: Các Queue Worker chưa kịp gửi cái email nào cả, chúng thậm chí chưa kịp chạy! Server chết ngay từ lúc tiến trình đang cố gắng nhét (push) 1 triệu cái Job đó vào Redis.

Chào mừng anh em đến với một khái niệm cực kỳ đáng sợ trong thiết kế hệ thống phân tán: Spike Enqueue (Tăng vọt tải đẩy vào hàng đợi). Bài viết này sẽ mổ xẻ nguyên nhân và cách chúng ta "làm phẳng" đồ thị này nhé. Lên xe!

1. Bản chất của Spike Enqueue là gì?

Khi bạn gọi hàm SendEmailJob::dispatch($user) trong Laravel, framework không thực thi Job đó ngay. Nó sẽ tạo ra một chuỗi JSON (chứa thông tin class, tham số) và thực hiện một Network Request để nhét chuỗi JSON đó vào Queue Broker (thường là Redis, RabbitMQ hoặc Database).

Vấn đề nảy sinh khi bạn nhét thao tác này vào một vòng lặp lớn:

Code "Ngây thơ" gây sập server:

public function pushNotificationToAllUsers()
{
    // Lấy 1 triệu user ra (OOM RAM lầm 1)
    $users = User::all(); 

    foreach ($users as $user) {
        // Thực hiện 1 triệu lệnh Insert/Set vào Redis trong nháy mắt! (Chết Redis)
        SendPromoNotificationJob::dispatch($user); 
    }
}

Việc gọi dispatch() 1 triệu lần đồng nghĩa với việc PHP phải mở 1 triệu kết nối (hoặc I/O operations) tới Redis trong thời gian tính bằng giây. Hậu quả:

  1. Redis bị nghẽn cổ chai (Max Connections hoặc CPU 100%).
  2. Tiến trình PHP chạy vòng lặp bị Time-out.
  3. RAM của Web Server cạn kiệt.

2. Tuyệt chiêu 1: Từ "Bán lẻ" sang "Bán sỉ" với Queue::bulk()

Cách đơn giản nhất để giảm tải Network I/O (Số lượt giao tiếp qua mạng) là thay vì chở từng kiện hàng, chúng ta dùng xe tải chở một lúc 1000 kiện hàng.

Laravel hỗ trợ hàm Queue::bulk() (hoặc dùng Bus::batch()) để đẩy một mảng các Jobs vào Redis trong MỘT LẦN duy nhất. Redis hỗ trợ Pipeline, nên nó ghi mảng này cực kỳ nhanh.

Code "Nâng cấp":

use Illuminate\Support\Facades\Queue;

public function pushNotification()
{
    // Dùng chunk để tránh tốn RAM (giống bài trước mình chia sẻ)
    User::chunk(1000, function ($users) {
        
        $jobs = [];
        foreach ($users as $user) {
            $jobs[] = new SendPromoNotificationJob($user);
        }

        // Đẩy sỉ 1000 jobs vào Queue trong 1 lần kết nối Redis!
        Queue::bulk($jobs); 
    });
}

Với cách này, thay vì 1.000.000 request, bạn chỉ còn 1.000 request tới Redis. Spike Enqueue đã giảm đi 1000 lần!

3. Tuyệt chiêu 2: Master-Worker Pattern (Đẳng cấp Senior)

Dù Queue::bulk() rất tốt, nhưng nếu bạn chạy hàm trên thông qua một HTTP Request hoặc một Cronjob thông thường, tiến trình đó vẫn phải truy vấn Database 1 triệu lần và mất rất nhiều thời gian mới chạy xong. Nhỡ Cronjob bị kill giữa chừng thì sao?

Tư duy của Senior Architect lúc này là: Đừng để Web Server làm việc nặng. Hãy ném luôn cái việc "Tạo Job" vào trong Queue!

Chúng ta tạo ra một Job đặc biệt gọi là Master Job (Người điều phối). Nhiệm vụ của nó không phải là gửi email, mà là truy vấn DB để sinh ra các Worker Jobs (Người làm thuê).

Code "Hạng nặng" (Master-Worker Pattern):

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Queue;
use App\Models\User;

class MasterDispatchJob implements ShouldQueue
{
    use Dispatchable, Queueable;

    public $timeout = 120; // Tránh job chạy quá lâu

    // Lưu lại ID cuối cùng đã xử lý để làm mốc cho lần sau
    public function __construct(public int $lastUserId = 0) {}

    public function handle()
    {
        // 1. Chỉ lấy đúng 1000 user tiếp theo
        $users = User::where('id', '>', $this->lastUserId)
                     ->orderBy('id', 'asc')
                     ->limit(1000)
                     ->get();

        // Nếu hết user thì kết thúc chiến dịch
        if ($users->isEmpty()) {
            return; 
        }

        // 2. Tạo mảng Worker Jobs và đẩy sỉ vào Queue
        $jobs = $users->map(function ($user) {
            return new SendPromoNotificationJob($user);
        })->toArray();
        
        Queue::bulk($jobs);

        // 3. MAGIC NẰM Ở ĐÂY: Master tự phân thân!
        // Gọi lại chính nó để tiếp tục xử lý mẻ tiếp theo.
        $lastId = $users->last()->id;
        self::dispatch($lastId); 
    }
}

Cách kích hoạt: Lúc 00:00, Cronjob của bạn chỉ cần gọi duy nhất 1 dòng code nhẹ tựa lông hồng: MasterDispatchJob::dispatch(0);

Kết quả: Server của bạn không hề có bất kỳ một Spike Enqueue nào. Master Job sẽ từ từ "đẻ" ra các Worker Jobs theo từng block 1000 cái. Cả Database lẫn Redis đều chạy với tải rất êm và bền bỉ. Nếu server lỡ bị sập, Master Job vẫn còn lưu trạng thái $lastUserId và sẽ chạy tiếp đoạn đang dở dang khi server có điện lại!

4. Gia vị cuối cùng: Làm phẳng đồ thị Execution bằng delay()

Sau khi giải quyết xong bài toán Spike Enqueue (Nhét vào hàng đợi), chúng ta lại phải đối mặt với Spike Execution (Lấy ra khỏi hàng đợi).

Nếu bạn có 50 Queue Workers rảnh rỗi, chúng sẽ lao vào cắn 1 triệu cái email này ngay lập tức. Điều này có thể làm sập API của Mailgun/SendGrid hoặc làm sập Database nếu Job của bạn có thao tác Update.

Hãy rải đều áp lực ra bằng một "lời nguyền" Delay ngẫu nhiên:

$jobs = $users->map(function ($user) {
    // Ép Worker phải chờ ngẫu nhiên từ 1 đến 3600 giây (1 tiếng) mới được chạy
    $randomDelay = rand(1, 3600);
    return (new SendPromoNotificationJob($user))->delay(now()->addSeconds($randomDelay));
})->toArray();

Nhờ hàm rand(), 1 triệu email của bạn sẽ được gửi rải rác một cách cực kỳ mượt mà trong suốt 1 tiếng đồng hồ. Đồ thị tải của Server sẽ là một đường thẳng nằm ngang đẹp mắt thay vì một ngọn núi dựng đứng.

5. Lời kết

Trong hệ thống Distributed (phân tán), việc "làm phẳng đồ thị tải" (Flatten the curve) là một nguyên lý sống còn.

Dùng Queue::bulk() để tối ưu kết nối mạng, dùng Master-Worker Pattern để tránh chết yểu tiến trình sinh Job, và dùng delay() để bảo vệ tài nguyên khi thực thi. Kết hợp 3 yếu tố này, bạn có thể tự tin tuyên bố với Sếp: "Hệ thống của em cân mọi thể loại Flash Sale!".

Anh em đã từng làm server bốc khói vì những vòng lặp dispatch ngớ ngẩn nào chưa? Cùng kể lại kỷ niệm rớt nước mắt đó ở phần bình luận nhé!

Chúc anh em code chắc tay, bug bay màu!


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.