النمط المضاد للإدخال/الإخراج غير المفيد

يمكن أن يكون للتأثير التراكمي لعددٍ كبيرٍ من طلبات الإدخال/الإخراج تأثيرٌ كبيرٌ على الأداء والاستجابة.

وصف المشكلة

استدعاءات الشبكة وعمليات الإدخال/إخراج الأخرى بطيئة بطبيعتها مقارنةً بمهام الحساب. عادةً ما يكون لكل طلب إدخال/إخراج حملٌ كبيرٌ، ويمكن أن يؤدي التأثير التراكمي للعديد من عمليات الإدخال/الإخراج إلى إبطاء النظام. فيما يلي بعض الأسباب الشائعة للإدخال/الإخراج المكتظ:

قراءة السجلات الفردية وكتابتها في قاعدة بيانات كطلبات مميزة

يقرأ المثال التالي من قاعدة بيانات المنتجات. هناك ثلاثة جداول هما Product و ProductSubcategory و ProductPriceListHistory. تسترد التعليمات البرمجية جميع المنتجات في فئةٍ فرعيةٍ، جنباً إلى جنب مع معلومات الأسعار، عن طريق تنفيذ سلسلة من الاستعلامات:

  1. الاستعلام عن الفئة الفرعية من الجدول ProductSubcategory.
  2. ابحث عن جميع المنتجات في تلك الفئة الفرعية عن طريق الاستعلام عن الجدول Product.
  3. لكل منتج، استعلم عن بيانات الأسعار من الجدول ProductPriceListHistory.

التطبيق يستخدم Entity Framework للاستعلام عن قاعدة البيانات. يمكنك العثور على النموذج الكامل هنا.

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);
    }
}

يوضح هذا المثال المشكلة صراحةً، ولكن في بعض الأحيان يمكن لـ O/RM إخفاء المشكلة، إذا كانت تجلب ضمنياً السجلات التابعة واحداً تلو الآخر. يُعرف هذا باسم "مشكلة N+1".

تنفيذ عملية منطقية واحدة كسلسلة من طلبات HTTP

يحدث هذا غالباً عندما يحاول المطورون اتباع نموذج مُوجَّه للعناصر، والتعامل مع العناصر البعيدة كما لو كانت عناصر محلية في الذاكرة. يمكن أن يؤدي هذا إلى عدد كبير جداً من الجولات عبر الشبكة. على سبيل المثال، تعرض واجهة برمجة تطبيقات الويب التالية الخصائص الفردية للعناصر User من خلال أساليب HTTP GET الفردية.

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)
    {
        ...
    }
}

على الرغم من عدم وجود أي خطأ في هذا النهج، فإن معظم العملاء سيحتاجون على الأرجح إلى الحصول على العديد من الخصائص لكلٍ منهم User، مما يؤدي إلى تعليمة برمجية للعميل كما يلي.

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();

القراءة والكتابة إلى ملف على القرص

يتضمن إدخال/إخراج الملف فتح ملف والانتقال إلى النقطة المناسبة قبل قراءة البيانات أو كتابتها. عند اكتمال العملية، قد يتم إغلاق الملف لحفظ موارد نظام التشغيل. التطبيق الذي يقرأ ويكتب كميات صغيرة من المعلومات إلى ملف باستمرار سيولد عبء إدخال/إخراج كبير. يمكن أن تؤدي طلبات الكتابة الصغيرة أيضاً إلى تجزئة الملف، مما يؤدي إلى إبطاء عمليات الإدخال/الإخراج اللاحقة بدرجةٍ أكبر.

يستخدم المثال التالي FileStream لكتابة عنصر Customer إلى ملف. يؤدي إنشاء FileStream إلى فتح الملف، والتخلص منه يغلق الملف. (تتخلص العبارة using تلقائياً من العنصر FileStream.) إذا كان التطبيق يستدعي هذا الأسلوب بصفةٍ متكررةٍ عند إضافة عملاء جُدد، يمكن أن يتراكم حمل الإدخال/الإخراج بسرعةٍ.

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);
    }
}

كيفية حل المشكلة

تقليل عدد طلبات الإدخال/الإخراج عن طريق تجميع البيانات في طلبات أكبر وأقل.

إحضار البيانات من قاعدة بيانات كاستعلامٍ واحدٍ، بدلاً من عدة استعلامات أصغر. فيما يلي إصدار منقح من التعليمات البرمجية التي تسترد معلومات المنتج.

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);
    }
}

اتبع مبادئ تصميم REST لواجهات برمجة تطبيقات الويب. فيما يلي إصدار منقح من واجهة برمجة تطبيقات الويب من المثال السابق. بدلاً من أساليب GET المنفصلة لكل خاصية، هناك أسلوب GET واحد يرجع User. يؤدي هذا إلى نص استجابة أكبر لكل طلب، ولكن من المحتمل أن يجري كل عميل استدعاءات أقل لواجهة برمجة التطبيقات.

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();

بالنسبة إلى إدخال/إخراج الملفات، ضع في اعتبارك تخزين البيانات مؤقتاً في الذاكرة ثم كتابة البيانات المُخزَّنة مؤقتاً إلى ملف كعمليةٍ واحدةٍ. يقلل هذا الأسلوب من الحمل الناتج عن فتح الملف وإغلاقه بصفةٍ متكررةٍ، ويساعد على تقليل تجزئة الملف على القرص.

// 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);

الاعتبارات

  • يجري المثالان الأولان عدد أقل من استدعاءات الإدخال/إخراج، ولكن كل استدعاء يسترد مزيد من المعلومات. يجب مراعاة المفاضلة بين هذين العاملين. تعتمد الإجابة الصحيحة على أنماط الاستخدام الفعلية. على سبيل المثال، في مثال واجهة برمجة تطبيقات الويب، قد يتضح أن العملاء يحتاجون غالباً إلى اسم المستخدم فقط. في هذه الحالة، قد يكون من المنطقي كشفه كاستدعاء منفصل لواجهة برمجة التطبيقات. لمزيد من المعلومات، راجع النمط المضاد للإحضار غير المألوف.

  • عند قراءة البيانات، لا تجعل طلبات الإدخال/إخراج لديك كبيرةً جداً. يجب أن يسترد التطبيق المعلومات التي من المحتمل أن يستخدمها فقط.

  • في بعض الأحيان، يكون من المفيد تقسيم المعلومات الخاصة بعنصرٍ ما إلى مجموعتين، البيانات التي يتم الوصول إليها بصفةٍ متواترةٍ والتي تمثل معظم الطلبات، والبيانات التي يتم الوصول إليها بدرجةٍ أقل والتي نادراً ما يتم استخدامها. غالباً ما تكون البيانات التي يتم الوصول إليها بصفةٍ متواترةٍ جزءاً صغيراً نسبياً من إجمالي البيانات لعنصرٍ ما، لذلك يمكن أن يؤدي إرجاع ذلك الجزء فقط إلى حفظ حمل إدخال/إخراج كبير.

  • عند كتابة البيانات، تجنب تأمين الموارد لفترةٍ أطول من اللازم، لتقليل فرص المنافسة على الاتصال أثناء عملية طويلة. إذا امتدت عملية الكتابة عبر مخازن بيانات أو ملفات أو خدمات متعددة، فاتبع نهجاً متسقاً في النهاية. راجع إرشادات تناسق البيانات.

  • إذا خزنت البيانات مؤقتاً في الذاكرة قبل كتابتها، تكون البيانات عرضةً للخطر إذا تعطلت العملية. إذا كان معدل البيانات يحتوي عادةً على اندفاعات أو كان متباعداً نسبياً، فقد يكون تخزين البيانات مؤقتاً في قائمة انتظار دائمة خارجية مثل مراكز الأحداث أكثر أماناً.

  • ضع في اعتبارك التخزين المؤقت للبيانات التي تستردها من خدمة أو قاعدة بيانات. يمكن أن يساعد هذا في تقليل حجم الإدخال/الإخراج عن طريق تجنب الطلبات المتكررة للبيانات نفسها. لمزيد من المعلومات، راجع أفضل ممارسات التخزين المؤقت.

كيف تكتشف المشكلة

تتضمن أعراض الإدخال/الإخراج المكتظ زمن انتقالٍ عالٍ ومعدل نقل منخفض. من المحتمل أن يبلغ المستخدمون النهائيون عن أوقات الاستجابة المُوسَّعة أو حالات الفشل الناجمة عن انتهاء مهلة الخدمات، بسبب زيادة الخلاف على موارد الإدخال/الإخراج.

يمكنك تنفيذ الخطوات التالية للمساعدة في تحديد أسباب أي مشاكل:

  1. إجراء مراقبة عملية لنظام التشغيل، لتحديد العمليات ذات أوقات الاستجابة الأبطأ.
  2. إجراء اختبار تحميل لكل عملية تم تحديدها في الخطوة السابقة.
  3. أثناء اختبارات التحميل، اجمع بيانات تتبع الاستخدام عن طلبات الوصول إلى البيانات التي تقدمها كل عملية.
  4. اجمع إحصائيات مُفصَّلة لكل طلب يتم إرساله إلى مخزن بيانات.
  5. بادر بإعداد ملف تعريف التطبيق في بيئة الاختبار لتحديد مكان حدوث ازدحامات الإدخال/الإخراج المحتملة، حيثما أمكن ذلك.

ابحث عن أي من هذه الأعراض:

  • تم إجراء عدد كبير من طلبات الإدخال/الإخراج الصغيرة إلى الملف نفسه.
  • عدد كبير من طلبات الشبكة الصغيرة التي تم إجراؤها بواسطة مثيل تطبيق للخدمة نفسها.
  • عدد كبير من الطلبات الصغيرة التي تم إجراؤها بواسطة مثيل تطبيق لمخزن البيانات نفسه.
  • أصبحت التطبيقات والخدمات مرتبطة بالإدخال/الإخراج.

مثال التشخيص

تطبق الأقسام التالية هذه الخطوات على المثال المُوضَّح سابقاً الذي يستعلم عن قاعدة بيانات.

اختبار تحميل التطبيق

يعرض هذا الرسم البياني نتائج اختبار التحميل. يُقاس متوسط وقت الاستجابة بعشرات الثواني لكل طلب. يظهر الرسم البياني زمن انتقال عالٍ جداً. مع تحميل 1000 مستخدم، قد يتعين على المستخدم الانتظار لمدة دقيقة تقريباً لرؤية نتائج الاستعلام.

مؤشرات رئيسية نتائج اختبار التحميل لتطبيق عينة الإدخال/الإخراج الدردشة

إشعار

وزِع التطبيق كتطبيق Azure App Service على الويب، باستخدام Azure SQL Database. استخدم اختبار التحميل حمل عمل خطوة مُحاكي لما يصل إلى 1000 مستخدم متزامن. تم تكوين قاعدة البيانات مع قائمة اتصال تدعم ما يصل إلى 1000 اتصال متزامن، لتقليل فرصة تأثير المنافسة على الاتصالات على النتائج.

مراقبة التطبيق

يمكنك استخدام حزمة إدارة أداء التطبيق (APM) لالتقاط وتحليل المقاييس الرئيسية التي قد تحدد الإدخال/الإخراج الدردشة. ستعتمد المقاييس المهمة على حمل عمل الإدخال/الإخراج. على سبيل المثال، كانت طلبات الإدخال/الإخراج المثيرة للاهتمام هي استعلامات قاعدة البيانات.

تظهر الصورة التالية النتائج التي تم إنشاؤها باستخدام New Relic APM. بلغ متوسط وقت استجابة قاعدة البيانات ذروته عند حوالي 5.6 ثانية لكل طلب أثناء الحد الأقصى لحمل العمل. تمكَّن النظام من دعم متوسط 410 طلب في الدقيقة طوال الاختبار.

نظرة عامة على نسبة استخدام الشبكة التي تصل إلى قاعدة بيانات AdventureWorks2012

جمع معلومات مُفصَّلة عن الوصول إلى البيانات

يظهر البحث الأدق في بيانات المراقبة أن التطبيق ينفذ ثلاثة عبارات SQL SELECT مختلفة. تتوافق هذه مع الطلبات التي تم إنشاؤها بواسطة Entity Framework لإحضار البيانات من الجداول ProductListPriceHistory و Product وProductSubcategory. علاوةً على ذلك، فإن الاستعلام الذي يسترد البيانات من الجدول ProductListPriceHistory هو إلى حدٍ بعيدٍ أكثر عبارات SELECT تنفيذاً، حسب ترتيب الحجم.

الاستعلامات التي يتم إجراؤها بواسطة نموذج التطبيق قيد الاختبار

اتضح أن الأسلوب GetProductsInSubCategoryAsync، المُوضَّح سابقاً، ينفذ 45 استعلاماً عن SELECT. يتسبب كل استعلام في فتح التطبيق لاتصال SQL جديد.

إحصائيات الاستعلام عن نموذج التطبيق قيد الاختبار

إشعار

تعرض هذه الصورة معلومات التتبع لأبطأ مثيل للعملية GetProductsInSubCategoryAsync في اختبار التحميل. في بيئة التشغيل، من المفيد فحص تتبعات أبطأ المثيلات، لمعرفة ما إذا كان هناك نمط يُشير إلى وجود مشكلة. إذا ألقيت نظرةً على القيم المتوسطة فقط، فقد تتجاهل المشكلات التي ستزداد سوءاً بدرجةٍ كبيرةٍ تحت الحمل.

تعرض الصورة التالية عبارات SQL الفعلية التي تم إصدارها. يُشغَّل الاستعلام الذي يحضر معلومات عن الأسعار لكل منتج فردي في الفئة الفرعية للمنتج. قد يؤدي استخدام صلة إلى تقليل عدد استدعاءات قاعدة البيانات بدرجةٍ كبيرةٍ.

تفاصيل الاستعلام عن نموذج التطبيق قيد الاختبار

إذا كنت تستخدم O/RM، مثل Entity Framework، يمكن أن يوفر تتبع استعلامات SQL نظرةً ثاقبةً حول كيفية ترجمة O/RM للاستدعاءات البرمجية إلى عبارات SQL، والإشارة إلى المناطق التي يمكن تحسين الوصول إلى البيانات فيها.

نفذ الحل وتحقق من النتيجة

أسفرت إعادة كتابة الاستدعاء إلى Entity Framework عن النتائج التالية.

تحمل المؤشرات الرئيسية نتائج اختبار واجهة برمجة التطبيقات المكتنزة في تطبيق عينة الإدخال/الإخراج الدردشة

تم إجراء اختبار التحميل هذا على التوزيع نفسه، باستخدام ملف تعريف التحميل نفسه. هذه المرة، يُظهر الرسم البياني زمن انتقال أقل بكثير. يتراوح متوسط وقت الطلب عند 1000 مستخدم ما بين 5 و6 ثوانٍ، أي أقل من دقيقةٍ واحدةٍ تقريباً.

هذه المرة دعم النظام متوسط 3970 طلباً في الدقيقة، مقارنةً بـ 410 طلباً في الاختبار السابق.

نظرة عامة على المعاملة لواجهة برمجة التطبيقات المكتنزة

يُظهر تتبع عبارة SQL أنه يتم إحضار جميع البيانات في عبارة SELECT واحدة. على الرغم من أن هذا الاستعلام أكثر تعقيداً إلى حدٍ كبيرٍ، فإنه يتم تنفيذه مرةً واحدةً فقط لكل عملية. وبينما يمكن أن تصبح الصلات المُعقدة مُكلِّفة، يتم تحسين أنظمة قواعد البيانات الارتباطية لهذا النوع من الاستعلام.

تفاصيل الاستعلام لواجهة برمجة التطبيقات المكتنزة