Case study Trung bình 4 phút đọc

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
  1. Khi nào service notification chuyên có giá trị?
  2. Số nào nên ngân sách?
  3. Kiến trúc trông thế nào?
  4. Cấu hình .NET 10 cho worker routing?
  5. Đường scale-out hỗ trợ?
  6. Tạo failure mode nào?
  7. Khi nào service notification quá liều?
  8. Đi tiếp đâu từ đây?

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ợ?

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?

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?
Các kênh có rate limit và đặc tính tin cậy khác nhau. Email gửi 100/giây, SMS 10/giây (Twilio), push hàng nghìn. Một queue mỗi kênh cho mỗi consumer scale độc lập và không cho provider SMS chậm chèn email. Queue mỗi provider, không phải kênh, khi có nhiều provider mỗi kênh.
Tránh gửi trùng ra sao?
Idempotency key mỗi notification. Tính 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?
Postgres, với map (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?
Template sống trong DB với placeholder ({{ 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.