Bỏ qua đến nội dung
Quay lại

Thiết kế hệ thống thông báo đa kênh, bền vững

Đã đăng:  at  10:00 SA

1. Bài toán và phạm vi

Nền tảng nhận notification command từ các product service như billing, chat, security và marketing. Nền tảng render template có version, đánh giá preference của người nhận, tạo delivery cho từng channel email, SMS, push và in-app, đồng thời lưu audit trail bền vững.

Điểm khó không phải là gửi một message một lần. Điểm khó là giữ đúng state transition khi bất kỳ boundary nào cũng có thể lỗi: client có thể retry, worker có thể crash, hoặc provider có thể timeout sau khi đã nhận message. Thiết kế này bao phủ acceptance API, state bền vững, delivery bất đồng bộ, retry, cô lập provider, cancellation và audit.

[SOURCE FACT] Hợp đồng delivery là at-least-once. Notification đã được chấp nhận không được mất âm thầm. Không cam kết exactly-once delivery vì provider có thể timeout sau khi đã chấp nhận message.

[PROPOSED DESIGN] Dùng idempotency key ở mọi command boundary và delivery ID ổn định khi gọi provider nếu provider hỗ trợ deduplication. Response đồng bộ chỉ xác nhận durable acceptance, không xác nhận provider delivery. Nhờ đó provider SMS chậm không làm mọi product request chậm theo.

Yêu cầu chức năng:

[SOURCE FACT] Yêu cầu phi chức năng:

2. Mô hình capacity

[SOURCE FACT] Workload ban đầu là một triệu channel message mỗi ngày. Một logical notification có thể fan-out, vì vậy đơn vị sizing là channel message, không phải producer event.

Đại lượngCơ sởKết quả
Message mỗi ngàyYêu cầu đã nêu1,000,000/ngày
Tốc độ enqueue/dispatch trung bình1,000,000 / 86,40011.6 messages/s
Tốc độ peak10x trung bình cho launch và billing run116 messages/s
Growth headroom3x peak cho capacity và hấp thụ burst350 messages/s
Payload message trung bìnhRendered body 4 KB cộng metadata4 GB/ngày raw
Durable row footprintPayload 4 KB cộng 2 KB index/overhead6 GB/ngày
Audit retention13 tháng, xấp xỉ 395 ngày2.37 TB trước compression
Delivery-attempt row1.5 attempt/message, mỗi row 2 KB1.19 TB/năm
In-app read20% message thành inbox item; 5 read/item1,000,000 read/ngày

[ANALYSIS] Ước tính 6 GB/ngày là 1,000,000 x 6 KB. Với 3x replica, lượng dữ liệu trong 395 ngày khoảng 7.1 TB. Cold audit storage có thể giảm chi phí; không nên bắt hot database giữ toàn bộ 13 tháng. Partition theo tháng làm retention và deletion đơn giản hơn về vận hành.

Ở envelope đã nêu, rendered payload bandwidth là 4 KB x 350 messages/s = 1.4 MB/s, tương đương khoảng 11.2 Mb/s trước protocol overhead. Provider egress là phần riêng. SMS và email có thể thêm response từ provider, còn payload push thường nhỏ hơn. Với workload đã nêu, in-app có khoảng 5:1 read so với write; durable delivery path vẫn thiên về write.

[ASSUMPTION] Một product model có 2 triệu daily active user và 0.5 notification trên mỗi active user mỗi ngày sẽ tạo 2,000,000 x 0.5 = 1,000,000 notification. Đây là giả định minh họa cho product, không phải tuyên bố về mọi sản phẩm. Với volume tăng 15% mỗi năm, average năm đầu khoảng 1.08M/ngày; 350 messages/s là capacity envelope ban đầu.

[ANALYSIS] Availability 99.95% tương ứng khoảng 21.9 phút downtime mỗi tháng cho acceptance API. Trong một API outage ngắn, dispatch vẫn có thể tiếp tục từ queue; vì vậy acceptance và delivery nên có SLO riêng.

3. API contract

[PROPOSED DESIGN] Mọi endpoint dùng TLS, OAuth2/mTLS cho service-to-service và X-Request-Id. Server lấy tenant_id từ credential và kiểm tra quyền tenant trước khi đọc hoặc thay đổi dữ liệu.

Submit notification

POST /v1/notifications

{
  "idempotency_key": "billing:invoice:inv_928:due",
  "recipient": {"user_id": "usr_42"},
  "template": {"name": "invoice_due", "version": 3},
  "variables": {"amount": "125.00", "currency": "USD", "due_date": "2026-08-20"},
  "channels": ["email", "push", "in_app"],
  "send_at": "2026-08-19T04:00:00Z",
  "dedupe_window_seconds": 86400
}

Producer đã được xác thực. Variables được kiểm tra theo schema và giới hạn kích thước; mặc định từ chối HTML và URL tùy ý. Server trả 202 Accepted sau khi ghi command bền vững:

{"notification_id":"ntf_01J...", "status":"accepted", "channels":["email","push","in_app"]}

Unique key (tenant_id, idempotency_key) khiến retry trả về notification ID ban đầu. Vì vậy nếu response bị mất, client vẫn có thể retry an toàn. 409 Conflict nghĩa là cùng key được dùng cho request fingerprint khác.

Query status

GET /v1/notifications/{notification_id} trả logical state và state của từng channel. Kết quả eventually consistent tối đa vài giây trong lúc worker cập nhật attempt.

Preferences

GET /v1/users/{user_id}/notification-preferences và PUT /v1/users/{user_id}/notification-preferences quản lý opt-out và quiet hours. PUT idempotent với version If-Match; version cũ trả 412 Precondition Failed. Channel quan trọng với product có thể được policy bảo vệ, nhưng legal opt-out luôn được ưu tiên.

In-app inbox

GET /v1/users/{user_id}/inbox?cursor=...&limit=50 trả danh sách phân trang bằng cursor. POST /v1/users/{user_id}/inbox/{message_id}/read idempotent và ghi read_at.

Cancellation dùng POST /v1/notifications/{id}/cancel cùng idempotency key. Chỉ thành công khi channel delivery còn pending hoặc scheduled. Provider call đã bắt đầu có thể vẫn hoàn tất, nên không quảng bá cancellation như một cơ chế retraction.

4. Durable state

[PROPOSED DESIGN] PostgreSQL là source of truth cho command, preference và state transition. Kafka chỉ vận chuyển work, không phải audit database. Command và idempotency record phải được commit trước khi API trả 202.

CREATE TABLE notifications (
  tenant_id        bigint NOT NULL,
  notification_id  uuid NOT NULL,
  idempotency_key  text NOT NULL,
  request_hash     bytea NOT NULL,
  user_id          bigint NOT NULL,
  template_name    text NOT NULL,
  template_version int NOT NULL,
  variables_json   jsonb NOT NULL,
  created_at       timestamptz NOT NULL,
  send_at          timestamptz NOT NULL,
  status           text NOT NULL,
  PRIMARY KEY (tenant_id, notification_id),
  UNIQUE (tenant_id, idempotency_key)
) PARTITION BY RANGE (created_at);

CREATE TABLE deliveries (
  tenant_id        bigint NOT NULL,
  delivery_id      uuid NOT NULL,
  notification_id  uuid NOT NULL,
  channel          text NOT NULL,
  status            text NOT NULL,
  attempt_count    int NOT NULL,
  next_attempt_at  timestamptz,
  provider_id      text,
  last_error       text,
  created_at       timestamptz NOT NULL,
  updated_at       timestamptz NOT NULL,
  PRIMARY KEY (tenant_id, delivery_id)
);

request_hash ngăn việc dùng lại idempotency key cho request khác. Mỗi delivery có status và retry schedule riêng, nên SMS provider lỗi không chặn email đang hoạt động bình thường.

Preference và inbox row phải có tenant scope. Audit record nên append-only và có event type, actor hoặc service, timestamp, delivery ID nếu có và outcome. Personal content nên tách khỏi operational metadata để privacy deletion không phải rewrite toàn bộ lịch sử vận hành.

5. Processing flow

[PROPOSED DESIGN] Dùng transactional outbox:

  1. API validate command, kiểm tra idempotency key, lưu notification và channel delivery, đồng thời ghi outbox event trong một PostgreSQL transaction.
  2. Outbox publisher đọc các row đã commit và publish work lên Kafka. Chỉ đánh dấu outbox row là published sau khi broker acknowledge.
  3. Channel consumer claim work, kiểm tra lại cancellation và policy state, render hoặc load template đã được duyệt, rồi gọi provider qua channel adapter.
  4. Consumer lưu attempt và state kế tiếp. Crash có thể gây redelivery, nên state transition và attempt record phải an toàn khi lặp lại.

Outbox xử lý khoảng trống giữa database và broker. Nó không biến provider delivery thành exactly once; stable delivery ID, khả năng deduplication của provider và xử lý state idempotent ở local mới giúp giảm duplicate effect.

Scheduled work có thể nằm trong PostgreSQL đến send_at, hoặc đặt trong queue hỗ trợ delay. Invariant cần giữ là item scheduled không được dispatch trước due time và cancellation phải lấy row lock trước khi dispatch claim.

6. Retry và cô lập provider

Provider call dùng timeout có giới hạn. Failure có thể retry gồm timeout, lỗi connection và response của provider được phân loại rõ là transient. Lỗi validation hoặc policy là permanent, không retry. Exponential backoff có jitter và retry window tối đa; delivery hết retry được đưa vào DLQ cùng error và delivery ID.

[PROPOSED DESIGN] Giữ concurrency limit và connection pool riêng cho từng provider và channel. Circuit breaker mở sau một failure threshold đã cấu hình, chặn call mới trong recovery interval, sau đó cho phép một số probe nhỏ. Consumer tạo backpressure khi provider limit hoặc local pool đầy, thay vì nhận vô hạn work trong memory.

Provider adapter nên phân biệt accepted, rejected, rate_limited, transient_error và permanent_error. Nếu timeout khiến kết quả chưa biết, ghi unknown và retry với cùng delivery ID. Không đánh dấu message failed chỉ vì client-side timeout đã hết.

7. Consistency, cancellation và inbox read

Notification có thể đã được accept trong khi policy evaluation hoặc delivery còn pending. Preference nên được đọc tại policy decision point. Sau khi delivery đã handed off cho provider, opt-out mới không thể reliably recall message; hệ thống chỉ có thể dừng attempt tiếp theo và ghi lại quyết định.

Cancellation lấy row lock hoặc dùng compare-and-set transition từ scheduled hoặc pending sang cancelled. Dispatch dùng cùng transition boundary. Nếu dispatch đã chuyển sang sending, cancellation trả outcome không thành công vì provider call có thể hoàn tất.

Inbox read độc lập với trạng thái delivery bên ngoài. Tạo in-app item như một channel delivery riêng, phân trang bằng cursor ổn định (created_at, message_id), và để read endpoint thực hiện idempotent update. Cách này tránh offset pagination bị dịch khi message mới đến.

8. Vận hành và kiểm chứng

Theo dõi acceptance latency, queue lag, delivery latency theo channel, số retry, DLQ depth, nhóm lỗi provider, trạng thái circuit breaker, mức sử dụng database pool và tuổi outbox row. Alert khi lag kéo dài hoặc DLQ tăng liên tục, không chỉ khi API báo lỗi.

Audit state transition thay vì chỉ dựa vào status column có thể thay đổi. Một audit record hữu ích phải trả lời: command được accept lúc nào, policy decision là gì, attempt nào đã chạy, provider trả gì và vì sao chọn final state.

Test trực tiếp các failure boundary: submit trùng, mất response, consumer crash sau provider call, provider timeout với outcome chưa biết, outbox retry, preference version cũ, cancellation race với dispatch và DLQ replay. Công cụ replay phải giữ nguyên delivery ID, đồng thời không bypass tenant authorization hoặc policy check.

9. Tóm tắt

Thiết kế tách durable acceptance khỏi provider delivery bất đồng bộ. PostgreSQL quản lý command và state, outbox publish work đã commit, Kafka buffer work, còn channel consumer xử lý retry độc lập. Idempotency key, stable delivery ID, timeout có giới hạn, backpressure, circuit breaker và append-only audit trail xử lý các failure mode chính. Các con số capacity là envelope sizing ban đầu; giới hạn production cần được kiểm chứng bằng payload thực tế, quota của provider và failure testing.


Chia sẻ bài viết này trên:

Bài trước
Thiết kế bảng tin xếp hạng với hybrid fan-out
Bài tiếp theo
Thiết kế hệ thống thanh toán với sổ cái kép