الاتساق والتخزين المؤقت
الاتساق والتخزين المؤقت
كل ذاكرة تخزين مؤقت تُنشئ نسخةً ثانيةً من بياناتك. بمجرد وجود نسختين، يمكنهما أن تتباعدا — وعندما يحدث ذلك يرى المستخدمون نتائج قديمة، أو تفرض أنظمة الفوترة مبالغ خاطئة، أو تصبح أرصدة المخزون سلبية. الاتساق (Consistency) هو الانضباط الذي يهدف إلى إبقاء هذه النسخ متزامنة. يستعرض هذا الدرس نماذج الاتساق التي يجب أن تأخذها في اعتبارك، وأنماط الفشل التي يُفرزها التخزين المؤقت تحديدًا، والاستراتيجيات العملية التي تعتمدها شركات مثل Amazon وNetflix وSlack لإبقاء الذاكرات المؤقتة وقواعد البيانات متوافقة.
التوتر الجوهري
التخزين المؤقت والاتساق الصارم في تعارض مباشر. فالذاكرة المؤقتة موجودة أصلًا لأن الرجوع إلى قاعدة البيانات في كل طلب مكلف. غير أن كل لحظة تحتفظ فيها الذاكرة المؤقتة بقيمة دون التحقق من قاعدة البيانات هي لحظة يكون فيها ذلك القيم عرضةً للتقادم. التحدي الهندسي يكمن في تحديد مقدار التقادم المقبول — وجعل هذا القرار صريحًا لكل نوع من البيانات، بدلًا من السماح به بشكل غير مقصود.
نماذج الاتساق في الأنظمة الموزعة
قبل دراسة التخزين المؤقت تحديدًا، من المفيد امتلاك مسمّيات واضحة لمستويات الاتساق التي يمكنك استهدافها:
- الاتساق القوي (Strong Consistency): كل قراءة تُرجع أحدث قيمة مكتوبة. لا قراءات قديمة أبدًا. التكلفة: كل قراءة يجب أن تتحقق من المخزن الرئيسي أو تتجه إليه، مما يُلغي معظم فوائد الأداء في التخزين المؤقت.
- الاتساق النهائي (Eventual Consistency): إذا لم تحدث كتابات جديدة، ستتقارب جميع النسخ في نهاية المطاف نحو القيمة ذاتها. القراءات القديمة ممكنة في النافذة الزمنية بين الكتابة وتحديث الذاكرة المؤقتة. معظم أنظمة التخزين المؤقت واسعة النطاق تعمل ضمن هذا النموذج.
- قراءة كتاباتك (Read-Your-Own-Writes): يرى المستخدم دائمًا أحدث ما كتبه، حتى لو رأى مستخدمون آخرون قيمة أقدم قليلًا. هذا هو الحد الأدنى من الاتساق لمعظم الميزات الموجهة للمستخدم.
- القراءات الرتيبة (Monotonic Reads): بمجرد قراءة قيمة عند إصدار V، لن تقرأ قيمة أقدم من V في القراءات اللاحقة. يمنع ذلك التجربة المزعجة لصفحة تبدو وكأنها "تتراجع" عند التحديث.
كيف تكسر الذاكرات المؤقتة الاتساق
ثلاث حالات فشل ملموسة تقع في الأنظمة الحقيقية:
- الكتابة بعد القراءة (قراءات قديمة): يقرأ المستخدم A مخزون منتج معين: تُعيد الذاكرة المؤقتة القيمة 5. يشتري المستخدم B خمس وحدات، فيكتب
stock = 0في قاعدة البيانات. مدة صلاحية إدخال الذاكرة المؤقتة للمستخدم A هي 30 ثانية. في الـ 29 ثانية التالية، يرى أي مستخدم يصل إلى ذلك الإدخال المخزّن مؤقتًا الرقم 5 ويحاول الشراء الذي سيفشل عند الدفع. - التدافع عند الإبطال (Stampede): تحذف مفتاح ذاكرة مؤقتة مطلوبًا كثيرًا بعد كتابة ما. تصطدم مئات الطلبات المتزامنة بالذاكرة فتجدها فارغة، وتتجه جميعها إلى قاعدة البيانات، وتحسب القيمة ذاتها، وتحاول جميعها كتابتها مجددًا. تتعرض قاعدة البيانات لضغط هائل وتُعاد تعبئة الذاكرة المؤقتة بعمل مكرر. يُسمى هذا أحيانًا قطيع الرعد (Thundering Herd).
- رؤية التحديث الجزئي: يحدّث مستخدم ملفه الشخصي (الاسم + الصورة الرمزية). تذهب الكتابة إلى قاعدة البيانات. يُبطَل مفتاح ذاكرة الاسم المؤقتة فورًا؛ مفتاح الصورة لا ينتهي إلا بعد 10 دقائق. طوال هذه المدة، تُرجع الاستعلامات الاسم الجديد مع الصورة القديمة — وهو عرض غير متسق داخليًا لكيان واحد.
الاستراتيجية الأولى — الكتابة المتزامنة مع الإبطال الذري
في ذاكرة التخزين المؤقت بالكتابة المتزامنة (Write-Through)، تُحدَّث كل كتابة في قاعدة البيانات والذاكرة المؤقتة في العملية المنطقية ذاتها. عند الجمع بين ذلك وإبطال الذاكرة بشكل ذري (حذف الإدخال عند كل كتابة)، تُختصر نافذة التقادم إلى زمن انتشار العملية — عادةً أقل من ميلي ثانية على كتلة Redis محلية.
المشكلة: التحديث الذري لنظامين (قاعدة البيانات + الذاكرة المؤقتة) دون معاملة موزعة يعني أنك يجب أن تتعامل مع حالات الفشل الجزئي. النمط الموصى به هو الكتابة إلى قاعدة البيانات أولًا، ثم حذف مفتاح الذاكرة المؤقتة — لا العكس. المنطق: إذا فشل حذف الذاكرة، أسوأ نتيجة هي قراءة قديمة؛ قاعدة البيانات (مصدر الحقيقة) صحيحة والمفتاح القديم سينتهي بالتقادم. أما إذا كتبت الذاكرة أولًا وفشلت كتابة قاعدة البيانات، فستكون الذاكرة تُقدّم بيانات لم تُحفظ قط.
الاستراتيجية الثانية — Cache-Aside مع مفاتيح الإصدار
في cache-aside (التحميل الكسول)، تدير التطبيقات الذاكرة المؤقتة بصراحة: تحقق من الذاكرة → عند الإخفاق، حمّل من قاعدة البيانات → خزّن في الذاكرة. لمعالجة مشكلة رؤية التحديث الجزئي، خزّن بيانات الكيان تحت مفتاح مرقَّم بالإصدار، مثل user:{id}:v{version}، حيث version عدد صحيح يتزايد مع كل كتابة. عند تحديث المستخدم، زد رقم إصداره في قاعدة البيانات. البحث التالي في الذاكرة سيستخدم المفتاح الجديد، ويفشل في إيجاده، ويُعيد تعبئته بشكل ذري من قاعدة البيانات. المفاتيح القديمة تنتهي طبيعيًا بانتهاء TTL.
الاستراتيجية الثالثة — الإبطال بالأحداث (CDC)
في الأنظمة الكبيرة المعقدة حيث تأتي الكتابات من خدمات متعددة، يصبح الحفاظ على منطق إبطال الذاكرة المؤقتة داخل كل مسار كتابة هشًّا. نهج أكثر قابلية للتوسع هو Change Data Capture (CDC): تقرأ عملية في الخلفية سجل النسخ المتماثل لقاعدة البيانات (مثل binlog في MySQL عبر Debezium)، ولكل تغيير في صف تنشر حدثًا على ناقل رسائل (مثل Kafka). تشترك عمال الإبطال في هذه الأحداث وتحذف مفاتيح الذاكرة ذات الصلة أو تُحدّثها. هذا يفصل التخزين المؤقت عن كود التطبيق كليًا.
مشكلة قراءة كتاباتك الخاصة
سيناريو كلاسيكي: يحدّث مستخدم صورته الشخصية، يُعاد توجيهه إلى صفحة ملفه، فيرى الصورة القديمة. يحدث ذلك لأن الكتابة وصلت إلى قاعدة البيانات الرئيسية، وأن إعادة التوجيه جلبت الملف من ذاكرة مؤقتة لم تُبطَل بعد، وأن TTL كان لا يزال 5 دقائق. الحلول بترتيب التعقيد:
- الإبطال العدواني عند الكتابة: احذف إدخال الذاكرة المؤقتة فور كل كتابة. بسيط وكافٍ في الغالب للبيانات المملوكة للمستخدم.
- توجيه القراءات بعد الكتابة إلى قاعدة البيانات الرئيسية: لمدة قصيرة بعد الكتابة (مثلًا 5 ثوانٍ)، تجاوز الذاكرة المؤقتة واقرأ من قاعدة البيانات الرئيسية مباشرةً. خزّن علامة جلسة قصيرة لكل مستخدم تشير إلى "لديه كتابة معلقة". تفعل Amazon DynamoDB و MySQL RDS Proxy ذلك تلقائيًا.
- تقسيم فضاء أسماء الذاكرة المؤقتة لكل مستخدم: قسّم مفاتيح الذاكرة المؤقتة حسب جلسة المستخدم حتى لا تصل قراءاته أبدًا إلى مفتاح كتبه مسار عملية آخر. أثقل على ذاكرة الذاكرة المؤقتة، لكنه يُلغي هذا النوع من الأخطاء بالكامل.
تحديد ميزانية الاتساق
نموذج ذهني مفيد هو إسناد ميزانية اتساق صريحة لكل نوع بيانات — الحد الأقصى المقبول للتقادم بالثواني. قيم شائعة في الأنظمة الحقيقية:
- رصيد الحساب، حالة الدفع: 0 ثانية — لا تُخزَّن مؤقتًا أبدًا؛ اقرأ دائمًا من قاعدة البيانات الرئيسية.
- رصيد المخزون: 5–10 ثوانٍ — TTL قصير؛ أبطل عند الشراء.
- سعر المنتج: 30–60 ثانية — الأسعار تتغير نادرًا؛ TTL قصير مقبول.
- الملف الشخصي العام للمستخدم (يقرأه الآخرون): 60–300 ثانية — مقبول؛ المستخدم ينشر نادرًا.
- الإعدادات العامة / أعلام الميزات: 30–120 ثانية — تردد كتابة منخفض؛ خزّن بقوة.
- محتوى الصفحة الرئيسية / صفحات CMS: 300–3600 ثانية — تردد كتابة منخفض جدًا؛ خزّن لدقائق أو ساعات.
وثّق هذه القيم في تصميم نظامك؛ فهي تحكم اختيارات TTL، واختيار استراتيجية الإبطال، وتخطيط سعة طبقة الذاكرة المؤقتة. عامل ميزانية الاتساق كمتطلب من الدرجة الأولى، لا كفكرة لاحقة.
الاتساق في النشر متعدد المناطق
حين يمتد نظامك عبر مناطق جغرافية متعددة، يغدو الاتساق أصعب. كتابة على قاعدة البيانات الرئيسية في US-East تنتشر إلى نسخة EU المتماثلة في ~120 مللي ثانية. أي ذاكرة مؤقتة في منطقة EU تُبطَل قبل اكتمال النسخ المتماثل ستُعيد تعبئتها عند إخفاق القراءة من نسخة متماثلة قديمة. الاستراتيجيات:
- أرجئ إبطال الذاكرة المؤقتة بهامش زمن تأخر النسخ المتماثل القابل للتهيئة (مثلًا 200 مللي ثانية) قبل نشر حدث الحذف.
- ضع طابع إصدار على القيم المخزنة مؤقتًا باستخدام LSN قاعدة البيانات (رقم تسلسل السجل)؛ ارفض إعادة التعبئة من النسخ المتماثلة التي يكون LSN الخاص بها متأخرًا عن LSN الكتابة.
- للبيانات ذات الاتساق القوي، وجّه جميع القراءات إلى قاعدة البيانات الرئيسية — وتقبّل تكلفة زمن التأخر عبر المناطق.
الاتساق والتخزين المؤقت مصيران متشابكان لا يمكن فصلهما. كل قرار تخزين مؤقت هو في الوقت ذاته قرار اتساق. بناء أنظمة متينة يتطلب منك أن تكون صريحًا، لكل نوع بيانات، بشأن نموذج الاتساق الذي تقبله — وأن تُصمّم استراتيجية الإبطال ومدة TTL ومسار الكتابة وفقًا لذلك.