Giriş
SaaS uygulamamda sayfa geçişleri gecikmeli gerçekleşiyordu. Kullanıcı on saniye beklememekteydi, ama uygulamanın ucuz görünmesini sağlayan kısa ve sürekli bir yavaşlık söz konusu. Problemi çözmeye oturdum ve zihnimde hazır bir hipotez ile: bir yerde caching yoktu.
Yanılmışım ve yanlış hipotezim sorunu bulmamı engellemişti. Darboğaz, önceden düşündüğüm hiçbir şeyde değildi; çünkü bu katman çok iyi çalışıyordu ve dikkatimizi çekmiyordu.
<h2>Mevcut Durum ve Yetersizlikleri</h2>
<p>Projemdeki cache kurulumları vasat değil, düzgün bir şekilde yapılandırılmıştı. <code>TenantCache</code> ve <code>GlobalCache</code> adında iki ince katman, Laravel'in cache yapısını sararak, üç ana unsur sağlıyordu: her anahtar takım kapsamı ile başlıyor (<code>t{teamId}:{domain}:{suffix}</code>), her giriş bir enum ile belirlenen TTL ile başlıyor ve her giriş blok invalidation için bir tag ile başlıyor. <code>flexible</code> ile sıcak yerlerde stale-while-revalidate uygulandı, veri değiştiğinde observer invalidation çalışıyor ve <code>Cache::</code> ile <code>cache()</code> gibi doğrudan çağrılar, mimari testlerindeki kısıtlamalar nedeniyle yasaklandı.</p>
<p>İnformasyona sahip olduğum bu altyapının en çok gurur duyduğum kısmıydı; ancak bu problemle hiçbir ilgisi yoktu.</p>
<p>Dersim, yanlış yerlerde arayarak zaman kaybettikten sonra anladığım bir şeydi: <strong>cache, tekrarlanan pahalı hesaplamaları çözümler. Kullanıcı her mobil geçişte baştan aşağı yeniden hesaplanıyorsa, cache bir çözüm değildir.</strong> Eğer maliyet "her zaman çalışan yirmi basit query" ise, cache seni kurtaramaz; çünkü depolayacak pahalı bir yanıt yoktur. Sadece çok fazla küçük şey sürekli olarak oluşuyordur.</p>
<h2>Gerçek Problem Yeri</h2>
<p>Inertia'da her sayfa geçişi, sunucuya tam bir request göndermektedir. Ve merkezi bir yer olan <code>HandleInertiaRequests</code>, her sayfanın aldığı paylaşılan prop'ları inşa etmektedir: oturum açmış kullanıcı, kullanıcı ekipleri, mevcut ekip, izinler, plan özellikleri, navigasyon sayacı. Bu, <strong>her navigasyonda yeniden oluşturulmakta</strong>. Sistem genelinde en fazla çalıştırılan kod ve performansı incelediğinizde en az dikkat çekeni, çünkü kimse shared props'ın middleware'ini, yavaş bir sorgu olup olmadığını kontrol etmek için açmaz.</p>
<p>Query kayıtlarını açtım ve saydım. On takım ile bir kullanıcı, her geçişte 27 sorgu yapıyordu. İki sebep vardı ve her ikisi de klasik bir durumu yansıtıyordu.</p>
<p>İlki, bir rahatlık metodunun arkasında gizlenmiş bir N+1 sorunudur. <code>toUserTeams()</code> tüm takımları tek bir sorguda alıyor, güzel görünüyor ve ardından her takım için bir payload oluşturmak için haritalama işlemi gerçekleştiriyor. Ama bu haritalama içinde <code>teamRole($team)</code> fonksiyonu, kullanıcının o takımda hangi rolde olduğunu öğrenmek üzere bir sorgu çalıştırıyordu. Her takım için ayrı bir sorgu. Eager loading doğru yerde olsa da, takımları yükleyip her seferinde üyelikleri birer birer sorguluyordum. Kullanıcı her yeni takımla her tıkında yeni bir sorgu ekleniyordu.</p>
<p>İkinci durum ise beni en çok sinirlendiren bir konu. Inertia'nın özel bir tuzağı. Props'lar closure olarak yazılmış ve bağımsız olarak değerlendiriliyor:</p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code><span class="c1">// app/Http/Middleware/HandleInertiaRequests.php</span>‘currentTeam’ => fn () => $this->resolveCurrentUserTeam($user),
‘teamPermissions’ => fn () => $user->toTeamPermissions($this->resolveActiveTeam($user)),
‘teamFeatures’ => fn () => $this->teamFeatures($this->resolveActiveTeam($user)),
‘teamTrial’ => fn () => $this->teamTrial($this->resolveActiveTeam($user)),
<p>Dört farklı prop, aktif takıma ihtiyacını duyuyor ve <code>resolveActiveTeam</code> bunları dört kez sıfırdan çözüyor: oturumu okuyor, takımı sorguluyor, kullanıcının o takıma üye olup olmadığını kontrol ediyor. Dört kez aynı iş, her istek için. Her satır için sorun yok, bu nedenle kod incelemelerinde gözden kaçıyor; çünkü bunların hiçbirinde bir yanlışlık yok, ancak toplamda dört tane olduğu için...</p>
<p>Çözüm birkaç satıra sığdırılabilir. İlişkiyi bir kez yükleyip, payload oluşturulurken kullanmalıyız:</p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code><span class="c1">// app/Http/Middleware/HandleInertiaRequests.php</span>// Bir membership okuması ile tüm payload: takımlar, currentTeam ve
// teamPermissions hepsi rolü çözüyor ve toUserTeams() her takım için bir kere bunu yapıyor.
$user?->load(‘teamMemberships’);
<p>Ve <code>teamRole()</code>, eğer ilişkiler zaten bellekte varsa, bunu tercih ederek geçiyor:</p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code><span class="c1">// app/Concerns/HasTeams.php</span>public function teamRole(Team $team): ?TeamRole
{
if ($this->relationLoaded(<span class=”s1>’teamMemberships’)) {
return $this->teamMemberships->firstWhere(<span class=”s1>’team_id’, $team->id)?->role;
}
<span class="k">return</span> <span class="nv">$this</span><span class="o">-></span><span class="nf">teamMemberships</span><span.p>(</span><span class="s1>'team_id'</span><span class="p">,</span> <span class="nv">$team</span><span.class="o">-></span><span class="n">id</span><span.class="p">)</span><span.class="o">-></span><span.class="nf">first</span><span.class="p">()</span><span.class="o">?-></span><span.class="n">role</span><span.class="p">;</span>}
<p>Aktif takımın daha fazla bellekle tutulması, 15 query yapılmasını sağladı ve bu sayı oldukça iyi. Ama önemli olan nokta, şimdi bu maliyetin sabit olması. Önceden, her geçişte kullanıcıların takım sayısına göre maliyet büyüyordu, bu da uygulamayı en çok etkileyecek duruma sokuyordu. Bu tür bir büyüme, üretimde keşfetmek istemeyeceğiniz bir durum.</p>
<h2>İlk Durum Hatalıydı</h2>
<p>Şimdi asıl ilgi çekici kısma gelelim: <code>teamRole()</code> daha hızlı hale geldiğinde <code>belongsToTeam()</code> de öyle olmalıydı, değil mi? Aynı tabloya, aynı soruya yanıt verecek ve her istekte birkaç kez çağrılacak. Bariz değişikliği yaptım. <strong>208 test bozuldu.</strong></p>
<p>Testlerin birdenbire bozulması ilk anda testlerin yanlış olduğuna inanma ya da test kurulumlarının hatalı olduğunu düşünme isteği doğuruyor. Ama sorun değil, gerçek bir bug ortaya çıkıyordu: kullanıcının bir takıma daveti kabul etmesi gerçekleşiyordu. Bu işlem esnasında üyelik veritabanında oluşturuluyordu. Ardından, aynı istekte <code>switchTeam()</code>, o takıma geçip geçemeyeceğini kontrol etmek için <code>belongsToTeam()</code> çağrısı yapıyordu. Bellekte bir ilişki yüklüyken, bu okuma yazmadan sonraki durumu görmemekteydi. Üyelik veritabanındaysa mevcut, ancak bellekdeki nesne bunu bilmediği için <code>belongsToTeam()</code> "aparten olur" diyordu ve <code>switchTeam()</code> başaramıyordu..</p>
<p>Açık bir hata yoktu. Hata kaydı yoktu. Tekrar yüklendiğinde ekibi yanlış yerde görünüyordu.</p>
<p>Aynı düşman, yine iki önceki gönderide olduğu gibi. Orada bir yeşil teste yanlış kapı açarken, burada da gerçek bir hız optimizasyonu sağlamaya çalışırken, doğrudan doğruya düzgün bir şekilde verdim. <strong>Otomasyon, bir şeyin bozulduğunda uyarı vermez; hızlı ve yanlış hale getirir ki bu, yavaş ama doğru olmaktan çok daha tehlikeli.</strong></p>
<p>Bu nedenle <code>belongsToTeam()</code> kesin bir şekilde sorgulama yapmak durumunda kalarak, kodda bir yorum yapılması gerekti. Çünkü bir karara dayanarak yapılan bir tasarım, sonraki kişi (ya da sonraki yapay zeka) bunu optimize ettiğini düşündü:</p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code><span class="c1">// app/Concerns/HasTeams.php</span>/
Hiçbir zaman önbelleğe alma, başka birinin her zaman veritabanını sorgulayan bir işlev.
switchTeam() hemen bir davet geldiğinde bu ilişkide ki durum bilgisini aldıktan sonra kontrol eder ve
önbellekteki bilgi, yazma işleminden önceki hali göstermekte olur: değişim sessiz bir şekilde başarısız olur ve kullanıcı eski takımla kalır.
*/Üç Önemli Kural
- Cache’den önce sayın. Cache, herkesin ilk hipotezidir ve tekrarlanan pahalı hesaplamaları çözer. Eğer maliyet küçük ve çok sayıda sorgu ise, cache ihtiyacınız olan bir çözüm değil, sorgularınızı azaltmanın bir yoludur.
- İlk önce tüm geçişlerde çalışan koda bakın. Inertia’da bu shared props’dır, kendi yapınızda başka bir isimle anılabilir. Bu, sistem genelinde en çok çalışan koddur ve en az gözden geçirilen kısımdır; çünkü bu bir özellik ile ilgili değildir. Gereksiz bir sorgu orada, açılmayan bir sayfadakine göre daha fazla maliyetlidir.
- Önbellekteki ilişki sadece görüntülemek için, asla karar vermek için değildir. Bellekten çekim yaparken ekranı oluşturmavnızı sağlamak sorun değil. Ancak bir yetkilendirme, giriş ya da durum değişikliğinde, veritabanındaki güncel bilgilere karar vermeniz gerekir.
Sonuç Olarak
Buradaki asıl kazanım 27 sorgudan 15’e geçmek değil, bir eğrinin sabit bir hale getirilmesidir. Bunu ancak kolay hipotez (cache) işe yaramadığında ve ölçüm yapmaya zorlandığımda buldum.
Yine görülmesi gereken bir durum vardı: her takım ile birlikte hâlâ her istekte dört kez üyelik kontrolü yapılıyor. Derinlemesine incelemeyle bu durum, diğer sorgu sayısını arttıracak veya azaltacaktır. Test, dört tane değişken ile sınırlı kalacak, bu sayı asla artamayacaktır. Eğer bir gün beş olursa, bu durum tekrardan birkaç deneme ile ortaya çıkacak ve bunu bilmek istiyorum.
Eğer uygulamanız “neden yavaş olduğunu anlayamadığınız” noktada takılı kalıyorsa, bu sorunun bulunduğunuz sayfada olmadığını varsayıyorum. Shared props middleware’in kayıtlarını açın ve sorguları sayın. Ne bulduğunuzu bana bildirin.
Kaynak: Orijinal Makale


