Halo, Habr! Saya hadir untuk Anda terjemahan artikel
"Arsitektur GraphQL berkinerja tinggi ke mesin SQL" .
Ini adalah terjemahan dari artikel tentang bagaimana strukturnya terstruktur secara internal dan solusi optimalisasi dan arsitektur apa yang dibawa Hasura - server GraphQL ringan berkinerja tinggi, yang bertindak sebagai lapisan antara aplikasi web Anda dan database PostgreSQL.
Ini memungkinkan Anda untuk menghasilkan skema GraphQL berdasarkan pada database yang ada atau membuat yang baru. Ini mendukung Langganan GraphQL dari kotak berdasarkan pemicu Postgres, kontrol akses dinamis, pembuatan gabungan otomatis, menyelesaikan masalah permintaan N + 1 (batching) dan banyak lagi.
Anda dapat menggunakan batasan kunci asing di PostgreSQL untuk mengambil data hierarkis dalam satu permintaan. Misalnya, Anda dapat menjalankan kueri ini untuk mendapatkan album dan trek yang sesuai (jika kunci asing dibuat di tabel "trek" yang menunjuk ke tabel "album")
{ album (where: {year: {_eq: 2018}}) { title tracks { id title } } }
Seperti yang sudah Anda tebak, Anda dapat meminta data dengan kedalaman apa pun. API ini, dikombinasikan dengan kontrol akses, memungkinkan aplikasi web untuk meminta data dari PostgreSQL tanpa menulis backend mereka sendiri. Ini dirancang untuk memenuhi permintaan secepat mungkin, memiliki bandwidth tinggi, sekaligus menghemat waktu prosesor dan konsumsi memori di server. Kami akan berbicara tentang solusi arsitektur yang memungkinkan kami untuk mencapai ini.
Minta Siklus Hidup
Permintaan yang dikirim ke Hasura melewati tahapan berikut:
- Sesi penerimaan : Permintaan masuk ke gateway, yang memeriksa kunci (jika ada) dan menambahkan berbagai tajuk, misalnya, peran pengidentifikasi dan pengguna.
- Penguraian kueri : Hasura menerima kueri, mengurai header untuk mendapatkan informasi pengguna, membuat GraphQL AST berdasarkan pada badan permintaan.
- Validasi permintaan : Ini memeriksa apakah permintaan secara semantik benar, maka hak akses yang sesuai dengan peran pengguna diterapkan.
- Eksekusi Query : Query dikonversi ke SQL dan dikirim ke Postgres.
- Generasi respons : Hasil kueri SQL diproses dan dikirim ke klien ( gateway dapat menggunakan gzip jika perlu ).
Tujuan
Persyaratannya kira-kira sebagai berikut:
- Stack HTTP harus menambahkan overhead minimum dan dapat menangani banyak permintaan bersamaan untuk throughput tinggi.
- Pembuatan SQL cepat dari permintaan GraphQL.
- Query SQL yang dihasilkan harus efisien untuk Postgres.
- Hasil dari query SQL harus secara efektif dikembalikan dari Postgres.
Pemrosesan permintaan GraphQL
Ada beberapa pendekatan untuk mendapatkan data yang diperlukan untuk permintaan GraphQL:
Penyelesai konvensional
Menjalankan query GraphQL biasanya melibatkan pemanggilan resolver untuk setiap bidang.
Dalam contoh permintaan, kami mendapatkan album yang dirilis pada tahun 2018, dan kemudian untuk masing-masing kami meminta trek yang sesuai dengannya - masalah klasik permintaan N +1. Jumlah kueri tumbuh secara eksponensial dengan meningkatnya kedalaman kueri.
Permintaan yang dibuat oleh Postgres adalah:
SELECT id,title FROM album WHERE year = 2018;
Permintaan ini akan mengembalikan semua album kepada kami. Misalkan jumlah album yang dikembalikan oleh permintaan sama dengan N. Kemudian untuk setiap album kami akan menjalankan permintaan berikut:
SELECT id,title FROM tracks WHERE album_id = <album-id>
Secara total, Anda mendapatkan N + 1 kueri untuk mendapatkan semua data yang diperlukan.
Permintaan batching
Alat-alat seperti
dataloader dirancang untuk memecahkan masalah permintaan N + 1 menggunakan batching. Jumlah kueri SQL untuk data yang disematkan tidak lagi tergantung pada ukuran sampel awal, karena Sekarang itu mempengaruhi jumlah node dalam permintaan GraphQL. Dalam hal ini, 2 permintaan kepada Postgres diperlukan untuk mendapatkan data yang diperlukan:
Kami mendapat album:
SELECT id,title FROM album WHERE year = 2018
Kami mendapatkan trek untuk album yang kami terima di permintaan sebelumnya:
SELECT id, title FROM tracks WHERE album_id IN {the list of album ids}
Secara total, 2 kueri diterima. Kami menghindari mengeksekusi query SQL pada trek untuk setiap album individual, sebagai gantinya, kami menggunakan operator WHERE untuk mendapatkan semua trek yang diperlukan dalam satu permintaan sekaligus.
Bergabung
Dataloader dirancang untuk bekerja dengan sumber data yang berbeda dan tidak memungkinkan pemanfaatan kemampuan yang khusus. Dalam kasus kami, Postgres adalah satu-satunya sumber data dan, seperti semua basis data relasional, Postgres menyediakan kemampuan untuk mengumpulkan data dari beberapa tabel dengan satu permintaan menggunakan operator BERGABUNG. Kami dapat menentukan semua tabel yang diperlukan untuk permintaan GraphQL dan menghasilkan satu permintaan SQL menggunakan GABUNG untuk mendapatkan semua data. Ternyata data yang diperlukan untuk setiap permintaan GraphQL dapat diperoleh dengan menggunakan satu query SQL. Data ini dikonversi sebelum dikirim ke klien.
Permintaan seperti itu:
SELECT album.id as album_id, album.title as album_title, track.id as track_id, track.title as track_title FROM album LEFT OUTER JOIN track ON (album.id = track.album_id) WHERE album.year = 2018
Akan mengembalikan data tersebut kepada kami:
album_id, album_title, track_id, track_title 1, Album1, 1, track1 1, Album1, 2, track2 2, Album2, NULL, NULL
Kemudian akan dikonversi ke JSON dan dikirim ke klien:
[ { "title" : "Album1", "tracks": [ {"id" : 1, "title": "track1"}, {"id" : 2, "title": "track2"} ] }, { "title" : "Album2", "tracks" : [] } ]
Optimalisasi generasi respons
Kami menemukan bahwa sebagian besar waktu dalam pemrosesan query dihabiskan untuk fungsi mengkonversi hasil dari query SQL ke JSON.
Setelah beberapa upaya untuk mengoptimalkan fungsi ini dengan berbagai cara, kami memutuskan untuk mentransfernya ke Postgres. Postgres 9.4 (
dirilis sekitar waktu rilis pertama Hasura ) menambahkan fitur untuk agregasi JSON yang membantu kami melakukan pekerjaan kami. Setelah optimasi ini, pertanyaan SQL mulai terlihat seperti ini:
SELECT json_agg(r.*) FROM ( SELECT album.title as title, json_agg(track.*) as tracks FROM album LEFT OUTER JOIN track ON (album.id = track.album_id) WHERE album.year = 2018 GROUP BY album.id ) r
Hasil kueri ini akan memiliki satu kolom dan satu baris, dan nilai ini akan dikirim ke klien tanpa konversi lebih lanjut. Menurut pengujian kami, pendekatan ini sekitar 3-6 kali lebih cepat daripada fungsi konversi Haskell.
Pernyataan yang disiapkan
Kueri SQL yang dihasilkan bisa sangat besar dan kompleks tergantung pada tingkat bersarang kueri dan kondisi penggunaan. Biasanya, aplikasi web memiliki serangkaian kueri yang berulang kali dieksekusi dengan parameter berbeda. Misalnya, kueri sebelumnya perlu dijalankan untuk 2017, bukan 2018. Pernyataan yang disiapkan paling cocok untuk kasus di mana ada kueri SQL kompleks berulang di mana hanya parameter yang diubah.
Katakanlah kueri ini dijalankan untuk pertama kali:
{ album (where: {year: {_eq: 2018}}) { title tracks { id title } } }
Kami membuat pernyataan yang disiapkan untuk kueri SQL alih-alih menjalankannya:
PREPARE prep_1 AS SELECT json_agg(r.*) FROM ( SELECT album.title as title, json_agg(track.*) as tracks FROM album LEFT OUTER JOIN track ON (album.id = track.album_id) WHERE album.year = $1 GROUP BY album.
Setelah itu kami segera menjalankannya:
EXECUTE prep_1('2018');
Ketika Anda perlu menjalankan permintaan GraphQL untuk 2017, kami cukup memanggil pernyataan yang disiapkan sama dengan argumen yang berbeda:
EXECUTE prep_1('2017');
Ini memberikan sekitar 10-20% peningkatan kecepatan tergantung pada kompleksitas permintaan GraphQL.
Haskell
Haskell berfungsi dengan baik karena beberapa alasan:
Pada akhirnya
Semua optimasi yang disebutkan di atas menghasilkan keuntungan kinerja yang cukup serius:

Faktanya, konsumsi memori yang rendah dan penundaan yang tidak signifikan dibandingkan dengan panggilan langsung ke PostgreSQL memungkinkan dalam kebanyakan kasus untuk mengganti ORM di backend Anda dengan panggilan API GraphQL.
Tolak ukur:Test stand:
- Laptop dengan RAM 8GB dan i7
- Postgres berjalan di komputer yang sama
- wrk , digunakan sebagai alat perbandingan dan untuk berbagai jenis permintaan kami mencoba "memaksimalkan" rps
- Salah satu contoh dari Hasura GraphQL Engine
- Ukuran Koneksi Pool: 50
- Dataset : chinook
Permintaan 1: tracks_media_some query tracks_media_some { tracks (where: {composer: {_eq: "Kurt Cobain"}}){ id name album { id title } media_type { name } }}
- Permintaan per detik: 1375 req / s
- Penundaan: 17,5 ms
- CPU: ~ 30%
- RAM: ~ 30MB (Hasura) + 90MB (Postgres)
Permintaan 2: tracks_media_all query tracks_media_all { tracks { id name media_type { name } }}
- Permintaan per detik: 410 req / s
- Keterlambatan: 59ms
- CPU: ~ 100%
- RAM: ~ 30MB (Hasura) + 130MB (Postgres)
Permintaan 3: album_tracks_genre_some query albums_tracks_genre_some { albums (where: {artist_id: {_eq: 127}}) { id title tracks { id name genre { name } } }}
- Permintaan per detik: 1029 req / s
- Keterlambatan: 24 ms
- CPU: ~ 30%
- RAM: ~ 30MB (Hasura) + 90MB (Postgres)
Permintaan 4: album_tracks_genre_all query albums_tracks_genre_all { albums { id title tracks { id name genre { name } } }
- Permintaan per detik: 328 req / s
- Keterlambatan: 73ms
- CPU: 100%
- RAM: ~ 30MB (Hasura) + 130MB (Postgres)