Arsitektur
ArsitekturMengatasi "masalah n+1"

Mengatasi "masalah n+1"

Mari kita pelajari bagaimana Gato GraphQL sepenuhnya menghindari "masalah n+1" berkat desain arsitekturalnya.

Apa itu "masalah n+1"

"Masalah n+1" pada dasarnya berarti bahwa jumlah query yang dieksekusi terhadap database bisa sebesar jumlah node dalam graf.

Apa maksudnya? Mari kita lihat dengan sebuah contoh: katakanlah kita ingin mengambil daftar sutradara, dan untuk masing-masing sutradara, film-filmnya, melalui query berikut:

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

Untuk menjadi efisien, kita berharap hanya mengeksekusi 2 query untuk mengambil data dari database: 1 untuk mengambil data sutradara, dan 1 untuk mengambil data semua film dari semua sutradara.

Namun, untuk memenuhi query ini, GraphQL perlu mengeksekusi query "n+1" terhadap database: 1 pertama untuk mengambil daftar N sutradara (10 dalam kasus ini) dan kemudian, untuk setiap N sutradara, 1 query untuk mengambil daftar filmnya. Dalam kasus kita, kita harus mengeksekusi 1+10=11 query.

Masalah ini muncul karena resolver GraphQL hanya menangani 1 objek pada satu waktu, bukan semua objek dari jenis yang sama secara bersamaan. Dalam kasus kita, resolver yang menangani objek dari tipe Query (yang merupakan tipe root) akan dipanggil sekali untuk mendapatkan daftar semua objek Director dan kemudian, resolver yang menangani tipe Director akan dipanggil sekali untuk setiap objek Director, untuk mengambil daftar filmnya.

Dengan kata lain: resolver GraphQL melihat pohon, bukan hutan.

Masalah ini sebenarnya lebih buruk dari yang terlihat pada awalnya, karena jumlah node dalam sebuah graf tumbuh secara eksponensial sesuai dengan jumlah level graf tersebut. Maka, nama "n+1" hanya valid untuk graf sedalam 2 level. Untuk graf sedalam 3 level, seharusnya disebut masalah "N2+n+1"! Dan seterusnya...

Misalnya, mengikuti contoh kita di atas, mari kita tambahkan juga daftar aktor/aktris dari setiap film ke dalam query, seperti ini:

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

Maka, query yang dieksekusi terhadap database adalah: 1 pertama untuk mengambil daftar 10 sutradara, kemudian 1 query untuk mengambil daftar film setiap sutradara untuk masing-masing dari 10 sutradara, dan akhirnya 1 query untuk mengambil setiap daftar aktor/aktris untuk masing-masing dari 10 film untuk masing-masing dari 10 sutradara. Ini memberikan total 1+10+100=111 query.

Setelah menyadari perilaku ini, "masalah n+1" dapat dengan mudah dianggap sebagai hambatan performa terbesar GraphQL: jika dibiarkan, melakukan query pada graf beberapa level lebih dalam bisa menjadi sangat lambat, sehingga efektif membuat GraphQL hampir tidak berguna.

Solusi umum untuk "masalah n+1"

Solusi standar untuk "masalah n+1" pertama kali disediakan oleh utilitas DataLoader. Strateginya sangat sederhana: menunda penyelesaian segmen-segmen query hingga tahap berikutnya, di mana semua objek dari jenis yang sama dapat diselesaikan sekaligus, dalam satu query. Strategi ini, yang disebut "batching", secara efektif memecahkan masalah "n+1".

Selain itu, DataLoader men-cache objek setelah mengambilnya, sehingga jika query berikutnya perlu memuat objek yang sudah dimuat, ia dapat melewati eksekusi dan mengambil objek dari cache. Strategi ini, yang disebut "caching", sebagian besar merupakan optimasi di atas "batching".

Masalah dengan solusi "batching/deferred"

Secara teknis, tidak ada masalah sama sekali dengan strategi "batching" atau "deferred": ia hanya bekerja.

(Mulai sekarang, mari kita sebut strateginya sebagai "deferred" saja.)

Namun, masalahnya adalah bahwa strategi ini merupakan pemikiran belakangan: developer mungkin pertama kali mengimplementasikan server dan kemudian, setelah menyadari betapa lambatnya menyelesaikan query, akan memutuskan untuk memperkenalkan mekanisme deferring. Oleh karena itu, mengimplementasikan resolver mungkin melibatkan beberapa langkah yang tidak perlu, menambah gesekan dalam proses pengembangan. Selain itu, karena developer harus memahami cara kerja mekanisme "deferred", implementasinya menjadi lebih kompleks dari yang seharusnya.

Masalah ini bukan terletak pada strategi itu sendiri, melainkan pada server GraphQL yang menawarkan fungsionalitas ini sebagai tambahan, meskipun tanpanya, melakukan query bisa sangat lambat sehingga membuat GraphQL hampir tidak berguna.

Solusi untuk masalah ini, oleh karena itu, adalah mudah: strategi "deferred" seharusnya bukan tambahan tetapi sudah tertanam dalam server GraphQL itu sendiri. Alih-alih memiliki 2 strategi eksekusi query, "normal" dan "deferred", seharusnya hanya ada 1, "deferred". Dan server GraphQL harus mengeksekusi mekanisme "deferred" meskipun developer mengimplementasikan resolver dengan cara "normal" (dengan kata lain, server GraphQL yang menangani kompleksitas tambahan, bukan developer).

Dan itulah yang dilakukan oleh Gato GraphQL.

Menjadikan "deferred" sebagai satu-satunya strategi yang dieksekusi oleh server GraphQL

Masalah dengan kebanyakan server GraphQL adalah bahwa tanggung jawab untuk menyelesaikan tipe objek (object, union dan interface) sebagai objek dilakukan oleh resolver itu sendiri ketika memproses node induk (misalnya: films => directors), alih-alih mendelegasikan tugas ini ke mesin pemuatan data.

Gato GraphQL memindahkan tanggung jawab ini dari resolver ke mesin pemuatan data server, seperti ini:

  1. Resolver mengembalikan ID, bukan objek, ketika menyelesaikan hubungan antara node induk dan anak
  2. Diberikan daftar ID dari tipe tertentu, entitas DataLoader mendapatkan objek yang sesuai dari tipe tersebut
  3. Mesin pemuatan data server adalah lem antara 2 bagian ini: ia pertama kali mendapatkan ID objek dari resolver dan, tepat sebelum mengeksekusi query bersarang untuk hubungan tersebut (pada saat itu ia sudah mengumpulkan semua ID yang harus diselesaikan untuk tipe tertentu), ia mengambil objek untuk ID-ID tersebut melalui DataLoader (yang dapat secara efisien mencakup semua ID dalam satu query).

Pendekatan ini dapat diringkas sebagai: "Bekerja dengan ID, bukan dengan objek".

Mari kita gunakan contoh yang sama dari sebelumnya untuk memvisualisasikan pendekatan baru ini. Query di bawah mengambil daftar sutradara dan film mereka:

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

Perhatikan 2 field yang akan diambil dari setiap sutradara, name dan films, dan bagaimana keduanya saat ini berbeda:

Field name bertipe scalar. Ini dapat segera diselesaikan, karena kita bisa berharap objek bertipe Director berisi properti bertipe string bernama name, yang berisi nama sutradara. Oleh karena itu, setelah kita memiliki objek Director, tidak perlu mengeksekusi query tambahan untuk menyelesaikan properti ini.

Field films, sebaliknya, adalah daftar dari tipe objek. Biasanya tidak dapat langsung diselesaikan, karena merujuk pada daftar objek bertipe Film, yang masih harus diambil dari database melalui 1 atau lebih query tambahan. Oleh karena itu, developer perlu mengimplementasikan mekanisme "deferred" untuknya.

Sekarang, mari kita pertimbangkan perilaku yang berbeda, dan biarkan field films diselesaikan sebagai daftar ID (alih-alih daftar objek). Karena kita bisa berharap objek Director berisi properti bernama filmIDs yang berisi ID semua filmnya, bertipe array of string (dengan asumsi bahwa ID direpresentasikan sebagai string), maka field ini juga dapat diselesaikan segera, tanpa harus mengimplementasikan mekanisme "deferred".

Terakhir, selain ID, resolver harus memberikan informasi tambahan: tipe objek yang diharapkan (dalam contoh kita, bisa berupa [(Film, 2), (Film, 5), (Film, 9)]). Namun, informasi ini bersifat internal, diteruskan ke mesin, dan tidak perlu disertakan dalam respons query.

Mengimplementasikan pendekatan yang disesuaikan dalam kode

Mari kita lihat bagaimana Gato GraphQL mengimplementasikan pendekatan ini dalam kode PHP. Kode di bawah mendemonstrasikan berbagai resolver (untuk tujuan kejelasan, semua kode di bawah telah diedit).

FieldResolvers

FieldResolvers menerima objek dari tipe tertentu, dan menyelesaikan field-nya. Untuk hubungan, ia juga harus menunjukkan tipe objek yang diselesaikannya. Ini adalah kontraknya:

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

Implementasinya terlihat seperti ini:

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;
  }
}

Perhatikan bagaimana, dengan menghapus logika yang menangani promises/objek deferred, kode yang menyelesaikan field author menjadi sangat sederhana dan ringkas.

TypeResolvers

TypeResolvers adalah objek yang menangani tipe tertentu: mereka mengetahui nama tipe dan TypeDataLoader mana yang memuat objek dari tipenya, di antara hal-hal lainnya.

Mesin pemuatan data, saat menyelesaikan field, akan diberikan ID dari kelas TypeResolver tertentu. Kemudian, saat mengambil objek untuk ID tersebut, mesin pemuatan data akan menanyakan kepada TypeResolver objek TypeDataLoader mana yang harus digunakan untuk memuat objek-objek tersebut.

Kontraknya didefinisikan seperti ini:

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

Dalam contoh kita, kelas UserTypeResolver mendefinisikan bahwa tipe User harus memuat datanya melalui kelas UserTypeDataLoader:

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

TypeDataLoaders

TypeDataLoaders menerima daftar ID dari tipe tertentu, dan mengembalikan objek yang sesuai dari tipe tersebut. Ini adalah kontraknya:

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

Mengambil pengguna dilakukan seperti ini:

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

Mengeksekusi query yang (benar-benar) besar

Mari kita uji bahwa strategi ini berhasil. Buka klien GraphiQL di Gato GraphQL, dan eksekusi query di bawah, yang melibatkan graf sedalam 10 level (posts => author => posts => tags => posts => comments => author => posts => comments => author) dan tidak dapat diselesaikan dalam waktu yang wajar jika "masalah n+1" terjadi.

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
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Dengan menggulir ke bawah pada hasilnya, kita akan melihat betapa besarnya respons tersebut, berapa banyak entitas yang terlibat, dan berapa banyak level yang diambil, namun tetap dieksekusi dengan cepat, tanpa kesulitan apapun.