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:

  1. Mengkueri subkategori dari tabel ProductSubcategory.
  2. Mencari semua produk di subkategori tersebut dengan mengkueri tabel Product.
  3. 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:

  1. Lakukan pemantauan proses sistem produksi untuk mengidentifikasi operasi dengan waktu respons yang buruk.
  2. Lakukan pengujian beban setiap operasi yang diidentifikasi pada langkah sebelumnya.
  3. Selama pengujian beban, kumpulkan data telemetri tentang permintaan akses data yang dibuat oleh setiap operasi.
  4. Kumpulkan statistik mendetail setiap permintaan yang dikirim ke penyimpanan data.
  5. 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.

Key indicators load-test results for the chatty I/O sample application

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 pemantauan 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.

Overview of traffic hitting the AdventureWorks2012 database

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.

Queries performed by the sample application under test

Diketahui bahwa metode GetProductsInSubCategoryAsync, yang ditunjukkan sebelumnya, melakukan 45 kueri SELECT. Setiap kueri menyebabkan aplikasi membuka koneksi SQL baru.

Query statistics for the sample application under test

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.

Query details for the sample application under test

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.

Key indicators load test results for the chunky API in the chatty I/O sample application

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.

Transaction overview for the chunky API

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.

Query details for the chunky API