Trafik polislerinin konumu saniyeler içinde değişirken; olayın nerede olduğunu, en yakın uygun ekibin kim olduğunu ve o ekibin gerçekten müsait olup olmadığını tek ekranda cevaplayan; görevlendirmeyi iki operatör aynı anda denese bile tutarlı tutan sistem.
Önce sistemin ne işe yaradığını görürüz. Sonra onu ayakta tutan mimari kararlara ineriz. Ardından "iki operatör aynı anda ne yaparsa ne olur" sorusuyla yüzleşiriz. En sonunda sistemi bugünkünün on katı yüke hazırlayan kararları tartışırız. Soldaki renkli şerit her an hangi aşamada olduğunuzu söyler.
Kod yazmadan önce cevaplanması gereken soru: bir trafik operasyon merkezinde gün nasıl geçiyor ve hangi an kritik?
Aşağıdaki sahne gerçek bir vardiyayı temsil ediyor: solda olaylar birikiyor, sağda ekipler sahada. Operatör kimin nerede olduğunu bilmediği için görevlendirmeyi tahminle yapıyor.
Aynı ekip iki ayrı olaya yönlendirilir. İkinci olay ekip gelmediği için bekler; kimse fark etmez.
Olaya 12 km uzaktaki ekip gönderilirken 800 m ötedeki müsait ekip beklemede kalır.
"Bu ekip saat 14:20'de neredeydi?" sorusunun cevabı hiçbir yerde tutulmaz.
Sistem yeni bir iş akışı dayatmaz. Operatörün zaten sorduğu üç soruyu, sayfa yenilemeden güncellenen tek bir ekranda cevaplar.
Olay, öncelik rengiyle haritaya düşer. Konumu, türü ve açılış zamanı kayıt altındadır.
Sahadaki tüm personel harita üzerinde. İşaretçi rengi görev durumunu gösterir.
Görevlendirme anında kilitle doğrulanır. Meşgul bir ekip ikinci bir olaya atanamaz.
Yetki kontrolü uç nokta seviyesinde yapılır ve API sözleşmesinin görünür parçasıdır. Yeni bir uç nokta eklendiğinde yetki kuralı yazılmadıysa bu, sözleşme dosyasında gözle görülür.
| Rol | Personel | Olay | Görevlendirme | Görüntüleme |
|---|---|---|---|---|
| ADMIN sistem yöneticisi |
Oluştur · Güncelle · Pasifleştir | Tam yetki | Tam yetki | Tümü |
| OPERATOR merkez operatörü |
Yazamaz | Oluştur · Güncelle | Ata · Kaldır · Durum değiştir | Tümü |
| VIEWER izleyici |
Yazamaz | Yazamaz | Yazamaz | Tümü |
/** Yeni personel oluşturur. Sicil numarası benzersizdir. */ @PostMapping @PreAuthorize("hasRole('ADMIN')") @Idempotent ResponseEntity<PoliceOfficerDetail> create( @RequestBody @Valid CreatePoliceOfficerRequest request);
Aynı olay, altı durakta izleniyor. Her durak farklı bir teknolojiye denk geliyor; sunumun geri kalanı bu durakları tek tek açacak.
Aşağıdaki pano canlı çalışıyor: işaretçiler hareket ediyor, başka bir operatörün açtığı olay bildirim olarak düşüyor. Ekran tasarımı MUI v6 üzerine kurulu; renk kodlaması tüm ekranlarda aynı.
İşaretçi rengi görev durumu, olay rengi öncelik. Renkler TypeScript sabitlerinde tutulur — canvas bağlamı CSS değişkeni çözemez.
Ağır toplama sorguları Redis üzerinde 30 saniye önbelleklenir.
240+ kayıt sayfalı listede. Arama girişi geciktirilir, her tuş vuruşunda istek gitmez.
Başka bir operatörün işlemi bu ekrana da düşer. Sayfa yenilemesi yoktur.
Ekranın arkasında hangi kararlar var? Bu aşamada paket düzeninden indeks seçimine kadar her karar gerekçesiyle birlikte.
Projede "ileride lazım olur" diye eklenip kapalı bırakılmış bileşen yok. Her biri çalışır durumda ve neyi çözdüğü ölçülebilir.
Virtual thread, record ve sealed interface desteği. Güvenlik, veri erişimi ve izleme tek yapılandırma altında.
Tablo bölümleme, pg_trgm ve BRIN indeks çekirdekte. Ek ürün gerekmedi.
Tek thread'li yürütme atomik Lua script'e imkân verir. Dört ayrı iş için kullanılıyor.
Düzensiz ve yüksek hacimli konum akışını veritabanından ayıran tampon.
Tek yönlü akış için WebSocket'ten az parça: HTTP üzerinde çalışır, yeniden bağlanma protokolde tanımlı.
Sunucu durumu RTK Query önbelleğinde, istemci durumu Redux slice'ta. İki kaynak birbirine kopyalanmaz.
Dördünü tek bileşenle karşılamak, işletim yükünü dört ayrı ürüne dağıtmaktan düşük tutuyor.
Katman bazlı paketlemede tek bir özellik değişikliği beş ayrı klasöre dokunmayı gerektirir. Burada bir özelliğin tüm parçaları tek ağaç altında.
Model küçük. Zorluk tablo sayısında değil, event_assignments üzerindeki tek kuralda: bir personelin aynı anda yalnızca bir aktif görevlendirmesi olabilir.
| Tablo | İçerik | İlişki | Not |
|---|---|---|---|
| users | Kullanıcı, rol, şifre özeti | refresh_tokens 1–n | BCrypt cost 12 |
| units / teams | Organizasyon hiyerarşisi | birim → takım → personel | Referans verisi migration'da |
| police_officers | Sicil, ad, rütbe, görev durumu | teams, vehicles | Ada göre trigram indeks |
| vehicles | Plaka, tip, zimmet | police_officers 1–1 | |
| traffic_events | Kod, tür, öncelik, durum, konum | event_assignments 1–n | Sürüm alanı ile korunur |
| event_assignments | Rol, durum, zaman damgaları | olay ⟷ personel | Kısmi UNIQUE indeks burada |
| police_locations | Enlem, boylam, hız, yön, zaman | police_officers n–1 | Aylık bölümlenmiş tablo |
police_locations tablosu recorded_at sütununa göre aylık RANGE bölümlerine ayrılmış. Aşağıda iki sorguyu karşılaştırın.
"Son 24 saatin rotası" sorgusu yalnızca ilgili ayın bölümünü okur.
Eski veriyi silmek milyonlarca satırlık DELETE değil, tek bir DROP TABLE. Saniyeler sürer, tablo şişmez.
Her bölüm kendi indeksini taşır; indeksler bellekte tutulabilir boyutta kalır.
Operatör personel adının ortasından arama yapar: LIKE '%yılmaz%'. Standart B-tree indeksi bu deseni kullanamaz. Aşağıda iki plan yan yana koşuyor.
| İndeks | Alan | Neden bu tip |
|---|---|---|
| B-tree (bileşik) | (police_id, recorded_at DESC) | Hem "son konum" hem "rota geçmişi" sorgusunu tek indeksle karşılar. |
| GIN + pg_trgm | ad, sicil no | Alt dize araması B-tree kullanamaz. Trigram indeksi olmadan her arama tam tablo taraması yapar. |
| BRIN | police_locations.recorded_at | Veri zamana göre sıralı eklendiği için B-tree'nin çok küçük bir kesri boyutunda benzer fayda verir. |
| Kısmi UNIQUE | (police_id) WHERE status='ACTIVE' | İş kuralını uygulamaya değil veritabanına yaptırır. Sonraki aşamanın konusu. |
Buraya kadar her şey tek kullanıcı varsayımıyla çalışıyordu. Şimdi ikinci operatör geliyor — ve aynı polisi farklı bir olaya atamaya çalışıyor.
İki operatör aynı saniyede "ata" düğmesine basıyor. Kilit kapalıyken iki transaction da "personel müsait" cevabını okuyor ve ikisi de yazıyor.
Her katman bir öncekinin atlanabileceği varsayımıyla eklenmiş. Üçüncüsü uygulamaya değil veritabanına dayanan nihai garanti.
PESSIMISTIC_WRITE ile işlem başlarken olay ve personel satırları kilitlenir. İkinci transaction ilkinin bitmesini bekler ve güncel durumu okur.
Kilitler daima aynı sırayla alınır: önce olay, sonra personeller kimliğe göre artan sırada. Sıra sabit olmasaydı iki görevlendirme karşılıklı bekleyip kilitlenirdi.
Uygulama katmanındaki her kontrol bir hata sonucu atlansa bile veritabanı ikinci aktif görevlendirmeyi reddeder.
-- Bir personelin ayni anda yalnizca bir AKTIF gorevlendirmesi olabilir. -- Kismi indeks: kural yalnizca aktif satirlar icin gecerli, kapanmis -- gorevlendirmeler ayni personel icin sinirsiz sayida birikebilir. CREATE UNIQUE INDEX ux_assignment_active_officer ON event_assignments (police_id) WHERE status = 'ACTIVE';
Görevlendirmede kötümser kilit doğru; ama kayıt düzenlemede her açılan formda satır kilitlemek sistemi kilitler. Burada iyimser kilitleme kullanılıyor.
Access token 15 dakika geçerli. Süresi dolduğunda arayüz kullanıcıya hissettirmeden yeniler ve başarısız isteği tekrarlar.
Token çalınsa bile kullanım penceresi dar. Sunucuda oturum tutulmadığı için backend yatay ölçeklenebilir.
Her yenilemede eski refresh token geçersiz kılınır. Bir token ikinci kez kullanılırsa bu bir sızıntı işaretidir ve zincirin tamamı iptal edilir.
Refresh token'lar veritabanında SHA-256 özetiyle durur. Veritabanı okunsa bile doğrudan kullanılamaz.
Tarayıcının EventSource API'si özel HTTP başlığı göndermeye izin vermez. Akışı açarken kimlik nasıl taşınacak?
# 1) Normal, kimlikli istek — token Authorization basliginda POST /api/auth/stream-ticket → { "ticket": "9f2c...a7", "expiresInSeconds": 60 } # 2) EventSource yalnizca URL alabilir — bilet burada kullanilir GET /api/realtime/stream?ticket=9f2c...a7 → text/event-stream (acik kalir)
Sistem çalışıyor. Peki tek sunucu yetmediğinde, saha cihazları toplu veri gönderdiğinde ve aynı istek üç kez ulaştığında ne oluyor?
Olay 1 numaralı örnekte oluşuyor. Ama operatörün SSE bağlantısı 2 numaralı örnekte — ve haberi olmuyor. Yük dengeleyici eklendiği gün ortaya çıkan, test ortamında görünmeyen hata.
Konum bildirimi en yüksek hacimli uç nokta ve akışı düzensiz. Bağlantısı kopan bir cihaz yeniden bağlandığında biriken kayıtları toplu gönderir.
Kafka'yı eklemek kolay; doğru yapılandırmak asıl iş. Aşağıdaki dört değer rastgele seçilmedi.
| Parametre | Değer | Gerekçe |
|---|---|---|
| Bölüm anahtarı | policeId | Kafka sıralamayı yalnızca bölüm içinde garanti eder. Aynı personelin kayıtları aynı bölüme düşmezse rota sırası karışır ve harita geriye zıplar. |
| Bölüm sayısı | 6 | Tüketici sayısının (3) üzerinde tutuldu; yatay büyüme için topic'i yeniden oluşturmak gerekmez. |
| Batch boyutu | 500 | Tek tek yazım yerine toplu yazım. Veritabanına giden gidiş-dönüş sayısı iki mertebe azalır. |
| Onay modu | AckMode.BATCH | Grup veritabanına yazılmadan offset ilerlemez. Tüketici çökerse grup yeniden okunur, veri kaybolmaz. |
SSE bağlantıları saatlerce açık kalır. Klasik platform thread havuzunda her açık bağlantı bir thread'i meşgul eder — ve o thread'in tek yaptığı beklemektir.
Virtual thread her yere serpiştirilecek bir hızlandırıcı değil. Dört yerde bilerek kullanılmadı; gerekçeleri kodda yorum olarak da yazılı.
EntityManager thread'e bağlıdır. Transaction içindeki sorguları farklı thread'lere dağıtmak sessiz veri bozulması riski doğurur. Dashboard'un üç sorgusu tek transaction'da sırayla kalır.
Virtual thread yalnızca bloke eden G/Ç sırasında taşıyıcı thread'i serbest bırakır. Hesaplama süresini kısaltmaz; çekirdek sayısı neyse odur.
60 eş zamanlı yazma çağrısı bağlantı havuzunu doldurup gerçek operatör isteklerini geciktirirdi. Simülatör bilerek sıralı bırakıldı.
Java 21'de synchronized içindeki virtual thread taşıyıcıya sabitlenir (pinning) ve kazanç kaybolur. Bu yüzden eş zamanlı koleksiyonlar tercih edildi.
HTTP'de POST doğası gereği tekrarlanabilir değildir. Ağ zaman aşımı sonrası istemci tekrarı da aynı sonucu doğurur: iki olay kaydı, iki görevlendirme.
| Durum | Davranış |
|---|---|
| İlk istek | Anahtar rezerve edilir, istek işlenir, yanıt saklanır. |
| Aynı anahtar, tamamlanmış | Saklanan yanıt döner + Idempotent-Replay: true |
| Aynı anahtar, işleniyor | 409 — istemci kısa süre sonra tekrar dener. |
| Aynı anahtar, farklı gövde | 409 — gövdenin SHA-256 özeti tutmuyor. |
| Redis erişilemiyor | İstek işlenir (fail-open). |
Aynı anahtar farklı gövdeyle gelirse sessizce yanlış yanıt dönmek yerine hata verilir. Yanlış yanıt, yanıt vermemekten tehlikelidir.
Rezervasyon kilidi kısa, saklanan yanıt uzun ömürlü. Sunucu işin ortasında durursa anahtar 24 saat asılı kalmaz.
Anahtar form açıldığında üretilir ve başarıya kadar değişmez. Her tıklamada yenilenseydi çift tıklama iki farklı anahtar üretir, koruma hiç çalışmazdı. Bir testle korunuyor.
Her istek kovadan bir token alır. Kova boşsa istek 429 ile reddedilir. Kova sabit hızda yeniden dolar.
Token bucket "oku → hesapla → yaz" adımlarından oluşur. Bu üç adım ayrı Redis komutlarıyla yapılırsa iki eş zamanlı istek aynı eski değeri okur ve ikisi de geçer.
-- Redis komutlari tek thread'de calisir: bu script basladiginda -- araya baska hicbir komut giremez. Dagitik kilide gerek yoktur. local now = redis.call('TIME') -- sunucu saati: ornekler -- arasi saat sapmasi etkilemesin local tokens = tonumber(bucket[1]) or capacity local delta = (now - last) * refill_per_second tokens = math.min(capacity, tokens + delta) if tokens >= requested then tokens = tokens - requested redis.call('HSET', KEYS[1], 't', tokens, 'ts', now) return { 1, tokens, capacity, 0 } -- gecti else return { 0, tokens, capacity, retry_after } end
Bağlantısı kopan saha cihazı biriken konumları toplu gönderir. Kayan pencere bu meşru ani yükü reddederdi. Token bucket kapasite kadar burst'ü kabul edip yalnızca sürdürülebilir ortalamayı sınırlar.
İstek geçirilir (fail-open). Hız sınırı uygulamanın kendisi değil bir koruma katmanıdır; acil durum merkezinde tüm trafiği reddetmek korumasız çalışmaktan ağır bir sonuçtur.
Uygulama açılışta hata verir. Aksi hâlde sıfıra bölme sonsuz değer üretir ve sistem sessizce limitsiz çalışır — en tehlikeli hata tipi budur.
Üç kuralın tamamı yeniden derleme gerektirmeden değiştirilebilir. Aynı değişkenler Docker kurulumunda da geçerli.
# capacity -> tolere edilen ANI YUK (burst) # refill_per_second -> SURDURULEBILIR ortalama hiz RATE_LIMIT_ENABLED=true # Genel kural: tum /api/** istekleri RATE_LIMIT_DEFAULT_CAPACITY=300 RATE_LIMIT_DEFAULT_REFILL_PER_SECOND=100 # Giris — kaba kuvvet korumasi, IP bazli ve bilincli olarak siki RATE_LIMIT_LOGIN_CAPACITY=10 RATE_LIMIT_LOGIN_REFILL_PER_SECOND=0.1667 # ~10/dk # Konum bildirimi — en yuksek hacimli uc nokta RATE_LIMIT_LOCATION_CAPACITY=600 RATE_LIMIT_LOCATION_REFILL_PER_SECOND=50 IDEMPOTENCY_ENABLED=true IDEMPOTENCY_TTL=24h SCHEDULER_CONCURRENCY_LIMIT=-1 # -1 = sinirsiz
Bir mimari sunumunun en değerli kısmı, kapsam dışında bırakılanları da söylemesidir. Aşağıdakiler eksik değil, bilinçli sınır.
| Konu | Mevcut durum | Önerilen adım |
|---|---|---|
| Yük testi | Darboğazlar analizle bulunup giderildi, çözümlerin çalıştığı ölçüldü. Hedef ölçekte yük testi koşulmadı. | Üretim öncesi konum uç noktası için yük testi |
| Kafka topolojisi | KRaft modunda tek broker. | En az üç broker, replikasyon faktörü 3 |
| Hatalı mesajlar | Sürekli başarısız mesaj yeniden deneme sonrası atlanır. | Ayrı hata topic'i (dead letter) ve inceleme akışı |
| Hız sınırı kuralları | Üç kural koda tanımlı; değerleri ortam değişkeninden. | Kural tanımının veritabanından yönetilmesi, rol bazlı kota |
| Bölüm yönetimi | Aylık bölümler migration ile oluşturulur. | Zamanlanmış görev veya pg_partman |
| İzleme | Actuator metrikleri açık. | Merkezî metrik toplama ve alarm |
Görünürlük problemi. Üç soru tek ekranda, canlı olarak cevaplanıyor.
İş alanına göre paketleme, sözleşme arayüzleri, bölümlenmiş konum tablosu, doğru indeks tipi.
Yarış koşuluna karşı üç katman, sabit kilit sırası, iyimser kilitleme, kısa ömürlü kimlik.
Redis pub/sub, Kafka tamponu, virtual thread, idempotency ve Lua ile atomik hız sınırı.