İçeriğe atla

2025-09-08

Ö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

Bir bildirim özelliği “X olduğunda email gönder” olarak başlar ve bir çeyrek içinde çok kanallı bir teslim problemine dönüşür: email, SMS, push ve uygulama içi; her birinin kendi teslim garantileri, retry semantiği ve kullanıcı tercihi yüzeyi vardır. Mimari hata, bunu bir şablon-ve-gönder problemi olarak ele almaktır; asıl problem, kullanıcı-kanal-olay başına fan-out, birleştirme, bastırma ya da erteleme kararını veren ve bu kararı uyumluluk ve destek için denetlenebilir tutmak zorunda olan bir yönlendiricidir.

Bu yazı, üretim düzeyinde bir bildirim sistemi inşa etme serisinin birinci parçasıdır. Event-driven mimariyi (üreticiler, router, dispatcher), olay, tercih ve teslim durumu için veritabanı şemasını, kanal router desenini ve hataları kullanıcıya ulaşmadan önce hata ayıklanabilir kılan gözlemleme yüzeyini ele alır.

”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 emailleri, ş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 farklı ölçeklerde ve sektörlerde doğrulanmıştır. Event-driven temel, kanal router ve analytics pipeline dahil her bileşen, yokluğunda üretimde bir şeyler bozulduğu için bu şekle kavuşmuştur.

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 implementasyon birkaç iterasyonun ardından şekillenen tasarımı yansıtı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
    );
  }
}

Buradaki ana içgörü: her işlem başarısız olabilir ve neler olduğuna dair görünürlüğü koruyarak hataları zarifçe ele alman gerekiyor.

Veritabanı Tasarımı: Seni Yapan ya da Bozan Temel

Aşağıdaki şema birden fazla iterasyonda rafine edilmiş ve üretim ölçeğindeki yük altında dayanıklılığını kanıtlamıştır:

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(),
    INDEX idx_notification_events_user_created (user_id, created_at DESC),
    INDEX idx_notification_events_status (status, scheduled_at),
    INDEX idx_notification_events_correlation (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(),
    INDEX idx_deliveries_event_channel (event_id, channel),
    INDEX idx_deliveries_retry (status, next_retry_at) WHERE status = 'pending'
);

-- 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)
);

Üretimden Performans Dersleri

Milyonlarca bildirimi işlerken gerçekten önemli olan indexing stratejileri:

-- 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;

-- Yaygın sorgular için partial indexler
CREATE INDEX idx_events_recent_active 
ON notification_events(created_at DESC) 
WHERE created_at > NOW() - INTERVAL '7 days';

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

Partial indexler kritik. Bunlar olmadan, milyonlarca event’e ulaştığınızda analitik sorgularınız timeout’a düşmeye başlar.

Kullanıcı Tercihleri: Düşündüğünden Daha Karmaşık

Kullanıcı tercihleri edge case’lere çarpana kadar basit görünür. Gerçek dünya karmaşıklığını yönetmiş tercih yöneticisi:

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;
  }
}

Sadece timezone yönetimi bile doğru yapmak için birden fazla iterasyon gerektirir. Global bir uygulamada kullanıcı tercihleri ilk göründüğünden çok daha karmaşık hale gelir.

Template Sistemi: Lokalizasyon ve Kişiselleştirme

Template’ler kullanıcı deneyimi için lastik yolda karşılaştığı yer. Lokalizasyon, kişiselleştirme ve A/B testini yöneten template servisi:

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
    };
  }
}

Üretim Sistemleri İçin Temel Dersler

Günlük milyonlarca mesaj işleyen sistemler aynı dersleri tekrar tekrar ortaya çıkarır:

  1. İdempotency ile başla: Her bildirim işlemi idempotent olmalıdır. Kullanıcılar eksik bildirimlerden çok yinelenenleri fark eder.

  2. Observability için tasarla: Teslimat sorunlarını debug etmek, özellik geliştirmekten daha fazla zaman alır. Correlation ID’ler ve ayrıntılı loglama zorunludur.

  3. Endişeleri erken ayır: Notification engine’in monolite dönüşmesi ölçeklemeyi zorlaştırır. Her kanal bağımsız olarak deploy edilebilir olmalıdır.

  4. Veri saklama için planla: Bildirim verisi hızla büyür. Retention ve arşivleme stratejisi ilk günden itibaren gereklidir.

  5. Kullanıcı tercihleri karmaşıktır: Basit açık/kapalı anahtarı gibi görünen şey; timezone-aware, frekans-bazlı, kanal-özel tercihler ve acil durum geçersiz kılmaları ile sessiz saatlere dönüşür.

Serinin bir sonraki bölümü gerçek zamanlı teslimat mekanizmalarını ele alır: WebSocket bağlantıları, push bildirimleri ve kanal-özel implementasyonlar. Retry mantığının ve circuit breaker’ların üretim bildirim servisinde neden zorunlu olduğu da bu kapsamda açıklanır.

Kaynaklar

Burada özetlenen temel, basit bir bildirim sistemi için aşırı mühendislik gibi görünebilir. Bir ürün lansmanı sırasında 50.000 kullanıcının şifre sıfırlama emaillerini neden almadığını debug etmek gerektiğinde, bu tasarıma yerleştirilen her observability ve dayanıklılık katmanının değeri kendini kanıtlar.

Ö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ı

İlgili yazılar

Veritabanı Seçim Rehberi: Klasikten Edge'e - Kapsamlı Mühendislik Perspektifi

Projeniz için doğru veritabanını seçmek için kapsamlı rehber - SQL, NoSQL, NewSQL ve edge çözümlerini gerçek dünya implementasyon hikayeleri ve performans ölçümleri ile kapsıyor.

databasepostgresqlmysql+8
Aurora Serverless v2 İncelemesi: Mimari Derinlemesine Bakış

Aurora Serverless v2'nin altında ne çalışıyor: paylaşılan Aurora depolama katmanı, ACU temelli işlem katmanı, Caspian ısı yönetimi alt yapısı, sıfıra ölçekleme mekanikleri ve karışık modlu cluster'lar.

awsauroraaurora-serverless-v2+4
SaaS Yetkilendirme için AWS Cognito + Verified Permissions

AWS Cognito ve Verified Permissions ile SaaS yetkilendirme mimarisi. Cedar politika dili, çok kiracılı desenler, JWT token akışı, maliyet analizi ve TypeScript örnekleriyle yaygın hatalar.

authorizationawscognito+4
Harici Yetkilendirme Yönetim Sistemleri: Mimarınız İçin Doğru Platformu Seçmek

AWS Verified Permissions, SpiceDB, OpenFGA, Cerbos ve OPA dahil harici yetkilendirme platformlarının tarafsız değerlendirmesi. Mimari desenler, maliyet analizi ve mühendislik ekipleri için karar çerçevesi.

authorizationsecurityarchitecture+5
Cedar vs Rego vs OpenFGA: Politika Dili Karşılaştırması

Cedar, Rego, OpenFGA DSL ve Cerbos YAML/CEL politika dillerinin derinlemesine teknik karşılaştırması. Söz dizimi, performans kıyaslamaları, biçimsel doğrulama, araç desteği ve her dil için TypeScript entegrasyon örneklerini kapsar.

authorizationsecurityarchitecture+3