Thiết kế hệ thống notification đa kênh trong .NET
Cách xây service notification trong .NET: kênh email, SMS, push, kho preference, dedup, và pipeline queue giao tin tin cậy.
Mục lục
Hệ notification là ví dụ sạch nhất của fan-out xuyên kênh với luật preference chặt. Chương này thiết kế một trong .NET: event vào, preference route, queue theo kênh rút sang provider, và delivery được track. Cùng pattern từ chương trước - queue, idempotency, outbox - lắp thành service sống sót outage provider.
Khi nào service notification chuyên có giá trị?
Ba tín hiệu.
Nhiều sender, cùng logic. Khi năm service khác nhau cùng tự gửi email, bạn có năm cài đặt template, retry, xử unsubscribe khác nhau. Tập trung dừng drift đó.
Tuân thủ preference và unsubscribe. GDPR, CAN-SPAM, và quy định tương tự đòi xử opt-out có audit. Service tập trung là chỗ thực dụng duy nhất để enforce.
Routing đa kênh. "Đã giao" đi tới email + push + SMS tuỳ preference user. Logic routing không thuộc về service order.
Nếu chỉ có một sender, một kênh, không ràng buộc tuân thủ,
method SendEmailAsync trên service là đủ.
Số nào nên ngân sách?
Event / ngày 10M
Channel/event (avg) 1.5
Notification / ngày 15M
Tốc độ đỉnh 15M / 100K * 5 = 750/giây
Email gửi 100/giây mỗi provider
SMS gửi 10/giây mỗi provider
Push gửi ~1000/giây
Storage 1 KB mỗi notification * 15M = 15 GB/ngày
Các con số nói: một đội ASP.NET Core, một Postgres cho storage + preference, một cache Redis cho idempotency key, ba queue (mỗi kênh). Rate limit provider thường là điểm nghẽn.
Kiến trúc trông thế nào?
flowchart LR
Producer[Service order] --> Q0[(queue events)]
Q0 --> Router[Worker routing]
Router --> Pref[(DB preference)]
Router --> QE[(queue email)]
Router --> QS[(queue SMS)]
Router --> QP[(queue push)]
QE --> WE[Worker email] --> Mailgun
QS --> WS[Worker SMS] --> Twilio
QP --> WP[Worker push] --> FCM
WE --> NDB[(log notification)]
WS --> NDB
WP --> NDB
Producer publish một event mỗi hành động nghiệp vụ. Worker routing
tra preference, phân giải sang message theo kênh, publish sang
queue kênh. Mỗi worker kênh rút queue sang provider. Mọi delivery
ghi log notifications cho audit và analytics.
Cấu hình .NET 10 cho worker routing?
public record OrderShipped(Guid OrderId, Guid UserId, string TrackingNumber);
public class NotificationRouter(
IPreferenceService prefs,
IPublishEndpoint bus,
AppDbContext db) : IConsumer<OrderShipped>
{
public async Task Consume(ConsumeContext<OrderShipped> ctx)
{
var msg = ctx.Message;
var userPrefs = await prefs.GetAsync(msg.UserId, "order_shipped");
var notification = new Notification
{
Id = Guid.NewGuid(),
EventType = "order_shipped",
UserId = msg.UserId,
Payload = JsonSerializer.Serialize(msg),
CreatedAt = DateTimeOffset.UtcNow
};
db.Notifications.Add(notification);
await db.SaveChangesAsync();
if (userPrefs.Email)
await bus.Publish(new SendEmail(notification.Id, msg.UserId, "order_shipped", msg));
if (userPrefs.Sms)
await bus.Publish(new SendSms(notification.Id, msg.UserId, "order_shipped", msg));
if (userPrefs.Push)
await bus.Publish(new SendPush(notification.Id, msg.UserId, "order_shipped", msg));
}
}
// Worker email - send idempotent
public class EmailSendConsumer(IMailgunClient mailgun, IConnectionMultiplexer redis,
ITemplateRenderer renderer, AppDbContext db)
: IConsumer<SendEmail>
{
public async Task Consume(ConsumeContext<SendEmail> ctx)
{
var key = $"sent:email:{ctx.Message.NotificationId}";
var redisDb = redis.GetDatabase();
if (!await redisDb.StringSetAsync(key, "1", TimeSpan.FromDays(7), When.NotExists))
return; // đã gửi
var template = await db.Templates.FirstAsync(t => t.EventType == ctx.Message.EventType
&& t.Channel == "email");
var rendered = renderer.Render(template.Body, ctx.Message.Payload);
var providerId = await mailgun.SendAsync(ctx.Message.UserId, template.Subject, rendered);
await db.NotificationDeliveries.AddAsync(new()
{
NotificationId = ctx.Message.NotificationId,
Channel = "email",
ProviderId = providerId,
DeliveredAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
}
Ba chi tiết. StringSetAsync với When.NotExists là check
idempotency atomic. Renderer template plug được - đổi Handlebars
sang Liquid hay gì - nên người không phải engineer sửa template
không cần đổi code. Mọi delivery log với ID provider để bạn
correlate bounce và complaint về notification.
Đường scale-out hỗ trợ?
- Worker routing: song song được - partition theo hash user ID để event của một user giữ thứ tự nếu cần.
- Worker kênh: scale theo kênh; mỗi kênh thường có rate limit provider cố định, nên điểm nghẽn là provider.
- Storage: partition
notificationstheo tháng; archive sau 90 ngày. - Preference: cache trong Redis; backed bởi Postgres.
Cho volume rất cao (>1M notification/phút), hệ chuyên biệt như AWS SNS hay Firebase thay worker SMS/push hoàn toàn. Logic routing ở lại service của bạn.
Tạo failure mode nào?
- Outage provider - provider email trả 503. Phòng: retry Polly; fallback provider thứ hai; queue không mất message.
- Complaint spam - user báo email của bạn là spam, hại danh tiếng domain. Phòng: enforce unsubscribe, suppress list bounce tự động, theo dõi tỉ lệ complaint.
- Lỗi template - typo placeholder làm renderer crash cho event đó. Phòng: validate schema lúc save template; cô lập một event xấu khỏi queue (xử poison message).
- Loop / runaway - một notification kích event kích
notification khác. Phòng: dedup theo
(userId, eventType, contentHash)cho cửa sổ ngắn.
Khi nào service notification quá liều?
Khi bạn chỉ gửi email giao dịch và không có ma trận preference.
Một call MailKit từ service order ổn cho volume đó. Xây service
chuyên khi có nhiều sender, nhiều kênh, hoặc yêu cầu tuân thủ -
không trước.
Đi tiếp đâu từ đây?
Case study kế tiếp: service upload file - hình đổi (object nhị phân lớn, presigned URL) nhưng nhiều block (queue, idempotency, observability) chuyển trực tiếp.
Câu hỏi thường gặp
Sao một queue mỗi kênh?
Tránh gửi trùng ra sao?
hash(eventId + userId + channel); sender check Redis trước khi gửi. Nếu có, bỏ. Send + ghi key bọc trong Lua script cho atomic. Chương idempotency giải thích pattern đầy đủ.Data preference user lưu ở đâu?
(user_id, event_type) -> channels[]. Cache row hot trong Redis. Preference đổi hiếm; TTL cache một giờ là an toàn. Lookup xảy ra trong worker routing - nếu user opt-out email, queue email không bao giờ được ghi.Template chạy ra sao?
{{ user.name }}). Renderer (Handlebars.NET hoặc Liquid) thay lúc gửi. Localisation sống trong bảng template_translations (template_id, locale, body). Versioning theo template_id; khi marketer sửa, version mới được tạo và cũ giữ lại để tái lập.