Antipattern Chatty I/O
Efek kumulatif dari permintaan I/O dalam jumlah besar dapat memiliki dampak signifikan terhadap performa dan responsivitas.
Deskripsi masalah
Panggilan jaringan dan operasi I/O lainnya memang lambat dibandingkan dengan tugas komputasi. Setiap permintaan I/O biasanya memiliki biaya ekstra yang besar, dan efek kumulatif dari berbagai operasi I/O dapat memperlambat sistem. Berikut beberapa penyebab umum dari I/O yang cerewet.
Membaca dan menulis catatan individu ke database sebagai permintaan yang berbeda
Contoh berikut dibaca dari database produk. Ada tiga tabel, Product
, ProductSubcategory
, dan ProductPriceListHistory
. Kode mengambil semua produk dalam subkategori, beserta informasi harga, dengan mengeksekusi serangkaian kueri:
- Mengkueri subkategori dari tabel
ProductSubcategory
. - Mencari semua produk di subkategori tersebut dengan mengkueri tabel
Product
. - Untuk setiap produk, buat kueri data harga dari tabel
ProductPriceListHistory
.
Aplikasi menggunakan Kerangka Kerja Entitas untuk mengkueri database. Anda dapat menemukan sampel lengkap di sini.
public async Task<IHttpActionResult> GetProductsInSubCategoryAsync(int subcategoryId)
{
using (var context = GetContext())
{
// Get product subcategory.
var productSubcategory = await context.ProductSubcategories
.Where(psc => psc.ProductSubcategoryId == subcategoryId)
.FirstOrDefaultAsync();
// Find products in that category.
productSubcategory.Product = await context.Products
.Where(p => subcategoryId == p.ProductSubcategoryId)
.ToListAsync();
// Find price history for each product.
foreach (var prod in productSubcategory.Product)
{
int productId = prod.ProductId;
var productListPriceHistory = await context.ProductListPriceHistory
.Where(pl => pl.ProductId == productId)
.ToListAsync();
prod.ProductListPriceHistory = productListPriceHistory;
}
return Ok(productSubcategory);
}
}
Contoh ini menunjukkan masalah secara eksplisit, tetapi terkadang O/RM dapat menutupi masalah, jika secara implisit mengambil catatan turunan sekaligus. Ini dikenal sebagai "masalah N +1".
Menerapkan satu operasi logis sebagai serangkaian permintaan HTTP
Hal ini sering kali terjadi saat pengembang mencoba untuk mengikuti paradigma berorientasi objek, dan memperlakukan objek jarak jauh seolah-olah objek tersebut adalah objek lokal dalam memori. Hal ini dapat mengakibatkan terlalu banyak round-trip jaringan. Misalnya, API web berikut mengekspos setiap properti objek User
melalui metode HTTP GET individu.
public class UserController : ApiController
{
[HttpGet]
[Route("users/{id:int}/username")]
public HttpResponseMessage GetUserName(int id)
{
...
}
[HttpGet]
[Route("users/{id:int}/gender")]
public HttpResponseMessage GetGender(int id)
{
...
}
[HttpGet]
[Route("users/{id:int}/dateofbirth")]
public HttpResponseMessage GetDateOfBirth(int id)
{
...
}
}
Meskipun secara teknis tidak ada yang salah dengan pendekatan ini, sebagian besar klien mungkin perlu mendapatkan beberapa properti untuk masing-masing User
, sehingga menghasilkan kode klien seperti berikut.
HttpResponseMessage response = await client.GetAsync("users/1/username");
response.EnsureSuccessStatusCode();
var userName = await response.Content.ReadAsStringAsync();
response = await client.GetAsync("users/1/gender");
response.EnsureSuccessStatusCode();
var gender = await response.Content.ReadAsStringAsync();
response = await client.GetAsync("users/1/dateofbirth");
response.EnsureSuccessStatusCode();
var dob = await response.Content.ReadAsStringAsync();
Membaca dan menulis ke file di disk
File I/O melibatkan pembukaan file dan dipindahkan ke titik yang sesuai sebelum membaca atau menulis data. Setelah operasi selesai, file mungkin ditutup untuk menyimpan sumber daya sistem operasi. Aplikasi yang terus membaca dan menulis informasi dalam jumlah kecil ke file akan menghasilkan overhead I/O yang signifikan. Permintaan penulisan kecil juga dapat menyebabkan fragmentasi file, sehingga lebih memperlambat operasi I/O berikutnya.
Contoh berikut menggunakan FileStream
untuk menulis objek Customer
ke file. Membuat FileStream
akan membuka file, dan membuangnya akan menutup file. (Pernyataan using
secara otomatis membuang FileStream
objek.) Jika aplikasi memanggil metode ini berulang kali saat pelanggan baru ditambahkan, overhead I/O dapat terakumulasi dengan cepat.
private async Task SaveCustomerToFileAsync(Customer customer)
{
using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
{
BinaryFormatter formatter = new BinaryFormatter();
byte [] data = null;
using (MemoryStream memStream = new MemoryStream())
{
formatter.Serialize(memStream, customer);
data = memStream.ToArray();
}
await fileStream.WriteAsync(data, 0, data.Length);
}
}
Cara memperbaiki masalah ini
Kurangi jumlah permintaan I/O dengan mengemas data menjadi permintaan yang lebih besar dan lebih sedikit.
Ambil data dari database sebagai kueri tunggal, bukan beberapa kueri yang lebih kecil. Berikut adalah kode versi revisi yang mengambil informasi produk.
public async Task<IHttpActionResult> GetProductCategoryDetailsAsync(int subCategoryId)
{
using (var context = GetContext())
{
var subCategory = await context.ProductSubcategories
.Where(psc => psc.ProductSubcategoryId == subCategoryId)
.Include("Product.ProductListPriceHistory")
.FirstOrDefaultAsync();
if (subCategory == null)
return NotFound();
return Ok(subCategory);
}
}
Ikuti prinsip desain REST untuk API web. Berikut API web versi revisi dari contoh sebelumnya. Sebagai alternatif metode GET terpisah untuk setiap properti, ada satu metode GET yang menampilkan User
. Metode ini menghasilkan isi respons yang lebih besar per permintaan, tetapi setiap klien cenderung melakukan lebih sedikit panggilan API.
public class UserController : ApiController
{
[HttpGet]
[Route("users/{id:int}")]
public HttpResponseMessage GetUser(int id)
{
...
}
}
// Client code
HttpResponseMessage response = await client.GetAsync("users/1");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadAsStringAsync();
Untuk file I/O, pertimbangkan buffering data dalam memori dan kemudian menulis data buffer ke file sebagai operasi tunggal. Pendekatan ini akan mengurangi overhead dari membuka dan menutup file berulang kali, dan membantu mengurangi fragmentasi file pada disk.
// Save a list of customer objects to a file
private async Task SaveCustomerListToFileAsync(List<Customer> customers)
{
using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
{
BinaryFormatter formatter = new BinaryFormatter();
foreach (var customer in customers)
{
byte[] data = null;
using (MemoryStream memStream = new MemoryStream())
{
formatter.Serialize(memStream, customer);
data = memStream.ToArray();
}
await fileStream.WriteAsync(data, 0, data.Length);
}
}
}
// In-memory buffer for customers.
List<Customer> customers = new List<Customers>();
// Create a new customer and add it to the buffer
var customer = new Customer(...);
customers.Add(customer);
// Add more customers to the list as they are created
...
// Save the contents of the list, writing all customers in a single operation
await SaveCustomerListToFileAsync(customers);
Pertimbangan
Dua contoh pertama membuat panggilan I/O lebih sedikit, tetapi masing-masing mengambil lebih banyak informasi. Anda harus mempertimbangkan kompensasi antara kedua faktor ini. Jawaban yang benar bergantung pada pola penggunaan aktual. Misalnya, dalam contoh API web, dapat dilihat bahwa klien sering kali hanya membutuhkan nama pengguna. Dalam hal ini, mungkin masuk akal mengeksposnya sebagai panggilan API terpisah. Untuk informasi selengkapnya, lihat anti-pola Extraneous Fetching.
Saat membaca data, jangan membuat permintaan I/O terlalu besar. Aplikasi hanya boleh mengambil informasi yang mungkin digunakan.
Terkadang, hal ini memudahkan partisi pembagian informasi untuk objek menjadi dua potong, data yang sering diakses yang menyumbang sebagian besar permintaan, dan data yang jarang diakses yang jarang digunakan. Data yang paling sering diakses adalah sebagian kecil dari total data untuk objek, sehingga menampilkan bagian ini saja dapat menghemat overhead I/O yang signifikan.
Saat menulis data, jangan mengunci sumber daya lebih lama dari yang diperlukan, untuk mengurangi kemungkinan konflik selama operasi yang panjang. Jika operasi tulis mencakup beberapa penyimpanan data, file, atau layanan, gunakan pendekatan yang akhirnya konsisten. Lihat Panduan Konsistensi Data.
Jika Anda melakukan buffer data dalam memori sebelum menulisnya, data akan rentan jika prosesnya mengalami crash. Jika kecepatan data biasanya tinggi atau relatif jarang, mungkin lebih aman untuk melakukan buffer data dalam antrean tahan lama eksternal seperti Azure Event Hubs.
Pertimbangkan penembolokan data yang Anda ambil dari layanan atau database. Ini dapat membantu mengurangi volume I/O dengan menghindari permintaan data yang sama secara berulang. Untuk informasi selengkapnya, lihat Praktik terbaik penembolokan.
Cara mendeteksi masalah
Gejala I/O cerewet meliputi latensi tinggi dan throughput rendah. Pengguna akhir cenderung melaporkan waktu respons yang panjang atau kegagalan yang disebabkan habisnya waktu layanan, karena meningkatnya konflik untuk sumber daya I/O.
Anda dapat melakukan langkah berikut untuk membantu mengidentifikasi penyebab masalah:
- Lakukan pemantauan proses sistem produksi untuk mengidentifikasi operasi dengan waktu respons yang buruk.
- Lakukan pengujian beban setiap operasi yang diidentifikasi pada langkah sebelumnya.
- Selama pengujian beban, kumpulkan data telemetri tentang permintaan akses data yang dibuat oleh setiap operasi.
- Kumpulkan statistik mendetail setiap permintaan yang dikirim ke penyimpanan data.
- Profil aplikasi dalam lingkungan pengujian untuk menentukan kemungkinan penyempitan I/O terjadi.
Cari salah satu dari gejala ini:
- Permintaan I/O kecil dalam jumlah besar yang dibuat untuk file yang sama.
- Permintaan jaringan kecil dalam jumlah besar yang dibuat oleh instans aplikasi ke layanan yang sama.
- Permintaan kecil dalam jumlah besar yang dibuat oleh instans aplikasi ke penyimpanan data yang sama.
- Aplikasi dan layanan yang menjadi terikat I/O.
Contoh diagnosis
Bagian berikut menerapkan langkah-langkah ini untuk contoh yang ditunjukkan sebelumnya yang mengkueri database.
Menguji beban aplikasi
Grafik ini menunjukkan hasil pengujian beban. Waktu respons rata-rata diukur dalam puluhan detik per permintaan. Grafik menunjukkan latensi yang sangat tinggi. Dengan beban 1000 pengguna, pengguna mungkin harus menunggu hampir satu menit untuk melihat hasil kueri.
Catatan
Aplikasi ini disebarkan sebagai aplikasi web Azure App Service, menggunakan Azure SQL Database. Uji beban menggunakan beban kerja langkah simulasi hingga 1000 pengguna bersamaan. Database dikonfigurasi dengan kumpulan koneksi yang mendukung hingga 1000 koneksi bersamaan, untuk mengurangi kemungkinan bahwa konflik koneksi akan mempengaruhi hasil.
Memantau aplikasi
Anda dapat menggunakan paket manajemen performa aplikasi (APM) untuk mengambil dan menganalisis metrik utama yang mungkin mengidentifikasi I/O yang cerewet. Metrik yang penting bergantung pada beban kerja I/O. Untuk contoh ini, permintaan I/O yang menarik adalah kueri database.
Gambar berikut menunjukkan hasil yang dihasilkan menggunakan Relic APM Baru. Waktu respons database rata-rata memuncak sekitar 5,6 detik per permintaan selama beban kerja maksimum. Sistem ini mampu mendukung rata-rata 410 permintaan per menit selama pengujian.
Mengumpulkan informasi akses mendetail
Berdasarkan data pemantauan, dapat dilihat bahwa aplikasi mengeksekusi tiga pernyataan SQL SELECT yang berbeda. Ini sesuai dengan permintaan yang dihasilkan oleh Entity Framework untuk mengambil data dari tabel ProductListPriceHistory
, Product
, dan ProductSubcategory
. Lebih lanjut, kueri yang mengambil data dari tabel ProductListPriceHistory
sejauh ini adalah pernyataan SELECT yang paling sering dieksekusi, berdasarkan urutan ukurannya.
Diketahui bahwa metode GetProductsInSubCategoryAsync
, yang ditunjukkan sebelumnya, melakukan 45 kueri SELECT. Setiap kueri menyebabkan aplikasi membuka koneksi SQL baru.
Catatan
Gambar ini menunjukkan informasi jejak untuk instans operasi GetProductsInSubCategoryAsync
yang paling lambat dalam pengujian beban. Dalam lingkungan produksi, sebaiknya periksa jejak instans yang paling lambat, untuk mengetahui apakah ada pola yang mengindikasikan masalah. Jika Anda hanya melihat nilai rata-rata, Anda mungkin mengabaikan masalah yang akan menjadi lebih buruk dengan beban.
Gambar berikutnya menunjukkan pernyataan SQL aktual yang dikeluarkan. Kueri yang mengambil informasi harga dijalankan untuk setiap produk individu dalam subkategori produk. Menggunakan gabungan akan sangat mengurangi jumlah panggilan database.
Jika Anda menggunakan O/RM, seperti Entity Framework, menelusuri kueri SQL dapat memberikan wawasan tentang bagaimana O/RM menerjemahkan panggilan terprogram ke dalam SQL pernyataan, dan menunjukkan area di mana akses data dapat dioptimalkan.
Menerapkan solusi dan memverifikasi hasilnya
Menulis ulang panggilan ke Entity Framework menyebabkan hasil berikut.
Pengujian beban ini dilakukan pada penyebaran yang sama, menggunakan profil beban yang sama. Kali ini grafik menunjukkan latensi yang jauh lebih rendah. Waktu permintaan rata-rata pada 1000 pengguna antara 5 dan 6 detik, turun dari hampir satu menit.
Kali ini sistem mendukung rata-rata 3.970 permintaan per menit, dibandingkan dengan 410 untuk pengujian sebelumnya.
Menelusuri pernyataan SQL menunjukkan bahwa semua data diambil dalam satu pernyataan SELECT. Meskipun jauh lebih kompleks, kueri ini dilakukan hanya sekali per operasi. Dan meskipun gabungan yang kompleks bisa menjadi mahal, sistem database relasional dioptimalkan untuk jenis kueri ini.