Ö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

"Basit" bildirim özelliğiniz gerçek kullanıcı yükü altında çökmeye başladığında o batma hissini yaşadınız mı hiç? Ben o duruma itiraf ettiğimden çok daha fazla düştüm. "X olduğunda email gönder" gibi basit başlayan şey, hızla milyonlarca bildirimi birden fazla kanal üzerinden göndermesi, kullanıcı tercihlerini yönetmesi, teslimat garantileri ve analitiği koruması gereken bir canavara dönüşüyor.

Üç farklı şirkette bildirim sistemleri geliştirdikten sonra - 50 kişilik startup'tan Fortune 500 kurumuna kadar - ilk gün alacağınız mimari kararların ya aklınızı başınızda tutacağını ya da sizi yıllarca rahatsız edeceğini öğrendim. Gerçekten ölçeklenen bildirim sistemleri inşa etmek hakkında öğrendiklerimi paylaşayım.

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

Gençken bildirimlerin ne olduğunu şöyle düşünürdüm: olay tetiklenir → mesaj gönderir → biter. Gerçekte neler olduğu ise: kullanıcı tercihlerinin, teslimat kanallarının, hız sınırlamanın, yeniden deneme mantığının, şablon yönetiminin, analitik takibinin ve yasal uyumluluğun karmaşık orkestrasyonu.

Gerçek uyarı genellikle ilk büyük ürün lansmanında gelir. 10.000 kullanıcınız aniden hoşgeldin emailleri, şifre sıfırlama ve aktivite bildirimleri alıyor. Email servisiniz throttle etmeye başlıyor, veritabanı bağlantı havuzunuz maksimuma çıkıyor ve kullanıcılar yinelenen bildirimlerden şikayet etmeye başlıyor. Tanıdık geliyor?

Sistem Mimarisi: Üretim Acılarından Öğrenmek#

Farklı ölçeklerde ve sektörlerde bana iyi hizmet vermiş mimariyi anlatayım. Bu teorik değil - buradaki her bileşen üretimde bir şeyler bozulduğu için var.

Loading diagram...

Event-Driven Mimari#

Öğrendiğim ilk ders: bildirimler request-response işlemleri değil. Asenkron olarak işlenmesi gereken fire-and-forget eventleridir. Birden fazla sistemde çalışan event yapısı şu şekilde:

TypeScript
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ü kritik. O correlation ID, dağıtık sistemlerde bildirim akışlarını takip ederken beni sayısız debugging saatinden kurtardı.

Notification Engine: Sistemin Kalbi#

Karmaşıklığın çoğu notification engine'de yaşar. Birkaç iterasyon geliştirdikten sonra öğrendiklerim:

TypeScript
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#

Farklı şirketlerde bildirim veritabanlarını üç kez yeniden tasarladım. Her seferinde, üretimde gerçekten neyin önemli olduğu hakkında yeni bir şey öğrendim. Zamanın ve ölçeğin testinden geçmiş şema şu şekilde:

Temel Tablolar#

SQL
-- 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 tasarımı en büyük hatalarımı yaptığım yer. Öğrendiklerim:

SQL
-- 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 - bunu zor yoldan öğrendim
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:

SQL
-- 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:

TypeScript
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 üç iterasyon aldı. Global bir uygulamada kullanıcı tercihlerinin ne kadar karmaşık hale geldiğini hafife almayın.

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:

TypeScript
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ı dengelediğin yer. Etkili rate limiting implementasyonu hakkında öğrendiklerim:

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

Başlarken Keşke Bilseydiklerim#

Günlük milyonlarca mesaj yöneten bildirim sistemleri geliştirdikten sonra, aylarca refactoring yapmaktan beni kurtaracak dersler:

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

  2. Observability için tasarla: Özellik geliştirmekten çok teslimat sorunlarını debug ederek zaman geçireceksin. Correlation ID'ler ve detaylı loglama opsiyonel değil.

  3. Endişeleri erken ayır: Notification engine'inin monolite dönüşmesine izin verme. Her kanal bağımsız olarak deploy edilebilmeli ve ölçeklenebilmeli.

  4. Veri saklama için planla: Bildirim verisi hızla büyür. İlk günden retention ve arşivleme stratejin olsun.

  5. Kullanıcı tercihleri karmaşık: 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.

Bu serinin bir sonraki bölümünde, gerçek zamanlı teslimat mekanizmalarına dalacağız - WebSocket bağlantıları, push bildirimleri ve hepsini çalıştıran kanal-özel implementasyonlar. Retry mantığının ve circuit breaker'ların neden sadece güzel-olsa-iyi özellikler olmadığını öğreten üretim olaylarını da ele alacağız.

Burada kurduğumuz temel, basit bir bildirim sistemi için aşırı mühendislik gibi görünebilir, ama güven bana - bir ürün lansmanı sırasında 50.000 kullanıcının şifre sıfırlama emaillerini almadığını debug ederken, içine pişirdiğimiz her observability ve dayanıklılık parçası için minnettar olacaksın.

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

İlerleme1/4 yazı tamamlandı
Loading...

Yorumlar (0)

Sohbete katıl

Düşüncelerini paylaşmak ve toplulukla etkileşim kurmak için giriş yap

Henüz yorum yok

Bu yazı hakkında ilk düşüncelerini paylaşan sen ol!

Related Posts