İçeriğe atla

Ölçeklenebilir Kullanıcı Bildirim Sistemi: Mimari ve Veritabanı Tasarımı

Milyonlarca kullanıcıya hizmet veren kurumsal bildirim sistemleri için tasarım desenleri, veritabanı şemaları ve mimari kararlar

Ayhan Sipahi Ayhan Sipahi

Çok kanallı bildirim sistemleri, ekipler bunları şablon-ve-gönder pipeline’ı olarak modellediğinde başarısız olur. Bildirim aslında kullanıcı başına verilen bir yönlendirme kararıdır: e-posta, SMS, push ve uygulama içi kanallar arasında dağıtılabilir, birleştirilebilir, bastırılabilir veya ertelenebilir. Açık bir router katmanı olmadan teslim garantileri çöker, kullanıcı tercihleri göz ardı edilir ve uyumluluk denetimi imkânsız hale gelir.

Başlangıç için varsayılan yaklaşım, ayrı bir kanal router’ı olan event-driven bir pipeline. Üreticiler event yayınlar, engine tercihleri ve rate limit’leri çözer, router ise kanala özgü her kararı üstlenir. Bunu destekleyen üç parça atlanamaz: event’leri teslimat denemelerinden ayıran bir PostgreSQL şeması, zaman dilimine duyarlı sessiz saatlerle tercih çözümü ve hataların kullanıcıya ulaşmadan önce ayıklanabilir kalması için her kayıtta bir correlation ID.

”Basit” Bildirimlerin Gizli Karmaşıklığı#

Bildirimler için başlangıç zihinsel modeli şu şekilde işler: olay tetiklenir → mesaj gönderilir → biter. Üretim gerçekliği ise kullanıcı tercihleri, teslimat kanalları, hız sınırlama, yeniden deneme mantığı, şablon yönetimi, analitik takibi ve yasal uyumluluk gibi bileşenlerin karmaşık orkestrasyonudur.

Karmaşıklık ilk büyük ürün lansmanında kendini gösterir. 10.000 kullanıcı aynı anda hoş geldin e-postaları, şifre sıfırlama ve aktivite bildirimleri alır. Email servisi throttle etmeye başlar, veritabanı bağlantı havuzu maksimuma çıkar ve kullanıcılar yinelenen bildirimlerden şikayet etmeye başlar.

Sistem Mimarisi#

Aşağıdaki mimari event alımını, politika çözümünü ve kanal teslimatını birbirinden ayırır. Her bileşen ayrı bir hata moduna karşılık gelir: event bus ani yükleri emer, tercih yöneticisi ve rate limiter işi sağlayıcıya ulaşmadan önce reddeder, router ise bir kanaldaki hatanın diğerlerine bulaşmasını engeller.

Event Sources

Event Bus

Notification Engine

Template Service

Preference Manager

Rate Limiter

Channel Router

In-App Channel

Email Channel

Push Channel

SMS Channel

Webhook Channel

WebSocket Manager

Email Provider

Push Provider

SMS Provider

HTTP Client

Analytics Store

Monitoring Dashboard

Event-Driven Mimari#

Bildirimler request-response işlemleri değildir; asenkron olarak işlenmesi gereken fire-and-forget eventleridir. Aşağıdaki event yapısı birden fazla sistemde güvenilir biçimde çalışır:

interface NotificationEvent {
  id: string;
  userId: string;
  type: NotificationType;
  templateId?: string;
  data: Record<string, any>;
  priority: 'low' | 'normal' | 'high' | 'critical';
  scheduledAt?: Date;
  expiresAt?: Date;
  metadata: {
    source: string;
    correlationId: string;
    retryCount: number;
    maxRetries: number;
  };
}

enum NotificationType {
  PROJECT_UPDATE = 'project_update',
  SECURITY_ALERT = 'security_alert', 
  FEATURE_ANNOUNCEMENT = 'feature_announcement',
  SYSTEM_MAINTENANCE = 'system_maintenance',
  USER_ACTIVITY = 'user_activity',
  INTEGRATION_UPDATE = 'integration_update'
}

Metadata bölümü kritiktir: correlation ID, dağıtık sistemlerde bildirim akışlarını izlemeyi mümkün kılar ve teslimat hatalarını ayıklamak için zorunludur.

Notification Engine: Sistemin Kalbi#

Karmaşıklığın çoğu notification engine’de yaşar. Aşağıdaki implementasyonda kontrollerin sırası, kontrollerin kendisi kadar önemli: ucuz elemeler, herhangi bir şablon render edilmeden ve hiçbir sağlayıcı çağrılmadan önce yapılır.

class NotificationEngine {
  constructor(
    private eventBus: EventBus,
    private templateService: TemplateService,
    private preferenceManager: PreferenceManager,
    private rateLimiter: RateLimiter,
    private channelRouter: ChannelRouter,
    private analytics: AnalyticsService
  ) {}

  async processEvent(event: NotificationEvent): Promise<void> {
    try {
      // Kullanıcının var olduğunu ve aktif olduğunu kontrol et
      const user = await this.getUserWithPreferences(event.userId);
      if (!user?.isActive) {
        await this.analytics.trackSkipped(event.id, 'user_inactive');
        return;
      }

      // Kullanıcı tercihlerini filtrele
      const enabledChannels = await this.preferenceManager
        .getEnabledChannels(event.userId, event.type);
      
      if (enabledChannels.length === 0) {
        await this.analytics.trackSkipped(event.id, 'all_channels_disabled');
        return;
      }

      // Rate limiting kontrolü
      const rateLimitResult = await this.rateLimiter
        .checkLimits(event.userId, event.type);
      
      if (!rateLimitResult.allowed) {
        await this.scheduleRetry(event, rateLimitResult.retryAfter);
        return;
      }

      // Her aktif kanal için işlem yap
      const deliveryPromises = enabledChannels.map(channel => 
        this.processChannel(event, channel, user)
      );

      const results = await Promise.allSettled(deliveryPromises);
      await this.analytics.trackDeliveryResults(event.id, results);

    } catch (error) {
      await this.handleProcessingError(event, error);
    }
  }

  private async processChannel(
    event: NotificationEvent,
    channel: NotificationChannel,
    user: User
  ): Promise<DeliveryResult> {
    // Template rendering kullanıcı verisiyle
    const template = await this.templateService.getTemplate(
      event.type,
      channel,
      user.locale
    );

    const renderedContent = await this.templateService.render(
      template,
      { ...event.data, user }
    );

    // Uygun kanal handler'ına yönlendir
    return await this.channelRouter.deliver(
      channel,
      user,
      renderedContent,
      event.metadata
    );
  }
}

Promise.allSettled bilinçli bir tercih. Başarısız olan bir kanal diğerlerini iptal etmemeli ve her kanalın sonucu ayrı kaydedilir; böylece aynı event için geri dönen bir e-posta ile teslim edilen bir push bildirimi teslimat tablosunda birbirinden ayrılabilir kalır.

Veritabanı Tasarımı: Şema ve İndeksleme Stratejisi#

Şema üç sorumluluğa ayrılır: ne gönderilecek (event’ler ve şablonlar), kim istiyor (tercihler) ve ne oldu (teslimatlar ve metrikler). Üçüncüsünü birincisinden ayrı tutmak, kanal bazlı yeniden denemeleri ve denetim sorgularını ucuzlatan şeydir.

Temel Tablolar#

-- Users tablosu (var olduğunu varsayıyoruz)
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    phone VARCHAR(20),
    locale VARCHAR(10) DEFAULT 'en',
    timezone VARCHAR(50) DEFAULT 'UTC',
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Tercih sistemi - bu hızla karmaşık hale geliyor
CREATE TABLE notification_preferences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id) ON DELETE CASCADE,
    notification_type VARCHAR(100) NOT NULL,
    channel VARCHAR(50) NOT NULL,
    enabled BOOLEAN DEFAULT true,
    frequency VARCHAR(20) DEFAULT 'immediate', -- immediate, daily, weekly
    quiet_hours_start TIME DEFAULT '22:00:00',
    quiet_hours_end TIME DEFAULT '08:00:00',
    metadata JSONB DEFAULT '{}', -- kanal-özel ayarlar için
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(user_id, notification_type, channel)
);

-- Template yönetimi - lokalizasyon kritik
CREATE TABLE notification_templates (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    notification_type VARCHAR(100) NOT NULL,
    channel VARCHAR(50) NOT NULL,
    locale VARCHAR(10) DEFAULT 'en',
    subject VARCHAR(500),
    body TEXT NOT NULL,
    variables JSONB DEFAULT '{}', -- beklenen değişkenler
    is_active BOOLEAN DEFAULT true,
    version INTEGER DEFAULT 1,
    created_by UUID REFERENCES users(id),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(notification_type, channel, locale, version)
);

Event Storage ve Takip#

Event depolama, ölçekleme sürprizlerine en açık alandır. Aşağıdaki şema en yaygın hata modlarını giderir:

-- Ana event tablosu - bu BÜYÜK hale geliyor
CREATE TABLE notification_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id) ON DELETE CASCADE,
    notification_type VARCHAR(100) NOT NULL,
    template_id UUID REFERENCES notification_templates(id),
    priority VARCHAR(20) DEFAULT 'normal',
    data JSONB DEFAULT '{}',
    scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    expires_at TIMESTAMP WITH TIME ZONE,
    status VARCHAR(20) DEFAULT 'pending',
    processed_at TIMESTAMP WITH TIME ZONE,
    correlation_id VARCHAR(255), -- takip için
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- PostgreSQL'de satır içi INDEX yoktur; indexler ayrı ifadelerdir
CREATE INDEX idx_notification_events_correlation
ON notification_events(correlation_id);

-- Teslimat takibi - performans için ayrı
CREATE TABLE notification_deliveries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id UUID REFERENCES notification_events(id) ON DELETE CASCADE,
    channel VARCHAR(50) NOT NULL,
    status VARCHAR(20) DEFAULT 'pending', -- pending, sent, delivered, failed, bounced
    attempt_count INTEGER DEFAULT 0,
    max_attempts INTEGER DEFAULT 3,
    next_retry_at TIMESTAMP WITH TIME ZONE,
    sent_at TIMESTAMP WITH TIME ZONE,
    delivered_at TIMESTAMP WITH TIME ZONE,
    failed_at TIMESTAMP WITH TIME ZONE,
    error_code VARCHAR(50),
    error_message TEXT,
    provider_id VARCHAR(255), -- harici sağlayıcı mesaj ID'si
    provider_response JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_deliveries_event_channel
ON notification_deliveries(event_id, channel);

-- Analitik toplama tablosu - doğrudan sorgular milyonlarca event'te timeout'a düşer
CREATE TABLE notification_metrics (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    date DATE NOT NULL,
    hour SMALLINT NOT NULL, -- 0-23
    notification_type VARCHAR(100) NOT NULL,
    channel VARCHAR(50) NOT NULL,
    status VARCHAR(20) NOT NULL,
    count INTEGER DEFAULT 1,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(date, hour, notification_type, channel, status)
);

İndeksleme Stratejisi#

Aşağıdaki indexler sistemin en sık çalıştırdığı üç sorgu örüntüsünü izler: kullanıcı zaman çizelgeleri, işleme kuyruğu ve yeniden deneme kuyruğu.

-- Query pattern'lara dayalı kritik indexler
CREATE INDEX idx_events_user_type_created 
ON notification_events(user_id, notification_type, created_at DESC);

CREATE INDEX idx_events_processing_queue 
ON notification_events(status, scheduled_at) 
WHERE status IN ('pending', 'retry');

CREATE INDEX idx_deliveries_retry_queue 
ON notification_deliveries(next_retry_at, status) 
WHERE status = 'pending' AND next_retry_at IS NOT NULL;

-- Yalnızca eklenen geçmiş: btree'nin şiştiği yerde BRIN küçük kalır
CREATE INDEX idx_events_created_brin
ON notification_events USING BRIN (created_at);

-- Analitik sorguları için
CREATE INDEX idx_metrics_time_type 
ON notification_metrics(date, hour, notification_type);

Bu indexlerin işe yaraması iki ayrıntıya bağlı. Partial index koşulu immutable olmak zorundadır, bu yüzden NOW() - INTERVAL '7 days' gibi kayan bir pencere doğrudan reddedilir; zaman sıralı geçmiş yerine BRIN indexine aittir. Kuyruk indexleri ise yalnızca WHERE koşulları terminal duruma ulaşmış satırları dışarıda bıraktığı için küçük kalır.

Kullanıcı Tercihi Yönetimi#

Kullanıcı tercihleri; sessiz saatler, zaman dilimleri ve sıklık pencereleri devreye girene kadar boolean gibi görünür. Aşağıdaki çözücü bunları sabit bir sırayla uygular, böylece aynı event her seferinde aynı kanal kümesini üretir:

class PreferenceManager {
  async getEnabledChannels(
    userId: string, 
    notificationType: string
  ): Promise<NotificationChannel[]> {
    
    // Global kullanıcı tercihlerini kontrol et
    const userPrefs = await this.db.query(`
      SELECT np.channel, np.enabled, np.frequency, 
             np.quiet_hours_start, np.quiet_hours_end,
             u.timezone
      FROM notification_preferences np
      JOIN users u ON u.id = np.user_id
      WHERE np.user_id = $1 AND np.notification_type = $2
    `, [userId, notificationType]);

    if (userPrefs.length === 0) {
      // Bu bildirim türü için default tercihleri kullan
      return this.getDefaultChannels(notificationType);
    }

    const currentTime = new Date();
    const enabledChannels: NotificationChannel[] = [];

    for (const pref of userPrefs) {
      if (!pref.enabled) continue;

      // Sessiz saatleri kontrol et
      if (this.isInQuietHours(currentTime, pref)) {
        // Sessiz saatleri geçersiz kılan kritik bildirim mi kontrol et
        if (!this.isCriticalNotification(notificationType)) {
          continue;
        }
      }

      // Sıklık tercihlerini kontrol et
      if (!this.shouldSendBasedOnFrequency(userId, pref.frequency, notificationType)) {
        continue;
      }

      enabledChannels.push(pref.channel as NotificationChannel);
    }

    return enabledChannels;
  }

  private isInQuietHours(currentTime: Date, pref: any): boolean {
    // Mevcut zamanı kullanıcının zaman dilimine çevir
    const userTime = moment(currentTime)
      .tz(pref.timezone || 'UTC')
      .format('HH:mm:ss');

    const quietStart = pref.quiet_hours_start;
    const quietEnd = pref.quiet_hours_end;

    // Gece yarısını geçen sessiz saatleri yönet
    if (quietStart > quietEnd) {
      return userTime >= quietStart || userTime <= quietEnd;
    }

    return userTime >= quietStart && userTime <= quietEnd;
  }
}

Gece yarısını geçen dal, çoğu implementasyonun atladığı yerdir. quiet_hours_start, quiet_hours_end’ten geç olduğunda karşılaştırmanın AND yerine OR kullanması gerekir. Kullanıcının zaman dilimini tercih satırının yanında tutmak bu kontrolü tek sorgunun içinde bitirir.

Template Sistemi: Lokalizasyon ve Kişiselleştirme#

Kullanıcının okuduğu metni şablonlar belirler, bu yüzden şablon aramasının öngörülebilir biçimde bozulması gerekir. Aşağıdaki servis önce istenen locale’i çözer, bulamazsa hata fırlatmak yerine İngilizce’ye düşer:

interface Template {
  id: string;
  name: string;
  type: string;
  channel: string;
  locale: string;
  subject?: string;
  body: string;
  variables: Record<string, TemplateVariable>;
  abTest?: ABTestConfig;
}

class TemplateService {
  async getTemplate(
    notificationType: string,
    channel: NotificationChannel,
    locale: string = 'en'
  ): Promise<Template> {
    
    // Önce lokalize template almayı dene
    let template = await this.db.findTemplate({
      type: notificationType,
      channel,
      locale,
      isActive: true
    });

    // Lokalize versiyon yoksa İngilizce'ye geri dön
    if (!template && locale !== 'en') {
      template = await this.db.findTemplate({
        type: notificationType,
        channel,
        locale: 'en',
        isActive: true
      });
    }

    if (!template) {
      throw new Error(`No template found for ${notificationType}/${channel}/${locale}`);
    }

    return template;
  }

  async render(template: Template, data: Record<string, any>): Promise<RenderedContent> {
    try {
      // Gerekli değişkenleri doğrula
      await this.validateTemplateData(template, data);

      // Template'i Handlebars veya benzeri ile işle
      const subject = template.subject 
        ? await this.renderString(template.subject, data)
        : undefined;

      const body = await this.renderString(template.body, data);

      return {
        subject,
        body,
        templateId: template.id,
        locale: template.locale
      };

    } catch (error) {
      // Template rendering hatalarını debugging için logla
      await this.logger.error('Template rendering failed', {
        templateId: template.id,
        error: error.message,
        data: this.sanitizeDataForLogging(data)
      });
      
      throw new TemplateRenderError(`Failed to render template ${template.id}`, error);
    }
  }
}

Rate Limiting: Kullanıcıları ve Sağlayıcıları Koruma#

Rate limiting, kullanıcı deneyimi ile sistem kararlılığını dengeler. Aşağıdaki implementasyon kullanıcı-tür başına atomik Redis kontrolleriyle çalışır:

interface RateLimitConfig {
  notificationType: string;
  channel: string;
  limits: {
    perMinute: number;
    perHour: number;
    perDay: number;
  };
  burstAllowance: number;
}

class RateLimiter {
  constructor(private redis: Redis, private configs: RateLimitConfig[]) {}

  async checkLimits(
    userId: string, 
    notificationType: string
  ): Promise<RateLimitResult> {
    
    const config = this.getConfig(notificationType);
    if (!config) {
      return { allowed: true, remainingToday: Infinity };
    }

    const now = Date.now();
    const keys = {
      minute: `rate_limit:${userId}:${notificationType}:${Math.floor(now / 60000)}`,
      hour: `rate_limit:${userId}:${notificationType}:${Math.floor(now / 3600000)}`,
      day: `rate_limit:${userId}:${notificationType}:${Math.floor(now / 86400000)}`
    };

    // Atomik kontroller için Redis pipeline kullan
    const pipeline = this.redis.pipeline();
    pipeline.incr(keys.minute);
    pipeline.expire(keys.minute, 60);
    pipeline.incr(keys.hour);
    pipeline.expire(keys.hour, 3600);
    pipeline.incr(keys.day);
    pipeline.expire(keys.day, 86400);

    const results = await pipeline.exec();
    const counts = {
      minute: results[0][1] as number,
      hour: results[2][1] as number,
      day: results[4][1] as number
    };

    // Limitlerle karşılaştır
    if (counts.minute > config.limits.perMinute ||
        counts.hour > config.limits.perHour ||
        counts.day > config.limits.perDay) {
      
      return {
        allowed: false,
        retryAfter: this.calculateRetryAfter(counts, config),
        remainingToday: Math.max(0, config.limits.perDay - counts.day)
      };
    }

    return {
      allowed: true,
      remainingToday: config.limits.perDay - counts.day
    };
  }
}

Tasarımda Hesaba Katılacak Kısıtlar#

Yukarıdaki kodun dışında kalan parçaları dört kısıt biçimlendirir. Her biri sonradan eklemektense şimdi tasarlamak için daha ucuzdur:

  1. İdempotency: Her bildirim işlemi, event ID ve kanal ikilisine göre idempotent olmalıdır. Kullanıcılar eksik bildirimlerden çok yinelenenleri fark eder.

  2. Observability: Başarısız bir gönderimi sonradan açıklanabilir kılan şey, correlation ID’ler ve teslimat bazlı hata kayıtlarıdır. Bunları sonradan eklemek, artık var olmayan satırları geriye dönük doldurmak demektir.

  3. Kanal yalıtımı: Notification engine’in monolite dönüşmesi ölçeklemeyi zorlaştırır. Her kanal bağımsız deploy edilebilmeli ki yavaş bir SMS sağlayıcısı uygulama içi teslimatı geciktirmesin.

  4. Veri saklama: Events ve deliveries tabloları trafikle orantılı büyür, bu yüzden retention ve arşivleme politikası ilk migration ile birlikte gelmelidir. Sonradan eklemek, bir denetim sorgusunun hâlâ ihtiyaç duyabileceği satırları silmek anlamına gelir.

Bu yapı, bildirimler ikiden fazla kanala yayıldığında veya tercihler ile uyumluluk kuralları kullanıcı bazında değiştiğinde maliyetini karşılar. Yalnızca transactional e-posta gönderen bir üründe ne router’a ne de deliveries tablosuna gerek vardır; sağlayıcı SDK’sı ve bir yeniden deneme kuyruğu yeter. İkinci bir kanal eklendiğinde ya da belirli bir kullanıcının belirli bir mesajı neden almadığını soran bir destek kaydı geldiğinde, router ve teslimat bazlı kayıtlar daha ucuz seçenek haline gelir.

Serinin bir sonraki bölümü gerçek zamanlı teslimatı ele alır: WebSocket bağlantıları, push bildirimleri, kanala özgü implementasyonlar ve hatalı tek bir sağlayıcının tüm pipeline’ı tıkamasını engelleyen retry mantığı ile circuit breaker’lar.

Kaynaklar#

Ölçeklenebilir Kullanıcı Bildirim Sistemi Geliştirme

Kurumsal seviye bildirim sistemlerinin tasarımı, implementasyonu ve üretim zorluklarını kapsayan kapsamlı 4-parça serisi. Mimari ve veritabanı tasarımından gerçek zamanlı teslimat, ölçekte debugging ve performans optimizasyonuna kadar.

İlerleme 1/4 yazı tamamlandı

İlgili yazılar