Ekstraksi Penagihan: Kisah Pengoptimalan API Internal GraphQL

Diterbitkan: 2022-03-11

Salah satu prioritas utama untuk tim teknik Toptal adalah migrasi menuju arsitektur berbasis layanan. Elemen penting dari inisiatif ini adalah Billing Extraction , sebuah proyek di mana kami mengisolasi fungsionalitas penagihan dari platform Toptal untuk menerapkannya sebagai layanan terpisah.

Selama beberapa bulan terakhir, kami mengekstrak bagian pertama dari fungsi tersebut. Untuk mengintegrasikan penagihan dengan layanan lain, kami menggunakan API asinkron (berbasis Kafka) dan API sinkron (berbasis HTTP).

Artikel ini adalah catatan upaya kami untuk mengoptimalkan dan menstabilkan API sinkron.

Pendekatan Inkremental

Ini adalah tahap pertama dari inisiatif kami. Dalam perjalanan kami menuju ekstraksi penagihan penuh, kami berusaha untuk bekerja secara bertahap memberikan perubahan kecil dan aman pada produksi. (Lihat slide dari pembicaraan luar biasa tentang aspek lain dari proyek ini: ekstraksi tambahan mesin dari aplikasi Rails.)

Titik awalnya adalah platform Toptal, aplikasi Ruby on Rails monolitik. Kami mulai dengan mengidentifikasi sambungan antara penagihan dan platform Toptal di tingkat data. Pendekatan pertama adalah mengganti relasi Active Record (AR) dengan pemanggilan metode biasa. Selanjutnya, kami perlu menerapkan panggilan REST ke layanan penagihan yang mengambil data yang dikembalikan oleh metode tersebut.

Kami menerapkan layanan penagihan kecil yang mengakses database yang sama dengan platform. Kami dapat menanyakan penagihan baik menggunakan HTTP API atau dengan panggilan langsung ke database. Pendekatan ini memungkinkan kami untuk menerapkan fallback yang aman; jika permintaan HTTP gagal karena alasan apa pun (implementasi salah, masalah kinerja, masalah penerapan), kami menggunakan panggilan langsung dan mengembalikan hasil yang benar ke pemanggil.

Untuk membuat transisi aman dan lancar, kami menggunakan tanda fitur untuk beralih antara HTTP dan panggilan langsung. Sayangnya, upaya pertama yang diterapkan dengan REST terbukti sangat lambat. Cukup mengganti hubungan AR dengan permintaan jarak jauh menyebabkan kerusakan saat HTTP diaktifkan. Meskipun kami mengaktifkannya hanya untuk persentase panggilan yang relatif kecil, masalah tetap ada.

Kami tahu kami membutuhkan pendekatan yang sangat berbeda.

API Internal Penagihan (alias B2B)

Kami memutuskan untuk mengganti REST dengan GraphQL (GQL) untuk mendapatkan lebih banyak fleksibilitas di sisi klien. Kami ingin membuat keputusan berdasarkan data selama transisi ini untuk dapat memprediksi hasil kali ini.

Untuk melakukan itu, kami melengkapi setiap permintaan dari platform Toptal (monolit) ke penagihan dan mencatat informasi terperinci: waktu respons, parameter, kesalahan, dan bahkan pelacakan tumpukan (untuk memahami bagian mana dari platform yang menggunakan penagihan). Ini memungkinkan kami mendeteksi hotspot — tempat dalam kode yang mengirim banyak permintaan atau yang menyebabkan respons lambat. Kemudian, dengan stacktrace dan parameter , kami dapat mereproduksi masalah secara lokal dan memiliki umpan balik singkat untuk banyak perbaikan.

Untuk menghindari kejutan buruk pada produksi, kami menambahkan level lain dari flag fitur. Kami memiliki satu flag per metode di API untuk berpindah dari REST ke GraphQL. Kami mengaktifkan HTTP secara bertahap dan mengamati jika "sesuatu yang buruk" muncul di log.

Dalam kebanyakan kasus, "sesuatu yang buruk" adalah waktu respons yang lama (multi-detik), 429 Too Many Requests , atau 502 Bad Gateway . Kami menggunakan beberapa pola untuk memperbaiki masalah ini: memuat dan menyimpan data dalam cache, membatasi data yang diambil dari server, menambahkan jitter, dan membatasi kecepatan.

Pramuat dan Caching

Masalah pertama yang kami perhatikan adalah membanjirnya permintaan yang dikirim dari satu kelas/tampilan, mirip dengan masalah N+1 di SQL.

Pramuat Rekaman Aktif tidak berfungsi melewati batas layanan dan, sebagai hasilnya, kami memiliki satu halaman yang mengirimkan ~1.000 permintaan ke penagihan dengan setiap pemuatan ulang. Seribu permintaan dari satu halaman! Situasi di beberapa pekerjaan latar belakang tidak jauh lebih baik. Kami lebih suka membuat lusinan permintaan daripada ribuan.

Salah satu pekerjaan latar belakang adalah mengambil data pekerjaan (sebut saja model ini Product ) dan memeriksa apakah suatu produk harus ditandai sebagai tidak aktif berdasarkan data penagihan (untuk contoh ini, kita akan memanggil model BillingRecord ). Meskipun produk diambil dalam batch, data penagihan diminta setiap kali dibutuhkan. Setiap produk memerlukan catatan penagihan, jadi memproses setiap produk menyebabkan permintaan ke layanan penagihan untuk mengambilnya. Itu berarti satu permintaan per produk dan menghasilkan sekitar 1.000 permintaan yang dikirim dari satu eksekusi pekerjaan.

Untuk memperbaikinya, kami menambahkan pramuat batch catatan penagihan. Untuk setiap batch produk yang diambil dari database, kami meminta catatan penagihan satu kali dan kemudian menetapkannya ke masing-masing produk:

 # fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end end

Dengan batch 100 dan satu permintaan ke layanan penagihan per batch, kami beralih dari ~1.000 permintaan per pekerjaan menjadi ~10.

Bergabung di sisi klien

Permintaan batching dan catatan penagihan caching bekerja dengan baik ketika kami memiliki koleksi produk dan kami membutuhkan catatan penagihan mereka. Tapi bagaimana dengan sebaliknya: jika kita mengambil catatan penagihan dan kemudian mencoba menggunakan produk masing-masing, diambil dari database platform?

Seperti yang diharapkan, ini menyebabkan masalah N+1 lainnya, kali ini di sisi platform. Saat kami menggunakan produk untuk mengumpulkan N catatan penagihan, kami melakukan kueri database N.

Solusinya adalah mengambil semua produk yang dibutuhkan sekaligus, menyimpannya sebagai hash yang diindeks oleh ID, dan kemudian menetapkannya ke catatan penagihan masing-masing. Implementasi yang disederhanakan adalah:

 def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end end

Jika Anda berpikir bahwa ini mirip dengan hash join, Anda tidak sendirian.

Penyaringan dan Kekurangan Sisi Server

Kami melawan lonjakan permintaan dan masalah N+1 terburuk di sisi platform. Kami masih memiliki respons yang lambat. Kami mengidentifikasi bahwa mereka disebabkan oleh memuat terlalu banyak data ke platform dan memfilternya di sana (pemfilteran sisi klien). Memuat data ke memori, membuat serial, mengirimkannya melalui jaringan, dan menghapus serial hanya untuk membuang sebagian besar adalah pemborosan yang sangat besar. Itu nyaman selama implementasi karena kami memiliki titik akhir yang umum dan dapat digunakan kembali. Selama operasi, itu terbukti tidak dapat digunakan. Kami membutuhkan sesuatu yang lebih spesifik.

Kami mengatasi masalah ini dengan menambahkan argumen pemfilteran ke GraphQL. Pendekatan kami mirip dengan pengoptimalan terkenal yang terdiri dari pemindahan pemfilteran dari tingkat aplikasi ke kueri DB ( find_all vs. where di Rails). Di dunia database, pendekatan ini jelas dan tersedia sebagai WHERE dalam kueri SELECT . Dalam hal ini, kami harus mengimplementasikan penanganan kueri sendiri (dalam Penagihan).

Kami menerapkan filter dan menunggu untuk melihat peningkatan kinerja. Sebaliknya, kami melihat 502 kesalahan pada platform (dan pengguna kami juga melihatnya). Tidak baik. Tidak bagus sama sekali!

Mengapa itu terjadi? Perubahan itu seharusnya meningkatkan waktu respons, bukan merusak layanan. Kami telah memperkenalkan bug halus secara tidak sengaja. Kami mempertahankan kedua versi API (GQL dan REST) ​​di sisi klien. Kami beralih secara bertahap dengan flag fitur. Versi malang pertama yang kami terapkan memperkenalkan regresi di cabang REST lama. Kami memfokuskan pengujian kami pada cabang GQL, jadi kami melewatkan masalah kinerja di REST. Hal yang dipelajari: Jika parameter pencarian hilang, kembalikan koleksi kosong, bukan semua yang Anda miliki di database Anda.

Lihatlah data NewRelic untuk Penagihan. Kami menerapkan perubahan dengan pemfilteran sisi server selama lalu lintas sepi (kami menonaktifkan lalu lintas penagihan setelah mengalami masalah platform). Anda dapat melihat bahwa respons lebih cepat dan lebih dapat diprediksi setelah penerapan.

Gambar: Data NewRelic untuk layanan penagihan. Respons lebih cepat setelah penerapan.

Tidak terlalu sulit untuk menambahkan filter ke skema GQL. Situasi di mana GraphQL benar-benar bersinar adalah kasus di mana kami mengambil terlalu banyak bidang, tidak terlalu banyak objek. Dengan REST, kami mengirimkan semua data yang mungkin diperlukan. Membuat titik akhir generik memaksa kami untuk mengemasnya dengan semua data dan asosiasi yang digunakan di platform.

Dengan GQL, kami dapat memilih bidang. Alih-alih mengambil 20+ bidang yang memerlukan pemuatan beberapa tabel database, kami memilih hanya tiga hingga lima bidang yang diperlukan. Itu memungkinkan kami untuk menghapus lonjakan penggunaan penagihan secara tiba-tiba selama penerapan platform karena beberapa kueri tersebut digunakan oleh pekerjaan pengindeksan ulang pencarian elastis yang dijalankan selama penerapan. Sebagai efek samping positif, itu membuat penyebaran lebih cepat dan lebih dapat diandalkan.

Permintaan Tercepat Adalah Yang Tidak Anda Buat

Kami membatasi jumlah objek yang diambil dan jumlah data yang dikemas ke dalam setiap objek. Apa lagi yang bisa kami lakukan? Mungkin tidak mengambil data sama sekali?

Kami melihat area lain dengan ruang untuk perbaikan: Kami sering menggunakan tanggal pembuatan catatan penagihan terakhir di platform dan setiap kali, kami menelepon penagihan untuk mengambilnya. Kami memutuskan bahwa alih-alih mengambilnya secara sinkron setiap kali dibutuhkan, kami dapat menyimpannya dalam cache berdasarkan peristiwa yang dikirim dari penagihan.

Kami merencanakan ke depan, menyiapkan tugas (empat hingga lima di antaranya), dan mulai bekerja untuk menyelesaikannya sesegera mungkin, karena permintaan tersebut menghasilkan beban yang signifikan. Kami memiliki dua minggu pekerjaan di depan kami.

Untungnya, tidak lama setelah kami mulai, kami melihat masalahnya lagi dan menyadari bahwa kami dapat menggunakan data yang sudah ada di platform tetapi dalam bentuk yang berbeda. Alih-alih menambahkan tabel baru ke cache data dari Kafka, kami menghabiskan beberapa hari membandingkan data dari penagihan dan platform. Kami juga berkonsultasi dengan pakar domain apakah kami dapat menggunakan data platform.

Akhirnya, kami mengganti panggilan jarak jauh dengan kueri DB. Itu adalah kemenangan besar dari sudut pandang kinerja dan beban kerja. Kami juga menghemat lebih dari seminggu waktu pengembangan.

Gambar: Performa dan beban kerja dengan kueri DB alih-alih panggilan jarak jauh.

Mendistribusikan Beban

Kami menerapkan dan menerapkan pengoptimalan tersebut satu per satu, namun masih ada kasus ketika penagihan ditanggapi dengan 429 Too Many Requests . Kami dapat meningkatkan batas permintaan di Nginx, tetapi kami ingin memahami masalah ini dengan lebih baik, karena ini merupakan petunjuk bahwa komunikasi tidak berjalan seperti yang diharapkan. Seperti yang mungkin Anda ingat, kami dapat mengatasi kesalahan tersebut pada produksi, karena kesalahan tersebut tidak terlihat oleh pengguna akhir (karena fallback ke panggilan langsung).

Kesalahan terjadi setiap hari Minggu, ketika platform menjadwalkan pengingat untuk anggota jaringan bakat mengenai timesheets yang terlambat. Untuk mengirimkan pengingat, pekerjaan mengambil data penagihan untuk produk yang relevan, yang mencakup ribuan catatan. Hal pertama yang kami lakukan untuk mengoptimalkannya adalah mengelompokkan dan memuat data penagihan sebelumnya, dan hanya mengambil bidang yang diperlukan. Keduanya adalah trik yang terkenal, jadi kami tidak akan membahasnya secara mendetail di sini.

Kami mengerahkan dan menunggu hari Minggu berikutnya. Kami yakin bahwa kami telah memperbaiki masalahnya. Namun, pada hari Minggu, kesalahan itu muncul kembali.

Layanan penagihan dipanggil tidak hanya selama penjadwalan tetapi juga ketika pengingat dikirim ke anggota jaringan. Pengingat dikirim dalam pekerjaan latar belakang yang terpisah (menggunakan Sidekiq), jadi pramuat tidak mungkin dilakukan. Awalnya kami mengira tidak akan ada masalah karena tidak semua produk membutuhkan reminder dan karena reminder dikirim sekaligus. Pengingat dijadwalkan pukul 17.00 di zona waktu anggota jaringan. Kami melewatkan detail penting, meskipun: Anggota kami tidak didistribusikan di seluruh zona waktu secara seragam.

Kami menjadwalkan pengingat untuk ribuan anggota jaringan, sekitar 25% di antaranya tinggal di satu zona waktu. Sekitar 15% tinggal di zona waktu terpadat kedua. Saat jam menunjukkan pukul 5 sore di zona waktu itu, kami harus mengirim ratusan pengingat sekaligus. Itu berarti ledakan ratusan permintaan ke layanan penagihan, yang lebih dari yang bisa ditangani oleh layanan tersebut.

Pramuat data penagihan tidak dapat dilakukan karena pengingat dijadwalkan dalam tugas independen. Kami tidak dapat mengambil lebih sedikit bidang dari penagihan, karena kami telah mengoptimalkan jumlah tersebut. Memindahkan anggota jaringan ke zona waktu yang kurang padat juga tidak mungkin. Jadi apa yang kami lakukan? Kami memindahkan pengingat, hanya sedikit.

Kami menambahkan jitter ke waktu ketika pengingat dijadwalkan untuk menghindari situasi di mana semua pengingat akan dikirim pada waktu yang sama persis. Alih-alih menjadwalkan tepat pukul 17.00, kami menjadwalkannya dalam rentang dua menit, antara 17.59 dan 18.01.

Kami menyebarkan layanan dan menunggu hari Minggu berikutnya, yakin bahwa kami akhirnya menyelesaikan masalah. Sayangnya, pada hari Minggu, kesalahan muncul lagi.

Kami bingung. Menurut perhitungan kami, permintaan seharusnya tersebar dalam periode dua menit, yang berarti kami memiliki, paling banyak, dua permintaan per detik. Itu bukan sesuatu yang tidak bisa ditangani oleh layanan. Kami menganalisis log dan waktu permintaan penagihan dan kami menyadari bahwa penerapan jitter kami tidak berfungsi, sehingga permintaan masih muncul dalam kelompok yang ketat.

Gambar: Tingginya jumlah permintaan yang disebabkan oleh implementasi jitter yang tidak memadai.

Apa yang menyebabkan perilaku itu? Begitulah cara Sidekiq mengimplementasikan penjadwalan. Ini polling redis setiap 10-15 detik dan karena itu, tidak dapat memberikan resolusi satu detik. Untuk mencapai distribusi permintaan yang seragam, kami menggunakan Sidekiq::Limiter – kelas yang disediakan oleh Sidekiq Enterprise. Kami menggunakan pembatas jendela yang memungkinkan delapan permintaan untuk jendela satu detik yang bergerak. Kami memilih nilai itu karena kami memiliki batas Nginx 10 permintaan per detik pada penagihan. Kami menyimpan kode jitter karena menyediakan dispersi permintaan berbutir kasar: ini mendistribusikan pekerjaan Sidekiq selama dua menit. Kemudian Sidekiq Limiter digunakan untuk memastikan bahwa setiap kelompok pekerjaan diproses tanpa melanggar ambang batas yang ditentukan.

Sekali lagi, kami menyebarkannya dan menunggu hari Minggu. Kami yakin bahwa kami akhirnya menyelesaikan masalah — dan kami melakukannya. Kesalahan menghilang.

Optimasi API: Nihil Novi Sub Sole

Saya yakin Anda tidak terkejut dengan solusi yang kami gunakan. Batching, pemfilteran sisi server, hanya mengirim bidang yang diperlukan, dan pembatasan kecepatan bukanlah teknik baru. Insinyur perangkat lunak yang berpengalaman tidak diragukan lagi menggunakannya dalam konteks yang berbeda.

Pramuat untuk menghindari N+1? Kami memilikinya di setiap ORM. Hash bergabung? Bahkan MySQL memilikinya sekarang. Kurang menarik? SELECT * vs. SELECT field adalah trik yang diketahui. Menyebarkan beban? Ini juga bukan konsep baru.

Jadi mengapa saya menulis artikel ini? Mengapa kita tidak melakukannya dengan benar dari awal ? Seperti biasa, konteks adalah kuncinya. Banyak dari teknik tersebut tampak familier hanya setelah kami menerapkannya atau hanya ketika kami melihat masalah produksi yang perlu dipecahkan, bukan ketika kami melihat kodenya.

Ada beberapa kemungkinan penjelasan untuk itu. Sebagian besar waktu, kami mencoba melakukan hal paling sederhana yang dapat berhasil untuk menghindari over-engineering. Kami mulai dengan solusi REST yang membosankan dan baru kemudian pindah ke GQL. Kami menerapkan perubahan di balik tanda fitur, memantau bagaimana semuanya berperilaku dengan sebagian kecil dari lalu lintas, dan menerapkan peningkatan berdasarkan data dunia nyata.

Salah satu penemuan kami adalah bahwa penurunan kinerja mudah diabaikan saat refactoring (dan ekstraksi dapat diperlakukan sebagai refactoring yang signifikan). Menambahkan batas yang ketat berarti kami memutuskan ikatan yang ditambahkan untuk mengoptimalkan kode. Itu tidak jelas, sampai kami mengukur kinerja. Terakhir, dalam beberapa kasus, kami tidak dapat mereproduksi lalu lintas produksi di lingkungan pengembangan.

Kami berusaha keras untuk memiliki permukaan kecil dari HTTP API universal dari layanan penagihan. Hasilnya, kami mendapatkan banyak titik akhir/kueri universal yang membawa data yang diperlukan dalam kasus penggunaan yang berbeda. Dan itu berarti dalam banyak kasus penggunaan, sebagian besar data tidak berguna. Ini sedikit tradeoff antara DRY dan YAGNI: Dengan DRY, kami hanya memiliki satu titik akhir/kueri yang mengembalikan catatan penagihan sementara dengan YAGNI, kami berakhir dengan data yang tidak digunakan di titik akhir yang hanya merusak kinerja.

Kami juga melihat tradeoff lain ketika mendiskusikan kegelisahan dengan tim penagihan. Dari sudut pandang klien (platform), setiap permintaan harus mendapat respons saat platform membutuhkannya. Masalah kinerja dan kelebihan server harus disembunyikan di balik abstraksi layanan penagihan. Dari sudut pandang layanan penagihan, kita perlu menemukan cara untuk membuat klien menyadari karakteristik kinerja server untuk menahan beban.

Sekali lagi, tidak ada yang baru atau inovatif di sini. Ini tentang mengidentifikasi pola yang diketahui dalam konteks yang berbeda dan memahami pertukaran yang diperkenalkan oleh perubahan. Kami telah belajar itu dengan cara yang sulit dan kami berharap kami telah menghindarkan Anda dari mengulangi kesalahan kami. Alih-alih mengulangi kesalahan kami, Anda pasti akan membuat kesalahan sendiri dan belajar darinya.

Terima kasih khusus kepada kolega dan rekan tim saya yang berpartisipasi dalam upaya kami:

  • Makar Ermokhin
  • Gabriele Renzi
  • Samuel Vega Caballero
  • Luca Guidi