Mimari
Mimari"n+1 sorunu"nun bastırılması

"n+1 sorunu"nun bastırılması

Gato GraphQL'in "n+1 sorununu" mimari tasarımı sayesinde nasıl tamamen önlediğini öğrenelim.

"n+1 sorunu" nedir?

"n+1 sorunu" temel olarak şu anlama gelir: veritabanına karşı çalıştırılan queries sayısı, grafikteki düğüm sayısı kadar büyük olabilir.

Bu ne anlama geliyor? Bir örnekle inceleyelim: diyelim ki aşağıdaki query aracılığıyla bir yönetmenler listesini ve her birinin filmlerini almak istiyoruz:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

Verimli olmak için, veritabanından veri almak üzere yalnızca 2 queries çalıştırmayı bekleriz: 1 tanesi yönetmenlerin verilerini almak için, 1 tanesi de tüm yönetmenlerin tüm filmlerinin verilerini almak için.

Ancak bu query'yi karşılamak için GraphQL'in veritabanına karşı "n+1" queries çalıştırması gerekecektir: önce N yönetmenin listesini (bu durumda 10) almak için 1 tane, ardından N yönetmenin her biri için film listesini almak üzere 1 query. Bizim örneğimizde 1+10=11 queries çalıştırmalıyız.

Bu sorun, GraphQL resolver'larının aynı türdeki tüm nesneleri aynı anda değil, yalnızca birer nesne işlemesinden kaynaklanmaktadır. Bizim örneğimizde, Query türündeki nesneleri (kök tür olan) işleyen resolver, tüm Director nesnelerinin listesini almak için ilk kez çağrılır; ardından Director türünü işleyen resolver, film listesini almak için her bir Director nesnesi için bir kez çağrılır.

Başka bir deyişle: GraphQL resolver'ları ağacı görür, ormanı değil.

Bu sorun aslında ilk göründüğünden daha kötüdür; çünkü bir grafikteki düğüm sayısı, grafiğin seviye sayısıyla üstel olarak büyür. Dolayısıyla "n+1" adı yalnızca 2 seviye derinliğindeki bir grafik için geçerlidir. 3 seviye derinliğindeki bir grafik için buna "N2+n+1 sorunu" demek gerekirdi! Ve bu böyle devam eder...

Örneğin, yukarıdaki örneğimizi izleyerek, her filmin oyuncu/oyuncu listesini de query'ye ekleyelim:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
        actors(first: 10) {
          name
        }
      }
    }
  }
}

Bu durumda veritabanına karşı çalıştırılan queries şunlardır: önce 10 yönetmenin listesini almak için 1 tane, ardından 10 yönetmenin her biri için yönetmenin film listesini almak üzere 1 query ve son olarak 10 yönetmenin her birinin 10 filmi için oyuncu/oyuncu listesini almak üzere 1 query. Bu toplam 1+10+100=111 queries eder.

Bu davranışı fark ettikten sonra "n+1 sorunu", GraphQL'in en büyük performans engeli olarak kolayca değerlendirilebilir: kontrol altına alınmazsa, birkaç seviye derinliğindeki grafikleri sorgulamak o kadar yavaş hale gelebilir ki GraphQL neredeyse kullanılamaz hale gelir.

"n+1 sorununa" genel çözüm

"n+1 sorununa" standart çözüm ilk kez DataLoader yardımcı programı tarafından sağlanmıştır. Stratejisi çok basittir: query'nin bölümlerini çözümlemeyi, aynı türdeki tüm nesnelerin tek bir query'de birlikte çözümlenebildiği sonraki bir aşamaya erteleyin. "Toplu işleme" (batching) olarak adlandırılan bu strateji, "n+1" sorununu etkili biçimde çözer.

Ayrıca DataLoader, nesneleri aldıktan sonra önbelleğe alır; böylece sonraki bir query zaten yüklenen bir nesneyi yüklemesi gerekirse, çalıştırmayı atlayabilir ve nesneyi önbellekten alabilir. "Önbellekleme" (caching) olarak adlandırılan bu strateji, çoğunlukla "toplu işleme" üzerine bir optimizasyondur.

"Toplu işleme/ertelenmiş" çözümüyle ilgili sorunlar

Teknik açıdan bakıldığında, "toplu işleme" veya "ertelenmiş" stratejisiyle herhangi bir sorun yoktur: sadece çalışır.

(Bundan böyle stratejiye yalnızca "ertelenmiş" diyelim.)

Ancak sorun şudur: bu strateji sonradan akla gelmiş bir çözümdür. Geliştirici önce sunucuyu uygulayabilir ve ardından queries'i çözümlemenin ne kadar yavaş olduğunu fark edince erteleme mekanizmasını tanıtmaya karar verebilir. Bu nedenle, resolver'ları uygulamak bazı sahte adımları içerebilir ve geliştirme sürecine sürtüşme ekleyebilir. Ayrıca geliştirici "ertelenmiş" mekanizmanın nasıl çalıştığını anlamak zorunda olduğundan, uygulaması gereğinden daha karmaşık hale gelir.

Bu sorun stratejinin kendisinde değil, GraphQL sunucusunun bu işlevselliği eklenti olarak sunmasında yatar; oysa onsuz queries o kadar yavaş olabilir ki GraphQL neredeyse kullanılamaz hale gelebilir.

Bu sorunun çözümü ise açıktır: "ertelenmiş" strateji bir eklenti olmamalı, GraphQL sunucusunun kendisine entegre edilmelidir. "Normal" ve "ertelenmiş" olmak üzere 2 query çalıştırma stratejisine sahip olmak yerine yalnızca 1 tane olmalıdır: "ertelenmiş". Ve GraphQL sunucusu, geliştirici resolver'ı "normal" şekilde uygulasa bile "ertelenmiş" mekanizmayı çalıştırmalıdır (başka bir deyişle, ekstra karmaşıklıkla geliştirici değil GraphQL sunucusu ilgilenir).

İşte tam olarak Gato GraphQL'in yaptığı budur.

"Ertelenmiş"i GraphQL sunucusu tarafından çalıştırılan tek strateji haline getirme

Çoğu GraphQL sunucusundaki sorun şudur: nesne türlerini (object, union ve interface) nesneler olarak çözümleme sorumluluğu, bu görevi veri yükleme motoruna devretmek yerine üst düğümü işlerken resolver'ların kendisi tarafından üstlenilir (örneğin: films => directors).

Gato GraphQL bu sorumluluğu resolver'dan sunucunun veri yükleme motoruna şu şekilde aktarır:

  1. Resolver'lar, üst ve alt düğümler arasındaki bir ilişkiyi çözümlerken nesneler yerine ID'ler döndürür
  2. Belirli bir türdeki ID'lerin listesi verildiğinde, bir DataLoader varlığı o türdeki karşılık gelen nesneleri alır
  3. Sunucunun veri yükleme motoru bu 2 parça arasındaki yapıştırıcıdır: önce resolver'lardan nesne ID'lerini alır ve ilişki için iç içe query'yi çalıştırmadan hemen önce (bu noktada belirli tür için çözümlenmesi gereken tüm ID'leri biriktirmiş olacaktır), DataLoader aracılığıyla bu ID'lerin nesnelerini alır (tüm ID'leri tek bir query'ye verimli şekilde dahil edebilir).

Bu yaklaşım şu şekilde özetlenebilir: "Nesnelerle değil, ID'lerle çalışın."

Bu yeni yaklaşımı görselleştirmek için daha önceki aynı örneği kullanalım. Aşağıdaki query, yönetmenler ve filmlerinin listesini alır:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

Her yönetmenden alınacak 2 alana dikkat edin: name ve films; bunların şu anda nasıl farklı olduğuna bakın:

name alanı skaler türde. Hemen çözümlenebilir; çünkü Director türündeki nesnenin, yönetmenin adını içeren string türünde name adlı bir özelliği içerdiğini bekleyebiliriz. Dolayısıyla Director nesnesine sahip olduğumuzda, bu özelliği çözümlemek için ekstra bir query çalıştırmaya gerek yoktur.

films alanı ise bir nesne türü listesidir. Normalde hemen çözümlenemez; çünkü henüz 1 veya daha fazla ekstra query aracılığıyla veritabanından alınması gereken Film türündeki nesnelerin bir listesine başvurur. Dolayısıyla geliştirici, bunun için "ertelenmiş" mekanizmayı uygulaması gerekir.

Şimdi farklı davranışı ele alalım ve films alanının nesneler listesi yerine ID'ler listesi olarak çözümlenmesini sağlayalım. Director nesnesinin, ID'nin string olarak temsil edildiği varsayımıyla, tüm filmlerinin ID'lerini içeren filmIDs adlı dizi of string türünde bir özellik içerdiğini bekleyebileceğimizden, bu alan da "ertelenmiş" mekanizmayı uygulamaya gerek kalmadan hemen çözümlenebilir.

Son olarak, ID'ye ek olarak, resolver'ın fazladan bir bilgi vermesi gerekir: beklenen nesnenin türü (örneğimizde [(Film, 2), (Film, 5), (Film, 9)] olabilir). Bu bilgi dahilidir; motora iletilir ve query'nin yanıtında çıktı olarak verilmesi gerekmez.

Uyarlanmış yaklaşımı kodda uygulama

Gato GraphQL'in bu yaklaşımı PHP kodunda nasıl uyguladığını görelim. Aşağıdaki kod, farklı resolver'ları göstermektedir (netlik amacıyla aşağıdaki tüm kod düzenlenmiştir).

FieldResolvers

FieldResolvers, belirli bir türdeki bir nesneyi alır ve alanlarını çözümler. İlişkiler için, çözümledikleri nesnenin türünü de belirtmeleri gerekir. Bu onların sözleşmesidir:

interface FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = []);
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}

Uygulaması şu şekilde görünür:

class PostFieldResolver implements FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = [])
  {
    $post = $object;
    switch ($field) {
      case 'title':
        return $post->title;
      case 'author':
        return $post->authorID; // This is an ID, not an object!
    }
 
    return null;
  }
 
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string
  {
    switch ($field) {
      case 'author':
        return UserTypeResolver::class;
    }
 
    return null;
  }
}

Promise'ler/ertelenmiş nesnelerle ilgilenen mantığın kaldırılmasıyla author alanını çözümleyen kodun nasıl çok basit ve özlü hale geldiğine dikkat edin.

TypeResolvers

TypeResolvers, belirli bir türle ilgilenen nesnelerdir: türün adını ve diğerlerinin yanı sıra hangi TypeDataLoader'ın o türün nesnelerini yüklediğini bilirler.

Veri yükleme motoru, alanları çözümlerken belirli bir TypeResolver sınıfından ID'ler alacaktır. Ardından, bu ID'ler için nesneleri alırken, veri yükleme motoru, TypeResolver'a bu nesneleri yüklemek için hangi TypeDataLoader nesnesini kullanacağını soracaktır.

Sözleşmeleri şu şekilde tanımlanmıştır:

interface TypeResolverInterface
{
  public function getTypeName(): string;
  public function getTypeDataLoaderClass(): string;
}

Örneğimizde UserTypeResolver sınıfı, User türünün verilerinin UserTypeDataLoader sınıfı aracılığıyla yüklenmesi gerektiğini tanımlar:

class UserTypeResolver implements TypeResolverInterface
{
  public function getTypeName(): string
  {
    return 'User';
  }
 
  public function getTypeDataLoaderClass(): string
  {
    return UserTypeDataLoader::class;
  }
}

TypeDataLoaders

TypeDataLoaders, belirli bir türdeki ID'lerin listesini alır ve o türdeki karşılık gelen nesneleri döndürür. Bu onların sözleşmesidir:

interface TypeDataLoaderInterface
{
  public function getObjects(array $ids): array;
}

Kullanıcıların alınması şu şekilde yapılır:

class UserTypeDataLoader implements TypeDataLoaderInterface
{
  public function getObjects(array $ids): array
  {
    $userAPI = UserAPIFacade::getInstance();
    return $userAPI->getUsers($ids);
  }
}

(Gerçekten) büyük bir query çalıştırma

Bu stratejinin işe yaradığını test edelim. Gato GraphQL'deki GraphiQL istemcisine gidin ve aşağıdaki query'yi çalıştırın; bu query 10 seviye derinliğinde bir grafik içermektedir (posts => author => posts => tags => posts => comments => author => posts => comments => author) ve "n+1 sorunu" yaşansaydı makul bir sürede çözümlenemezdi.

query {
  posts(pagination:{ limit:10 }) {
    excerpt
    title
    url
    author {
      name
      url
      posts(pagination:{ limit:10 }) {
        title
        tags(pagination:{ limit:10 }) {
          slug
          url
          posts(pagination:{ limit:10 }) {
            title
            comments(pagination:{ limit:10 }) {
              content
              date
              author {
                name
                posts(pagination:{ limit:10 }) {
                  title
                  url
                  comments(pagination:{ limit:10 }) {
                    content
                    date
                    author {
                      name
                      username
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Sonuçlarda aşağı kaydırdığımızda yanıtın ne kadar büyük olduğunu, kaç varlık içerdiğini ve kaç seviye aldığını göreceğiz; bununla birlikte herhangi bir zorluk yaşanmadan hızla çalıştırıldı.