1. مقدمة: حقل الألغام المخفي وراء إضافة كتلة واحدة بحجم 4 كيلوبايت إلى ملف
عندما تضغط على Ctrl+S في محرر نصوص أو تؤكد تحويلاً مصرفياً، يصدر تطبيقك استدعاء النظام write(). من منظور مبرمج التطبيقات، يبدو حفظ البيانات وكأنه خطوة واحدة فورية وغير قابلة للتجزئة (عملية ذرية Atomic Operation).
ولكن تحت غطاء نواة نظام التشغيل (OS Kernel) ونظام الملفات ومحرك تخزين قواعد البيانات ووحدة تحكم أقراص الحالة الصلبة (SSD)، تكمن واحدة من أصعب المشكلات في هندسة نظم الحاسوب: مشكلة الاتساق عند الانهيار (Crash Consistency Problem).
لنأخذ أبسط عملية ممكنة في نظام ملفات UNIX تقليدي (مثل UFS أو ext2): إلحاق كتلة بيانات جديدة واحدة بحجم $4\,\text{KB}$ ($D_{\text{new}}$) بنهاية ملف موجود. حتى هذه العملية البسيطة تتطلب تحديث ثلاث هياكل منفصلة فيزيائياً على الأقل على قرص التخزين:
- خريطة بتات البيانات (Data Bitmap $B$): قلب بت التخصيص للكتلة الفيزيائية رقم $k$ من $0$ (متاح) إلى $1$ (مخصص).
- عقدة الفهرس (Inode $I$): تحديث البيانات الوصفية للملف — زيادة حجم الملف
size، وتحديث وقت التعديلmtime، وكتابة عنوان الكتلة الفيزيائية $k$ في مصفوفة مؤشرات الكتل. - كتلة البيانات نفسها ($D_{\text{new}}$): كتابة بيانات المستخدم الفعلية البالغة $4\,\text{KB}$ في القطاع الفيزيائي للكتلة $k$.
هنا نصطدم بعدم تماثل فيزيائي جوهري في العتاد: الذاكرة الرئيسية (DRAM) سريعة لكنها متطايرة (Volatile) تفقد محتواها فور انقطاع التيار، بينما الأقراص المغناطيسية وذواكر الفلاش (NAND SSD) غير متطايرة (Non-Volatile) لكنها عاجزة فيزيائياً عن تحديث ثلاثة مواقع متباعدة $(B, I, D_{\text{new}})$ في آنٍ واحد ضمن عملية ذرية واحدة. فإذا انقطعت الكهرباء في منتصف عمليات الكتابة الثلاث ($2^3 - 2 = 6$ حالات كتابة جزئية)، يستيقظ نظام الملفات عند إعادة التشغيل في حالة فساد كارثي:
| كُتب على القرص | فُقد في ذاكرة RAM | حالة عدم الاتساق الناتجة وخطورتها |
|---|---|---|
| $D_{\text{new}}$ فقط | $B,\; I$ | فقدان بيانات حميد: البيانات مكتوبة على القرص لكن لا يشير إليها أي Inode أو خريطة بتات. تظل بنية نظام الملفات سليمة. |
| $B$ فقط | $I,\; D_{\text{new}}$ | تسرب المساحة (Space Leak / كتلة يتيمة): الكتلة $k$ مسجلة كمستخدمة ($1$) في الخريطة $B$ دون أن يشير إليها أي ملف، فتضيع سعتها للأبد. |
| $I$ فقط (أو $I + D_{\text{new}}$) | $B$ | تخصيص مزدوج (Double Allocation — تلف كارثي للميتاداتا): يشير Inode $I$ إلى الكتلة $k$، لكن الخريطة $B$ تعتبرها فارغة ($0$). لاحقاً يحجز ملف آخر نفس الكتلة $k$ فيدمر الملفان بيانات بعضهما! |
| $I$ و$B$ | $D_{\text{new}}$ | كشف بيانات قديمة محذوفة (Stale Data Exposure — ثغرة أمنية خطيرة): تم تحديث Inode والخريطة دون كتابة $D_{\text{new}}$. قراءة الملف الآن تُرجع بيانات محذوفة سابقة لمستخدم آخر (كلمات مرور أو مفاتيح خاصة) كانت في القطاع $k$! |
2. لماذا لا تصل عمليات الكتابة إلى القرص بالترتيب الذي كتبته؟
في المعمارية الحديثة، نادراً ما يتطابق الترتيب الزمني لاستدعاءات write() مع ترتيب الكتابة الفيزيائية على الوسيط غير المتطاير بسبب أربع طبقات من التخزين المؤقت المتطاير وإعادة الترتيب (Reordering):
flowchart TD
APP["1. فضاء المستخدم للتطبيق<br/>مخازن وقت التشغيل (stdio fwrite) / مجمع مخازن قاعدة البيانات"]
PC["2. ذاكرة تخزين الصفحات في نواة النظام (Page Cache / Dirty Pages)<br/>كتابة متأخرة (Write-Back): تدمج وتؤخر الكتابة لعدة ثوانٍ"]
SCHED["3. طبقة الكتل ومجدول الإدخال/الإخراج (mq-deadline / BFQ)<br/>يعيد ترتيب الطلبات حسب عناوين LBA لتقليل زمن البحث"]
DEV["4. ذاكرة DRAM المتطايرة داخل القرص وطوابير NCQ/NVMe<br/>وحدة تحكم FTL في SSD تعيد ترتيب الكتابة عبر قنوات NAND"]
MEDIA[("5. الوسيط الفيزيائي غير المتطاير<br/>خلايا NAND Flash / الأقراص المغناطيسية (هذا فقط ينجو من انقطاع التيار!)")]
APP -->|"استدعاء النظام write()"| PC
PC -->|"تفريغ بالخلفية / fsync()"| SCHED
SCHED -->|"أوامر NVMe / SATA"| DEV
DEV -->|"حاجز العتاد FLUSH CACHE / FUA"| MEDIA
ولضمان الترتيب والبقاء، يجب على النظام إصدار حواجز عتادية صريحة (NVMe Flush أو SATA FLUSH CACHE EXT أو بت FUA: Force Unit Access) لإجبار القرص على تفريغ ذاكرته المتطايرة في خلايا الفلاش غير المتطايرة.
3. الجيل الأول: التحديث في المكان، وأداة fsck، والتحديثات اللينة (Soft Updates)
- أداة
fsck(فحص اتساق نظام الملفات) وجدار قابلية التوسع: اعتمدت أنظمة UNIX المبكرة (FFS وext2) على فحص القرص بالكامل بعد أي انقطاع للتيار قبل تركيب النظام (fsck). ولأن زمن تشغيلfsckيتناسب طردياً مع السعة الإجمالية للقرص $O(N_{\text{disk}})$، فإن فحص مصفوفات التخزين الحديثة ذات السعات بالتيرابايت يستغرق ساعات أو أياماً من التوقف — حتى لو كان ملف واحد فقط بحجم $4\,\text{KB}$ قيد الكتابة لحظة انقطاع الكهرباء! - التحديثات اللينة (Soft Updates، Ganger & McKusick 1994): استُخدمت في FreeBSD UFS لتفادي السجلات عبر تتبع تبعيات المؤشرات في الذاكرة بدقة، وكسر التبعيات الدائرية عبر عمليات تراجع (Roll-back) وتقدم (Roll-forward) مؤقتة داخل ذاكرة RAM قبل كتابة الكتلة.
4. العمود الفقري للتخزين الحديث: التسجيل المسبق (WAL) والـ Journaling
لتقليص زمن الاسترداد من $O(N_{\text{disk}})$ إلى حجم المعاملات النشطة فقط لحظة الانهيار $O(N_{\text{active}})$ (بضع ملي ثانية!)، تستخدم أنظمة الملفات الحديثة (ext3/ext4 وXFS وNTFS) وقواعد البيانات التسجيل المسبق للكتابة (Write-Ahead Logging, WAL / Journaling):
- كتابة السجل (Journal Write): قبل لمس المواقع الأصلية على القرص (Home Locations)، يضيف النظام تتابعياً إلى السجل الدائري (Journal) كتلة وصف بداية المعاملة
TxB(مع رقمTID) متبوعة بصور الكتل المحدثة $(I, B, D_{\text{new}})$. - اعتماد السجل (Journal Commit — نقطة الاعتماد الذرية): بعد حاجز تفريغ عتادي، تُكتب كتلة نهاية واحدة
TxE(Transaction End). إن كتابة هذا القطاع الواحدTxEهي المفتاح الذري الذي يعتمد المعاملة بأكملها! - نقطة التفتيش (Checkpoint): بعد استقرار
TxEعلى القرص، تُنسخ $(I, B, D_{\text{new}})$ بشكل غير متزامن إلى مواقعها الأصلية.
flowchart LR
subgraph Journal ["السجل الدائري على القرص (إضافة تتابعية)"]
TXB["1. TxB (TID=42)<br/>كتلة الوصف"] --> JB["2. خريطة البتات B المسجلة"]
JB --> JI["3. عقدة Inode I المسجلة"]
JI --> JD["4. البيانات D_new المسجلة"]
JD ==>|"★حاجز تفريغ عتادي FLUSH"| TXE["5. TxE (TID=42)<br/>كتلة الاعتماد الذرية"]
end
subgraph Home ["المواقع الأصلية على القرص (Home Locations)"]
HB["خريطة البتات الأصلية"]
HI["جدول Inode الأصلي"]
HD["كتلة البيانات الأصلية"]
end
TXE ==>|"6. Checkpoint (كتابة غير متزامنة)"| HB
TXE ==>|"6. Checkpoint"| HI
TXE ==>|"6. Checkpoint"| HD
- انقطاع الكهرباء قبل
TxE: المواقع الأصلية لم تُلمس؛ وعند إعادة التشغيل يتم تجاهل المعاملة غير المكتملة. - انقطاع الكهرباء بعد
TxE: عند إعادة التشغيل، يعيد النظام تطبيق (Redo replay) الكتل من السجل إلى مواقعها الأصلية في ملي ثانية. بل إن ext4 الحديث يلغي الحاجز الأوسط عبر تضمين مجموع تحقق (CRC32C Checksum) داخلTxE.
ولتجنب مضاعفة حجم الكتابة ($2\times$ Write Amplification)، يقوم الوضع الافتراضي data=ordered في ext4 بتسجيل البيانات الوصفية $(I, B)$ فقط في السجل، لكنه يشترط اكتمال كتابة كتلة البيانات $D_{\text{new}}$ في موقعها الأصلي أولاً قبل السماح بكتابة كتلة اعتماد الميتاداتا TxE في السجل. وفي قواعد البيانات العلائقية، يدير بروتوكول ARIES (Mohan et al., 1992) سياسة Steal / No-Force عبر أرقام pageLSN وثلاث مراحل: Analysis وRedo (إعادة التاريخ) وUndo (مع سجلات التعويض CLR).
5. الجيل الثالث: النسخ عند الكتابة (Copy-on-Write, CoW) وأشجار ميركل (ZFS / Btrfs)
تمنع أنظمة الملفات الحديثة مثل ZFS وBtrfs وAPFS الكتابة فوق البيانات الموجودة (In-Place Overwrite) نهائياً، وتعتمد النسخ عند الكتابة (Copy-on-Write, CoW / Shadow Paging):
- تُكتب أي كتلة بيانات معدلة $D_{2,\text{new}}$ وعقدة Inode الأب $I_{v2}$ دائماً في مساحة فارغة جديدة، مع إبقاء الكتل القديمة $D_{2,\text{old}}$ سليمة تماماً.
- بعد استقرار جميع الكتل الفرعية الجديدة على القرص، تقوم عملية كتابة ذرية واحدة بتحديث كتلة الجذر العليا (Uberblock في ZFS) من $U_1$ إلى $U_2$.
- المزايا: استرداد فوري في $0$ ثانية دون إعادة تشغيل سجلات، ولقطات فورية (Snapshots) بتعقيد $O(1)$ دون تكلفة نسخ، وإصلاح ذاتي (Self-Healing) ضد تلف البتات الصامت (Bit Rot) بفضل أشجار ميركل (Merkle Trees) التي تخزن بصمات تشفيرية بطول 256 بت في كل مؤشر أب.
6. فخاخ fsync() للمبرمجين واختبارات حقن الأعطال
حتى على نظام يدعم Journaling أو CoW، فإن استبدال ملف عبر rename("config.json.tmp", "config.json") دون استدعاء fsync() مسبقاً يؤدي إلى ملفات فارغة بحجم $0$ بايت بعد انقطاع التيار في ظل التخصيص المؤجل (Delayed Allocation في ext4)، لأن إعادة تسمية الميتاداتا تُعتمد قبل تفريغ صفحات البيانات من ذاكرة التخزين المؤقت!
البروتوكول الكامل المكون من 6 خطوات للحفظ الذري الآمن ضد الانهيار:
(1) write() في الملف المؤقت $\to$ (2) fsync(fd) للملف المؤقت $\to$ (3) close(fd) $\to$ (4) استدعاء rename() الذري $\to$ (5) فتح المجلد الأب واستدعاء fsync(dir_fd) لتثبيت مدخل المجلد $\to$ (6) close(dir_fd).
وكما كشفت فضيحة fsyncgate في PostgreSQL عام 2018 (حيث كانت نواة لينكس تضع علامة Clean على الصفحة المتسخة بعد فشل fsync() بخطأ EIO، وهو ما أُصلح لاحقاً عبر errseq_t)، تعتمد هندسة التخزين الحديثة على أطر حقن الأعطال المنهجي (Systematic Fault Injection) مثل ALICE وCrashMonkey لاختبار جميع تباديل إعادة الترتيب العتادي عند انقطاع الكهرباء.
