Ö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:
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:
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#
-- 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:
-- 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:
-- 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 üç 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:
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:
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:
-
İdempotency ile başla: Her bildirim işlemi idempotent olmalı. Kullanıcılar eksik bildirimlerden çok yinelenelerden şikayet eder.
-
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.
-
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.
-
Veri saklama için planla: Bildirim verisi hızla büyür. İlk günden retention ve arşivleme stratejin olsun.
-
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.
Bu Serideki Tüm Yazılar
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!
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!