Bir araç paylaşım uygulaması oluşturmak, başlangıçta “sadece bir eğitimi takip et” gibi görünebilir; ancak işin içine girdiğinizde, eğitim sürümünün üretim sürümünden tamamen farklı bir yapı olduğunu hızlıca anlarsınız. Mittal Technologies olarak, yakın zamanda tam kapsamlı bir araç paylaşım platformu, sürücü eşleştirme, gerçek zamanlı yönlendirme, canlı konum takibi, ücret hesaplama, ödeme akışları ve daha fazlasını başarıyla tamamladık. Bu makale, aldığımız mimari kararları, beklemediğimiz sorunları ve farklı yapmayı düşündüğümüz noktaları ele alacak. Ön plan değil, gerçek yapıyı göstereceğiz.
<h3>
<strong>Ana Zorluk: Her Şey Aynı Anda Oluyor</strong>
</h3>
<p>Araç paylaşımının (örneğin, yemek dağıtım uygulamasına göre) temel zorluğu, eş zamanlı gerçek zamanlı olayların yoğunluğudur. Bir yolcu bir yolculuk talep ettiğinde, yapmanız gereken işlemler:</p>
<ol>
<li>Yakındaki mevcut sürücüleri bulmak</li>
<li>Talebi uygun sürücülere iletmek</li>
<li>İlk kabul edilen sürücü için kabul işlemini gerçekleştirmek ve diğerlerine iptal bildirimleri göndermek</li>
<li>Yolcu ve sürücü için canlı konum takibine başlamak</li>
<li>Şartlar değiştikçe ETA'ları güncellemek</li>
<li>Varış noktasına ulaştıklarında ödemeyi tetiklemek</li>
</ol>
<p>Tüm bunların alt saniye duyarlılığıyla gerçekleşmesi gerekiyor. Bir kullanıcı, maç onayı için 4 saniye beklerse, bu "yavaş" değil, "bozuk" olarak algılanır.</p>
<h3>
<strong>Teknoloji Yığını İncelemesi</strong>
</h3>
<p><strong>Backend:</strong> Node.js + Express<br/><strong>Gerçek Zamanlı:</strong> Socket.io<br/><strong>Haritalama:</strong> Google Maps Platform (Yönlendirme, Mesafe Matrisi, Coğrafi Kodlama)<br/><strong>Veritabanı:</strong> MongoDB (kullanıcı/yolculuk verileri) + Redis (canlı konum durumu)<br/><strong>Kimlik Doğrulama:</strong> JWT + yenileme token döngüsü<br/><strong>Ödemeler:</strong> Stripe Connect (sürücü ödemeleri için)<br/><strong>Hosting:</strong> AWS EC2 + ElastiCache for Redis</p>
<p>Node.js'i tercih ettik çünkü etkinlik odaklı, bloklamayan I/O, sürekli dış olayları bekleyen bir sistem için doğal bir uyum sağlıyor (sürücü kabulü, konum pingleri, ödeme onayları) ve bu işlemler sırasında thread'leri bloke etmek istemiyoruz.</p>
<h3>
<strong>Gerçek Zamanlı Mimari: Socket.io Oda Stratejisi</strong>
</h3>
<p>Çoğu kişi tarafından yeterince mimarisi yapılmamış kısım. Naif yaklaşım, her şeyi herkese yayınlamak ve istemci tarafında filtrelemektir. Bu 10 eş zamanlı kullanıcı için işe yarar; fakat 1.000'de kendinize çok maliyetli bir sorun yaratıyorsunuz.</p>
<p>Bizim yaklaşımımız, Socket.io odalarını yoğun bir şekilde kullanmak oldu.</p>
<p><strong>Sürücü odaları için geohash bölgesi:</strong><br/>Bir sürücü çevrimiçi olduğunda, kendi geohash bölgesine karşılık gelen bir odaya katılır (precision-5 geohash kullandık, yaklaşık 5km x 5km hücreler). Bir yolculuk talep edildiğinde, yolcunun geohash'ini hesaplıyoruz ve yalnızca bu odaya, ayrıca kenar durumları için komşu hücrelere yayın yapıyoruz.</p>
<p>// Sürücü çevrimiçi olduğunda<br/>socket.on('driver:online', async (data) => {<br/>const { driverId, lat, lng } = data;<br/>const geohash = Geohash.encode(lat, lng, 5);</p>
<p>// Bölge odasına katıl<br/>socket.join(<code>zone:${geohash}</code>);</p>
<p>// Kenar hücreler için komşu bölgeleri de katıl<br/>const neighbors = Geohash.neighbors(geohash);<br/>neighbors.forEach(neighbor => socket.join(<code>zone:${neighbor}</code>));</p>
<p>// Canlı durumu Redis'te tut (TTL 30s, kalp atışıyla yenilenir)<br/>await redis.setex(<code>driver:location:${driverId}</code>, 30, JSON.stringify({<br/>lat, lng, geohash, socketId: socket.id, available: true<br/>}));<br/>});</p>
<p><strong>Eşleşmiş yolculuklar için trip odaları:</strong><br/>Bir eşleşme onaylandığında, hem sürücü hem de yolcu özel bir trip odasına katılır. Tüm sonraki konum güncellemeleri, durum değişiklikleri ve mesajlar yalnızca bu oda üzerinden gider.</p>
<p>// Eşleşme onayı sırasında<br/>const tripRoom = <code>trip:${tripId}</code>;<br/>driverSocket.join(tripRoom);<br/>riderSocket.join(tripRoom);</p>
<p>// Konum güncellemeleri artık yalnızca bu oda kapsamında<br/>socket.on('location:update', async ({ lat, lng }) => {<br/>const tripId = await getActiveTripForSocket(socket.id);<br/>if (!tripId) return;</p>
<p>io.to(<code>trip:${tripId}</code>).emit('location:update', {<br/>role: socket.data.role, // 'driver' veya 'rider'<br/>lat,<br/>lng,<br/>timestamp: Date.now()<br/>});</p>
<p>// Redis durumunu yenile<br/>await redis.setex(<code>socket:location:${socket.id}</code>, 60, JSON.stringify({ lat, lng }));<br/>});</p>
<h3>
<strong>Eşleştirme Algoritması</strong>
</h3>
<p>Burada üç aşamadan geçtik. İlki fazla basitti (sadece en yakın sürücü). İkincisi ise çok karmaşık hale geldi (v1 için uğraşmamamız gereken bir makine öğrenimi konusuna daldık). Üçüncü aşama, üretime gönderdiğimiz versiyondu.</p>
<p>Üretim eşleştirme mantığı, mevcut sürücüleri üç faktöre göre puanlar:</p>
<p>async function scoreDriversForRequest(riderLat, riderLng, drivers) {<br/>const scores = await Promise.all(drivers.map(async (driver) => {<br/>// Faktör 1: Mesafe (Google Mesafe Matrisi için gerçek yol mesafesi, düz hat değil)<br/>const distanceData = await getDistanceMatrix(<br/>{ lat: driver.lat, lng: driver.lng },<br/>{ lat: riderLat, lng: riderLng }<br/>);<br/>const etaMinutes = distanceData.duration.value / 60;<br/>const distanceKm = distanceData.distance.value / 1000;</p>
<div class="highlight js-code-highlight">
<pre class="highlight plaintext"><code>// Faktör 2: Sürücü kabul oranı (her sürücü için depolanır, asenkron olarak güncellenir)const acceptanceRate = await redis.get(driver:acceptance:${driver.id}) || 0.8;
// Faktör 3: Yolculuk tamamlama kalite puanı
const qualityScore = driver.rating / 5;
// Ağırlıklı puanlama
const score = (
(1 / (etaMinutes + 1)) 0.5 + // Yakınlığa %50 ağırlık
parseFloat(acceptanceRate) 0.3 + // Güvenilirliğe %30 ağırlık
qualityScore * 0.2 // Kaliteye %20 ağırlık
);
return { driver, score, etaMinutes, distanceKm };
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewbox="0 0 24 24" class="highlight-action crayons-icon highlight-action--fullscreen-off"><title>Geniş ekran modundan çık</title>
<path d="M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z"/>
</svg>
</div>
</div>
</div>
<p>}));</p>
<p>return scores.sort((a, b) => b.score - a.score);<br/>}</p>
<p>Bilinmeyen bir problem: Google Mesafe Matrisi API her öğede (kalkış x varış çifti) ücretlendiriyor. 20 yakın sürücü ve 1 yolcu olduğunda, her talep olayında 20 API çağrısı yapılıyor. Ölçeklendiğinde bu, hızlı bir şekilde pahalı hale geliyor. Sonuçta, daha önce hesaplanmış rotalar için bir Redis önbelleği ekledik (5 dakikalık TTL), bu da uygulama kullanımımızı pratikte yaklaşık %60 oranında azalttı.</p>
<h3>
<strong>Yarış Koşulu Problemi</strong>
</h3>
<p>Test sırasında başımıza gelen bir sorun ve doğru bir şekilde çözmek bir günümüzü aldı.</p>
<p>Bir yolculuk talebi sürücü bölgesine yayınlandığında, birden fazla sürücünün neredeyse aynı anda kabul etmesi mümkündür. Doğru bir kilitleme olmadan, iki sürücü de onay alabilir. Bunu düzeltmek için ilk denememiz, tripId + durum="accepted" üzerinde veritabanı seviyesinde benzersiz bir kısıtlama koymaktı. Bu işe yaradı, ancak ikinci sürücü, iyi bir kurtarma yolu olmadan kafa karıştırıcı bir hata aldı.</p>
<p>Doğru çözüm, trip kabulü için bir Redis dağıtılmış kilidi kullanmaktı:</p>
<p>async function acceptTrip(tripId, driverId, socket) {<br/>const lockKey = <code>lock:trip:${tripId}</code>;<br/>const lockValue = <code>${driverId}:${Date.now()}</code>;</p>
<p>// Kilo almaya çalışma (10sn son tarih olarak)<br/>const acquired = await redis.set(lockKey, lockValue, 'NX', 'EX', 10);</p>
<p>if (!acquired) {<br/>// Başka bir sürücü önce davrandı<br/>socket.emit('trip:already_accepted', { tripId });<br/>return;<br/>}</p>
<p>try {<br/>// Yolculuğun hala eşleşmemiş olduğuna bak<br/>const trip = await Trip.findById(tripId);<br/>if (trip.status !== 'pending') {<br/>socket.emit('trip:already_accepted', { tripId });<br/>return;<br/>}</p>
<div class="highlight js-code-highlight">
<pre class="highlight plaintext"><code>// Eşleşmeyi onaylatrip.driverId = driverId;
trip.status=”matched”;
trip.matchedAt = new Date();
await trip.save();
// Yolcuya bildir, diğer sürücülere yayını iptal et
io.to(trip:rider:${trip.riderId}).emit(‘trip:matched’, {
driverId,
eta: / hesaplanan ETA /
});
io.to(zone:${trip.requestZone}).emit(‘trip:cancelled’, { tripId });
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewbox="0 0 24 24" class="highlight-action crayons-icon highlight-action--fullscreen-off"><title>Geniş ekran modundan çık</title>
<path d="M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z"/>
</svg>
</div>
</div>
</div>
<p>} finally {<br/>// Kilidi her zaman kaldır<br/>const currentValue = await redis.get(lockKey);<br/>if (currentValue === lockValue) {<br/>await redis.del(lockKey);<br/>}<br/>}<br/>}</p>
<h3>
<strong>Google Haritalar Entegrasyonu: Bizi Şaşırtan Kısımlar</strong>
</h3>
<p><strong>Yönlendirme API'si için rota görüntüleme</strong> — çok basit. Bir poligon alıyorsunuz, bunu çözüyor ve görüntülüyorsunuz.<br/><strong>Gerçek zamanlı ETA güncellemeleri</strong> — daha az basit. Her konum pinginde Yönlendirme API'sine başvurmak pahalı ve gereksiz. Her 90 saniyede bir ve önemli rota sapmalarında (>200m) sorgulama yapıyoruz ve sürücü konumunu Redis'ten kullanıyoruz, socket olayını beklemiyoruz.<br/><strong>Alma / bırakma için coğrafi kodlama</strong> — arama girdisinden ileri coğrafi kodlama (adres → koordinatlar) normaldi. Ters coğrafi kodlama (koordinatlar → okunabilir adres, alma onayı için) önbellekleme gerektiriyor. Aynı koordinatlar, dakikalar içinde onlarca kez coğrafi kodlanıyor. 24 saat TTL ile kesirlenmiş lat/lng anahtarlamalı basit bir Redis önbelleği ile bu sorunu çözdük.</p>
<h3>
<strong>Farklı Yapmayı Düşündüğümüz Noktalar</strong>
</h3>
<p><strong>WebSocket'ler vs Socket.io:</strong> Socket.io'nun otomatik yeniden bağlantı ve oda yönetimi gerçekten kullanışlı, ancak geri dönüş aktarımlarına ihtiyaç duymayan bağlantılar için onun tam ağırlığını taşıyoruz. v2'de, sürücü istemcileri için özel bir yeniden bağlantı katmanıyla ham WebSocket'i değerlendirmek ve Socket.io'yu yalnızca yolcuya yönelik web istemcileri için tutmak isteyeceğiz.<br/><strong>Konum güncelleme sıklığı:</strong> Sürücü konum pingleri için varsayılan olarak 3 saniye belirledik. Aktif yolculuklar için 3 saniye yeterlidir. Yakın ama eşleşmemiş sürücüler için 10 saniye yeterlidir ve Redis yazma yükünü anlamlı bir şekilde azaltır.<br/><strong>Gerçek zamanlı akışların test edilmesi:</strong> Birim testleri, Socket.io davranışını ölçeklendirmede gerçekten yakalayamaz. Daha erken Artillery veya k6 ile yük testi kurmamız gerekirdi. Staging’de bir socket odası sızıntısı bulduk ki, bunu testte yakalamalıydık.</p>
<h3>
<strong>Üretimde Performans</strong>
</h3>
<p>Başlangıçta yük: ~400 eş zamanlı WebSocket bağlantısı zirvede. Redis, ~2.000 ops/saniye işliyor. Ortalama eşleşme süresi: 4.2 saniye, talep ile sürücü onayı arasında. Google Haritalar API maliyetleri: önceden tahmin edilenin üstünde, önbellek iyileştirmelerinden sonra 1. ayda normalleşti, ardından 2. ayda bütçeye dönüştü.</p>
<p>Mimari sağlam kaldı. İzlediğimiz ana ölçekleme darboğazı, eşleştirme algoritmasının Mesafe Matrisi çağrılarıdır; araç çağrı hacmi arttıkça, yüksek yoğunluklu kentsel bölgeler için önbelleğe alınmış bir grafik yaklaşımına geçmeyi değerlendiriyoruz.</p>
<p>Buna benzer bir şey inşa ediyorsanız ve mimari kararlarla ilgili konuşmak isterseniz, yorumlarda sorularınızı bırakmaktan çekinmeyin. Ayrıca, bu tür gerçek zamanlı altyapıyı göndermiş bir ekibe ihtiyaç duyuyorsanız, <a href="https://mittaltechnologies.com/" target="_blank" rel="noopener noreferrer">Mittal Technologies</a>’nin bunu kanıtlayan bir vaka çalışması var.</p>Kaynak: Orijinal Makale


