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:

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-oncegửi tối đa 1 lần — có thể mất, không bao giờ trùng
at-least-oncegử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-onceat-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 writeghi 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 outboxghi "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
relayprocess đọc outbox rồi đẩy lên broker
inbox / dedupe tablebả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 keykhoá gửi kèm request để bên nhận biết "request này tôi xử lý rồi"
leasequyề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 tokensố 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 confirmack: 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:

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ả:

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ểmChuyện gì xảy raXử lý bằng
① dual write DB + queueorder 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 jobworker chết / timeout / lease hết → queue giao job cho worker khácdedupe key + claim có điều kiện + fencing token
③ side effect không rõ kết quảtimeout sau khi SMTP đã nhậnchọ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áchKết quảĐánh giá
T1 — QUEUE_CONNECTION=database, cùng connection với orderrollback 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_commitorder rollback nhưng job vẫn nằm trong RedisSai: gửi mail cho đơn không tồn tại (phantom mail)
T3 — Redis + after_commit, rollbackkhông có jobĐúng cho trường hợp rollback
T4 — Redis + after_commit, Redis chết lúc pushorder đã commit, không có job, user nhận lỗi 500Sai: 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:

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áchGiữ transaction thêmDispatch 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=true0Order đã commit, job mất, user vẫn nhận 500Bẫy. Tự tay phá tính nguyên tử của T1
D — Redis, không afterCommitTới lúc timeout (lab: 3s)ROLLBACK, mất đơn hàng vì Redis chếtSai. Còn bị phantom job (T2) khi tx rollback vì lý do khác
E — Redis + afterCommit + try/catch0 (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:

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 database thì 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): ResendTransport copy mọi header của mail vào trường headers của API — tức là thành header trong email, không phải HTTP header Idempotency-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 mailMất thì saoTrùng thì saoChính sách khi timeout không rõ
OTP, reset passworduser kẹt, gọi supportvô hạigửi lại (at-least-once)
Xác nhận đơn, hoá đơnkhách lo, gọi supportkhó 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ọngkhách tưởng bị trừ 2 lầnunknown → đối soát với log/webhook provider rồi mới quyết
Marketingkhông saotăng unsubscribe, spam reportkhô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)

  1. Dùng afterCommit rồ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.
  2. Lease và retry_after lệch nhau → mail kẹt mãi ở sending. Worker bị SIGKILL lúc đang gửi. Database queue giao lại job sau retry_after (mặc định 90s). Nếu lease = 120s thì lúc đó lease chưa hết → claim trả 0 dòng → job return → 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.
  3. Lease ngắn hơn thời gian gửi thật → gửi trùng. Lab ở mục 0: timeout=3 nhưng mất 6,4s. Đặt lease theo p99.9 thời gian gửi đo thật, không theo số trong config. $timeout của job (pcntl kill) mới là giới hạn cứng.
  4. 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.
  5. Queue chạy connection DB khác với order (vd queue.connections.database.connection trỏ 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.
  6. Bật after_commit=true cho driver database "cho an toàn". Lab C: INSERT jobs chạ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=false cho connection database; chỉ Redis/SQS mới cần afterCommit.
  7. 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 đầy idle in transaction. Chữa: afterCommit + try/catch, đặt timeout Redis 1–2s, thêm idle_in_transaction_session_timeout phía PostgreSQL làm lưới an toàn.
  8. Dedupe key chọn sai. order-confirmed:42 đúng cho mail xác nhận. Nhưng password-reset:user-7 sẽ 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.
  9. 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 sent cũ hơn N ngày theo lô, hoặc partition theo tháng rồi DROP partition. Xem bài scale out queue worker.
  10. 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

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ốngNghĩa là gìXử lý
nackBroker báo không nhận messageChưa tới nơi. Retry kèm backoff (chờ tăng dần)
timeout, không có confirmKhông biết broker đã nhận hay chưaCoi 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ậnMở 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ỗiSau 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?

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)

  1. 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; Kafka enable.auto.commit=false, commit offset sau khi DB commit.
  2. event_id sinh 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.
  3. Dùng id của broker để dedupe. delivery tag của RabbitMQ đổi mỗi lần giao, offset Kafka đổi khi relay publish lại. Chỉ dùng id do producer sinh.
  4. 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_at quá cũ (vd > 24h với mail xác nhận).
  5. 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.
  6. Đổ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ạ.
  7. 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 theo hash(order_id) % N, mỗi phần 1 relay với 1 advisory lock riêng.
  8. 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).
  9. 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).
  10. 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:

Vì sao không xoá job khỏi queue

QueueJob nằm ở đâuXoá 1 job theo order_id được không
Laravel databasebảng jobs, cột payloadChỉ 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 Redislist queues:default, delayed ở sorted set queues:default:delayedPhả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)
RabbitMQqueueKhông có lệnh xoá 1 message theo id. Chỉ purge cả queue
Kafkalog 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ả:

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() + middleware SkipIfBatchCancelled cũng là cơ chế "đánh dấu rồi job tự thoát" giống hệt, chỉ là cờ nằm ở bảng job_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

  1. Cho huỷ cả dòng đang sending. Viết WHERE status IN ('pending','sending') cho "chắc". Chạy thật:
    UPDATE 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 |        1
    Khách đã nhận mail, DB lại ghi cancelled. Support tra cứu sẽ nói sai với khách. Chữa: chỉ huỷ pending; sending là "quá muộn".
  2. 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.
  3. 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.
  4. Skip kiểm tra sai thời điểm. Viết Skip::when($this->order->status === 'cancelled') với SerializesModels thì 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.
  5. Đơn bị xoá cứng thay vì đổi status: job có SerializesModels ném ModelNotFoundException và vào failed_jobs. Thêm public $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 đếnKết quảViệc tiếp theo
chưa có dòngtạo tombstonekhông có gì
pending→ cancelledpublish EmailCancelled
sendinggiữ sending, bật cancel_requestedchờ kết quả SMTP (xem dưới)
sent / unknownkhông đổi, không có trong RETURNINGpublish 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

  1. 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
  2. Tombstone dùng event_id của event huỷ. 1 event huỷ nhiều mail thì đụng UNIQUE:
    ERROR:  duplicate key value violates unique constraint "tmp_mail_event_id_key"
    DETAIL:  Key (event_id)=(0199a1f0-0000-7000-8000-00000000c045) already exists.
    Consumer throw → nack → giao lại → throw tiếp: poison message. Chữa: tombstone để event_id NULL (UNIQUE cho phép nhiều NULL), hoặc lưu id event huỷ ở cột riêng không UNIQUE.
  3. 0 dòng trả về có 2 nghĩa. Broker giao lại OrderCancelled lần 2 → dòng đã cancelled → 0 dòng. Dòng đã sent → cũng 0 dòng. Phải SELECT status cá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át EmailCancelTooLate sai.
  4. Tombstone chặn mãi mãi. Đơn bị huỷ rồi được mở lại (reopen) → OrderConfirmed mớ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ặc OrderReopened xoá tombstone.
  5. 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 OrderConfirmed sẽ tạo mail cho đơn đã huỷ.
  6. 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 / EmailCancelTooLate quay về.

So sánh nhanh

Kiến trúc 1Kiến trúc 2
Cách huỷUPDATE ... WHERE status='pending' cùng tx với huỷ đơnevent OrderCancelled → upsert tombstone
Biết kết quả huỷngay, qua RETURNINGtrễ, 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_aftercộ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

3. Tác hại / hạn chế

4. Trade-off

Tiêu chíKiến trúc 1 — monolith, chung DBKiế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 outboxUNIQUE 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-once1 (queue → worker)3 (relay → broker, broker → consumer, sender → SMTP) + chiều feedback
Biết trạng thái mailJOIN trực tiếpqua 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ànhthấpcao: broker, DLQ, schema, 2 DB
Chọn khi1 team, 1 codebase, < vài trăm nghìn mail/ngàynhiề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):

PollingCDC
Độ trễ= chu kỳ poll (0,5–1s)gần tức thì
Tải DBthêm query liên tụcđọc WAL, không query bảng
Vận hànhmột artisan commandKafka Connect + replication slot. Slot bị bỏ quên → WAL dồn đầy đĩa primary
Chọn khimặ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õiCâu đoBá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 NULLtuổi > 1 phút
Consumer lag / DLQ depthmetric của brokerDLQ > 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 ratewebhook providerprovider có thể khoá tài khoản khi quá ngưỡng

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

  1. SELECT status, attempts, provider_id, locked_until FROM email_outbox WHERE dedupe_key = 'order-confirmed:42'.
  2. 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 theo dedupe_key: lần 1 có exception timeout không? Có → đúng lab mục 0.
  3. Có 2 dòng khác dedupe key → bug ở chỗ tạo key (vd key chứa timestamp). Sửa key.
  4. 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 UNIQUE dedupe_key.
  5. 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 > 1 sau 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)

  1. 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.
  2. 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.
  3. Ư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).
  4. 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

  1. Dừng consumer ngay (scale về 0) — mỗi phút chạy thêm là thêm hàng nghìn mail.
  2. 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).
  3. Thêm chặn tạm: bỏ qua event có occurred_at < now() - 24h với loại mail xác nhận.
  4. Đưa offset tới đúng điểm cần xử lý lại, chạy lại consumer.
  5. 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)

  1. Loại mail này dùng chính sách unknown, không tự retry.
  2. Lấy danh sách status = 'unknown', đối soát với log / webhook delivered của provider theo Message-ID hoặc header riêng mình gắn vào mail (vd X-Dedupe-Key).
  3. Có trong log provider → sent. Không có → pending để gửi lại.
  4. 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

  1. Xem 1 message trong DLQ: thường là field đổi tên / đổi kiểu, hoặc version mới chưa hỗ trợ.
  2. Rollback mail service, hoặc sửa consumer chấp nhận cả 2 dạng.
  3. Đẩ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á

  1. Tra dòng mail theo dedupe_key: status, attempts, sent_at, và so với thời điểm huỷ đơn.
  2. sent_at trướ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.
  3. sent_at sau lúc huỷ, attempts > 1 → retry sau khi huỷ thua lúc sending. Kiến trúc 1: claim thiếu NOT EXISTS đơn đã huỷ. Kiến trúc 2: nhánh lỗi không đọc cancel_requested.
  4. sent_at sau 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 theo order_id.
  5. 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ại OrderCancelled cho 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

Đọc thêm trong repo này