Tentang N+1: Beberapa Hal yang Harus Kamu Tahu
N+1 query problem bisa bikin API kamu lemot tanpa kamu sadari. Ini cara mendeteksi dan memperbaikinya sebelum jadi masalah di production.
Waktu baca: ~ 3 menit
Suatu ketika, kamu menerima laporan bahwa salah satu API sangat lambat. Saat mengecek APM, rupanya ada salah satu kode baris yang melakukan query di dalam loop.
Inilah N+1 query problem.
Sesuai namanya, N+1 query ini akan melakukan query sebanyak N+1, di mana 1 adalah query pertama yang dilakukan, dan N adalah jumlah query yang dilakukan setelah query pertama selesai.
Misalnya begini: kamu sedang membuat aplikasi toko online. Setiap produk bisa memiliki satu kategori. Kamu perlu menampilkan kategori tersebut di halaman list produk. Kodenya begini:
@products = Product.order(created_at: :desc).limit(50)
@products.each do |product|
puts product.category.name
end
Kode ini akan menghasilkan query sebagai berikut:
SELECT products.* FROM products ORDER BY created_at DESC LIMIT 50
SELECT categories.* FROM categories WHERE product_id = 1
SELECT categories.* FROM categories WHERE product_id = 2
...
SELECT categories.* FROM categories WHERE product_id = 50
Ada 51 query yang dipanggil hanya untuk menampilkan list produk. Ini baru menampilkan kategori. Bagaimana kalau perlu menampilkan relasi lainnya juga? Ratusan query dipanggil!
Sebagai backend developer, issue ini cukup sering kamu temukan. Solusinya sederhana: lakukan preloading/eager loading.
Sederhananya, preloading/eager loading adalah teknik untuk mengambil data relasi di awal (sekaligus) agar aplikasi tidak perlu melakukan query tambahan saat data relasi diakses.
Preloading
Di preloading, untuk mendapatkan nilai dari relasi, query dilakukan sebanyak jumlah relasi.
Untuk kasus toko online ini, jumlah query dari yang tadinya 51 kini turun drastis menjadi 2 saja dengan query sebagai berikut:
SELECT products.* FROM products ORDER BY created_at DESC LIMIT 50
SELECT categories.* FROM categories WHERE product_id IN (1, 2, ..., 50)
Hasil query tersebut kemudian disimpan di dalam sebuah hash map supaya lebih mudah diakses. Jika tanpa ORM, kurang lebih caranya begini:
products = db.execute("SELECT products.* FROM products ORDER BY created_at DESC LIMIT 50")
categories = db.execute("SELECT categories.* FROM categories WHERE product_id IN (?)", products.pluck("id"))
categories_map = {}
categories.each do |category|
categories_map[category["product_id"]] = category
end
product.each do |product|
puts categories_map[product["id"]]["name"]
endDi Ruby on Rails, cukup menambahkan .preload setelah .limit(50) .
@products = Product.order(created_at: :desc).limit(50).preload(:category)
@products.each do |product|
puts product.category.name
end
Eager loading
Berbeda dengan preloading yang menggunakan pendekatan query terpisah, eager loading menggunakan join ke tabel relasi untuk mendapatkan data. Sehingga, jumlah query yang dipanggil hanya 1.
SELECT
products.*,
categories.name AS category_name
FROM products
LEFT OUTER JOIN categories ON categories.product_id = products.id
ORDER BY products.created_at DESC
LIMIT 50
Di Ruby on Rails, cukup menambahkan .eager_load setelah .limit(50) .
@products = Product.order(created_at: :desc).limit(50).eager_load(:category)
@products.each do |product|
puts product.category.name
end
Kapan Tidak Melakukan Eager Loading
Eager loading memang menyelesaikan masalah N+1, tapi kalau sembarangan melakukannya, akan berpengaruh terhadap performa aplikasi juga.
Contoh eager loading sembarangan:
- Eager load relasi yang tidak dipakai. Ibarat makan: ngambil banyak menu tapi nggak habis. Mubazir.
- Eager load relasi yang hanya butuh menampilkan jumlah data atau mengecek ada/tidaknya data. Ini bisa diselesaikan dengan kombinasi left join dan fungsi agregat.
- Eager load relasi yang datanya banyak sekali. Memang isu N+1 query tidak terjadi, tapi join ke tabel yang jumlahnya banyak sekali itu akan berat, tergantung jumlah data. Semakin banyak data di tabel relasi, semakin berat proses joinnya. Kalau sudah begini, lebih baik dibuat API baru yang bisa dipaginasi.
Baik eager loading dan preloading akan menambah jumlah objek yang perlu dialokasikan. Jadi, sebagai programmer, kita perlu melakukannya dengan penuh pertimbangan.
Cara Mendeteksi N+1
Cara mendeteksi N+1 bisa dilakukan di dua lingkungan: pre-production (development, staging) dan production itu sendiri.
Di production, N+1 biasanya dideteksi oleh Application Performance Monitoring. Ini adalah bagian dari observability, yang nanti saya coba bahas di tulisan terpisah.
Di pre-production, N+1 bisa dideteksi melalui automated testing dengan membuat tes pada kode menggunakan seed data yang se-realistis mungkin. Ada beberapa library buatan komunitas yang bisa kamu gunakan untuk melakukan assertion N+1 di tes dengan lebih mudah.
Kalau kamu menemukan pola SQL yang sama dengan pola yang saya jelaskan di awal tulisan ini, maka perbaiki kodemu sesegera mungkin.