Gửi mail marketing cho 1 triệu active user trong 30 phút

Câu hỏi phỏng vấn: "Marketing muốn gửi mail khuyến mãi cho toàn bộ active user, khoảng 1 triệu người, flash sale nên phải xong trong 30 phút. Bạn làm thế nào?" Trả lời "foreach user rồi Mail::queue()" là trượt. Bài toán thật có 4 phần: chọn đúng người, đẩy đủ nhanh mà không làm sập hệ thống, không gửi trùng, dừng được giữa chừng, và mail vào inbox chứ không vào spam. Phần thứ 4 thường là phần khó nhất, và là phần hay bị quên.

Bối cảnh bài này (theo lựa chọn của bạn): Laravel 12 + PostgreSQL + Redis queue, provider chính là Amazon SES, có so sánh với ESP khác (SendGrid/Mailgun/Postmark) và tự host. Yêu cầu: gửi xong trong 30 phút.

Cái gì đã chạy thật, cái gì là ví dụ. Mọi câu SQL và output là chạy thật trên PostgreSQL 18.4 (my-test-pgdb), bảng tmp_mk_* với 1,2 triệu user giả. Số đo Redis (bộ nhớ, eviction, limiter) là chạy thật trên Redis 8.8.0 + Laravel 12.64.0. Code gọi SES là ví dụ minh hoạ: không gọi được SES thật trong lab, và project chưa cài aws/aws-sdk-php. Hạn mức SES, luật của Gmail/Yahoo, giá tiền lấy từ tài liệu nhà cung cấp — chưa verify bằng lab. Độ trễ API (80 ms, 300 ms) là số giả định để tính toán.

Thuật ngữ dùng trong bài

Thuật ngữNghĩa ngắn
ESP (Email Service Provider)dịch vụ gửi mail hộ bạn: SES, SendGrid, Mailgun, Postmark
deliverabilitytỉ lệ mail vào được inbox (không phải chỉ "gửi đi được")
sender reputationđiểm uy tín của domain/IP gửi mail trong mắt Gmail, Outlook…
warm-uptăng dần lượng mail gửi mỗi ngày để xây uy tín cho domain/IP mới
bounce (hard/soft)mail bị trả về. Hard = địa chỉ không tồn tại (không gửi lại nữa). Soft = hộp thư đầy, server tạm lỗi
complaintngười nhận bấm "Báo cáo spam"
suppression listdanh sách địa chỉ cấm gửi: đã bounce, đã complaint, đã huỷ đăng ký
one-click unsubscribe (RFC 8058)header List-Unsubscribe-Post cho phép Gmail hiện nút "Huỷ đăng ký" và gọi thẳng POST về server bạn
snapshot audiencechụp lại danh sách người nhận vào 1 bảng tại 1 thời điểm, rồi chỉ gửi theo bảng đó
fan-out1 việc lớn tách ra thành nhiều job nhỏ chạy song song
chunk job1 job xử lý 1 đoạn người nhận (ở đây 500 người), thay vì 1 job cho 1 người
claim"nhận việc": đổi trạng thái dòng từ pending sang sending trong 1 câu UPDATE, ai đổi được thì người đó gửi
rate limiter / token bucket / fixed windowbộ đếm giới hạn tốc độ. Fixed window = đếm theo từng khung giây cố định
at-most-once / at-least-oncetối đa 1 lần (có thể mất) / ít nhất 1 lần (có thể trùng)
HOT updateUPDATE của PostgreSQL không phải sửa index, ghi ít hơn nhiều
eviction policyluật Redis chọn key nào để xoá khi đầy RAM
Little's lawsố việc đang chạy cùng lúc = tốc độ × thời gian mỗi việc (L = λ × W)

0. Tính con số trước khi vẽ kiến trúc

Interviewer muốn thấy bạn tính trước. 4 con số dưới đây quyết định mọi lựa chọn sau đó.

Đại lượngCách tínhKết quả
Số người nhận thật1 triệu active − opt-out − suppression (lab: 3% opt-out, 1,2% suppression)971 696 (đo thật)
Tốc độ cần971 696 / 1 800 giây~540 mail/s, đặt mục tiêu 600/s để có dư
Hạn mức SES cần xin600/s × 2 (vì limiter fixed window cho lọt gấp đôi — xem 1.6)≥ 1 200 mail/s. Mặc định account mới chỉ vài chục/s
Số lời gọi APISendBulkEmail 50 người/lần → 600/5012 lần gọi/s
Số worker (Little's law)12 lần/s × 0,3 s (giả định) = 3,6 lời gọi cùng lúc~8 worker là dư. Nếu gửi từng mail (SendEmail, 80 ms): 600 × 0,08 = 48 → cần ~60 worker
Số chunk job971 696 / 5001 944 job (thay vì 971 696 job)
Chi phí SES~0,10 USD / 1 000 mail (giá tham khảo)~100 USD / campaign, chưa tính data transfer
Con số quan trọng nhất không có trong bảng: uy tín domain. Nếu bình thường domain của bạn chỉ gửi 20 nghìn mail/ngày, thì đột ngột bắn 1 triệu mail trong 30 phút sẽ bị Gmail hoãn nhận (defer) và đẩy vào spam, dù hệ thống của bạn chạy hoàn hảo. Xem Bẫy 9 và Case 6.

1. Nó là gì / chạy thế nào

1.1. Cách ngây thơ và vì sao nó chết

// Controller — ĐỪNG làm thế này
foreach (User::where('status', 'active')->get() as $user) {
    Mail::to($user)->queue(new PromoMail($user));
}

Đo thật trên Laravel 12.64.0 + Redis 8.8.0, đẩy 100 000 job rồi nhân 10:

dispatch 100000 job nho tung cai: 55.5s
Mail::to()->queue() 100000 mailable: 81.1s
mk1: 100000 job, 72.5 MB, 760 byte/job, 1 payload mau = 748 byte
mk2: 100000 job, 103.4 MB, 1084 byte/job, 1 payload mau = 1061 byte
mk3: 1944 job, 1.5 MB, 788 byte/job, 1 payload mau = 772 byte
Vấn đềCon số (1 triệu người)
->get() nạp 1 triệu model vào RAMPHP hết memory_limit trước khi gửi được mail nào
Chỉ riêng việc đẩy job vào queue~13,5 phút với Mailable (81,1 s × 10) — request web timeout từ lâu
RAM Redis cho queue~1 GB (103,4 MB × 10). So với 1 944 chunk job: 1,5 MB
Chết giữa vòng lặpKhông biết đã đẩy tới ai. Chạy lại = gửi trùng cho nửa đầu
Không có giới hạn tốc độWorker bắn hết sức → SES trả Maximum sending rate exceeded
Không dừng đượcMarketing phát hiện sai giá sau 5 phút → không có nút dừng, phải xoá queue bằng tay

1.2. Kiến trúc đề xuất

Luồng gửi campaign: snapshot, chunk job, limiter, SES, sự kiện quay về Campaign chụp danh sách người nhận vào bảng campaign_recipients. Từ bảng đó sinh ra 1 944 chunk job trong Redis queue riêng tên marketing. 8 worker lấy job, mỗi lần gọi SES phải xin phép cổng limiter 12 lần mỗi giây, mỗi lần 50 mail. Chấm tròn biểu thị lô 50 mail: đi tới cổng thì dừng chờ, cổng xanh thì đi tiếp tới SES. Sự kiện bounce và complaint từ SES đi qua SNS và webhook, quay về bảng email_suppressions để lần sau không gửi nữa. Mỗi chấm = 1 lời gọi SendBulkEmail (50 mail). Nó phải chờ ở cổng limiter. Campaign status campaign_ recipients 971 696 dòng Redis queue marketing 1 944 chunk job Worker × 8 500 người/job limiter 12/s Amazon SES quota ≥ 1 200/s SNS → webhook email_suppressions bounce, complaint
Hình 1. Từ trái sang: chụp danh sách → 1 944 chunk job → 8 worker → cổng limiter (chấm xanh dừng chờ khi cổng vàng) → SES. Chấm đỏ phía dưới là sự kiện bounce/complaint đi ngược về bảng suppression, để lần snapshot sau loại người đó ra.

Năm quyết định chính, mỗi cái giải một vấn đề ở 1.1:

  1. Snapshot người nhận vào bảng → biết chính xác gửi cho ai, tiếp tục được sau khi chết, không trùng.
  2. Chunk job 500 người thay vì 1 job/người → queue 1,5 MB thay vì 1 GB, dispatch trong ~1 giây.
  3. Claim bằng UPDATE … WHERE status = 'pending' → 2 worker nhận cùng chunk thì chỉ 1 người gửi.
  4. Một limiter chung trong Redis → cả cụm worker không vượt hạn mức SES, dù thêm bớt worker.
  5. Tách hẳn khỏi mail giao dịch (OTP, hoá đơn): khác subdomain, khác queue, khác worker.

1.3. Bước 1 — Snapshot người nhận

Một câu INSERT … SELECT. Cột seq đánh số liên tục 1, 2, 3… để chia chunk đều, không bị lỗ hổng như user_id.

CREATE TABLE campaign_recipients (
  campaign_id bigint NOT NULL,
  seq         int    NOT NULL,          -- 1..N liên tục, để chia chunk
  user_id     bigint NOT NULL,
  email       text   NOT NULL,
  status      text   NOT NULL DEFAULT 'pending',  -- pending | sending | sent | failed | skipped | unknown
  claimed_at  timestamptz,
  sent_at     timestamptz,
  PRIMARY KEY (campaign_id, seq),
  UNIQUE (campaign_id, user_id)         -- 1 người chỉ có 1 dòng / campaign
);
-- KHÔNG thêm index trên status. Xem Bẫy 5.

INSERT INTO campaign_recipients (campaign_id, seq, user_id, email)
SELECT 42, row_number() OVER (ORDER BY u.id), u.id, u.email
FROM users u
WHERE u.status = 'active' AND u.marketing_opt_in
  AND NOT EXISTS (SELECT 1 FROM email_suppressions s WHERE s.email = u.email);

Chạy thật trên 1,2 triệu user (1 triệu active, 981 818 còn opt-in, 12 372 địa chỉ trong suppression):

INSERT 0 971696
Time: 11993.012 ms (00:11.993)
 count  |  max
--------+--------
 971696 | 971696
 total  | heap
--------+-------
 145 MB | 86 MB

12 giây, 145 MB. Chạy trong 1 job riêng, không chạy trong request web. Đây là 1 transaction ghi ~1 triệu dòng: sinh nhiều WAL cùng lúc, replica có thể trễ vài giây. Chạy lúc thấp điểm, hoặc chạy trước giờ gửi 15 phút.

1.4. Bước 2 — Chia chunk và dispatch

<?php
// app/Jobs/StartCampaign.php — ví dụ minh hoạ
namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;

class StartCampaign implements ShouldQueue
{
    use Queueable;

    public int $timeout = 300;

    public function __construct(public int $campaignId) {}

    public function handle(): void
    {
        DB::insert(<<<'SQL'
            INSERT INTO campaign_recipients (campaign_id, seq, user_id, email)
            SELECT ?, row_number() OVER (ORDER BY u.id), u.id, u.email
            FROM users u
            WHERE u.status = 'active' AND u.marketing_opt_in
              AND NOT EXISTS (SELECT 1 FROM email_suppressions s WHERE s.email = u.email)
            ON CONFLICT DO NOTHING
        SQL, [$this->campaignId]);

        DB::table('campaigns')->where('id', $this->campaignId)->update(['status' => 'sending']);

        $max = DB::table('campaign_recipients')->where('campaign_id', $this->campaignId)->max('seq');
        for ($from = 1; $from <= $max; $from += 500) {
            SendCampaignChunk::dispatch($this->campaignId, $from, $from + 499)->onQueue('marketing');
        }
    }
}

1 944 lần dispatch × ~0,55 ms (đo ở 1.1) ≈ 1 giây. Không cần Bus::batch: tiến độ đọc thẳng từ bảng (GROUP BY status mất 267 ms trên 971 696 dòng — đo thật), còn dừng/tiếp tục đi qua cột campaigns.status. Xem so sánh ở mục 4.

1.5. Bước 3 — Chunk job: claim, gửi, đánh dấu

stateDiagram-v2
  [*] --> pending: snapshot
  pending --> skipped: đã huỷ đăng ký / bị suppress sau snapshot
  pending --> sending: claim (UPDATE ... WHERE status = pending)
  sending --> sent: SES trả SUCCESS
  sending --> failed: SES từ chối (MESSAGE_REJECTED...)
  sending --> pending: chưa gọi SES / pause / TRANSIENT_FAILURE
  sending --> unknown: timeout mạng hoặc worker chết giữa lời gọi
  sent --> [*]
  failed --> [*]
  unknown --> [*]: không tự gửi lại (at-most-once)
<?php
// app/Jobs/SendCampaignChunk.php — ví dụ minh hoạ, cần composer require aws/aws-sdk-php
namespace App\Jobs;

use Aws\Exception\AwsException;
use Aws\SesV2\SesV2Client;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\URL;

class SendCampaignChunk implements ShouldQueue
{
    use Queueable;

    public int $timeout = 120;   // queue 'marketing' đặt retry_after = 180, lớn hơn timeout
    public int $tries = 3;

    public function __construct(public int $campaignId, public int $from, public int $to) {}

    public function handle(SesV2Client $ses): void
    {
        $range = [$this->campaignId, $this->from, $this->to];

        // Ai huỷ đăng ký / bị suppress SAU lúc snapshot thì bỏ qua
        DB::update(<<<'SQL'
            UPDATE campaign_recipients r SET status = 'skipped'
            WHERE r.campaign_id = ? AND r.seq BETWEEN ? AND ? AND r.status = 'pending'
              AND (EXISTS (SELECT 1 FROM email_suppressions s WHERE s.email = r.email)
                   OR NOT EXISTS (SELECT 1 FROM users u WHERE u.id = r.user_id AND u.marketing_opt_in))
        SQL, $range);

        // Claim: 2 worker cùng chunk thì chỉ 1 người lấy được dòng
        $rows = DB::select(<<<'SQL'
            UPDATE campaign_recipients SET status = 'sending', claimed_at = now()
            WHERE campaign_id = ? AND seq BETWEEN ? AND ? AND status = 'pending'
            RETURNING seq, user_id, email
        SQL, $range);

        try {
            foreach (array_chunk($rows, 50) as $batch) {
                if (DB::table('campaigns')->where('id', $this->campaignId)->value('status') !== 'sending') {
                    return; // pause / cancel — finally trả phần chưa gửi về pending
                }
                Redis::throttle('ses:marketing')->allow(12)->every(1)->block(30)
                    ->then(fn () => $this->send($ses, $batch));
            }
        } finally {
            // Dòng còn 'sending' = chắc chắn chưa gọi SES → trả về pending cho lần sau
            DB::update("UPDATE campaign_recipients SET status = 'pending'
                        WHERE campaign_id = ? AND seq BETWEEN ? AND ? AND status = 'sending'", $range);
        }
    }

    private function send(SesV2Client $ses, array $batch): void
    {
        try {
            $res = $ses->sendBulkEmail([
                'FromEmailAddress'     => 'Shop <news@news.example.com>',
                'ConfigurationSetName' => 'marketing',
                'DefaultContent'       => ['Template' => [
                    'TemplateName' => "campaign-{$this->campaignId}", 'TemplateData' => '{}',
                ]],
                'DefaultEmailTags'     => [['Name' => 'campaign', 'Value' => (string) $this->campaignId]],
                'BulkEmailEntries'     => array_map(function ($r) {
                    $unsub = URL::signedRoute('unsub', ['user' => $r->user_id, 'c' => $this->campaignId]);
                    return [
                        'Destination' => ['ToAddresses' => [$r->email]],
                        'ReplacementEmailContent' => ['ReplacementTemplate' => [
                            'ReplacementTemplateData' => json_encode(['unsub' => $unsub]),
                        ]],
                        'ReplacementHeaders' => [
                            ['Name' => 'List-Unsubscribe',      'Value' => "<{$unsub}>"],
                            ['Name' => 'List-Unsubscribe-Post', 'Value' => 'List-Unsubscribe=One-Click'],
                        ],
                        'ReplacementTags' => [['Name' => 'seq', 'Value' => (string) $r->seq]],
                    ];
                }, $batch),
            ]);
        } catch (AwsException $e) {
            // Không có response = timeout mạng: SES có thể đã nhận. Không đoán → unknown.
            // Có response (4xx/5xx sau khi SDK tự retry) = chưa gửi → để finally trả về pending.
            if ($e->getResponse() === null) {
                $this->mark(array_column($batch, 'seq'), 'unknown');
            }
            throw $e;
        }

        $byStatus = [];
        foreach ($res['BulkEmailEntryResults'] as $i => $r) {
            $status = match ($r['Status']) {
                'SUCCESS' => 'sent',
                'TRANSIENT_FAILURE', 'ACCOUNT_THROTTLED' => 'pending',
                default => 'failed',
            };
            $byStatus[$status][] = $batch[$i]->seq;
        }
        foreach ($byStatus as $status => $seqs) {
            $this->mark($seqs, $status);
        }
    }

    private function mark(array $seqs, string $status): void
    {
        DB::table('campaign_recipients')
            ->where('campaign_id', $this->campaignId)->whereIn('seq', $seqs)
            ->update(['status' => $status] + ($status === 'sent' ? ['sent_at' => now()] : []));
    }
}

Vài điểm cần nói được khi interviewer hỏi:

Verify: 2 worker cùng nhận 1 chunk (xảy ra khi job chạy lâu hơn retry_after và bị worker khác lấy lại). Worker A claim rồi giữ transaction 2 giây; worker B vào sau 0,5 giây:

----- worker A -----
BEGIN
UPDATE 500
Time: 21.424 ms
 pg_sleep
Time: 2003.260 ms (00:02.003)
COMMIT

----- worker B (vào sau 0.5s, cùng chunk) -----
 pg_sleep
Time: 503.573 ms
UPDATE 0
Time: 1514.102 ms (00:01.514)

B chờ khoá dòng 1,5 giây, rồi PostgreSQL kiểm tra lại status = 'pending' trên bản mới nhất (READ COMMITTED), thấy không còn đúng → UPDATE 0. Không ai gửi trùng.

1.6. Bước 4 — Giới hạn tốc độ và cái bẫy "gấp đôi"

Redis::throttle() của Laravel dùng DurationLimiter: một hash trong Redis với start, end, count. Đọc source 12.64.0:

local function reset()
    redis.call('HMSET', KEYS[1], 'start', ARGV[2], 'end', ARGV[2] + ARGV[3], 'count', 1)
    ...
-- ARGV[2] = time()  (giây nguyên, làm tròn xuống)

Cửa sổ bắt đầu ở giây nguyên, không phải lúc request đầu tiên tới. Đó là fixed window: 50 lần ở cuối giây 1 + 50 lần ở đầu giây 2 đều hợp lệ. Đo thật: 16 process cùng xin allow(50)->every(1) liên tục trong 4 giây, có vạch xuất phát chung:

tong duoc phep: 250 trong 4s (limit 50/s => ky vong ~200)
nhieu nhat trong 1 cua so truot 1s bat ky: 100
  giay +-0.7: 50
  giay +0.0: 50
Fixed window cho lọt gấp đôi giới hạn Trục thời gian từ 0 tới 3 giây, chia 2 cửa sổ cố định từ giây 1 tới 2 và từ giây 2 tới 3. Giới hạn 50 mỗi cửa sổ. Ở giây 1,3 có 50 lần được phép, cột thứ nhất mọc lên. Ở giây 2,0 cửa sổ mới reset và thêm 50 lần nữa, cột thứ hai mọc lên. Một khung trượt dài 1 giây đặt từ 1,3 tới 2,3 chứa cả 2 cột, tức 100 lần trong chưa tới 1 giây, gấp đôi giới hạn. cửa sổ [1s, 2s] — tối đa 50 cửa sổ [2s, 3s] — tối đa 50 0s 1s 2s 3s mức 50/s 1,3s: 50 2,0s: 50 100 lần trong 0,7s = gấp đôi giới hạn
Hình 2. Mỗi cửa sổ cố định đều đúng luật (≤ 50), nhưng một khung 1 giây bất kỳ nằm vắt qua ranh giới thấy 100. SES đếm theo tốc độ thật, không quan tâm cửa sổ của bạn → lúc này bạn nhận Maximum sending rate exceeded.

Cách xử lý, chọn 1 trong 3:

  1. Xin quota SES ≥ 2 × mức limiter (limiter 600/s → quota 1 200/s). Đơn giản nhất. Bài này chọn cách này.
  2. Để SDK retry khi bị throttle. AWS nói nên chờ và gửi lại. Chạy được, nhưng log đầy lỗi và khó phân biệt với lỗi thật.
  3. Tự viết GCRA/token bucket bằng Lua (đếm theo mili giây). Mượt nhất, nhưng thêm code phải tự bảo trì. Chỉ làm khi quota bị khoá cứng, không xin thêm được.

Dùng ->block(30) (worker đứng chờ trong job) chứ không dùng middleware RateLimited. Theo docs Laravel, middleware đó release job về queue, và mỗi lần release vẫn tính 1 lần attempts → với $tries = 3 job bị đánh failed dù chưa lỗi gì. Xem Bẫy 4.

1.7. Bước 5 — Sau khi gửi: bounce, complaint, huỷ đăng ký

sequenceDiagram
  autonumber
  participant G as Gmail / Outlook
  participant S as Amazon SES
  participant N as SNS topic
  participant W as Laravel /ses/events
  participant DB as PostgreSQL
  G-->>S: bounce 550 / user bấm Báo spam
  S->>N: event (tag campaign=42, seq=1234)
  N->>W: POST (có chữ ký SNS)
  W->>W: verify chữ ký SNS
  W->>DB: INSERT email_suppressions ON CONFLICT DO NOTHING
  W->>DB: đếm complaint campaign 42, vượt ngưỡng thì status = paused
  Note over G,W: Nút Huỷ đăng ký của Gmail gọi POST thẳng tới List-Unsubscribe URL
  G->>W: POST /u/{user}?signature=... (List-Unsubscribe=One-Click)
  W->>DB: users.marketing_opt_in = false
// routes/web.php
Route::get('/u/{user}',  [UnsubscribeController::class, 'confirm'])->name('unsub')->middleware('signed');
Route::post('/u/{user}', [UnsubscribeController::class, 'store'])->middleware('signed');

// bootstrap/app.php — Gmail POST không có CSRF token, không miễn thì trả 419
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['u/*', 'ses/events']);
})

Luật của Gmail và Yahoo cho người gửi số lượng lớn (công bố từ 02/2024, chưa verify bằng lab):

Phía SES (theo tài liệu AWS, chưa verify): bật account-level suppression list cho BOUNCE và COMPLAINT. Gửi vào địa chỉ nằm trong global suppression list vẫn bị tính vào quota và bounce rate, nên phải tự lọc từ bảng của mình trước, đừng chỉ trông vào SES.

1.8. Tách marketing khỏi mail giao dịch

LớpMail giao dịch (OTP, hoá đơn)Mail marketingVì sao
Domain gửimail.example.comnews.example.comMarketing bị báo spam thì uy tín subdomain marketing giảm, OTP vẫn vào inbox
SES configuration settransactionalmarketingTách số liệu, tách event, có thể pause riêng
Queue + workerdefault, worker riêngmarketing, 8 worker riêng1 944 chunk job không chiếm worker của OTP (Case 3)
RedisQueue ở Redis riêng, noeviction; cache ở Redis khác, allkeys-lruBẫy 1 và Bẫy 2

2. Lợi ích

3. Tác hại / hạn chế — danh sách bẫy

Xếp theo mức hay dính và mức nguy hiểm. Bẫy nổ trễ (ở campaign thứ 10, ở ngày Redis đầy) để lên trước.

Bẫy 1. Queue dùng chung Redis với cache, policy allkeys-lru → mất cả queue, không một dòng lỗi

Nhiều team để queue, cache, session chung 1 Redis. Cache nên dùng allkeys-lru. Nhưng cả queue là 1 key (queues:marketing là 1 list). Khi RAM đầy, Redis có thể xoá nguyên key đó. Đo thật với maxmemory 20mb:

--- doi sang allkeys-lru:
SET cache:product:1 -> OK
LLEN queues:marketing = 0
evicted_keys:2
allkeys-lru xoá nguyên key queue khi Redis đầy Redis đầy tới maxmemory. Trong đó có một key lớn là list queues:marketing chứa 23 246 job và vài key cache nhỏ. Một lệnh SET cache:product:1 đi vào. Để có chỗ, Redis chọn key để xoá theo LRU và xoá nguyên list queue. LLEN từ 23 246 về 0, lệnh SET trả OK, không có lỗi nào. Redis — maxmemory 20mb — maxmemory-policy allkeys-lru queues:marketing LLEN = 23 246 LLEN = 0 (bị xoá cả key) cache:* SET cache:product:1 Kết quả trả về cho app: OK. Không exception, không log.
Hình 3. Lệnh SET cache đi vào Redis đang đầy. Redis cần chỗ nên xoá key theo LRU — và key đó là nguyên cả hàng đợi. 23 246 job biến mất, app vẫn nhận OK.

Đổi sang noeviction thì không mất job, nhưng lại hỏng chỗ khác — xem Bẫy 2. Chữa: queue ở Redis riêng (REDIS_QUEUE_CONNECTION trỏ sang connection khác), maxmemory-policy noeviction, cảnh báo khi used_memory quá 70%.

Bẫy 2. 1 job / 1 người trên Redis dùng chung, policy noeviction → cả site lỗi

1 triệu job ~ 1 GB. Redis đầy thì mọi lệnh ghi đều bị từ chối, gồm cả ghi cache và session:

errors: 16754, replies: 40000
--- noeviction:
LLEN queues:marketing = 23246
SET cache:product:1 -> OOM command not allowed when used memory > 'maxmemory'.

Trong Laravel, Cache::put() ném exception, login ghi session lỗi → trang chủ 500. Một campaign marketing làm sập việc mua hàng. Chữa: chunk job (1,5 MB), và Redis riêng cho queue.

Bẫy 3. Limiter fixed window cho lọt gấp đôi

Đã đo ở 1.6: allow(50)->every(1) cho 100 lần trong 0,7 giây, và 250 lần trong 4 giây thay vì 200. Nổ trễ vì chỉ lộ ra khi đủ nhiều worker cùng chạy — lúc test với 2 worker thì không thấy. Chữa: quota ≥ 2 × limiter, hoặc token bucket tự viết.

Bẫy 4. Middleware RateLimited đốt hết tries

Theo docs Laravel: "Releasing a rate limited job back onto the queue will still increment the job's total number of attempts." Với 1 944 job tranh nhau 12 lượt/giây, phần lớn job bị release nhiều lần → vượt $tries → MaxAttemptsExceededException → job vào failed_jobs dù chưa gửi mail nào. Chữa: dùng ->block(n) trong job như code ở 1.5, hoặc dùng retryUntil() thay $tries. (Theo docs, chưa chạy lại trong lab bài này.)

Bẫy 5. Thêm index trên status cho dashboard → ghi chậm gần gấp đôi

Mỗi dòng bị UPDATE 2 lần (sending, rồi sent). Nếu cột status có index, PostgreSQL không dùng được HOT update, mỗi UPDATE phải ghi thêm vào index. Đo thật, chạy hết 1 944 chunk:

Biến thểThời gian ghi DB% HOTDung lượng sau
A. fillfactor 100, không index status, 2 UPDATE/dòng62,0 s3,7%145 → 405 MB
B. fillfactor 70, không index status, 2 UPDATE/dòng53,3 s51,8%181 → 369 MB
C. fillfactor 70, có index (campaign_id, status)97,2 s0,0%187 → 392 MB
D. fillfactor 100, 1 UPDATE/dòng (claim theo chunk)32,9 s3,7%145 → 285 MB
F. bảng recipients đứng yên, ghi kết quả vào bảng chỉ-INSERT42,6 s—145 + 78 MB, 0 dead tuple

Đọc bảng cho đúng: DB không phải nút cổ chai — tệ nhất là 97 giây thời gian ghi rải trong 30 phút. Nên chọn theo độ đơn giản và ngữ nghĩa, không tối ưu sớm. Chỉ cần nhớ 2 điều: đừng index status (dashboard dùng GROUP BY status quét 1 campaign, 267 ms là đủ), và bảng phình ~2,8 lần sau mỗi campaign — autovacuum sẽ dọn, xem Dead tuple và index bloat.

Bẫy 6. Người dùng huỷ đăng ký sau lúc snapshot vẫn nhận mail

Snapshot lúc 9:00, user bấm huỷ lúc 9:10, chunk của họ chạy lúc 9:20. Nếu job chỉ đọc bảng recipients thì họ vẫn nhận mail — và bấm "Báo spam" vì tức. Chữa: câu UPDATE … SET status = 'skipped' đầu mỗi chunk (1.5) kiểm tra lại opt-in và suppression ngay trước khi gửi.

Bẫy 7. Link huỷ đăng ký bằng GET bị máy quét "bấm hộ"

Hệ thống bảo mật mail của công ty (và vài app chống virus) tự mở mọi link trong mail để quét. Nếu GET /u/{user} huỷ đăng ký luôn thì user bị huỷ mà không hề bấm. Chữa: GET chỉ hiện trang xác nhận; POST mới thật sự huỷ. Gmail one-click dùng POST nên vẫn chạy.

Bẫy 8. One-click POST của Gmail bị 419

Gmail POST không có CSRF token. Middleware ValidateCsrfToken trả 419, Gmail coi như huỷ thất bại, bạn vi phạm luật "xử lý trong 2 ngày" mà không biết. Chữa: miễn CSRF cho u/*, bảo vệ bằng signed URL thay thế. Dùng URL::signedRoute (không hết hạn) vì mail cũ 6 tháng vẫn phải huỷ được.

Bẫy 9. Domain "lạnh" không thể nhận 1 triệu mail trong 30 phút

Hệ thống chạy đúng 600/s, SES nhận hết, nhưng Gmail trả 4xx (hoãn) hoặc đẩy vào spam vì domain chưa bao giờ gửi nhiều như vậy. SES tự thử lại mail bị hoãn, nên campaign "xong" sau 30 phút nhưng mail tới inbox sau nhiều giờ — hoặc không tới. Đây là giới hạn không sửa được bằng code (chưa verify bằng lab — không mô phỏng được bộ lọc của Gmail). Chữa: warm-up nhiều tuần trước ngày sale; gửi trước cho nhóm hay mở mail (đọc thêm Case 6).

Bẫy 10. retry_after nhỏ hơn thời gian chạy job

Chunk job chờ limiter có thể chạy 30–60 giây. Nếu retry_after = 90 (mặc định) mà job chạy lâu hơn, Redis đưa job cho worker khác → 2 bản chạy song song. Claim chặn được gửi trùng (đã verify ở 1.5), nhưng log có lỗi lạ và attempts tăng. Chữa: $timeout < retry_after cho connection marketing (120 < 180). Xem thêm Scale out queue worker.

Bẫy 11. Worker bị kill giữa lời gọi SES

Deploy gửi SIGTERM, --timeout hết giờ, OOM killer… finally không chạy. 500 dòng kẹt ở sending. Không biết lô đang bay đã tới SES chưa. Chữa: reconcile đổi sending quá 10 phút thành unknown, không tự gửi lại. Với marketing, mất vài trăm mail tốt hơn gửi trùng (đọc thêm mục 4).

Bẫy 12. Bảng recipients lớn dần theo thời gian

1 triệu dòng/campaign × 3 campaign/tuần = ~150 triệu dòng/năm. Đo thật: DELETE 971 696 dòng mất 1,3 s nhưng để lại chừng đó dead tuple chờ VACUUM; DETACH + DROP partition mất 21 ms + 116 ms. Chữa khi cần: partition theo tháng, giữ 90 ngày. Với 1 campaign/tháng thì chưa cần — DELETE là đủ.

4. Trade-off

4.1. Chọn provider

Amazon SESESP khác (SendGrid, Mailgun, Postmark)Tự host (Postfix)Nền tảng marketing (Mailchimp, Braze, Customer.io)
Chi phí 1 triệu mail~100 USDGói tháng, thường vài trăm USD trở lênRẻ tiền server, rất đắt tiền ngườiTính theo số contact — 1 triệu contact thường vài nghìn USD/tháng
Gửi hàng loạtSendBulkEmail, 50/lầnBatch lớn hơn (theo tài liệu: tới 1 000 người/lần)Tự lo hếtKhông cần code — marketing tự bấm
Bounce/complaint/unsubscribeCó suppression list, event qua SNS; unsubscribe tự làmCó sẵn, kèm trang quản lý đăng kýTự làm, tự parse bounceCó sẵn toàn bộ
Uy tín IPIP dùng chung hoặc dedicated (có warm-up tự động)Tương tự, có công cụ warm-upIP mới = vào spam nhiều tuầnCủa họ lo
Hạn mứcPhải xin tăng, AWS xét ~24 giờTheo góiKhông giới hạn — nhưng Gmail giới hạn bạnTheo gói
Dữ liệu userỞ nhà bạnỞ nhà bạnỞ nhà bạnPhải đồng bộ sang họ

Tiêu chí chọn:

4.2. Các lựa chọn bên trong hệ thống

Lựa chọnPhương án APhương án BChọn gì, khi nào
Đơn vị job1 job / 1 người1 job / 500 ngườiB. A chỉ ổn khi dưới ~50 nghìn người và Redis riêng
Danh sách người nhậnQuery thẳng users lúc gửi (cursor)Snapshot vào bảngB: resume được, biết ai đã nhận. A khi campaign nhỏ, gửi 1 lần là xong
Ngữ nghĩa khi không rõat-least-once (gửi lại unknown)at-most-once (bỏ unknown)Marketing: B — trùng làm người nhận khó chịu, báo spam. Mail hoá đơn: A. Xem at-least-once / exactly-once
Theo dõi tiến độBus::batch (bảng job_batches, có cancel())Đếm từ bảng recipientsB: batch không có pause/resume, và job dispatch thêm bởi reconcile không thuộc batch cũ. A ổn nếu chỉ cần huỷ và callback then()
API gửiSendEmail từng mail (hoặc Laravel Mail)SendBulkEmail 50/lầnB cho 600/s (~8 worker thay vì ~60). A khi mỗi mail nội dung khác hẳn nhau, cần render Blade riêng
Nội dungRender Blade trên workerTemplate ở SES, chỉ gửi biếnB nhẹ worker. A khi cần logic phức tạp theo từng người (gợi ý sản phẩm)

5. Khó khăn khi vận hành thực tế

5.1. Đo cái gì

Chỉ sốLấy từ đâuNgưỡng cảnh báo (ví dụ)
Tiến độ theo statusSELECT status, count(*) … GROUP BY statusTốc độ sent dưới 400/s trong 3 phút
Số job trong queue marketingQueue::size('marketing') (tính cả reserved + delayed)Không giảm trong 5 phút
Số dòng unknown / failedbảng recipientsVượt 0,1%
Bounce rateevent SESTrên 2% → dừng (AWS xem xét account khi tới ~5%)
Complaint rateevent SES, Google Postmaster ToolsTrên 0,1% → tự động paused
RAM Redis queueINFO memoryTrên 70% maxmemory
Độ trễ mail OTPthời gian từ dispatch tới gửiTrên 30 giây khi đang có campaign

5.2. Kill switch

Nút "Dừng" trên admin = UPDATE campaigns SET status = 'paused'. Worker thấy ở lô 50 kế tiếp. "Tiếp tục" = đặt lại sending rồi chạy reconcile. Ngoài ra ở tầng SES có thể pause cả configuration set marketing — mail giao dịch không bị ảnh hưởng.

5.3. Reconcile — một lệnh cho resume, retry, và cứu sự cố

// routes/console.php — chạy tay, hoặc mỗi 5 phút khi có campaign đang gửi
Artisan::command('campaign:reconcile {id}', function (int $id) {
    if (Queue::size('marketing') > 0) {
        return $this->info('Queue còn job, chờ chạy hết đã.');
    }
    // Kẹt ở sending quá 10 phút = worker chết giữa chừng → không đoán
    DB::update("UPDATE campaign_recipients SET status = 'unknown'
                WHERE campaign_id = ? AND status = 'sending' AND claimed_at < now() - interval '10 minutes'", [$id]);

    $chunks = DB::select("SELECT DISTINCT (seq - 1) / 500 AS c FROM campaign_recipients
                          WHERE campaign_id = ? AND status = 'pending'", [$id]);
    foreach ($chunks as $r) {
        SendCampaignChunk::dispatch($id, $r->c * 500 + 1, $r->c * 500 + 500)->onQueue('marketing');
    }
    if (! $chunks) {
        DB::table('campaigns')->where('id', $id)->where('status', 'sending')->update(['status' => 'done']);
    }
});

Câu DISTINCT trên 971 696 dòng: 971 ms (đo thật). Đủ dùng cho lệnh chạy vài phút một lần.

5.4. Con người và chi phí

6. Case thực tế và cách xử lý

Case 1 — Campaign đứng ở 62% lúc 3h sáng

  1. Xem GROUP BY status: còn nhiều pending, 0 sending → không ai đang làm.
  2. Queue::size('marketing') = 0? Nếu 0 mà còn pending: job đã mất (Redis restart, bị evict — Bẫy 1) hoặc vào failed_jobs.
  3. Xem failed_jobs: nếu toàn MaxAttemptsExceededException → Bẫy 4. Nếu lỗi SES ACCOUNT_DAILY_QUOTA_EXCEEDED → hết hạn mức 24 giờ: chờ hoặc xin tăng, đừng retry liên tục.
  4. Sửa nguyên nhân, chạy php artisan campaign:reconcile 42. Chỉ các chunk còn pending được dispatch lại, không ai nhận trùng.

Case 2 — Complaint rate lên 0,4% sau 10 phút

  1. Webhook tự đặt paused khi vượt 0,1% (sau khi đã gửi ≥ 10 nghìn mail, để số đủ tin cậy).
  2. Xem complaint rơi vào nhóm nào: user lâu không đăng nhập? Nhập từ nguồn cũ? Thường là nhóm "active" định nghĩa quá rộng.
  3. Thu hẹp: chỉ gửi người đăng nhập hoặc mở mail trong 90 ngày. Đánh dấu phần còn lại skipped, rồi resume.
  4. Theo dõi Google Postmaster Tools vài ngày sau — uy tín tụt thì các campaign sau phải gửi chậm lại.

Case 3 — OTP đăng nhập tới trễ 8 phút khi campaign đang chạy

  1. Nguyên nhân hay gặp: worker xử lý cả default và marketing, hoặc cả 2 dùng chung 1 cổng limiter / 1 quota SES.
  2. Tách supervisor program: queue:work redis --queue=default và queue:work redis --queue=marketing riêng.
  3. Limiter marketing đặt dưới quota SES một khoảng dành cho mail giao dịch (vd quota 1 200/s, marketing 600/s).
  4. Về lâu dài: 2 configuration set, 2 subdomain (mục 1.8).

Case 4 — Marketing phát hiện sai mã giảm giá sau khi gửi 200 nghìn mail

  1. Bấm Dừng → paused. Trong vài giây, mỗi worker gửi nốt tối đa 1 lô 50.
  2. Sửa template SES (campaign-42). Mail đã gửi không sửa được.
  3. Quyết định nghiệp vụ, không phải kỹ thuật: gửi mail đính chính cho 200 nghìn người đã sent (tạo campaign mới với snapshot WHERE campaign_id = 42 AND status = 'sent'), hay làm mã cũ vẫn dùng được.
  4. Resume phần còn lại. Bài học: gửi seed list + chờ 5 phút trước khi mở full.

Case 5 — Deploy giữa lúc gửi, 1 500 dòng kẹt sending

  1. Supervisor restart worker, 3 worker bị kill giữa chunk → finally không chạy (Bẫy 11).
  2. Reconcile sau 10 phút đổi chúng thành unknown.
  3. Muốn cứu: đối chiếu event SES theo tag seq. Dòng nào có event Send/Delivery → sent; còn lại → pending, rồi reconcile.
  4. Phòng: stopwaitsecs của Supervisor lớn hơn thời gian 1 lô, và không deploy worker marketing khi đang có campaign.

Case 6 — CEO: "Phải xong trong 30 phút", nhưng domain mới chỉ gửi 20 nghìn mail/ngày

  1. Nói thẳng: hệ thống đẩy được 600/s, nhưng Gmail sẽ hoãn nhận và đẩy vào spam. "Gửi xong" không bằng "tới inbox".
  2. Đề xuất chia đợt theo mức tương tác: đợt 1 trong 30 phút cho ~200 nghìn người mở mail gần đây (đây là nhóm mua hàng nhiều nhất); đợt 2 rải trong 1–2 ngày cho phần còn lại.
  3. Kế hoạch dài hạn: warm-up domain news. vài tuần trước mùa sale, tăng dần volume mỗi ngày.

7. Câu hỏi phụ interviewer hay hỏi tiếp

Q: Vì sao không foreach rồi Mail::queue()?
A: Đo thật: đẩy 1 triệu Mailable mất ~13 phút và ~1 GB RAM Redis, chết giữa chừng thì không biết đã tới đâu, chạy lại là gửi trùng. Chunk job: 1 944 job, 1,5 MB, ~1 giây.

Q: Có đạt exactly-once được không?
A: Không. Timeout mạng khi gọi SES thì không ai biết mail đã đi chưa. Chỉ chọn được mất (at-most-once) hay trùng (at-least-once). Marketing chọn mất, và đo được bao nhiêu qua trạng thái unknown.

Q: Thêm worker thì nhanh hơn không?
A: Không, khi đã chạm limiter. Tốc độ = min(limiter, quota SES, worker × tốc độ mỗi worker). Với SendBulkEmail 8 worker đã dư. Thêm worker chỉ để chịu lỗi khi 1 VM chết.

Q: Lên 10 triệu user thì sao?
A: Kiến trúc giữ nguyên, đổi số: 19 440 chunk (~15 MB queue), snapshot ~2 phút (nên chia theo khoảng user_id), partition bảng recipients. Nút cổ chai thật là quota SES và uy tín domain, không phải code.

Q: Gửi đúng 9h sáng theo giờ địa phương của từng user?
A: Thêm cột send_after tính lúc snapshot từ timezone user. Chunk theo (send_after, seq), scheduler mỗi phút dispatch các chunk tới giờ. Không dùng delayed job cho 1 triệu người — lại quay về Bẫy 2.

Q: Open rate đo được bao nhiêu phần tin cậy?
A: Ít. Apple Mail Privacy Protection tải trước pixel nên gần như mọi mail Apple đều "mở". Dùng click rate và đơn hàng từ link có UTM để đánh giá campaign.

Q: Sao không dùng Kafka?
A: 1 944 job, 1 lần/tuần — Redis queue dư sức. Kafka có ích khi nhiều hệ thống cùng đọc luồng sự kiện gửi mail, hoặc volume hàng trăm triệu. Thêm Kafka ở đây là thêm 1 hệ thống phải trực 3h sáng mà không giải quyết vấn đề nào.

Q: A/B test tiêu đề thế nào?
A: Lúc snapshot gán variant theo user_id % 100: 10% nhận A, 10% nhận B, 80% chờ. Sau 2 giờ xem click rate, cập nhật template cho 80% còn lại rồi resume.

Q: Làm sao test cả luồng mà không spam ai?
A: Staging trỏ mọi địa chỉ về SES mailbox simulator; test tự động dùng Queue::fake() và mock SesV2Client; test claim bằng 2 session PostgreSQL song song như ở 1.5.

Tóm tắt

Đọc thêm trong repo này

Phụ lục — chạy lại lab

# PostgreSQL: 1,2 triệu user giả, snapshot, mô phỏng 1 944 chunk
P=/var/www/chiennn/postgresql-learning
$P/scripts/psql.sh -c "SELECT count(*) FROM tmp_mk_users"      # bảng tmp_mk_* (đã drop sau khi đo)

# Redis tạm + Laravel 12.64 đọc framework của project (read-only)
docker run -d --rm --name tmp-redis-lab redis:alpine
docker run --rm --network container:tmp-redis-lab \
  -v /var/www/chiennn/laravel-livewire:/var/www/html:ro -v "$PWD/lab":/lab \
  -e REDIS_HOST=127.0.0.1 -e REDIS_PASSWORD= -e REDIS_DB=0 \
  --entrypoint php laravel-app /lab/payload.php 100000

# Eviction: đầy RAM rồi SET 1 key cache
docker exec tmp-redis-lab redis-cli config set maxmemory 20mb
docker exec tmp-redis-lab redis-cli config set maxmemory-policy allkeys-lru
docker stop tmp-redis-lab