Gửi mail at-least-once và exactly-once — monolith vs mail service riêng
Câu hỏi phỏng vấn: "Đặt hàng xong phải gửi mail xác nhận. Làm sao chắc chắn mail luôn được gửi, và không gửi 2 lần?" Câu trả lời "dùng queue, có retry" mới đúng một nửa. Nửa còn lại: order đã lưu mà job mất, job chạy 2 lần, SMTP nhận mail rồi mà mình không biết, và exactly-once thật sự thì không tồn tại — phải nói được vì sao và làm gì thay thế.
Bài này đi qua 2 kiến trúc:
- Kiến trúc 1 — monolith: nơi tạo job (dispatcher) và mail worker cùng một hệ thống, cùng đọc/ghi một database.
- Kiến trúc 2 — mail service riêng: service khác, có thể viết bằng ngôn ngữ khác, nhận việc qua message queue (RabbitMQ / Kafka), không được đọc DB của service tạo job.
Bối cảnh kiểm chứng: mọi câu SQL và output trong bài là chạy
thật trên PostgreSQL 18.4 (my-test-pgdb đóng vai DB của order
service, my-test-pgshard2 đóng vai DB riêng của mail service). Hành vi
Laravel đo thật trên Laravel 12.64.0 (container laravel-app,
Redis tạm). Phần RabbitMQ / Kafka lấy từ tài liệu chính thức (qua Context7),
chưa chạy broker thật. Số liệu tải (50k mail, 20 phút…) là
ví dụ minh hoạ.
Thuật ngữ
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| at-most-once | gửi tối đa 1 lần — có thể mất, không bao giờ trùng |
| at-least-once | gửi ít nhất 1 lần — không mất, nhưng có thể trùng |
| exactly-once | đúng 1 lần — với side effect (tác động ra ngoài) như gửi mail thì không làm được tuyệt đối |
| effectively-once | at-least-once + xử lý idempotent → kết quả như chạy 1 lần. Đây là thứ người ta thật sự làm khi nói "exactly-once" |
| dual write | ghi vào 2 hệ thống (DB + queue) mà không có transaction chung → một bên thành công, bên kia thất bại |
| transactional outbox | ghi "việc cần gửi" vào bảng outbox trong cùng transaction với dữ liệu nghiệp vụ, gửi đi sau |
| relay | process đọc outbox rồi đẩy lên broker |
| inbox / dedupe table | bảng phía người nhận, ghi lại message đã xử lý để bỏ qua bản trùng |
| dedupe key (business key) | khoá chống trùng theo nghĩa nghiệp vụ, vd order-confirmed:42 |
| idempotency key | khoá gửi kèm request để bên nhận biết "request này tôi xử lý rồi" |
| lease | quyền giữ một việc trong X giây; hết hạn thì người khác được lấy lại |
| fencing token | số tăng dần gắn với mỗi lần lấy lease; ghi kết quả phải kèm đúng số này, để worker cũ (đã mất lease) không ghi đè |
| ack / publisher confirm | ack: consumer báo broker "xử lý xong, xoá đi". Confirm: broker báo publisher "đã nhận an toàn" |
| DLQ (dead letter queue) | hàng đợi chứa message lỗi mãi không xử lý được |
| CDC (change data capture) | đọc thay đổi từ WAL của DB (vd Debezium) thay vì polling bảng |
0. Sự thật phải nói trước: exactly-once gửi mail không tồn tại
Worker gửi mail cho SMTP server / email API. Nếu kết nối đứt sau khi server nhận xong nội dung nhưng trước khi worker nhận được câu trả lời "250 OK", worker không thể biết mail đã đi hay chưa. Nó chỉ có 2 lựa chọn:
- Gửi lại → có thể trùng → at-least-once.
- Không gửi lại → có thể mất → at-most-once.
Không có lựa chọn thứ 3 nếu phía nhận không hợp tác. Đây là bài toán Two Generals (hai bên không bao giờ chắc chắn bên kia đã nhận được tin qua một kênh có thể mất tin).
Mình dựng thử: một SMTP server giả nhận trọn nội dung mail lần 1 nhưng cố tình không
trả 250; lần 2 trả bình thường. Worker là Laravel 12.64 gửi bằng
Mail::raw(), timeout = 3, gặp lỗi thì thử lại:
[server] nhan mail so 1: Message-ID: <d22dc56344cf4b33db1ff45ba6d578cd@example.com>
[server] da nhan du noi dung, co tinh khong tra 250
[worker] lan 1: Symfony\Component\Mailer\Exception\TransportException: Connection to "127.0.0.1:2526" timed out. (6.4s)
[server] nhan mail so 2: Message-ID: <e595666136a49b38a7a10a01eec656f8@example.com>
[worker] lan 2: OK (0.8s)
Đọc kết quả:
- Server đã có mail lần 1, nhưng worker nhận về exception → nghĩ là thất bại.
- Retry → khách nhận 2 mail, và 2 mail có
Message-IDkhác nhau (Laravel sinh mới mỗi lần gửi) → mail client của khách không tự gộp được. - Cấu hình
timeout = 3nhưng đo thật mất 6,4 giây mới ném lỗi. Timeout cấu hình không phải thời gian tối đa thật — con số này quan trọng khi đặt lease ở dưới.
Câu trả lời chuẩn khi phỏng vấn: "Exactly-once cho side effect ra ngoài hệ thống là không có. Cái làm được là at-least-once delivery + idempotent processing để trạng thái trong DB đúng như chạy 1 lần, và thu hẹp cửa sổ gửi trùng xuống một khoảng rất nhỏ. Muốn đóng nốt khoảng đó thì phía nhận phải hợp tác — vd email API có
Idempotency-Key." Nói được câu này là qua được 70% câu hỏi.
1. Nó là gì / chạy thế nào
1.1. Ba chỗ mail có thể mất hoặc trùng
flowchart LR
REQ["HTTP request đặt hàng"] --> TX["DB transaction: INSERT orders"]
TX -->|"① commit xong, đẩy job lỗi → MẤT mail"| Q["Queue / broker"]
Q -->|"② worker chết giữa chừng, job được giao lại → TRÙNG"| W["Mail worker"]
W -->|"③ SMTP nhận rồi, mất câu trả lời → MẤT hoặc TRÙNG"| SMTP["SMTP / Email API"]
W -.->|"đánh dấu đã gửi"| DB[("DB")]
| Điểm | Chuyện gì xảy ra | Xử lý bằng |
|---|---|---|
| ① dual write DB + queue | order lưu rồi, job không lên queue (hoặc ngược lại: job lên queue, transaction rollback) | transactional outbox |
| ② giao lại job | worker chết / timeout / lease hết → queue giao job cho worker khác | dedupe key + claim có điều kiện + fencing token |
| ③ side effect không rõ kết quả | timeout sau khi SMTP đã nhận | chọn chính sách (gửi lại / không gửi lại) theo loại mail, hoặc idempotency key của provider |
1.2. Kiến trúc 1 — monolith, dispatcher và worker dùng chung DB
Bước 0: vì sao cách "bình thường" của Laravel chưa đủ
Mình chạy 4 biến thể "tạo order + queue mail trong DB::transaction" trên
Laravel 12.64. Output thật:
T1. database driver, rollback
exception: RuntimeException: loi sau khi dispatch, truoc commit
orders co id=1: 0
jobs: 0
T1b. database driver, commit
orders co id=2: 1
jobs: 1
T2. redis, after_commit=false, rollback
exception: RuntimeException: loi sau khi dispatch, truoc commit
orders co id=3: 0
redis queues:now length: 1
T3. redis, after_commit=true, rollback
exception: RuntimeException: loi sau khi dispatch, truoc commit
orders co id=4: 0
redis queues:ac length: 0
T4. redis, after_commit=true, commit OK nhung Redis chet
exception: RedisException: Connection refused
orders co id=5: 1
| Cách | Kết quả | Đánh giá |
|---|---|---|
T1 — QUEUE_CONNECTION=database, cùng connection với order | rollback thì mất cả order lẫn job; commit thì có cả hai | Đúng. Bảng jobs chính là một outbox miễn phí |
T2 — Redis, không after_commit | order rollback nhưng job vẫn nằm trong Redis | Sai: gửi mail cho đơn không tồn tại (phantom mail) |
T3 — Redis + after_commit, rollback | không có job | Đúng cho trường hợp rollback |
T4 — Redis + after_commit, Redis chết lúc push | order đã commit, không có job, user nhận lỗi 500 | Sai: mất mail âm thầm. User thấy lỗi, bấm lại → còn tạo thêm đơn trùng |
afterCommit()chỉ đổi thứ tự (commit trước, push sau). Nó không làm 2 việc thành một khối. Giữa commit và push vẫn có khe hở: Redis chết, process bị kill khi deploy, OOM. Đó vẫn là dual write → at-most-once.
Bước 1: at-least-once — outbox là nguồn sự thật, queue chỉ là "chuông cửa"
Ý tưởng: mỗi mail cần gửi là một dòng trong bảng email_outbox,
ghi cùng transaction với order. Dòng này là nguồn sự thật. Job trong queue chỉ mang
email_id để "bấm chuông" cho worker. Mất chuông thì có sweeper (quét định kỳ)
bấm lại. Chuông kêu 2 lần cũng không sao vì worker claim có điều kiện.
CREATE TABLE email_outbox (
id bigserial PRIMARY KEY,
dedupe_key text NOT NULL UNIQUE, -- 'order-confirmed:42'
to_email text NOT NULL,
template text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}',
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','sending','sent','failed','unknown','cancelled')),
attempts int NOT NULL DEFAULT 0, -- cũng là fencing token
locked_until timestamptz, -- lease
provider_id text, -- id mail phía SMTP/API trả về
created_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz
);
-- partial index: chỉ index các dòng còn việc, bảng lớn lên index vẫn nhỏ
CREATE INDEX email_outbox_todo ON email_outbox (id)
WHERE status IN ('pending','sending');
Kiểm chứng tính nguyên tử (bảng tmp_* cùng schema):
BEGIN;
INSERT INTO tmp_orders VALUES (41, 'a@example.com');
INSERT INTO tmp_email_outbox (dedupe_key, to_email, template)
VALUES ('order-confirmed:41', 'a@example.com', 'order_confirmed');
ROLLBACK;
SELECT (SELECT count(*) FROM tmp_orders) AS orders,
(SELECT count(*) FROM tmp_email_outbox) AS outbox;
orders | outbox
--------+--------
0 | 0
sequenceDiagram
participant C as Controller
participant DB as PostgreSQL
participant Q as Queue
participant W as Worker
participant S as SMTP / API
C->>DB: BEGIN, INSERT orders, INSERT email_outbox (pending)
alt database driver, cùng connection, after_commit=false
C->>DB: dispatch = INSERT INTO jobs (vẫn trong transaction, ~1 ms)
C->>DB: COMMIT: order, outbox, job cùng sống hoặc cùng chết
else Redis queue
C->>DB: COMMIT: order + outbox
C->>Q: dispatch sau COMMIT (afterCommit), lỗi thì chỉ report
Note over C,Q: mất job cũng không sao: sweeper bấm chuông lại từ outbox
end
Q->>W: giao job
W->>DB: UPDATE ... SET status sending, attempts+1 WHERE còn pending RETURNING
DB-->>W: 1 dòng (thắng) hoặc 0 dòng (bỏ qua)
W->>S: gửi mail
S-->>W: 250 OK / message id
W->>DB: UPDATE status sent WHERE id và attempts khớp
Dispatch trong transaction: có giữ transaction lâu không, lỗi thì rollback hay commit?
Hai câu hỏi, hai thứ quyết định khác nhau:
- Giữ transaction lâu hay không → do queue driver quyết định.
- Dispatch lỗi thì rollback hay commit → do
after_commitquyết định.DB::transaction()bắt mọi exception ném ra từ closure rồiROLLBACK. Vậy chỉ cần hỏi: lúc dispatch lỗi, closure còn đang chạy không?
Lab trên Laravel 12.64 (SQLite in-memory, dispatch 1 job trong DB::transaction). Muốn
INSERT jobs lỗi thì xoá bảng jobs. Muốn Redis chết thì trỏ host sang IP không tồn tại,
timeout=3. Output thật:
A. database, after_commit=false, bang jobs OK
dispatch trong tx: 2.1 ms | tong: 2.8 ms
exception: -
orders: 1 | jobs: 1
B. database, after_commit=false, INSERT jobs loi
exception: Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1 no such table: jobs
orders: 0 | jobs: khong co bang
C. database, after_commit=TRUE, INSERT jobs loi
exception: Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1 no such table: jobs
orders: 1 | jobs: khong co bang
D. redis khong toi duoc (timeout=3s), after_commit=false
dispatch trong tx: 0.0 ms | tong: 3006.0 ms
exception: RedisException: Operation timed out
orders: 0 | jobs: khong co bang
E. redis chet (timeout=2s), DB::afterCommit + try/catch (code o duoi)
report(): RedisException: Operation timed out
tong: 2151.1 ms | exception ra ngoai: - | orders: 1
| Cách | Giữ transaction thêm | Dispatch lỗi thì | Đánh giá |
|---|---|---|---|
A/B — database, cùng connection, after_commit=false | ~1–2 ms (1 câu INSERT) | ROLLBACK cả order + outbox, user nhận 500 | Đúng. Nguyên tử thật, lỗi thì user thử lại, không có gì nửa vời |
C — database + after_commit=true | 0 | Order đã commit, job mất, user vẫn nhận 500 | Bẫy. Tự tay phá tính nguyên tử của T1 |
D — Redis, không afterCommit | Tới lúc timeout (lab: 3s) | ROLLBACK, mất đơn hàng vì Redis chết | Sai. Còn bị phantom job (T2) khi tx rollback vì lý do khác |
E — Redis + afterCommit + try/catch | 0 (request vẫn chờ timeout, nhưng lúc đó tx đã đóng, không giữ lock) | Order + outbox đã commit, job mất, user nhận thành công | Đúng khi có outbox + sweeper. Không có try/catch thì thành T4: đơn đã lưu mà user thấy 500 |
Vì sao C xảy ra: after_commit không phải tính năng riêng của Redis. Nó nằm
trong Queue::enqueueUsing() dùng chung cho mọi driver
(vendor/laravel/framework/src/Illuminate/Queue/Queue.php, đoạn
shouldDispatchAfterCommit → db.transactions->addCallback). Bật nó cho driver
database thì INSERT jobs bị đẩy ra sau COMMIT, thành một statement riêng. Kết quả
là dual write (ghi 2 nơi không cùng transaction).
Vì sao D nguy hiểm hơn con số 3s: trong lúc chờ Redis, session PostgreSQL ở trạng thái
idle in transaction (mở transaction nhưng không chạy câu nào). Nó vẫn:
- giữ row lock (khoá dòng) đã lấy. Nếu tx có
UPDATE products SET stock = stock - 1thì mọi request mua cùng sản phẩm phải xếp hàng; - chiếm 1 connection DB và 1 PHP-FPM worker. Redis chết 1 phút là cạn pool;
- giữ snapshot, làm VACUUM không dọn được dead tuple (dòng cũ đã chết).
Project laravel-livewire không đặt timeout cho Redis trong config/database.php,
còn default_socket_timeout của PHP trong container là 60. Nghĩa là mỗi request có
thể treo tới cỡ 60s. Con số 60s suy ra từ config, chưa verify bằng đo thật.
Với outbox, job chỉ là "chuông cửa": mất job thì sweeper bù được, mất đơn hàng thì không. Vì vậy: driver
databasethì dispatch trong transaction (rẻ, nguyên tử). Redis thì dispatch sau commit, lỗi thì chỉreport(), không ném ra. Đừng để một hệ thống phụ (queue) quyết định việc lưu dữ liệu chính.
Bước 2: claim có điều kiện — nhiều worker không lấy trùng
Nếu dùng poller riêng (không qua Laravel queue), worker lấy lô bằng
FOR UPDATE SKIP LOCKED. Hai worker chạy cùng lúc, worker-1 giữ lock 1 giây:
UPDATE tmp_email_outbox
SET status = 'sending', attempts = attempts + 1,
locked_until = now() + interval '2 seconds'
WHERE id IN (
SELECT id FROM tmp_email_outbox
WHERE status = 'pending'
OR (status = 'sending' AND locked_until < now()) -- lease hết hạn thì lấy lại
ORDER BY id
LIMIT 2
FOR UPDATE SKIP LOCKED
)
RETURNING id, dedupe_key, attempts;
----- worker-1 -----
id | dedupe_key | attempts
----+--------------------+----------
2 | order-confirmed:42 | 1
5 | order-confirmed:43 | 1
----- worker-2 ----- (chạy sau 0,2s, lúc worker-1 còn giữ lock)
id | dedupe_key | attempts
----+--------------------+----------
6 | order-confirmed:44 | 1
7 | order-confirmed:45 | 1
Worker-2 không chờ, nhảy qua 2 dòng đang bị khoá và lấy 2 dòng sau. Quan trọng:
transaction claim phải ngắn — commit ngay sau khi đổi sang
sending, rồi mới gọi SMTP ở ngoài transaction. Giữ transaction trong lúc gọi
SMTP 6 giây là giữ connection DB 6 giây và chặn VACUUM.
Bước 3: "exactly-once" (effectively-once) — 4 lớp chặn trùng
Lớp 1 — dedupe key UNIQUE: cùng một sự kiện nghiệp vụ không thể tạo 2 dòng outbox, dù code gọi 2 lần (user bấm 2 lần, listener chạy 2 lần):
INSERT INTO tmp_email_outbox (dedupe_key, to_email, template)
VALUES ('order-confirmed:42', 'b@example.com', 'order_confirmed');
ERROR: duplicate key value violates unique constraint "tmp_email_outbox_dedupe_key_key"
DETAIL: Key (dedupe_key)=(order-confirmed:42) already exists.
INSERT INTO tmp_email_outbox (dedupe_key, to_email, template)
VALUES ('order-confirmed:42', 'b@example.com', 'order_confirmed')
ON CONFLICT (dedupe_key) DO NOTHING RETURNING id;
id
----
(0 rows)
INSERT 0 0
Lớp 2 — claim có điều kiện: câu UPDATE ... WHERE status = 'pending'
ở trên. Job bị giao 2 lần thì lần 2 nhận 0 dòng và thoát.
Lớp 3 — fencing token: worker-1 chậm (GC, mạng), lease hết, worker-3 claim
lại (attempts = 2). Worker-1 quay về, muốn đánh dấu sent với token cũ:
-- worker-3 claim lại sau khi lease 2s hết
id | dedupe_key | attempts
----+--------------------+----------
2 | order-confirmed:42 | 2
UPDATE tmp_email_outbox SET status='sent', provider_id='msg-from-worker-1'
WHERE id = 2 AND status = 'sending' AND attempts = 1;
UPDATE 0 -- worker-1 bị chặn
UPDATE tmp_email_outbox SET status='sent', provider_id='msg-from-worker-3'
WHERE id = 2 AND status = 'sending' AND attempts = 2;
UPDATE 1
Bẫy hay bị hiểu sai: fencing token chỉ giữ cho trạng thái trong DB đúng. Nó không chặn được mail thứ 2 — worker-1 và worker-3 đều đã gọi SMTP. Khách vẫn nhận 2 mail. Chống việc này phải dùng lớp 4, và đặt lease đủ dài.
Lớp 4 — idempotency key phía provider: gửi dedupe_key làm
Idempotency-Key. Theo tài liệu Resend: key tối đa 256 ký tự, hết hạn sau
24 giờ; gửi lại cùng key + cùng payload thì trả về response cũ và không gửi lại; cùng key
mà payload khác thì trả 409. Đây là lớp duy nhất đóng được điểm ③.
Bẫy Laravel (đọc code 12.64):
ResendTransportcopy mọi header của mail vào trườngheaderscủa API — tức là thành header trong email, không phải HTTP headerIdempotency-Key. Thêm$message->getHeaders()->addTextHeader('Idempotency-Key', ...)không có tác dụng chống trùng, chỉ làm lộ key trong mail. Muốn dùng phải gọi Resend SDK/HTTP trực tiếp. SMTP thuần không có khái niệm idempotency key. Amazon SES: chưa verify có hỗ trợ hay không.
Bước 4: cửa sổ cuối cùng — chọn chính sách cho điểm ③
stateDiagram-v2 [*] --> pending: INSERT cùng transaction với order pending --> sending: worker claim, attempts+1 sending --> sent: SMTP trả OK, mark theo fencing token sending --> pending: lỗi rõ ràng (4xx, connect refused) thì retry có backoff sending --> sending: lease hết, worker khác claim lại sending --> unknown: timeout sau DATA, loại mail không được trùng pending --> failed: hết số lần thử pending --> cancelled: huỷ trước khi claim (mục 1.4) unknown --> sent: đối soát log provider thấy đã gửi unknown --> pending: đối soát thấy chưa gửi sent --> [*] failed --> [*] cancelled --> [*]
| Loại mail | Mất thì sao | Trùng thì sao | Chính sách khi timeout không rõ |
|---|---|---|---|
| OTP, reset password | user kẹt, gọi support | vô hại | gửi lại (at-least-once) |
| Xác nhận đơn, hoá đơn | khách lo, gọi support | khó chịu nhẹ | gửi lại + idempotency key nếu provider có |
| Thông báo trừ tiền, cảnh báo pháp lý | nghiêm trọng | khách tưởng bị trừ 2 lần | unknown → đối soát với log/webhook provider rồi mới quyết |
| Marketing | không sao | tăng unsubscribe, spam report | không gửi lại (at-most-once) |
Code Laravel cho kiến trúc 1
// Controller / service: order + outbox + job trong CÙNG transaction.
// QUEUE_CONNECTION=database và cùng connection với order → job cũng nằm trong transaction (T1).
DB::transaction(function () use ($data) {
$order = Order::create($data);
$emailId = DB::table('email_outbox')->insertGetId([
'dedupe_key' => "order-confirmed:{$order->id}",
'to_email' => $order->email,
'template' => 'order_confirmed',
'payload' => json_encode(['order_id' => $order->id]),
]);
SendOutboxEmail::dispatch($emailId); // ~1 ms, lỗi thì rollback cả order (lab A/B)
});
// Đừng bật 'after_commit' => true cho connection database: INSERT jobs bị đẩy ra sau COMMIT (lab C).
// Biến thể Redis queue: dispatch SAU commit, và không để lỗi Redis thành lỗi 500 cho user.
// Nếu ném exception ra, user thấy lỗi dù đơn đã lưu → bấm lại → tạo đơn trùng.
DB::transaction(function () use ($data) {
$order = Order::create($data);
$emailId = DB::table('email_outbox')->insertGetId([/* như trên */]);
DB::afterCommit(function () use ($emailId) {
try {
SendOutboxEmail::dispatch($emailId);
} catch (Throwable $e) {
report($e); // outbox vẫn pending → sweeper bấm chuông lại
}
});
});
// Đặt thêm 'timeout' / 'read_timeout' ~1–2s cho connection Redis trong config/database.php,
// để request không treo tới default_socket_timeout (60s) khi Redis chết.
class SendOutboxEmail implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public array $backoff = [10, 60, 300, 900];
public int $timeout = 30; // luật: timeout < LEASE < retry_after (xem bẫy 2)
private const LEASE = 60;
public function __construct(public int $emailId) {}
public function handle(): void
{
$row = DB::selectOne("
UPDATE email_outbox
SET status = 'sending', attempts = attempts + 1,
locked_until = now() + make_interval(secs => ?)
WHERE id = ?
AND (status = 'pending' OR (status = 'sending' AND locked_until < now()))
RETURNING id, attempts, to_email, template, payload
", [self::LEASE, $this->emailId]);
if (! $row) {
return; // đã sent, hoặc worker khác đang giữ lease
}
try {
$sent = Mail::to($row->to_email)
->send(new TemplateMail($row->template, json_decode($row->payload, true)));
} catch (Throwable $e) {
// trả về pending để lần retry của job claim lại được ngay, không phải chờ lease
DB::update("UPDATE email_outbox SET status = 'pending', locked_until = NULL
WHERE id = ? AND attempts = ?", [$row->id, $row->attempts]);
throw $e; // để Laravel retry theo $backoff
}
DB::update("UPDATE email_outbox SET status = 'sent', sent_at = now(), provider_id = ?
WHERE id = ? AND attempts = ?",
[$sent?->getMessageId(), $row->id, $row->attempts]);
}
public function failed(Throwable $e): void
{
DB::update("UPDATE email_outbox SET status = 'failed' WHERE id = ?", [$this->emailId]);
}
}
// Sweeper: chạy mỗi phút bằng Schedule. Bấm lại chuông cho dòng bị bỏ quên
// (job mất khi Redis chết — T4, worker bị SIGKILL, bug...). Dispatch trùng vô hại vì claim có điều kiện.
Schedule::call(function () {
DB::table('email_outbox')
->whereIn('status', ['pending', 'sending'])
->where('created_at', '<', now()->subMinutes(5))
->where(fn ($q) => $q->whereNull('locked_until')->orWhere('locked_until', '<', now()))
->orderBy('id')->limit(500)
->pluck('id')
->each(fn ($id) => SendOutboxEmail::dispatch($id));
})->everyMinute()->withoutOverlapping();
Bẫy của kiến trúc 1 (xếp theo mức hay dính)
- Dùng
afterCommitrồi tưởng là đủ. T4 ở trên: order commit, mail mất, không có log gì ngoài 1 dòng exception. Chữa: outbox + sweeper. - Lease và
retry_afterlệch nhau → mail kẹt mãi ởsending. Worker bị SIGKILL lúc đang gửi. Database queue giao lại job sauretry_after(mặc định 90s). Nếu lease = 120s thì lúc đó lease chưa hết → claim trả 0 dòng → jobreturn→ Laravel xoá job → không còn ai gửi mail đó. Chữa:$timeout < LEASE < retry_after, và sweeper làm lưới an toàn. Bẫy này nổ trễ: chỉ lộ khi có worker bị kill, thường là lúc deploy. - Lease ngắn hơn thời gian gửi thật → gửi trùng. Lab ở mục 0:
timeout=3nhưng mất 6,4s. Đặt lease theo p99.9 thời gian gửi đo thật, không theo số trong config.$timeoutcủa job (pcntl kill) mới là giới hạn cứng. - Gọi SMTP bên trong transaction claim. Giữ lock và connection trong lúc chờ mạng. Pool connection cạn khi SMTP chậm. Chữa: claim → commit → gửi → update.
- Queue chạy connection DB khác với order (vd
queue.connections.database.connectiontrỏ sang DB khác, hoặc tách read/write). Khi đó T1 không còn nguyên tử. Kiểm tra config, đừng giả định. - Bật
after_commit=truecho driverdatabase"cho an toàn". Lab C:INSERT jobschạy sau COMMIT → lỗi thì order đã lưu, job mất. Tính nguyên tử của T1 mất mà không ai để ý, vì lúc bình thường mọi thứ vẫn chạy đúng. Chữa: đểafter_commit=falsecho connection database; chỉ Redis/SQS mới cầnafterCommit. - Dispatch Redis trong transaction, không
afterCommit. Lab D: Redis chết → transaction treo tới timeout (mặc định PHP 60s), giữ lock + connection, rồi rollback luôn đơn hàng. Dấu hiệu lúc sự cố:pg_stat_activityđầyidle in transaction. Chữa:afterCommit+ try/catch, đặttimeoutRedis 1–2s, thêmidle_in_transaction_session_timeoutphía PostgreSQL làm lưới an toàn. - Dedupe key chọn sai.
order-confirmed:42đúng cho mail xác nhận. Nhưngpassword-reset:user-7sẽ chặn luôn lần user xin reset thứ 2 hợp lệ. Key phải chứa id của ý định (vd id của token reset), không chỉ id của đối tượng. - Bảng outbox phình to. Mỗi mail = 1 dòng + nhiều lần UPDATE → dead tuple. Chữa:
partial index như trên, xoá/archive dòng
sentcũ hơn N ngày theo lô, hoặc partition theo tháng rồiDROPpartition. Xem bài scale out queue worker. - Sequence nhảy số. Mỗi lần INSERT bị conflict vẫn đốt 1 giá trị sequence
(lab: id đi 2 → 5). Vô hại, nhưng đừng dùng
idđể đếm số mail.
1.3. Kiến trúc 2 — mail service riêng, qua message queue, không đọc DB bên kia
Khác biệt gốc: không còn một transaction chung nào. Order service chỉ nắm DB của nó. Mail service chỉ nắm DB của nó. Ở giữa là broker. Nên bài toán tách thành 3 chặng, mỗi chặng at-least-once, và chống trùng dồn hết về phía nhận.
flowchart LR
subgraph OS["Order service (Laravel)"]
API["API đặt hàng"] --> ODB[("DB order: orders + outbox")]
RL["Relay: đọc outbox, publish"] --> ODB
end
subgraph MB["Broker"]
TOP["exchange / topic: order.events"]
DLQ["DLQ"]
end
subgraph MS["Mail service (Go / Node / ...)"]
CON["Consumer"] --> MDB[("DB mail: mail + UNIQUE event_id, dedupe_key")]
SND["Sender: claim, gửi, mark"] --> MDB
end
RL -->|"chặng 1: publish + confirm"| TOP
TOP -->|"chặng 2: deliver, ack sau commit"| CON
CON -.->|"lỗi parse mãi"| DLQ
SND -->|"chặng 3: gửi"| ESP["SMTP / Email API"]
SND -.->|"EmailSent / EmailFailed"| TOP2["topic: mail.events"]
TOP2 -.->|"order service tự cập nhật trạng thái"| OS
Chặng 1 — order service: outbox + relay
Giống kiến trúc 1, nhưng outbox chứa event chứ không chứa mail. Order service không biết mail gửi thế nào; nó chỉ nói "đơn 42 đã xác nhận".
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
event_id uuid NOT NULL DEFAULT uuidv7() UNIQUE, -- sinh LÚC GHI outbox, không phải lúc publish
topic text NOT NULL, -- 'order.events'
event_type text NOT NULL, -- 'OrderConfirmed'
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
attempts int NOT NULL DEFAULT 0, -- số lần publish lỗi
last_error text,
dead_at timestamptz -- lỗi quá N lần: bỏ qua + alert
);
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL AND dead_at IS NULL;
uuidv7() có sẵn từ PostgreSQL 18 (chạy thử:
01a0b3bb-ce2b-7699-8416-32ac62c829db) — sắp theo thời gian nên index B-tree không bị
chèn lung tung như UUIDv4.
// Relay: artisan command chạy dưới Supervisor, 1 process (xem bẫy thứ tự bên dưới).
// Chỉ 1 relay → dùng advisory lock thay cho FOR UPDATE, không giữ transaction lúc chờ broker.
if (! DB::scalar('SELECT pg_try_advisory_lock(42)')) exit(0); // relay khác đang chạy
while (true) {
$rows = DB::select("SELECT id, event_id, topic, event_type, payload, attempts FROM outbox
WHERE published_at IS NULL AND dead_at IS NULL
ORDER BY id LIMIT 100");
$ok = [];
foreach ($rows as $r) {
try {
// message_id = event_id. Chờ publisher confirm (RabbitMQ) / acks=all (Kafka).
$broker->publishAndWaitConfirm($r->topic, $r->payload, [
'message_id' => $r->event_id, 'type' => $r->event_type,
], timeout: 5);
$ok[] = $r->id;
} catch (Throwable $e) {
DB::update("UPDATE outbox SET attempts = attempts + 1, last_error = ?,
dead_at = CASE WHEN attempts + 1 >= 10 THEN now() END
WHERE id = ?", [mb_substr($e->getMessage(), 0, 500), $r->id]);
$failed = $r;
break; // dừng lô để giữ thứ tự
}
}
// Publish thành công tới đâu, commit tới đó — không rollback cả lô.
if ($ok) DB::table('outbox')->whereIn('id', $ok)->update(['published_at' => now()]);
if (isset($failed)) { sleep(min(2 ** $failed->attempts, 60)); unset($failed); } // backoff
elseif (! $rows) usleep(500_000);
}
Vì sao không bọc cả lô trong DB::transaction? Bản đầu tiên của đoạn
code này làm thế, và hỏng khi 1 event lỗi giữa lô:
event 1..36 → đã publish, broker đã nhận
event 37 → nack / timeout → throw
→ ROLLBACK → 1..36 vẫn published_at = NULL
→ process chết → Supervisor bật lại → publish lại 1..36 → chết ở 37 → lặp mãi
- Tắc cả hàng. Event 37 lỗi vĩnh viễn (payload quá lớn, exchange không tồn tại) → mọi event sau nó đứng im. Đây là head-of-line blocking (phần tử đầu hàng chặn cả hàng).
- Trùng lặp liên tục. 1..36 bị publish lại mỗi vòng. Phía nhận vẫn chặn được
theo
event_id, nhưng broker và consumer phải chịu tải rác. - Transaction mở trong lúc chờ mạng (100 × round-trip × timeout) → giữ xmin (mốc transaction cũ nhất còn mở) → VACUUM không dọn được dead tuple.
Chạy thử trên PG 18: lô 5 event, event 3 timeout confirm, chỉ commit phần đầu 1–2 và ghi lỗi cho 3:
BEGIN;
UPDATE tmp_outbox SET published_at = now() WHERE id = ANY('{1,2}');
UPDATE tmp_outbox SET attempts = attempts + 1, last_error = 'confirm timeout 5s' WHERE id = 3;
COMMIT;
id | published | attempts | last_error
----+-----------+----------+--------------------
1 | t | 0 |
2 | t | 0 |
3 | f | 1 | confirm timeout 5s
4 | f | 0 |
5 | f | 0 |
-- lần quét sau (WHERE published_at IS NULL AND dead_at IS NULL ORDER BY id)
id
----
3 ← bắt đầu lại đúng từ event lỗi, thứ tự giữ nguyên
4
5
Advisory lock (khoá theo một số tự đặt, giữ theo session) khi 2 relay chạy cùng lúc:
relay A: SELECT pg_try_advisory_lock(42) → t (giữ 2 giây)
relay B: SELECT pg_try_advisory_lock(42) → f ← thoát, không chạy song song
relay B sau khi A thoát → t
Relay bị treo mà connection đứt thì PG tự nhả lock — không có lock "mồ côi".
Mỗi loại lỗi xử lý khác nhau:
| Tình huống | Nghĩa là gì | Xử lý |
|---|---|---|
| nack | Broker báo không nhận message | Chưa tới nơi. Retry kèm backoff (chờ tăng dần) |
| timeout, không có confirm | Không biết broker đã nhận hay chưa | Coi như chưa gửi, publish lại. Có thể trùng — phía nhận chặn bằng event_id. RabbitMQ docs: "Unconfirmed messages should be considered undelivered and potentially republished" |
| mất connection giữa lô | Mọi message chưa confirm đều chưa rõ số phận | Mở lại connection. Lô sau quét lại từ event lỗi |
| lỗi vĩnh viễn (payload quá lớn, message bị trả về vì không route được) | Retry bao nhiêu cũng lỗi | Sau N lần → dead_at + alert. Không để nó chặn hàng mãi |
Trade-off: event chết có được bỏ qua không?
- Dừng hẳn khi có event chết: đúng thứ tự tuyệt đối, nhưng mọi event bị tắc tới khi có người sửa. Chọn khi thứ tự là sống còn.
- Bỏ qua (code ở trên): hệ thống vẫn chạy, nhưng event sau có thể đến trước event
chết — vd
OrderCancelledđến màOrderConfirmedchết. Tombstone ở mục 1.4 đỡ được đúng trường hợp này. Chặt hơn: chỉ chặn các event cùngorder_idvới event chết, đơn khác vẫn chạy.
Với mail, chọn bỏ qua + alert: một mail xác nhận trễ vẫn đỡ hơn cả hệ thống đứng.
Tốc độ: chờ confirm từng cái → 100 × ~5ms RTT ≈ 0,5s mỗi lô (số ước lượng). Nhanh
hơn: publish cả lô rồi chờ confirm một lần (RabbitMQ docs gọi là batch publishing; Java
waitForConfirms(timeout)). Đổi lại, lô lỗi thì khó biết event nào hỏng, thường phải
publish lại cả lô. Throughput thấp thì giữ cách từng cái cho dễ debug.
Monitoring: count(*) WHERE published_at IS NULL (độ tồn),
now() - min(created_at) của dòng chưa publish (độ trễ), count(*) WHERE dead_at IS NOT NULL.
Alert khi độ trễ > 1 phút hoặc có dòng dead mới.
Relay chết sau khi publish mà chưa kịp update published_at → lần sau publish lại
cùng event_id. Đó là trùng có chủ đích: chấp nhận ở chặng này, chặn
ở phía nhận. Tài liệu RabbitMQ nói rõ: publisher mất confirm thì phải gửi lại, "can lead to message
duplication… Consumer applications must handle deduplication".
Message gửi đi (hợp đồng giữa 2 service):
{
"event_id": "0199a1f0-0000-7000-8000-000000000042",
"event_type": "OrderConfirmed",
"version": 1,
"occurred_at":"2026-09-18T08:00:00Z",
"dedupe_key": "order-confirmed:42",
"data": { "order_id": 42, "email": "b@example.com", "locale": "vi",
"total": "1250000", "items": [ ... ] }
}
Vì mail service không được đọc DB order, message phải mang đủ dữ liệu để dựng mail
(fat event). Nếu chỉ gửi order_id (thin event) thì mail service phải gọi API của order
service → thêm một điểm lỗi, và dữ liệu lúc gọi có thể đã khác lúc sự kiện xảy ra.
Chặng 2 — broker giao cho mail service: ack sau khi commit
sequenceDiagram participant B as Broker participant C as Mail consumer participant M as DB mail service B->>C: deliver event_id E C->>M: INSERT INTO mail ... ON CONFLICT DO NOTHING M-->>C: 1 dòng (mới) hoặc 0 dòng (trùng) C->>M: COMMIT C->>B: ack Note over C,B: chết trước ack thì broker giao lại, INSERT lần 2 trả 0 dòng, ack, xong
Phía mail service, cách lười nhất mà đúng: không cần bảng inbox riêng.
Đặt UNIQUE lên event_id và dedupe_key ngay trên bảng mail.
Chạy thật trên node DB thứ 2 (my-test-pgshard2):
INSERT INTO tmp_mail (event_id, dedupe_key, to_email)
VALUES ('0199a1f0-0000-7000-8000-000000000042', 'order-confirmed:42', 'b@example.com')
ON CONFLICT DO NOTHING
RETURNING id;
-- E1. lần 1
id
----
1
INSERT 0 1
-- E2. broker giao lại cùng message (redelivery)
(0 rows)
INSERT 0 0
-- E3. producer phát event KHÁC event_id nhưng cùng dedupe_key (vd bug gọi 2 lần)
(0 rows)
INSERT 0 0
E3 cho thấy vì sao cần cả hai khoá: event_id chặn redelivery,
dedupe_key chặn cùng một sự kiện nghiệp vụ bị phát 2 lần với id khác nhau.
ON CONFLICT DO NOTHING không ghi tên cột thì bắt được conflict ở mọi unique constraint.
Hai consumer nhận cùng một message cùng lúc (redelivery khi consumer đầu còn đang xử lý). Consumer-A insert rồi giữ transaction 2 giây; consumer-B insert sau 0,3 giây:
----- consumer-A (2.52s) -----
BEGIN
id
----
4
INSERT 0 1
COMMIT
----- consumer-B (2.52s) -----
id
----
(0 rows)
INSERT 0 0
Consumer-B bị chặn chờ ở unique index cho tới khi A commit, rồi nhận 0 dòng. Nếu A rollback (crash trước commit) thì B chờ xong sẽ insert được:
----- consumer-A (2.28s) ----- INSERT 0 1 ... ROLLBACK
----- consumer-B (2.31s) ----- id = 7, INSERT 0 1
Tức là unique index vừa chống trùng vừa không làm mất message — không cần lock phân tán.
Khi nào mới cần bảng inbox riêng? Khi 1 event không tạo đúng 1 dòng mail: event bị bỏ qua (khách tắt nhận mail), hoặc 1 event tạo nhiều mail (gửi khách + gửi shop). Lúc đó
INSERT INTO inbox(event_id)+ tạo các dòng mail trong cùng transaction. Chỉ 1 event → 1 mail thì bảng inbox là thừa.
Chặng 3 — mail service gửi: y hệt kiến trúc 1
Bảng mail của mail service chính là outbox của nó. Claim có điều kiện, lease, fencing token,
idempotency key, chính sách unknown — dùng lại nguyên mục 1.2. Viết bằng Go hay Node
thì câu SQL vẫn vậy.
Chiều về — order service muốn biết mail đã gửi chưa
Order service không được đọc DB mail. Mail service publish EmailSent /
EmailFailed / EmailBounced (kèm dedupe_key) lên topic
mail.events. Order service consume — và cũng phải chống trùng, vì chiều
này cũng at-least-once. Update kiểu SET email_status = 'sent' vốn đã idempotent, nhưng
event đến sai thứ tự (Failed đến sau Sent) thì phải so
occurred_at trước khi ghi.
Kafka "exactly-once" có giải quyết được không? — Không
Tài liệu Kafka: exactly-once dùng transactional producer + consumer
read_committed, gói offset và output vào cùng transaction — nhưng chỉ khi
đọc từ Kafka và ghi vào Kafka. Nguyên văn: "Exactly-once delivery for other
destination systems generally requires cooperation with such systems". SMTP không tham gia
transaction của Kafka. Cách Kafka gợi ý cho hệ thống ngoài: lưu offset cùng chỗ với output
(ví dụ lưu offset vào DB mail trong cùng transaction) — vẫn không chạm được tới hộp thư của khách.
Bẫy của kiến trúc 2 (xếp theo mức hay dính)
- Ack trước khi commit. Consumer ack xong mới ghi DB, DB lỗi → message mất vĩnh viễn.
Nhiều thư viện mặc định auto-ack / auto-commit offset. Kiểm tra: RabbitMQ
autoAck=false; Kafkaenable.auto.commit=false, commit offset sau khi DB commit. event_idsinh lúc publish thay vì lúc ghi outbox. Relay publish lại sau crash sẽ ra id mới → phía nhận coi là message mới → gửi trùng. Id phải nằm trong dòng outbox.- Dùng id của broker để dedupe.
delivery tagcủa RabbitMQ đổi mỗi lần giao, offset Kafka đổi khi relay publish lại. Chỉ dùng id do producer sinh. - Xoá dedupe key sớm hơn cửa sổ replay. Kafka giữ message 7 ngày, bảng mail dọn dòng
cũ hơn 3 ngày. Ai đó reset offset về đầu để xử lý lại → mail 4–7 ngày trước gửi lại hàng loạt. Bẫy nổ trễ,
thường nổ lúc sự cố khác. Chữa: giữ key ≥ thời gian retention của broker, và bỏ qua event có
occurred_atquá cũ (vd > 24h với mail xác nhận). - Poison message làm tắc queue. Payload sai schema, consumer throw, nack requeue, giao lại ngay, throw tiếp — vòng lặp vô hạn, message sau bị chặn (Kafka: cả partition đứng). Chữa: phân biệt lỗi tạm (DB chết → retry) và lỗi vĩnh viễn (parse lỗi → DLQ ngay). RabbitMQ quorum queue có delivery limit.
- Đổi schema event mà không có version. Order service deploy v2 trước, mail service
chưa hiểu → toàn bộ vào DLQ. Chữa: field
version, chỉ thêm field (không đổi nghĩa field cũ), consumer bỏ qua field lạ. - Nhiều relay +
SKIP LOCKED→ mất thứ tự. Relay A lấy id 1–100, relay B lấy 101–200, B publish xong trước. Với mail thường không sao; với "huỷ đơn" đến trước "xác nhận đơn" thì sai. Chữa: 1 relay giữpg_try_advisory_lock(deploy chồng lên nhau cũng không chạy 2 relay), hoặc chia theohash(order_id) % N, mỗi phần 1 relay với 1 advisory lock riêng. - Relay giữ transaction trong lúc chờ broker confirm. Broker chậm 30s → transaction mở 30s → chặn VACUUM trên bảng outbox. Tệ hơn: 1 event lỗi → rollback cả lô → publish lại phần đã gửi, lặp mãi. Chữa: không bọc lô trong transaction; publish tới đâu commit tới đó (code ở trên).
- Advisory lock đi qua PgBouncer transaction mode. Lock giữ theo session, mà PgBouncer đổi connection sau mỗi transaction → lock nằm ở connection khác, relay thứ 2 vẫn chạy được. Chữa: relay nối thẳng PG hoặc pool session mode (chưa verify với PgBouncer thật).
- Timeout confirm ngắn quá. 1s mà broker chậm → gần như event nào cũng bị publish 2 lần. Đặt timeout lớn hơn p99 confirm latency (độ trễ confirm ở mức 99% request).
1.4. Huỷ mail đã xếp hàng (cancel job) ở cả 2 kiến trúc
Tình huống: khách huỷ đơn 42 lúc mail xác nhận còn nằm trong queue. Hoặc mail "nhắc đánh giá sản phẩm" được hẹn gửi sau 3 ngày (delayed job), mà đơn bị huỷ ở ngày thứ 2. Câu hỏi phỏng vấn: "Huỷ cái job đó thế nào?"
Hai sự thật phải nói trước:
- Không "rút lại" được mail đã giao cho SMTP. Huỷ chỉ thắng khi nó xảy ra trước lúc worker claim. Sau đó chỉ còn cách gửi mail đính chính.
- Đừng cố xoá job khỏi queue. Cách đúng là đánh dấu trạng thái ở chỗ worker bắt buộc phải đọc trước khi gửi. Job cứ chạy, thấy đã huỷ thì thoát.
Vì sao không xoá job khỏi queue
| Queue | Job nằm ở đâu | Xoá 1 job theo order_id được không |
|---|---|---|
Laravel database | bảng jobs, cột payload | Chỉ bằng LIKE trên chuỗi serialize (lab bên dưới). Chậm, dễ xoá nhầm, vỡ khi đổi tên class/thuộc tính |
| Laravel Redis | list queues:default, delayed ở sorted set queues:default:delayed | Phải quét cả zset + list rồi ZREM/LREM, trong lúc worker đang pop song song. Laravel không có API xoá 1 job (chỉ queue:clear xoá cả queue) |
| RabbitMQ | queue | Không có lệnh xoá 1 message theo id. Chỉ purge cả queue |
| Kafka | log bất biến (không sửa được) | Không. Message chỉ mất khi hết retention |
Lab Laravel 12.64, driver database: payload của job là object PHP serialize, không có
cột order_id để WHERE:
jobs sau dispatch delay 3 ngay: 1 | available_at - now = 259200s
O:14:"ReviewReminder":2:{s:7:"orderId";i:42;s:5:"delay";O:25:"Illuminate\Support\Carbon":3...
flowchart LR
U["Khách bấm huỷ đơn"] --> TX["Transaction: orders.status = cancelled + đánh dấu mail"]
TX --> ST[("Trạng thái mail trong DB")]
Q["Queue: job vẫn còn đó"] --> W["Worker"]
W -->|"claim: chỉ lấy dòng pending, đơn chưa huỷ"| ST
ST -->|"0 dòng"| SKIP["Thoát, job bị xoá, không gửi"]
ST -->|"1 dòng"| SEND["Gửi mail"]
Kiến trúc 1 — cùng DB: huỷ = UPDATE có điều kiện, cùng transaction với huỷ đơn
-- Trong transaction huỷ đơn
UPDATE orders SET status = 'cancelled' WHERE id = 42;
UPDATE email_outbox SET status = 'cancelled', cancelled_at = now()
WHERE dedupe_key = 'order-confirmed:42' AND status = 'pending'
RETURNING id, dedupe_key, status;
Claim của worker vốn đã có WHERE status = 'pending' (mục 1.2), nên dòng
cancelled tự bị bỏ qua. Không cần sửa worker. Chạy thật 3 thứ tự có thể xảy ra
(bảng tmp_*, 2 session song song, một bên giữ transaction 2 giây):
C1. huỷ trước, worker claim sau
cancel: UPDATE 1 → order-confirmed:42 | cancelled
worker: (0 rows) UPDATE 0 → không gửi
C2. worker đang claim (giữ tx 2s), cancel chạy sau 0,3s
worker: order-confirmed:43 | sending | attempts 1
cancel: (0 rows) UPDATE 0 Time: 1734.408 ms → bị chặn chờ lock, rồi thua
C3. cancel đang chạy (giữ tx 2s), worker claim sau 0,3s
cancel: order-confirmed:44 | cancelled
worker: (0 rows) UPDATE 0 Time: 1707.041 ms → bị chặn chờ lock, rồi thua
Kết quả cuối
order_id | order_status | dedupe_key | mail_status
----------+--------------+--------------------+-------------
42 | cancelled | order-confirmed:42 | cancelled
43 | cancelled | order-confirmed:43 | sending ← đơn huỷ mà mail vẫn đi
44 | cancelled | order-confirmed:44 | cancelled
Đọc kết quả:
- Hai câu
UPDATE ... WHERE status = 'pending'tranh nhau cùng 1 dòng thì PostgreSQL xếp hàng bằng row lock. Bên đến sau chờ, rồi đọc lại dòng mới nhất (READ COMMITTED re-check — kiểm tra lại điều kiện WHERE trên bản đã commit) và thấy điều kiện sai → 0 dòng. Không bao giờ có chuyện cả hai cùng thắng. RETURNINGlà câu trả lời cho người dùng. 1 dòng = huỷ kịp. 0 dòng = mail đã/đang đi (C2) → màn hình không được báo "đã huỷ mail", mà phải xử lý "quá muộn" (xem dưới).- C2 cho thấy request huỷ đơn bị chặn 1,7s bởi transaction claim của worker. Trong code thật claim là 1 câu autocommit (vài ms). Nếu ai đó bọc claim + gọi SMTP trong 1 transaction thì request huỷ đơn treo theo SMTP.
Lớp 2 — check-at-send (kiểm tra nghiệp vụ lúc gửi): chỉ dựa vào dòng outbox thì hỏng
khi có đường huỷ đơn khác quên đánh dấu outbox (tool admin, câu SQL tay, service khác). Kiến trúc 1 đọc được
bảng orders, nên cho claim kiểm tra luôn:
-- admin huỷ đơn 45 bằng tool khác, QUÊN huỷ outbox
UPDATE tmp_orders SET status = 'cancelled' WHERE id = 45;
UPDATE tmp_email_outbox e SET status = 'sending', attempts = attempts + 1
WHERE e.dedupe_key = 'order-confirmed:45' AND e.status = 'pending'
AND NOT EXISTS (SELECT 1 FROM tmp_orders o WHERE o.id = 45 AND o.status = 'cancelled')
RETURNING e.id;
id
----
(0 rows)
UPDATE 0
Lớp này còn chặn một bẫy khác: mail đang sending thì huỷ thua (C2), nhưng nếu SMTP lỗi, worker
trả dòng về pending để retry → lần retry sẽ gửi mail cho đơn đã huỷ. Có
NOT EXISTS trong claim thì lần retry nhận 0 dòng.
Không có outbox, chỉ dùng queue Laravel thường (mail không quan trọng, hoặc delayed
job kiểu nhắc đánh giá): dùng middleware Skip (có trong
Illuminate\Queue\Middleware\Skip, đọc code 12.64 — Context7 không trả về trang tài liệu của
nó). Lab thật: dispatch delay 3 ngày, huỷ đơn, giả lập hết 3 ngày, chạy worker:
class ReviewReminder implements ShouldQueue
{
use Queueable;
public function __construct(public int $orderId) {}
public function middleware(): array
{
// closure chạy lúc worker lấy job, không phải lúc dispatch
return [Skip::when(fn () => Order::whereKey($this->orderId)
->where('status', 'cancelled')->exists())];
}
public function handle(): void { /* gửi mail */ }
}
sau khi worker chay (don da huy): jobs = 0
2026-09-22 02:36:08 ReviewReminder ................................. RUNNING
2026-09-22 02:36:08 ReviewReminder ............................. 4.84ms DONE
handle() không chạy, job bị xoá khỏi bảng. Nhưng log ghi DONE, không phải
"skipped" — dashboard/Horizon đếm nó là job thành công. Muốn biết bao nhiêu mail bị huỷ thì tự
Log::info() hoặc đếm từ bảng.
Job batch:
$batch->cancel()+ middlewareSkipIfBatchCancelledcũng là cơ chế "đánh dấu rồi job tự thoát" giống hệt, chỉ là cờ nằm ở bảngjob_batches. Dùng khi huỷ cả một lô (vd huỷ chiến dịch marketing 100k mail), không dùng cho huỷ 1 mail theo đơn.
Bẫy khi huỷ ở kiến trúc 1
- Cho huỷ cả dòng đang
sending. ViếtWHERE status IN ('pending','sending')cho "chắc". Chạy thật:
Khách đã nhận mail, DB lại ghiUPDATE tmp_email_outbox SET status='cancelled' WHERE id=2 AND status IN ('pending','sending'); UPDATE 1 -- worker gửi SMTP xong, mark sent theo fencing token UPDATE tmp_email_outbox SET status='sent' WHERE id=2 AND status='sending' AND attempts=1; UPDATE 0 id | dedupe_key | status | attempts ----+--------------------+-----------+---------- 2 | order-confirmed:43 | cancelled | 1cancelled. Support tra cứu sẽ nói sai với khách. Chữa: chỉ huỷpending;sendinglà "quá muộn". - Huỷ đơn và huỷ mail ở 2 transaction. Crash giữa chừng → đơn huỷ, mail vẫn
pending. Lớp check-at-send cứu được, nhưng đừng dựa vào may mắn: để chung 1 transaction. - Chỉ huỷ mail xác nhận, quên các mail khác cùng đơn (nhắc thanh toán, nhắc đánh giá). Dùng
dedupe key có cùng tiền tố hoặc cột
order_idđể huỷ theo đơn, không theo từng key. - Skip kiểm tra sai thời điểm. Viết
Skip::when($this->order->status === 'cancelled')vớiSerializesModelsthì model được load lại từ DB lúc chạy nên vẫn đúng. Nhưng nếu truyền mảng/DTO chụp lúc dispatch thì điều kiện luôn là giá trị cũ → không bao giờ skip. Luôn đọc lại DB trong closure. - Đơn bị xoá cứng thay vì đổi status: job có
SerializesModelsnémModelNotFoundExceptionvà vàofailed_jobs. Thêmpublic $deleteWhenMissingModels = true;nếu "không còn đơn" nghĩa là "khỏi gửi".
Kiến trúc 2 — không có DB chung: huỷ = một event nữa
Order service không sửa được bảng mail, cũng không xoá được message trong broker. Nó chỉ làm được một việc:
phát event OrderCancelled qua cùng outbox + relay (chặng 1). Mail service tự quyết
"huỷ đơn thì huỷ những mail nào" — order service không cần biết có mail nhắc đánh giá.
Cái khó: event có thể đến sai thứ tự. OrderCancelled có thể đến trước
OrderConfirmed khi: nhiều relay (bẫy 7 ở trên), message bị nack/requeue, OrderConfirmed
nằm ở DLQ rồi được đẩy lại sau. Nếu handler huỷ chỉ viết UPDATE ... WHERE status = 'pending' thì
lúc đó chưa có dòng nào để update → 0 dòng → ack → xong. Rồi OrderConfirmed đến → tạo mail →
gửi mail xác nhận cho đơn đã huỷ.
Chữa bằng tombstone (dòng "bia mộ": ghi trước một dòng cancelled cho mail chưa
tồn tại). Vì dedupe_key là UNIQUE, OrderConfirmed đến sau sẽ đụng dòng này và bị
ON CONFLICT DO NOTHING bỏ qua.
sequenceDiagram participant B as Broker participant C as Mail consumer participant M as DB mail service B->>C: OrderCancelled (đến TRƯỚC) C->>M: INSERT tombstone dedupe_key order-confirmed:43, status cancelled M-->>C: dòng mới (tombstone) C->>B: ack B->>C: OrderConfirmed (đến SAU) C->>M: INSERT mail order-confirmed:43 ON CONFLICT DO NOTHING M-->>C: 0 dòng, đụng tombstone C->>B: ack, không có mail nào được tạo
Handler OrderCancelled — một câu SQL xử lý cả 4 trạng thái. Chạy thật trên
my-test-pgshard2, bảng mail thêm cột cancel_requested và cho
event_id được NULL (tombstone không có event tạo mail):
-- trước khi chạy: 50 = pending, 51 = sending, 52 = sent, 53 = chưa có dòng
INSERT INTO tmp_mail (dedupe_key, status, cancel_requested)
SELECT k, 'cancelled', true
FROM unnest(ARRAY['order-confirmed:50','order-confirmed:51',
'order-confirmed:52','order-confirmed:53']) AS k
ON CONFLICT (dedupe_key) DO UPDATE
SET cancel_requested = true,
status = CASE WHEN tmp_mail.status = 'pending' THEN 'cancelled' ELSE tmp_mail.status END
WHERE tmp_mail.status IN ('pending','sending')
RETURNING dedupe_key, status, (xmax = 0) AS tombstone_moi;
dedupe_key | status | tombstone_moi
--------------------+-----------+---------------
order-confirmed:50 | cancelled | f ← huỷ kịp
order-confirmed:51 | sending | f ← quá muộn, chỉ kịp chặn retry
order-confirmed:53 | cancelled | t ← tombstone, chặn OrderConfirmed đến sau
(3 rows) ← 52 không có trong RETURNING: đã sent, quá muộn
INSERT 0 3
| Trạng thái lúc cancel đến | Kết quả | Việc tiếp theo |
|---|---|---|
| chưa có dòng | tạo tombstone | không có gì |
pending | → cancelled | publish EmailCancelled |
sending | giữ sending, bật cancel_requested | chờ kết quả SMTP (xem dưới) |
sent / unknown | không đổi, không có trong RETURNING | publish EmailCancelTooLate — order service quyết có gửi mail đính chính không |
Vì sao cần cancel_requested: kiến trúc 2 không có check-at-send vì mail service
không đọc được bảng orders. Nếu SMTP lỗi, worker trả dòng về pending → retry → gửi
mail cho đơn đã huỷ. Chạy thật cả bẫy lẫn cách chữa:
-- bẫy: nhánh lỗi của worker trả về pending, không nhìn cờ huỷ
UPDATE tmp_mail SET status='pending' WHERE dedupe_key='order-confirmed:44' AND status='sending' AND attempts=1;
dedupe_key | status
--------------------+---------
order-confirmed:44 | pending ← lần retry sau sẽ GỬI
-- chữa: nhánh lỗi đọc cờ
UPDATE tmp_mail SET status = CASE WHEN cancel_requested THEN 'cancelled' ELSE 'pending' END
WHERE dedupe_key='order-confirmed:44' AND status='sending' AND attempts=1
RETURNING dedupe_key, status;
dedupe_key | status
--------------------+-----------
order-confirmed:44 | cancelled
Câu claim của sender cũng phải thêm AND NOT cancel_requested, cho trường hợp lease hết hạn và
worker khác claim lại dòng sending.
Mail hẹn giờ (delayed) ở kiến trúc 2: đừng hẹn giờ bằng broker
Nếu dùng RabbitMQ delayed message plugin hay "gửi message sau 3 ngày", message nằm trong broker 3 ngày và
không huỷ được. Cách lười mà đúng: consumer ghi dòng mail ngay với cột send_after, sender chỉ claim
dòng có send_after <= now(). Hẹn giờ nằm trong DB của mail service → huỷ vẫn là 1 câu UPDATE như trên.
Hẹn giờ ở phía provider (Resend có scheduled_at và POST /emails/{id}/cancel — theo tài
liệu qua Context7, chưa chạy thật) thì huỷ phải gọi thêm API ra ngoài, và API đó cũng có thể timeout
giống điểm ③. Chỉ dùng khi thật sự cần provider giữ lịch; mặc định giữ lịch ở DB mình.
Bẫy khi huỷ ở kiến trúc 2
- Handler huỷ chỉ UPDATE, không tạo tombstone. Nổ trễ: chạy đúng 99% thời gian, chỉ sai khi
event đến lệch thứ tự — thường là lúc có sự cố khác (DLQ replay, broker failover). Lab có tombstone:
-- OrderCancelled đến trước id | status | tombstone_moi 3 | cancelled | t -- OrderConfirmed đến sau: INSERT ... ON CONFLICT DO NOTHING RETURNING id (0 rows) INSERT 0 0 - Tombstone dùng
event_idcủa event huỷ. 1 event huỷ nhiều mail thì đụng UNIQUE:
Consumer throw → nack → giao lại → throw tiếp: poison message. Chữa: tombstone đểERROR: duplicate key value violates unique constraint "tmp_mail_event_id_key" DETAIL: Key (event_id)=(0199a1f0-0000-7000-8000-00000000c045) already exists.event_idNULL (UNIQUE cho phép nhiều NULL), hoặc lưu id event huỷ ở cột riêng không UNIQUE. - 0 dòng trả về có 2 nghĩa. Broker giao lại
OrderCancelledlần 2 → dòng đãcancelled→ 0 dòng. Dòng đãsent→ cũng 0 dòng. PhảiSELECT statuscác key không có trong RETURNING để phân biệt "đã huỷ rồi" với "quá muộn", nếu không sẽ phátEmailCancelTooLatesai. - Tombstone chặn mãi mãi. Đơn bị huỷ rồi được mở lại (reopen) →
OrderConfirmedmới bị tombstone nuốt. Chữa: lần xác nhận mới phải có dedupe key mới (order-confirmed:43:v2), hoặcOrderReopenedxoá tombstone. - Dọn tombstone quá sớm. Giống bẫy 4 ở trên: giữ ít nhất bằng retention của broker, nếu không
replay lại
OrderConfirmedsẽ tạo mail cho đơn đã huỷ. - Order service báo khách "đã huỷ mail" ngay. Kiến trúc 2 là bất đồng bộ: lúc bấm huỷ chưa ai
biết mail kịp huỷ hay không. Chỉ nói "đơn đã huỷ", còn mail thì chờ
EmailCancelled/EmailCancelTooLatequay về.
So sánh nhanh
| Kiến trúc 1 | Kiến trúc 2 | |
|---|---|---|
| Cách huỷ | UPDATE ... WHERE status='pending' cùng tx với huỷ đơn | event OrderCancelled → upsert tombstone |
| Biết kết quả huỷ | ngay, qua RETURNING | trễ, qua event trả về |
| Sai thứ tự | không có (1 DB, row lock xếp hàng) | có → cần tombstone |
| Chặn retry sau khi huỷ | check-at-send: claim NOT EXISTS đơn đã huỷ | cờ cancel_requested |
| Mail hẹn giờ | delayed job + Skip::when, hoặc cột send_after | cột send_after trong DB mail, không hẹn bằng broker |
| Quá muộn (đã gửi) | giống nhau: không rút lại được, chỉ gửi mail đính chính — là quyết định nghiệp vụ | |
2. Lợi ích
- Không mất mail khi hạ tầng lỗi: Redis chết, broker chết, worker bị kill khi deploy — outbox còn đó, sweeper/relay chạy lại là gửi tiếp.
- Trạng thái mail truy vết được: mỗi mail là 1 dòng có
status,attempts,provider_id. Khách hỏi "sao tôi không nhận mail" → 1 câuSELECTlà biết. - Gửi lại có kiểm soát: support bấm "gửi lại" = đổi
statusvềpending, không phải chạy tay job. - Kiến trúc 2 tách được tải: mail service scale riêng, đổi provider riêng, viết bằng ngôn ngữ khác, không làm order service chậm khi SMTP chậm.
3. Tác hại / hạn chế
- Vẫn có cửa sổ gửi trùng nếu provider không có idempotency key. Chỉ thu nhỏ được, không xoá được. Nói thẳng điều này khi phỏng vấn.
- Trễ hơn: sweeper/relay polling thêm 0,5–60 giây. Mail OTP cần nhanh thì phải bấm chuông ngay (dispatch/afterCommit), sweeper chỉ là lưới an toàn.
- Thêm bảng, thêm process, thêm việc dọn dẹp: outbox phình, relay phải được giám sát.
- Kiến trúc 2 thêm hẳn một hệ phân tán: broker, DLQ, schema event, versioning, chiều feedback. Chi phí vận hành gấp nhiều lần kiến trúc 1.
- Idempotency key của provider có hạn (Resend: 24 giờ). Retry sau 24h là gửi trùng được.
4. Trade-off
| Tiêu chí | Kiến trúc 1 — monolith, chung DB | Kiến trúc 2 — mail service riêng |
|---|---|---|
| Chống mất (điểm ①) | 1 transaction: order + outbox (+ job nếu database driver) | outbox + relay ở order service |
| Chống trùng (điểm ②) | claim có điều kiện trên chính dòng outbox | UNIQUE event_id + dedupe_key ở DB mail service |
| Điểm ③ (SMTP) | giống nhau: chính sách theo loại mail + idempotency key của provider | |
| Số chặng at-least-once | 1 (queue → worker) | 3 (relay → broker, broker → consumer, sender → SMTP) + chiều feedback |
| Biết trạng thái mail | JOIN trực tiếp | qua event feedback, có độ trễ, có thể sai thứ tự |
| Dữ liệu dựng mail | đọc thẳng DB lúc gửi (luôn mới nhất) | phải nằm trong event (chụp lúc xảy ra) |
| Chi phí vận hành | thấp | cao: broker, DLQ, schema, 2 DB |
| Chọn khi | 1 team, 1 codebase, < vài trăm nghìn mail/ngày | nhiều service cùng cần gửi mail, team riêng lo mail, cần đổi provider/scale độc lập |
Polling relay vs CDC (Debezium đọc WAL):
| Polling | CDC | |
|---|---|---|
| Độ trễ | = chu kỳ poll (0,5–1s) | gần tức thì |
| Tải DB | thêm query liên tục | đọc WAL, không query bảng |
| Vận hành | một artisan command | Kafka Connect + replication slot. Slot bị bỏ quên → WAL dồn đầy đĩa primary |
| Chọn khi | mặc định | đã có Kafka Connect, cần độ trễ thấp, nhiều bảng outbox |
Khi nào KHÔNG cần tất cả những thứ trên: mail marketing, digest hằng tuần, thông báo
"có người thích bài viết" — mất vài mail không ai biết. Dùng Mail::queue() bình thường là đủ.
Dựng outbox + 4 lớp chống trùng cho loại mail này là over-engineering.
5. Khó khăn khi vận hành thực tế
| Cần theo dõi | Câu đo | Báo động khi |
|---|---|---|
| Tuổi mail pending cũ nhất (outbox lag) | SELECT now() - min(created_at) FROM email_outbox WHERE status = 'pending' | > 5 phút |
| Mail kẹt ở sending | ... WHERE status = 'sending' AND locked_until < now() - interval '10 min' | > 0 |
Số mail unknown / failed | đếm theo giờ | tăng đột biến |
| Event chưa publish (kiến trúc 2) | ... FROM outbox WHERE published_at IS NULL | tuổi > 1 phút |
| Consumer lag / DLQ depth | metric của broker | DLQ > 0 |
Tỉ lệ attempts > 1 | đếm theo giờ | tăng → lease ngắn hoặc provider chập chờn → sắp có mail trùng |
| Bounce / complaint rate | webhook provider | provider có thể khoá tài khoản khi quá ngưỡng |
- Debug lúc 3h sáng: luôn log
dedupe_key+event_id+attempts+provider_idở mọi chặng. Không có 4 trường này thì không nối được "khách nhận 2 mail" với "worker nào gửi lần nào". - Rate limit của provider: sau sự cố, backlog xả một lượt → provider trả 429 → tất cả
retry cùng lúc → càng 429. Cần giới hạn tốc độ ở worker (
Redis::throttlehoặc giới hạn số worker). - Con người: support cần nút "gửi lại mail" an toàn — nút này phải tạo dedupe key mới
(
order-confirmed:42:resend:1) hoặc đổi dòng cũ vềpending, không được chạy tay job cũ. - Kiến trúc 2: 2 team, 2 lịch deploy, 1 hợp đồng event. Cần contract test hoặc schema registry, không thì lỗi chỉ lộ ra trên production khi DLQ đầy.
6. Case thực tế và cách xử lý
Case 1: Khách phàn nàn nhận 2 mail xác nhận đơn
SELECT status, attempts, provider_id, locked_until FROM email_outbox WHERE dedupe_key = 'order-confirmed:42'.- Chỉ có 1 dòng,
attempts = 2→ chạy 2 lần do lease hết hoặc timeout sau DATA. Tìm log 2 lần gửi theodedupe_key: lần 1 có exception timeout không? Có → đúng lab mục 0. - Có 2 dòng khác dedupe key → bug ở chỗ tạo key (vd key chứa timestamp). Sửa key.
- Kiến trúc 2: 1 dòng mail nhưng provider log có 2 → sender gửi trùng; 2 dòng mail với 2
event_id→ producer phát 2 lần và thiếu UNIQUEdedupe_key. - Sửa gốc: tăng lease theo p99.9 thời gian gửi đo thật, thêm idempotency key của provider nếu có.
Theo dõi tỉ lệ
attempts > 1sau khi sửa.
Case 2: Broker/Redis chết 20 phút lúc cao điểm (ví dụ minh hoạ: 50k mail dồn lại)
- Không cần làm gì với dữ liệu: order đã commit kèm outbox, không mất gì. Đây là lúc outbox trả công.
- Khi broker sống lại, đừng xả hết một lượt: relay/sweeper lấy lô nhỏ, worker có throttle, nếu không provider trả 429 hàng loạt.
- Ưu tiên: OTP/reset password đi queue riêng, xả trước. Mail cũ hơn N giờ loại marketing → đánh dấu
failed, không gửi nữa (gửi "chúc mừng đặt hàng" sau 6 tiếng còn tệ hơn không gửi). - Theo dõi outbox lag giảm về 0, rồi viết postmortem: vì sao cảnh báo đến muộn.
Case 3: Reset offset Kafka để xử lý lại, khách nhận lại mail cũ tuần trước
- Dừng consumer ngay (scale về 0) — mỗi phút chạy thêm là thêm hàng nghìn mail.
- Nguyên nhân: dedupe key đã bị dọn sau 3 ngày, broker giữ 7 ngày (bẫy 4 kiến trúc 2).
- Thêm chặn tạm: bỏ qua event có
occurred_at< now() - 24h với loại mail xác nhận. - Đưa offset tới đúng điểm cần xử lý lại, chạy lại consumer.
- Sửa lâu dài: thời gian giữ dedupe key ≥ retention của broker; runbook "reset offset" phải có bước kiểm tra này.
Case 4: SMTP timeout hàng loạt, không biết mail nào đã đi (mail thông báo trừ tiền)
- Loại mail này dùng chính sách
unknown, không tự retry. - Lấy danh sách
status = 'unknown', đối soát với log / webhookdeliveredcủa provider theoMessage-IDhoặc header riêng mình gắn vào mail (vdX-Dedupe-Key). - Có trong log provider →
sent. Không có →pendingđể gửi lại. - Không đối soát được (provider không có log) → quyết định nghiệp vụ: gửi lại kèm dòng "nếu đã nhận thông báo trước, vui lòng bỏ qua".
Case 5: Mail service deploy bản mới, toàn bộ message vào DLQ
- Xem 1 message trong DLQ: thường là field đổi tên / đổi kiểu, hoặc
versionmới chưa hỗ trợ. - Rollback mail service, hoặc sửa consumer chấp nhận cả 2 dạng.
- Đẩy lại từ DLQ về queue chính (shovel). An toàn vì consumer đã chống trùng theo
event_id.
Case 6: Khách huỷ đơn rồi mà vẫn nhận mail xác nhận / mail nhắc đánh giá
- Tra dòng mail theo
dedupe_key:status,attempts,sent_at, và so với thời điểm huỷ đơn. sent_attrước lúc huỷ → không phải bug, là "quá muộn". Kiểm tra màn hình có báo đúng "mail đã gửi" không, và có gửi mail đính chính theo quy định không.sent_atsau lúc huỷ,attempts > 1→ retry sau khi huỷ thua lúcsending. Kiến trúc 1: claim thiếuNOT EXISTSđơn đã huỷ. Kiến trúc 2: nhánh lỗi không đọccancel_requested.sent_atsau lúc huỷ,attempts = 1→ huỷ không chạm được dòng mail. Kiến trúc 1: có đường huỷ đơn quên đánh dấu outbox. Kiến trúc 2:OrderCancelledđến trước mà handler không tạo tombstone — tìm trong log thứ tự 2 event theoorder_id.- Sửa gốc xong thì quét các đơn đã huỷ còn mail
pendingđể huỷ nốt:... WHERE status = 'pending' AND order_id IN (SELECT id FROM orders WHERE status = 'cancelled')(kiến trúc 2: phát lạiOrderCancelledcho các đơn đó — handler idempotent nên an toàn).
7. Câu hỏi phụ interviewer hay hỏi tiếp
Exactly-once có tồn tại không?
Cho side effect ra ngoài hệ thống: không. Làm được at-least-once + idempotent (effectively-once). Chỉ đóng
được hết khi phía nhận hợp tác (idempotency key).
Sao không dùng 2PC / XA giữa DB và queue?
Redis, SMTP không hỗ trợ. RabbitMQ/Kafka cũng không làm XA với PostgreSQL theo cách thực dụng. 2PC còn khoá
tài nguyên khi coordinator chết. Outbox cho cùng kết quả mà chỉ cần transaction local.
afterCommit() không đủ à?
Không. Nó chỉ đẩy job sau commit; khe giữa commit và push vẫn làm mất job (lab T4: order commit, Redis chết,
không có job). Nó giải quyết vấn đề khác: worker chạy trước khi dữ liệu commit.
Sao không dedupe bằng Redis SET NX?
Được, nhưng Redis có thể evict key (allkeys-lru) hoặc mất key khi failover → gửi trùng âm thầm. Dedupe key
trong cùng DB với dữ liệu thì cùng số phận với dữ liệu. Xem bài idempotency key Redis.
Kafka có exactly-once rồi mà?
Chỉ trong phạm vi Kafka → Kafka. Tài liệu Kafka nói hệ thống ngoài "requires cooperation with such systems".
Mail service cần dữ liệu đơn hàng thì lấy ở đâu nếu không được đọc DB?
Fat event (mang đủ dữ liệu trong message) — mặc định nên chọn. Hoặc thin event + gọi API order service —
thêm phụ thuộc lúc chạy, dữ liệu có thể đã đổi.
Tại sao không cho mail service đọc thẳng DB order cho nhanh?
Hai service dính nhau qua schema: order đổi tên cột là mail service chết. Không ai sở hữu rõ ràng dữ liệu.
Đó là "distributed monolith" — mang cái giá của microservice mà không được cái lợi.
Khách huỷ đơn, làm sao huỷ job gửi mail đang nằm trong queue?
Không xoá job. Đánh dấu trạng thái (cancelled) ở chỗ worker bắt buộc đọc lúc claim; job chạy tới thấy
0 dòng thì thoát. Monolith: UPDATE ... WHERE status = 'pending' cùng transaction với huỷ đơn. Service
riêng: event OrderCancelled + tombstone, vì event có thể đến trước OrderConfirmed.
Mail đang gửi dở mà khách huỷ thì sao?
Huỷ thua, không rút lại được mail đã giao cho SMTP. Việc làm được: chặn các lần retry sau đó (check-at-send hoặc
cờ cancel_requested), báo đúng "quá muộn" cho người dùng, và để nghiệp vụ quyết có gửi mail đính chính.
Giữ thứ tự mail thế nào (welcome phải trước xác nhận đơn)?
Thường không nên phụ thuộc thứ tự. Nếu bắt buộc: partition theo user_id, 1 consumer mỗi partition,
1 relay. Đổi lại mất khả năng scale song song trong một partition.
Chọn dedupe key thế nào?
Theo ý định nghiệp vụ, không theo thời gian: order-confirmed:{order_id},
password-reset:{token_id}. Không chứa timestamp, không random.
Outbox table lớn quá thì sao?
Partial index cho dòng còn việc, xoá theo lô dòng đã sent, hoặc partition theo thời gian rồi
DROP partition cũ — nhưng nhớ giữ dedupe key đủ lâu (bẫy 4 kiến trúc 2).
Tóm tắt
- Exactly-once gửi mail không có. Nói "at-least-once + idempotent", và chỉ ra cửa sổ còn lại ở SMTP.
- Chống mất = transactional outbox.
afterCommitkhông phải outbox. - Monolith Laravel:
QUEUE_CONNECTION=databasecùng connection là outbox miễn phí; thêm bảngemail_outboxcódedupe_keyUNIQUE, claim có điều kiện, fencing token, sweeper. - Service riêng: 3 chặng at-least-once; chống trùng ở phía nhận bằng UNIQUE
event_id+dedupe_key; ack sau commit;event_idsinh lúc ghi outbox. - Đóng nốt điểm ③ chỉ bằng idempotency key của provider (Resend: 24h) — và
ResendTransportcủa Laravel không gửi được nó. - Mail không quan trọng thì đừng làm hết những thứ này.
Đọc thêm trong repo này
- Scale out queue worker —
retry_after, SKIP LOCKED, bloat bảng jobs. - Idempotency key ở Redis — vì sao eviction tạo bản ghi trùng.
- Chống double-submit — trùng từ phía người dùng, trước khi tới queue.
- Thông báo đa kênh — Notification của Laravel, bẫy gửi đồng bộ.