تخزين البيانات الدائم وواجهات برمجة تطبيقات ملفات Linux

أثناء بحثي عن استقرار تخزين البيانات في الأنظمة السحابية، قررت أن أختبر نفسي للتأكد من أنني أفهم الأشياء الأساسية. أنا بدأت بقراءة مواصفات NVMe من أجل فهم الضمانات المتعلقة باستمرارية البيانات (أي ضمانات توفر البيانات بعد فشل النظام) التي توفرها لنا أقراص NMVe. لقد توصلت إلى الاستنتاجات الرئيسية التالية: تحتاج إلى النظر في البيانات التالفة من لحظة إعطاء أمر كتابة البيانات، وحتى لحظة كتابتها على وسيط التخزين. ومع ذلك، في معظم البرامج، يتم استخدام مكالمات النظام بأمان تام لكتابة البيانات.

في هذا المنشور، أستكشف آليات التخزين المستمر التي توفرها واجهات برمجة التطبيقات لملفات Linux. يبدو أن كل شيء يجب أن يكون بسيطًا هنا: يستدعي البرنامج الأمر write()وبعد اكتمال هذا الأمر، سيتم حفظ البيانات بشكل آمن على القرص. لكن write() يقوم فقط بنسخ بيانات التطبيق إلى ذاكرة التخزين المؤقت للنواة الموجودة في ذاكرة الوصول العشوائي (RAM). من أجل إجبار النظام على كتابة البيانات على القرص، يجب استخدام بعض الآليات الإضافية.

تخزين البيانات الدائم وواجهات برمجة تطبيقات ملفات Linux

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

ميزات استخدام وظيفة الكتابة ().

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

هل هذا يعني أن العملية write() هو الذري؟ من الناحية الفنية، نعم. يجب أن تقوم عمليات قراءة البيانات بإرجاع كل ما تمت كتابته أو لا شيء منه write(). لكن العملية write()، وفقًا للمعيار، ليس من الضروري أن ينتهي بكتابة كل ما طُلب منه تدوينه. يُسمح لها بكتابة جزء فقط من البيانات. على سبيل المثال، قد يكون لدينا خيطين يُلحق كل منهما 1024 بايت بملف موصوف بواسطة نفس واصف الملف. من وجهة نظر المعيار، ستكون النتيجة المقبولة عندما يمكن لكل عملية كتابة إلحاق بايت واحد فقط بالملف. ستبقى هذه العمليات ذرية، لكن بعد اكتمالها، ستختلط البيانات التي كتبتها في الملف. ها هو مناقشة مثيرة للاهتمام للغاية حول هذا الموضوع على Stack Overflow.

وظائف fsync () وfdatasync ().

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

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

قد يتم تنفيذ هذه الآلية بشكل مختلف على أنظمة الملفات المختلفة. إستعملت com.blktrace للتعرف على عمليات القرص المستخدمة في أنظمة الملفات ext4 وXFS. يقوم كلاهما بإصدار أوامر الكتابة المعتادة إلى القرص لكل من محتويات الملفات ودفتر يومية نظام الملفات، ومسح ذاكرة التخزين المؤقت والخروج عن طريق إجراء FUA (فرض الوصول إلى الوحدة، وكتابة البيانات مباشرة إلى القرص، وتجاوز ذاكرة التخزين المؤقت) إلى دفتر اليومية. ربما يفعلون ذلك فقط من أجل تأكيد حقيقة المعاملة. على محركات الأقراص التي لا تدعم FUA، يؤدي هذا إلى عمليتي مسح لذاكرة التخزين المؤقت. لقد أظهرت تجاربي ذلك fdatasync() أسرع قليلا fsync(). جدوى blktrace يدل علي fdatasync() عادةً ما يكتب بيانات أقل على القرص (في نظام ext4 fsync() يكتب 20 كيلو بايت، و fdatasync() - 16 كيلو بايت). واكتشفت أيضًا أن XFS أسرع قليلاً من ext4. وهنا مع المساعدة blktrace كان قادرا على معرفة ذلك fdatasync() يقوم بمسح بيانات أقل على القرص (4 كيلو بايت في XFS).

مواقف غامضة عند استخدام fsync ()

أستطيع أن أفكر في ثلاث حالات غامضة فيما يتعلق fsync()التي واجهتها في الممارسة العملية.

وقعت أول حادثة من هذا القبيل في عام 2008. في ذلك الوقت، تم "تجميد" واجهة Firefox 3 في حالة كتابة عدد كبير من الملفات على القرص. كانت المشكلة أن تنفيذ الواجهة يستخدم قاعدة بيانات SQLite لتخزين معلومات حول حالتها. بعد كل تغيير حدث في الواجهة، تم استدعاء الدالة fsync()مما أعطى ضمانات جيدة لتخزين البيانات بشكل مستقر. في نظام الملفات ext3 المستخدم آنذاك، تم استخدام الوظيفة fsync() قام بإلقاء جميع الصفحات "القذرة" الموجودة في النظام على القرص، وليس فقط تلك الصفحات المرتبطة بالملف المقابل. وهذا يعني أن النقر على زر في Firefox قد يؤدي إلى كتابة ميغابايت من البيانات على قرص مغناطيسي، وهو ما قد يستغرق عدة ثوانٍ. الحل للمشكلة، بقدر ما أفهم من هو كانت المادة هي نقل العمل مع قاعدة البيانات إلى مهام الخلفية غير المتزامنة. وهذا يعني أن Firefox كان يطبق متطلبات أكثر صرامة لاستمرارية التخزين مما هو مطلوب بالفعل، ولم تؤدي ميزات نظام الملفات ext3 إلا إلى تفاقم هذه المشكلة.

المشكلة الثانية حدثت في عام 2009. بعد ذلك، بعد تعطل النظام، واجه مستخدمو نظام الملفات ext4 الجديد حقيقة أن العديد من الملفات التي تم إنشاؤها حديثًا كانت ذات طول صفري، لكن هذا لم يحدث مع نظام الملفات ext3 الأقدم. في الفقرة السابقة، تحدثت عن كيفية قيام ext3 بدفع الكثير من البيانات إلى القرص، مما أدى إلى إبطاء الأمور كثيرًا. fsync(). لتحسين الوضع، يقوم ext4 بمسح تلك الصفحات "القذرة" ذات الصلة بملف معين فقط. وتبقى بيانات الملفات الأخرى في الذاكرة لفترة أطول بكثير من الملفات ext3. تم ذلك لتحسين الأداء (افتراضيًا، تظل البيانات في هذه الحالة لمدة 30 ثانية، ويمكنك تكوين ذلك باستخدام dirty_expire_centisecs; هنا يمكنك العثور على مزيد من المعلومات حول هذا). وهذا يعني أنه يمكن فقدان كمية كبيرة من البيانات بشكل لا يمكن استرجاعه بعد حدوث أي عطل. الحل لهذه المشكلة هو الاستخدام fsync() في التطبيقات التي تحتاج إلى توفير تخزين مستقر للبيانات وحمايتها قدر الإمكان من عواقب الفشل. وظيفة fsync() يعمل بشكل أكثر كفاءة مع ext4 مقارنة مع ext3. عيب هذا الأسلوب هو أن استخدامه، كما كان من قبل، يبطئ بعض العمليات، مثل تثبيت البرامج. انظر التفاصيل حول هذا هنا и هنا.

المشكلة الثالثة فيما يتعلق fsync()، نشأت في عام 2018. بعد ذلك، في إطار مشروع PostgreSQL، تم اكتشاف أنه إذا كانت الوظيفة fsync() واجه خطأ، فإنه يضع علامة على الصفحات "القذرة" على أنها "نظيفة". ونتيجة لذلك، المكالمات التالية fsync() لا تفعل شيئا مع مثل هذه الصفحات. ولهذا السبب، يتم تخزين الصفحات المعدلة في الذاكرة ولا يتم كتابتها على القرص مطلقًا. هذه كارثة حقيقية، لأن التطبيق سوف يعتقد أن بعض البيانات مكتوبة على القرص، ولكن في الواقع لن يكون الأمر كذلك. مثل هذه الإخفاقات fsync() نادرة، فإن التطبيق في مثل هذه المواقف لا يمكنه فعل أي شيء تقريبًا لمكافحة المشكلة. في هذه الأيام، عندما يحدث هذا، تتعطل تطبيقات PostgreSQL والتطبيقات الأخرى. ومن، في المقالة "هل يمكن للتطبيقات التعافي من فشل fsync؟"، تم استكشاف هذه المشكلة بالتفصيل. أفضل حل حاليًا لهذه المشكلة هو استخدام الإدخال/الإخراج المباشر مع العلامة O_SYNC أو مع العلم O_DSYNC. باستخدام هذا الأسلوب، سيقوم النظام بالإبلاغ عن الأخطاء التي قد تحدث عند إجراء عمليات كتابة بيانات محددة، ولكن هذا الأسلوب يتطلب من التطبيق إدارة المخازن المؤقتة بنفسه. اقرأ المزيد عنها هنا и هنا.

فتح الملفات باستخدام علامتي O_SYNC وO_DSYNC

دعنا نعود إلى مناقشة آليات Linux التي توفر تخزينًا مستقرًا للبيانات. وهي أننا نتحدث عن استخدام العلم O_SYNC أو العلم O_DSYNC عند فتح الملفات باستخدام استدعاء النظام افتح(). باستخدام هذا الأسلوب، يتم تنفيذ كل عملية كتابة بيانات كما لو كانت بعد كل أمر write() يتم إعطاء النظام، على التوالي، الأوامر fsync() и fdatasync(). في مواصفات بوسيكس وهذا ما يسمى "إكمال سلامة ملف الإدخال / الإخراج المتزامن" و"إكمال سلامة البيانات". الميزة الرئيسية لهذا النهج هي أنه يجب تنفيذ استدعاء نظام واحد فقط لضمان سلامة البيانات، وليس اثنين (على سبيل المثال - write() и fdatasync()). العيب الرئيسي لهذا الأسلوب هو أن جميع عمليات الكتابة باستخدام واصف الملف المقابل ستتم مزامنتها، مما قد يحد من القدرة على هيكلة كود التطبيق.

استخدام الإدخال/الإخراج المباشر مع علامة O_DIRECT

استدعاء النظام open() يدعم العلم O_DIRECT، والذي تم تصميمه لتجاوز ذاكرة التخزين المؤقت لنظام التشغيل لإجراء عمليات الإدخال / الإخراج من خلال التفاعل مباشرة مع القرص. وهذا يعني في كثير من الحالات أن أوامر الكتابة الصادرة عن البرنامج ستتم ترجمتها مباشرة إلى أوامر تهدف إلى العمل مع القرص. ولكن، بشكل عام، هذه الآلية ليست بديلا عن الوظائف fsync() أو fdatasync(). والحقيقة هي أن القرص نفسه يمكن تأجيل أو ذاكرة التخزين المؤقت الأوامر المناسبة لكتابة البيانات. والأسوأ من ذلك، في بعض الحالات الخاصة، يتم تنفيذ عمليات الإدخال / الإخراج عند استخدام العلم O_DIRECT, إذاعة في العمليات المخزنة التقليدية. أسهل طريقة لحل هذه المشكلة هي استخدام العلامة لفتح الملفات O_DSYNC، مما يعني أن كل عملية كتابة ستتبعها مكالمة fdatasync().

اتضح أن نظام ملفات XFS قد أضاف مؤخرًا "مسارًا سريعًا" لملفات O_DIRECT|O_DSYNC-سجلات البيانات. إذا تم الكتابة فوق الكتلة باستخدام O_DIRECT|O_DSYNC، ثم XFS، بدلاً من مسح ذاكرة التخزين المؤقت، سيقوم بتنفيذ أمر الكتابة FUA إذا كان الجهاز يدعمه. لقد تحققت من ذلك باستخدام الأداة المساعدة blktrace على نظام Linux 5.4/Ubuntu 20.04. يجب أن يكون هذا الأسلوب أكثر كفاءة، لأنه يكتب الحد الأدنى من البيانات إلى القرص ويستخدم عملية واحدة، وليس عمليتين (كتابة ذاكرة التخزين المؤقت ومسحها). لقد وجدت رابطا ل رقعة قماشية 2018 النواة التي تنفذ هذه الآلية. هناك بعض المناقشات حول تطبيق هذا التحسين على أنظمة الملفات الأخرى، ولكن على حد علمي، XFS هو نظام الملفات الوحيد الذي يدعمه حتى الآن.

وظيفة sync_file_range()

لدى Linux مكالمة نظام sync_file_range()، والذي يسمح لك بنقل جزء فقط من الملف إلى القرص، وليس الملف بأكمله. يبدأ هذا الاستدعاء تدفقًا غير متزامن ولا ينتظر حتى يكتمل. ولكن في إشارة إلى sync_file_range() ويقال أن هذا الأمر "خطير للغاية". لا ينصح باستخدامه. الميزات والمخاطر sync_file_range() وصفها بشكل جيد للغاية في هذا مادة. على وجه الخصوص، يبدو أن هذا الاستدعاء يستخدم RocksDB للتحكم عندما تقوم النواة بمسح البيانات "القذرة" إلى القرص. ولكن في الوقت نفسه هناك، لضمان تخزين البيانات بشكل مستقر، يتم استخدامه أيضًا fdatasync(). في شفرة لدى RocksDB بعض التعليقات المثيرة للاهتمام حول هذا الموضوع. على سبيل المثال، يبدو أن المكالمة sync_file_range() عند استخدام ZFS، فإنه لا يقوم بتدفق البيانات إلى القرص. تخبرني التجربة أن الكود الذي نادرًا ما يستخدم من المحتمل أن يحتوي على أخطاء. لذلك، أنصح بعدم استخدام استدعاء النظام هذا إلا في حالة الضرورة القصوى.

مكالمات النظام للمساعدة في ضمان استمرارية البيانات

لقد توصلت إلى نتيجة مفادها أن هناك ثلاث طرق يمكن استخدامها لإجراء عمليات الإدخال/الإخراج المستمرة. كلهم يتطلبون استدعاء دالة fsync() للدليل الذي تم إنشاء الملف فيه. وهذه هي المقاربات:

  1. استدعاء الوظيفة fdatasync() أو fsync() بعد الوظيفة write() (من الأفضل استخدامه fdatasync()).
  2. العمل مع واصف ملف مفتوح بعلامة O_DSYNC أو O_SYNC (أفضل - بعلم O_DSYNC).
  3. استخدام الأوامر pwritev2() مع العلم RWF_DSYNC أو RWF_SYNC (ويفضل أن يكون ذلك مع العلم RWF_DSYNC).

ملاحظات الأداء

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

  1. تعد الكتابة فوق بيانات الملف أسرع من إلحاق البيانات بملف (يمكن أن يصل زيادة الأداء إلى 2-100%). يتطلب إرفاق البيانات بملف إجراء تغييرات إضافية على البيانات التعريفية للملف، حتى بعد استدعاء النظام fallocate()ولكن قد يختلف حجم هذا التأثير. أوصي، للحصول على أفضل أداء، للاتصال fallocate() لتخصيص المساحة المطلوبة مسبقًا. ثم يجب ملء هذه المساحة بشكل صريح بالأصفار واستدعائها fsync(). سيضمن هذا أن يتم وضع علامة على الكتل المقابلة في نظام الملفات على أنها "مخصصة" بدلاً من "غير مخصصة". وهذا يعطي تحسنًا صغيرًا في الأداء (حوالي 2٪). بالإضافة إلى ذلك، قد يكون لدى بعض الأقراص وصول أولي إلى الكتلة أبطأ من غيرها. وهذا يعني أن ملء المساحة بالأصفار يمكن أن يؤدي إلى تحسن كبير (حوالي 100%) في الأداء. على وجه الخصوص، يمكن أن يحدث هذا مع الأقراص أوس إي بي إس (هذه بيانات غير رسمية، ولم أتمكن من تأكيدها). الشيء نفسه ينطبق على التخزين. القرص الثابت GCP (وهذه معلومات رسمية مؤكدة بالاختبارات). وقد فعل خبراء آخرون نفس الشيء الملاحظات، المتعلقة بالأقراص المختلفة.
  2. كلما قل عدد مكالمات النظام، زاد الأداء (يمكن أن يصل الربح إلى حوالي 5%). يبدو وكأنه مكالمة open() مع العلم O_DSYNC أو أتصل pwritev2() مع العلم RWF_SYNC أسرع من المكالمة fdatasync(). أظن أن النقطة هنا هي أنه مع هذا النهج، فإن حقيقة أنه يجب إجراء عدد أقل من مكالمات النظام لحل نفس المهمة (مكالمة واحدة بدلاً من اثنتين) تلعب دورًا. لكن فرق الأداء صغير جدًا، لذا يمكنك تجاهله بسهولة واستخدام شيء ما في التطبيق لا يؤدي إلى تعقيد منطقه.

إذا كنت مهتمًا بموضوع التخزين المستدام للبيانات، فإليك بعض المواد المفيدة:

  • طرق الوصول إلى الإدخال/الإخراج - نظرة عامة على أساسيات آليات الإدخال / الإخراج.
  • ضمان وصول البيانات إلى القرص - قصة عما يحدث للبيانات أثناء انتقالها من التطبيق إلى القرص.
  • متى يجب عليك fsync الدليل المحتوي - الجواب على سؤال متى التقديم fsync() للدلائل. باختصار، اتضح أنك تحتاج إلى القيام بذلك عند إنشاء ملف جديد، والسبب في هذه التوصية هو أنه في Linux يمكن أن يكون هناك العديد من المراجع لنفس الملف.
  • SQL Server على Linux: FUA Internals - فيما يلي وصف لكيفية تنفيذ تخزين البيانات المستمر في SQL Server على نظام التشغيل Linux. توجد بعض المقارنات المثيرة للاهتمام بين مكالمات نظام Windows وLinux هنا. أنا متأكد تقريبًا أنه بفضل هذه المادة تعلمت عن تحسين FUA لـ XFS.

هل سبق لك أن فقدت البيانات التي كنت تعتقد أنها مخزنة بشكل آمن على القرص؟

تخزين البيانات الدائم وواجهات برمجة تطبيقات ملفات Linux

تخزين البيانات الدائم وواجهات برمجة تطبيقات ملفات Linux

المصدر: www.habr.com