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ảngtmp_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àiaws/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 |
| deliverability | tỉ 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-up | tă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 |
| complaint | người nhận bấm "Báo cáo spam" |
| suppression list | danh 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 audience | chụ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-out | 1 việc lớn tách ra thành nhiều job nhỏ chạy song song |
| chunk job | 1 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 window | bộ đếm giới hạn tốc độ. Fixed window = đếm theo từng khung giây cố định |
| at-most-once / at-least-once | tối đa 1 lần (có thể mất) / ít nhất 1 lần (có thể trùng) |
| HOT update | UPDATE của PostgreSQL không phải sửa index, ghi ít hơn nhiều |
| eviction policy | luật Redis chọn key nào để xoá khi đầy RAM |
| Little's law | số 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ượng | Cách tính | Kết quả |
|---|---|---|
| Số người nhận thật | 1 triệu active − opt-out − suppression (lab: 3% opt-out, 1,2% suppression) | 971 696 (đo thật) |
| Tốc độ cần | 971 696 / 1 800 giây | ~540 mail/s, đặt mục tiêu 600/s để có dư |
| Hạn mức SES cần xin | 600/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 API | SendBulkEmail 50 người/lần → 600/50 | 12 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 job | 971 696 / 500 | 1 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 RAM | PHP 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ặp | Khô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 được | Marketing 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
Năm quyết định chính, mỗi cái giải một vấn đề ở 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.
- Chunk job 500 người thay vì 1 job/người → queue 1,5 MB thay vì 1 GB, dispatch trong ~1 giây.
- Claim bằng UPDATE … WHERE status = 'pending' → 2 worker nhận cùng chunk thì chỉ 1 người gửi.
- 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.
- 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:
- Không lưu
MessageIdtừng dòng. Tagcampaign+seqđi kèm mọi sự kiện SES gửi về (delivery, bounce, complaint), nên map ngược được mà không cần thêm 1 lần UPDATE/dòng. - Template nằm ở SES, PHP chỉ gửi dữ liệu thay thế. Không render Blade 1 triệu lần trên worker.
- Kiểm tra pause mỗi lô 50, không chỉ đầu job. Bấm dừng thì tối đa 50 mail/worker còn bay. ~19 nghìn câu SELECT trong 30 phút = ~11 câu/s, không đáng kể.
- SES nhận tối đa 50 entry/lần (theo tài liệu AWS, chưa verify bằng lab). Hạn mức tốc độ SES tính theo số người nhận, không theo số lần gọi API.
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
Maximum sending rate exceeded.Cách xử lý, chọn 1 trong 3:
- 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.
- Để 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.
- 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):
- Có SPF, DKIM và DMARC (ít nhất
p=none), domain trongFromphải khớp (alignment). - Mail marketing phải có one-click unsubscribe (
List-Unsubscribe+List-Unsubscribe-Post), và xử lý huỷ đăng ký trong 2 ngày. - Tỉ lệ bị báo spam trong Google Postmaster Tools giữ dưới 0,3% (nên dưới 0,1%).
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ớp | Mail giao dịch (OTP, hoá đơn) | Mail marketing | Vì sao |
|---|---|---|---|
| Domain gửi | mail.example.com | news.example.com | Marketing bị báo spam thì uy tín subdomain marketing giảm, OTP vẫn vào inbox |
| SES configuration set | transactional | marketing | Tách số liệu, tách event, có thể pause riêng |
| Queue + worker | default, worker riêng | marketing, 8 worker riêng | 1 944 chunk job không chiếm worker của OTP (Case 3) |
| Redis | Queue ở Redis riêng, noeviction; cache ở Redis khác, allkeys-lru | Bẫy 1 và Bẫy 2 | |
2. Lợi ích
- Tiếp tục được sau mọi sự cố. Trạng thái nằm trong PostgreSQL, không nằm trong queue.
Redis mất hết job? Chạy lệnh reconcile (mục 5.3) là dispatch lại đúng các chunk còn
pending. - Không gửi trùng nhờ claim. Chỗ duy nhất có thể trùng hoặc mất đã được đặt tên rõ:
unknown. - Dừng được trong vài giây. Một câu
UPDATE campaigns SET status = 'paused'. - Nhẹ cho hạ tầng. Queue 1,5 MB. DB tổng cộng ~1 phút thời gian ghi, rải trong 30 phút.
- Tốc độ không phụ thuộc số worker. Thêm worker để chịu lỗi, còn limiter giữ trần.
- Trả lời được "ai đã nhận?" — cần cho khiếu nại, cho tính hiệu quả campaign, cho pháp lý.
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
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 | % HOT | Dung lượng sau |
|---|---|---|---|
| A. fillfactor 100, không index status, 2 UPDATE/dòng | 62,0 s | 3,7% | 145 → 405 MB |
| B. fillfactor 70, không index status, 2 UPDATE/dòng | 53,3 s | 51,8% | 181 → 369 MB |
| C. fillfactor 70, có index (campaign_id, status) | 97,2 s | 0,0% | 187 → 392 MB |
| D. fillfactor 100, 1 UPDATE/dòng (claim theo chunk) | 32,9 s | 3,7% | 145 → 285 MB |
| F. bảng recipients đứng yên, ghi kết quả vào bảng chỉ-INSERT | 42,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 SES | ESP khác (SendGrid, Mailgun, Postmark) | Tự host (Postfix) | Nền tảng marketing (Mailchimp, Braze, Customer.io) | |
|---|---|---|---|---|
| Chi phí 1 triệu mail | ~100 USD | Gói tháng, thường vài trăm USD trở lên | Rẻ tiền server, rất đắt tiền người | Tính theo số contact — 1 triệu contact thường vài nghìn USD/tháng |
| Gửi hàng loạt | SendBulkEmail, 50/lần | Batch lớn hơn (theo tài liệu: tới 1 000 người/lần) | Tự lo hết | Không cần code — marketing tự bấm |
| Bounce/complaint/unsubscribe | Có suppression list, event qua SNS; unsubscribe tự làm | Có sẵn, kèm trang quản lý đăng ký | Tự làm, tự parse bounce | Có sẵn toàn bộ |
| Uy tín IP | IP dùng chung hoặc dedicated (có warm-up tự động) | Tương tự, có công cụ warm-up | IP mới = vào spam nhiều tuần | Của họ lo |
| Hạn mức | Phải xin tăng, AWS xét ~24 giờ | Theo gói | Không giới hạn — nhưng Gmail giới hạn bạn | Theo gói |
| Dữ liệu user | Ở nhà bạn | Ở nhà bạn | Ở nhà bạn | Phải đồng bộ sang họ |
Tiêu chí chọn:
- Đã ở AWS, team có dev lo được bounce/unsubscribe → SES. Rẻ nhất khi volume lớn.
- Team nhỏ, muốn có sẵn trang quản lý đăng ký, dashboard, hỗ trợ deliverability → ESP.
- Marketing muốn tự chia nhóm, A/B test, lên lịch mà không nhờ dev → mua nền tảng, đừng tự build. Đây là câu trả lời Tech Lead nên dám nói.
- Tự host: gần như không bao giờ. Chỉ khi có luật bắt dữ liệu không ra ngoài, và có người chuyên lo deliverability.
4.2. Các lựa chọn bên trong hệ thống
| Lựa chọn | Phương án A | Phương án B | Chọn gì, khi nào |
|---|---|---|---|
| Đơn vị job | 1 job / 1 người | 1 job / 500 người | B. A chỉ ổn khi dưới ~50 nghìn người và Redis riêng |
| Danh sách người nhận | Query thẳng users lúc gửi (cursor) | Snapshot vào bảng | B: 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 recipients | B: 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ửi | SendEmail từng mail (hoặc Laravel Mail) | SendBulkEmail 50/lần | B 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 dung | Render Blade trên worker | Template ở SES, chỉ gửi biến | B 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ừ đâu | Ngưỡng cảnh báo (ví dụ) |
|---|---|---|
| Tiến độ theo status | SELECT status, count(*) … GROUP BY status | Tốc độ sent dưới 400/s trong 3 phút |
Số job trong queue marketing | Queue::size('marketing') (tính cả reserved + delayed) | Không giảm trong 5 phút |
Số dòng unknown / failed | bảng recipients | Vượt 0,1% |
| Bounce rate | event SES | Trên 2% → dừng (AWS xem xét account khi tới ~5%) |
| Complaint rate | event SES, Google Postmaster Tools | Trên 0,1% → tự động paused |
| RAM Redis queue | INFO memory | Trên 70% maxmemory |
| Độ trễ mail OTP | thời gian từ dispatch tới gửi | Trê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í
- Test không gửi thật: SES có mailbox simulator (
success@simulator.amazonses.com,bounce@…,complaint@…) — chạy thử cả luồng event mà không hại uy tín (theo tài liệu AWS). - Gửi thử cho nội bộ trước (seed list 20 người, có đủ Gmail/Outlook/Yahoo) và xem mail rơi vào tab nào.
- Quy trình duyệt: marketing soạn, 1 người khác bấm gửi. Sai giá trong mail 1 triệu người là sự cố thật, không thu hồi được.
- Xin quota trước ít nhất vài ngày. AWS xét ~24 giờ nhưng có thể hỏi thêm thông tin.
6. Case thực tế và cách xử lý
Case 1 — Campaign đứng ở 62% lúc 3h sáng
- Xem
GROUP BY status: còn nhiềupending, 0sending→ không ai đang làm. Queue::size('marketing')= 0? Nếu 0 mà còn pending: job đã mất (Redis restart, bị evict — Bẫy 1) hoặc vàofailed_jobs.- Xem
failed_jobs: nếu toànMaxAttemptsExceededException→ Bẫy 4. Nếu lỗi SESACCOUNT_DAILY_QUOTA_EXCEEDED→ hết hạn mức 24 giờ: chờ hoặc xin tăng, đừng retry liên tục. - 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
- Webhook tự đặt
pausedkhi vượt 0,1% (sau khi đã gửi ≥ 10 nghìn mail, để số đủ tin cậy). - 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.
- 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. - 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
- Nguyên nhân hay gặp: worker xử lý cả
defaultvàmarketing, hoặc cả 2 dùng chung 1 cổng limiter / 1 quota SES. - Tách supervisor program:
queue:work redis --queue=defaultvàqueue:work redis --queue=marketingriêng. - 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).
- 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
- Bấm Dừng →
paused. Trong vài giây, mỗi worker gửi nốt tối đa 1 lô 50. - Sửa template SES (
campaign-42). Mail đã gửi không sửa được. - 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 snapshotWHERE campaign_id = 42 AND status = 'sent'), hay làm mã cũ vẫn dùng được. - 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
- Supervisor restart worker, 3 worker bị kill giữa chunk →
finallykhông chạy (Bẫy 11). - Reconcile sau 10 phút đổi chúng thành
unknown. - 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. - Phòng:
stopwaitsecscủ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
- 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".
- Đề 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.
- 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
- Tính trước: ~540 mail/s, 1 944 chunk, 12 lời gọi SES/s, ~8 worker, quota SES ≥ 1 200/s.
- Snapshot người nhận vào bảng → chunk 500 → claim bằng UPDATE có điều kiện → limiter chung → SendBulkEmail.
- Trạng thái nằm ở PostgreSQL, không ở queue. Một lệnh reconcile lo resume, retry và cứu sự cố.
- Bẫy đã đo thật: Redis chung với cache làm mất cả queue hoặc làm sập cache; fixed window cho lọt gấp đôi;
index trên
statuslàm ghi chậm gần gấp đôi. - Phần khó nhất không nằm trong code: uy tín domain, one-click unsubscribe, complaint rate. Tách marketing khỏi mail giao dịch.
- Dám nói "mua nền tảng marketing" khi marketing cần tự chia nhóm, A/B test, lên lịch.
Đọc thêm trong repo này
- Gửi mail at-least-once và exactly-once — outbox, dedupe key, vì sao exactly-once không tồn tại.
- Scale out queue worker —
retry_after, SIGKILL, Little's law. - Gửi thông báo đa kênh — tách queue theo kênh.
- Dead tuple và index bloat — vì sao bảng phình sau campaign.
- Redis cần biết — maxmemory, eviction policy.
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