RFC'den Production'a: Implementation Hakkında Anlatmadıkları
RFC tasarımlarının production'la karşılaşınca nerede saptığı, bildirim sistemi örneği üzerinden: faydalı uyarlamayı mimari sürüklenmeden ayırmanın yolu.
RFC’ler production’la temas ettikten sonra nadiren değişmeden kalır ve bu kendiliğinden bir başarısızlık sayılmaz. İncelemede temiz görünen mimari diyagramlar altı ay sonra karmaşıklaşır; timeline baskısı, eksik gereksinimler ve operasyonel gerçekler tasarımda hiç yer almayan trade-off’ları dayatır.
Sorunun kaynağı kötü tasarım ya da kötü mühendislik değil. Bu, iş kısıtları altında karmaşık sistem kurmanın olağan maliyeti. Asıl beceri, sinyali sürüklenmeden ayırmakta: bir boşluk, tasarımın inceleme sırasında bilemeyeceği bir gereksinimi karşılıyorsa sinyaldir; tasarıma uymak zahmetli geldiği için terk edilmişse sürüklenmedir. Bildirim sistemleri iyi bir örnek, çünkü spesifikasyonu kısa, operasyonel yüzeyi ise beklenenden geniştir.
Bildirim Sistemi: RFC’den Çalışan Servise#
Çoğu ekibin tanıdık bulacağı, varsayımsal bir bildirim RFC’si düşünelim. Dört başarı kriteriyle açılıyor ve bu kriterlerin her biri, operatörlerin kendi sistemleri için yayımladığı sayılarla karşılaştırılabilir.
// Varsayımsal bir RFC'nin başarı kriterleri; henüz tek satır kod yokken yazıldı
interface NotificationSystemGoals {
deliveryTime: 'in-app için <100ms, email için <5s',
throughput: 'Saniyede 10.000+ bildirim',
uptime: '%99,9 erişilebilirlik',
timeline: '2 developer ile 12 hafta'
}
Yayımlanmış rakamlarla satır satır karşılaştırınca bu dört satırın üçü anlam değiştiriyor.
Uptime. Google’ın Site Reliability Engineering kitabındaki erişilebilirlik tablosu %99,9’u ayda 43,2 dakika ve yılda 8,76 saat kesintiye, %99,99’u ise ayda 4,32 dakika ve yılda 52,6 dakikaya çeviriyor. Geri alması bir saat süren tek bir deploy, %99,9 hedefinde ayın bütçesini, %99,99 hedefinde yılın bütçesini bitirir. Hedefin satın aldığı şey sabit bir dakika sayısı ve bu sayı çoğu incident’tan kısa.
Teslimat süresi. LinkedIn mühendislik blogu, bildirim platformu Air Traffic Controller’ın günde bir milyardan fazla istek işlediğini yazıyor ve teslimat yolunun yeniden ele alınmasının üyeden üyeye mesajlaşma push bildirimlerinde uçtan uca P90 gecikmesini yaklaşık 12 saniyeden yaklaşık 1,5 saniyeye indirdiğini aktarıyor. Bu rakam push kanalına ait. RFC yalnızca in-app ve email için sayı veriyor, push için hiçbir hedef koymuyor. Uygulama açık değilken bildirimin izlediği yol hakkında ise tek kelime etmiyor.
In-app satırının in-app ölçümüyle karşılaştırılması gerekiyor ve yayımlanmış ölçümler milisaniyenin altında ama kapsamları dar. Netflix’in Pushy ekibi medyan gecikmeyi bir milisaniyenin altında, 99. yüzdelik gecikmeyi 4ms’nin altında bildiriyor; ölçümün sınırı ise “gelen mesajın Pushy’ye ulaşmasından yanıtın cihaza gönderilmesine kadar” şeklinde tanımlı. Bu ölçüm Netflix’in sahibi olduğu altyapıdaki tek bir sıçramayı kapsıyor. Kullanıcının telefonu birkaç sıçrama daha uzakta ve bu sıçramaların hiçbiri rakamın içinde değil. Yani 100ms, Netflix’in çizdiği sınırda geçerli; RFC ise kendi sınırını nereye çizdiğini hiç söylemiyor.
Throughput. Aynı Netflix yazısı yüz milyonlarca eşzamanlı WebSocket bağlantısından ve saniyede düzenli olarak 300.000 mesaja ulaşan bir sistemden söz ediyor: node başına ortalama 200.000 bağlantı, gerekirse 400.000’e çıkma payı ve bunların yerini aldığı eski instance başına 60.000 rakamı. Uçtan uca size ait bir kanal böyle görünür. Bildirim başka birinin sahip olduğu bir kanala dağıldığı anda tavanı o taraf belirler:
| Dağıtım yolu | Yayımlanmış limit |
|---|---|
| Slack kanalına mesaj | Slack’in chat.postMessage referansı belirli bir kanala saniyede yaklaşık 1 mesaj, workspace genelinde dakikada birkaç yüz mesaj ve bunun üstüne bir burst payı tanımlıyor |
| ABD long code üzerinden SMS | Twilio, ABD long code için saniyede 1, short code için 100 mesaj yazıyor; fazlası varsayılan olarak 10 saate kadar kuyrukta bekliyor, sonra 30001 hatası olarak dönüyor |
| Amazon SES üzerinden email | AWS, sandbox hesapları için saniyede 1 email ve 24 saatte 200 email veriyor; production oranları kullanım senaryosuna göre değişiyor, mesaj başına 50 alıcı sınırı ise sabit |
| iOS push payload | Apple, normal remote notification için 4096 bayt, VoIP için 5120 bayt sınırı yayımlıyor ve APNs’in bu boyutu aşan bildirimleri reddettiğini belirtiyor |
İçerideki hedefle dışarıdaki tavanlar aynı gerçeklik biriminde değil. Saniyede 1 mesaj taşıyan bir ABD long code’u, saniyede 10.000 hedefinin dört mertebe altında; saniyede 100 mesajlık bir short code bile iki mertebe altında kalıyor. İçeride yapılan hiçbir mühendislik bu sayıları oynatmıyor; dolayısıyla RFC’deki throughput satırı sistemin tamponlama kapasitesini tarif ediyor. Gönderim hızını kanalın sahibi belirliyor.
RFC’nin geri kalanı gerçekten kapsamlı olabilir: rate limiting, deduplication, preference management, sessiz saatler. Bunların hepsi sizin kontrolünüzde. Yukarıdaki tablodaki tavanlar ise değil ve ilk gruptaki titizlik ikinci grupta hiçbir şey satın almıyor.
Uygulama Zorlukları ve Uyarlamalar#
Database Şemasının Evrimi#
İlk şema tasarımı temiz normalizasyonu önceliyordu: düzgün foreign key’ler ve constraint’ler.
-- İlk RFC şema tasarımı
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),
data JSONB DEFAULT '{}',
status VARCHAR(20) DEFAULT 'pending',
sent_at TIMESTAMP,
delivered_at TIMESTAMP,
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
Production’la temas ettikten sonra ayakta kalan şema genelde şuna benziyor:
-- Production uyarlamalarından sonraki şema
CREATE TABLE notification_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID, -- Performance sorunları nedeniyle foreign key kaldırıldı
notification_type VARCHAR(100),
notification_type_v2 VARCHAR(255), -- Migration devam ediyor
template_id UUID,
template_id_v2 BIGINT, -- Farklı takım farklı ID tipi kullandı
data JSONB DEFAULT '{}',
data_compressed BYTEA, -- JSONB çok büyüyünce eklendi
status VARCHAR(20) DEFAULT 'pending',
status_v2 VARCHAR(50), -- Beklenenden fazla status
priority INTEGER DEFAULT 0, -- RFC'de yok, production için kritik
retry_count INTEGER DEFAULT 0, -- RFC'de yok, debugging için şart
channel VARCHAR(50), -- Query performance için denormalize edildi
correlation_id UUID, -- Distributed tracing için eklendi
partition_key INTEGER, -- Sharding için eklendi
sent_at TIMESTAMP,
delivered_at TIMESTAMP,
read_at TIMESTAMP,
failed_at TIMESTAMP, -- RFC'de yok, çok gerekli
expires_at TIMESTAMP, -- RFC'de yok, sonsuz büyümeyi önledi
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() -- Bir satırın en son ne zaman değiştiği bilinemeyince eklendi
);
-- Her yeni erişim deseni beraberinde bir index getiriyor ve her index yalnızca
-- eklendiği sorguda değil, her insert ve update'te ödeniyor
CREATE INDEX CONCURRENTLY idx_notification_events_user_created ON notification_events(user_id, created_at DESC) WHERE status != 'deleted';
CREATE INDEX CONCURRENTLY idx_notification_events_correlation ON notification_events(correlation_id) WHERE correlation_id IS NOT NULL;
Buradaki her kolon, tasarım incelemesinin sorma imkânı olmayan bir production sorusunu yanıtlıyor. Sürekli maliyet çıkaran kısım ise index’ler. PostgreSQL dokümantasyonu takası açıkça yazıyor: bir index oluşturulduktan sonra sistemin onu tabloyla senkron tutması gerekir, bu da veri değiştiren işlemlere ek yük bindirir ve heap-only tuple güncellemelerini engelleyebilir; bu yüzden sorgularda seyrek kullanılan veya hiç kullanılmayan index’ler kaldırılmalıdır. Canlı bir tabloda index eklemeyi güvenli kılan şey CREATE INDEX CONCURRENTLY, çünkü yazmaları kilitlemiyor. PostgreSQL bunun iki maliyetini de belgeliyor: build daha uzun sürüyor ve başarısız olan bir build geride geçersiz bir index bırakıyor; o index biri silene kadar güncelleme yükünü taşımaya devam ediyor.
WebSocket Bağlantı Yönetiminin Karmaşıklığı#
RFC, en iyi performans için WebSocket üzerinden teslimat öngörüyordu. İlk implementasyon yaklaşımı sadeydi:
// RFC'nin WebSocket implementation'ı
class NotificationWebSocketManager {
private connections: Map<string, WebSocket> = new Map();
async sendNotification(userId: string, notification: NotificationEvent) {
const connection = this.connections.get(userId);
if (connection && connection.readyState === WebSocket.OPEN) {
connection.send(JSON.stringify({
type: 'notification',
data: notification
}));
}
}
}
Yeniden tasarımın büyük kısmını transport’un kendi belgelenmiş limitleri yapıyor. Amazon API Gateway, WebSocket API kotalarını yayımlıyor: frame başına 32 KB ve mesaj payload’ı başına 128 KB (ikisi de artırılamıyor), 10 dakikalık idle bağlantı zaman aşımı, 2 saatlik azami bağlantı süresi ve Region başına hesap başına saniyede 500 yeni bağlantı. Kota sayfası ilkini yok saymanın sonucunu da yazıyor: 32 KB’dan büyük bir mesajın 32 KB veya daha küçük frame’lere bölünmesi gerekiyor, daha büyük bir mesaj ya da frame ise bağlantıyı 1009 koduyla kapatıyor. Aynı sayfa bunun @connections komutları için de geçerli olduğunu belirtiyor; yani bölme işi servisin değil, mesajı gönderen backend’in sorumluluğunda.
Geri kalanını sahiplik modeli belirliyor. Yönetilen bir WebSocket API’de socket’i API Gateway sonlandırıyor, yani backend hiçbir zaman bir socket tutmuyor. $connect yolu ona socket yerine bir connection ID veriyor; sonraki her mesaj @connections/{connectionId} adresine imzalı bir POST olarak gidiyor. Bildirimi gönderen süreç, bağlantıyı kabul eden süreç olmak zorunda değil; bu yüzden ID’lerin ikisinin de okuyabildiği bir depoda durması gerekiyor. AWS, ID eskidiğinde ne olduğunu da yazıyor: bağlantı kurulmadan önce ya da istemci ayrıldıktan sonra gönderilen bir post GoneException dönüyor. İncelenecek yerel bir socket ve onu sunucu tarafından yeniden açmanın bir yolu yok; dolayısıyla health check’in yerini bu yanıt alıyor.
Kotalar ve bu sahiplik modeli, ikinci sürümde duran kodun büyük kısmını açıklıyor:
import {
ApiGatewayManagementApiClient,
GoneException,
PostToConnectionCommand
} from '@aws-sdk/client-apigatewaymanagementapi';
// Amazon API Gateway WebSocket kotaları: frame başına 32 KB, mesaj başına 128 KB.
// Frame kotası @connections için de geçerli, yani tek bir post tek frame'e sığmalı
const MAX_FRAME_BYTES = 32 * 1024;
class NotificationDispatcher {
private management: ApiGatewayManagementApiClient;
private connections: ConnectionStore; // paylaşılan depo, süreç belleği değil
private rateLimiter: SharedRateLimiter;
private deadLetterQueue: Queue<FailedNotification>;
private circuit: CircuitBreaker;
async sendNotification(userId: string, notification: NotificationEvent) {
// Bu ID'leri $connect yazdı; şu an gönderen süreç istemciyi hiç görmemiş olabilir
const connectionIds = await this.connections.listByUser(userId);
if (connectionIds.length === 0) {
await this.queueForLaterDelivery(userId, notification);
return;
}
let message = this.serializeNotification(notification);
// 32 KB üstü mesajı frame'lere bölme kuralı @connections için de geçerli,
// yani tek bir post frame kotasında duruyor, üstü referans olarak gidiyor
if (Buffer.byteLength(message) > MAX_FRAME_BYTES) {
message = await this.serializeAsReference(notification);
}
// Tek kullanıcı, birden fazla cihaz (mobile + web + tablet), her birine bir connection ID
let goneConnections = 0;
const results = await Promise.allSettled(
connectionIds.map(async (connectionId) => {
try {
// Connection başına rate limiting, her göndericinin görebildiği yerde sayılıyor
if (!await this.rateLimiter.tryAcquire(connectionId)) {
await this.backpressure(connectionId, notification);
return;
}
// Cascading failure'lar için circuit breaker
return await this.circuit.fire(() =>
this.management.send(new PostToConnectionCommand({
ConnectionId: connectionId,
Data: message
}))
);
} catch (error) {
// 10 dakika idle kalmış ya da 2 saati doldurmuş olsun, ID gitmiş demektir
// ve yenisini yalnızca istemci açabilir
if (error instanceof GoneException) {
goneConnections += 1;
await this.connections.remove(userId, connectionId);
return;
}
await this.handleDeliveryFailure(userId, connectionId, notification, error);
}
})
);
// Eskimiş ID'lerle dolu bir depo boş depo sayılmıyor, yani yukarıdaki erken dönüş
// çalışmadı. Her post GoneException dönüyorsa kullanıcı çevrimdışı demektir ve
// bildirim, hiç connection ID yokken izleyeceği yolu izliyor
if (goneConnections === connectionIds.length) {
await this.queueForLaterDelivery(userId, notification);
}
// Delivery metriklerini takip et
await this.recordDeliveryMetrics(userId, notification, results);
}
// $connect ve $disconnect yolları depoyu gerçekle uyumlu tutuyor
}
Burada zekice hiçbir şey yok. İkinci sürüm aslında hiçbir bağlantıyı yönetmiyor: connection ID’leri okuyup onlara post atıyor. Kimin ulaşılabilir olduğunu tek bilen yer depo; bunun sebebi idle zaman aşımı ile iki saatlik tavan. Aynı ikisi yüzünden GoneException bir incident değil, olağan bir yanıt. Olağan olması bedelsiz olduğu anlamına gelmiyor. Depoda yalnızca eskimiş ID’ler kalabilir; o zaman her post bu yanıtı dönüyor ve kullanıcıya hiçbir şey ulaşmıyor. Bu durumda bildirimin, boş bir deponun kullanacağı çevrimdışı kuyruğa düşmesi gerekiyor. Backend’in devredemediği kota frame kotası. Mesajı 32 KB’lık frame’lere bölme kuralı @connections için de geçerli, dolayısıyla bu boyutu aşan bir payload istemcinin ayrıca çektiği bir referans olarak gidiyor. Bu transport’ta yeniden bağlanma istemcinin işi ve kendi başına bir konu: Netflix, Pushy üzerindeki cihazların kabaca 30 dakikada bir, aralarında biraz kaydırma olacak şekilde yeniden bağlandığını bildiriyor. Bu, yeniden bağlanmayı istisna olmaktan çıkarıp bilinçli olarak yayılması gereken sürekli bir trafiğe dönüştürüyor.
Yayımlanmış Verilerde Takvim ve Kapsam Kayması#
Bir RFC’nin aşama planı genelde şöyle görünür: hafta 1-4 core infrastructure, hafta 5-8 advanced features, hafta 9-12 entegrasyon ve optimizasyon. Bu türden bir takvim bir tahmindir ve bu tahminlerin ölçülmüş bir dağılımı var; kimsenin tahmin yürütmesine gerek yok.
Flyvbjerg ve Budzier, Harvard Business Review için 1.471 BT projesini incelemiş ve ortalama %27 maliyet aşımı bulmuş; altı projeden biri ise uç değer gibi davranıyor: ortalama %200 maliyet aşımı ve %70’e yakın takvim aşımı. Ortalamayla yaşanabilir, kuyrukla yaşanamaz ve bu kuyruk yüzde bir değil, altıda bir. PMI’nın Pulse of the Profession 2018 raporu aynı sorunun kapsam tarafını ölçüyor: son 12 ayda tamamlanan projelerin %52’si kapsam kayması veya kontrolsüz kapsam değişikliği yaşamış; bu oran beş yıl önce %43’müş. Aynı raporda projelerin yalnızca %52’si ilk planlanan sürede, %57’si ilk bütçesiyle bitmiş. Popülasyon düzeyinde takvime uymak yazı tura demek.
Bu, büyüyen kanal listesinin anlamını da değiştiriyor. Lansmandan sonra SMS, Slack, webhook ve nihayetinde sesli arama eklenmesi, projenin ait olduğu popülasyonda en sık görülen sonuç. İnceleme bu kanalların hangileri olacağını tahmin edemez, çünkü kimse edemez. Ama sıra geldiğinde hangi entegrasyon yüzeylerinin hâlâ ayakta olacağını sorabilir.
Microsoft’un Office 365 connector’larını emekliye ayırması bu riskin belgelenmiş hâli. Microsoft 365 developer blogu Temmuz 2024’te yeni connector oluşturmanın 15 Ağustos 2024’te kapatılacağını, tüm bulutlardaki connector’ların ise 1 Ekim 2024’te çalışmayı bırakacağını duyurdu. Kapanış tarihi sonrasında dört kez kaydı: önce Aralık 2025’e, ardından 31 Mart 2026’ya, sonra 30 Nisan 2026’ya, en son 18-22 Mayıs 2026 arasına yayılan bir rollout’a. Geçiş yolu olarak Power Automate Workflows gösterildi. Connector üzerine kurulmuş her Teams bildiriminin yeniden yazılması gerekti ve bu işin takvimi sürekli güncellenen bir üretici blog yazısından geldi.
Gecikme ters yönde de işliyor. RFC 8030, Generic Event Delivery Using HTTP Push, Aralık 2016’da Proposed Standard olarak yayımlandı. WebKit ise Web Push desteğini iOS ve iPadOS 16.4 beta’sında 16 Şubat 2023’te, yani altı yılı aşkın bir süre sonra duyurdu; üstelik yalnızca kullanıcının ana ekrana eklediği web uygulamaları için. Yayımlanmış bir standardı kullanılabilir bir platform yeteneği sayan tasarım, bu sürenin tamamını beklemiş olurdu.
RFC’nin Modellemediği Authentication Bağlamları#
Temiz bir API tasarımı tek bir authentication deseni varsayar. Mevcut bir ürüne bağlanan bildirim sistemi ise o ürünün biriktirdiği bütün desenleri devralır:
// RFC varsayımı
interface AuthContext {
userId: string;
token: string;
}
// Eski bir üründe bildirim yolunun karşılaştıkları
type AuthContext =
| { type: 'jwt'; userId: string; token: string; claims: JWTClaims }
| { type: 'oauth2'; userId: string; accessToken: string; refreshToken: string; expiresAt: Date }
| { type: 'legacy'; sessionId: string; userId?: string; cookieData: LegacyCookie }
| { type: 'service_account'; serviceId: string; apiKey: string }
| { type: 'anonymous'; temporaryId: string; ipAddress: string };
// Her varyant kendi rate limiting, güvenlik doğrulaması
// ve audit gereksinimlerini getiriyor
Bunların hiçbiri mimari açıdan ilginç değil; tasarım incelemesinin bu işi sıfır fiyatlamasının sebebi de tam olarak bu. Aynı zamanda demo’da gösterilecek hiçbir şey üretmeden takvimi büyüten türden bir iş.
Takım Yapısı ve Organizasyonel Değişiklikler#
RFC, işin ne olduğu henüz bilinmeden işi boyutlandırır. “12 hafta için 2 developer” diye yazılan bir plan tahmin edilebilir yönlerde büyür: production support’u üstlenmek için biri katılır, hızlı kazanç için bir contractor gelir, query gecikmesi darboğaza dönüşünce bir database uzmanı devreye girer, dağıtım için yalnızca danışmanlık yapacak olan mühendis işi tamamen sahiplenir.
Bu değişikliklerin asıl maliyeti bağlam aktarımıdır. Karar alındıktan sonra gelenler o kararı yeniden açar; çünkü gerekçe, hiç okumadıkları bir inceleme kaydında durur. Yeniden yapılanmalar, RFC’nin kapandığını varsaydığı mimari soruları tekrar gündeme getirir. RFC’yi takvim haftasıyla boyutlandırmak sabit bir ekip varsayar ve genellikle ilk kırılan varsayım budur.
Monitoring’de Sonradan Çıkan İhtiyaçlar#
Bir RFC’nin monitoring bölümü standart metrikleri kapsar: delivery rate, response time ve error rate. Sistemi işletmek ise daha uzun bir liste üretiyor:
// RFC monitoring planı
const plannedMetrics = [
'delivery_rate',
'response_time',
'error_rate',
'throughput'
];
// Production işletiminin gerektirdikleri
const productionMetrics = [
// Temel metrikler (RFC'den)
'delivery_rate_by_channel_by_priority_by_user_segment',
'response_time_p50_p95_p99_p999',
'error_rate_by_type_by_service_by_retry_count',
// Incident'ların sonucunu belirleyen metrikler
'template_render_time_by_template_by_variables_count',
'database_connection_pool_wait_time',
'redis_operation_time_by_operation_type',
'webhook_retry_backoff_effectiveness',
'notification_staleness_at_delivery',
'user_preference_cache_hit_rate',
'deduplication_effectiveness_by_time_window',
'rate_limit_rejection_by_reason',
'circuit_breaker_state_transitions',
'message_size_distribution_by_channel',
'websocket_reconnection_storms',
'push_token_invalidation_rate',
'email_bounce_classification',
'notification_feedback_loop_latency',
'cost_per_notification_by_channel',
'regulatory_compliance_audit_completeness',
// Sıra dışı olanlar; her biri bir şey bozulduğu için var
'mobile_app_version_vs_notification_compatibility',
'timezone_calculation_accuracy',
'emoji_rendering_failures_by_client',
'notification_delivery_during_database_failover',
'memory_leak_in_template_cache',
'thundering_herd_detection'
];
Ek metriklerin her biri production’da ortaya çıkan belirli bir operasyonel zorluğu karşılıyor. Tasarım anındaki gözlemlenebilirlik ihtiyacı ile çalışma anındaki ihtiyaç arasındaki fark burada görünür hâle geliyor.
Teknik Borcun Biriktiği Yerler#
Hiçbir RFC’de teknik borç yoktur, çünkü inceleme anında henüz borç birikmemiştir. İkinci sürüme ihtiyaç duyacak kadar yaşayan bildirim sistemlerinin neredeyse hepsinde iki kalıp beliriyor.
Template Sisteminin Karmaşıklığı#
Birden fazla template engine olağan bir varış noktası. Transactional mesajlar, pazarlama mesajları ve uygulama içi metinler farklı takımlardan farklı araçlarla geliyor; her engine kendi escaping kurallarını, kendi helper setini ve kendi hata biçimlerini getiriyor. Renderer bir fonksiyon olmaktan çıkıp bir dağıtım tablosuna dönüşüyor:
// Çok takımlı gereksinimlerin yığıldığı hibrit template katmanı
class NotificationTemplateManager {
private engines: Map<TemplateEngineId, TemplateEngine>;
private engineByTemplate: Map<string, TemplateEngineId>;
async render(templateId: string, data: unknown): Promise<string> {
const engineId = this.engineByTemplate.get(templateId);
if (!engineId) {
throw new UnknownTemplateError(templateId);
}
const engine = this.engines.get(engineId);
if (!engine) {
throw new UnsupportedEngineError(engineId);
}
// Asıl ucuz iş render etmek. Borç yukarıdaki eşlemede:
// daha önce oluşturulmuş her template için doğru kalmak zorunda.
return engine.render(templateId, data);
}
}
Personalization maliyeti de burada görünür hâle geliyor. Üç servisten veri alan bir template, tek bir bildirimi üç çağrıya çeviriyor; bu maliyet template başına değil alıcı başına ödendiği için hacim gelene kadar görünmüyor.
Şema Migration’ının Zorlukları#
Her şema değişikliği ikinci bir tablo gerektirmiyor. Nullable bir kolon eklemek, concurrent index oluşturmak, aynı tabloyu backfill etmek ve okuma ile yazma yollarını aşama aşama taşımak yerinde yapılan işler. İki şemanın aynı anda çalışması, eski tablonun taşıyamayacağı bir bölünmede gerekiyor. Bu işin biçimi de yayımlanmış durumda. Stripe, online migration’larını dört fazda anlatıyor: iki tabloyu senkron tutmak için çift yazma, bütün okuma yollarını yeni tabloya taşıma, bütün yazma yollarını yeni tabloya taşıma ve eski veri modeline dayanan verinin kaldırılması. İlk faz bir kısıttan doğuyor: servis geçiş boyunca durdurulamıyor ve tam yükte çalışmaya devam etmek zorunda.
Aynı yazı ölçeği herkesin tekrarlayabileceği bir aritmetikle anlatıyor. Nesne başına bir saniyeyle, 100 milyon nesne 100 milyon saniye eder; günde 86.400 saniye olduğuna göre bu kabaca 1.157 gün, yani iş sırayla yürütülürse üç yıldan fazla. Backfill’lerin gruplanıp paralel çalıştırılmasının sebebi tam olarak bu; migration da bu yüzden, çift yazma başlamadan önce yazılmış satırları sayfa sayfa dolaşan, tekrar eden küçük gruplar hâlinde işliyor:
-- Çift yazma yolunun yanında çalışan tek bir backfill grubu
BEGIN;
-- 4 fazdan 1'i: çift yazma zaten başladığı andan sonrasını kapsıyor,
-- bu grup öncesindeki geçmişi en eski satırdan başlayarak yürüyor
WITH batch AS (
SELECT
id,
user_id,
notification_type,
-- Eski sözlüğün yeniye eşlenmesi, yalnızca eşlemenin tanıdığı tipler için
CASE
WHEN notification_type IN ('old_type_1', 'old_type_2') THEN 'new_type_1'
-- LIKE içinde _ tek karakterlik joker olduğu için önek escape'leniyor:
-- escape edilmezse legacyXfoo gibi bir tip de eşleşiyor, dönüşümden
-- değişmeden çıkıyor ve migrate edilmiş sayılıyor
WHEN notification_type LIKE 'legacy\_%' ESCAPE '\'
THEN regexp_replace(notification_type, '^legacy_', 'classic_')
END as notification_type_v2,
data,
created_at
FROM notification_events
-- created_at NULL olunca karşılaştırma bilinmez sonuca düşüyor ve satır aşağıdaki
-- iki daldan da kayboluyor; bu yüzden koşul NULL'u açıkça yazıyor: tarihsiz satır
-- geçmişle birlikte taranıyor, NOT EXISTS de çift yazmanın tekrar eklemesini önlüyor
WHERE (created_at IS NULL
OR created_at < (SELECT dual_write_started_at FROM migration_status
WHERE migration_name = 'notification_schema_v2'))
AND NOT EXISTS (
SELECT 1 FROM notification_events_v2
WHERE notification_events_v2.id = notification_events.id
)
AND NOT EXISTS (
SELECT 1 FROM migration_unmapped
WHERE migration_unmapped.id = notification_events.id
)
ORDER BY created_at
LIMIT 10000 -- tek sayfa; hiçbir satır seçilmeyene kadar tekrarlanır
-- Her worker kendi sayfasını üstleniyor, böylece birkaçı aynı anda çalışabiliyor
FOR UPDATE OF notification_events SKIP LOCKED
),
quarantined AS (
-- CASE'in kapsamadığı bir tip, nullable kolona NULL olarak düşerdi;
-- migrate edilmiş sayılmak yerine burada park ediliyor
INSERT INTO migration_unmapped (id, notification_type, seen_at)
SELECT id, notification_type, NOW() FROM batch
WHERE notification_type_v2 IS NULL
RETURNING id
),
inserted AS (
INSERT INTO notification_events_v2 (id, user_id, notification_type_v2, data, created_at)
SELECT id, user_id, notification_type_v2, data, created_at FROM batch
WHERE notification_type_v2 IS NOT NULL
RETURNING id
)
-- İlerleme, bu grubun gerçekten yazdığı satırlardan geliyor
UPDATE migration_status
SET last_run = NOW(),
records_migrated = records_migrated + (SELECT COUNT(*) FROM inserted),
records_unmapped = records_unmapped + (SELECT COUNT(*) FROM quarantined)
WHERE migration_name = 'notification_schema_v2';
-- Conflict kontrolü, rollback yolu ve metrikler aynı transaction'a ait
COMMIT;
Bu gruptaki iki ifade işin yükünü taşıyor. FOR UPDATE ... SKIP LOCKED, ikinci bir worker’ın aynı sayfa için yarışmak yerine başka bir sayfayı üstlenmesini sağlıyor; paralel ilerlemeyle sürekli yeniden deneme arasındaki fark burada. Karantina dalı ise legacy tipi eşlemede bulunmayan satırları migrate edilmiş sayısının dışında tutuyor. O dal olmadan eşleme NULL dönüyor, insert yine de başarılı oluyor ve migration yapmadığı ilerlemeyi raporluyor. Aynı ağırlıkta iki küçük ayrıntı daha var. LIKE içinde _ joker karakter olduğu için önek escape’leniyor. Escape olmadan legacyXfoo gibi bir ad da eşleşiyor, dönüşümden değişmeden çıkıyor ve migrate edilmiş sayılıyor. NULL’u açıkça yazan koşul aynı işi seçim adımında yapıyor: created_at’i olmayan bir satırda karşılaştırma bilinmez sonuç veriyor, satır iki dala da girmiyor ve grup tamamlandı raporlarken geride kalıyor.
RFC’nin Ölçmediği Metrikler#
Varsayımsal RFC’deki bütün başarı kriterleri teknikti: %99,9 uptime, in-app için 100ms altı delivery, saniyede 10.000 bildirim. Bu hedefler gerekli ama yeterli değil. Bir bildirim sisteminin işletilmeye değip değmediğini belirleyen ölçüler spesifikasyonun tamamen dışında kalıyor:
- Bildirim kapatma oranı: zamanlama yanlışsa ve kullanıcı kanalı susturuyorsa yüksek delivery rate’in anlamı kalmıyor.
- Diğer takımların entegrasyon süresi: benimsenmesi için sürekli destek isteyen bir API, tasarım incelemesi ne derse desin temiz sayılmaz.
- Operasyonel yük: her hafta birinin nöbet tutmasını gerektiren otomasyon, işi yalnızca yer değiştirmiştir.
- Özelliklere erişilebilirlik: sadece yazan takımın konfigüre edebildiği yetenekler anlamlı biçimde teslim edilmiş sayılmaz.
Bunların hiçbiri throughput hedefinde görünmüyor ama her biri sistemin ikinci yılına ulaşıp ulaşmayacağını belirliyor.
Temel Dersler#
Bildirim sistemi implementasyonlarında tutarlı olarak birkaç kalıp ortaya çıkar:
1. RFC Bir Başlangıç Hipotezidir#
RFC’yi sabit bir spesifikasyon yerine başlangıç hipotezi olarak ele almak uyum sağlamayı kolaylaştırır. Belge, uygulamadan öğrenilenlerle birlikte güncellenmeli; dondurulmuş bir referans noktası olarak kalmamalı.
2. Sonradan Çıkan Gereksinimleri Planlamak#
Sabit yüzdeli bir tampon ortalama durumu karşılar ama kuyruğu ıskalar; takvimler de asıl orada kırılır. Planlamada daha işe yarar soru şu: planın hangi parçaları bir uç değere dayanır, hangileri onunla birlikte çöker?
3. Evrime Hazır Tasarım#
Sistemler er ya da geç migration, versiyonlama ve geriye dönük uyumluluk ister. Bu yetenekleri erken inşa etmek ileride teknik borcu ve operasyonel karmaşıklığı azaltır.
4. Edge Case’ler Çekirdek Gereksinimdir#
Tasarım incelemelerinde konuşulan senaryolar genellikle production’da karşımıza çıkar. Bu durumları ilk implementasyonda planlamak, sonradan tepkisel düzeltme yapmaktan daha verimli.
5. Organizasyonel Bağlamı Hesaba Katmak#
Teknik tasarımın başarısı organizasyonel uyuma bağlı. Takım değişiklikleri, yeniden yapılanmalar ve paydaş önceliklerindeki kaymalar uygulamayı mimari zarafetten daha çok etkiliyor.
6. Operasyonel Gözlemlenebilirlik#
Monitoring’i belirleyen şey, bir incident anında neye ihtiyaç duyulacağı. İş etkisi, kullanıcı deneyimi ve operasyonel detay, debugging sırasında tasarım belgesinin listelediği standart metriklerden daha değerli bilgi verir.
Tasarımla Uygulama Arasını Kapatmak#
RFC ile production arasındaki boşluğu daraltmaya yardımcı olan birkaç yaklaşım var:
Aşamalı Özellik Geliştirme#
Çekirdek işlevi iyi yapılmış bir sistemle başlamak, her şeyi birden kapsayan bir ilk sürümden daha iyi iterasyon sağlar. Kusursuz çalışan email bildirimleri, yarım kalmış çok kanallı bir yapıdan daha sağlam bir temel kurar.
Değişime Göre Tasarım#
Zarif biçimde evrimleşebilen sistemler, tahmin edilen senaryolara göre optimize edilmiş sistemlerden değişen gereksinimlerle daha iyi başa çıkar. Esneklik çoğu zaman ilk günkü mükemmellikten daha değerlidir.
Developer Experience’a Yatırım#
Entegrasyonu ve işletilmesi kolay olan sistem daha çok benimsenir. API kullanılabilirliği, sistemin başarısını çoğu zaman teknik spesifikasyonlardan daha çok belirler.
Yaşayan Dokümantasyon#
Dokümantasyonu yaşayan bir belge olarak tutmak ekibin sistem anlayışını güçlendirir; tarihi bir kayda dönüşmesine izin vermeyin. Orijinal tasarım, mevcut implementasyon ve öğrenilen dersler için ayrı bölümler tutmak bağlamı bir arada verir.
Çok Katmanlı Feedback Loop’ları#
Kullanıcı deneyimi, operasyonel metrikler ve developer iş akışını kapsayan feedback loop’ları hızlı iterasyon sağlar. Kısa öğrenme döngüleri problemi daha erken görünür kılar.
Boşluk Ne Zaman Sinyal, Ne Zaman Sürüklenme#
RFC’yi hipotez, boşluğu da veri olarak ele alın. İnceleme sırasında bilinemeyecek bir şeye dayanıyorsa boşluk kabul edilebilir: işin sonradan ihtiyaç duyduğu bir kanal, kimsenin belgelemediği bir authentication şeması, ancak bir incident’ta görünen bir metrik. Değişikliği belgeye işleyin ve devam edin. Boşluğun neden var olduğunu kimse açıklayamıyorsa, mevcut yapı yalnızca “çalışıyor” diye savunuluyorsa ya da RFC ile çalışan sistem çelişirken yeni gelen mühendisler hâlâ önce RFC’yi okuyorsa, boşluk sorunlu hâle gelir. O noktada düzeltilmesi gereken şey belgedir.
Sonraki adım basit: tasarım belgesiyle dağıtımın ayrıştığı bir sistem seçin ve her ayrışmayı bu iki kategoriden birine yerleştirin.
Kaynaklar#
- RFC 8030: Generic Event Delivery Using HTTP Push - IETF (yeni sekmede açılır) - Aralık 2016’da Proposed Standard olarak yayımlanan Web Push spesifikasyonu; belgenin durumu ve geçmişiyle birlikte
- Web Push for Web Apps on iOS and iPadOS - WebKit (yeni sekmede açılır) - WebKit’in Şubat 2023’te, spesifikasyondan altı yıl sonra, ana ekrana eklenen web uygulamaları için Web Push desteğini duyurduğu yazı
- Availability Table - Google, Site Reliability Engineering (yeni sekmede açılır) - Erişilebilirlik yüzdelerini yıl, çeyrek, ay, hafta ve gün bazında somut kesinti bütçelerine çeviren tablo
- Air Traffic Controller: Member-First Notifications at LinkedIn (yeni sekmede açılır) - Günde bir milyardan fazla istek işleyen LinkedIn bildirim platformu ve iyileştirme öncesi/sonrası uçtan uca P90 gecikme değerleri
- Pushy to the Limit: Evolving Netflix’s WebSocket Proxy for the Future (yeni sekmede açılır) - Netflix’in yüz milyonlarca eşzamanlı WebSocket bağlantısını işletme deneyimi; node başına bağlantı sayıları, mesaj hızları ve yeniden bağlanma davranışı
- chat.postMessage - Slack Web API referansı (yeni sekmede açılır) - Slack’in kanal ve workspace bazındaki mesaj gönderme limitleri ile burst payı
- Build to Scale: Queueing and Latency - Twilio (yeni sekmede açılır) - Twilio’nun numara tipine göre SMS hız sınırları, fazla mesajları ne kadar kuyrukta tuttuğu ve kuyruk taşınca dönen hata
- Amazon SES Servis Kotaları - AWS (yeni sekmede açılır) - Sandbox ve production hesapları için gönderim hızı ile günlük hacim limitleri, mesaj başına alıcı ve boyut sınırları
- Creating the Remote Notification Payload - Apple (yeni sekmede açılır) - Apple’ın normal ve VoIP push bildirimleri için azami payload boyutları ve APNs’in daha büyüğüne ne yaptığı
- WebSocket API Kotaları - AWS API Gateway (yeni sekmede açılır) - Frame ve mesaj boyutu kotaları, idle ve azami bağlantı süreleri, bağlantı hızları ve büyük frame’de dönen kapatma kodu
- Backend Servisinde @connections Komutlarını Kullanmak - AWS API Gateway (yeni sekmede açılır) - Bir backend’in sahibi olduğu bir socket yerine connection ID üzerinden bağlı istemciye nasıl mesaj gönderdiği ve bağlantı düştükten sonra dönen GoneException
- Online Migrations at Scale - Stripe (yeni sekmede açılır) - Stripe’ın dört fazlı çift yazma migration deseni ve canlı bir servisin şema değişikliği için neden durdurulamadığı
- Introduction to Indexes - PostgreSQL dokümantasyonu (yeni sekmede açılır) - Bir index’in oluşturulduktan sonraki maliyeti, kullanılmayan index’lerin neden silinmesi gerektiği ve concurrent index build davranışı
- Why Your IT Project May Be Riskier than You Think - Flyvbjerg ve Budzier (yeni sekmede açılır) - 1.471 BT projesini inceleyen Harvard Business Review çalışmasının yazar ön baskısı; ortalama maliyet aşımı ve arkasındaki kalın kuyruk
- Pulse of the Profession 2018: Success in Disruptive Times - PMI (yeni sekmede açılır) - Kapsam kayması, takvim ve bütçe performansı ile artan proje karmaşıklığına dair anket verileri
- Retirement of Office 365 Connectors within Microsoft Teams - Microsoft 365 Developer Blog (yeni sekmede açılır) - Her uzatmayı içeren tam emeklilik takvimi ve Power Automate geçiş yolu
İlgili yazılar
Yüksek riskli üretim ortamlarında bildirim sistemi hatalarından edinilen gerçek dünya debugging teknikleri, izleme stratejileri ve dersler
debugging · monitoring · production +4
RFC yapısı, stakeholder incelemesi ve teknik tartışmaları ekibin gerçekten uyduğu kararlara dönüştürme üzerine pratik rehber.
rfc · documentation · architecture +3
Yeşil dashboard'ların gizlediği Fargate arızaları: ENI kotasının tükenmesi, subnet route sorunları, memory leak'ler ve her birini bulan kontroller.
aws · fargate · debugging +4
Legacy kodu kimin yazdığını sormayı bırakın. Sorumluluğu, hesap verebilirliği ve suçlamayı ayırın; miras kodu sahipsiz bırakmak yerine sahiplendirin.
engineering-culture · leadership · team-management +3
AWS Verified Permissions, SpiceDB, OpenFGA, Cerbos ve OPA gibi harici yetkilendirme platformlarını mimari, maliyet ve karar çerçevesi açısından tarafsızca inceliyoruz.
authorization · security · architecture +4